← All work

Non-profit sector

Donation platform for verified NGOs

Year
2025
Role
Tech lead, team of two
Outcomes
Beta: 11 organisations onboarded

Context

Small non-profits have nowhere to collect donations in a way that lets the donor see where the money went. The problem is not technical but one of trust: the platform exists for the public reporting, and payment intake is merely how that reporting is made possible.

Constraints

  • One server and zero cloud budget
  • A payment provider with no sandbox environment
  • Public reporting has to reconcile to the last coin

Solution

Public reporting has to reconcile to the last coin, so money is stored as integers in dirams rather than floating point, and every operation is idempotent on the provider's key.

src/payments/ledger.ts
export async function recordIntake(
  tx: Transaction,
  event: ProviderEvent,
): Promise<LedgerEntry> {
  const existing = await tx.ledger.findByIdempotencyKey(event.id);
  if (existing !== null) {
    return existing; // a replayed webhook is not a second payment
  }

  return tx.ledger.insert({
    idempotencyKey: event.id,
    campaignId: event.campaignId,
    amountDiram: BigInt(event.amountDiram),
    receivedAt: event.occurredAt,
  });
}

We verified NGOs by hand, over email. That is right for eleven organisations and unworkable for fifty.

Architecture

Money enters once: a replayed webhook does not create a second intake. donor → web; web → api; api → payment provider; payment provider → api (webhook); api → ledger; ledger → postgres; postgres → reportingwebhookdonorwebpayment providerapiledgerpostgresreporting
Money enters once: a replayed webhook does not create a second intake

Key decisions

  • How to test the payment integration

    What I chose

    A local double of the provider, built from the documentation and from logs of real responses. Tests run against the double; production runs against the real API.

    What I rejected

    Testing against the live provider with small payments. Cheap to start, but every check needs a refund and a manual reconciliation.

    Why

    A double lies exactly where it diverges from the documentation, and that is found once. Live micro-payments lie differently every time and leave a trace in reporting that is required to stay clean.

  • How to store amounts

    What I chose

    Integers in dirams — the smallest unit of the currency.

    What I rejected

    Floating point, or decimal handled in application code.

    Why

    Public reporting has to reconcile to the last coin. Any rounding at a type boundary eventually produces a one-unit discrepancy, and there is no way to explain that to a donor.

What was hard

  • Problem

    The provider replayed the webhook on any slow response, and the first campaigns recorded duplicate intakes.

    Solution

    Every operation is idempotent on the provider event id: a replay returns the already-recorded intake instead of creating a second one.

  • Problem

    Organisations needed CSV for their own auditors almost immediately, and reporting had been built as a page rather than an export.

    Solution

    The export had to be bolted onto finished markup. The right shape would have been to treat the report as data from the start, with the page as one of its views.

Outcomes

organisations
11
campaigns completed
38
reconciliation mismatches
0

Stack