Naalya Handbook
Payments

Charges vs Transactions

A charge is what is owed; a transaction is the money moving — how the two differ and connect.

A charge is what is owed. A transaction is the money moving. They are two different tables for two different jobs: one records the bill, the other records each payment attempt.

At a glance

Payment chargeTransaction
What it isA materialized obligation — one row per (item, user)A money-movement record — one payment attempt/settlement
Tablepayment_chargetransaction
When createdWhen an ASSIGNED item is synced (background job)When someone pays, through a gateway
Paid statusDerived — computed from transactionsStored — its own status column
Exists for ELIGIBILITY items?NoYes (a payment with no charge)

Payment charge

A payment charge (entity payment_charge, in apps/server/src/app/payment-item/entities/payment-charge.entity.ts) is a materialized obligation — a pre-computed bill row that says "this user owes this amount for this item". Materialized means it is written to a real table ahead of time, not computed on the fly.

  • One row per (paymentItemId, userId). The @Unique(['paymentItemId', 'userId']) constraint makes re-creating charges idempotent — running the sync twice produces no duplicates, it only adds rows for new users (ON CONFLICT DO NOTHING).
  • Only ASSIGNED items produce charges. ELIGIBILITY items have no bill, so they have no charge.
apps/server/src/app/payment-item/entities/payment-charge.entity.ts
@Entity('payment_charge')
@TenantScoped()
@Unique(['paymentItemId', 'userId'])
export class PaymentChargeEntity extends DatabaseEntity {
  @Column({ name: 'payment_item_id', type: 'uuid' }) paymentItemId: string;
  @Column({ name: 'user_id', type: 'uuid' }) userId: string;
  @Column({ name: 'student_id', type: 'uuid', nullable: true }) studentId?: string;
  @Column({ name: 'campus_id', type: 'uuid', nullable: true }) campusId?: string;
  @Column({ type: 'numeric' }) amount: number;
  @Column({ type: 'enum', enum: PaymentChargeStatus }) status: PaymentChargeStatus;
}

The status column exists, but whether a charge is paid is not read from it — the service computes it by checking for a matching successful transaction (in listMyCharges / listItemCharges). The transactions are the source of truth; the charge just names the debt.

How charges are created

Charges are written by a BullMQ background job — a task pushed onto a queue and run later by a worker, off the request path. The PaymentChargeProcessor on the PAYMENT_CHARGE_QUEUE (in apps/worker/src/processors/payment-charge/payment-charge.processor.ts) does the work.

Its payload carries schoolId explicitly:

libs/shared/src/payment-charge/payment-charge.types.ts
export interface PaymentChargeJobPayload {
  paymentItemId: string;
  schoolId: string;
}
  • The worker runs outside request context — there is no logged-in user and no tenant (the school the data belongs to) bound automatically. So the job carries schoolId and the processor binds the tenant from it, then loads the item scoped by it.
  • It resolves the item's audience (the rules naming who the item applies to) via raw SQL: students (through enrollment, filtered to the item's campusAcademicYearId — only that year's active enrollments become charges) and specific_users (direct). user_type rules are not materialized — they stay eligibility-based. Each charge row records the item's year.
  • It bulk-inserts charges in chunks of 500 rows (INSERT_CHUNK), with ON CONFLICT DO NOTHING so re-syncs only add new targets.

See Queues & Messaging for how jobs are enqueued and run, and Audiences for the rule shapes.

Transaction

A transaction (entity transaction, in apps/server/src/app/transaction/entities/transaction.entity.ts) is the canonical money-movement record — one payment attempt or settlement through a gateway (the external provider that actually moves the money).

apps/server/src/app/transaction/entities/transaction.entity.ts
@Entity('transaction')
@TenantScoped()
export class TransactionEntity extends DatabaseEntity {
  @Column({ type: 'numeric' }) amount: number;
  @Column({ type: 'enum', enum: TransactionStatus }) status: TransactionStatus;
  @Column({ type: 'enum', enum: TransactionReason }) reason: TransactionReason;
  @Column({ type: 'enum', enum: TransactionProvider }) provider: TransactionProvider;
  @Column({ name: 'user_id', type: 'uuid' }) userId: string;
  @Column({ name: 'payment_item_id', type: 'uuid', nullable: true }) paymentItemId?: string;
  @Column({ unique: true }) reference: string;
  @Column({ name: 'external_reference', nullable: true }) externalReference?: string;
  @Column({ name: 'receipt_number', nullable: true }) receiptNumber?: string;
  @Column({ name: 'redirect_url', type: 'text', nullable: true }) redirectUrl?: string;
  @Column({ name: 'provider_metadata', type: 'jsonb', nullable: true })
  providerMetadata?: Record<string, unknown>;
}

Key fields:

  • status (TransactionStatus) — the stored lifecycle: PENDINGSUCCESS / FAILED (plus CANCELLED, REFUND_PENDING, REFUNDED)
  • provider (TransactionProvider) — which gateway handled it; reason (TransactionReason) — why the payment was made.
  • paymentItemId? — links back to the item being paid for (nullable; not every transaction maps to one item).
  • resourceId? — for a resource-bound item, the specific instance paid for (e.g. the applicationId). The (paymentItemId, resourceId) pair is what makes a retry reuse the same transaction instead of double-charging.
  • reference — our unique id for the attempt; externalReference — the provider's id; receiptNumber — the provider's receipt.
  • redirectUrl — where to send the user when the gateway needs a hosted page; providerMetadata — the raw provider payload (jsonb).

A transaction is created when someone pays, then driven through the gateway adapter (the class wrapping one provider): register the charge → instant result or redirect → the provider calls back via a webhook (an HTTP callback the provider POSTs to us) → parseCallback reads it and sets the final status. See Gateways.

How they connect

Item typeCharge?Transaction on payment?What marks it paid
ELIGIBILITYnoneyesnothing to mark — there is no bill
ASSIGNEDone per useryesa successful transaction satisfies that user's charge
  • An ELIGIBILITY item has no charge but still records a transaction when someone pays it.
  • An ASSIGNED item has a charge per user; a successful transaction is what makes that charge show as paid.

The charge is the bill. The transaction is the payment. The charge never changes when money arrives — the transaction is what flips the derived status.

Where to go next

On this page