System Architecture
Tradewind is a milestone-escrow services marketplace. Money is held in escrow on the Stripe platform balance and mirrored in an immutable, always-balanced double-entry ledger in PostgreSQL. Three roles — client, freelancer, admin — operate over a strict escrow + dispute + payout state machine. Stripe (Checkout + Connect Express, test mode) is the payment rail.
Source of truth for architecture; behavior lives in
SPEC.md. Precedence: RESEARCH > ARCHITECTURE > SPEC. Build depth: comprehensive.
1 · System overview
Design goals
- Money correctness above all. The ledger always balances; no funds lost or double-paid.
- Provider is the source of truth. Funding is confirmed by a verified Stripe webhook, never the browser redirect.
- Exactly-once effects. At-least-once webhooks + retried requests yield one business effect.
- Isolation. No cross-tenant or same-tenant cross-user access to projects, balances, or disputes.
- Auditability. Every fund-moving and privileged admin action is captured.
Scale tier — small (<1k concurrent)
Single managed Postgres + framework pool. No Redis, no queue broker, no replicas. The
worker polls Postgres. The has_payments domain signal bumps one concern above the tier:
concurrency-safe money moves via row locks at any scale.
Single Docker Compose: postgres · api · web · worker.
2 · Topology
Four services behind a TLS-terminating reverse proxy. The browser talks to the API directly and to Stripe hosted Checkout — card data never enters Tradewind (PCI SAQ-A).
Legend: app Tradewind services · data stores · ext third-party.
3 · The money path
Every state-mutating money action runs in one serializable transaction that emits balanced
ledger legs (sum = 0), an audit_logs row, and — where a party must be notified — a
pending_emails outbox row. Network calls to Stripe happen after commit with an
idempotency key.
Fund W3
Client clicks Fund → API creates a Checkout Session
(idempotencyKey = fund:<id>) → browser pays on Stripe → payment_intent.succeeded
webhook → API verifies signature, dedupes the event, then posts:
Deliver W4
Freelancer marks the milestone DELIVERED — a guarded state transition, no money movement.
Approve & release W5
Client approves → API locks the escrow account
(SELECT … FOR UPDATE), asserts held ≥ amount, and posts (10% fee):
Double-release is impossible: row lock + one-way transition + UNIQUE(release:<id>).
Payout W6
Freelancer requests a payout → API locks FREELANCER_PAYABLE,
asserts available ≥ amount, posts the move to clearing, then creates a Stripe Transfer:
On payout.paid → clearing → EXTERNAL (PAID). On
payout.failed → reverse clearing → payable (funds returned).
Dispute & resolve W7
Either party disputes a funded/delivered milestone → admin resolves: refund (escrow → external), release (as W5), or split (escrow → external + payable + fee). Every resolution writes an audit entry; admins cannot move funds without one.
4 · Chart of accounts
A closed double-entry system. Balances are derived (sum of immutable ledger entries), never stored. Every transaction's legs sum to zero, and the sum of all account balances across the whole system is always zero — verified by the reconciliation job.
Money is always BigInt integer minor units (cents) + a
currency code — never floats. Corrections are reversing entries, never updates or deletes.
5 · Modules & layers
The service boundary is the central rule: no route handler calls Prisma on a scoped table
directly — every scoped read/write goes through services/*. This centralizes tenant + object-level
authorization so a new sub-resource cannot silently inherit only a tenant filter.
6 · Data model
PostgreSQL via Prisma. Money columns are BigInt amountMinor + currency CHAR(3).
The accounting tables are immutable and append-only.
| Table | Purpose | Key constraints |
|---|---|---|
| users | CLIENT · FREELANCER · ADMIN; scrypt password hash; Connect account id | email unique |
| sessions | server-side session tokens (only the hash is stored) | token_hash unique |
| projects | owned by a client | FK clientId |
| milestones | escrow state machine: PENDING → ACCEPTED → FUNDED → DELIVERED → RELEASED / DISPUTED → REFUNDED / SPLIT | indexed by status |
| accounts | chart of accounts (5 types) | unique (type, ownerUserId, milestoneId) |
| ledger_transactions | immutable; one per money operation | unique idempotency_key (INV-3) |
| ledger_entries | immutable, append-only; one signed row per leg | per-txn Σ amountMinor = 0 (INV-7) |
| payouts | REQUESTED → PAID / FAILED | FK freelancerId |
| disputes | OPEN → RESOLVED (REFUND / RELEASE / SPLIT) | one open per milestone |
| webhook_events | idempotency dedupe (≥30-day retention) | unique stripe_event_id (INV-4) |
| pending_emails | transactional outbox (pending → sent / failed / dlq) | unique (to, template, ref) |
| audit_logs | append-only audit trail of every money/admin action | — |
7 · Auth, authorization & tenancy
Authentication
- Session cookie —
httpOnly+Secure+SameSite=Lax; only the token hash is stored. - scrypt verifier (N=16384) with constant-time compare.
- HIBP k-anonymity breach screening + 15-char minimum (NIST 800-63B); no composition rules, no forced rotation.
- Anti-enumeration: identical register/login response shape and timing; rate-limit before the hash compare.
Authorization (two layers)
- Tenant/ownership scope — services filter by owner; cross-owner reads return
404 NOT_FOUND(existence never leaks). - Object-level —
assertResourceAccess(row, principal)on every owned route and every sub-resource (the classic IDOR miss). - Admin actions require explicit
requireRole('ADMIN')— never an implicit bypass.
8 · Cross-cutting infrastructure
| Concern | Approach |
|---|---|
| Logging | pino structured JSON; request id; secrets / PAN-like values redacted (INV-9). |
| Metrics + alerting | /metrics (Prometheus text) — funds held, releases, payouts, reconciliation_imbalance_cents. |
| Error reporting | Sentry (@sentry/node · @sentry/nextjs); no-op without a DSN. |
| Rate limiting | In-memory token bucket keyed per route dimension (small tier → no Redis). |
| Dual-write / email | Transactional outbox — enqueued in the same txn as the business write; a drain worker delivers via Resend with capped backoff → dlq. |
| Reconciliation | Worker every N minutes + admin GET /v1/ledger/reconcile: per-txn Σ=0 and closed-system Σ=0; alert on imbalance. |
| CI/CD | ci.yml: install → typecheck → lint → test → build → invariant lint. |
| TLS | HTTPS only — reverse proxy terminates TLS, forces HTTP→HTTPS + HSTS; header buffers sized 32 KB. |
9 · Key technical decisions & alternatives
| Decision | Chosen | Alternatives | Why |
|---|---|---|---|
| Escrow mechanism | Separate charges & transfers (hold on platform, Transfer on approval) | Destination charges | Destination charges move funds immediately — no milestone-gated hold. |
| Ledger balances | Derived (sum of entries) | Stored mutable column | Stored balances drift; derived is provably correct at small scale. |
| Idempotency | DB UNIQUE (event id + business key) | App-level read-then-check | Closes the TOCTOU race at the database. |
| Auth | Server session cookie + scrypt | JWT · Auth.js | Simplest correct multi-role + revocation; no client token storage. |
| Email / dual-write | Outbox + drain worker | Synchronous provider call | Guaranteed delivery; decouples request latency from provider health. |
| Money type | BigInt minor units | Float · Decimal | Float corrupts totals; integer cents match Stripe. |
| Concurrency | Serializable txn + FOR UPDATE | Optimistic version only | has_payments bumps concurrency-safety above the small-tier baseline. |
10 · Architectural invariants (§9)
Each rule has a machine-checkable or manual entry in invariants.json (same id).
The Stage 4 build lint pass and the ship-time Drift Detection Gate evaluate them. All 12 pass.
| ID | Rule | Check |
|---|---|---|
| INV-1 | All scoped DB reads/writes go through services/* — no direct prisma.<model> outside. | forbidden-pattern |
| INV-2 | Money is never floating point — BigInt minor units only. | forbidden-pattern |
| INV-3 | Ledger transactions enforce a unique idempotency key (run-at-most-once). | unique-constraint |
| INV-4 | Webhook events deduped by a unique stripe_event_id. | unique-constraint |
| INV-5 | Webhook signature verification precedes any DB read. | boundary-order |
| INV-6 | An executable invariant-lint runner exists and is wired into CI. | required-file |
| INV-7 | The ledger posting function asserts every transaction's legs sum to zero. | manual |
| INV-8 | Object-level authorization on owned resources and every sub-resource. | manual |
| INV-9 | No raw card data (PAN/CVV) stored or logged — tokens + last4/brand only. | forbidden-pattern |
| INV-10 | Every screen routable; every endpoint UI-reachable; every workflow has an e2e test. | ui-coverage |
| INV-11 | Payout amount can never exceed available balance, enforced under a row lock. | manual |
| INV-12 | The chart of accounts is a closed system — all balances sum to zero. | manual |
11 · Threat model (STRIDE by trust boundary)
Browser ↔ API
Session auth + object-level authz (IDOR). Server is the sole authority on amounts/state — client amounts are never trusted. Audit log per money action. Card data never crosses this boundary → SAQ-A.
API ↔ Stripe
Idempotency keys on every charge / transfer / payout. Secret keys server-side, test-mode, never logged. Pre-transfer balance assertion under a lock.
Stripe webhook ↔ API
HMAC signature verified before any DB read.
Timestamp tolerance + persisted event.id dedupe (at-least-once → idempotent). Bad signature → 400, never 500.
API ↔ DB
Least-privilege role; mandatory owner predicate. Money moves in serializable, row-locked transactions with Σ=0 enforced and verified by reconciliation. Append-only ledger.
Top risks mitigated: ledger imbalance · negative / over-drawn balance · double-release · webhook replay/spoof · concurrent-move races · same-tenant IDOR · cross-tenant leakage · secret leakage · dispute-authz bypass · PCI scope creep.
12 · Technology stack
| Layer | Technology | Notes |
|---|---|---|
| Web | Next.js 15 (App Router) · Tailwind · React Context | 13 screens; tokens in globals.css. |
| API | Express 5 · REST under /v1/ | Typed error contract; zod validation. |
| ORM / DB | Prisma 6 · PostgreSQL 16 | Serializable txns, CHECK constraints, row locks. |
| Payments | Stripe (Checkout + Connect Express), test mode | Stub-aware adapter; webhooks signed/verified with the real SDK. |
| Worker | Node poll-loop | Outbox drain + reconciliation; no broker. |
| Email / errors | Resend (outbox) · Sentry | Guaranteed-delivery email; error reporting. |
| Container | Single Docker Compose | postgres · api · web · worker. |