Naalya Handbook
Payments

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

apps/server/src/app/payment-item/entities/payment-item.entity.ts
@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

FieldTypeNotes
scopePaymentItemScopeSCHOOL or CAMPUS. SCHOOL items apply across every campus, so campusId stays null.
campusIduuid?Required when scope = CAMPUS; nullable otherwise. Enforced by assertScopeShape.
name / descriptionstringWhat the payer sees.
amountnumericA single price. Stored as numeric, so cast with Number(...) when handing it off.
reasonTransactionReasonWhy the money moves — copied onto every transaction the item settles.
audienceAudienceRule[]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.
allowedProvidersTransactionProvider[]?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.
collectionModeenumHow the obligation is tracked — the key choice below.
statusPaymentItemStatusINACTIVE items are hidden from payers and can't be paid.
isCompulsorybooleanA 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.

ModeWhat happensUse 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."
ASSIGNEDA 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:

apps/server/src/app/payment-item/payment-item.service.ts
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 same campusAcademicYearId (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: AdmissionApplicationService resolves 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:

apps/server/src/app/payment-item/payment-item.service.ts
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 a paid flag. 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 checks status is active, validates the chosen gateway against allowedProviders, confirms eligibility, then hands off to TransactionService.

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:

apps/server/src/app/payment-item/payment-item.service.ts
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.

Where to go next

On this page