Complete task 4 backend security architecture docs
This commit is contained in:
173
09 - Audits/Payment Provider Adapter Spec.md
Normal file
173
09 - Audits/Payment Provider Adapter Spec.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user