Summary: A full-stack checkout system built on Hono and React, covering real user accounts, role-based authorization, server-validated discount codes, a RabbitMQ event pipeline, and a customer-facing self-service cancellation flow. Nine backend tables, 137 backend tests, and a 30-scenario Playwright suite run across three browsers.
Links: GitHub: github.com/bbornino/hono_checkout_flow
Tools & Tech Stack
- Backend: Hono, Drizzle ORM, PostgreSQL 16, Zod
- Auth: JWT (jsonwebtoken), bcryptjs, role-based authorization (customer / admin)
- Messaging: RabbitMQ (amqplib), producer and consumer as separate processes
- Frontend: React, Vite, Tailwind CSS, shadcn/ui, React Router v7, TanStack Query, Zustand, React Hook Form, Axios
- Testing: Vitest (137 backend tests), Playwright (30 E2E scenarios, Chromium/Firefox/WebKit)
- Infrastructure: pnpm workspace monorepo, Docker (Postgres + RabbitMQ)
Browsing and the Cart
The Products page pulls live inventory from the API, not a static list. Each card shows current price, SKU, and active/inactive status, with an Add to Cart button that updates a Zustand-backed cart persisted across page reloads.

That “Soy Wax Candle” card marked Inactive is real seed data doing its job. The product still exists and can still be looked up by id, it just doesn’t get sold. Deleting a product outright is blocked at the database level if it’s ever appeared on an order, which is exactly the scenario isActive exists to avoid forcing.
Checkout
This is the actual point of the app. A logged-in customer picks a shipping address (with an optional separate billing address), enters a discount code, and places an order. Everything shown before submission is explicitly labeled an estimate. The real total only exists once the server recalculates it from scratch inside the order transaction.

The discount line above isn’t guesswork on the frontend’s part. Typing a code and clicking Apply calls a dedicated POST /discounts/validate endpoint that checks the code’s validity, active status, date range, and usage limit, then returns just enough to render this line: the type, the amount, nothing about internal ids or how many times it’s been used. When Place Order is actually clicked, POST /orders independently re-validates the same code against the same rules, inside the same transaction that calculates tax, applies the discount, and writes the order’s line items. The frontend’s check is a convenience. The order’s own check is the one that can’t be skipped.
Order History and Self-Service Cancellation
The Orders page keeps the list compact by default and expands a card in place to show line items, rather than navigating to a separate detail page for every click.

The Cancel button only appears when the order’s current status actually allows it (pending, paid, or fulfilling). That’s a display convenience, not the actual security boundary. The real enforcement lives in PATCH /orders/:id, which checks both the requester’s role and, for a customer, that the order belongs to them, before it ever looks at whether the requested status transition is legal. An admin can move an order through any step in the pipeline; a customer can only ever ask for cancellation, on their own order, and only while it’s still cancellable.
RabbitMQ as a Real Second Process
Every successful order publishes an order_placed message to a durable queue. Nothing about this lives inside the API process. The consumer runs as its own long-lived Node process, started separately, listening continuously and acknowledging each message once it’s handled.

The consumer terminal below shows the other half of the same event, captured moments after placing a real order through the checkout page above, not a canned example.

Durability matters here more than it sounds like it should for a learning project. The queue is declared durable specifically so a message published while the consumer happens to be down doesn’t just vanish. It sits waiting until something picks it up.
Auth: Two Tables, One Login
users holds credentials and role. customers holds shipping info and order history. A nullable userId foreign key links them. A guest checkout gets a customers row with no linked user at all. A registered shopper gets both, created together in one transaction. An admin gets only a users row, since admins never place orders of their own.
Every route touching customer-owned data follows the same shape: pull userId off the verified JWT, resolve the linked customer, compare its id against whatever the request is trying to reach. A mismatch returns 404, not 403. A customer probing with someone else’s real id gets an identical response to one using a made-up id, so the response itself never confirms whether a given record exists.
Test Coverage
Every route across all eight backend feature files has coverage for its actual authorization boundary, not just its happy path: role checks, ownership checks, and the two-step discount validation all get exercised directly.

The Playwright suite runs the same purchase journey (and its edge cases: an empty cart, an expired discount code, a customer with no saved addresses) against three separate browser engines in one run.

Challenges
The discountId that wasn’t. Early in building checkout, POST /orders accepted a discountId directly. Redesigning that into the code-based validation flow above meant the schema, the calculation logic, and the transaction all needed to agree on where the real id came from. One of the three didn’t get updated. The transaction kept reading a field that no longer existed anywhere upstream, silently undefined, and orders started shipping with no discount attached despite a valid code being entered. Nothing threw an error. The bug only surfaced once a test asserted on the actual discount amount instead of just the response status.
A security fix that broke its own test data. POST /auth/signup originally let anyone link a new login to an existing guest customer record just by knowing its id. Closing that meant requiring the submitted email to match the customer’s email on file. The fix was correct and immediate. Every existing test that signed up a “linked” account was generating a fresh random email and expecting the link to succeed anyway, which the new check correctly rejected. Tracing a wall of 401s back to one shared test helper that never passed the customer’s real email through took longer than writing the actual fix.
Windows, Vite, and a folder named @. shadcn’s CLI has a known bug on Windows where it writes generated components into a literal folder named @ instead of resolving the @/ alias to src/, even with components.json and vite.config.ts both configured correctly. The fix became a two-step ritual after every shadcn add: move the files to where they actually belong, delete the stray folder, and, on one occasion, fix a typo introduced during that manual move (componets instead of components) that only got caught because the alias in components.json was spelled correctly and no longer matched.
A password hidden in a race condition. A Playwright test for account settings temporarily changed the shared seeded customer’s password mid-test, then reverted it. That worked fine alone. Running the full suite in parallel, an unrelated checkout test occasionally tried to log in with that same account in the exact window the password was temporarily different, and failed with a completely accurate “invalid password” error that had nothing to do with checkout. The fix mirrored a lesson the backend suite had already learned: give every test its own throwaway account, and stop letting any test mutate shared fixtures other tests depend on staying stable.
Lessons Learned
Auth belonged in the schema from the first table, not retrofitted onto seven already-shipped feature files one at a time. Every retrofit turned up something real: a route with no ownership check at all, a stale field left behind by an earlier refactor, test helpers written before auth existed and never updated. That’s a decent sign the checks were worth adding, but it also meant re-touching and re-verifying code that had already worked once.
The recurring pattern underneath most of the harder bugs wasn’t really about syntax. Adding a discount code to checkout wasn’t a schema change, it was a decision about who’s allowed to know a discount exists before they’ve used it. Letting a customer cancel their own order wasn’t a button, it was a decision about exactly which transitions a customer should ever be trusted to request on their own. The code that actually shipped each feature was the easy part once that decision was made explicitly instead of assumed.
Setup instructions, environment variables, and seeded test accounts are documented in the repo README.