Payments
How the platform charges for things — items, audiences, charges, transactions, and the gateways that move the money.
The payments domain is how a school charges for things — a trip fee, an application fee — and tracks who has paid. It splits the job across five pieces. Learn the pieces once and the rest of the domain reads cleanly.
The five pieces
- Payment item — a fee definition: the thing a school charges for. It carries an amount and an audience (a rule for who it applies to). Lives in
payment_item. - Audience — the declarative rule describing who an item is for (a user type, a named list of users, an owned resource). Stored as JSON on the item and resolved when a payer asks.
- Payment charge — a per-user obligation: "this user owes this amount for this item." Created only for assigned items. Lives in
payment_charge. - Transaction — the actual money movement: one payment attempt. Lives in
transaction. - Gateway — an external payment provider (Pesapal, SchoolPay) that takes the money. Each school configures its gateways per campus, with encrypted credentials.
The first four are yours (rows in your database). The gateway is theirs (a third party). A transaction is the bridge: you create it, hand it to a gateway, and the gateway tells you when it settled.
Two ways to collect
A payment item is collected one of two ways — its collection mode:
| Mode | Meaning |
|---|---|
ELIGIBILITY | Offered to whoever matches the audience; they pay on demand. Nothing is created up front. |
ASSIGNED | A payment charge is created up front per matching user — an explicit obligation they can see they owe. |
For an ASSIGNED item, the charges are materialized (the rule is expanded into one concrete payment_charge row per matching user) by a background worker — see enqueueMaterialization in apps/server/src/app/payment-item/payment-item.service.ts. Materialization is idempotent (safe to re-run; it won't double-create), so editing the audience or backfilling late joiners just re-runs the job.
Paid is derived, not stored
A charge does not store a paid/unpaid flag. Whether something is paid is computed from transactions: a payment counts as settled when a transaction for it reaches SUCCESS. See paidItemIds and paidUserIdsForItem in payment-item.service.ts — both read transaction rows with status SUCCESS and fold them into a set.
The one-line why: money is the source of truth. A flag on the charge could drift from what actually happened at the gateway; deriving from transactions can't.
The end-to-end flow
- Define a payment item with an amount and an audience.
- Reach the payer — either eligible payers see the item live (
listEligible), or assigned charges are materialized into obligations. - Pay — the payer calls
pay; the service checks eligibility, resolves the campus and gateway, and asksTransactionServiceto create atransaction(statusPENDING). - Register — the transaction is sent to the chosen gateway, which returns a redirect URL and an external reference.
- Settle — the gateway calls back via a webhook (an HTTP request the provider sends to us when something changes), and the transaction moves to
SUCCESS.
That last step is why paid status is derived: the gateway, not your code, decides when money actually moved.
A transaction in one glance
amount: number;
status: TransactionStatus; // PENDING → SUCCESS | FAILED | …
provider: TransactionProvider; // SCHOOL_PAY | PESAPAL
paymentItemId?: string; // the fee this settles, if any
reference: string; // our id, unique
externalReference?: string; // the gateway's id
providerMetadata?: Record<string, unknown>; // provider-specific extrasProvider-agnostic fields and provider-specific ones (a SchoolPay receipt number, say) sit side by side, so reporting queries can treat every transaction uniformly regardless of which gateway took the money.
Where to go next
Payment Items
The fee definition — scope, amount, collection mode, and the assigned-charge lifecycle.
Audiences
The rules that decide who a payment item is for, and how eligibility is resolved.
Charges vs Transactions
Obligation vs money movement, and why paid status is derived.
Gateways
The provider adapters that register and settle transactions.
Gateway Config
Per-campus provider setup with encrypted credentials.