174 lines
6.5 KiB
Markdown
174 lines
6.5 KiB
Markdown
---
|
|
title: Payment Provider Adapter Spec
|
|
tags: [adapters, payments, specification, architecture]
|
|
created: 2026-05-24
|
|
status: advisory
|
|
reviewers: [backend, security, product]
|
|
---
|
|
|
|
# Payment Provider Adapter Spec
|
|
|
|
This specification standardizes how payment providers are plugged in so platform logic
|
|
does not depend on SHKeeper or any single webhook implementation.
|
|
|
|
The contract below replaces provider-specific branching in domain services and should
|
|
be used by all pay-in, payout, release, and reconciliation logic.
|
|
|
|
> Canonical implementation note: this is an advisory ADR for tasks in `task 4.6`.
|
|
> It maps to [[Funds Ledger and Escrow State Machine Specification]] and [[Webhook Security Spec]].
|
|
|
|
## 1. Core provider contract
|
|
|
|
Implementations expose one typed adapter:
|
|
|
|
```ts
|
|
interface PaymentProviderAdapter {
|
|
readonly provider: "shkeeper" | "request_network" | "manual_wallet" | "admin_wallet" | string;
|
|
|
|
createPayInIntent(input: PayInIntentInput): Promise<PayInIntentResult>;
|
|
getPayInStatus(input: PayInStatusInput): Promise<PayInStatusResult>;
|
|
handleProviderWebhook(input: ProviderWebhookInput): Promise<ProviderWebhookResult>;
|
|
createHostedPaymentLink(input: HostedLinkInput): Promise<HostedLinkResult>;
|
|
createReleaseInstruction(input: ReleaseInstructionInput): Promise<ReleaseInstructionResult>;
|
|
createRefundInstruction(input: RefundInstructionInput): Promise<RefundInstructionResult>;
|
|
getPayoutStatus(input: PayoutStatusInput): Promise<PayoutStatusResult>;
|
|
searchProviderPayments(input: ProviderSearchInput): Promise<ProviderPaymentRecord[]>;
|
|
}
|
|
```
|
|
|
|
All adapters must return a normalized result shape:
|
|
|
|
```ts
|
|
type NormalizedProviderStatus = "pending" | "processing" | "confirmed" | "completed" | "failed" | "cancelled" | "released" | "refunded";
|
|
type NormalizedProviderEvent = {
|
|
providerPaymentId: string;
|
|
purchaseRequestId?: string;
|
|
requestId?: string;
|
|
providerReference?: string;
|
|
amount: string; // decimal string
|
|
currency: string;
|
|
status: NormalizedProviderStatus;
|
|
transactionHash?: string;
|
|
providerEventType: string;
|
|
receivedAt: string; // ISO timestamp
|
|
rawFingerprint: string; // provider payload hash
|
|
};
|
|
```
|
|
|
|
## 2. Method semantics
|
|
|
|
### 2.1 `createPayInIntent`
|
|
|
|
- Create a provider-specific payment intent from a canonical request.
|
|
- Must return:
|
|
- `providerPaymentId` (source of truth for future reconciliation),
|
|
- canonical `status`,
|
|
- `payInUrl` when redirect/payment-page flow is used,
|
|
- an expiry timestamp.
|
|
- Must persist provider metadata under `payment.providerData.<provider>`.
|
|
|
|
### 2.2 `getPayInStatus`
|
|
|
|
- Query provider status for an existing intent.
|
|
- Must map provider statuses into `NormalizedProviderStatus` and include a provider-specific raw snapshot.
|
|
- Must be idempotent and side-effect free.
|
|
|
|
### 2.3 `handleProviderWebhook`
|
|
|
|
- Input must include raw body bytes, headers, provider identifier, and parsed envelope.
|
|
- Must verify signatures before parsing business fields.
|
|
- On success, emit canonical domain events and return an idempotency decision:
|
|
- `processed` for first apply,
|
|
- `duplicate` for replay,
|
|
- `ignored` for unknown payment / no-op transitions.
|
|
|
|
### 2.4 `createHostedPaymentLink`
|
|
|
|
- Return the user-visible payment URL + optional redirect/callback endpoints.
|
|
- Should support provider aliases (for migration aliasing, e.g., `request-network` vs `request_network`).
|
|
|
|
### 2.5 `createReleaseInstruction` and `createRefundInstruction`
|
|
|
|
- Produce signed/payload instructions and pre-check:
|
|
- account/release eligibility,
|
|
- dispute hold not active,
|
|
- sufficient releasable balance (ledger-derived),
|
|
- admin approval requirements if configured.
|
|
- Must never directly mutate release state.
|
|
- Must be idempotent by `(paymentId, actionType)` where action type is `release|refund`.
|
|
|
|
### 2.6 `getPayoutStatus`
|
|
|
|
- Return state of pending/processing payout tasks and chain/on-chain confirmation status.
|
|
- Return normalized status to domain services:
|
|
- `processing` for queued/broadcast not-finalized,
|
|
- `completed` for finalized payment,
|
|
- `failed` with provider error code when rejected.
|
|
|
|
### 2.7 `searchProviderPayments`
|
|
|
|
- Used for reconciliation and manual verification.
|
|
- Must support:
|
|
- `providerPaymentId`/`requestId` lookup,
|
|
- time-window pagination,
|
|
- optional min/max amount filtering.
|
|
- Must never be the primary source for state transitions without reconciliation checks.
|
|
|
|
## 3. Routing and selection
|
|
|
|
Provider selection follows environment-configured capability flags:
|
|
|
|
- `PAYMENT_ENABLED_PROVIDERS` (comma-separated allowlist),
|
|
- `PAYMENT_DEFAULT_PROVIDER` (read-first fallback),
|
|
- `PAYMENT_ROLLBACK_PROVIDER` (read-only fallback target for cutbacks),
|
|
- `PAYMENT_MODE`:
|
|
- `standard`: normal provider routing,
|
|
- `dry_run`: no writes, status-only,
|
|
- `read_only`: no new pay-in/intent writes.
|
|
|
|
Selection rules:
|
|
|
|
1. Validate provider support and provider license/credential validity.
|
|
2. Route legacy requests to `shkeeper` when explicit migration window is active.
|
|
3. For unknown `provider`, return a `400 Bad Request` with explicit operator-visible error code.
|
|
4. If requested provider is disabled, return `409` with migration explanation and owner-visible hint for operator override.
|
|
|
|
## 4. Canonical metadata contract
|
|
|
|
Payment documents keep provider-specific data namespaced under:
|
|
|
|
- `metadata.providers.<provider>.rawPayload`
|
|
- `metadata.providers.<provider>.rawEvents[]`
|
|
- `metadata.providers.<provider>.providerPaymentId`
|
|
- `metadata.providers.<provider>.lastWebhookAt`
|
|
|
|
Domain services must never read `metadata.providers.*` as mutable funds state. They must use ledger-derived balances and canonical status fields only.
|
|
|
|
## 5. Error contract
|
|
|
|
All adapter methods return standard failure modes:
|
|
|
|
- `retryable: true` for transient provider errors (timeouts, 5xx, queue backpressure).
|
|
- `retryable: false` for invalid payloads, invalid signatures, and authorization failures.
|
|
- `errorCode` must be stable across retries for auditability.
|
|
|
|
## 6. Test coverage required
|
|
|
|
- Contract tests per adapter:
|
|
- `createPayInIntent`, status polling, webhook handling
|
|
- invalid/absent signature behavior
|
|
- duplicate webhook idempotency
|
|
- unknown payment reference behavior
|
|
- rollback selection and read-only mode behavior.
|
|
- Reconciliation tests:
|
|
- provider backfill for missing payment references,
|
|
- status drift correction,
|
|
- duplicate/missing event merge.
|
|
|
|
## Related
|
|
|
|
- [[Webhook Security Spec]]
|
|
- [[Funds Ledger and Escrow State Machine Specification]]
|
|
- [[Backend Core Stack Decision Record - 2026-05-24]]
|
|
- [[Backend Funds Migration and Operational Runbooks]]
|