Payment Items
Fee definitions — what they hold, how scope works, and the two collection modes.
A payment item is a fee definition — the thing a school charges for (a term fee, an exam levy, an admission charge). Staff create one; payers settle it. The entity is payment_item (apps/server/src/app/payment-item/entities/payment-item.entity.ts), and it's tenant-scoped — every row belongs to one school, so a query only ever sees that school's items.
The item itself carries no per-user data. Who owes it is a declarative rule (the audience), and how the obligation is tracked is one field (collectionMode). Everything else is the fee's identity and price.
The entity
@Entity('payment_item')
@TenantScoped()
@WithTimestamps()
export class PaymentItemEntity extends DatabaseEntity implements PaymentItem {
@Column() scope: PaymentItemScope; // SCHOOL | CAMPUS
@Column({ nullable: true }) campusId?: string;
@Column() name: string;
@Column({ type: 'text', nullable: true }) description?: string;
@Column({ type: 'numeric' }) amount: number;
@Column({ type: 'enum', enum: TransactionReason }) reason: TransactionReason;
@Column({ type: 'jsonb', default: () => "'[]'" })
audience: AudienceRule[]; // who it applies to
@Column({ type: 'enum', array: true, nullable: true })
allowedProviders?: TransactionProvider[]; // gateways that may pay it
@Column() collectionMode: PaymentItemCollectionMode; // ELIGIBILITY | ASSIGNED
@Column() status: PaymentItemStatus; // ACTIVE | INACTIVE
@Column({ name: 'is_compulsory', default: false })
isCompulsory: boolean; // required fee that gates a domain action
}extends DatabaseEntity adds id and timestamps; the @-prefixed lines are decorators — annotations TypeORM reads to map the class onto a Postgres table. implements PaymentItem ties the entity to the shared interface in dto/payment-item.types.ts, so the entity and the DTOs can't drift apart (see Data Modeling).
Fields
| Field | Type | Notes |
|---|---|---|
scope | PaymentItemScope | SCHOOL or CAMPUS. SCHOOL items apply across every campus, so campusId stays null. |
campusId | uuid? | Required when scope = CAMPUS; nullable otherwise. Enforced by assertScopeShape. |
name / description | string | What the payer sees. |
amount | numeric | A single price. Stored as numeric, so cast with Number(...) when handing it off. |
reason | TransactionReason | Why the money moves — copied onto every transaction the item settles. |
audience | AudienceRule[] | An array of audience rules (who-it-applies-to rules), stored as JSONB (a JSON column Postgres can index and query) and resolved at read time. See Audiences. |
allowedProviders | TransactionProvider[]? | Optional allow-list of gateways (the payment providers, e.g. Pesapal, SchoolPay) a payer may use. Empty means any gateway the campus has enabled. See Gateways. |
collectionMode | enum | How the obligation is tracked — the key choice below. |
status | PaymentItemStatus | INACTIVE items are hidden from payers and can't be paid. |
isCompulsory | boolean | A required fee that gates a domain action (an admission application can't proceed until it's paid). Single-instance per (scope, reason) — see below. |
Collection mode
collectionMode decides whether the system pre-writes a per-user obligation, or just offers the item and waits.
| Mode | What happens | Use it for |
|---|---|---|
ELIGIBILITY (default) | No per-user rows. The item is offered to whoever the audience matches; they pay on demand. Unpaid = audience minus paid. | "Anyone in class 5 may pay this." |
ASSIGNED | A per-user charge — a stored obligation row, one per matched user — is materialized (written out ahead of time) for everyone the audience resolves to. | "Everyone in class 5 owes this." |
A charge is a separate entity from the transaction that settles it — see Charges vs Transactions. On create, an ASSIGNED item enqueues materialization automatically:
if (created.collectionMode === PaymentItemCollectionMode.ASSIGNED) {
await this.enqueueMaterialization(created.id, created.schoolId);
}enqueueMaterialization pushes a job onto the PAYMENT_CHARGE_QUEUE — a BullMQ queue (a Redis-backed background job list) — so the slow per-user row-writing happens off the request. See Queues & Messaging.
Compulsory items
isCompulsory marks a fee as required, with two extra behaviours:
- Single-instance per
(scope, reason, campus academic year). Activating a compulsory item deactivates any other active compulsory item with the same scope + reason in the samecampusAcademicYearId(deactivateCompulsorySiblings) — one live term fee per year, while prior years' items stay untouched. Payment items are year-scoped: the year is required at create, and mutations are rejected once the item's year is locked. - It gates a domain action. Another module looks up the active compulsory item for a reason and blocks until it's paid. Admissions is the first user:
AdmissionApplicationServiceresolves the campus's active compulsory admission-fee item and starts payment for a draft application, returning the transaction so the caller can redirect the payer.
So a resource-bound admission fee is configured once as a compulsory item; every application is then payable against it — the per-application ownership check lives in Audiences.
Materialize
PaymentItemService.materialize(id) re-queues charge creation for an ASSIGNED item. It's idempotent — running it twice doesn't double up charges — so it's the way to backfill late joiners or pick up an audience edit:
async materialize(id: string): Promise<void> {
const item = await this.repository.findById(id);
if (!item) throw new NotFoundException('Payment item not found');
if (item.collectionMode !== PaymentItemCollectionMode.ASSIGNED) {
throw new BadRequestException('Only ASSIGNED items materialize charges');
}
await this.enqueueMaterialization(item.id, item.schoolId);
}Listing and paying
Two payer-facing methods drive the app:
listEligible(userId)— returns active items the payer's audience matches, each with apaidflag. Resource-bound items are excluded (those are surfaced and paid per-resource by their owning domain, e.g. an admission application).pay(userId, itemId, dto)— initiates payment. It checksstatusis active, validates the chosen gateway againstallowedProviders, confirms eligibility, then hands off toTransactionService.
pay reuses an existing pending or successful transaction for a resource-bound item instead of creating a second one — so a retry after a failure doesn't double-charge:
if (resourceId) {
const existing = await this.activeTransactionForResource(item.id, resourceId);
if (existing) return existing;
}
return this.transactionService.create(userId, {
reason: item.reason,
campusId,
paymentItemId: item.id,
resourceId,
provider: dto.provider,
amount: Number(item.amount),
// ...payer details
});TransactionService records the transaction and dispatches it to the gateway — the item never talks to a provider directly.