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 charge | Transaction | |
|---|---|---|
| What it is | A materialized obligation — one row per (item, user) | A money-movement record — one payment attempt/settlement |
| Table | payment_charge | transaction |
| When created | When an ASSIGNED item is synced (background job) | When someone pays, through a gateway |
| Paid status | Derived — computed from transactions | Stored — its own status column |
| Exists for ELIGIBILITY items? | No | Yes (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.
@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;
}Paid status is derived
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:
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
schoolIdand 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'scampusAcademicYearId— only that year's active enrollments become charges) andspecific_users(direct).user_typerules 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), withON CONFLICT DO NOTHINGso 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).
@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:PENDING→SUCCESS/FAILED(plusCANCELLED,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. theapplicationId). 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 type | Charge? | Transaction on payment? | What marks it paid |
|---|---|---|---|
| ELIGIBILITY | none | yes | nothing to mark — there is no bill |
| ASSIGNED | one per user | yes | a 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.