Skip to content

Payment architecture

Audience: backend, product
Status: specced
Owns: backend
Depends on: System architecture, Orchestrator backend, CAP Payment module, LMS API, Event catalog

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.

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
  1. Client → CAP: POST /cap/applications/:id/pay
  2. CAP validates state (correct stage, not already paid) and computes the amount (RPL fee, NSQ fee, etc.).
  3. CAP → Orchestrator (gRPC): InitiatePayment({ amount, currency, referenceType: "cap_application", referenceId: applicationId, userId, purpose, metadata })
  4. Orchestrator creates a payments row (pending), calls the provider adapter, returns { paymentId, checkoutUrl } to CAP.
  5. CAP → Client: checkout URL.
  6. Provider → Orchestrator webhookone URL; only Orchestrator holds the provider secret and verifies signatures.
  7. Orchestrator marks success and outbox-publishes payment.completed { referenceType, referenceId, paymentId, amount, paidAt, provider? }.
  8. CAP consumes the event, matches referenceId to applicationId, 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.

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
  • 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.

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.