Payment architecture
Payment architecture
Section titled “Payment architecture”Audience: backend, product
Status: specced
Owns: backend
Depends on: System architecture, Orchestrator backend, CAP Payment module, LMS API, Event catalog
Rule: Orchestrator never knows price
Section titled “Rule: Orchestrator never knows price”Orchestrator’s payment service must never know about price. The business service that owns the purchasable thing (CAP applications; LMS enrollments; WorkMasters later) computes the amount. Orchestrator only ever processes (amount, reference) pairs.
If Orchestrator called back into CAP to ask “what does this application cost?”, that would be a backward dependency — a shared platform service depending on vertical business logic — and it worsens the day LMS or WorkMasters also need payment.
| Concern | Owner |
|---|---|
| Pricing (RPL fee, NSQ fee, course price, …) | CAP / LMS / … |
Processing (checkout, provider adapter, webhook, payments row) |
Orchestrator |
| Client-facing pay API | Business service (POST /cap/applications/:id/pay, POST /lms/enrollments/:id/checkout) — not Orchestrator public HTTP |
Clients never talk to Orchestrator directly for payment initiation. Amount is computed server-side in CAP and passed on an internal gRPC call the client never touches — no client price tampering path.
Flow (CAP application payment)
Section titled “Flow (CAP application payment)”sequenceDiagram participant Client participant CAP participant OL as Orchestrator participant PS as Paystack participant RMQ as RabbitMQ
Client->>CAP: POST /cap/applications/:id/pay CAP->>CAP: Validate stage; compute amount CAP->>OL: gRPC InitiatePayment(amount, referenceType, referenceId, ...) OL->>OL: payments row pending OL->>PS: Provider createCheckout PS-->>OL: checkoutUrl + providerReference OL-->>CAP: paymentId, checkoutUrl CAP-->>Client: checkoutUrl PS->>OL: Webhook (single URL) OL->>OL: Verify signature; mark success OL->>RMQ: outbox payment.completed RMQ->>CAP: payment.completed CAP->>CAP: Match referenceId; unlock next stage- Client → CAP:
POST /cap/applications/:id/pay - CAP validates state (correct stage, not already paid) and computes the amount (RPL fee, NSQ fee, etc.).
- CAP → Orchestrator (gRPC):
InitiatePayment({ amount, currency, referenceType: "cap_application", referenceId: applicationId, userId, purpose, metadata }) - Orchestrator creates a
paymentsrow (pending), calls the provider adapter, returns{ paymentId, checkoutUrl }to CAP. - CAP → Client: checkout URL.
- Provider → Orchestrator webhook — one URL; only Orchestrator holds the provider secret and verifies signatures.
- Orchestrator marks success and outbox-publishes
payment.completed{ referenceType, referenceId, paymentId, amount, paidAt, provider? }. - CAP consumes the event, matches
referenceIdtoapplicationId, unlocks Folder Arrangement (RPL) or induction (NSQ).
CAP is the client-facing entry point and calls Orchestrator server-to-server — never the reverse for pricing or initiation.
Provider adapters (same shape as stage strategies)
Section titled “Provider adapters (same shape as stage strategies)”Closed set of adapters in Orchestrator code; adding a provider is a new class — CAP and the public API contract do not change. payments.provider records which adapter handled each row (regional routing / failover).
interface PaymentProviderAdapter { readonly key: string; // "paystack" | "flutterwave" | ...
createCheckout(input: { amount: number; currency: string; reference: string; metadata: Record<string, unknown>; }): Promise<{ providerReference: string; checkoutUrl: string }>;
verifyWebhookSignature(rawBody: Buffer, signature: string): boolean;
parseWebhookEvent(rawBody: Buffer): { providerReference: string; status: "success" | "failed"; };}Paystack is the first adapter; design stays provider-agnostic.
Idempotency (both ends)
Section titled “Idempotency (both ends)”| Side | Rule |
|---|---|
| CAP | Before InitiatePayment, check for an existing pending or successful payment against application_id. Pending with a stored checkoutUrl is returned as 200 (resume); completed is 409; do not re-initiate blindly |
| Orchestrator | Webhooks are at-least-once: unique constraint on provider_reference; replay of an already-success payment is a no-op |
Webhooks only in Orchestrator
Section titled “Webhooks only in Orchestrator”- One registered provider webhook URL
- One place holding the provider secret
- One signature-verification path
No business service (CAP, LMS, …) exposes its own payment-provider webhook endpoint.
Generalization
Section titled “Generalization”Same flow for LMS course payments or a future WorkMasters subscription: business service prices + InitiatePayment with e.g. referenceType: "lms_enrollment" — no change to Orchestrator’s payment service shape.
See also
Section titled “See also”- Product intent → Payment module · LMS checkout
- OL tables / Phase 1 → Orchestrator backend
- Event row → Event catalog (
payment.completed) - Workflow gate → RPL lifecycle · LMS lifecycle · Workflow abstraction