Pharmacy POS

Case studyPoint-of-sale & inventory rebuildProject 08

Tested · CI green · not deployed (portfolio build) · public repository

Screenshot · synthetic data Pharmacy POS — screenshot of the point-of-sale screen, synthetic seed data

Summary

Pharmacy POS is a web point-of-sale and inventory application for a pharmacy counter: sales with printable receipts, stock received as dated batches and sold earliest-expiry first, suppliers, credit customers with repayments, date-range reports and two staff roles. It was rebuilt from the reference domain model of an older Windows-only C# WinForms pharmacy system; the repository describes that system but does not link to it. The domain (drugs, batches, suppliers, sales, debtors, staff, an events log) follows the original; what is mine is the rebuild: the redesigned schema (integer cents, per-batch stock, typed columns, a token version) and the rules around money, stock and sessions. The engineering worth reading is small and specific: integer cents, a pure batch allocator inside a transaction, a sale the browser cannot price, and a session a server can revoke.

The problem and who it serves

A pharmacy counter breaks a generic shop till in three places: medicine expires, so stock must be tracked per batch and sold from the batch expiring soonest; totals accumulate over thousands of small sales, so money cannot be allowed to drift through floating-point rounding; and customers buy on credit. The older Windows application this was rebuilt from kept one running stock total per drug and stored most values as text.

Counter staff (pharmacist role) selling and recording credit, and a manager who also controls suppliers, staff and deletions. It is a self-hostable portfolio build with a seeded demo; it has no hosted instance and no users, and nothing on this page speaks to regulatory status or fitness for a real pharmacy.

My role

Solo. I set the scope, the data model and the money, stock and session rules, and own the public repository: 7 commits between 2026-06-14 and 2026-09-06, including the September pass that repaired a lockfile no clean install could use, added CI and cleared the dependency advisories reported at the time, with the before-and-after counts recorded in the commit bodies.

Architecture

The diagram is drawn from the code. Money is an integer number of cents everywhere in the schema and is converted only at the form and display edges. Stock availability is computed from non-expired batches, so expired units are shown separately and can never be sold. There is no middleware layer: authorisation lives inside each action and route handler.

Screenshot of the Pharmacy POS point-of-sale screen: a drug picker with stock badges, a cart holding 12 Paracetamol 500mg and 3 Amoxicillin 250mg, and a payment panel showing a total of KSh 96.00.
Screenshot: the point-of-sale screen on a local production build, 2026-10-24. The drugs, supplier and prices are the repository’s own synthetic seed.
Phone-width screenshot of the stock page listing four batches with batch numbers, supplier, remaining against received quantities, and the Paracetamol batch showing 488 of 500 after the sale.
Screenshot: batches at phone width (Pixel 7 viewport) after the sale above. The table scrolls sideways, so the expiry column is off-screen here; the Paracetamol batch reads 488 / 500.

Decisions and trade-offs

  • Integer cents, converted only at the edges. Every price, total, deposit and balance is an integer in cents. toCents rounds to the nearest cent and throws on negative, non-finite or absurd input; formatMoney never throws. The cost is that every form field passes through a conversion, and the 10-million-unit ceiling is a blunt guard rather than a business rule.
  • Per-batch stock with a pure first-expiry-first-out allocator. Each delivery is a batch with quantity, remaining and expiry. allocateFEFO sorts by expiry, skips empty batches, throws on insufficient stock and does not mutate its input; the inventory module calls it inside the caller’s transaction and writes the result back. The trade-off is in that write: it sets an absolute remaining value from the row it read, so the no-oversell guarantee rests on the database serialising transactions. SQLite does; a move to PostgreSQL would need a row lock or a conditional update first.
  • The browser never prices a sale. completeSale validates the cart, merges duplicate lines, reloads each price from the database, decrements stock and writes the sale and its lines in one transaction. A discount larger than the bill is refused. The cost is stale availability in the cart until the next refresh, so a legitimate second sale can fail with “not enough stock” instead of being stopped earlier; that is the behaviour the walk-through reproduced.
  • Sessions a server can revoke. The signed cookie carries a snapshot of the user’s token version, and each request compares it with the database. Logout, a password change and disabling an account each bump the version, so a copied cookie stops working. The cost is a user lookup on every request, which a bare stateless JWT avoids.

Verified results

MeasureResultEvidence
Unit tests in CI24 passed in 2 files (money 16, batch allocation 8)CI run 34045206132, main 6f04341, 2026-09-06; rerun on a fresh clone 2026-10-24. Pure functions, no database
Lint, typecheck, production buildall cleanGated in the same workflow; repeated locally 2026-10-24
Database check script3 / 3 checks (stock decremented by exactly 7, sale persisted, total = price × quantity), then undid its own writesscripts/test-sale.mjs against the seeded SQLite database, 2026-10-24. It mirrors the sale’s database operations rather than calling the production function
CI historyfirst run failed → 3 greenRun 33656928745 (2026-09-02) failed at npm ci on a platform-specific lockfile entry; fixed by regenerating the lockfile with no node_modules present (1f76113)
Dependency advisories1 moderate + 10 high → 0 by 2026-09-06; 8 open on 2026-10-24September counts from commit messages 1f76113, aa59f16 and 6f04341. npm audit on the unchanged lockfile on 2026-10-24: 2 moderate, 5 high, 1 critical (the critical one in next), so these are advisories added since
Local walk-throughsale → stock decrement confirmed in the database → dashboard → PDF receipt (200, application/pdf) → CSV export → pharmacist refused at /staff → unauthenticated routes return 401Production build on localhost with the synthetic seed, 2026-10-24; screenshots on this page
Server-side stock checktwo carts each held the last 5 units; the first sale succeeded, the second was refused and stock ended at 0Same session. The requests were sequential, so this proves the re-check, not behaviour under true concurrency
Quickstart gap foundnpm run seed fails without the database variable exported; npx prisma db seed worksFresh clone, 2026-10-24: the Prisma CLI loads .env and the Prisma 6 client does not. The app itself is unaffected

Limitations and remaining work

  • Not deployed. The repository has no hosting configuration, container file or deployment record, no hosted instance and no users, and nothing here speaks to regulatory status or fitness for a real pharmacy.
  • Test coverage stops at the two pure modules. The 16 server actions, the pages, the PDF and CSV routes and the login flow have no automated tests; the walk-through above is a manual check, not a suite.
  • The no-oversell guarantee is not proven under concurrent load, and the batch update would need a row lock or conditional update before the database could be swapped for PostgreSQL, which the README describes as possible but which has not been tried.
  • Role gating in the code is looser than the README states: a pharmacist can create and edit drugs and receive stock, not only view them. Only deletions, suppliers and staff are manager-only.
  • Dependencies are behind again: npm audit on 2026-10-24 reports 8 open advisories, 1 of them critical in next, and they need a bump before any real use.
  • A sale can be completed with less paid than the total: it is recorded with a balance due and no debtor record, so unpaid amounts outside the credit flow are not tracked.
  • No returns, refunds, sale voiding or stock adjustment other than deleting a whole batch; a credit customer is one drug on credit rather than a customer account; sales use the drug’s catalogue price, which follows the most recent batch, not the batch’s own selling price.
  • The audit log is written but never displayed; drug and supplier create and edit actions do not log at all, and the sale’s audit row is written after its transaction commits, so a failure there leaves a sale with no log entry. The documented quickstart ships two default logins that the README tells the operator to change.