Money is an integer number of cents, the ledger only ever appends, and no balance is ever computed in the browser. Most of what makes a banking app trustworthy is a handful of rules you decide once and then refuse to break.
- Role
- Project manager & front-end lead
- Team
- 5 people
- Date
- September 2026
- Backend
- Python standard library only
- API
- 19 routes, token-authenticated
- Deployment
- AWS — CloudFront, S3, DocumentDB
- Python 3.10+
- React 19
- Vite
- MongoDB Atlas
- AWS CloudFront
- AWS S3
- Amazon DocumentDB
- JSON Web Tokens
Live at d1ws2llwooqj6z.cloudfront.net · source on GitHub. The palette borrows from a well-known bank's colours; none of its logo, wordmark or name appears anywhere, which is a deliberate line rather than an oversight.
What it does
Users register, open accounts, deposit, withdraw, transfer money to another person by name, and read a paginated transaction history. Admins freeze and unfreeze accounts with a recorded reason, post correcting ledger entries, and read an audit log of who did what to which account and why. Admins hold no accounts of their own — the separation is enforced in the service layer, not just hidden in the interface.
The backend runs on the Python standard library. No framework, no third-party package, nothing to install. That is a deliberate constraint: it keeps the whole system installable and gradeable on any machine with Python on it, and it forced every routing, auth and serialisation decision to be made explicitly rather than inherited from a framework's defaults.
What I did
- Project manager. Scope, work sequencing, task assignment across five people, and the conventions document the team worked from. I authored 41 of the repository's 99 commits.
- Full stack. Everyone on the team worked across both halves; I wrote backend service code and front-end alike.
- Front-end lead for everything except the admin console. I did add features into it, including the user search.
- Design system, theming and dark mode — the part I owned end to end, and the part worth reading about below.
- The vision. What the product should feel like, and the standard the interface had to hold to.
The rules that shape everything
The architecture is strictly layered — controller, serialisers, services, models, repository — and each layer depends only on the one below it. The rule is enforced rather than asserted: the business-rule test suite never imports the HTTP layer at all, so if a rule leaked upward into a route handler the tests could not reach it.
Money is an integer number of cents
123456 is £1,234.56. The API rejects 25.00 and
"2500" with a 400 rather than guessing, because guessing
wrong on a decimal point is a hundredfold error. Dollars are parsed on
the way in and formatted on the way out; nothing in between ever holds a
float.
The ledger only appends
There is no route that sets a balance, and there should never be one. A correction is a new entry in the opposite direction. An admin reconciliation endpoint lists any account where the balance disagrees with the sum of its ledger, which is the running proof that the rule still holds.
The browser never computes a balance
Every write endpoint returns the whole updated account alongside the transaction, and the client renders that. A balance derived on the client is a balance that can silently disagree with the server.
A double-clicked button cannot move money twice
Deposits, withdrawals and transfers carry a client-generated transaction id, one per submission attempt, so a retry or an impatient second click resolves to the same transaction rather than a second one.
Never authorise off the token's role claim
The token is signed, so it is not forged — but tokens last a week, and a user demoted on Tuesday still carries one saying ADMIN until the following Monday. Authentication re-reads the user from storage and authorises on what the record says now.
Also rejected: exposing account numbers in the interface. A transfer names a person and the server resolves the account; an account belonging to someone else reads as 404, not 403, so the API does not confirm that it exists.
Deployment on AWS
The application was taken from a local demo to a production deployment on AWS, in three pieces:
- Front end — production bundle built with Vite, uploaded to an S3 bucket and distributed through a CloudFront CDN, which is what serves the live URL.
- Back end — the Python runtime provisioned on an EC2/App Runner instance, connected to the managed database, with environment secrets held in AWS Systems Manager Parameter Store rather than in the image or the repository.
- Database — Amazon DocumentDB, the managed MongoDB cluster, replacing the in-memory store used in development. The repository layer made that a one-file swap, which is exactly the payoff the layering was for.
- Between them — HTTPS throughout, and CORS on the backend scoped to the CloudFront origin, then verified end to end against the production URL rather than assumed from a local run.
The design system
This is the part I owned outright, and the part I would point at first. A banking interface is almost entirely form fields, so the contrast that matters is not only body text — it is the border of everything you can click or type into.
Two brand tokens, not one
The brand navy exists twice: once as a fill that white text sits on, and once as ink for headings. A colour bright enough to work as a button almost never clears 4.5:1 as body text, and in dark mode the two move in opposite directions — the fill stays dark enough for white text while only the ink gets lighter. One token doing both jobs is what makes a dark mode unreadable.
Every ratio computed, not eyeballed
All 85 colour tokens carry their measured contrast ratio in a comment beside them — 93 annotations in total. Interactive borders are held to the 3:1 required for non-text contrast, and they are a separate token from the decorative dividers, which have no such requirement and would otherwise drag the interactive ones down with them.
Dark mode has three states, not two
Light, dark, and nothing stored — which means follow the operating system. Choosing the mode your system already asks for clears the saved value rather than pinning it, so the site keeps tracking the system if it changes later. Pinning on first press is the common bug: the site stops following the system forever and appears broken when the machine switches to dark at sunset.
Rejected: inverting the palette. The navy becomes cyan in dark mode, chosen rather than lightened, and the surfaces, hero and field borders each get their own second answer.
Screenshots
Every shot exists in both modes. Flip one with its own switch, or flip all of them with the switch at the top of the page.
What is not done
Stating the boundary is part of the work.
- Deliberately skipped: refresh tokens, httpOnly cookie sessions, and login rate limiting. All three are the right end state for a real bank; none was in scope here.
- No live regions. Results that change without a page load — a search narrowing, a transfer succeeding — are not announced to screen readers yet. Everything else is wired: visible focus rings, described and invalid states on fields, and tabular figures so money columns align.
-
The development server is not a production server.
Python's
http.serveris single-threaded, and it was the right choice for a project whose backend had to install with zero setup.
More work on the projects page, or back to Selected Work.