diff --git a/README.md b/README.md index 7f5daeb..17fe80e 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,431 @@ -# ๐Ÿฆ Neo-Bank +# Neo-Bank -Register โ†’ fund with a test card โ†’ send money to someone else โ†’ watch both -balances update live, no reload. A neobank you run on your own laptop, built -to prove the ledger never lies โ€” not when a Postgres node dies mid-transfer, -not when the same request arrives twice. +Neo-Bank is a small retail bank you run on your own machine. Customers register with an email code, top up with a Stripe test card, send money to another customer's IBAN, and watch both balances change without reloading the page. Behind the UI are seven Go services, one gateway, Kafka, and a three-node Postgres cluster with automatic failover. -> ๐ŸŽฌ **Demo GIF โ€” pending.** Recording instructions and the exact shot list -> are in [docs/screenshots/CHECKLIST.md](docs/screenshots/CHECKLIST.md). -> Until then, [DEMO.md](DEMO.md) is the same 5โ€“10 minute walkthrough as a -> script you can run yourself. +The project exists to answer engineering questions with measurements rather than assumptions. Can two concurrent transfers push an account negative? What happens when the same request arrives twice? What does a customer see when the database leader dies mid-transfer? Where does throughput stop? No real money moves, and the IBANs carry a fictional bank code (`ZZZZ`). [![CI](https://github.com/Adwerse/Neo-Bank/actions/workflows/ci.yml/badge.svg)](https://github.com/Adwerse/Neo-Bank/actions/workflows/ci.yml) [![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](go.work) +[![React](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)](frontend/package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -๐Ÿ“‹ [Demo script](DEMO.md) ยท ๐Ÿ“Š [Load test report](https://claude.ai/code/artifact/b40504bd-656e-452a-bb32-3e4ec344bd26) ยท ๐Ÿ“– [Full engineering log](spec.md) ยท ๐ŸŽฅ Video โ€” recording soon +Desktop dashboard for Maya Walsh: balance โ‚ฌ2,320.25, a balance trend chart, account details with IBAN IE13 ZZZZ, money flow of โ‚ฌ82.75 in and โ‚ฌ162.50 out, quick actions, and five operations including a rejected โ‚ฌ6,000.00 transfer. -It's not a product โ€” no real account, no transfer ever leaves the test -environment. It's an answer to seven concrete engineering questions, each -one answered with a measurement, not an assumption. See ๐Ÿง  below, or the -full story in [spec.md](spec.md). +[Demo script](DEMO.md) ยท [Engineering log](spec.md) ยท [Load test report](https://claude.ai/code/artifact/b40504bd-656e-452a-bb32-3e4ec344bd26) ยท [Development diary](DEVLOG.md) ---- +## What the system guarantees -## ๐Ÿš€ Quick start +Each row is a property the code enforces, the mechanism that enforces it, and the test or measurement that checks it. -```bash -git clone https://github.com/Adwerse/Neo-Bank && cd Neo-Bank -cp .env.example .env -docker compose up -d +| Guarantee | Mechanism | Evidence | +| --- | --- | --- | +| An account never goes negative, even under concurrent transfers | `SELECT โ€ฆ FOR UPDATE` on both ledger rows, in ascending id order, before the balance check | 20 concurrent transfers against a 10,000-unit account: exactly 10 succeed ([test](services/ledger-svc/ledger_test.go#L655)) | +| A deposit or refund delivered twice is posted once | `pg_advisory_xact_lock` on the operation's reference | 20 concurrent calls sharing one reference produce one ledger entry ([test](services/ledger-svc/ledger_test.go#L965)) | +| A retried transfer with the same idempotency key moves money once | Unique key on `transfers`; a replay returns the stored row | Load test, duplicates profile: exactly 9.00 replays per executed transfer at 10, 30, 60 and 120 virtual users | +| A crash cannot drop an event, or publish one for a change that never committed | Transactional outbox: the event row is written in the same Postgres transaction as the change | [`outbox.go:84`](pkg/outbox/outbox.go#L84), relay and cleanup tests in `pkg/outbox` | +| A transfer whose outcome is unknown is resolved from the ledger, not guessed | It stays `pending`; a worker asks `ledger-svc.GetTransactionByReference` | Run for this README: fraud-svc stopped, transfer answered `202 pending`, worker closed it as `failed / timeout_unresolved` 27 s later | +| A fraud-service outage never lets a transfer through unchecked | Fail-closed: no fraud decision means no ledger call | Same run: the money did not move while fraud-svc was down | +| Killing the Postgres leader loses no confirmed transaction | Patroni `synchronous_mode` and HAProxy routing by Patroni role | [`failover_test.go`](infra/failover/failover_test.go#L424): four `docker kill` runs, 23.6โ€“25.4 s of write downtime, every confirmed pair present on the new leader | +| A balance on screen is never a stale value pushed over the socket | The WebSocket carries only `balance.changed`; the client re-fetches over HTTP | [`notify.go:55`](gateway/notify.go#L55) | + +The repository has 251 Go test functions in 37 files. They run against a real Postgres with no database mocks, and they skip themselves when `DATABASE_URL` is unset. CI builds and vets every workspace module; the integration tests need the compose stack and run locally. + +## Product tour + +The screenshots and the animation come from a local run on 9 October 2026 with two test users, Maya and Liam. Desktop widths get the light theme and phone widths the dark one; the viewport picks the theme, not the OS setting. + +### One transfer, two phones + +Two phones side by side. On the left Maya confirms a โ‚ฌ42.50 transfer. On the right Liam's balance moves from โ‚ฌ770.00 to โ‚ฌ812.50, a new row appears in his recent operations and a toast reads Incoming transfer: +โ‚ฌ42.50. + +Maya confirms โ‚ฌ42.50 on the left. Liam's phone receives a `balance.changed` signal over the WebSocket, fetches the new balance and history over HTTP, and shows a toast. Nobody reloads anything. + +### Onboarding + +| Register | Verification code, delivered to Mailpit | +| --- | --- | +| Registration form with email maya.walsh@example.org and two password fields. | Mailpit showing the email Verify your Neo-Bank email with a six-digit code that expires in ten minutes. | + +auth-svc sends the six-digit code over real SMTP to Mailpit, a local inbox, so no email leaves the Docker network. Verifying the code proves ownership of the mailbox and nothing more; there is no KYC. + +### Money in, money moved + +| Card top-up, Stripe test mode | Transfer confirmation | +| --- | --- | +| Pay by card step with Stripe's Payment Element: card number 4242 4242 4242 4242, expiry 12/34, CVC 123, country Ireland. | Confirm transfer step: recipient IE42 ZZZZ 0000 4373 7769 08, sending โˆ’โ‚ฌ120.00, remaining balance โ‚ฌ2,280.00, buttons Confirm and send and Edit. | + +A deposit has two separate facts with two statuses: Stripe confirmed the card charge (`succeeded`), and the ledger credited the money (`credited`). The UI says "Payment accepted, crediting within a minute" until the second fact is true. The accounts in these screenshots were funded with [`cmd/devtopup`](services/ledger-svc/cmd/devtopup/main.go) instead, because Stripe's checkout runs an invisible hCaptcha that an automated browser cannot pass. The full deposit flow, from PaymentIntent through webhook to the credit worker, and its manual verification are in [spec.md โ†’ Stripe-funded deposits](spec.md#stripe-funded-deposits-transfers-svc-ledger-svc). + +Liam's desktop dashboard a moment after receiving โ‚ฌ120.00: a toast in the top right, balance โ‚ฌ770.00, money flow showing incoming transfers of โ‚ฌ120.00, and the completed transfer in the operations table. + +Liam's desktop dashboard a moment after Maya's โ‚ฌ120.00 transfer: the toast, the new balance, the money-flow bar and the operation row all arrived without a reload. + +### Fraud check, profile, mobile + +| A blocked transfer | Profile | +| --- | --- | +| Transfer card with a warning: This transfer was blocked by our security system to protect you. Reason: the amount exceeds the limit for a single transfer. Below it the history shows a rejected โˆ’โ‚ฌ6,000.00 transfer. | Profile page with initials avatar M, name Maya Walsh, email, IBAN, account number and an Active status badge. | + +โ‚ฌ6,000.00 is over the `amount_threshold` rule (โ‚ฌ5,000.00). fraud-svc names exactly one rule per decision, the API returns `rejected / amount_threshold`, and ledger-svc is never called. The recipient is not told the transfer existed. + + + + + + + + + + +
Mobile dashboard in the dark theme: balance โ‚ฌ2,320.25, balance-over-time chart, IBAN, quick actions, money flow and recent operations.Mobile transfer screen in the dark theme with the IBAN and amount form above an operation history with filters for type, status and time.
Mobile dashboardTransfer form and history with filters
+ +## Architecture + +The browser talks only to the Gateway. The Gateway validates the JWT, sets `X-User-Id`, and proxies by path prefix. Services call each other over gRPC. ledger-svc has no HTTP API and is unreachable from the Gateway. + +```mermaid +flowchart TB + browser(["Browser ยท React SPA"]) -->|"HTTPS + WebSocket"| gw["Gateway
JWT ยท routing ยท WS push"] + gw --> auth["auth-svc"] + gw --> accounts["accounts-svc"] + gw --> transfers["transfers-svc"] + transfers -->|"gRPC"| accounts + transfers -->|"gRPC"| fraud["fraud-svc"] + transfers -->|"gRPC"| ledger["ledger-svc"] + accounts -->|"gRPC"| ledger + transfers <-->|"REST + webhook"| stripe(["Stripe test mode"]) + auth --> redis[("Redis")] + auth --> minio[("MinIO")] + notif["notifications-svc"] -->|"SMTP"| mailpit(["Mailpit"]) + auth & accounts & transfers & fraud & ledger & notif --- pg[("Postgres 16, three nodes
Patroni + etcd ยท HAProxy")] ``` -One command, 17 containers: 7 Go services + a 3-node Postgres cluster + -Kafka + Redis + MinIO + Jaeger + Mailpit. No Stripe key? Everything works -except `POST /deposits` โ€” registration, transfers, fraud checks, the live -dashboard, even a Postgres failover. First build takes a few minutes; after -that, under one. +Events travel the other way, asynchronously. A producer writes the event into an outbox table in the same transaction as the business change, and a relay publishes it to Kafka afterwards. + +```mermaid +flowchart LR + auth["auth-svc"] -.->|"outbox"| ue[["user.events
compacted"]] + accounts["accounts-svc"] -.->|"outbox"| ae[["account.events
compacted"]] + transfers["transfers-svc"] -.->|"outbox"| te[["transfer.events"]] + ue --> accounts + ue --> notif["notifications-svc"] + ae --> notif + ae --> gw["Gateway"] + te --> notif + te --> gw + notif -->|"SMTP"| mailpit(["Mailpit"]) + notif -.->|"after 5 attempts"| dlq[["transfer.events.dlq"]] + gw -->|"balance.changed"| ws(["browser WebSocket"]) +``` -```bash -cd frontend && npm install && npm run dev # โ†’ http://localhost:5173 +| Service | Owns | Talks to | Port | +| --- | --- | --- | --- | +| [gateway](gateway/README.md) | JWT validation, routing, WebSocket registry | every service, Kafka | 8080 | +| [auth-svc](services/auth-svc/README.md) | users, sessions, profile, avatars | Redis, MinIO, Kafka (outbox) | 8081 | +| [accounts-svc](services/accounts-svc/README.md) | account records, IBAN generation and resolve | ledger-svc, Kafka | 8082, gRPC 9082 | +| [ledger-svc](services/ledger-svc/README.md) | the append-only double-entry log, balance cache | Postgres only | gRPC 8083 | +| [transfers-svc](services/transfers-svc/README.md) | transfers, deposits, withdrawals, reconciliation | accounts, fraud, ledger, Stripe, Kafka | 8084 | +| [fraud-svc](services/fraud-svc/README.md) | three fixed rules and a log of every check | Postgres only | 8085, gRPC 9085 | +| [notifications-svc](services/notifications-svc/README.md) | transactional email, retry, dead-letter topic | Kafka, Mailpit | 8086 | + +### One transfer, end to end + +```mermaid +sequenceDiagram + autonumber + participant M as Maya + participant G as Gateway + participant T as transfers-svc + participant A as accounts-svc + participant F as fraud-svc + participant L as ledger-svc + participant K as Kafka + participant R as Liam + M->>G: POST /transfers/ + G->>T: + X-User-Id + T->>A: resolve IBANs + T->>T: INSERT pending + T->>F: CheckTransfer + F-->>T: approve + T->>L: ExecuteTransfer + Note over L: lock rows, check funds,
2 entries, COMMIT + L-->>T: tx id + T->>T: completed + outbox + T-->>M: 201 completed + T--)K: TransferCompleted + K--)G: transfer.events + G--)R: balance.changed + R->>G: GET /accounts/me ``` -Full requirements, port table, test commands, and the load test: [spec.md โ†’ Quick start](spec.md#quick-start). +### Status lifecycles + +```mermaid +stateDiagram-v2 + direction LR + state "Transfer" as T { + [*] --> pending + pending --> rejected: a fraud rule fired + pending --> completed: ledger posted both entries + pending --> failed: ledger refused, e.g. insufficient funds + pending --> completed: reconciliation found the entry + pending --> failed: reconciliation found nothing + } + state "Deposit" as D { + [*] --> d_pending + d_pending: pending + d_pending --> succeeded: Stripe webhook or poll + d_pending --> d_failed: payment failed or abandoned + d_failed: failed + succeeded --> credited: worker posts to the ledger + credited --> refunded: charge.refunded, reversal entry + } +``` + +## Engineering highlights + +Seven problems that a concurrency test, the failover test or the load test forced a decision on. Each links to the code. + +| Problem | Decision | Code | +| --- | --- | --- | +| Two transfers on one account can both read "funds sufficient" before either writes | Lock both rows with `FOR UPDATE` in a fixed order before checking the balance | [`ledger.go:343`](services/ledger-svc/ledger.go#L343), [`ledger.go:312`](services/ledger-svc/ledger.go#L312) | +| Kafka and Stripe both deliver at least once, so a deposit or refund can arrive twice | Serialize posts per reference with `pg_advisory_xact_lock`, then check whether it already happened | [`ledger.go:530`](services/ledger-svc/ledger.go#L530) | +| A crash between "write the row" and "publish the event" loses or invents an event | Outbox in the same transaction; a relay publishes afterwards | [`outbox.go:84`](pkg/outbox/outbox.go#L84), [`relay.go:81`](pkg/outbox/relay.go#L81) | +| A gRPC call to the ledger can succeed while its response is lost | Keep the transfer `pending` and ask the ledger by reference | [`reconcile.go:58`](services/transfers-svc/reconcile.go#L58), [`reconcile.go:90`](services/transfers-svc/reconcile.go#L90) | +| Stripe and the ledger cannot share a transaction | Two statuses (`succeeded`, `credited`), a worker between them, refunds as reversal entries | [`deposit_reconcile.go:66`](services/transfers-svc/deposit_reconcile.go#L66), [`webhook.go:207`](services/transfers-svc/webhook.go#L207) | +| WebSocket messages can arrive late, reordered or not at all | Push a signal, never a value; the client re-reads over HTTP | [`notify.go:55`](gateway/notify.go#L55) | +| Naive failover can promote a replica that missed a confirmed commit | Patroni `synchronous_mode`: only the synchronous standby may be promoted | [`patroni.yml:99`](infra/patroni/patroni.yml#L99) | + +Ten architecture decisions, each with the rejected alternative, are written up in [spec.md โ†’ Architecture decisions](spec.md#architecture-decisions-mini-adrs): a monorepo, a custom Gateway instead of Traefik or Kong, Kafka instead of NATS, an event instead of a synchronous call for account creation, the outbox, signal-only WebSocket messages, two deposit statuses, no NoSQL store, the profile living in auth-svc, and parallel requests on the profile screen. + +## Observability + +Every service exports OpenTelemetry spans to Jaeger. One `trace_id` covers the whole path of a request, and every SQL statement, `BEGIN`, `COMMIT` and `pool.acquire` is its own span. Credentials, emails, card data and account numbers never go into spans. The amount does, as a bare integer with no currency or account next to it, because a money trace without the amount cannot answer why a transfer was rejected. + +Jaeger trace gateway: POST /transfers/, 201 ms, 5 services, depth 7, 58 spans, with nested spans for gateway, transfers-svc and accounts-svc including pool.acquire and individual SQL queries. + +One completed transfer: 58 spans across five services in 201 ms. + +Jaeger trace transfers-svc: reconcile transfer, 22 ms, 12 spans: GetTransactionByReference into ledger-svc, then UPDATE, INSERT and COMMIT in transfers-svc. + +The reconciliation worker's trace for the transfer that got stuck while fraud-svc was down. It is a separate trace, connected to the original request by a `FOLLOWS_FROM` span link rather than a parent-child relation, because the parent finished long before the worker started. The worker asked ledger-svc for the reference, found nothing, and closed the transfer as `failed`. + +Mailpit inbox with Neo-Bank emails: transfer failed, transfer declined, and pairs of transfer sent and transfer received for 64.00, 18.75, 42.50 and 120.00 EUR, followed by verification emails. ---- +A successful transfer sends two emails, one to each side. A fraud block sends one email to the sender and does not name the rule. notifications-svc retries five times with backoff, then routes the event to `transfer.events.dlq` so one bad message cannot block the partition. -## ๐Ÿ—๏ธ Architecture +## Load test -Neo-Bank architecture: browser through a single Gateway to six Go microservices; the highlighted path is one transfer โ€” Gateway to transfers-svc, which calls accounts-svc, fraud-svc and ledger-svc over gRPC; every service shares a 3-node Postgres cluster; Stripe, Jaeger and Mailpit sit at the edges. + + + Load test headline: 176.4 transfers per second distributed ceiling; 31.5 transfers per second on one hot account at every concurrency level; 53,789 transfers and 87,888 ledger entries in the final run; 0 invariant violations. + -*The blue path is one real transfer โ€” everything else is routing and shared -infrastructure.* Async side, not pictured: `auth-svc`/`transfers-svc` write -to an outbox in the same Postgres transaction as the change; a relay -publishes to Kafka a second later. +k6 drives `POST /transfers/` through the Gateway, so every run measures the full path: one proxy hop, five gRPC calls, 24 SQL statements and four commits per transfer. The three profiles differ only in how each iteration picks sender and recipient. A Go tool, `loadtest/cmd/lt`, creates the fixtures through the public API, samples Postgres during the run and checks eight invariants afterwards. -| | service | owns | talks to | -|---|---|---|---| -| ๐Ÿšช | **gateway** | JWT, routing, WebSocket push | every service ยท Kafka | -| ๐Ÿ” | **auth-svc** | users, sessions, profile, avatars | Redis ยท MinIO ยท Kafka | -| ๐Ÿ’ณ | **accounts-svc** | accounts, IBAN resolve | ledger-svc (gRPC) ยท Kafka | -| ๐Ÿ“’ | **ledger-svc** | the double-entry log โ€” no public API | Postgres only | -| ๐Ÿ’ธ | **transfers-svc** | transfers, deposits, withdrawals, reconciliation | accounts/fraud/ledger-svc ยท Stripe ยท Kafka | -| ๐Ÿ•ต๏ธ | **fraud-svc** | rule-based transfer scoring | Postgres only | -| โœ‰๏ธ | **notifications-svc** | transactional email | Kafka ยท Mailpit | + + + Throughput and median latency against 10, 30, 60 and 120 virtual users. Distributed throughput 59.7, 151.0, 176.4, 171.9 transfers per second; hot account 31.6, 31.5, 31.5, 32.1. Median latency distributed 166, 188, 327, 683 ms; hot account 314, 938, 1893, 3778 ms. + -Each service has its own short README: [gateway](gateway/README.md) ยท -[auth-svc](services/auth-svc/README.md) ยท -[accounts-svc](services/accounts-svc/README.md) ยท -[ledger-svc](services/ledger-svc/README.md) ยท -[transfers-svc](services/transfers-svc/README.md) ยท -[fraud-svc](services/fraud-svc/README.md) ยท -[notifications-svc](services/notifications-svc/README.md). +The distributed profile peaks at 176.4 transfers/s at 60 virtual users. Past that point extra concurrency turns into queueing: from 60 to 120 users throughput stays flat while median latency doubles. The hot account holds 31.5 transfers/s from 10 to 120 users and its latency grows twelvefold, because every transfer into that account waits for the same row lock. That works out to 31.7 ms of lock hold time per transfer. The lock is the same one that keeps the account from going negative, so the ceiling is a chosen cost. ---- +
+All twelve runs -## ๐Ÿง  What's actually interesting here +40 accounts, a transfer of 100 minor units, 60 seconds per stage, latency measured as the full round trip through the Gateway. -Seven problems a concurrency test, a failover test, or the load test forced -a real decision on. Each links straight to the code โ€” not a paraphrase of -it. Full write-up on every row: [spec.md โ†’ Engineering highlights](spec.md#engineering-highlights). +| Profile | VUs | Requests/s | Executed/s | p50 | p95 | p99 | max | Errors | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| distributed | 10 | 59.7 | 59.7 | 166 ms | 208 ms | 237 ms | 386 ms | 0% | +| distributed | 30 | 151.0 | 151.0 | 188 ms | 280 ms | 353 ms | 523 ms | 0% | +| distributed | 60 | 176.4 | 176.4 | 327 ms | 464 ms | 546 ms | 760 ms | 0% | +| distributed | 120 | 171.9 | 171.9 | 683 ms | 847 ms | 942 ms | 1,070 ms | 0% | +| hot account | 10 | 31.6 | 31.6 | 314 ms | 405 ms | 490 ms | 657 ms | 0% | +| hot account | 30 | 31.5 | 31.5 | 938 ms | 1,136 ms | 1,477 ms | 2,137 ms | 0% | +| hot account | 60 | 31.5 | 31.5 | 1,893 ms | 2,180 ms | 2,386 ms | 2,962 ms | 0% | +| hot account | 120 | 32.1 | 32.1 | 3,778 ms | 4,016 ms | 4,312 ms | 4,988 ms | 0% | +| duplicates | 10 | 234.3 | 23.4 | 37 ms | 138 ms | 167 ms | 791 ms | 0% | +| duplicates | 30 | 507.4 | 50.8 | 43 ms | 203 ms | 260 ms | 435 ms | 0% | +| duplicates | 60 | 540.0 | 54.0 | 96 ms | 260 ms | 321 ms | 514 ms | 0% | +| duplicates | 120 | 560.0 | 56.0 | 200 ms | 363 ms | 434 ms | 589 ms | 0% | -| ๐Ÿงฉ the problem | ๐Ÿ› ๏ธ the fix | ๐Ÿ“ code | -|---|---|---| -| Two transfers on one account race past "funds sufficient" | Row lock (`FOR UPDATE`), deterministic order, *before* the balance check | [`ledger.go:343`](services/ledger-svc/ledger.go#L343) | -| A deposit or Stripe refund can be delivered twice | `pg_advisory_xact_lock` per operation โ€” 20 concurrent posts, 1 entry | [`ledger.go:530`](services/ledger-svc/ledger.go#L530) | -| A crash between "write" and "publish" drops or duplicates an event | Outbox: event + change in one transaction; a relay publishes after | [`outbox.go:84`](pkg/outbox/outbox.go#L84) | -| A gRPC call to the ledger succeeds but the response is lost | Stays `pending` โ€” a worker asks the ledger directly, by reference | [`reconcile.go:90`](services/transfers-svc/reconcile.go#L90) | -| Stripe and the ledger can't share one transaction | Two statuses (`succeeded`โ†’`credited`) + a reversal entry, never a delete | [`deposit_reconcile.go:66`](services/transfers-svc/deposit_reconcile.go#L66) | -| WebSocket delivery order isn't guaranteed | Push a signal only (`balance.changed`) โ€” never the value itself | [`notify.go:55`](gateway/notify.go#L55) | -| The Postgres leader can die mid-transfer | Patroni + etcd, `synchronous_mode` โ€” 0 tx lost across 4 real kills | [`patroni.yml:99`](infra/patroni/patroni.yml#L99) | +Across all twelve runs there was no 5xx, no dropped connection, and no `failed`, `rejected` or unresolved transfer. The only outcomes were `completed` and, in the duplicates profile, `replayed`. ---- +
-## ๐Ÿ“Š Load test +### Where the time goes -176.4 transfers per second ceiling on distributed load, 31.5 transfers per second flat on a hot account, 53,789 transfers and 87,888 ledger entries in the final run, 24 of 24 invariant checks passed with 0 violations. + + + Waterfall of the slowest transfer at 120 virtual users, 841 ms: ledger-svc spends 441 ms in pool.acquire waiting for a free connection and 247 ms waiting on the row lock. + -251 Go test functions, run against a real Postgres โ€” plus 8 database -invariant checks re-run after **every** load-test profile, because a load -test that only reads HTTP status codes can't tell a silent double-post from -a clean 201. Interactive charts, all four bottlenecks, methodology caveats: -**[the full report](https://claude.ai/code/artifact/b40504bd-656e-452a-bb32-3e4ec344bd26)** -or [spec.md โ†’ Load testing](spec.md#load-testing-k6--loadtest). + + + Stacked bar of a calm 126 ms transfer: four commits of 30.8, 18.4, 24.2 and 19.3 ms make 92.7 ms; everything else is 33.3 ms. + ---- +| # | Bottleneck | Measurement | Status | +| --- | --- | --- | --- | +| 1 | ledger-svc's connection pool: 16 connections, the `pgxpool` default of one per CPU, never configured | 441 of 841 ms in `pool.acquire`; `lock_waiters` caps at exactly 15 | Left as is; raising it means budgeting 7 pools against `max_connections = 200` | +| 2 | Four synchronously replicated commits per transfer | 92.7 of 126 ms on a calm request; sync flush lag peaks at 27.9 ms | Deliberate; it is what makes failover safe | +| 3 | The outbox relay publishes one message per `WriteMessages` call against a 1 s batch timeout | 1.0 event/s; the backlog grew from 1,140 to 34,216 rows during one profile | Known; the fix changes the relay's partial-failure semantics and needs its own tests | +| 4 | The overdraft check sums the account's whole journal while holding the lock | 0.019 ms at 1 entry, 1.6 ms at 10,003 entries | Known; switching to the balance cache moves the source of truth | -## โš ๏ธ Honest limitations +fraud-svc was a suspect and was cleared: its two velocity aggregates cost about 0.6 ms each on a covering index. The first thing to fall over was Jaeger, which held 13.3 of 15.5 GiB of memory after about 60,000 traced transfers at 100% sampling. + +### Invariants checked after every profile + +A load test that only reads HTTP status codes cannot tell a silent double-post from a clean 201, so `lt verify` runs eight SQL checks after each profile. All eight passed after all three profiles: 53,789 transfers, 87,888 entries, 0 violations. + +| Check | Asserts | +| --- | --- | +| `entries_sum_zero` | `SUM(entries.amount) = 0` across the whole table | +| `no_negative_balances` | no test account went below zero | +| `transfer_entries_paired` | every completed transfer has exactly two entries with its reference: a debit, a credit, equal size | +| `balance_delta_matches_transfers` | per account, money moved according to `transfers` equals money moved in the ledger; two tables written by two services | +| `no_duplicate_idempotency_keys` | one `transfers` row per key | +| `balance_cache_matches_log` | `account_balances` equals `SUM(entries)` per account | +| `no_entries_for_failed_or_rejected` | no money moved for a failed or rejected transfer | +| `cohort_money_conserved` | the test accounts hold exactly what setup gave them | + +The numbers describe this stack on one laptop: the load generator shares 16 CPUs with the system, Postgres runs on Docker Desktop for Windows where `fsync` is slow, and each stage ran once. Method, caveats and every bottleneck in detail: [spec.md โ†’ Load testing](spec.md#load-testing-k6--loadtest). + +## Postgres failover + + + + Failover breakdown: about 20 s for the leader key TTL, about 2 s for election and promotion, 1 to 2 s for HAProxy. Four measured kills: 25.4, 23.6, 23.6 and 24.6 seconds of write downtime. + + +Three Postgres 16 nodes run under Patroni with etcd as the consensus store. HAProxy asks Patroni's REST API which node holds the leader key and routes port 5432 there. With `synchronous_mode`, the leader cannot confirm a commit until the synchronous standby has flushed it, and Patroni will promote only that standby. A confirmed transaction therefore always exists on the node that becomes the next leader. + +`infra/failover/failover_test.go` kills the leader with `docker kill` while four goroutines send transfers through ledger-svc. It then checks that every confirmed transaction has both entries on the new leader, that `SUM(entries) = 0` per transaction, that no service container restarted, and that the old leader rejoins as a replica rather than a second leader. Most of the ~24 s is the leader key's 20 s TTL, which is Patroni's minimum. + +The same kill, repeated by hand for this README on 9 October 2026, with a probe calling `GET /accounts/me` every 500 ms: + +```text ++ 0.0s leader: pg-node1 ++ 0.4s POST /transfers/ 201 completed ++ 2.7s GET /accounts/me 200 (5 ms) ++ 3.6s docker kill neo-bank-pg-node1-1 ++ 3.6s GET /accounts/me 500 (4 ms) ++ 28.1s GET /accounts/me 200 (30 ms) 24.5 s without a leader ++ 28.1s leader: pg-node3 ++ 28.5s POST /transfers/ 201 completed ++ 29.5s docker compose up -d pg-node1 ++ 49.8s pg-node1 replica streaming ++ 49.8s pg-node2 sync_standby streaming ++ 49.8s pg-node3 leader running +``` + +No service was restarted. The connection pools reconnected on their own because HAProxy closes sessions to a node the moment it is marked down. + +## Run locally + +Requirements: Docker Desktop with Compose v2, Node.js 18 or later for the frontend, and Go 1.25 if you want to run tests or the dev tools outside containers. + +```bash +git clone https://github.com/Adwerse/Neo-Bank && cd Neo-Bank +cp .env.example .env +docker compose up -d +cd frontend && npm install && npm run dev # http://localhost:5173 +``` + +The first build compiles the Go images and takes a few minutes. `docker compose ps` should show 17 running containers and two one-shot jobs, `kafka-init` and `minio-init`, that exit with code 0. + +Everything except card deposits works without Stripe keys: registration, transfers, fraud checks, live updates and failover. To put money on an account without Stripe, use the dev tool. It moves money from a genesis account through the same `ExecuteTransfer` code path as a real transfer: + +```bash +DATABASE_URL="postgres://neobank:neobank_dev_password@localhost:5432/neobank?sslmode=disable" \ +LEDGER_GRPC_ADDR="localhost:8083" \ + go run ./services/ledger-svc/cmd/devtopup --account-id --amount 50000 +``` + +| Variable | File | Purpose | +| --- | --- | --- | +| `STRIPE_SECRET_KEY` | `.env` | Stripe test-mode secret key (`sk_test_โ€ฆ`); transfers-svc refuses to start without a value | +| `STRIPE_WEBHOOK_SECRET` | `.env` | Webhook signing secret (`whsec_โ€ฆ`) printed by `stripe listen --forward-to localhost:8080/webhooks/stripe` | +| `RECONCILE_STALE_AFTER` | `.env` | Optional; how long a transfer may stay `pending` before the worker checks it. Default 2 minutes | +| `VITE_STRIPE_PUBLISHABLE_KEY` | `frontend/.env` | Stripe test-mode publishable key (`pk_test_โ€ฆ`) for the Payment Element | + +Without a running `stripe listen`, deposits still settle: after two minutes the reconciliation worker polls Stripe for the PaymentIntent's status. Test cards are listed in [spec.md โ†’ Stripe test cards](spec.md#stripe-test-cards). + +| URL | What it is | +| --- | --- | +| http://localhost:5173 | the web app (Vite dev server) | +| http://localhost:8080 | the Gateway, the only API entry point | +| http://localhost:8025 | Mailpit, every email the system sends | +| http://localhost:16686 | Jaeger, traces | +| `localhost:5432` / `5433` / `5434` | Postgres via HAProxy: current leader / any standby / synchronous standby | + +[DEMO.md](DEMO.md) is a 5โ€“10 minute walkthrough of the whole system, from registration to killing the Postgres leader, with a `curl` fallback for every step. + +## Tests + +```bash +# Go: unit and integration tests for every workspace module, against the running stack +DATABASE_URL="postgres://neobank:neobank_dev_password@localhost:5432/neobank?sslmode=disable" \ + go list -m -f '{{.Dir}}' | while IFS= read -r dir; do (cd "$dir" && go test ./...); done + +# Frontend: unit tests, lint, type check and production build +cd frontend && npm test && npm run lint && npm run build + +# Failover: kills a Postgres container, so it only runs when asked +FAILOVER_TEST=1 go test ./infra/failover/... -v -count=1 -timeout 15m + +# Load test: fixtures, relaxed velocity rules, three profiles, restore the rules +go run ./loadtest/cmd/lt setup -users 40 -fund 100000000 +go run ./loadtest/cmd/lt fraud -mode loadtest +./loadtest/run.sh all +go run ./loadtest/cmd/lt fraud -mode restore +``` + +`go test ./...` from the repository root does not cover the modules, because the root has a `go.work` and no `go.mod`. The loop above is the same one CI uses. + +## Repository layout + +```text +gateway/ JWT, reverse proxy, WebSocket push, OpenAPI contract (openapi.yaml) +services/ + auth-svc/ users, sessions, profile, avatars in MinIO + accounts-svc/ accounts, IBAN generation and resolve + ledger-svc/ double-entry ledger; cmd/devtopup, cmd/seed + transfers-svc/ transfers, deposits, withdrawals, Stripe webhook, reconciliation + fraud-svc/ rule-based scoring + notifications-svc/ email from Kafka events, retry and DLQ +pkg/ shared Go modules: outbox, pgha (pools and retry for failover), tracing, iban, health +proto/ gRPC contracts and generated Go code +infra/ Patroni, HAProxy and Postgres config; the failover test +loadtest/ k6 profiles and the lt tool (setup, probe, verify, report) +frontend/ React 19, TypeScript, Vite, TanStack Query, Recharts, Stripe Elements +docs/ screenshots and charts used in this README +``` -Things this project doesn't do โ€” on purpose, not for lack of time. Full -reasoning for each: [spec.md โ†’ Honest limitations](spec.md#honest-limitations). +## Limitations -- ๐Ÿ’ธ **Withdrawals are simulated** โ€” a real payout needs a money transmitter license, not more code. -- ๐Ÿ›๏ธ **The IBAN's bank code is fictional** (`ZZZZ`) โ€” never points at a real institution. -- ๐ŸŒ **Transfers are intra-bank only** โ€” no SEPA, and that's deliberate, not unfinished. -- ๐Ÿ™ˆ **No recipient name on confirmation** โ€” the field doesn't exist in the system, so nothing can leak. -- ๐Ÿชช **No KYC** โ€” email ownership only; identity verification is a different project. -- ๐Ÿ”‘ **Refresh token lives in `localStorage`**, not an httpOnly cookie โ€” a real, documented trade-off. -- ๐Ÿ“ง **Exactly-once email is impossible** โ€” a duplicate notification beats a silently missed one. -- ๐Ÿšซ **Fail-closed when fraud-svc is down** โ€” a transfer waits rather than skip the check. -- ๐ŸŒ **A hot account caps at 31.5 tx/s, flat** โ€” the price of the row lock that keeps it from going negative. +These are choices, not unfinished work. The reasoning for each is in [spec.md โ†’ Honest limitations](spec.md#honest-limitations). ---- +- **Withdrawals are simulated.** `POST /withdrawals` debits the balance through the ledger, but no payout leaves the system; a real payout needs a money transmitter licence. +- **Transfers stay inside the bank.** An IBAN from another bank is rejected with its own message. SEPA needs clearing-system membership. +- **The bank code is fictional.** IBANs pass the ISO 7064 check but use `ZZZZ`, so generated details never point at a real institution. +- **No recipient name on confirmation.** Registration collects no name, and a name lookup would turn IBAN resolve into an enumeration oracle. +- **No KYC.** Email verification proves mailbox ownership, not identity. +- **The refresh token is kept in `localStorage`.** An httpOnly cookie is the correct fix and needs a change to the token contract. The access token stays in memory. +- **Email is at-least-once.** An idempotency barrier makes duplicates rare; it cannot make them impossible. +- **Fraud checks fail closed.** While fraud-svc is down, transfers wait in `pending` instead of skipping the check. +- **A hot account tops out at 31.5 transfers/s.** That is the price of the row lock that prevents overdrafts. +- **Services trust each other.** Internal gRPC has no mTLS or shared secret. +- **Dev credentials live in `docker-compose.yml`.** Only the Stripe keys are real secrets, and they stay in the gitignored `.env`. +- **etcd is a single node.** Production needs three or five. +- **Tracing samples 100%.** Fine locally; at load-test volume Jaeger's in-memory store ran out of memory first. -## ๐Ÿ“š Go deeper +## Further reading -- **[spec.md](spec.md)** โ€” the full engineering log: every sprint, every mini-ADR, every manual verification, sourced line-for-line from the real implementation. -- **[DEMO.md](DEMO.md)** โ€” a 5โ€“10 minute guided walkthrough, registration to Postgres failover. -- **[docs/architecture.svg](docs/architecture.svg)** ยท **[docs/screenshots/CHECKLIST.md](docs/screenshots/CHECKLIST.md)** โ€” the diagram source and what's still pending to capture. +- [spec.md](spec.md): the full engineering log, with every sprint, mini-ADR, manual verification and bottleneck write-up. +- [DEMO.md](DEMO.md): the live demo script, including how each step was verified. +- [DEVLOG.md](DEVLOG.md): the development diary. +- Each service directory has a short README covering what it owns, its API, and the events it publishes and consumes. -MIT licensed. Not a real bank โ€” see โš ๏ธ above. +MIT licensed. Not a real bank. diff --git a/docs/charts/commit-cost-dark.svg b/docs/charts/commit-cost-dark.svg new file mode 100644 index 0000000..ad40b57 --- /dev/null +++ b/docs/charts/commit-cost-dark.svg @@ -0,0 +1,29 @@ + + +74% of a calm transfer is four commits +One transfer at 10 VUs, 126 ms end to end. Each COMMIT waits for the synchronous standby to flush its WAL (~20โ€“30 ms). + +COMMIT 1 +transfers INSERT +30.8 ms + +COMMIT 2 +fraud_checks +18.4 ms + +COMMIT 3 +ledger entries +24.2 ms + +COMMIT 4 +transfers + outbox +19.3 ms + +other work +everything else +33.3 ms + + + +92.7 ms of commits + diff --git a/docs/charts/commit-cost-light.svg b/docs/charts/commit-cost-light.svg new file mode 100644 index 0000000..ac75968 --- /dev/null +++ b/docs/charts/commit-cost-light.svg @@ -0,0 +1,29 @@ + + +74% of a calm transfer is four commits +One transfer at 10 VUs, 126 ms end to end. Each COMMIT waits for the synchronous standby to flush its WAL (~20โ€“30 ms). + +COMMIT 1 +transfers INSERT +30.8 ms + +COMMIT 2 +fraud_checks +18.4 ms + +COMMIT 3 +ledger entries +24.2 ms + +COMMIT 4 +transfers + outbox +19.3 ms + +other work +everything else +33.3 ms + + + +92.7 ms of commits + diff --git a/docs/charts/failover-downtime-dark.svg b/docs/charts/failover-downtime-dark.svg new file mode 100644 index 0000000..ed7927d --- /dev/null +++ b/docs/charts/failover-downtime-dark.svg @@ -0,0 +1,43 @@ + + +Killing the Postgres leader: ~24 s of write downtime, 0 confirmed transactions lost +docker kill (SIGKILL) under live transfer load. Downtime = first failed write โ†’ first successful write. +Where the time goes +ttl = 20 s is Patroni's hard minimum + + + + +leader key TTL expires ~20 s + +election + promotion ~2 s + +HAProxy sees new leader ~1โ€“2 s +Measured, 4 kills +infra/failover/failover_test.go + +25.4 s + +23.6 s + +23.6 s + +24.6 s +Run 1 ยท pg-node2 killed โ†’ pg-node1 leads +Run 2 ยท pg-node1 killed โ†’ pg-node3 leads +Run 3 ยท pg-node2 killed โ†’ pg-node1 leads +Run 4 ยท pg-node1 killed โ†’ pg-node3 leads + + +0 s + +5 s + +10 s + +15 s + +20 s + +25 s + diff --git a/docs/charts/failover-downtime-light.svg b/docs/charts/failover-downtime-light.svg new file mode 100644 index 0000000..c5f59e3 --- /dev/null +++ b/docs/charts/failover-downtime-light.svg @@ -0,0 +1,43 @@ + + +Killing the Postgres leader: ~24 s of write downtime, 0 confirmed transactions lost +docker kill (SIGKILL) under live transfer load. Downtime = first failed write โ†’ first successful write. +Where the time goes +ttl = 20 s is Patroni's hard minimum + + + + +leader key TTL expires ~20 s + +election + promotion ~2 s + +HAProxy sees new leader ~1โ€“2 s +Measured, 4 kills +infra/failover/failover_test.go + +25.4 s + +23.6 s + +23.6 s + +24.6 s +Run 1 ยท pg-node2 killed โ†’ pg-node1 leads +Run 2 ยท pg-node1 killed โ†’ pg-node3 leads +Run 3 ยท pg-node2 killed โ†’ pg-node1 leads +Run 4 ยท pg-node1 killed โ†’ pg-node3 leads + + +0 s + +5 s + +10 s + +15 s + +20 s + +25 s + diff --git a/docs/charts/load-test-kpis-dark.svg b/docs/charts/load-test-kpis-dark.svg new file mode 100644 index 0000000..c6658ac --- /dev/null +++ b/docs/charts/load-test-kpis-dark.svg @@ -0,0 +1,19 @@ + + + +Distributed ceiling +176.4transfers/s +ledger-svc's 16-connection pool + +One hot account +31.5transfers/s +flat from 10 to 120 VUs + +Final run +53,789transfers +87,888 ledger entries + +Invariant checks +0violations +8 SQL checks after each profile + diff --git a/docs/charts/load-test-kpis-light.svg b/docs/charts/load-test-kpis-light.svg new file mode 100644 index 0000000..536df50 --- /dev/null +++ b/docs/charts/load-test-kpis-light.svg @@ -0,0 +1,19 @@ + + + +Distributed ceiling +176.4transfers/s +ledger-svc's 16-connection pool + +One hot account +31.5transfers/s +flat from 10 to 120 VUs + +Final run +53,789transfers +87,888 ledger entries + +Invariant checks +0violations +8 SQL checks after each profile + diff --git a/docs/charts/load-throughput-latency-dark.svg b/docs/charts/load-throughput-latency-dark.svg new file mode 100644 index 0000000..d04830e --- /dev/null +++ b/docs/charts/load-throughput-latency-dark.svg @@ -0,0 +1,67 @@ + + +Throughput stops growing; latency doesn't +k6 through the Gateway, 60 s per stage, 40 funded accounts, transfer of 1.00 EUR. One run per stage. + + +Distributed: 40 users pay each other at random + + +Hot account: everyone pays one account +Throughputtransfers / s + +0 + +50 + +100 + +150 + +200 +10 +30 +60 +120 +virtual users + + + + + + + + + + +176.4 peak +31.5 at every level +Median latency (p50)ms + +0 + +1,000 + +2,000 + +3,000 + +4,000 +10 +30 +60 +120 +virtual users + + + + + + + + + + +3,778 +683 + diff --git a/docs/charts/load-throughput-latency-light.svg b/docs/charts/load-throughput-latency-light.svg new file mode 100644 index 0000000..ff908c5 --- /dev/null +++ b/docs/charts/load-throughput-latency-light.svg @@ -0,0 +1,67 @@ + + +Throughput stops growing; latency doesn't +k6 through the Gateway, 60 s per stage, 40 funded accounts, transfer of 1.00 EUR. One run per stage. + + +Distributed: 40 users pay each other at random + + +Hot account: everyone pays one account +Throughputtransfers / s + +0 + +50 + +100 + +150 + +200 +10 +30 +60 +120 +virtual users + + + + + + + + + + +176.4 peak +31.5 at every level +Median latency (p50)ms + +0 + +1,000 + +2,000 + +3,000 + +4,000 +10 +30 +60 +120 +virtual users + + + + + + + + + + +3,778 +683 + diff --git a/docs/charts/slowest-transfer-trace-dark.svg b/docs/charts/slowest-transfer-trace-dark.svg new file mode 100644 index 0000000..0da6e46 --- /dev/null +++ b/docs/charts/slowest-transfer-trace-dark.svg @@ -0,0 +1,46 @@ + + +The slowest transfer at 120 VUs: 841 ms, half of it in a queue +Span tree from Jaeger. ledger-svc has 16 pooled connections (pgxpool default = CPU count); nobody configured it. + +0 ms + +200 ms + +400 ms + +600 ms + +800 ms +service +span +gateway +POST /transfers/ + +transfers-svc +POST / + +transfers-svc +INSERT transfers + +transfers-svc +fraud/CheckTransfer + +transfers-svc +ledger/ExecuteTransfer + +ledger-svc +pool.acquire + +441 ms waiting for a free connection +ledger-svc +SELECT โ€ฆ FOR UPDATE + +247 ms waiting on the row lock +ledger-svc +COMMIT + +transfers-svc +COMMIT + + diff --git a/docs/charts/slowest-transfer-trace-light.svg b/docs/charts/slowest-transfer-trace-light.svg new file mode 100644 index 0000000..a28fec2 --- /dev/null +++ b/docs/charts/slowest-transfer-trace-light.svg @@ -0,0 +1,46 @@ + + +The slowest transfer at 120 VUs: 841 ms, half of it in a queue +Span tree from Jaeger. ledger-svc has 16 pooled connections (pgxpool default = CPU count); nobody configured it. + +0 ms + +200 ms + +400 ms + +600 ms + +800 ms +service +span +gateway +POST /transfers/ + +transfers-svc +POST / + +transfers-svc +INSERT transfers + +transfers-svc +fraud/CheckTransfer + +transfers-svc +ledger/ExecuteTransfer + +ledger-svc +pool.acquire + +441 ms waiting for a free connection +ledger-svc +SELECT โ€ฆ FOR UPDATE + +247 ms waiting on the row lock +ledger-svc +COMMIT + +transfers-svc +COMMIT + + diff --git a/docs/load-test-summary.svg b/docs/load-test-summary.svg deleted file mode 100644 index 80fbe87..0000000 --- a/docs/load-test-summary.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - โšก LOAD TEST โ€” k6, THREE PROFILES, FOUR CONCURRENCY STEPS EACH - - - - - - - - - 176.4 - tx/s ceiling - distributed load โ€” bottlenecked on - ledger-svc's unconfigured pool - - - 31.5 - tx/s, flat - one hot account, 12ร— more - concurrency changes nothing - - - 53,789 - transfers executed - 87,888 ledger entries in the - final run โ€” all balanced - - - 24/24 - invariants held - 8 Postgres checks ร— 3 profiles, - 0 violations - - Full charts, bottleneck breakdown, and methodology โ†’ the published load test report - diff --git a/docs/screenshots/CHECKLIST.md b/docs/screenshots/CHECKLIST.md deleted file mode 100644 index 1e6b2df..0000000 --- a/docs/screenshots/CHECKLIST.md +++ /dev/null @@ -1,32 +0,0 @@ -# Screenshot/gif checklist - -Not filled in automatically โ€” the environment this commit was prepared in -had no browser tool for real UI/Jaeger screenshots, and it isn't worth -inserting broken image links or passing off a mockup as a real screen. -Below is the exact list of what to capture, with the [DEMO.md](../../DEMO.md) -step each screenshot reproduces, and the expected filename. Capture each -one by following DEMO.md's steps (stack up, `npm run dev` in `frontend/`), -drop it here next to this file, and uncomment the corresponding line in -the README (see "Screenshots" right at the top, next to "Quick start") โ€” -`![caption](docs/screenshots/)`. - -| file | DEMO.md step | what's on screen | -|---|---|---| -| `01-dashboard-empty.png` | 1 | Dashboard right after login: balance `0.00 EUR`, empty operations feed | -| `02-mailpit-code.png` | 1 | Mailpit UI with the email open, six-digit code visible | -| `03-deposit-pending.png` | 2 | Deposit screen right after `confirmPayment`: "payment accepted, crediting within a minute" โ€” **before** the balance updates | -| `04-deposit-credited.png` | 2 | Same dashboard seconds later: balance already grew, no reload | -| `05-transfer-both-sides.gif` | 3 | gif or two screenshots side by side: Alice's and Bob's dashboards before and right after the transfer, both updated without F5 (two different browser profiles โ€” see DEMO.md, "Preparation") | -| `06-fraud-blocked.png` | 4 | Response/toast with the rejection and reason (`amount_threshold`) on the transfer form | -| `07-jaeger-transfer-trace.png` | 5 | Jaeger, expanded span tree for one transfer โ€” gateway โ†’ transfers-svc โ†’ accounts-svc/fraud-svc/ledger-svc visible | -| `08-jaeger-reconciliation-link.png` | 6 | Jaeger, the `outbox publish` span or the reconciliation worker's span with a span link back to the original trace | -| `09-failover-terminal.png` | 7 | Terminal: `docker kill`, a run of failed transfer attempts, then a successful one โ€” this can just be pasted as text into the README instead of an image, see the example below | - -Step 7 doesn't have to be a screenshot โ€” a real terminal log is more -honest than a screenshot and easier to keep up to date, e.g. -`docker kill neo-bank-pg-node3-1` followed by a series of transfer -attempts up to the first success. The exact downtime numbers (three runs -of the strict automated test, not a manual curl script) are already in -[spec.md](../../spec.md), "Postgres: replication and automatic failover" โ†’ -"Measured failover time" (23.6โ€“25.4 s); trust those numbers for the -screenshot/log demo, not a stopwatch during the presentation. diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..63428e7 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/deposit-card.png b/docs/screenshots/deposit-card.png new file mode 100644 index 0000000..2c86652 Binary files /dev/null and b/docs/screenshots/deposit-card.png differ diff --git a/docs/screenshots/fraud-rejected.png b/docs/screenshots/fraud-rejected.png new file mode 100644 index 0000000..4252b8e Binary files /dev/null and b/docs/screenshots/fraud-rejected.png differ diff --git a/docs/screenshots/jaeger-reconcile-trace.png b/docs/screenshots/jaeger-reconcile-trace.png new file mode 100644 index 0000000..e156cb6 Binary files /dev/null and b/docs/screenshots/jaeger-reconcile-trace.png differ diff --git a/docs/screenshots/jaeger-transfer-trace.png b/docs/screenshots/jaeger-transfer-trace.png new file mode 100644 index 0000000..edb8daa Binary files /dev/null and b/docs/screenshots/jaeger-transfer-trace.png differ diff --git a/docs/screenshots/live-transfer.gif b/docs/screenshots/live-transfer.gif new file mode 100644 index 0000000..5e54597 Binary files /dev/null and b/docs/screenshots/live-transfer.gif differ diff --git a/docs/screenshots/mailpit-inbox.png b/docs/screenshots/mailpit-inbox.png new file mode 100644 index 0000000..8fa803c Binary files /dev/null and b/docs/screenshots/mailpit-inbox.png differ diff --git a/docs/screenshots/mobile-dashboard.png b/docs/screenshots/mobile-dashboard.png new file mode 100644 index 0000000..27c2c3b Binary files /dev/null and b/docs/screenshots/mobile-dashboard.png differ diff --git a/docs/screenshots/mobile-history.png b/docs/screenshots/mobile-history.png new file mode 100644 index 0000000..6b5b547 Binary files /dev/null and b/docs/screenshots/mobile-history.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png new file mode 100644 index 0000000..1004e27 Binary files /dev/null and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/recipient-live-update.png b/docs/screenshots/recipient-live-update.png new file mode 100644 index 0000000..79c49b1 Binary files /dev/null and b/docs/screenshots/recipient-live-update.png differ diff --git a/docs/screenshots/register.png b/docs/screenshots/register.png new file mode 100644 index 0000000..d568ec2 Binary files /dev/null and b/docs/screenshots/register.png differ diff --git a/docs/screenshots/transfer-review.png b/docs/screenshots/transfer-review.png new file mode 100644 index 0000000..1013584 Binary files /dev/null and b/docs/screenshots/transfer-review.png differ diff --git a/docs/screenshots/verification-email.png b/docs/screenshots/verification-email.png new file mode 100644 index 0000000..5ed7e07 Binary files /dev/null and b/docs/screenshots/verification-email.png differ diff --git a/spec.md b/spec.md index 12f3a35..494346f 100644 --- a/spec.md +++ b/spec.md @@ -9,11 +9,10 @@ the balance update live โ€” a neobank you can run on your own laptop, built to prove the ledger never lies, even when a node dies mid-transfer or the same request arrives twice. -> ๐ŸŽฌ **Demo GIF โ€” pending.** Register โ†’ email code โ†’ deposit โ†’ transfer โ†’ -> both balances update with no reload. Recording instructions and the exact -> shot list are in [docs/screenshots/CHECKLIST.md](docs/screenshots/CHECKLIST.md); -> until then, [DEMO.md](DEMO.md) is the same walkthrough as a script you can -> run yourself in 5โ€“10 minutes. +![Two phones side by side: Maya sends โ‚ฌ42.50, and Liam's balance, history and a toast update without a reload.](docs/screenshots/live-transfer.gif) + +More screenshots are in the README's product tour; [DEMO.md](DEMO.md) is the +same walkthrough as a script you can run yourself in 5โ€“10 minutes. [![CI](https://github.com/Adwerse/Neo-Bank/actions/workflows/ci.yml/badge.svg)](https://github.com/Adwerse/Neo-Bank/actions/workflows/ci.yml) [![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](go.work)