Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/context/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions .claude/context/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
259 changes: 259 additions & 0 deletions .claude/prds/feat-009-design.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading