Building a Fintech Demo That Survives a Code Review
A demo becomes a handout when nobody can run its tests. Here are the money invariants worth asserting, and the scaffolding that lets a reviewer clone the repository and watch the ledger prove itself.

A reviewer who opens a financial demo does not read it the way a visitor reads a landing page. They clone it, run the tests, and wait for the ledger to contradict itself. When nothing contradicts, they keep reading. When the tests only assert that a route returns 200, they close the tab. The difference between a demo and a handout is not the polish of the interface. It is whether the rules about money are written down somewhere a machine checks them.
We build and review systems like this, so we keep a checklist. What follows is that checklist, plus the scaffolding that makes each item cheap enough to keep after the first week.
Six invariants worth testing
An invariant is a sentence that must remain true after every operation, whatever the input. For software that moves money, a small number of them matter more than any feature, and every one can be tested without a browser.
Every transaction sums to zero
A double-entry transaction is a set of entries that add up to nothing. If any code path can produce a transaction with a single entry, the ledger can create money by accident, and the first person to notice will be a customer. The check is a query, not a scenario: group the entries by transaction, sum each group, and assert that every group is zero. Run it over the whole table after a scenario, not only over the accounts the scenario touched, because the point is to catch the path nobody thought about. The accounting behind the rule is in our guide to double-entry ledgers; the question here is how to prove it in CI.
A transfer is atomic: both sides or neither
A test that posts a transfer and checks the response tests the route. The invariant needs a failure inserted in the middle. Force an error after the debit and before the credit, with a constraint violation, a trigger that raises, or a stub that throws, then assert that the ledger is exactly as it was: same number of entries, same balances, same transaction count. If the design has no seam where a failure can be inserted, that is already a finding, because production will find one for you.
The second half is concurrency. Start two transfers from the same account at the same time and assert the final balances. This needs a database that really locks rows. An in-memory SQLite database with one connection will pass a test that PostgreSQL would fail, so run this one against the engine you deploy. A handful of property-based tests help as well: generate random sequences of transfers and reversals, then assert the invariants after each step. Scripted scenarios check the order you imagined; generated ones find the order you did not.
A reversal creates entries instead of editing them
Call the reversal endpoint, then assert four things: the original entries still exist with unchanged values, new entries were written, the new entries reference the original transaction, and the pair sums to zero. A test that only checks the balance returned to its earlier value will also pass on an implementation that overwrites rows, which is the implementation the invariant exists to forbid. Add a retry with the same idempotency key and assert that the entry count did not move.
Balances are derived, not stored as the source of truth
Two assertions. First, the balance the API returns equals the sum of that account's entries. Second, if a balance row or a cache exists, delete it, rebuild it from the entries, and assert the same number. The second assertion is what separates a cache from a second source of truth, and it fails loudly on exactly the systems that need it.
Card numbers never appear in a log
Seed a card with a known number, send a payment, then capture the log output and assert that the full number is absent while the last four digits are present. Do it for the success path, for a validation failure, and for the audit table. Error messages are where full numbers leak most often, because the developer writing them is trying to be helpful. It is a crude test, a string search over stdout, and it catches the mistake that costs the most.
An operator action is attributable
Sign in as one operator, perform a privileged action, then assert that an audit record exists with that operator's identity, the time, and the before and after values. Then sign in as a customer and assert that the same action is refused. Attribution fails in predictable places: a scheduled job writes as something like system until every job does, or the operator console reaches the ledger through a shortcut that skips the audit write. A demo page usually claims an audit trail. The test is what tells you the claim is true.
What makes those tests cheap to write
A seed script that produces a known state
The tests need numbers they can assert without computing them from the implementation under test. A seed script creates the same accounts, the same opening balances and the same customers every time, and the tests call it in setup instead of depending on a dump someone exported once. Two properties matter: it runs against an empty database, and it is idempotent, so a reviewer can run it twice without corrupting the state. If seeded logins exist, print them in the README and label them as demo credentials. A reviewer should not have to read the seed code to find a password.
One command that runs everything
State the exact command in the README and what success looks like, for example the number of tests that should pass and the line the formatter prints. The reviewer's first experience should be a green suite in under a minute. If the setup needs a database, a container and two environment variables, write the three commands that do it, not a paragraph describing them. A reviewer who has to improvise is a reviewer who leaves.
Tests that assert on the ledger, not on HTTP
Keep a small number of HTTP tests for authentication, validation and status codes, because those are the parts a browser touches. Put the money assertions in tests that call the service layer and then read the entries table directly. They run faster, they fail with a useful message, and they survive a change to a response shape that has nothing to do with the invariant. A suite that takes minutes gets skipped; a suite that takes seconds runs on every commit.
Deterministic data, including the clock
Financial code depends on dates: interest, standing orders, statement periods, card expiry. A test that reads the current time fails at the end of a month or on the day the clocks change, and a test that fails for calendar reasons gets marked flaky and then ignored. Pass a clock into the code, freeze it in tests, and write at least one test that moves it forward on purpose to prove the accrual rule.
A test you have seen fail
Delete the zero-sum check from the service layer for a minute and run the suite. If everything stays green, the test is a decoration. We do this once per invariant when we review a demo, and it is the fastest way to find the assertions that were written to describe the code rather than to constrain it.
A README that says what the demo does not do
Write the absences down: no real money, no real card data, no PCI scope, no production hardening such as rate limits, secret management, backups or monitoring. This is not modesty. It is the paragraph that stops a reviewer from assuming you did not know, because the alternative reading of a silent README is that you believe the demo is ready for customers. The honest limit is also a scope statement: it tells the reviewer which gaps are deliberate, and it turns the rest into fair questions instead of doubts.
What a reviewer checks before reading any code
These four items take a few minutes and decide how the rest is read.
- Credentials in the repository. A committed
.env, a key inside a test fixture, a password in a compose file. Automated secret scanning helps on a large repository; on a small demo a search through the history is faster. - A
.env.examplethat matches the code. Every variable the code reads is listed, nothing listed is unused, and the placeholders fail loudly if someone runs with them unchanged. A drifted example file is evidence that the setup instructions were never executed from scratch. - Migrations that run from empty. Drop the database, run the migration chain, run the seed, start the app. If that sequence fails, the demo runs on one laptop.
- Honest limitations. The README, the licence, and whether the hosted instance admits that it is a demo. A demo URL that pretends to be a product is the quickest way to lose a technical reader.
A worked example
We published one of these as an open reference. Danube Bank is a FastAPI and MySQL banking demo with a customer app and a separate operator console, MIT licensed, with a seed command and a .env.example in the quick start. Its tests exercise the ledger invariants rather than the screens, the operator console writes through the same service layer as the customer app, and the page states plainly that there is no real money, no real cards and no real KYC. That last sentence is the one we would look for first if someone else had written it.
If you have a demo of your own, the cheapest improvement is one test: run a scripted scenario, then sum every transaction and assert zero. It takes an afternoon, it catches the class of bug that embarrasses a team in front of a client, and it turns a repository that shows an interface into one that proves a claim.
Frequently asked questions
- What makes a fintech demo credible to a code reviewer?
- A reviewer can clone the repository, run one command, and see tests that assert the money invariants rather than the screens. That means a seeded database with known numbers, tests that read the ledger directly, and a README that states what the demo deliberately does not do. Claims in the interface matter less than a suite that fails when the ledger stops balancing.
- What should a ledger test check after a transfer?
- That every transaction in the table sums to zero, that both entries were written in the same transaction, and that the account balances equal the sum of their entries. Also check the failure case: force an error between the debit and the credit and assert that nothing changed. A test that only checks the HTTP response proves the route exists, not that the money moved correctly.
- How do I test that card numbers are not logged?
- Seed a card with a known number, submit a payment, capture the log output and assert that the full number is absent while the last four digits are present. Repeat it for a validation error, because error messages are where full numbers leak most often. It is a string search rather than an elegant test, and it is quick to write.
- Why does a seed script matter for tests?
- It gives tests numbers they can assert without recomputing them from the code under test. A good seed creates the same accounts and opening balances every time, runs on an empty database and is safe to run twice. Without one, expectations get computed by the same logic that might be wrong, and the tests stop catching anything.
- Does a demo need a licence and a list of limitations?
- Yes, both. The licence tells a reader what they may do with the code. The limitations tell them what the code is not: no real money, no real card data, no PCI scope, no production hardening. Without that paragraph a reviewer can only assume either that you did not know the gaps exist or that you consider the demo ready for customers.
- Can I test atomicity with SQLite in memory?
- Partly. SQLite in memory is fine for checking that a rolled-back transaction leaves no entries, but it does not reproduce PostgreSQL row locking, so a concurrency test will pass there and fail in production. Run the tests that depend on isolation against the same database engine you deploy, ideally in a container started by the suite.


