|
| 1 | +# PayFlow Architecture |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +PayFlow is a payment-processing API. It ingests charge requests, calls a |
| 6 | +payment provider (Stripe in this reference), reconciles webhook events, |
| 7 | +and exposes a refund workflow. Correctness-critical properties it |
| 8 | +guarantees: |
| 9 | + |
| 10 | +1. **Exactly-once charge** — the same client-supplied idempotency key |
| 11 | + always produces the same outcome, even under retry storms. |
| 12 | +2. **Reconciled state** — every transaction row's status reflects the |
| 13 | + last authoritative webhook for that payment intent. |
| 14 | +3. **Immutable audit trail** — every state-changing request is recorded |
| 15 | + in an append-only `audit_log` table, including the request body hash |
| 16 | + and the actor. |
| 17 | + |
| 18 | +Stack: |
| 19 | + |
| 20 | +| Layer | Tech | |
| 21 | +|-------------|-------------------------------------------------------| |
| 22 | +| Service | Java 21 · Spring Boot 3.x · Spring Web · Spring Data JPA | |
| 23 | +| Persistence | PostgreSQL 15 · Flyway migrations | |
| 24 | +| Auth | JWT (HS256) issued by `/auth/token` | |
| 25 | +| Tests | JUnit 5 · Testcontainers (PG) · RestAssured | |
| 26 | +| Frontend | React 18 · Vite · TypeScript | |
| 27 | +| Ops | Docker multi-stage · docker-compose (svc + db) | |
| 28 | + |
| 29 | +## Domain model |
| 30 | + |
| 31 | +``` |
| 32 | +Merchant ── issuer of API keys, has many PaymentIntents |
| 33 | +Customer ── end-user; optional PaymentMethod tokens |
| 34 | +PaymentIntent ── lifecycle: requires_confirmation → processing → succeeded | failed | canceled |
| 35 | +Charge ── one row per attempt against a provider (1..N per intent) |
| 36 | +Refund ── lifecycle: pending → succeeded | failed |
| 37 | +WebhookEvent ── raw provider events, dedup'd on provider event id |
| 38 | +IdempotencyKey── (merchant_id, key) → response_hash + http_status + body |
| 39 | +AuditLog ── append-only audit rows |
| 40 | +``` |
| 41 | + |
| 42 | +### Why PaymentIntent + Charge split |
| 43 | + |
| 44 | +A single "intent" can span multiple charge attempts (retry after a |
| 45 | +timeout, reauthorize, 3DS challenge). We store the intent as the |
| 46 | +business-level object clients reason about, and each concrete attempt as |
| 47 | +a separate row — so the full history is on disk for dispute resolution. |
| 48 | + |
| 49 | +## Idempotency |
| 50 | + |
| 51 | +Clients supply `Idempotency-Key: <opaque>` on every POST. The first |
| 52 | +request with a given key: |
| 53 | + |
| 54 | +1. Opens a transaction. Inserts `(merchant_id, key)` into |
| 55 | + `idempotency_keys` with a placeholder. Unique constraint prevents |
| 56 | + concurrent duplicates. |
| 57 | +2. Runs the request handler. |
| 58 | +3. Updates the row with the response body, status code, and the SHA-256 |
| 59 | + of the canonicalized request body. |
| 60 | +4. Commits. |
| 61 | + |
| 62 | +A second request with the same key: |
| 63 | +- If the key exists and is complete: returns the stored response. |
| 64 | +- If the key exists but is incomplete (in-flight): returns 409 Conflict |
| 65 | + with `Retry-After: 5`. |
| 66 | +- If the key exists and the stored body hash differs from the new |
| 67 | + request's hash: returns 422 Unprocessable Entity (catch obvious |
| 68 | + programmer errors — a client re-using the same key for a different |
| 69 | + body). |
| 70 | + |
| 71 | +Keys are scoped per-merchant to avoid cross-tenant collisions. |
| 72 | +Retention: 7 days (configurable via `payflow.idempotency.ttl-days`). |
| 73 | + |
| 74 | +## Stripe webhook ingestion |
| 75 | + |
| 76 | +Endpoint: `POST /webhooks/stripe`. |
| 77 | + |
| 78 | +1. Read raw body (kept as bytes for signature verification). |
| 79 | +2. Verify `Stripe-Signature` header against the payload + shared secret |
| 80 | + using the HMAC-SHA256 scheme documented by Stripe. Reject 5-min-old |
| 81 | + signatures. |
| 82 | +3. Parse the event. Check `webhook_events` for the `provider_event_id`. |
| 83 | + If present, return 200 immediately (replay). |
| 84 | +4. Insert the event row, open a transaction, and dispatch to a handler |
| 85 | + by `event.type`: |
| 86 | + - `payment_intent.succeeded` / `.failed` / `.canceled` → update |
| 87 | + `payment_intents.status` + append to `charges`. |
| 88 | + - `charge.refunded` → update the matching `refunds` row. |
| 89 | + - Anything else → insert as "observed but unhandled". |
| 90 | +5. If any step after signature verification fails, we still return 200 |
| 91 | + (because 4xx/5xx triggers Stripe's retry with exponential backoff), |
| 92 | + but log the event with `status = 'error'` so a reconciliation job can |
| 93 | + retry manually. This preserves at-least-once delivery without |
| 94 | + turning into exponential pile-up on our side. |
| 95 | + |
| 96 | +## HTTP API |
| 97 | + |
| 98 | +``` |
| 99 | +POST /v1/payment-intents create + confirm |
| 100 | +GET /v1/payment-intents/:id fetch one |
| 101 | +GET /v1/payment-intents?status=&... list |
| 102 | +POST /v1/payment-intents/:id/cancel cancel if not yet processing |
| 103 | +POST /v1/refunds issue refund (partial or full) |
| 104 | +GET /v1/refunds/:id |
| 105 | +POST /webhooks/stripe Stripe posts here |
| 106 | +POST /auth/token exchange API key for short-lived JWT |
| 107 | +GET /healthz |
| 108 | +``` |
| 109 | + |
| 110 | +All create/update endpoints require `Authorization: Bearer <JWT>` + an |
| 111 | +`Idempotency-Key` header. Validation errors return RFC 7807 problem |
| 112 | +JSON. |
| 113 | + |
| 114 | +## Schema (Flyway: `V1__initial_schema.sql`) |
| 115 | + |
| 116 | +Abbreviated: |
| 117 | + |
| 118 | +```sql |
| 119 | +CREATE TABLE merchants ( |
| 120 | + id UUID PRIMARY KEY, |
| 121 | + name TEXT NOT NULL, |
| 122 | + api_key_hash TEXT NOT NULL UNIQUE, |
| 123 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() |
| 124 | +); |
| 125 | + |
| 126 | +CREATE TABLE customers ( |
| 127 | + id UUID PRIMARY KEY, |
| 128 | + merchant_id UUID NOT NULL REFERENCES merchants(id), |
| 129 | + email TEXT, |
| 130 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() |
| 131 | +); |
| 132 | + |
| 133 | +CREATE TABLE payment_intents ( |
| 134 | + id UUID PRIMARY KEY, |
| 135 | + merchant_id UUID NOT NULL REFERENCES merchants(id), |
| 136 | + customer_id UUID REFERENCES customers(id), |
| 137 | + amount_cents BIGINT NOT NULL CHECK (amount_cents > 0), |
| 138 | + currency CHAR(3) NOT NULL, |
| 139 | + status TEXT NOT NULL, |
| 140 | + provider TEXT NOT NULL, |
| 141 | + provider_id TEXT UNIQUE, |
| 142 | + description TEXT, |
| 143 | + metadata_json JSONB NOT NULL DEFAULT '{}'::JSONB, |
| 144 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), |
| 145 | + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() |
| 146 | +); |
| 147 | + |
| 148 | +CREATE TABLE charges ( |
| 149 | + id UUID PRIMARY KEY, |
| 150 | + intent_id UUID NOT NULL REFERENCES payment_intents(id), |
| 151 | + attempt_no INT NOT NULL, |
| 152 | + status TEXT NOT NULL, |
| 153 | + provider_charge_id TEXT UNIQUE, |
| 154 | + failure_code TEXT, |
| 155 | + failure_message TEXT, |
| 156 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), |
| 157 | + UNIQUE (intent_id, attempt_no) |
| 158 | +); |
| 159 | + |
| 160 | +CREATE TABLE refunds ( |
| 161 | + id UUID PRIMARY KEY, |
| 162 | + intent_id UUID NOT NULL REFERENCES payment_intents(id), |
| 163 | + amount_cents BIGINT NOT NULL CHECK (amount_cents > 0), |
| 164 | + status TEXT NOT NULL, |
| 165 | + reason TEXT, |
| 166 | + provider_refund_id TEXT UNIQUE, |
| 167 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), |
| 168 | + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() |
| 169 | +); |
| 170 | + |
| 171 | +CREATE TABLE idempotency_keys ( |
| 172 | + merchant_id UUID NOT NULL REFERENCES merchants(id), |
| 173 | + key TEXT NOT NULL, |
| 174 | + request_hash TEXT NOT NULL, |
| 175 | + response_status INT, |
| 176 | + response_body JSONB, |
| 177 | + completed_at TIMESTAMPTZ, |
| 178 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), |
| 179 | + PRIMARY KEY (merchant_id, key) |
| 180 | +); |
| 181 | + |
| 182 | +CREATE TABLE webhook_events ( |
| 183 | + id UUID PRIMARY KEY, |
| 184 | + provider TEXT NOT NULL, |
| 185 | + provider_event_id TEXT NOT NULL, |
| 186 | + event_type TEXT NOT NULL, |
| 187 | + payload JSONB NOT NULL, |
| 188 | + status TEXT NOT NULL, |
| 189 | + error TEXT, |
| 190 | + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), |
| 191 | + UNIQUE (provider, provider_event_id) |
| 192 | +); |
| 193 | + |
| 194 | +CREATE TABLE audit_log ( |
| 195 | + id BIGSERIAL PRIMARY KEY, |
| 196 | + actor_type TEXT NOT NULL, -- 'merchant' | 'system' | 'webhook' |
| 197 | + actor_id TEXT, |
| 198 | + action TEXT NOT NULL, |
| 199 | + resource_type TEXT NOT NULL, |
| 200 | + resource_id TEXT, |
| 201 | + request_hash TEXT, |
| 202 | + result TEXT NOT NULL, -- 'ok' | 'denied' | 'error' |
| 203 | + detail JSONB, |
| 204 | + at TIMESTAMPTZ NOT NULL DEFAULT now() |
| 205 | +); |
| 206 | +``` |
| 207 | + |
| 208 | +Indexes on `payment_intents(merchant_id, created_at DESC)`, `charges(intent_id)`, |
| 209 | +`audit_log(resource_type, resource_id)`. |
| 210 | + |
| 211 | +## Frontend |
| 212 | + |
| 213 | +A small React + Vite admin console. Not the consumer-facing checkout — |
| 214 | +that's scope creep for a reference project. The console shows: |
| 215 | + |
| 216 | +- Transactions list (filter by status, currency, date range) |
| 217 | +- Transaction detail (intent + charges + refunds + webhook timeline + audit) |
| 218 | +- Issue refund form |
| 219 | +- Usage stats panel (total succeeded, failed, refunded this month) |
| 220 | + |
| 221 | +Design language: navy + warm bronze + editorial serif for headings. |
| 222 | +Tabular numerics for money. |
| 223 | + |
| 224 | +## Testing |
| 225 | + |
| 226 | +- **Unit**: pure service tests for idempotency key manager, signature |
| 227 | + verifier, state machine transitions. JUnit 5. |
| 228 | +- **Integration**: Spring Boot Test with Testcontainers PostgreSQL. |
| 229 | + Full HTTP stack up through the controllers, real DB, mocked Stripe |
| 230 | + client. |
| 231 | +- **Property-ish**: random request body + key reuse test asserting same |
| 232 | + key → same response, different key → always executes. |
| 233 | +- **Webhook replay**: send the same event twice, assert idempotent. |
| 234 | + |
| 235 | +Target coverage: 85% on service layer, 75% overall. |
| 236 | + |
| 237 | +## Security notes |
| 238 | + |
| 239 | +- API keys are never stored raw — only SHA-256 hashed with a constant-time |
| 240 | + compare at verification. |
| 241 | +- JWT secret ≥ 32 bytes, mandatory in prod. |
| 242 | +- Webhook signature: HMAC-SHA256 with the shared secret, constant-time |
| 243 | + comparison, 5-minute replay window. |
| 244 | +- All DB queries use parameterized JPA/JDBC — no string concatenation. |
| 245 | +- Amounts are stored as `BIGINT cents`. Never float, never decimal |
| 246 | + with ambiguous precision. |
| 247 | + |
| 248 | +## Non-goals |
| 249 | + |
| 250 | +- Real Stripe integration in this reference — a mocked Stripe client |
| 251 | + with injectable fixtures. The webhook verifier is real Stripe-compatible. |
| 252 | +- Card data handling / PCI scope — we never see raw PANs; the frontend |
| 253 | + uses Stripe Elements for tokenization (documented but not hooked up |
| 254 | + in the demo). |
| 255 | +- Multi-currency FX. |
0 commit comments