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.
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
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