The brief
Fewer people carry cash every year, but the customs built around it have not gone anywhere. Hospitality staff still depend on tips, and the moment for one — as a customer stands to leave — lasts about fifteen seconds.
That constraint defines the product. A tip has to be possible from scanning a printed code to confirming payment, on a stranger phone, with no app installed and no account created. Anything that adds a step loses the tip.
Architecture decisions
The data model is the interesting part. A tip involves a venue, a member of staff, sometimes a shift or a team pool, a payment, and a payout — and the rules connecting them differ from one venue to the next. That belongs in PostgreSQL with constraints enforced by the database rather than only by application code, because money that reconciles incorrectly is not a bug you find in a test.
The scan path is read-heavy and latency-sensitive: every scan resolves a code to a venue and a payee before anything is shown. That lookup is cached in Redis, which also holds session state, so the common case never touches the primary database.
Authentication and authorisation are handled centrally at the request boundary rather than scattered through handlers, where a missing check is invisible in review.
Built to be operated
The deployment path was set up before the feature work, not after it. The application is containerised, configuration comes from the environment, and every release goes through the same pipeline, so the first production deployment will be a routine event rather than an occasion.
This is the pattern I return to on most projects. Deciding how something will be built, deployed, observed and rolled back while the codebase is still small is far cheaper than negotiating with it once there is a user base — and a payment flow — to protect.