Status: BUILT, DEPLOYED AND VERIFIED LIVE — 2026-10-06. CV1–CV8 are delivered and deployed and Hermes
has verified them live (28/28) against the client's own ERPNext; one refinement finding is recorded below and
CV9's remaining half is the group D guides, which Hermes writes next. Decisions 1 and 2 were settled by
Matthew on 2026-10-06, decision 3 is out on D4.3, and decision 4 is resolved against HMRC VAT Notice 700
§16.6.1/16.6.2.
Provenance: The Counter and the Till — Scope by the Operating-Pharmacy Test,
which ranks this work by what an operating pharmacy must do to trade. The design sketch it tests is
Counter Til. The counter itself is Phase 4's
pharmacy_counter(C1–C13), built, deployed and verified, accepted 2026-09-14. Code facts below were
checked in/root/work/pharmacy_booking_appon 2026-10-06; where the sketch and the code disagree, the
code wins.
In — the two blocking items, then the friction:
price_list_rate as net and ERPNext adds the tax, so a £2.50 standard-ratedOPEN).Out (deliberately):
Tender.basket is a OneToOneField.Nothing here changes it. Items, prices, invoices, tax and payments stay in the client's own ERPNext, reached
over frappe REST and never by writing tab* tables; the counter's own state (basket, tender, refund, till
session) stays in the client tenant's database; an NHS charge keeps the opaque token reference only
(D4.4); card data is nowhere; the central database holds nothing. A parked basket's free-text label is
staff-typed text on the client tier — minimal by design (a short label, never a name the pharmacy does
not need).
pharmacy_counter: dashboard, till, basket, receipt,refund, takings (urls.py), with PharmacyCounterStaffRequiredMixin on every view andPharmacyCounterRefundRequiredMixin on refunds and voids (D4.5).services.post_invoice posts one Sales Invoice, post_payment itsconsume_stock moves stock with the batch, record_tender writes the local tender; theservices.price_for_item reads ERPNext's price list; services.estimate_totals adds the_apply_invoice_totals parses item_wise_tax_detail), so the tax split is already ERPNext's arithmetic.TillSession with opened_by/closed_by, opening_float, counted_cash,expected_cash, cash_variance, and one open session enforced by a partial unique indexTillSession.Meta.constraints, one_open_till_session) — deliberately a database constraint, becauseclean() cannot survive concurrent requests. services.open_till refuses a second; close_till recordsBasket.operator, Tender.operator, Refund.actor,TillSession.opened_by/closed_by, and PharmacyAuditEvent.actor on every audit row.forms.AddLineForm matches item_name/item_code contains, barcode exact.CounterSettings (nhs_prescription_charge, counter_customer_name, receipt_footer)Preference withopt_out, and pharmacy_accounting's takings/VAT report, expected reimbursement, unmatched receipts and| Gate | What it settles | Owner |
|---|---|---|
| G1 | How the inclusive price is carried — an inclusive price list rate in ERPNext, or the inclusive figure computed from ERPNext's own tax detail. Either way, the hard constraint: the invoice's tax arithmetic stays ERPNext's | OpenCode |
| G2 | The operator's till, as settled — current() becomes the operator's own open session; opening while another operator holds the till refuses and names them; the handover is close-then-open |
OpenCode (decided by Matthew 2026-10-06) |
| G3 | The receipt's required content — done below against VAT Notice 700 §16.6.1/16.6.2 | Hermes (done) |
| G4 | The Counter pool still behaves — items, prices and batches present, so the price change is tested against real rows | OpenCode |
| # | Work item | Owner | Size |
|---|---|---|---|
| CV1 | The customer pays the price shown. The basket, the estimate and the invoice agree line by line: the displayed gross is the charged gross, the VAT inside it is ERPNext's figure, and no standard-rated line is charged more than its shelf price | OpenCode | M |
| CV2 | The operator's till. Session scoped to the operator; a refused open names the holder and points at handover; close-then-open handover with the counted figure carried as the next float; till and takings screens address your session; the handover recorded in the audit | OpenCode | M |
| CV3 | Park and resume. One visible act to park a basket with a short label and its item count, and one to resume it; the dashboard shows the parked queue in order. Parking posts no document and reserves no stock — the sale consumes at checkout, as now (D3.4) | OpenCode | S–M |
| CV4 | A scan adds the line. A scanner-wedge input (keyboard, terminates on Enter) that adds the item where the barcode matches exactly one active item, and falls back to the picker where it matches none or several — never guesses | OpenCode | S–M |
| CV5 | The receipt as a document. CounterSettings gains the premises name and address and the VAT registration number (blank where the client is not registered); the printed and emailed receipt can carry the retailers' VAT invoice set — name, address and VAT number, the time of supply, a description identifying the goods, and for each VAT rate the total payable including VAT and that rate (Notice 700 §16.6.2). Nothing beyond that is asserted as required; the GPhC premises number is display-only config, and no source was found requiring it on a receipt |
OpenCode | S–M |
| CV6 | The cash routine closes the day. At close, record the cash taken out to the safe or banked and the float carried forward, against the counted cash; the takings view shows both beside the variance | OpenCode | S–M |
| CV7 | Audit and DSPT evidence for the new acts — park, resume, handover, safe drop: actor, action, target, timestamp; no card data, no clinical content, no patient identifier beyond the opaque token reference | OpenCode | S |
| CV8 | Synthetic seed and tests. Awkward inclusive prices (e.g. £2.50, £4.99, £9.90) proving displayed gross = invoiced gross; the per-operator refusal and handover; park/resume; an ambiguous and an unknown barcode; the safe-drop variance. Documented runner, central database inspected | OpenCode | M |
| CV9 | Report back, then verification, walkthrough and the guides | OpenCode then Hermes | S + S |
Read from HMRC's VAT Notice 700 (retrieved 2026-10-06):
rate ÷ (100 + rate) — one-sixth at 20%. This is the arithmetic CV1 needs, and it is arithmetic onSo: the pharmacy must be able to give a VAT invoice on request, which the receipt's identity and
per-rate content must support. That is a product requirement, not a claim that every till receipt must be one.
Against the client's own ERPNext, with synthetic data:
OpenCode built the counter views in five commits on feature/wda-supplier-lookup — 2d3bfa1 (CV1),
e8e9440 (CV2), 528f2a8 (CV3/CV4), 1bf5628 (CV5/CV6), 9c5cf3a (CV7/CV8) — deployed to the app engine
with pharmacy_counter.0002–0004 and pharmacy.0020/0021 applied to the client tenant. Verified live by
Hermes the same day, against the client's own ERPNext and with synthetic data, through a new committed
verifier — veripath-ops fa46eb0, scripts/verify-pharmacy-counter-views-live.py — which drives the real
tenant through the real OIDC login and then reads the client database, the ERPNext books and the central
database independently of the app: 28/28 checks.
| Acceptance criterion | Evidence |
|---|---|
| 1. the price shown is the price charged | basket 9 / ACC-SINV-2026-00021: a £2.50 shelf item and the £9.90 NHS charge invoice at £12.40, the OTC line carrying net_amount 2.08 and amount 2.50 under tax head VAT @ 20% (included in rate), tax 0.42, item_wise_tax_detail {"DEMO-OTC-PARA-500":[20.0,0.416…],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]}; the app's running estimate (11.98 / 0.42 / 12.40) equals the invoiced figures exactly |
| 2. the operator's till | a second operator's open is refused and names the holder; the handover is a close with the counted figure followed by an open at that figure; the float carried forward equals the counted cash and the variance is reported (0.00), never smoothed |
| 3. park and resume | parks with a short label, appears on the dashboard queue, refuses edits while parked, resumes intact — and parking posts no document and reserves no stock (no invoice, no stock movement) |
| 4. scan-to-line | a unique barcode adds the line in one act; an unknown barcode returns the picker with the scan prefilled (?q=) and adds nothing |
| 5. the receipt | pharmacy identity (name and address), the time of supply, per-line VAT bands and per-rate totals — Standard @ 20.00% 2.50 / Zero-rated @ 0% 9.90 — with the NHS charge zero-rated, never exempt; the VAT number renders when set and is omitted cleanly when blank |
| 6. the cash routine | the close records the counted cash, the cash banked (50.00) and the float carried forward (62.40 = counted − banked), read back from the client database |
| 7. audit | COUNTER_BASKET_PARKED, COUNTER_BASKET_RESUMED, COUNTER_TILL_HANDOVER (naming both operators), COUNTER_TILL_CLOSED (counted / expected / variance / banked / float) and COUNTER_SALE — each with its actor and timestamp |
| 8. the client tier only | the central database holds zero pharmacy% tables; pharmacy_counter.0002–0004 and pharmacy.0020/0021 are applied on the client tenant |
One finding, a refinement rather than a defect (for OpenCode, 2026-10-06). The charge picker lists every
DISPENSED/ENDORSED token, including ones whose charge has already been collected, so an operator can choose a
token and receive the (correct) refusal "The charge for this token has already been collected." Filtering the
form's queryset by the rule services.token_collectable already applies would take a dead choice off the
counter's screen. No double collection is possible — the service refuses it, which the run proved.
Data notes for the guide figures (2026-10-06). The tenant's CounterSettings identity is now seeded —
Synthetic Test Pharmacy (DEMO), 1 Example Street, Testborough, TE5 7ST — and the VAT registration number is
blank: it was set to an obviously synthetic value to prove the receipt renders it, then cleared. Every token the
picker offers now has its charge collected, so a figure showing the picker wants a spare token seeded first.
The till's legacy behaviour, worth teaching. A session opened before attribution (this tenant had one from
2026-09-14, with no holder) is treated as open to anyone, so the first operator to reach it sees "your session"
and takes it over by handover. Every session opened since is attributed, and the refusal names the holder.