Skip to content

Payment

Audience: domain, product, backend
Status: implemented
Owns: product + backend
Depends on: Lifecycle, Domain entities, Payment architecture, Orchestrator overview

  • 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 minus processing_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: PAYMENT with status typically AWAITING_ACTION until paid.
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.

  1. Validate application is in the correct stage and not already paid (RPL v2 / NSQ v2: centre must have approved application_form first).
  2. Compute amount (business rules / fee config owned by CAP) — same quote as GET …/payment-quote.
  3. Idempotency: skip re-InitiatePayment if a pending or successful payment already exists. Pending with a stored URL returns 200 { paymentId, checkoutUrl }; completed is 409. Failed checkouts may retry.
  4. gRPC InitiatePayment to Orchestrator with referenceType: "cap_application", referenceId: applicationId.
  5. Return checkoutUrl to the client. GET /applications/{id}/stages also includes checkoutUrl on the payment row while pending.
  6. On payment.completed, match referenceId and unlock Folder Arrangement (RPL) or induction (NSQ).
  7. In the same success path, credit the centre wallet with the net fee (gross payment minus processing_fee_percentage). Idempotent on Orchestrator paymentId. paidAt and wallet createdAt are 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.

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.

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.