Status: ACCEPTED 2026-09-14 — authorised by Matthew the same day with all eight decisions settled and
the vocabulary ratified, the gates closed and verified, and the counter (C1–C13) built, deployed and
verified against the artifacts by Hermes (see "As delivered" below). One open product decision:
counter prices are net as built, and a UK counter price is VAT-inclusive (finding 1 below).
Phases 5 and 6 are authorised and specified, each gated on the phase before it. The unblocked first work is the two gates no decision
touches — G4.1 (the ERPNext tax-template surface) and G4.2 (confirming the Counter pool has items,
prices and batches) — exactly as Phase 3's G1/G2 ran ahead of that phase's decisions. The two gates that remain are G4.1 (the ERPNext tax-template surface) and
G4.2 (confirming the Counter pool has items, prices and batches), both OpenCode's pre-work. This page follows the shape of
Phase 2 and
Phase 3: scope in and out, the data boundary,
what exists to build on, pre-work gates, work items, decisions held for Matthew with a recommendation
each, acceptance and risks. Build order is gated: the module map sets Phase 3 as Phase 4's gate, and
Phase 3 was accepted on 2026-09-14.
Phase 4 of the agreed plan in module-map — its
module 5 row (OTC counter checkout: Django owns the intent, ERPNext owns the document). This is the
first phase where the pharmacy takes money, and the first that must handle VAT properly.
In:
Out (deliberately):
Counter data is the client's commercial data on the client tier, with one new twist to decide
explicitly: an NHS charge collected at the counter concerns a dispensing token that holds patient
identifiers.
| Data | Where it lives |
|---|---|
| Items, prices, sales invoices, tax, payments, takings | The client's own ERPNext, reached over frappe REST |
| The Django-side counter state (basket, tenders, refunds, till sessions) | A PHARMACY_APPS app in the client tenant's database |
| The link from a charge collected here back to the dispensing token | An opaque token reference only — see D4.4 |
| Card data | Nowhere. The app records that a card tender happened; it never sees the PAN |
| Anything at all in the central database | Nothing |
appointments/payment.py creates Stripeappointments/models_payment.py holds PaymentConfig (per practice, with aPaymentTransactionpaid_by, recorded_by). So the pattern — per-premises encrypted paymentcreate_sales_invoice, submit_sales_invoice, cancel_sales_invoice, get_or_create_item,get_item_price/create_item_price, warehouse ensure/list, stock entries with submit and cancel, andappointments/invoice_models.py withgrep for tax/POS in integrations/erpnext_client.py returns nothing butEndorsementRecord.charge_due, exemption category and| # | Work item | Owner | Indicative size |
|---|---|---|---|
| C0 | Gates G4.1–G4.5 closed, decisions recorded | OpenCode (G4.1/G4.2), Hermes (G4.4), Matthew (G4.3) | S |
| C1 | The counter app on the client tier — registered in PHARMACY_APPS: basket, line, tender, refund and till-session models. No prices, no tax and no stock held as truth here; ERPNext owns all three |
OpenCode | M |
| C2 | ERPNext tax and invoice surface — tax-template support in the client, and a sale that posts one submitted Sales Invoice with its Payment Entry and cancels cleanly on a void | OpenCode | M–L |
| C3 | The counter screen — item search/scan, quantities, live line prices from ERPNext, the VAT treatment shown per line, basket total | OpenCode | M–L |
| C4 | Selling consumes Counter stock — through Phase 3's service with reason sale (batch recorded); zero/negative stock warns and records rather than blocking (mirroring D3.4) | OpenCode | S–M |
| C5 | NHS charge collection — read the dispensing token's declaration; collect where the charge is due; record the tender against the token by opaque reference (D4.4). An exempt declaration must not be collectable | OpenCode | M |
| C6 | Tender and receipt — method, amount, change, operator; a printable/emailable receipt | OpenCode | M |
| C7 | Refunds and voids — reason-bearing, actor-recorded, ERPNext cancellation, stock returned where the item comes back (D4.5) | OpenCode | M |
| C8 | End of day — takings by method and VAT band, cash-up count against recorded cash, variance reported not hidden | OpenCode | S–M |
| C9 | Role gating — who may sell, who may refund, who may open and close a till; refusals tested at the view layer for every role, including the ones kept off it | OpenCode | S–M |
| C10 | Audit and DSPT evidence — actor, action, target, timestamp for every sale, refund, void and till event; no card data, no clinical content | OpenCode | S |
| C11 | Synthetic seed — obviously fake items at both VAT treatments, a sale, a refund, a till session, an NHS charge collected against a seeded token | OpenCode | S–M |
| C12 | Tests and the documented runner — the VAT split per line and in the totals, sale posts once under retry, void cancels cleanly, refund returns stock, RBAC refusals, and that no counter row or document reaches the central database | OpenCode | M |
| C13 | Report back, then verification, walkthrough and the user guides | OpenCode then Hermes | S + S |
Matthew answered all eight on 2026-09-14, every one as recommended. Recorded as settled with the
reasoning kept, as Phase 2's and Phase 3's were — the reasoning is what a later reader needs when a
trade-off is revisited.
| # | Decision | Settled |
|---|---|---|
| D4.1 | How cards are taken at the counter — integrate Stripe Terminal (card-present via the existing Stripe account), or record the tender from the pharmacy's existing standalone PDQ terminal, or online PaymentIntent on a tablet | SETTLED: record the tender from the existing terminal. It keeps the app out of card data entirely (the PAN never touches us or ERPNext, so the PCI and DSPT surface stays where it is today), needs no new hardware, and reuses the PaymentConfig/PaymentTransaction pattern. The trade-off is real and worth stating: no automatic reconciliation against Stripe — the operator records what the terminal says. Stripe Terminal is a later, separately-costed option |
| D4.2 | Cash handling — record cash tenders with change and a cash-up count, or leave cash out of v1 | SETTLED: record it. A counter that cannot take cash is not a pharmacy counter; the cash-up variance is also the control that catches mistakes |
| D4.3 | Split tender (part card, part cash) in v1 | SETTLED: not in v1. One tender per sale; a split adds reconciliation complexity for little daily value in a small pharmacy |
| D4.4 | What the sale records about an NHS charge — an anonymous "NHS prescription charge" line, or a named customer/patient on the invoice | SETTLED: an anonymous line plus the opaque dispensing-token reference. ERPNext needs a customer for a Sales Invoice, so use a single standing counter customer (as the dental app does for anonymous retail) rather than creating per-patient customers — patient identity stays on the client database in the dispensing record, where it belongs |
| D4.5 | Who may refund or void | SETTLED: RP or Business Manager, with a reason, and never a silent edit — the same append-only discipline as the RP log, Phase 2's transitions and Phase 3's movements |
| D4.6 | Behaviour when ERPNext or the network is unavailable | SETTLED: no offline sales: the document must post before money is taken, and a failed post leaves the basket intact with a clear message. Half-posted money is worse than a short queue; an offline queue is a later phase if it is ever needed |
| D4.7 | Receipt delivery | SETTLED: both — printed through the browser (no printer integration) and emailable, with email only where the customer asks |
| D4.8 | Do zero-rated NHS and standard-rated OTC share one till session | SETTLED: one till, split by VAT band in the totals. Two sessions would double the end-of-day work for a pharmacy with a single counter |
The same discipline as Phase 2's terminology and Phase 3's movements: one word per concept, pinned before
it becomes a model field or a screen label, and nothing here renames either of those ratified sets.
| Concept | Use | Do not use |
|---|---|---|
| The transaction in progress at the counter | basket | "cart", "order", "transaction" |
| The completed transaction | sale | "order", "receipt" (that is the paper), "transaction" |
| The money taken at the counter, and its record | tender (with a method) | "payment" for the operator's act — the ERPNext document stays a Payment Entry, which is a different thing |
| Money returned to the customer as part of a tender | change | "balance", "cash back" |
| Money returned for goods that come back | refund (the money event; the stock side is the returned goods) | "return" as the name of the money event, "credit" |
| Abandoning a sale | void | "cancel" in the UI — ERPNext cancels the document; the operator's action is a void |
| A till's open-to-close period | till session | "shift" (that belongs to the RP log), "drawer", "Z" |
| The end-of-day total by tender and VAT band | takings | "sales report", "Z-report", "X-report" |
| The stock pool sold at the counter | Counter (ratified in Phase 3) | "shop", "retail" |
| The standing anonymous customer in ERPNext (D4.4) | counter customer | "walk-in customer", "anonymous patient" |
Recorded on the module map's pinned terminology list beside Phase 2's and Phase 3's terms, ratified on the
same "all as recommended" instruction that settled the eight decisions. Unlike Phase 2's, this vocabulary
is pinned by no applied migration — the counter app is new — so a later rename stays cheap; as ratified
they are fixed.
OpenCode built the counter as a new client-tier app, pharmacy_counter, in commits 20d6160 (C1–C12) and
1936921 (the live verification command), on top of the gate work 07482ad; origin/dev = eb1ce70.
Deployed to the app engine, with pharmacy.0009/pharmacy_counter.0001 applied to central +
sector_pharmacy and, on the client box, via migrate_client with per-app labels. Verified against the
artifacts:
| Checked | Evidence |
|---|---|
| Tests | Independent re-run here: 183 tests, OK (was 157) |
| The sale, read from ERPNext | ACC-SINV-2026-00015 (docstatus 2 — cancelled by the refund): currency GBP, taxes_and_charges Pharmacy Counter VAT - TCP, customer Counter Customer (the standing counter customer, D4.4 — no per-patient customer); net 12.40, tax 0.50, gross 12.90; lines DEMO-OTC-PARA-500 net 2.50 (standard-rated template) and NHS-PRESCRIPTION-CHARGE net 9.90 (zero-rated template); tax row VAT - TCP @ 20% with item_wise_tax_detail = {"DEMO-OTC-PARA-500":[20.0,0.5],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]} — so the split is ERPNext's own arithmetic, standard 2.50/0.50 and zero 9.90/0.00 exactly as the decision required |
| The payment | ACC-PAY-00001 (docstatus 2), paid_amount 12.90, receivable → till account, reference_no counter:1 — an opaque local reference, and no patient identifier anywhere on the financial documents |
| Stock | MAT-STE-2026-00025 consumed DEMO-OTC-PARA-B1, and the refund returned it: the Counter position read 60 → 59 → 60 across the sale and the refund. DEMO-OTC-IBU-200 untouched |
| The settled decisions, in the code | One tender per sale (D4.3) and the tender "records that a card or cash tender happened; no card data is ever stored" (D4.1); the invoice posts before the tender and a short tender is refused (D4.6 — the INVOICED step); refunds gated by PharmacyCounterRefundRequiredMixin (RP or Business Manager only, D4.5); receipt printable and emailable (D4.7); one till session with takings split by band (D4.8); a NHSCharge line kind carrying only the token reference (D4.4) |
| Role gating | PharmacyCounterStaffRequiredMixin on every counter view (RP, Technician, Counter Assistant, Business Manager) — the Counter Assistant is the primary actor here, as Phase 2 intended — with refunds restricted as above |
| The boundary | Six pharmacy_counter_* tables in the apex sector_pharmacy partition and on the client box; the central database holds zero pharmacy% tables. pharmacy.0010 and pharmacy_counter.0001 are applied on both client-tier instances |
| The finish-off items alongside it | Also pushed and verified: the RP log's "signed off by" snapshot (pharmacy.0010), the git-history purge (verified gone with git log --all -S), the orphaned database dropped (dumped first), migrate_client now defaulting to the tenant's sector apps, a migration-history reconciliation that lets a fresh database build from zero, and the Keycloak SSO script |
1010 - Clinic Till / Cash on Hand - TCP — a GP/dental-flavoured account name on a pharmacy site.healthcare_accounting.api.create_payment_entryPhase 4 is done when, against the client's own ERPNext and with synthetic data: