Status: Phases 0–6 COMPLETE — the v1 build is finished (every build item delivered, deployed and verified, 2026-09-15).
Post-v1, raised 2026-10-15 and now the next build: the Client Maintenance Surface
— the pharmacy half of the client-VPS patching managed service, which this fork does not have at all (measured:
the client's own Business Manager gets 404 on both maintenance routes and 403 on every GP/dental-shaped view). See
phase-6-accounting for the closing phase.
Phase 0: repository, sector/tenant, Keycloak realm, app-engine stack, HTTPS. Phase 1: identity/role
wiring, premises profile, GPhC/ODS capture with a verification record, and the Responsible Pharmacist
Log — built and verified, with the RP sign-on/off cycle still to be exercised live. See
phase-1-onboarding-rp-log. This page
carries the plan; index carries the status.
Provenance: derived from
research_1and
research_2, then verified against
/root/work/gp_booking_appand/root/work/dental_booking_app(2026-09-12). Where the research
and the code disagree, the code wins — see Corrections below.
The dental app is a fork of the GP codebase: same shared module tree, same settings module name
(dsp_clinic), same SectorDatabaseRouter, plus its own dental/ app registered in DENTAL_APPS.
The pharmacy app is that same move a third time — a fifth sector, not a new architecture:
VPS1 gateway + central Postgres + Keycloak + Wiki.js → metadata and routing only
VPS2 pharmacy_app (Daphne) + celery worker + celery beat + redis → our code
VPS3 ERPNext (frappe code + web/worker/scheduler) → accounting engine
client VPS per-pharmacy Postgres (clinical, POS, stock, ledgers) + the ERPNext site DB
Each pharmacy's ERPNext database lives on that pharmacy's own client VPS, with the Frappe code on
VPS3 reached down the existing tunnels — the pattern already in production for the test-client
library, so it is configuration, not new design.
Confirmed by Matthew 2026-09-12: we hold client-organisation data only. No patient-related data
is held by us at any tier, ever.
| Where | Holds | Must never hold |
|---|---|---|
| Central tier (VPS1) | The pharmacy as an organisation, and only what is needed to identify and route it: ODS code, GPhC premises number, tenant name/subdomain, Keycloak realm, routing/tunnel bindings, onboarding verification status | Anything patient-identifiable; clinical content; prescriptions or dispensing records; POS, stock or financial rows |
| Client-owned VPS (per pharmacy) | Everything operational: patient-identifiable and clinical data (prescription tokens, dispensing, handover), stock and batch/expiry, POS and financial records | — (this is its legitimate home) |
| Never, anywhere central | Patient names, NHS numbers, addresses, contact details, medication or dosage content, prescription or dispensing history | — |
Concretely: business contact details, patient records and payment credentials live on the
client-owned instance, not centrally — matching the split the research itself proposed for its
pharmacy profile. The central tier being stateless is a DSPT selling point; nothing in this plan may
weaken it.
In:
Deferred (named now so they do not creep):
Matthew's condition on the scope: v1 is the complete set of daily operations as understood today,
and further operational modules must be addable as and when needed without restructuring. Two
consequences for the build:
dental sits in DENTAL_APPS), so adding a modulePHARMACY_APPS), not hard-codedOwner = which side is authoritative. Storage = where the data actually lives (SIAAS rule: never
centrally, except the routing metadata).
| # | Module | Owner | Storage | Reuses | New work |
|---|---|---|---|---|---|
| 0 | Sector registration & provisioning | Django (central) | default (central) |
tenancy.Sector / Tenant; SectorDatabaseRouter; tunnel + stack patterns |
Pharmacy sector row, tenant rows, VPS2 stack, nginx vhost |
| 1 | Onboarding & premises identity | Django | central metadata; client DB for the rest | partner/ onboarding pipeline, create_partner_realm command |
GPhC premises/ODS capture + verification record |
| 2 | Responsible Pharmacist Log | Django | client DB | AES signing client, audit logging | New pharmacy_governance-style app: RP shift model, one-active-RP rule, sign-on UI |
| 3 | Dispensing state machine | Django | client DB | EPSFhirClient shape, mock_eps_fhir, USE_MOCK_EPS, AES signing |
Claim/assemble/dispense/endorse states + transitions, token model, endorsement data |
| 4 | Stock & inventory | Django orchestration → ERPNext | client DB (both) | ERPNextClient (REST), ERPNext batch/expiry natively |
FEFO selection logic, goods-in, expiry warnings, SNOMED↔item mapping |
| 5 | OTC counter checkout | Django owns intent; ERPNext owns the document | client DB | PaymentConfig / Stripe patterns, ERPNext POS + Sales Invoice |
Counter screen, terminal handshake, VAT/zero-rated split rules |
| 6 | Patient notification | Django | client DB | nhs_notify.py, ReminderLog |
Pharmacy templates, collection-ready trigger, opt-out handling |
| 7 | Accounting & reporting | ERPNext | client DB | provision_erpnext_config, full ERPNext + healthcare_accounting |
Pharmacy COA wiring, retail VAT vs zero-rated treatment, reimbursement tracking |
| 8 | Patient-facing (later) | Django / PWA | client DB | patient-portal-pwa, patient_portal app |
Pharmacy collection flow in the PWA |
Standing rule for every module: ERPNext is reached over frappe REST via a client object —
never by writing tab* tables with raw SQL.
Fork-and-strip, so these come for free and must not be re-invented: the sector database router and
tenant registry; Keycloak realm/role provisioning and the RBAC middleware; the AES portal signing
client; the NHS Notify wrapper and ReminderLog; the ERPNext REST client and site-provisioning
command; per-tenant encrypted payment configuration; the patient PWA shell; and the DSPT hygiene
stack (audit logging, Fernet-encrypted fields, Lynis scanning, DefectDojo).
| Research claim | Reality |
|---|---|
New PharmacySectorConfig central model |
Redundant — tenancy.Tenant already holds subdomain, encrypted DB credentials, host/port, config JSON, MinIO host/port |
Inventory task writes tabBatch / tabBin / tabStock Entry by raw SQL |
Off-pattern — use frappe REST via ERPNextClient (Item, Customer, Sales Invoice, Payment Entry, Account, Journal Entry all already implemented, incl. submit/cancel) |
| The ERPNext REST client is already complete (Item, Customer, Sales Invoice, Payment Entry, Account, Journal Entry) | Corrected 2026-09-14: true of the dental repo, not this one. pharmacy_booking_app/integrations/erpnext_client.py has items, customers, sales invoices, payments and PDF fetch only — no warehouses, batches, stock entries, item prices, accounts, journal entries, or submit/cancel. Phase 3's gate G1 is to extend or port it |
| A site-provisioning command comes for free | Corrected 2026-09-14: provision_erpnext_config exists in the dental repo; there is no equivalent in the pharmacy repo. Phase 3's gate G2 |
Dedicated pharmacy_governance app for pharmacy settings |
Largely redundant with Tenant.config; keep a new app only for the RP log (module 2) |
post_save signal fires ERPNext work in-band |
Real code funnels ERPNext writes through Celery tasks |
| Draft code mixes ERPNext POS frontend with Django payment routing | Resolved: Django owns the payment config and intent, ERPNext owns the accounting document |
Verified by Hermes against the repository, Keycloak, the running stack and the ops config on
2026-09-12 (not taken on OpenCode's word alone).
| Item | As built |
|---|---|
| Repository | New private repo, forked from the GP app's main (the newest common base — 24 commits past the dental/GP common ancestor, so it carries the latest shared security and infra fixes), branches main + dev |
| Stripped | The stale dental/ app and the GP-only pcn_booking/; every dental reference cleaned out of patient_portal, tenancy/views, dashboards/routing, settings, urls and the appointment commands |
| Added | pharmacy/ app skeleton — no clinical models — with a landing view; router gains PHARMACY_APPS and a data-driven SECTOR_APP_PARTITIONS registry; sector_pharmacy DB alias on its own env vars |
| Sector + tenant | Sector slug pharmacy; first tenant VeriPath Pharmacy (subdomain pharmacy) with encrypted DB credentials; pharmacy database created on the client Postgres and migrated (sector-DB pattern, no clinical tables) |
| Keycloak | Realm pharmacy with clients pharmacy-booking-app + erpnext, and all four roles created verbatim on both clients and as realm roles — RESPONSIBLE_PHARMACIST, DISPENSING_TECHNICIAN, COUNTER_ASSISTANT, BUSINESS_MANAGER — one group per role mapped to both clients |
| Stack (app engine) | pharmacy_app (port 8020) + celery worker + celery beat + dedicated redis, mirroring the corrected dental pattern (no local client Postgres service; Redis password kept off the healthcheck command line). All four containers healthy; the skeleton page serves |
| Wiring | App-tunnel forwards 8020; ops config for the vhost, bring-up, static sync and the stack guard updated and installed live; static files synced |
| TLS | pharmacy.veripath.co.uk resolves to the primary VPS and serves HTTPS (Let's Encrypt certificate, auto-renewing); HTTP 301s to HTTPS; HSTS, frame-deny, nosniff and referrer-policy headers present |
Tenant addressing: the app resolves tenants by host. The sector apex tenant is served at
pharmacy.veripath.co.uk; because a tenant subdomain is globally unique, a client premise is e.g.
test-client-pharmacy → test-client-pharmacy.pharmacy.veripath.co.uk (host prefix = tenant
subdomain, same as the GP and dental sectors).
test-client.pharmacy…, which would require decoupling host prefix from tenant subdomain. Theinfrastructure.ClientVPS.Sector has no pharmacy choice. Adding one requires a migration onveripath realm. Wiring the pharmacy realm intoFour operational roles agreed 2026-09-12. Client roles are issued on the Django app client and on
the ERPNext client; assigning a user to a group grants the mapped roles.
| Role | Django client roles | ERPNext client roles | Deliberately blocked |
|---|---|---|---|
| Responsible Pharmacist (clinical lead) | sign the RP log; dispense medication; pull EPS tokens | counter POS access (limited) | corporate accounting ledgers |
| Dispensing Technician / Assistant | dispense medication; pull EPS tokens | counter POS access; manage inventory records | corporate accounting ledgers |
| Counter Assistant | none — no access to NHS prescription queues | counter POS access | clinical records, accounting ledgers |
| Business Manager / Owner | none — no clinical access unless separately registered as a pharmacist | view financial ledgers; manage inventory records | patient clinical records |
Design principle carried over from the research and worth keeping: clinical roles are separated from
commercial data views, and commercial roles from clinical records, as a data-minimisation control
for DSPT Category 3. Enforce at the view/queryset layer, not only in navigation — the dental app's
role permissions matrix records
exactly the gap that leaves when nav gating is treated as authoritative.
These leak into every model, screen and manual, so settle them first:
Phase 4 additions (RATIFIED 2026-09-14 — see the Phase 4 page): basket (the transaction in
progress; not "cart", "order" or "transaction"); sale (the completed transaction; a receipt is the
paper); tender (the money taken at the counter, with a method — the ERPNext document stays a Payment
Entry); change; refund (the money event, where the goods also come back); void (abandoning a sale —
ERPNext cancels the document, the operator voids); till session (never "shift", which belongs to the RP
log); takings (the end-of-day total by tender and VAT band, not a "Z-report"); and counter customer
(the one standing anonymous customer in ERPNext, per D4.4).
Phase 3 additions (RATIFIED 2026-09-14 — see the Phase 3 page): receipt (not
"goods-in", which stays a screen label) for stock arriving; consumption for stock leaving against a
dispensed script (not "sale", which is Phase 4's counter event); adjustment for everything else (not
"correction" or "write-off", which are reason values); pack with a pack size; item as the thing
recorded against; batch with its expiry (never "lot"); and the two pools Dispensary and
Counter.
| Phase | Content | Gate |
|---|---|---|
| 0 | COMPLETE 2026-09-12 — repository fork-and-strip; pharmacy sector row + first tenant; Keycloak realm; stack skeleton; HTTPS live on pharmacy.veripath.co.uk |
none |
| 1 | BUILT 2026-09-12, verified — spec: phase-1-onboarding-rp-log. Identity/role wiring, premises profile, GPhC/ODS capture with a verification record, Responsible Pharmacist Log | phase 0 ✅ |
| 2 | COMPLETE 2026-09-14 — built, verified, deployed — spec: phase-2-dispensing. W0–W9 all built; the routing guard, client-tier dispensing models, pharmacy-side mock EPS surface, the ratified state machine, dispensary screens and role gating. All five verification findings are fixed and migration 0002 is applied on both client-tier instances; origin/dev = 1634804. Outstanding: only the W12 roles-and-permissions guide — published 2026-09-15 |
phase 1 ✅ |
| 3 | COMPLETE 2026-09-14 — built, verified, ACCEPTED — spec: phase-3-stock. Authorised with all seven decisions settled and the vocabulary ratified; G1/G2 and S1–S12 built and pushed (origin/dev = 1716142); 141 tests green; migrations applied both client-tier sides; zero pharmacy tables centrally; live ERPNext Stock Entries verified directly. The S5 blocker is closed and re-verified live by Hermes (a dispense now consumes the FEFO batch, writes the movement and posts the Material Issue, position 15.0 → 13.0), as is a hidden batch-position bug it had masked |
phase 2 ✅ |
| 4 | ACCEPTED 2026-09-14 — built, deployed and verified — spec: phase-4-counter-checkout. Gates closed and verified; the pharmacy_counter app (C1–C13) built and pushed (origin/dev = eb1ce70), 183 tests green; verified live by Hermes from ERPNext itself — one submitted Sales Invoice with the VAT split in ERPNext's own item_wise_tax_detail (standard 2.50/0.50, zero-rated NHS charge 9.90/0.00), a Payment Entry referencing counter:1 with no patient identity, stock consumed 60 → 59 and returned 60 on the refund; six counter tables on both client-tier instances and zero pharmacy tables centrally. One open product decision: counter prices are net as built, and a UK counter price is VAT-inclusive |
phase 3 ✅ |
| 5 | ACCEPTED 2026-09-15 — built, deployed and verified — phase-5-notifications. N1–N11 built as the client-tier app pharmacy_notifications (origin/dev = d77621f), 226 tests green, verified live by Hermes (never-notify on CANCELLED/ASSEMBLED/CHECKED, collection records the actor, delete() raises, the D5.2 payload clinical-free). Gate G5.1 closed 2026-09-15 — a Notify client does exist (services/nhs_notify.py) but is a sandbox-pointed placeholder skeleton, so N2 was net-new work |
phase 4 ✅ |
| 6 | COMPLETE 2026-09-15 — the closing phase. One acceptance criterion outstanding (A8, Hermes's) — phase-6-accounting. Chart-of-accounts wiring, the expected reimbursement model and its reconciliation, takings/VAT/stock/margin reporting read from ERPNext, the period-end pack and the bookkeeper handover documentation. All six decisions settled and the vocabulary ratified 2026-09-14: income only when the money arrives, patient charges collected for the NHS go against the receivable, not to income, reports-only automation at period end. A1–A11 delivered, deployed and verified (e6a5fbb, 276 tests green); 7 of 8 acceptance criteria proven; the gap is criterion 6, A8 the handover pack (Hermes's). Demo gap: no Business Manager identity exists, so the BM-only reports cannot be opened |
phase 5 ✅ |
| Later | CDs register; procurement; DCB0129 expansion; click & collect; live NHS Spine | DSPT Standards Met for live EPS |
Sequencing rationale: phase 1 is the legally blocking piece (a pharmacy cannot open without a
Responsible Pharmacist on duty), and phases 2–3 are the operational core. Compliance work (audit,
encryption, RBAC at the view layer, DSPT evidence) is built into each phase rather than bolted on.
Both are pointless before there is testable behaviour, so they are sequenced after implementation:
guides/dental_app_guides.pharmacy-app-guides skill — the per-workflow companion to dental-app-guides; mint it onceClientVPS.Sector — DONE 2026-09-13. The pharmacy choice exists (migration 0007) and thepharmacy and/pharmacy/ at the view layer by role.issue_prescription / check_status / cancel_prescription /search_prescriptions and nothing else; there is no claim, release, return, dispense or endorseEPSFhirClient shape,mock_eps_fhir") is therefore optimistic: the pharmacy-side EPS surface has to be designed andAESClient.sign_prescription() takes patient/medication/pharmacy fields and returns aprovision_pharmacy_erpnextprovision_pharmacy_counter provisions counter stock; the live site istest-client.accounts.pharmacy.veripath.co.uk.research_1 and research_2, with a dated note on each page). Both remain design provenance, not shipped behaviour.