Architecture · v0.2.1 UI preview Invariants Threat model

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).

Browser React client web Next.js 15 · :3000 api Express 5 · Prisma · :4000 postgres ledger + business Stripe (test) Checkout · Connect worker outbox + reconcile Resend / Sentry email · errors HTTPS REST /v1 SQL hosted Checkout webhook (signed) Stripe SDK poll shared DB

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.

1

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:

EXTERNAL −$100.00  /  ESCROW_HELD +$100.00  = 0
2

Deliver W4

Freelancer marks the milestone DELIVERED — a guarded state transition, no money movement.

3

Approve & release W5

Client approves → API locks the escrow account (SELECT … FOR UPDATE), asserts held ≥ amount, and posts (10% fee):

ESCROW_HELD −$100.00  /  FREELANCER_PAYABLE +$90.00  /  PLATFORM_FEE +$10.00  = 0

Double-release is impossible: row lock + one-way transition + UNIQUE(release:<id>).

4

Payout W6

Freelancer requests a payout → API locks FREELANCER_PAYABLE, asserts available ≥ amount, posts the move to clearing, then creates a Stripe Transfer:

FREELANCER_PAYABLE −amt  /  PAYOUT_CLEARING +amt

On payout.paid → clearing → EXTERNAL (PAID). On payout.failed → reverse clearing → payable (funds returned).

5

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.

EXTERNAL
the outside world / Stripe
ESCROW_HELD
per-milestone funds in custody
FREELANCER_PAYABLE
per-freelancer available balance
PLATFORM_FEE
platform revenue
PAYOUT_CLEARING
funds in flight to a connected account

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.

apps/api/src/ index.ts # bootstrap, middleware order, route mounting config/env.ts # zod-validated env (fail-fast) lib/ # prisma · logger(pino) · errors · money · stripe · sentry · metrics middleware/ # auth · authorize · rateLimit · csrf · validate · error services/ # ALL scoped DB writes live here (INV-1) auth · ledger · account · project · escrow · payout dispute · webhook · audit · outbox · reconciliation · report routes/ # thin controllers → services; zod input validation validation/ # zod schemas apps/web/app/ # Next.js App Router — 13 screens, sidebar + content prisma/ # schema · migrations · seed scripts/invariant-lint.mjs # machine-checks ARCHITECTURE §9 / invariants.json

6 · Data model

PostgreSQL via Prisma. Money columns are BigInt amountMinor + currency CHAR(3). The accounting tables are immutable and append-only.

TablePurposeKey constraints
usersCLIENT · FREELANCER · ADMIN; scrypt password hash; Connect account idemail unique
sessionsserver-side session tokens (only the hash is stored)token_hash unique
projectsowned by a clientFK clientId
milestonesescrow state machine: PENDING → ACCEPTED → FUNDED → DELIVERED → RELEASED / DISPUTED → REFUNDED / SPLITindexed by status
accountschart of accounts (5 types)unique (type, ownerUserId, milestoneId)
ledger_transactionsimmutable; one per money operationunique idempotency_key (INV-3)
ledger_entriesimmutable, append-only; one signed row per legper-txn Σ amountMinor = 0 (INV-7)
payoutsREQUESTED → PAID / FAILEDFK freelancerId
disputesOPEN → RESOLVED (REFUND / RELEASE / SPLIT)one open per milestone
webhook_eventsidempotency dedupe (≥30-day retention)unique stripe_event_id (INV-4)
pending_emailstransactional outbox (pending → sent / failed / dlq)unique (to, template, ref)
audit_logsappend-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-levelassertResourceAccess(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

ConcernApproach
Loggingpino structured JSON; request id; secrets / PAN-like values redacted (INV-9).
Metrics + alerting/metrics (Prometheus text) — funds held, releases, payouts, reconciliation_imbalance_cents.
Error reportingSentry (@sentry/node · @sentry/nextjs); no-op without a DSN.
Rate limitingIn-memory token bucket keyed per route dimension (small tier → no Redis).
Dual-write / emailTransactional outbox — enqueued in the same txn as the business write; a drain worker delivers via Resend with capped backoff → dlq.
ReconciliationWorker every N minutes + admin GET /v1/ledger/reconcile: per-txn Σ=0 and closed-system Σ=0; alert on imbalance.
CI/CDci.yml: install → typecheck → lint → test → build → invariant lint.
TLSHTTPS only — reverse proxy terminates TLS, forces HTTP→HTTPS + HSTS; header buffers sized 32 KB.

9 · Key technical decisions & alternatives

DecisionChosenAlternativesWhy
Escrow mechanismSeparate charges & transfers (hold on platform, Transfer on approval)Destination chargesDestination charges move funds immediately — no milestone-gated hold.
Ledger balancesDerived (sum of entries)Stored mutable columnStored balances drift; derived is provably correct at small scale.
IdempotencyDB UNIQUE (event id + business key)App-level read-then-checkCloses the TOCTOU race at the database.
AuthServer session cookie + scryptJWT · Auth.jsSimplest correct multi-role + revocation; no client token storage.
Email / dual-writeOutbox + drain workerSynchronous provider callGuaranteed delivery; decouples request latency from provider health.
Money typeBigInt minor unitsFloat · DecimalFloat corrupts totals; integer cents match Stripe.
ConcurrencySerializable txn + FOR UPDATEOptimistic version onlyhas_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.

IDRuleCheck
INV-1All scoped DB reads/writes go through services/* — no direct prisma.<model> outside.forbidden-pattern
INV-2Money is never floating point — BigInt minor units only.forbidden-pattern
INV-3Ledger transactions enforce a unique idempotency key (run-at-most-once).unique-constraint
INV-4Webhook events deduped by a unique stripe_event_id.unique-constraint
INV-5Webhook signature verification precedes any DB read.boundary-order
INV-6An executable invariant-lint runner exists and is wired into CI.required-file
INV-7The ledger posting function asserts every transaction's legs sum to zero.manual
INV-8Object-level authorization on owned resources and every sub-resource.manual
INV-9No raw card data (PAN/CVV) stored or logged — tokens + last4/brand only.forbidden-pattern
INV-10Every screen routable; every endpoint UI-reachable; every workflow has an e2e test.ui-coverage
INV-11Payout amount can never exceed available balance, enforced under a row lock.manual
INV-12The 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

LayerTechnologyNotes
WebNext.js 15 (App Router) · Tailwind · React Context13 screens; tokens in globals.css.
APIExpress 5 · REST under /v1/Typed error contract; zod validation.
ORM / DBPrisma 6 · PostgreSQL 16Serializable txns, CHECK constraints, row locks.
PaymentsStripe (Checkout + Connect Express), test modeStub-aware adapter; webhooks signed/verified with the real SDK.
WorkerNode poll-loopOutbox drain + reconciliation; no broker.
Email / errorsResend (outbox) · SentryGuaranteed-delivery email; error reporting.
ContainerSingle Docker Composepostgres · api · web · worker.
\← Gallery