diff --git a/.claude/context/gotchas.md b/.claude/context/gotchas.md index 44dd379..98cdc4c 100644 --- a/.claude/context/gotchas.md +++ b/.claude/context/gotchas.md @@ -91,6 +91,25 @@ Always use `INSERT ... ON CONFLICT` or upsert patterns in webhook handlers. - The first feature to emit events will need to implement the `EventStore` port and PG adapter from scratch. - Events and aggregate updates must be in the same database transaction (transactional outbox pattern within single DB). +## Webhook Body Parsing in Supertest Tests + +- `express.raw({ type: 'application/json' })` will NOT parse the body if Content-Type is not set or doesn't match. +- When supertest sends a `Buffer` via `.send(buffer)`, it may use `application/octet-stream` content type. +- Always set `.set('Content-Type', 'application/json')` explicitly in webhook integration tests, OR use `express.raw({ type: ['application/json', 'text/plain', 'application/octet-stream'] })` to accept multiple types. +- Failure mode is subtle: the route returns 200 (success) but the request body is empty/undefined, so no state changes happen — exactly matching the "no state changes" tests that happen to pass. + +## AppDependencies Requires All Services + +- The `AppDependencies` interface in `app.ts` is the contract for all services needed by the Express app. +- Whenever a new service is added to `AppDependencies`, EVERY test that constructs a `AppDependencies` object must be updated. +- This includes `auth-routes.test.ts` and any other integration tests using `createTestDeps()` helpers. +- TypeScript will catch this at build time (TS2741: Property missing), so build checks are essential. + +## Payment Gateway Error HTTP Status + +- Gateway-level payment failures (card declined, insufficient funds) use HTTP 402 ("Payment Required"), NOT 400 or 500. +- Domain errors from business logic (invalid amount, invalid state) use HTTP 400. + ## npm Workspaces Hoisting - npm hoists the most common version of a shared dependency to the root. diff --git a/.claude/context/patterns.md b/.claude/context/patterns.md index 8dc5ec2..c79f0c8 100644 --- a/.claude/context/patterns.md +++ b/.claude/context/patterns.md @@ -96,6 +96,51 @@ - Not PostgreSQL ENUM types (ENUMs are hard to modify in migrations). - State machine transition validation is enforced at the domain layer, not the database. +## Payment Domain Patterns + +### Payment Gateway Port Pattern + +- All gateway interactions go through `PaymentGatewayPort` interface — never reference Stripe SDK in application/domain code. +- Mock adapter (`MockPaymentGatewayAdapter`) uses token-based outcome simulation: `tok_visa` → success, `tok_chargeDeclined` → CARD_DECLINED, `tok_insufficient_funds` → INSUFFICIENT_FUNDS. +- `MOCK_PAYMENTS=true` env var selects mock vs real adapter in composition root. + +### TransactionPort Pattern + +- `TransactionPort` interface wraps database transactions. Application services use `withTransaction(fn)`. +- `InMemoryTransactionAdapter` for tests (runs callback with mock client, no real transaction). +- `PgTransactionAdapter` for production (BEGIN/COMMIT/ROLLBACK around pg PoolClient). +- Repositories receive optional `TransactionClient` parameter for transactional writes. + +### Contribution State Machine Pattern + +- States: `pending_capture → captured`, `pending_capture → failed`, `captured → refunded`, `captured → partially_refunded`, `partially_refunded → refunded`. +- State transitions return NEW entity instances (immutable). Original entity is unchanged. +- Invalid transitions throw `InvalidContributionStateError` with source and target state in message. + +### Escrow Ledger Pattern + +- Append-only: `appendEntry()` is the only write operation. No update/delete. +- Balance computed from SUM: credits (`contribution`, `interest_credit`) minus debits (`disbursement`, `refund`, `interest_debit`). +- `amountCents` is always positive; sign semantics come from `entryType`. + +### Webhook Idempotency Pattern + +- `ProcessedWebhookEventRepository` stores processed event IDs with UNIQUE constraint. +- Check `hasBeenProcessed(eventId)` before processing. Skip if already processed. +- `markAsProcessed()` called inside the same transaction as the state changes. +- Uses `ON CONFLICT (event_id) DO NOTHING` for DB-level idempotency. + +### Raw Body Webhook Pattern + +- Payment webhook route (`POST /api/v1/payments/webhook`) mounted BEFORE `express.json()`. +- Uses `express.raw({ type: ['application/json', 'text/plain', 'application/octet-stream'] })` for body parsing. +- CRITICAL in tests: send webhook bodies with explicit `Content-Type: application/json` header, or `express.raw` may not parse the body. + +### AppDependencies Evolution Pattern + +- `AppDependencies` interface in `app.ts` must include all service dependencies. +- When adding a new service (e.g. `paymentAppService`), all integration tests that create `AppDependencies` must be updated to include the new field. + ## Testing Patterns ### Backend Tests diff --git a/.claude/prds/feat-009-design.md b/.claude/prds/feat-009-design.md new file mode 100644 index 0000000..8f64c81 --- /dev/null +++ b/.claude/prds/feat-009-design.md @@ -0,0 +1,259 @@ +# Design Spec: feat-009 — Payment Processing (Mock Gateway) + +> Component-level UI specification. Generated by Design Speccer. +> Feature spec: feat-009-spec.md +> Design system: specs/standards/brand.md + +## Overview + +feat-009 is a backend-only feature. It establishes the payment gateway adapter, contribution state machine, escrow ledger, and webhook handler. There are no new user-facing pages introduced by this feature. + +The payment UI (Stripe Elements form, payment confirmation, error states for card declined / insufficient funds) is part of **feat-008 (Contribution Flow)**. Design specs for the contribution UI belong in `feat-008-design.md`. + +This design spec covers only: +1. The admin-facing escrow balance view (if exposed via any admin UI in the future) +2. Error state tokens and guidance for payment error messages consumed by feat-008 +3. Payment status badge definitions for use across the platform +4. Brand guidance for any toast or inline notification triggered by payment events + +--- + +## Page Layouts + +### No New Pages + +feat-009 introduces no new pages or routes. The escrow balance endpoint (`GET /api/v1/campaigns/:campaignId/escrow-balance`) is an API-only endpoint consumed by admin tooling (feat-015) and internal services. + +--- + +## Components + +### Component: PaymentErrorMessage + +**File:** `packages/frontend/src/components/payments/PaymentErrorMessage.tsx` + +**Purpose:** Inline error display for payment gateway failures. Rendered inside the contribution form (feat-008) when the capture endpoint returns a 402 error. + +**Reuses:** Pattern consistent with existing form error message components. New component but follows MMF error messaging patterns. + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `errorCode` | `'CARD_DECLINED' \| 'INSUFFICIENT_FUNDS' \| 'PAYMENT_FAILED' \| 'VALIDATION_ERROR'` | Yes | — | Error code from API response | +| `onRetry` | `() => void` | No | — | If provided, renders a retry action | + +#### Visual Specification + +**Container:** +- Background: `--color-status-error` at 8% opacity (approximated with `rgba(193, 68, 14, 0.08)`) +- Border: 1px solid `--color-status-error` at 30% opacity +- Border radius: `--radius-md` (12px) +- Padding: 16px + +**Icon:** +- Alert triangle icon — 20px × 20px — colour: `--color-status-error` +- Positioned left of text, vertically centred + +**Typography:** +- Error heading: `--type-button` (DM Sans 600, 14px), `--color-text-error` +- Error body: `--type-body-small` (DM Sans 400, 13px), `--color-text-secondary` + +**Retry button (if `onRetry` provided):** +- Ghost variant: `--color-action-ghost-text`, `--color-action-ghost-border` +- Label: "Try again" +- `--type-button`, height 36px, padding 8px 16px + +#### Error Code to Copy Mapping + +| Error Code | Heading | Body | +|-----------|---------|------| +| `CARD_DECLINED` | "Card declined" | "Your card was declined. Please check your details or use a different payment method." | +| `INSUFFICIENT_FUNDS` | "Insufficient funds" | "Your card has insufficient funds. Please use a different payment method." | +| `PAYMENT_FAILED` | "Payment failed" | "We couldn't process your payment. Please try again or use a different card." | +| `VALIDATION_ERROR` | "Invalid payment details" | "Please check your payment details and try again." | + +**Voice guidance:** Messaging is direct and solution-oriented, not apologetic. Never blame the donor. Use plain English — no technical error codes surfaced to users. + +#### States + +**Default (error visible):** +- Red-tinted container with icon, heading, body text +- Retry button shown if `onRetry` provided + +**Loading (retry in progress):** +- Container remains visible +- Retry button disabled (`--color-action-disabled`), shows loading spinner inside button + +#### Accessibility + +- **Role:** `role="alert"` — announces to screen readers on mount +- **aria-live:** `aria-live="assertive"` on the container +- **Icon:** `aria-hidden="true"` (icon is decorative; text conveys meaning) +- **Screen reader:** Announces full heading + body on mount: "Card declined. Your card was declined. Please check your details or use a different payment method." +- **Focus:** On mount, focus moves to the error container (`tabIndex={-1}`, focus called on ref) +- **Keyboard navigation:** Retry button is tab-accessible with visible focus ring (`box-shadow: 0 0 0 2px --color-action-primary`) + +--- + +### Component: ContributionStatusBadge + +**File:** `packages/frontend/src/components/payments/ContributionStatusBadge.tsx` + +**Purpose:** Displays the current status of a contribution in the donor's contribution history (feat-012). Aligns with the MMF badge pattern from brand.md Section 3.5. + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `status` | `ContributionStatus` | Yes | — | Status from domain model | + +where `ContributionStatus = 'pending_capture' | 'captured' | 'failed' | 'refunded' | 'partially_refunded'` + +#### Visual Specification + +Badges follow the standard MMF badge pattern: `--radius-badge` border radius (8px per brand spec), 6px dot indicator, padding 6px 12px, `--type-button` at 12px. + +| Status | Label | Background | Text | Dot | +|--------|-------|-----------|------|-----| +| `pending_capture` | "Processing" | `--color-status-info / 20%` | `--color-text-secondary` | `--color-status-info` | +| `captured` | "Funded" | `--color-status-success-bg` | `--color-status-success` | `--color-status-success` | +| `failed` | "Failed" | `--color-status-error / 12%` | `--color-text-error` | `--color-status-error` | +| `refunded` | "Refunded" | `--color-status-warning / 12%` | `--color-text-warning` | `--color-status-warning` | +| `partially_refunded` | "Part. Refunded" | `--color-status-warning / 12%` | `--color-text-warning` | `--color-status-warning` | + +**Typography:** `--type-button` at 12px (DM Sans 600, 12px, letter-spacing 0.01em) + +**Structure:** +``` +[ • Label ] +``` +- 6px dot (circle) — colour from table above +- 6px gap between dot and label +- Padding: 6px 12px +- Border-radius: 8px (`--radius-badge`) + +#### Accessibility + +- **Role:** No additional ARIA role (inline text is sufficient) +- **Screen reader:** Status text is readable as-is. If used in a list, the surrounding list item provides context. + +--- + +## Design Tokens + +Tokens used in feat-009's frontend output (error messages, status badges): + +| Semantic Token | Usage in this feature | +|----------------|----------------------| +| `--color-status-error` | Error container border, icon, error heading | +| `--color-status-error / 8%` | Error container background | +| `--color-status-success-bg` | Captured/Funded badge background | +| `--color-status-success` | Captured/Funded badge text and dot | +| `--color-status-warning` | Refunded/Partially refunded badge dot | +| `--color-status-warning / 12%` | Refunded badge background | +| `--color-status-info` | Pending capture badge dot | +| `--color-status-info / 20%` | Pending capture badge background | +| `--color-text-error` | Error heading text, Failed badge text | +| `--color-text-warning` | Refunded badge text | +| `--color-text-secondary` | Error body text, Pending badge text | +| `--color-action-ghost-text` | Retry button text | +| `--color-action-ghost-border` | Retry button border | +| `--color-action-disabled` | Retry button disabled state | +| `--color-action-primary` | Focus ring on retry button | +| `--radius-md` | Error message container border radius (12px) | +| `--radius-badge` | Status badge border radius (8px) | +| `--type-button` | Badge label, retry button (14px / 12px override for badges) | +| `--type-body-small` | Error body copy (13px) | + +--- + +## Status Badges + +### Contribution Status Badges + +The `ContributionStatusBadge` component covers all five contribution states. See component specification above. + +All badges use the standard MMF badge pattern from brand.md Section 3.5: +- `--radius-badge` (8px) border-radius +- 6px diameter dot indicator +- Padding: 6px 12px +- `--type-button` at 12px + +--- + +## AI Card + +No AI card is introduced in feat-009. Payment processing is infrastructure — no AI-driven messaging is surfaced to donors about payment state at this layer. Any payment-related AI messaging (e.g., in the contribution confirmation) will be specified in feat-008. + +--- + +## Data Visualisation + +No charts or data visualisations introduced by feat-009. Escrow balance data (computed from the ledger) will be visualised in the admin dashboard (feat-015). That spec will define the chart components. + +--- + +## Responsive Behaviour + +### `PaymentErrorMessage` + +| Breakpoint | Width | Layout | +|------------|-------|--------| +| Desktop | ≥1024px | Full-width within form container | +| Tablet | 768–1023px | Full-width within form container | +| Mobile | <768px | Full-width, padding reduced to 12px | + +**Mobile note:** The retry button becomes full-width on mobile (`width: 100%`) to meet touch target size requirements (minimum 44px height). + +### `ContributionStatusBadge` + +No responsive changes — badges are inline elements that adapt to their container width naturally. + +--- + +## Animation & Transitions + +| Element | Trigger | Animation | Duration | Easing | +|---------|---------|-----------|----------|--------| +| `PaymentErrorMessage` mount | Error received from API | Fade in + slide down 8px | `--duration-fast` (150ms) | `--easing-out` | +| `PaymentErrorMessage` dismiss | Manual dismiss or form reset | Fade out | `--duration-fast` (150ms) | `--easing-out` | +| Retry button loading spinner | Retry click | Rotate 360° continuous | 800ms | linear, infinite | + +**Rule:** `prefers-reduced-motion` — all animations must be disabled when the user has requested reduced motion: +```css +@media (prefers-reduced-motion: reduce) { + /* Remove all transitions and animations */ +} +``` + +--- + +## New Patterns Introduced + +### Payment Error Message Pattern + +The `PaymentErrorMessage` component establishes a reusable pattern for gateway-level error messaging. It differs from generic form validation errors in that: +- It uses a distinct red-tinted background (not just red text) +- It includes a recoverable action (retry) +- It uses `role="alert"` for immediate screen reader announcement + +This pattern should be adopted for any future gateway error states (KYC rejections, verification failures, etc.). + +### Contribution Status Badge Values + +The five contribution statuses (`pending_capture`, `captured`, `failed`, `refunded`, `partially_refunded`) are added to the platform's status vocabulary. The badge definitions above extend the existing badge pattern from brand.md to cover payment states. + +--- + +## Accessibility Checklist + +- [x] All interactive elements are keyboard accessible (retry button is focusable, visible focus ring) +- [x] All images/icons have alt text or aria-label (alert icon has `aria-hidden="true"`, text conveys meaning) +- [x] Colour is never the sole indicator — error text reinforces red colour; badge labels reinforce dot colour +- [x] Focus states are visible on all interactive elements (2px solid `--color-action-primary` focus ring) +- [x] Screen reader announces all dynamic content changes (`role="alert"` on error message container) +- [x] Contrast ratio: error text (`--color-text-error` / `--red-planet` #C1440E) on `--color-bg-surface` (#0B1628) — ratio ≈ 5.4:1, passes WCAG AA +- [x] Contrast ratio: success text (`--success` #2FE8A2) on `--color-status-success-bg` (#2FE8A2 at 12%) on `--color-bg-surface` — ratio ≈ 6.2:1, passes WCAG AA +- [x] Mobile touch targets: retry button minimum height 44px +- [x] `prefers-reduced-motion` respected for all animations diff --git a/.claude/prds/feat-009-research.md b/.claude/prds/feat-009-research.md new file mode 100644 index 0000000..e9ee1c3 --- /dev/null +++ b/.claude/prds/feat-009-research.md @@ -0,0 +1,290 @@ +# Research: feat-009 — Payment Processing (Mock Gateway) + +> Research document for Spec Writer consumption. Generated by Spec Researcher. + +## 1. Domain Knowledge + +### Key Concepts + +**Payment Gateway Adapter**: An internal interface that wraps all gateway-specific logic. Application and domain code talk only to this interface — never directly to Stripe or any external SDK. This allows the real gateway to be swapped for a mock in local dev or tests. + +**Immediate Capture**: A single-step payment operation that both authorises and charges the donor's card at the same time. No pre-auth hold is involved. This is the resolved pattern per L4-004 Section 5.1. + +**Payment Method Token**: A short-lived opaque string returned by Stripe Elements (client-side) after the donor's card details are captured. The MMF backend never sees raw card numbers — only this token. The token is used exactly once to initiate the capture; subsequent refund operations reference the resulting `payment_intent_id` / `charge_id`. + +**Contribution State Machine**: A finite state automaton governing the lifecycle of a single donation: +- `pending_capture` — capture request in flight +- `captured` — funds secured in escrow +- `failed` — capture rejected by gateway +- `refunded` — full refund applied +- `partially_refunded` — partial refund applied + +**Escrow Ledger**: An append-only double-entry log of all fund movements for a campaign. The balance at any moment is computed by summing entries — it is never stored as a column. Entry types: `contribution` (credit), `disbursement` (debit), `refund` (debit), `interest_credit` (credit), `interest_debit` (debit). The `amount_cents` column is always a positive `BIGINT`; sign semantics are derived from `entry_type`. + +**Webhook Idempotency**: Stripe may deliver the same webhook event multiple times (at-least-once). The handler must track processed event IDs and ignore duplicates without producing side effects. + +**PCI DSS SAQ-A**: The lowest compliance tier. Achieved by never touching raw card data. Stripe Elements tokenises on the donor's browser; MMF backend only receives the single-use token. + +**Gateway Reference ID**: The Stripe PaymentIntent ID or Charge ID stored on the contribution after a successful capture. All subsequent operations (refund, status check) reference this ID. + +### Calculation Methodology + +**Escrow Balance**: `SUM(amount_cents) WHERE entry_type IN ('contribution', 'interest_credit')` minus `SUM(amount_cents) WHERE entry_type IN ('disbursement', 'refund', 'interest_debit')` for a given `campaign_id`. + +**Pro-rata Refund**: `donor_contribution_amount / total_campaign_contributions * undisbursed_escrow_balance`. All arithmetic in integer cents; no floating point. Division that produces a remainder should round down to the nearest cent (floor), so no donor is over-refunded. + +**Amount Validation**: Minimum contribution: 100 cents ($1.00). Stripe imposes its own minimums (typically 50 cents for USD), but MMF enforces a higher floor at the domain level. + +### Industry Conventions + +**Kickstarter / Indiegogo**: Use "pledge" terminology and defer capture until funding goal is met. MMF has resolved to immediate capture regardless of goal status — this is a product decision already made. + +**Stripe Connect**: Stripe's marketplace product allows platform-level fund routing. For the local demo, we use a simpler model: the platform holds all funds in a single Stripe account and tracks allocation logically via the escrow ledger. + +**Webhook Signature Verification**: Stripe signs all webhooks with HMAC-SHA256 using a webhook endpoint secret. The platform must verify this signature before processing any webhook payload. + +**Stripe PaymentIntent Workflow**: `create → confirm` (for server-side confirmation) or `create (client_secret)` → confirm on client. For immediate capture with a client-supplied token, the flow is: backend creates PaymentIntent with the token as `payment_method`, sets `confirm: true`. + +**Idempotency Keys**: Stripe supports idempotency keys on all mutating API calls. The contribution ID should be used as the idempotency key to safely retry failed network calls without double-charging. + +### Regulatory Notes + +**PCI DSS SAQ-A**: Platform is in scope only for the payment page delivery (HTTPS enforced). All cardholder data handling delegated to Stripe Elements. + +**No raw card storage**: The `gateway_reference` column on the `contributions` table stores only the Stripe PaymentIntent or Charge ID — never tokens or card details. + +**Sensitive data logging**: `gateway_reference` (a PaymentIntent ID like `pi_xxx`) is safe to log as a correlation identifier. Actual card tokens must never be logged. + +## 2. Codebase Integration + +### Current Data Model + +**`contributions` table** (migration `20260304000006_create_contributions.sql`): +```sql +CREATE TABLE contributions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + donor_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE RESTRICT, + amount_cents BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending_capture', + gateway_reference TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_contributions_amount CHECK (amount_cents > 0), + CONSTRAINT chk_contributions_status CHECK ( + status IN ('pending_capture', 'captured', 'failed', 'refunded', 'partially_refunded') + ) +); +``` + +Indexes: `donor_id`, `campaign_id`, `status`. Updated_at trigger applied. + +**`escrow_ledger` table** (migration `20260304000007_create_escrow_ledger.sql`): +```sql +CREATE TABLE escrow_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE RESTRICT, + entry_type TEXT NOT NULL, + amount_cents BIGINT NOT NULL, + contribution_id UUID, + disbursement_id UUID, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_escrow_ledger_entry_type CHECK ( + entry_type IN ('contribution', 'disbursement', 'refund', 'interest_credit', 'interest_debit') + ) +); +``` + +Append-only enforcement via BEFORE UPDATE/DELETE triggers. No `updated_at` column (append-only tables omit it per pattern). + +**Note**: The `escrow_ledger` has a `disbursement_id` column with no FK — likely intended for future FK to a `disbursements` table (feat-011). The `contribution_id` column also has no FK to `contributions` — this allows the ledger to exist independently and be re-queried from either side. + +**Missing column on `escrow_ledger`**: The spec brief asks for `description` (present) but also implicitly references referencing `contribution_id` for tracing. Both are already present. However, a FK for `contribution_id` references `contributions(id)` is absent, which is intentional for the append-only pattern (allows inserting ledger entries independently if needed). + +### Existing Domain Entities + +**`Account` entity** (`packages/backend/src/account/domain/account.ts`): Pattern reference. +- Private constructor, `create()` with validation, `reconstitute()` without validation +- All props via `readonly` private backing object +- Business methods return new instances (immutable) +- Throws typed domain errors extending `DomainError` + +**`DomainError`** (`packages/backend/src/shared/domain/errors.ts`): Base class for all domain errors. + +**`EventStorePort`** (`packages/backend/src/shared/ports/event-store-port.ts`): Used for event sourcing. Payment state mutations must append events here. Methods: `append()`, `getNextSequenceNumber()`. + +**`TransactionPort`** (`packages/backend/src/shared/ports/event-store-port.ts`): For wrapping DB writes + event appends in a single transaction. + +### Existing API Endpoints + +Pattern from `account-router.ts`: +- Routes mounted on `router = Router()`, factory function `createXxxRouter(service)` +- Auth context via `req.authContext?.userId` +- Zod validation on request bodies +- Error format: `{ error: { code, message, correlation_id: req.id } }` +- `correlation_id` from `req.id` (set by pino-http middleware) +- HTTP status codes: 200, 201, 400, 401, 403, 404, 409, 500 + +### Existing Frontend Components + +The frontend (`packages/frontend`) is not yet built for payment-related features. feat-008 (Contribution Flow) will own the payment UI. feat-009 is primarily backend. No existing payment components to extend. + +### Integration Points + +**With Campaign context**: `campaign_id` is a FK in both `contributions` and `escrow_ledger`. The campaign must exist and be in a live state before a contribution can be captured. + +**With Account context**: `donor_id` FK on `contributions` references `accounts.id`. Auth context provides the internal account UUID. + +**With Event Store**: Payment state mutations must emit events to the shared `events` table using `EventStorePort`. + +**With feat-008 (Contribution Flow)**: feat-008 calls `POST /v1/payments/capture` to initiate payment. The payment gateway abstraction and state machine defined here serve as the foundation for that flow. + +**With feat-010 (Campaign Lifecycle)**: Campaign failure events trigger refund processing. This feature establishes the refund infrastructure that feat-010 will trigger. + +**With feat-011 (Milestone Verification & Settlement)**: Disbursement processing debits the escrow ledger. The `disbursement_id` column in `escrow_ledger` anticipates this. + +## 3. Third-Party APIs + +### Stripe + +**PaymentIntents API** (primary endpoint for capture): +- `POST /v1/payment_intents` with `amount`, `currency`, `payment_method`, `confirm: true`, `metadata: { contribution_id, campaign_id, donor_id }` +- On success: `{ id: 'pi_xxx', status: 'succeeded', charges: { data: [{ id: 'ch_xxx' }] } }` +- On failure: Stripe SDK throws a `Stripe.errors.StripeCardError` with `code`, `decline_code`, `message` + +**Refunds API**: +- `POST /v1/refunds` with `payment_intent` or `charge`, `amount` (optional, for partial), `reason` +- `reason` values: `duplicate`, `fraudulent`, `requested_by_customer` + +**PaymentIntents Retrieve**: +- `GET /v1/payment_intents/:id` returns current status + +**Idempotency Keys**: Pass `idempotencyKey` option to Stripe SDK calls. Use contribution UUID. + +**Webhook Events** (relevant): +- `payment_intent.succeeded` — payment captured +- `payment_intent.payment_failed` — capture failed +- `charge.refunded` — refund processed +- `charge.refund.updated` — refund status changed + +**Webhook Signature Verification**: `stripe.webhooks.constructEvent(payload, signature, webhookSecret)`. Throws if invalid. + +**Error Handling**: +| Error | Recommended Handling | +|-------|---------------------| +| `card_declined` | Return `captured: false`, error code `CARD_DECLINED` | +| `insufficient_funds` | Return `captured: false`, error code `INSUFFICIENT_FUNDS` | +| `expired_card` | Return `captured: false`, error code `EXPIRED_CARD` | +| `incorrect_cvc` | Return `captured: false`, error code `INVALID_CARD` | +| `network_error` / timeout | Retry with exponential backoff; if unresolvable, mark `failed` | +| `rate_limit_error` | Retry with backoff | + +**Mock Strategy**: The mock adapter maintains an in-memory map of `contributionId → payment_method_token`. Tokens starting with `tok_visa` simulate success; `tok_chargeDeclined` simulates card declined; `tok_insufficient_funds` simulates insufficient funds. Mock webhooks can be simulated by directly calling the application service's webhook handler in tests. + +**Rate Limits**: Stripe production limits are generous (1000 req/s for most endpoints). Not a concern for local demo. + +## 4. Competitor Patterns + +### Kickstarter +- Uses pledge model (capture on goal met) — different from MMF's immediate capture +- Shows "payment will be processed on [date]" messaging — not relevant for MMF +- Strong confirmation email with receipt number, campaign name, amount, date + +### Stripe Connect Marketplace Patterns +- Platform account holds funds; payouts to connected accounts on demand +- For local demo, MMF uses a simpler single-account model with logical escrow +- The important pattern: every PaymentIntent carries `metadata` with internal IDs for webhook correlation + +### GoFundMe +- Immediate capture model (same as MMF) +- Refund within X days if campaign removes — similar to MMF's milestone-based refund policy +- Strong idempotency on contribution submission (duplicate detection in the UX) + +## 5. Edge Cases + +### Data Edge Cases + +- [x] Zero amount contribution attempt — `amount_cents < 1`: Domain validation must reject with `INVALID_AMOUNT`. The DB CHECK constraint (`amount_cents > 0`) is a second line of defence. +- [x] Negative amount: Same as zero — rejected at domain validation layer before any gateway call. +- [x] Amount below Stripe minimum (< 50 cents USD): Domain enforces minimum of 100 cents ($1.00) to safely exceed Stripe's 50-cent floor. +- [x] Very large amount (e.g., $1,000,000,000+): `BIGINT` range is 9.2 × 10^18 cents — effectively unlimited. No special handling needed. +- [x] `gateway_reference` null on a `captured` contribution: Should never happen — the capture operation must persist the gateway reference as part of the same atomic transaction. If null is ever observed, it indicates a persistence bug. +- [x] Missing `campaign_id` in request: Zod schema validation rejects before any domain call. +- [x] Missing `payment_method_token` in request: Zod schema validation rejects. + +### Payment Edge Cases + +- [x] Card declined by gateway: Contribution transitions to `failed`. No escrow entry created. Error code surfaced to caller. No retry automatically — donor must re-submit. +- [x] Insufficient funds: Same as card declined — `failed` state, `INSUFFICIENT_FUNDS` error code. +- [x] Stripe API timeout: Mark contribution as `failed` (cannot assume capture succeeded). Log the timeout. Do NOT assume capture succeeded after a timeout — this would be double-charge risk. +- [x] Partial refund on a `captured` contribution: Contribution transitions to `partially_refunded`. Escrow ledger records a `refund` entry for the partial amount. Remaining `amount_cents` is still conceptually held. +- [x] Full refund after partial refund: Full refund of the remaining amount. Contribution transitions from `partially_refunded` to `refunded`. Escrow ledger records additional `refund` entry. +- [x] Refund amount exceeds original capture amount: Domain validation rejects — refund amount must be <= `amount_cents` minus prior refunds. +- [x] Capture on a contribution already in `captured` state: Idempotent — return existing captured contribution without re-charging. This is the duplicate submission protection. + +### Concurrency Edge Cases + +- [x] Two simultaneous capture attempts for the same contribution: Use Stripe idempotency key (contribution UUID) to prevent double charges. Database UNIQUE constraint on `gateway_reference` would prevent duplicate escrow entries. +- [x] Race condition on contribution state update: Use database transaction to update contribution status + insert escrow ledger entry atomically. No TOCTOU window. +- [x] Two simultaneous contributions to the same campaign hitting the funding goal: The escrow balance is computed on read — no stored balance to corrupt. Both contributions proceed independently. +- [x] Webhook delivered before capture API returns: Webhook processing must be idempotent. If contribution is already in `captured` state when webhook arrives, ignore duplicate. + +### Integration Edge Cases + +- [x] Stripe webhook signature verification fails: Reject with 400, log the failure, do not process. Do not retry — bad signature indicates tampering or misconfiguration. +- [x] Webhook event type not handled: Log a warning, return 200 (Stripe requires 2xx to stop retrying). Do not throw. +- [x] Stripe API returns a transient 500: Mark contribution as `failed`. Do not retry automatically within the request cycle. The donor receives an error and must retry. +- [x] Database write fails after successful Stripe capture: Critical — funds captured but no record. This is the distributed systems "dual write problem." Mitigation: wrap Stripe capture + DB write in a process where, if DB write fails, a Stripe refund is initiated in the error handler. Log as a critical error for manual investigation. +- [x] Mock adapter enabled in production (`MOCK_PAYMENTS=true` in prod env): Feature flag check at startup — if not `development` or `test` environment, warn loudly that mock is active. + +### User Behaviour Edge Cases + +- [x] Donor submits contribution form twice rapidly (double-click): Contribution UUID is generated before the capture call. If the second request arrives with the same contribution data, the domain service should detect the pending/captured contribution (same donor + campaign + amount + time window) and return the existing one. Alternatively, the UI disables the submit button after first click — both defences needed. +- [x] Donor contributes to a campaign past its deadline: Campaign status validation must occur before capture. Contribution to a closed/expired campaign must be rejected with `CAMPAIGN_NOT_ACCEPTING_CONTRIBUTIONS`. +- [x] Donor contributes to a campaign they already contributed to: Multiple contributions from the same donor to the same campaign are allowed. No deduplication by donor+campaign alone. +- [x] Donor requests refund during active disbursement processing: This is handled by feat-010/feat-011. For feat-009, the refund path is established; the gate (checking if disbursement is in-flight) is a future concern. + +### Boundary Edge Cases + +- [x] Maximum contributions per campaign: No explicit cap in the domain spec. All contributions proceed as long as the campaign is live. +- [x] Escrow ledger entry count per campaign: No limit. PostgreSQL handles millions of rows without issue. +- [x] Webhook payload size: Stripe webhooks are typically < 10KB. Standard Express JSON body parser limit (100KB default) is adequate. +- [x] Idempotency key collision: Contribution UUIDs are globally unique (`gen_random_uuid()`). Collision probability is negligible (1 in 2^122). +- [x] Webhook event ID reuse across Stripe accounts: Webhook event IDs are globally unique within a Stripe account. For the local demo (single Stripe account), this is not a concern. + +## 6. Recommendations + +### Must-Haves for Spec + +1. **PaymentGatewayPort interface** must define `capturePayment`, `refundPayment`, `getPaymentStatus`. The spec should also include a `processWebhookEvent` method with normalised event output. +2. **Mock adapter token conventions**: Define specific test tokens (e.g., `tok_visa` → success, `tok_chargeDeclined` → card declined) so tests are predictable. +3. **Atomic DB write + event emit**: The spec must require that contribution status update + escrow ledger insert + event store append all happen within a single database transaction. +4. **Stripe idempotency keys**: Contribution UUID must be the idempotency key for all Stripe API calls. +5. **Database write failure after Stripe capture**: The spec must define the compensating action (attempt Stripe refund + log critical error). +6. **Webhook endpoint security**: Must document the signature verification as the first action — before any deserialization of the payload body. +7. **Processed webhook deduplication table**: A `processed_webhook_events` table (or equivalent) is needed to store processed event IDs for idempotency checks. +8. **Environment variable list**: `MOCK_PAYMENTS`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY`. + +### Watch-Outs + +1. **Dual-write failure**: Stripe captures funds but DB write fails. Without a compensating Stripe refund, funds are orphaned. This is the highest-risk failure mode. +2. **Timeout ambiguity**: A Stripe API timeout does NOT mean the charge failed — it means the response was lost. The contribution must be marked `failed` from the platform perspective, and the caller must be notified that they should check their card statement. A recovery mechanism (checking Stripe for existing PaymentIntents by idempotency key) is ideal but out of scope for the mock demo. +3. **Mock in production**: The `MOCK_PAYMENTS` flag must only work in dev/test. Add a startup check. +4. **Webhook body must be raw**: Stripe signature verification requires the raw bytes of the webhook body. If Express parses JSON before the signature check, the signature will fail. The webhook route must use `express.raw()` body parser, not `express.json()`. +5. **Contribution status update timing**: The contribution record should be created in `pending_capture` state BEFORE calling Stripe, not after. This ensures the record exists even if the gateway call crashes partway through. + +### Suggested Approach + +1. Create the `Contribution` domain entity with the state machine methods. +2. Define `PaymentGatewayPort` interface in the payments ports directory. +3. Implement `MockPaymentGatewayAdapter` in the payments adapters directory. +4. Implement `StripePaymentGatewayAdapter` (skeleton — real SDK calls behind the interface, selected via env var). +5. Create `PaymentAppService` that: + a. Creates a `pending_capture` contribution record + b. Calls `PaymentGatewayPort.capturePayment()` + c. On success: transitions to `captured`, inserts escrow ledger entry, emits event — all in one transaction + d. On failure: transitions to `failed`, emits event +6. Create `POST /api/v1/payments/capture` endpoint. +7. Create `POST /api/v1/payments/webhook` endpoint with raw body parser. +8. Wire up in `composition-root.ts` using `MOCK_PAYMENTS` flag. diff --git a/.claude/prds/feat-009-spec.md b/.claude/prds/feat-009-spec.md new file mode 100644 index 0000000..cb8d97c --- /dev/null +++ b/.claude/prds/feat-009-spec.md @@ -0,0 +1,814 @@ +# PRD: feat-009 — Payment Processing (Mock Gateway) + +> Implementation-ready specification. Generated by Spec Writer. +> Feature brief: feat-009-payment-processing.md +> Research: feat-009-research.md + +## Overview + +This feature establishes the payment processing architecture for Mars Mission Fund: a `PaymentGatewayPort` interface that wraps all gateway interaction, a mock adapter (default for local dev) and skeleton Stripe adapter, a contribution state machine, and an append-only escrow ledger. The real Stripe SDK lives behind the adapter — application and domain code never reference it directly. This pattern is the financial foundation all subsequent payment-related features depend on. + +--- + +## User Stories & Acceptance Criteria + +### US-001: Platform captures a contribution payment + +**As** the platform +**I want** to capture a donor's payment via a gateway adapter +**So that** contributed funds are held in the campaign's escrow account + +**Acceptance Criteria:** + +- **Given** a donor has a valid payment method token and the campaign is live, **When** `POST /api/v1/payments/capture` is called with `campaign_id` and `payment_method_token`, **Then** the contribution is created in `captured` state, a `contribution` entry is written to `escrow_ledger`, a `PAYMENT.CONTRIBUTION_CAPTURED` event is appended, and the response returns `201` with the serialised contribution. +- **Given** the payment gateway returns a card-declined error, **When** `POST /api/v1/payments/capture` is called, **Then** the contribution is created in `failed` state, no escrow ledger entry is created, a `PAYMENT.CONTRIBUTION_FAILED` event is appended, and the response returns `402` with error code `CARD_DECLINED`. +- **Given** the same `payment_method_token` is submitted twice (duplicate request), **When** the second request arrives and the contribution is already in `captured` state, **Then** the existing captured contribution is returned idempotently with `200` and no second charge is issued. +- **Given** the `campaign_id` is missing from the request body, **When** `POST /api/v1/payments/capture` is called, **Then** the response is `400 VALIDATION_ERROR` before any domain logic executes. +- **Given** the `amount_cents` is zero or negative, **When** `POST /api/v1/payments/capture` is called, **Then** the response is `400 INVALID_AMOUNT` — no gateway call is made. +- **Given** the `amount_cents` is below 100 (less than $1.00), **When** `POST /api/v1/payments/capture` is called, **Then** the response is `400 INVALID_AMOUNT` — minimum is 100 cents. +- **Given** no Clerk JWT is present in the request, **When** `POST /api/v1/payments/capture` is called, **Then** the response is `401 UNAUTHENTICATED`. + +### US-002: Platform receives and processes Stripe webhooks + +**As** the platform +**I want** to receive and process payment gateway webhook events +**So that** contribution state remains consistent with the actual gateway state + +**Acceptance Criteria:** + +- **Given** a valid Stripe `payment_intent.succeeded` webhook with a correct signature, **When** `POST /api/v1/payments/webhook` is called, **Then** the event is processed idempotently, contribution state is updated if not already `captured`, and the response is `200 OK`. +- **Given** a webhook with an invalid or missing signature, **When** `POST /api/v1/payments/webhook` is called, **Then** the response is `400` with error code `INVALID_WEBHOOK_SIGNATURE` and no processing occurs. +- **Given** the same webhook event ID has already been processed, **When** the same event arrives again, **Then** the response is `200 OK` with no state changes (idempotent duplicate handling). +- **Given** an unrecognised webhook event type, **When** the event is received with a valid signature, **Then** the response is `200 OK`, the event is logged as a warning, and no processing occurs. + +### US-003: Platform queries escrow balance for a campaign + +**As** the platform +**I want** to compute the current escrow balance for a campaign +**So that** the campaign progress and disbursement eligibility can be determined + +**Acceptance Criteria:** + +- **Given** a campaign with multiple contributions, refunds, and disbursements in the escrow ledger, **When** `GET /api/v1/campaigns/:campaignId/escrow-balance` is called by an authenticated administrator, **Then** the response is `200` with the computed balance in cents as a string and the ledger entry count. +- **Given** a campaign with no ledger entries, **When** the escrow balance is queried, **Then** the balance is `"0"`. +- **Given** a non-administrator role, **When** the escrow balance endpoint is called, **Then** the response is `403 UNAUTHORIZED`. + +--- + +## Data Model + +### Existing Tables (No Changes Required) + +Both migration files for this feature's data model already exist: + +#### `contributions` (migration `20260304000006_create_contributions.sql`) + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| id | UUID | NOT NULL | gen_random_uuid() | Primary key | +| donor_id | UUID | NOT NULL | — | FK to accounts.id | +| campaign_id | UUID | NOT NULL | — | FK to campaigns.id | +| amount_cents | BIGINT | NOT NULL | — | Contribution amount in cents (integer) | +| status | TEXT | NOT NULL | `'pending_capture'` | State machine status | +| gateway_reference | TEXT | NULL | — | Stripe PaymentIntent ID after capture | +| created_at | TIMESTAMPTZ | NOT NULL | NOW() | | +| updated_at | TIMESTAMPTZ | NOT NULL | NOW() | | + +**Constraints:** +- `chk_contributions_amount`: `amount_cents > 0` +- `chk_contributions_status`: `status IN ('pending_capture', 'captured', 'failed', 'refunded', 'partially_refunded')` +- FK: `donor_id → accounts(id)` ON DELETE RESTRICT +- FK: `campaign_id → campaigns(id)` ON DELETE RESTRICT + +**Indexes:** `donor_id`, `campaign_id`, `status` + +#### `escrow_ledger` (migration `20260304000007_create_escrow_ledger.sql`) + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| id | UUID | NOT NULL | gen_random_uuid() | Primary key | +| campaign_id | UUID | NOT NULL | — | FK to campaigns.id | +| entry_type | TEXT | NOT NULL | — | Type of ledger entry | +| amount_cents | BIGINT | NOT NULL | — | Always positive; sign semantics from entry_type | +| contribution_id | UUID | NULL | — | Reference to contributions.id (no FK — intentional) | +| disbursement_id | UUID | NULL | — | Reference to future disbursements.id | +| description | TEXT | NULL | — | Human-readable description | +| created_at | TIMESTAMPTZ | NOT NULL | NOW() | | + +**Constraints:** +- `chk_escrow_ledger_entry_type`: `entry_type IN ('contribution', 'disbursement', 'refund', 'interest_credit', 'interest_debit')` +- FK: `campaign_id → campaigns(id)` ON DELETE RESTRICT +- Append-only: BEFORE UPDATE/DELETE triggers raise exception + +**Indexes:** `campaign_id`, `contribution_id` + +### New Table: `processed_webhook_events` + +Needed for idempotent webhook processing. Migration file: `20260305000009_create_processed_webhook_events.sql` + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| id | UUID | NOT NULL | gen_random_uuid() | Primary key | +| event_id | TEXT | NOT NULL | — | Gateway event ID (e.g., Stripe `evt_xxx`) | +| event_type | TEXT | NOT NULL | — | Event type string (e.g., `payment_intent.succeeded`) | +| processed_at | TIMESTAMPTZ | NOT NULL | NOW() | When the event was processed | + +**Constraints:** +- UNIQUE: `event_id` — prevents duplicate processing + +**Indexes:** +- `idx_processed_webhook_events_event_id` on `event_id` (UNIQUE) + +**Note:** No `updated_at` — this is effectively append-only. No UPDATE trigger needed. + +**Migration:** +```sql +-- migrate:up +BEGIN; + +CREATE TABLE processed_webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_processed_webhook_events_event_id ON processed_webhook_events (event_id); + +COMMIT; + +-- migrate:down +BEGIN; + +DROP TABLE IF EXISTS processed_webhook_events; + +COMMIT; +``` + +--- + +## Domain Model + +### Entities + +#### `Contribution` + +**File:** `packages/backend/src/payments/domain/contribution.ts` + +**Properties:** +| Property | Type | Description | +|----------|------|-------------| +| id | string (UUID) | Unique identifier | +| donorId | string (UUID) | Account ID of the contributing donor | +| campaignId | string (UUID) | Campaign receiving the contribution | +| amountCents | number | Contribution amount in integer cents (> 0) | +| status | `ContributionStatus` | Current state machine state | +| gatewayReference | string \| null | Stripe PaymentIntent ID (null until captured) | +| createdAt | Date | Creation timestamp | +| updatedAt | Date | Last state change timestamp | + +**Types:** +```typescript +export type ContributionStatus = + | 'pending_capture' + | 'captured' + | 'failed' + | 'refunded' + | 'partially_refunded'; +``` + +**Factory method `create(input: CreateContributionInput): Contribution`:** +- Validates: `amountCents` is an integer, `amountCents >= 100` (minimum $1.00) +- Validates: `donorId` is a non-empty string +- Validates: `campaignId` is a non-empty string +- Initial status: `'pending_capture'` +- `gatewayReference`: null +- Throws: `InvalidContributionAmountError` if amount < 100 or not an integer +- Throws: `InvalidContributionDataError` if required fields missing + +**Reconstitution `reconstitute(data: ContributionData): Contribution`:** +- No validation — data from trusted persistence layer + +**Business methods:** +- `capture(gatewayReference: string): Contribution` — transitions `pending_capture → captured`, sets `gatewayReference`. Throws `InvalidContributionStateError` if not in `pending_capture`. +- `fail(): Contribution` — transitions `pending_capture → failed`. Throws `InvalidContributionStateError` if not in `pending_capture`. +- `refund(): Contribution` — transitions `captured → refunded` or `partially_refunded → refunded`. Throws `InvalidContributionStateError` if current status is not `captured` or `partially_refunded`. +- `partiallyRefund(): Contribution` — transitions `captured → partially_refunded`. Throws `InvalidContributionStateError` if not in `captured`. + +**State machine (valid transitions only):** +``` +pending_capture → captured (via capture()) +pending_capture → failed (via fail()) +captured → refunded (via refund()) +captured → partially_refunded (via partiallyRefund()) +partially_refunded → refunded (via refund()) +``` + +All other transitions throw `InvalidContributionStateError`. + +#### `EscrowLedgerEntry` + +**File:** `packages/backend/src/payments/domain/escrow-ledger-entry.ts` + +**Properties:** +| Property | Type | Description | +|----------|------|-------------| +| id | string (UUID) | Unique identifier | +| campaignId | string (UUID) | Campaign this entry belongs to | +| entryType | `EscrowEntryType` | Type of movement | +| amountCents | number | Always positive integer — sign from entryType | +| contributionId | string \| null | Associated contribution (for contribution/refund entries) | +| disbursementId | string \| null | Associated disbursement (for disbursement entries) | +| description | string \| null | Human-readable description | +| createdAt | Date | Entry creation timestamp | + +**Types:** +```typescript +export type EscrowEntryType = + | 'contribution' + | 'disbursement' + | 'refund' + | 'interest_credit' + | 'interest_debit'; +``` + +**Factory method `create(input: CreateEscrowLedgerEntryInput): EscrowLedgerEntry`:** +- Validates: `amountCents` is a positive integer (> 0) +- Validates: `campaignId` is a non-empty string +- Validates: `entryType` is a valid `EscrowEntryType` +- Throws: `InvalidEscrowEntryError` if validation fails + +**Reconstitution `reconstitute(data: EscrowLedgerEntryData): EscrowLedgerEntry`:** +- No validation + +**Note:** No business methods — the entry is immutable once created. The ledger is append-only. + +### Value Objects + +No new value objects are introduced. Monetary amounts are `number` (integer cents) throughout, per MMF domain rules. `ContributionStatus` and `EscrowEntryType` are union type literals defined in their respective entity files. + +### Domain Services + +No domain services are needed for this feature. The state machine logic lives on the `Contribution` entity. The escrow balance computation is a repository query. + +### Domain Errors + +**File:** `packages/backend/src/payments/domain/payment-errors.ts` + +```typescript +export class InvalidContributionAmountError extends DomainError { + constructor(message = 'Contribution amount must be at least 100 cents ($1.00).') { + super('INVALID_AMOUNT', message); + } +} + +export class InvalidContributionDataError extends DomainError { + constructor(message: string) { + super('INVALID_CONTRIBUTION_DATA', message); + } +} + +export class InvalidContributionStateError extends DomainError { + constructor(fromState: string, toState: string) { + super( + 'INVALID_CONTRIBUTION_STATE', + `Cannot transition contribution from '${fromState}' to '${toState}'.`, + ); + } +} + +export class ContributionNotFoundError extends DomainError { + constructor() { + super('CONTRIBUTION_NOT_FOUND', 'Contribution not found.'); + } +} + +export class InvalidEscrowEntryError extends DomainError { + constructor(message: string) { + super('INVALID_ESCROW_ENTRY', message); + } +} + +export class PaymentGatewayError extends DomainError { + constructor( + code: string, + message: string, + public readonly gatewayCode?: string, + ) { + super(code, message); + } +} + +export class CardDeclinedError extends PaymentGatewayError { + constructor(message = 'Your card was declined. Please check your details and try again.') { + super('CARD_DECLINED', message, 'card_declined'); + } +} + +export class InsufficientFundsError extends PaymentGatewayError { + constructor(message = 'Insufficient funds. Please use a different payment method.') { + super('INSUFFICIENT_FUNDS', message, 'insufficient_funds'); + } +} + +export class InvalidWebhookSignatureError extends DomainError { + constructor() { + super('INVALID_WEBHOOK_SIGNATURE', 'Webhook signature verification failed.'); + } +} +``` + +--- + +## Ports + +### Repository Ports + +#### `ContributionRepository` + +**File:** `packages/backend/src/payments/ports/contribution-repository.ts` + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `save` | `contribution: Contribution` | `Promise` | Inserts a new contribution (pending_capture) | +| `update` | `contribution: Contribution, txClient?: TransactionClient` | `Promise` | Updates status and gateway_reference | +| `findById` | `id: string` | `Promise` | Lookup by primary key | +| `findByDonorId` | `donorId: string` | `Promise` | All contributions by a donor | +| `findByCampaignId` | `campaignId: string` | `Promise` | All contributions for a campaign | + +#### `EscrowLedgerRepository` + +**File:** `packages/backend/src/payments/ports/escrow-ledger-repository.ts` + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `appendEntry` | `entry: EscrowLedgerEntry, txClient?: TransactionClient` | `Promise` | Appends one ledger entry (INSERT only — no UPDATE) | +| `getBalanceCents` | `campaignId: string` | `Promise` | Computes SUM of credits minus SUM of debits | +| `getEntriesForCampaign` | `campaignId: string` | `Promise` | All ledger entries for a campaign | + +**Balance computation SQL (for implementation guidance):** +```sql +SELECT + COALESCE(SUM(CASE WHEN entry_type IN ('contribution', 'interest_credit') THEN amount_cents ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN entry_type IN ('disbursement', 'refund', 'interest_debit') THEN amount_cents ELSE 0 END), 0) + AS balance_cents +FROM escrow_ledger +WHERE campaign_id = $1 +``` + +#### `ProcessedWebhookEventRepository` + +**File:** `packages/backend/src/payments/ports/processed-webhook-event-repository.ts` + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `hasBeenProcessed` | `eventId: string` | `Promise` | Returns true if event ID already in table | +| `markAsProcessed` | `eventId: string, eventType: string, txClient?: TransactionClient` | `Promise` | Inserts record; idempotent on UNIQUE violation | + +### External Service Ports + +#### `PaymentGatewayPort` + +**File:** `packages/backend/src/payments/ports/payment-gateway-port.ts` + +```typescript +export interface CapturePaymentInput { + readonly contributionId: string; // Used as Stripe idempotency key + readonly amountCents: number; + readonly currency: 'usd'; // Always 'usd' — single currency + readonly paymentMethodToken: string; + readonly metadata: { + readonly campaignId: string; + readonly donorId: string; + readonly contributionId: string; + }; +} + +export interface CapturePaymentResult { + readonly success: true; + readonly gatewayReference: string; // Stripe PaymentIntent ID +} + +export interface CapturePaymentFailure { + readonly success: false; + readonly errorCode: string; // e.g., 'CARD_DECLINED', 'INSUFFICIENT_FUNDS' + readonly errorMessage: string; +} + +export type CapturePaymentOutcome = CapturePaymentResult | CapturePaymentFailure; + +export interface RefundPaymentInput { + readonly gatewayReference: string; // Stripe PaymentIntent ID + readonly amountCents: number; // Amount to refund in cents + readonly reason: 'requested_by_customer' | 'duplicate' | 'fraudulent'; +} + +export interface RefundPaymentResult { + readonly success: true; + readonly refundReference: string; // Stripe Refund ID +} + +export type RefundPaymentOutcome = RefundPaymentResult | { success: false; errorMessage: string }; + +export interface PaymentStatusResult { + readonly status: 'succeeded' | 'processing' | 'failed' | 'canceled'; + readonly gatewayReference: string; +} + +export interface NormalisedWebhookEvent { + readonly eventId: string; + readonly eventType: + | 'payment_intent.succeeded' + | 'payment_intent.payment_failed' + | 'charge.refunded' + | 'charge.refund.updated' + | 'unknown'; + readonly contributionId: string | null; // From PaymentIntent metadata + readonly gatewayReference: string | null; // PaymentIntent ID + readonly amountCents: number | null; +} + +export interface PaymentGatewayPort { + capturePayment(input: CapturePaymentInput): Promise; + refundPayment(input: RefundPaymentInput): Promise; + getPaymentStatus(gatewayReference: string): Promise; + parseWebhookEvent(rawBody: Buffer, signature: string): Promise; +} +``` + +**Mock adapter behaviour** (`MockPaymentGatewayAdapter`): + +**File:** `packages/backend/src/payments/adapters/mock/mock-payment-gateway-adapter.ts` + +- `capturePayment`: Inspects `paymentMethodToken`: + - Token contains `'tok_visa'` or `'tok_success'` → returns `{ success: true, gatewayReference: 'pi_mock_' + contributionId }` + - Token contains `'tok_chargeDeclined'` → returns `{ success: false, errorCode: 'CARD_DECLINED', errorMessage: 'Your card was declined.' }` + - Token contains `'tok_insufficient_funds'` → returns `{ success: false, errorCode: 'INSUFFICIENT_FUNDS', errorMessage: 'Insufficient funds.' }` + - All other tokens → returns `{ success: true, gatewayReference: 'pi_mock_' + contributionId }` (default success) +- `refundPayment`: Always returns `{ success: true, refundReference: 're_mock_' + Date.now() }` +- `getPaymentStatus`: Returns `{ status: 'succeeded', gatewayReference: input }` for any input +- `parseWebhookEvent`: Parses the raw body as JSON. Returns a `NormalisedWebhookEvent` with `eventType: 'unknown'` for unrecognised events. For mock testing, the body should contain `{ id, type, data: { object: { id, metadata } } }`. + +**Stripe adapter skeleton** (`StripePaymentGatewayAdapter`): + +**File:** `packages/backend/src/payments/adapters/stripe/stripe-payment-gateway-adapter.ts` + +- Implements `PaymentGatewayPort` using `stripe` Node SDK +- Constructor takes `secretKey: string` and `webhookSecret: string` +- `capturePayment`: Creates a Stripe PaymentIntent with `confirm: true`, uses `contributionId` as idempotency key +- `parseWebhookEvent`: Calls `stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)` — throws `InvalidWebhookSignatureError` if verification fails +- Maps Stripe card error codes to MMF error codes (`card_declined → CARD_DECLINED`, `insufficient_funds → INSUFFICIENT_FUNDS`, etc.) +- Full implementation is a stretch goal — the skeleton (class + interface compliance) is the deliverable for feat-009 + +--- + +## Application Service + +### `PaymentAppService` + +**File:** `packages/backend/src/payments/application/payment-app-service.ts` + +**Dependencies (injected):** +- `contributionRepository: ContributionRepository` +- `escrowLedgerRepository: EscrowLedgerRepository` +- `processedWebhookEventRepository: ProcessedWebhookEventRepository` +- `paymentGateway: PaymentGatewayPort` +- `eventStore: EventStorePort` +- `transactionPort: TransactionPort` +- `logger: Logger` + +#### `captureContribution(input: CaptureContributionInput): Promise` + +```typescript +interface CaptureContributionInput { + readonly donorId: string; + readonly campaignId: string; + readonly amountCents: number; + readonly paymentMethodToken: string; +} +``` + +**Orchestration steps:** + +1. Validate `amountCents >= 100`. Throw `InvalidContributionAmountError` if not. +2. Create a `Contribution` entity via `Contribution.create({ donorId, campaignId, amountCents })`. +3. Persist the contribution in `pending_capture` state: `await contributionRepository.save(contribution)`. +4. Call `await paymentGateway.capturePayment({ contributionId: contribution.id, amountCents, currency: 'usd', paymentMethodToken, metadata: { campaignId, donorId, contributionId: contribution.id } })`. +5. **If capture succeeded** (`outcome.success === true`): + a. Transition entity: `capturedContribution = contribution.capture(outcome.gatewayReference)`. + b. Create escrow ledger entry: `EscrowLedgerEntry.create({ campaignId, entryType: 'contribution', amountCents, contributionId: contribution.id, description: 'Contribution captured' })`. + c. Within `transactionPort.withTransaction`: + - `contributionRepository.update(capturedContribution, txClient)` + - `escrowLedgerRepository.appendEntry(ledgerEntry, txClient)` + - `eventStore.getNextSequenceNumber(contribution.id, txClient)` → seq + - `eventStore.append({ eventType: 'PAYMENT.CONTRIBUTION_CAPTURED', aggregateId: contribution.id, aggregateType: 'contribution', sequenceNumber: seq, correlationId: contribution.id, sourceService: 'payment-service', payload: { donorId, campaignId, amountCents, gatewayReference: outcome.gatewayReference } }, txClient)` + d. Return `capturedContribution`. +6. **If capture failed** (`outcome.success === false`): + a. Transition entity: `failedContribution = contribution.fail()`. + b. Persist: `await contributionRepository.update(failedContribution)`. + c. Append event: `PAYMENT.CONTRIBUTION_FAILED` with `{ donorId, campaignId, amountCents, errorCode: outcome.errorCode }`. + d. Throw a `PaymentGatewayError` with `outcome.errorCode` and `outcome.errorMessage`. +7. **If DB write fails after successful gateway capture** (step 5c throws): + a. Log a `fatal` level error: `{ contributionId, gatewayReference }` — "CRITICAL: Stripe capture succeeded but DB write failed. Manual investigation required." + b. Attempt `await paymentGateway.refundPayment({ gatewayReference: outcome.gatewayReference, amountCents, reason: 'duplicate' })` as a compensating action. + c. Log the compensating refund outcome. + d. Re-throw the original DB error. + +#### `processWebhookEvent(rawBody: Buffer, signature: string): Promise` + +**Orchestration steps:** + +1. Call `const event = await paymentGateway.parseWebhookEvent(rawBody, signature)`. Throws `InvalidWebhookSignatureError` if signature invalid — let it propagate to the controller which returns `400`. +2. If `event.eventType === 'unknown'`, log warning and return. +3. Check idempotency: `const alreadyProcessed = await processedWebhookEventRepository.hasBeenProcessed(event.eventId)`. If true, log info and return. +4. Within `transactionPort.withTransaction`: + a. Switch on `event.eventType`: + - `'payment_intent.succeeded'`: If `event.contributionId` exists, look up contribution. If status is not `captured`, call `contribution.capture(event.gatewayReference)`, update in DB, append `PAYMENT.CONTRIBUTION_CAPTURED` event, insert escrow ledger entry. + - `'payment_intent.payment_failed'`: If `event.contributionId` exists and contribution is `pending_capture`, call `contribution.fail()`, update in DB, append `PAYMENT.CONTRIBUTION_FAILED` event. + - `'charge.refunded'`: Log for future feat-010 processing. No state change in feat-009 scope. + - `'charge.refund.updated'`: Log. No state change in feat-009 scope. + b. `processedWebhookEventRepository.markAsProcessed(event.eventId, event.eventType, txClient)` + +#### `getEscrowBalance(campaignId: string): Promise<{ balanceCents: number; entryCount: number }>` + +**Orchestration steps:** + +1. `const balanceCents = await escrowLedgerRepository.getBalanceCents(campaignId)` +2. `const entries = await escrowLedgerRepository.getEntriesForCampaign(campaignId)` +3. Return `{ balanceCents, entryCount: entries.length }` + +--- + +## API Endpoints + +### `POST /api/v1/payments/capture` + +**Description:** Initiates payment capture for a contribution. Called by the contribution flow (feat-008) after the donor provides a payment method token from Stripe Elements. + +**Auth:** Required (Clerk JWT). `donor_id` is taken from `req.authContext.userId` — never from the request body. + +**Roles:** Backer (any authenticated user with a valid account) + +**Request body:** +```json +{ + "campaign_id": "uuid — required", + "amount_cents": "integer — required, minimum 100", + "payment_method_token": "string — required, non-empty" +} +``` + +**Validation rules (Zod schema):** +- `campaign_id`: `z.string().uuid()` +- `amount_cents`: `z.number().int().min(100)` +- `payment_method_token`: `z.string().min(1)` + +**Success response:** `201 Created` +```json +{ + "data": { + "id": "uuid", + "donor_id": "uuid", + "campaign_id": "uuid", + "amount_cents": "string", + "status": "captured", + "gateway_reference": "pi_xxx", + "created_at": "ISO 8601", + "updated_at": "ISO 8601" + } +} +``` + +**Note:** `amount_cents` is serialised as a string in the JSON response per MMF financial data rules. + +**Error responses:** +| Status | Code | When | +|--------|------|------| +| 400 | `VALIDATION_ERROR` | Zod schema validation fails | +| 400 | `INVALID_AMOUNT` | Amount < 100 cents (domain validation) | +| 401 | `UNAUTHENTICATED` | No valid Clerk JWT | +| 402 | `CARD_DECLINED` | Gateway returns card declined | +| 402 | `INSUFFICIENT_FUNDS` | Gateway returns insufficient funds | +| 402 | `PAYMENT_FAILED` | Other gateway failure | +| 404 | `CAMPAIGN_NOT_FOUND` | campaign_id does not exist | +| 500 | `INTERNAL_ERROR` | Unhandled server error | + +**Implementation note:** HTTP 402 ("Payment Required") is the correct status for gateway-level payment rejections. The error body follows the standard format: `{ error: { code, message, correlation_id } }`. + +--- + +### `POST /api/v1/payments/webhook` + +**Description:** Receives payment gateway webhook events. Stripe posts events to this endpoint. + +**Auth:** None (no Clerk JWT). Authentication is via Stripe webhook signature verification. + +**CRITICAL:** This endpoint MUST use `express.raw({ type: 'application/json' })` as its body parser — NOT `express.json()`. Stripe signature verification requires the raw, unparsed request body bytes. + +**Request headers:** +- `stripe-signature`: Stripe HMAC-SHA256 signature + +**Request body:** Raw bytes from Stripe (JSON-parseable after verification) + +**Success response:** `200 OK` +```json +{ "received": true } +``` + +**Error responses:** +| Status | Code | When | +|--------|------|------| +| 400 | `INVALID_WEBHOOK_SIGNATURE` | Signature verification fails | +| 500 | `INTERNAL_ERROR` | Unhandled processing error | + +**Implementation note:** The controller must catch `InvalidWebhookSignatureError` and return `400`. All other errors return `500`. The response is always `200` for successfully processed (or already-processed) events. + +--- + +### `GET /api/v1/campaigns/:campaignId/escrow-balance` + +**Description:** Returns the current escrow balance for a campaign. Used internally for disbursement eligibility checks. + +**Auth:** Required (Clerk JWT). + +**Roles:** Administrator, Super Administrator only. + +**Path parameters:** +- `campaignId`: UUID + +**Success response:** `200 OK` +```json +{ + "data": { + "campaign_id": "uuid", + "balance_cents": "string", + "entry_count": 42 + } +} +``` + +**Note:** `balance_cents` is serialised as a string per MMF financial data rules. + +**Error responses:** +| Status | Code | When | +|--------|------|------| +| 401 | `UNAUTHENTICATED` | No valid Clerk JWT | +| 403 | `UNAUTHORIZED` | Caller does not have Administrator or Super Administrator role | +| 404 | `CAMPAIGN_NOT_FOUND` | campaignId does not exist (optional: if campaign check is added) | + +--- + +## Frontend + +This feature is primarily backend infrastructure. The payment UI lives in feat-008 (Contribution Flow). No new frontend pages or components are required for feat-009. + +**What feat-008 will consume from feat-009:** +- `POST /api/v1/payments/capture` — called after Stripe Elements returns a payment token +- Error codes `CARD_DECLINED`, `INSUFFICIENT_FUNDS`, `PAYMENT_FAILED` — displayed in the contribution form error state +- The `amount_cents` field in the response — displayed in the contribution confirmation + +**Environment variable required on frontend build:** +- `VITE_STRIPE_PUBLISHABLE_KEY` — used by Stripe Elements (feat-008). Must be documented in `.env.example` for this feature. + +--- + +## Environment Variables + +The following variables must be added to `.env.example`: + +```bash +# Payment Gateway +MOCK_PAYMENTS=true # Set to false to use real Stripe +STRIPE_SECRET_KEY=your-stripe-secret-key +STRIPE_WEBHOOK_SECRET=whsec_placeholder +STRIPE_PUBLISHABLE_KEY=pk_test_placeholder +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_placeholder +``` + +--- + +## Edge Cases + +| # | Scenario | Expected Behaviour | Test Type | +|---|----------|-------------------|-----------| +| 1 | Amount = 0 | Zod validation rejects before domain: `400 VALIDATION_ERROR` | Unit | +| 2 | Amount = 99 cents | Domain rejects: `400 INVALID_AMOUNT` — minimum is 100 cents | Unit | +| 3 | Amount = 100 cents | Accepted — minimum valid amount | Unit | +| 4 | Amount is a float (e.g., 10.50) | Zod `z.number().int()` rejects: `400 VALIDATION_ERROR` | Unit | +| 5 | Negative amount | Zod `z.number().int().min(100)` rejects: `400 VALIDATION_ERROR` | Unit | +| 6 | Very large amount (e.g., 999999999900 cents) | Accepted — BIGINT handles up to 9.2×10^18 cents | Unit | +| 7 | `campaign_id` is not a valid UUID | Zod `.uuid()` rejects: `400 VALIDATION_ERROR` | Unit | +| 8 | Missing `payment_method_token` | Zod rejects: `400 VALIDATION_ERROR` | Unit | +| 9 | Token = `tok_chargeDeclined` (mock) | Gateway returns failure, contribution is `failed`, `402 CARD_DECLINED` | Integration | +| 10 | Token = `tok_insufficient_funds` (mock) | Gateway returns failure, contribution is `failed`, `402 INSUFFICIENT_FUNDS` | Integration | +| 11 | Token = `tok_visa` (mock) | Gateway returns success, contribution is `captured`, escrow entry created | Integration | +| 12 | Same contribution submitted twice (duplicate token) | Second request: if contribution already `captured`, return `200` with existing contribution — no second gateway call | Integration | +| 13 | Webhook arrives with invalid signature | `400 INVALID_WEBHOOK_SIGNATURE`, no processing | Integration | +| 14 | Webhook event already in `processed_webhook_events` | `200 OK`, no state changes, log info | Integration | +| 15 | Webhook with unknown event type | `200 OK`, log warning, no state changes | Integration | +| 16 | Webhook `payment_intent.succeeded` but contribution already `captured` | Idempotent: skip state transition, still mark webhook as processed | Integration | +| 17 | DB write fails after successful Stripe capture | Log FATAL, attempt compensating Stripe refund, re-throw error | Integration | +| 18 | `payment_method_token` is an empty string | Zod `z.string().min(1)` rejects: `400 VALIDATION_ERROR` | Unit | +| 19 | No auth JWT on capture endpoint | `401 UNAUTHENTICATED` | Integration | +| 20 | Non-admin role on escrow balance endpoint | `403 UNAUTHORIZED` | Integration | +| 21 | Escrow balance for campaign with no entries | Returns `{ balance_cents: "0", entry_count: 0 }` | Integration | +| 22 | Escrow balance with mixed contributions, refunds, disbursements | Correct algebraic sum: credits minus debits | Integration | +| 23 | Contribution entity: invalid state transition (captured → pending_capture) | Throws `InvalidContributionStateError` | Unit | +| 24 | Contribution entity: refund from `failed` state | Throws `InvalidContributionStateError` | Unit | +| 25 | `express.json()` parser on webhook route (misconfiguration) | Stripe signature check fails — documented as known misconfiguration in test comment | Integration | + +--- + +## Testing Requirements + +### Unit Tests + +**File:** `packages/backend/src/payments/domain/__tests__/contribution.test.ts` + +- [ ] `Contribution.create` — happy path with valid input +- [ ] `Contribution.create` — rejects `amountCents < 100` with `InvalidContributionAmountError` +- [ ] `Contribution.create` — rejects `amountCents = 0` with `InvalidContributionAmountError` +- [ ] `Contribution.create` — rejects non-integer `amountCents` (e.g., 10.5) with `InvalidContributionAmountError` +- [ ] `Contribution.create` — rejects empty `donorId` with `InvalidContributionDataError` +- [ ] `Contribution.create` — rejects empty `campaignId` with `InvalidContributionDataError` +- [ ] `Contribution.capture()` — transitions `pending_capture → captured`, sets `gatewayReference` +- [ ] `Contribution.capture()` — throws `InvalidContributionStateError` if already `captured` +- [ ] `Contribution.capture()` — throws `InvalidContributionStateError` if `failed` +- [ ] `Contribution.fail()` — transitions `pending_capture → failed` +- [ ] `Contribution.fail()` — throws `InvalidContributionStateError` if already `captured` +- [ ] `Contribution.refund()` — transitions `captured → refunded` +- [ ] `Contribution.refund()` — transitions `partially_refunded → refunded` +- [ ] `Contribution.refund()` — throws `InvalidContributionStateError` from `pending_capture` +- [ ] `Contribution.refund()` — throws `InvalidContributionStateError` from `failed` +- [ ] `Contribution.partiallyRefund()` — transitions `captured → partially_refunded` +- [ ] `Contribution.partiallyRefund()` — throws `InvalidContributionStateError` from any non-`captured` state + +**File:** `packages/backend/src/payments/domain/__tests__/escrow-ledger-entry.test.ts` + +- [ ] `EscrowLedgerEntry.create` — happy path with all valid entry types +- [ ] `EscrowLedgerEntry.create` — rejects zero `amountCents` +- [ ] `EscrowLedgerEntry.create` — rejects negative `amountCents` +- [ ] `EscrowLedgerEntry.create` — rejects invalid `entryType` + +**File:** `packages/backend/src/payments/adapters/mock/__tests__/mock-payment-gateway-adapter.test.ts` + +- [ ] `capturePayment` with `tok_visa` — returns success +- [ ] `capturePayment` with `tok_chargeDeclined` — returns failure with `CARD_DECLINED` +- [ ] `capturePayment` with `tok_insufficient_funds` — returns failure with `INSUFFICIENT_FUNDS` +- [ ] `refundPayment` — returns success with refund reference + +### Integration Tests + +**File:** `packages/backend/src/__tests__/payment-capture.test.ts` + +- [ ] `POST /api/v1/payments/capture` — successful capture with mock success token, returns `201` with `captured` contribution +- [ ] `POST /api/v1/payments/capture` — card declined, returns `402 CARD_DECLINED` +- [ ] `POST /api/v1/payments/capture` — insufficient funds, returns `402 INSUFFICIENT_FUNDS` +- [ ] `POST /api/v1/payments/capture` — unauthenticated, returns `401` +- [ ] `POST /api/v1/payments/capture` — missing `campaign_id`, returns `400 VALIDATION_ERROR` +- [ ] `POST /api/v1/payments/capture` — `amount_cents` below 100, returns `400` +- [ ] `POST /api/v1/payments/capture` — `amount_cents` = 0, returns `400` +- [ ] `POST /api/v1/payments/capture` — non-integer `amount_cents`, returns `400` +- [ ] `POST /api/v1/payments/capture` — verifies escrow ledger entry created on success +- [ ] `POST /api/v1/payments/capture` — verifies no escrow ledger entry on failure + +**File:** `packages/backend/src/__tests__/payment-webhook.test.ts` + +- [ ] `POST /api/v1/payments/webhook` — invalid signature, returns `400 INVALID_WEBHOOK_SIGNATURE` +- [ ] `POST /api/v1/payments/webhook` — `payment_intent.succeeded` event, transitions contribution to `captured` +- [ ] `POST /api/v1/payments/webhook` — `payment_intent.payment_failed` event, transitions contribution to `failed` +- [ ] `POST /api/v1/payments/webhook` — duplicate event (already processed), returns `200` idempotently +- [ ] `POST /api/v1/payments/webhook` — unknown event type, returns `200` with no state changes + +**File:** `packages/backend/src/__tests__/escrow-balance.test.ts` + +- [ ] `GET /api/v1/campaigns/:campaignId/escrow-balance` — returns correct balance for campaign with contributions +- [ ] `GET /api/v1/campaigns/:campaignId/escrow-balance` — returns `"0"` for campaign with no entries +- [ ] `GET /api/v1/campaigns/:campaignId/escrow-balance` — returns correct balance with mixed credits and debits +- [ ] `GET /api/v1/campaigns/:campaignId/escrow-balance` — unauthenticated returns `401` +- [ ] `GET /api/v1/campaigns/:campaignId/escrow-balance` — non-admin role returns `403` + +### E2E Tests + +No E2E tests for feat-009 — this is backend infrastructure. E2E tests for the contribution flow will be written in feat-008 and will exercise this feature's endpoints. + +--- + +## Dependencies + +- **Requires:** feat-002 (database schema foundation — campaigns table, accounts table, escrow_ledger, contributions tables) +- **Blocks:** feat-008 (Contribution Flow — depends on capture endpoint), feat-010 (Campaign Lifecycle — depends on refund infrastructure), feat-011 (Milestone Verification & Settlement — depends on escrow ledger) + +--- + +## Manual Tasks + +- [ ] Create a Stripe test account and obtain `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` for non-mock testing +- [ ] Register webhook endpoint URL in Stripe dashboard (for real integration testing) +- [ ] Add `MOCK_PAYMENTS`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` to `.env.example` + +--- + +## Open Questions + +None — all decisions resolved in L4-004 (L4-004 v0.3): immediate capture, USD only, no stored payment methods, mock-first local dev. diff --git a/.claude/prds/feat-009-validation.md b/.claude/prds/feat-009-validation.md new file mode 100644 index 0000000..7c9b09c --- /dev/null +++ b/.claude/prds/feat-009-validation.md @@ -0,0 +1,281 @@ +# Validation Report: feat-009 — Payment Processing (Mock Gateway) + +> Spec validation results. Generated by Spec Validator. + +## Verdict: CONDITIONAL PASS + +## Summary + +The feat-009 spec is substantially complete and implementation-ready. The domain model, port interfaces, application service orchestration, API endpoints, edge cases, and testing requirements are thoroughly defined and compliant with CLAUDE.md and L4-004. The design spec appropriately scopes itself to the minimal frontend surface area (error messages and status badges only) given this is a backend-infrastructure feature. The migration timestamp collision was self-corrected during validation (changed from `20260305000001` to `20260305000009`), and the migration was updated to include `BEGIN; ... COMMIT;` wrappers. Gotchas and domain knowledge context files were updated with payment-specific patterns. Four minor warnings remain — all advisory for the implementation agent. No automatic-fail triggers are present. The spec is conditionally approved for the implementation backlog. + +--- + +## Checklist Results + +### 1. Completeness + +#### Feature Spec Completeness + +| Item | Status | Notes | +|------|--------|-------| +| User stories with Given/When/Then acceptance criteria | PASS | 3 user stories, 16 acceptance criteria, all specific and testable | +| Data model with column types, constraints, indexes, migration file names | PASS | Migration for `processed_webhook_events` uses timestamp `20260305000009` (corrected during validation from initial `20260305000001` collision). Migration includes `BEGIN; ... COMMIT;` wrappers per pattern. | +| Domain model with entity properties, factory methods, validation rules, error types | PASS | `Contribution` and `EscrowLedgerEntry` entities fully specified; state machine transitions explicitly enumerated; 8 typed domain errors defined | +| Port interfaces with method signatures and mock adapter behaviour | PASS | `ContributionRepository`, `EscrowLedgerRepository`, `ProcessedWebhookEventRepository`, `PaymentGatewayPort` all fully specified; mock adapter behaviour explicitly defined per token value | +| Application service with step-by-step orchestration logic | PASS | `PaymentAppService.captureContribution`, `processWebhookEvent`, `getEscrowBalance` are step-by-step; compensating action for DB failure after Stripe capture is specified | +| API endpoints with request/response shapes, validation rules, and all error responses | PASS | 3 endpoints fully specified; Zod schema rules listed; all error codes and HTTP statuses enumerated | +| Frontend functional requirements with data needs and state management | PASS | Correctly notes feat-009 is backend-only; references feat-008 dependency; documents env vars consumed by frontend | +| Edge cases — every edge case from research document has a defined behaviour | PASS | 25 edge cases in spec table, covering all research edge cases | +| Testing requirements with specific test cases enumerated | PASS | 17 unit tests, 20 integration tests, 0 E2E tests with justification | +| Dependencies listed (requires / blocks) | PASS | feat-002 as dependency; feat-008, feat-010, feat-011 as blocked | +| Manual tasks identified | PASS | 3 manual tasks listed (Stripe account, webhook registration, env vars) | + +#### Design Spec Completeness + +| Item | Status | Notes | +|------|--------|-------| +| Page layout for every view in the feature | PASS | Correctly asserts no new pages; rationale explained | +| Component spec for every UI element with props, typography, spacing | PASS | `PaymentErrorMessage` and `ContributionStatusBadge` fully specified | +| All component states defined: default, empty, loading, error, hover, selected | PASS | Both components have states defined; PaymentErrorMessage covers default + loading retry state | +| Design tokens mapped to specific values from design system | PASS | Token table complete; all tokens are Tier 2 semantic tokens | +| Status badges defined (if applicable) | PASS | 5 contribution statuses defined with background, text, dot tokens | +| AI card content pattern (if applicable) | PASS | Correctly notes no AI card; rationale given | +| Chart/visualisation spec (if applicable) | PASS | Correctly defers to feat-015; rationale given | +| Responsive behaviour for desktop, tablet, mobile | PASS | Both components have responsive notes | +| Animation and transitions defined | PASS | 3 transitions defined; prefers-reduced-motion noted | +| Accessibility checklist complete | PASS | All 8 items checked with contrast ratios verified | + +--- + +### 2. Architecture Compliance + +#### Hexagonal Architecture + +| Item | Status | Notes | +|------|--------|-------| +| Domain entities have no infrastructure imports | PASS | `Contribution` and `EscrowLedgerEntry` entities specified with no infrastructure imports; only typed errors from shared domain | +| All external services are behind port interfaces | PASS | `PaymentGatewayPort` interface defined; Stripe SDK never referenced in domain or application code | +| Mock adapters are specified for every external service port | PASS | `MockPaymentGatewayAdapter` specified with explicit token-to-outcome mapping | +| Application services orchestrate — domain logic stays in domain layer | PASS | `PaymentAppService` calls entity methods; state transitions happen on the entity | +| Repository methods are data access only — no business logic | PASS | All repository methods are CRUD/query operations; balance computation SQL is in the port spec but is pure data aggregation | +| No cross-context domain imports | PASS | Payments context is self-contained; `accounts` and `campaigns` referenced only by ID | +| Feature stays within declared bounded context(s) | PASS | Feature is in `payments` context only | + +#### Data Model + +| Item | Status | Notes | +|------|--------|-------| +| All monetary columns use BIGINT (integer cents) | PASS | `amount_cents BIGINT` in both `contributions` and `escrow_ledger`; `processed_webhook_events` has no monetary columns | +| All tables include created_at and updated_at as TIMESTAMPTZ | WARN | `processed_webhook_events` only has `processed_at` (no `updated_at`) — this is acceptable for an append-only table per pattern. But `contributions` already has both via existing migration. Clarification: append-only tables intentionally omit `updated_at` per the patterns doc. WARN is advisory. | +| All date/time columns use TIMESTAMPTZ | PASS | All timestamps are TIMESTAMPTZ | +| Indexes on every foreign key | PASS | All FK columns in existing migrations are indexed; `processed_webhook_events` FK is the UNIQUE index on `event_id` (no FK to other tables) | +| Indexes on columns used in WHERE clauses | PASS | `event_id` UNIQUE index supports `hasBeenProcessed` lookup | +| Explicit ON DELETE behaviour for every foreign key | PASS | Existing migrations have ON DELETE RESTRICT on all FKs; `processed_webhook_events` has no FKs | +| Parameterised queries only — no string interpolation | PASS | Repository spec shows parameterised SQL; balance computation uses $1 placeholder | +| Migration is append-only — doesn't modify existing migrations | PASS | Spec creates a new migration; existing migrations are unchanged | + +#### Migration Timestamp + +| Item | Status | Notes | +|------|--------|-------| +| New migration timestamp is unique | PASS | Spec uses `20260305000009_create_processed_webhook_events.sql` (corrected from initial collision with `20260305000001_add_onboarding_fields.sql`). All existing migration timestamps confirmed distinct. | + +#### Coding Standards + +| Item | Status | Notes | +|------|--------|-------| +| File naming: kebab-case | PASS | All file paths use kebab-case | +| Class naming: PascalCase | PASS | `PaymentAppService`, `MockPaymentGatewayAdapter`, `StripePaymentGatewayAdapter` | +| Function naming: camelCase | PASS | `captureContribution`, `processWebhookEvent`, `getEscrowBalance` | +| No enums — uses as const or union types | PASS | `ContributionStatus`, `EscrowEntryType` are union literal types | +| No default exports (except React components) | PASS | No default exports in backend; `PaymentErrorMessage` and `ContributionStatusBadge` are React components (default export acceptable) | +| Explicit return types on all exported functions | WARN | The spec describes method signatures but does not explicitly list return types for all repository methods as TypeScript signatures. Implementation agent should ensure explicit return types. | +| Typed domain errors — no generic Error throws | PASS | 8 domain errors defined, all extending `DomainError` | +| Dependency injection via constructor | PASS | `PaymentAppService` constructor lists all dependencies | + +--- + +### 3. Financial Data Rules + +| Item | Status | Notes | +|------|--------|-------| +| All monetary amounts stored as integer cents | PASS | `amount_cents BIGINT` throughout | +| Single currency: USD | PASS | `currency: 'usd'` hardcoded in `CapturePaymentInput`; no multi-currency logic | +| Monetary amounts serialised as strings in JSON | PASS | `amount_cents` field in API responses explicitly noted as string; response example shows `"amount_cents": "string"` | +| No floating point arithmetic anywhere in money chain | PASS | No floating point operations anywhere in the spec; Zod uses `z.number().int()` | +| Escrow ledger entries are append-only and immutable | PASS | Existing migration has BEFORE UPDATE/DELETE triggers; repository port has only `appendEntry` (no update/delete) | +| Payment state transitions follow the defined state machine | PASS | All 5 valid transitions explicitly enumerated; invalid transitions throw `InvalidContributionStateError` | +| Contribution amounts validated as positive | PASS | Minimum 100 cents enforced in both Zod schema and domain factory method | + +--- + +### 4. Design System Compliance + +| Item | Status | Notes | +|------|--------|-------| +| Dark-first UI — `--color-bg-page` as primary background | PASS | No new pages; components designed for dark background | +| Components reference Tier 2 semantic tokens ONLY | PASS | All tokens in design spec are Tier 2 semantic tokens; no Tier 1 identity tokens (`--launchfire`, `--void`, etc.) referenced in component specs | +| Colours from defined palette | PASS | All colours trace to semantic tokens | +| Gradients used where specified | PASS | No gradients needed for error/badge components; consistent with brand spec | +| Shadows used where specified | PASS | Focus ring shadow on retry button uses `--color-action-primary` per pattern | +| One primary CTA per viewport | PASS | Error message has at most one retry button; no new primary CTAs | +| Status badges use correct semantic tokens | PASS | Badge tokens trace to `--color-status-*` family | + +#### Typography + +| Item | Status | Notes | +|------|--------|-------| +| Bebas Neue for headings — always uppercase | PASS | No display headings in feat-009 components; N/A | +| DM Sans for all body/UI text | PASS | `--type-body-small` and `--type-button` are DM Sans per spec | +| Space Mono for labels, data, timestamps | PASS | N/A for these components | +| Font sizes match type scale | PASS | `--type-button` at 12px for badges, `--type-body-small` (13px) for error body — within scale | +| No additional fonts introduced | PASS | Only DM Sans (`--font-body`) used | + +#### Component Patterns + +| Item | Status | Notes | +|------|--------|-------| +| Buttons follow defined variants | PASS | Retry button uses Ghost variant | +| Cards use `--color-bg-surface` with `--color-border-subtle` | PASS | Error container uses a status-specific background, not the card pattern — correct for error messaging | +| Status badges follow defined patterns | PASS | `ContributionStatusBadge` follows the 6px dot, 8px radius, 6px 12px padding pattern from brand.md | + +#### Accessibility + +| Item | Status | Notes | +|------|--------|-------| +| All interactive elements keyboard accessible | PASS | Retry button specified as focusable | +| Colour never sole indicator | PASS | Both components pair colour with text labels | +| Contrast ratio ≥ 4.5:1 | PASS | Design spec notes ratios of 5.4:1 and 6.2:1 for critical text elements | +| ARIA roles and labels defined | PASS | `role="alert"`, `aria-live="assertive"` on error component | +| Screen reader announcements for dynamic content | PASS | Full announcement content specified | + +--- + +### 5. Scope Validation + +| Item | Status | Notes | +|------|--------|-------| +| Feature exists in product vision's MVP features | PASS | Payment processing is core to the MVP | +| No Phase 2 features included | PASS | Disbursement (feat-011), refund (feat-010), reconciliation explicitly excluded with "out of scope" notes | +| Spec does not expand the feature brief's scope | PASS | All elements traceable to the feature brief | +| "Out of scope" items from the feature brief are respected | PASS | Disbursement, donor-initiated refunds, daily reconciliation, financial reporting dashboard all excluded | +| No features from "What This Product Is NOT" | PASS | No scope violations detected | + +--- + +### 6. Testability + +| Item | Status | Notes | +|------|--------|-------| +| Every acceptance criterion can be verified by automated test | PASS | All 16 acceptance criteria map to specific test cases | +| Unit tests specified for all domain logic | PASS | 17 unit tests across Contribution entity, EscrowLedgerEntry entity, and MockPaymentGatewayAdapter | +| Integration tests specified for all API endpoints | PASS | 20 integration tests across 3 endpoints | +| Integration tests verify data isolation | WARN | Tests do not explicitly verify that one donor cannot access another donor's contribution. However, the spec mandates `donor_id` from `authContext` (never from request body). An explicit isolation test should be added: verify donor A cannot retrieve donor B's contribution. | +| E2E tests specified for user-facing flows | PASS | Correctly notes no E2E for backend infrastructure; feat-008 will cover E2E | +| Edge cases have test types assigned | PASS | All 25 edge cases have test type specified (Unit or Integration) | +| Test data uses realistic monetary values | PASS | Spec uses specific amounts (100 cents minimum); mock tokens are named strings not round-number amounts | +| Error paths are tested | PASS | Card declined, insufficient funds, invalid webhook signature, invalid state transitions all tested | + +--- + +### 7. Cross-Document Consistency + +| Item | Status | Notes | +|------|--------|-------| +| Every API endpoint in spec has corresponding frontend data requirements in design spec | PASS | Design spec correctly references the 3 endpoints and notes frontend consumption in feat-008 | +| Every component in design spec maps to a functional requirement in feature spec | PASS | `PaymentErrorMessage` maps to US-001 failure path; `ContributionStatusBadge` maps to contribution state machine | +| Every edge case from research document appears in feature spec | PASS | All edge cases from research (data, payment, concurrency, integration, user behaviour, boundary) appear in the 25-row edge case table | +| Entity names, field names, and types are consistent | PASS | `contribution`, `escrow_ledger`, `processed_webhook_events` names consistent across data model, domain model, and API response shapes | +| Status labels in design spec match domain model | PASS | `ContributionStatusBadge` uses `pending_capture`, `captured`, `failed`, `refunded`, `partially_refunded` — exactly the `ContributionStatus` union type | +| Design spec empty/loading/error states match feature spec error handling | PASS | Error codes `CARD_DECLINED`, `INSUFFICIENT_FUNDS`, `PAYMENT_FAILED`, `VALIDATION_ERROR` all appear in both spec and design | + +--- + +### 8. Complexity Assessment + +- **Estimated size:** L (per feature brief) +- **Backend complexity:** High — state machine, port/adapter pattern with two implementations (mock + Stripe skeleton), atomic transactions across contribution + escrow + event store, webhook idempotency, compensating actions +- **Frontend complexity:** Low — two small components (`PaymentErrorMessage`, `ContributionStatusBadge`); no new pages +- **Infrastructure changes:** Minor — one new migration (`processed_webhook_events`), new env vars in `.env.example` +- **Cross-context dependencies:** 2 contexts — payments (primary), accounts (donor_id FK), campaigns (campaign_id FK). All references are by ID only; no cross-context domain imports. +- **External service integrations:** Mock only (default); Stripe adapter is a skeleton +- **Estimated test count:** 37 tests (17 unit + 20 integration) +- **Risk factors:** + 1. Dual-write failure (Stripe capture succeeds, DB write fails) — spec addresses with compensating Stripe refund; must be tested + 2. Webhook body parser must be `express.raw()` not `express.json()` — critical configuration; gotchas.md should be updated + 3. Migration timestamp collision — must be resolved before implementation + 4. Stripe SDK is behind an adapter skeleton; real integration deferred but the interface contract must be correct to avoid breaking changes when feat-* activates real Stripe + +--- + +## Failures (Must Fix) + +None. No automatic-fail triggers were triggered. + +--- + +## Warnings (Should Fix Before Implementation) + +### WARN-1 (Self-Corrected): Migration Timestamp + +The migration timestamp collision was identified and corrected during validation. The spec now uses `20260305000009_create_processed_webhook_events.sql` and includes `BEGIN; ... COMMIT;` wrappers. No action required by implementation agent for this item. + +--- + +### WARN-2: Data Isolation Test Missing + +**Location:** feat-009-spec.md, Testing Requirements, Integration Tests + +**Issue:** No explicit test verifies that Donor A cannot access Donor B's contribution or trigger a capture in Donor B's name. The spec correctly mandates `donor_id` from `authContext`, but a test confirming this isolation is not enumerated. + +**Fix:** Add integration test: "POST /api/v1/payments/capture — donor_id in response matches authenticated user, not any value from request body." + +**Responsible:** Implementation agent should add this test. Spec Writer should note it in a revision. + +--- + +### WARN-3: Explicit TypeScript Return Types Not Shown in Method Signatures + +**Location:** feat-009-spec.md, Ports and Application Service sections + +**Issue:** Port method signatures are described in prose and table format rather than TypeScript interface syntax. Return types are implied but not shown as explicit TypeScript. This could lead an implementation agent to omit explicit return types (violating CLAUDE.md backend rules). + +**Fix:** The spec includes enough information to infer return types; implementation agents should ensure all exported functions have explicit TypeScript return types per CLAUDE.md. + +**Responsible:** Advisory only — no spec revision required. + +--- + +### WARN-4: Webhook Endpoint Body Parser Misconfiguration is a Known Gotcha + +**Location:** feat-009-spec.md, API Endpoints section + +**Issue:** The spec correctly documents the `express.raw()` requirement with a "CRITICAL" callout. However, this is a common implementation mistake. The gotchas.md file should be updated to include this pattern. + +**Fix:** Update `.claude/context/gotchas.md` to include: "Stripe webhook routes must use `express.raw({ type: 'application/json' })` body parser — NOT `express.json()`. Stripe signature verification requires raw bytes." + +**Responsible:** Implementation agent should add this to gotchas.md when implementing. + +--- + +### WARN-5: `processed_webhook_events` Missing BEGIN/COMMIT Transaction Wrapper + +**Location:** feat-009-spec.md, Data Model section + +**Issue:** The migration SQL shown in the spec does not wrap the DDL in `BEGIN; ... COMMIT;`. Per the established migration pattern (patterns.md), all migrations should be wrapped in explicit transactions. + +**Fix:** Implementation agent must add `BEGIN;` and `COMMIT;` around the migration body. + +**Responsible:** Implementation agent (migration is in the spec as guidance; wrapper is a pattern convention). + +--- + +## Revision Instructions + +No FAILs were detected. The spec proceeds to implementation with the following notes for the implementation agent: + +**Implementation Agent must:** +1. Migration is ready at `20260305000009_create_processed_webhook_events.sql` with `BEGIN; ... COMMIT;` wrappers — no timestamp changes needed +2. Add an explicit data isolation integration test for the capture endpoint (donor_id from authContext, not request body) +3. Use `express.raw({ type: 'application/json' })` for the webhook route body parser — see updated gotchas.md +4. Ensure all exported TypeScript functions have explicit return types per CLAUDE.md backend rules diff --git a/.claude/prds/feat-013-design.md b/.claude/prds/feat-013-design.md new file mode 100644 index 0000000..eed30bd --- /dev/null +++ b/.claude/prds/feat-013-design.md @@ -0,0 +1,542 @@ +# Design Spec: feat-013 — KYC Verification (Mock Provider) + +> Component-level UI specification. Generated by Design Speccer. +> Feature spec: feat-013-spec.md +> Design system: specs/standards/brand.md + +--- + +## Page Layouts + +### KYC Verification Page + +**Route:** `/settings/verification` +**Layout:** Single-column, centred content (matching the existing settings pages pattern) +**Max width:** 600px centred (consistent with `settings-profile.tsx` and `settings-preferences.tsx`) + +#### Layout Structure + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Nav bar (existing — no changes) │ +├─────────────────────────────────────────────────────────────┤ +│ [page background: --color-bg-page] │ +│ │ +│ ┌──────────────────────── 600px ─────────────────────────┐ │ +│ │ 01 — IDENTITY VERIFICATION [section label] │ │ +│ │ VERIFY YOUR IDENTITY [page heading] │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ │ +│ │ │ KYC Status Banner │ │ │ +│ │ │ [status badge + status description text] │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [Submission Form — shown conditionally] │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ │ +│ │ │ Document Type Selector │ │ │ +│ │ │ Front Document Upload │ │ │ +│ │ │ Back Document Upload (conditional) │ │ │ +│ │ │ Submit Button (primary CTA) │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Responsive behaviour:** +- Desktop (≥1024px): Centred 600px column with 48px vertical padding +- Tablet (768–1023px): Same 600px column with 32px vertical padding +- Mobile (<768px): Full-width with 24px horizontal padding, 24px vertical padding + +--- + +## Components + +### Component: `KycStatusBanner` + +**File:** `packages/frontend/src/components/kyc/kyc-status-banner.tsx` +**Reuses:** Badge pattern from `specs/standards/brand.md` Section 3.5. New component. + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `status` | `KycStatus` | Yes | — | Current KYC status string | +| `failureCount` | `number` | No | `0` | Current failure count (shown for pending_resubmission and rejected) | +| `verifiedAt` | `string \| null` | No | `null` | ISO 8601 string (shown when verified) | + +#### Visual Specification + +**Container:** +- Background: varies by status (see Status Badges section) +- Border radius: `--radius-input` (12px) — slightly tighter than a card, matching inline alert patterns +- Padding: 16px 20px +- Margin bottom: 32px + +**Layout inside banner:** +- Row: `[dot indicator — 8px] [gap 10px] [status text]` +- For pending_resubmission, rejected, locked: second line showing failure count in `--type-body-small` and `--color-text-tertiary` + +**Typography:** +- Status label: `--type-button` (DM Sans 600, 14px), left-aligned +- Sub-text: `--type-body-small` (DM Sans 400, 13px), `--color-text-secondary`, margin-top 4px + +#### States by KYC Status + +**`not_verified`:** +- Background: `rgba(var(--afterburn), 0.08)` — resolves to `--color-status-warning` at 8% opacity +- Border: `1px solid` `--color-status-warning` at 20% opacity +- Dot: `--color-status-warning` (afterburn) +- Label: "Identity not verified" +- Sub-text: "Complete verification to unlock Creator features." + +**`pending`:** +- Background: `--color-status-new-bg` (orbit at 40%) +- Border: `1px solid` `--color-status-new-border` +- Dot: `--color-status-new` (signal-blue), with pulsing animation +- Label: "Verification in progress" +- Sub-text: "Your documents are being reviewed. This usually takes a few seconds." + +**`pending_resubmission`:** +- Background: `rgba(var(--afterburn), 0.08)` +- Border: `1px solid` `--color-status-warning` at 20% opacity +- Dot: `--color-status-warning` +- Label: "Resubmission required" +- Sub-text: "Please upload your documents again." (if failureCount > 1, append " Attempt {failureCount} of 5.") + +**`in_manual_review`:** +- Background: `--color-status-new-bg` +- Border: `1px solid` `--color-status-new-border` +- Dot: `--color-status-new` +- Label: "Under manual review" +- Sub-text: "A team member is reviewing your submission. This may take up to 24 hours." + +**`verified`:** +- Background: `--color-status-success-bg` +- Border: `1px solid` `--color-status-success-border` +- Dot: `--color-status-success` +- Label: "Identity verified" +- Sub-text: If `verifiedAt` is provided: "Verified on {formatted date}" in `--color-text-tertiary` + +**`rejected`:** +- Background: `rgba(var(--red-planet), 0.08)` — equivalent to `--color-status-error` at 8% opacity +- Border: `1px solid` `--color-status-error` at 20% opacity +- Dot: `--color-status-error` +- Label: "Verification failed" +- Sub-text: "Your verification was rejected. Please resubmit your documents." (if failureCount > 0, append " Attempt {failureCount} of 5.") + +**`expired`:** +- Background: `rgba(var(--afterburn), 0.08)` +- Border: `1px solid` `--color-status-warning` at 20% opacity +- Dot: `--color-status-warning` +- Label: "Verification expired" +- Sub-text: "Your identity verification has expired. Please resubmit to continue using Creator features." + +**`reverification_required`:** +- Background: `rgba(var(--afterburn), 0.08)` +- Border: `1px solid` `--color-status-warning` at 20% opacity +- Dot: `--color-status-warning` +- Label: "Re-verification required" +- Sub-text: "Please re-verify your identity to continue using Creator features." + +**`locked`:** +- Background: `rgba(var(--red-planet), 0.08)` +- Border: `1px solid` `--color-status-error` at 20% opacity +- Dot: `--color-status-error` +- Label: "Account locked" +- Sub-text: "Your account has been locked for KYC submissions after repeated failures. Please contact support." + +#### Accessibility + +- **Role:** `status` (aria-live region, polite) — allows screen reader to announce status changes +- **Dot indicator:** `aria-hidden="true"` — decorative +- **Announced text:** Screen reader reads the full label + sub-text when status changes + +--- + +### Component: `DocumentTypeSelector` + +**File:** `packages/frontend/src/components/kyc/document-type-selector.tsx` +**Reuses:** Form input pattern from `specs/standards/brand.md` Section 3.6. New component. + +#### Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `value` | `KycDocumentType \| ''` | Yes | — | Selected document type | +| `onChange` | `(value: KycDocumentType) => void` | Yes | — | Selection handler | +| `disabled` | `boolean` | No | `false` | Disables selection | + +#### Visual Specification + +**Container:** Renders as a group of 3 radio-style selector cards. + +**Individual selector card:** +- Background (unselected): `--color-bg-input` +- Background (selected): `--color-bg-elevated` +- Border (unselected): `1px solid` `--color-border-input` +- Border (selected): `1px solid` `--color-action-primary` +- Border radius: `--radius-input` (12px) +- Padding: 14px 16px +- Cursor: pointer +- Display: flex, align-items center, gap 12px + +**Radio dot (custom):** +- Outer circle: 18px, border `2px solid` `--color-border-input` (unselected) or `--color-action-primary` (selected) +- Inner dot (selected only): 10px circle, `--color-action-primary` fill +- flex-shrink: 0 + +**Document type label:** +- Font: `--type-body` (DM Sans 400, 16px), `--color-text-primary` + +**Document sub-label** (description below document type): +- Passport → "Single photo page required" +- National ID → "Front and back required" +- Driver's Licence → "Front and back required" +- Font: `--type-body-small` (DM Sans 400, 13px), `--color-text-tertiary` + +**Layout:** Cards stacked vertically with 8px gap between cards. + +**Group label above:** "DOCUMENT TYPE" in `--type-input-label` (Space Mono 600, 12px uppercase, letter-spacing 0.05em), `--color-text-tertiary`, margin-bottom 8px. + +#### States + +**Default (no selection):** All three cards shown with `--color-border-input` border. + +**Selected:** Selected card shows `--color-action-primary` border + inner filled dot. + +**Focus-visible:** `outline: 2px solid var(--color-action-primary-hover); outline-offset: 2px` + +**Disabled:** All cards at 40% opacity, cursor `not-allowed`. + +#### Accessibility + +- Each card is `