Payment
Payment
Section titled “Payment”Audience: domain, product, backend
Status: implemented
Owns: product + backend
Depends on: Lifecycle, Domain entities, Payment architecture, Orchestrator overview
Product intent
Section titled “Product intent”- After centre accepts the application, the candidate must pay the centre before a facilitator is assigned for evidence collection (RPL) or induction proceeds (NSQ).
- Centres provide payout/account details during centre onboarding; centres hold a wallet and can withdraw after a configured time frame.
- On
payment.completed, CAP credits the centre wallet with the net fee (gross minusprocessing_fee_percentage); the fee slice is platform revenue. - Platform admin owns a payments page: overview cards (platform revenue, wallet deposits, pending amount) and a global transactions table (search, status, centre filter, sort) with receipt via
GET /applications/{id}/receipt. - Platform admin also owns a settings form over floors / fee / terms via
GET/PATCH /admin/platform-config(versioned rows underneath). - Payout timing note from discovery: standard payouts on a ~5 days/month cadence (confirm in product).
- Stage:
PAYMENTwith status typicallyAWAITING_ACTIONuntil paid.
Ownership (pricing vs processing)
Section titled “Ownership (pricing vs processing)”| Concern | Owner |
|---|---|
| Price (RPL / NSQ application fee, …) | CAP — computed server-side on pay |
Process (checkout, Paystack adapter, webhook, payments row) |
Orchestrator — never knows how CAP priced the amount |
| Client API | GET /applications/{id}/payment-quote (fee preview — candidate or centre staff of that application’s centre) → POST /applications/{id}/pay (candidate-owned) → CAP returns checkout URL. Quote never initiates payment. |
| Unlock workflow | CAP consumes payment.completed and advances stage |
Clients do not call Orchestrator to start payment. Full sequence, idempotency, and provider adapters: Payment architecture.
CAP responsibilities on pay
Section titled “CAP responsibilities on pay”- Validate application is in the correct stage and not already paid (RPL v2 / NSQ v2: centre must have approved
application_formfirst). - Compute amount (business rules / fee config owned by CAP) — same quote as
GET …/payment-quote. - Idempotency: skip re-
InitiatePaymentif a pending or successful payment already exists. Pending with a stored URL returns 200{ paymentId, checkoutUrl }; completed is 409. Failed checkouts may retry. - gRPC
InitiatePaymentto Orchestrator withreferenceType: "cap_application",referenceId: applicationId. - Return
checkoutUrlto the client.GET /applications/{id}/stagesalso includescheckoutUrlon the payment row while pending. - On
payment.completed, matchreferenceIdand unlock Folder Arrangement (RPL) or induction (NSQ). - In the same success path, credit the centre wallet with the net fee (gross payment minus
processing_fee_percentage). Idempotent on OrchestratorpaymentId.paidAtand walletcreatedAtare UTC. This split is centre credit only — the candidate checkout amount is unchanged.
Dashboard total revenue / monthly revenue series read these credit rows, not current wallet balance. Callers without centre.wallet.read never see revenue fields.
The centre payments page uses GET /centre/payments/summary (all-time net credits, not the dashboard UTC year) and GET /centre/payments. Centre pendingCount includes unpaid and failed checkouts. Withdraw does not initiate a Paystack transfer. Receipts expose a snapshot (date, provider, amounts) plus an optional storage URL; PDF generation may still be stubbed.
Platform admin payments
Section titled “Platform admin payments”RBAC: platform.pricing.read.
| Method | Path | Use |
|---|---|---|
GET |
/admin/payments/summary |
Cards: platformRevenue, walletDeposits, pendingAmount |
GET |
/admin/payments |
Global list (q, status, centreId, sort paidAt | initiatedAt) |
Summary cards:
| Field | Meaning |
|---|---|
platformRevenue |
Processing-fee slice on completed payments (gross − centre net credit) |
walletDeposits |
Σ all centre wallet credit rows (net to centres) |
pendingAmount |
Σ gross for checkouts with status pending only (failed excluded) |
List row: applicationType, candidateName, status, centre (id / name), amount (candidate gross), centreNet (net to centre; null unless completed), receiptAvailable, timestamps. Receipt download: GET /applications/{id}/receipt (platform staff allowed alongside candidate / centre / assigned assessor / awarding-body staff).
Contrast with centre cards: centre totalRevenue = that centre’s credits; centre pendingCount includes failed — platform pendingAmount does not.
Platform admin config
Section titled “Platform admin config”Form-friendly aggregate over versioned pricing floors, processing_fee_percentage, and terms. Existing GET/POST on /admin/pricing, /admin/policies, and /admin/terms remain.
| Method | Path | Auth |
|---|---|---|
GET |
/admin/platform-config |
platform.pricing.read and platform.policy.read |
PATCH |
/admin/platform-config |
platform.pricing.write and platform.policy.write and platform.terms.write (always all three, even if body is partial) |
Snapshot (PlatformConfig): latest RPL/NSQ { minimum, version, effectiveFrom } (null if unseeded); processingFeePercentage from latest policy value.percentage; terms { content, version, effectiveFrom } (opaque string — plain or HTML/markdown; no contentType).
PATCH body (all optional; ≥1 required): pricing.RPL / pricing.NSQ (minimum Money, currency = CAP payment currency), processingFeePercentage (integer 0–100), termsContent (min length 1). Only present fields publish a new version with effectiveFrom=now in one transaction. Empty body → 422.
See Scalar — CAP REST (openapi/cap.yaml, lockstep with apps/cap/openapi/openapi.yaml) and API overview.
See also
Section titled “See also”- Invariant INV-03 → Domain entities
- Architecture → Payment architecture
- OL payment module → Orchestrator backend
- Flow → Experience flows — application happy path
- API group → API overview — Payment
- Tests → TC-HP-02 · TC-CFG-01 · TC-PAY-04
- Audience copy → Narrative — Paying the centre