Status: AUTHORISED by Matthew 2026-09-14, with all six decisions SETTLED and the vocabulary (D6.6) RATIFIED the same day ("all as recommended"). Planning is complete. Phase 5 was ACCEPTED 2026-09-15, so this phase's gate is satisfied and the build is OPEN. Gate G6.1 is CLOSED (2026-09-15) — see "G6.1 — the site's chart of accounts" below. A1 (the chart-of-accounts wiring) is DELIVERED and verified live 2026-09-15 — see "A1 — as delivered".
A1–A11 are DELIVERED, deployed and verified — the build is COMPLETE (four verification rounds, 2026-09-15;
origin/dev = e6a5fbb, 276 tests green on my own run).
Seven of the eight acceptance criteria now hold — 1, 2, 3, 4, 5, 7 and 8 — each proven live (see
"Re-verification round 4"), and A8 — the bookkeeper handover pack — was WRITTEN and verified live on 2026-10-02
(see "A8" below; the pack is published at
The Bookkeeper Handover Pack). The only step left in
this phase is the formal acceptance of criterion 6, which is Matthew's — nothing in the build is outstanding and no
code remains. Correction to an earlier line on this page: it previously listed "tests 2, 3, 4, 5, 6 and 7" as holding;
criterion 6 is the handover pack and cannot hold before A8 exists, so it was wrongly counted — the correct score is
7 of 8.
See "A2–A11 — as delivered and verified" and its three re-verification sections below. This page follows the shape of
Phases 2,
3 and
4.
Sequencing note, stated up front. Phase 4's counter (C1–C13) is built, deployed and accepted (2026-09-14), and Phase 5 is authorised and GO — its gate G5.1 was closed 2026-09-15. Phase 5 is built, deployed and accepted (2026-09-15), so Phase 6 is now a build order. Its first item is the pharmacy's own chart-of-accounts wiring, because G6.1 found the site's chart is a clinical one (below).
Phase 6 of the agreed plan in module-map — its module
7 row (accounting and reporting: ERPNext configuration and ledgers, reusing the proven integration). This
is the phase that makes the pharmacy's numbers usable by the people who have to answer for them: the
owner, and the client's bookkeeper. It is also the phase where the whole programme's financial claims
either tie out or do not.
In:
Out (deliberately):
| Data | Where it lives |
|---|---|
| The ledger, invoices, payments, journals, tax | The client's own ERPNext, reached over frappe REST |
| The expected-reimbursement record and the reporting configuration | The client tenant's database, in the client-tier partition |
| The actual reimbursement (what the NHSBSA or the bank actually paid) | ERPNext, posted as a document — not a figure held in our own tables |
| Anything at all in the central database | Nothing |
The rule that governs this phase: there must be one ledger, and it is ERPNext's. The expected
reimbursement is an estimate and is marked as one; nothing in our database is ever the last word on a
balance, a tax figure or a stock value.
get_account, create_account, ensure_account, list_accounts, create_journal_entry,submit_journal_entry, submit_sales_invoice, cancel_sales_invoice, create_payment_entry,get_invoice_pdf — alongside the stock surface. The building blocks for posting and correcting exist;item_wise_tax_detail. I verified that on a submitted invoice myself: net 15.00 (10.00 standard + 5.00{"G4-TAX-STD":[20.0,2.0],"G4-TAX-ZERO":[0.0,0.0]}. The VAT figures forOn Net Total row taxes[].net_amount is the whole invoice net, so the band split must be read fromitem_wise_tax_detail or the per-line template.PharmacyERPNextConfig carries the site, keys, company, thevat_account and standard_rate; provision_pharmacy_erpnextEndorsementRecord (the local endorsement and thehealthcare_accounting on the ERPNext side (the app the GBPerpnext-deploy), which is where a pharmacy-side patch belongs if one isRead from the live site (Test Client Pharmacy, abbr TCP, 115 accounts), through ERPNextClient
— not from the provisioning plan. The short answer: the chart is a clinical (GP/dental) chart with
pharmacy-shaped labels bolted on. It is perfectly workable; it is simply not wired for a pharmacy, and
three of the four things D6.1/D6.2 need do not exist at all.
What the site has that we can use
| Need | Account on the site | Note |
|---|---|---|
| VAT (the Phase 4 split) | VAT - TCP (type Tax) |
Already used by the counter; G4.1 proved the split |
| Stock | 1410 - Stock In Hand - TCP, 1420 - Medical Supplies Stock - TCP (type Stock) |
|
| Cost of sales | 5111 - Cost of Goods Sold - TCP (type Cost of Goods Sold) |
|
| Stock adjustment | 5119 - Stock Adjustment - TCP |
Phase 3 adjustments |
| Cash / bank | 1010 - Clinic Till / Cash on Hand - TCP (Cash), 1110 - Cash - TCP, 1030 - Stripe Clearing Account - TCP (Bank) |
|
| Receivable | 1120 - AR - Patients, 1130 - AR - Insurers, 1140 - AR - Corporate Memberships, 1310 - Debtors |
All payer receivables — none is the NHS |
| Income | 4040 - Prescribing & Dispensing Income - TCP, 4110 - Sales - TCP, 4120 - Service - TCP |
One combined line, not split by band |
What is missing — and therefore A1's work
4040 - Prescribing & Dispensing Income4110 - Sales. D6.6 requires reporting by band, and D6.1 requires income recognised1010 - Clinic Till / Cash on Hand — a GP/dental-flavoured name on a pharmacy site.Two further observations
VAT Zero Rated - TCP exists but is not used, and should stay that way. G4.1 established that theVAT - TCP account at 0%; a separate zero-ratedFee Income - Private Consultations, Medical Insurance,Occupational / Corporate Health, Missed Appointment Fees) — the chart came from the fork's UKSo A1 is: create the missing accounts idempotently, record the mapping on PharmacyERPNextConfig, and
document which document posts to which account. Not a re-configuration of what exists.
pharmacy/accounting.py (the account specs and the document-to-account map) plus a new
provision_pharmacy_accounts command, and nine new fields on PharmacyERPNextConfig (migration
pharmacy.0012). On test-client-pharmacy: Created 5, Mapped 3, and a second run reports
Created 0, Mapped 8 — genuinely idempotent, not just claimed.
| Field | Account | |
|---|---|---|
nhs_dispensing_income_account |
4060 - NHS Dispensing Income - TCP |
created |
retail_sales_income_account |
4070 - Counter Retail Sales - TCP |
created |
nhs_reimbursement_income_account |
4080 - NHS Reimbursement Income - TCP |
created |
nhs_receivable_account |
1160 - Accounts Receivable - NHS - TCP |
created |
counter_till_account |
1170 - Pharmacy Counter Till - TCP |
created |
stock_account |
1410 - Stock In Hand - TCP |
mapped, not created |
cogs_account |
5111 - Cost of Goods Sold - TCP |
mapped, not created |
stock_adjustment_account |
5119 - Stock Adjustment - TCP |
mapped, not created |
vat_account |
VAT - TCP |
already recorded from G4.1 |
DOCUMENT_ACCOUNTS maps every document the app posts — counter sale, dispensing consumption, stock
receipt, stock adjustment, the NHS charge collected, and the reimbursement receipt — to the accounts it
touches, and pins the D6.2 guard in executable form: for a charge collected on the NHS's behalf the
receivable role resolves to the NHS receivable, and the map records the reimbursement income account as
the one thing it must never resolve to. The tests assert it, so a later edit cannot quietly turn that
charge into revenue.
Two places the spec met reality — both found by running it live, not by testing it (which is the
argument for driving a new provisioning command against a real site before believing it):
account_name is the bare label, not the numbered one. name is "4000 - Income - TCP"account_name is "Income". The parent lookup matched only account_name, found nothing, passedNone, and failed with the same 417 — twice. Both lookups now search name and account_name, andRunner: 238 tests, OK (12 of them A1). origin/dev = bd2baec.
| # | Work item | Owner | Indicative size |
|---|---|---|---|
| A0 | Gates G6.1–G6.5 closed and recorded | OpenCode (G6.1), Hermes (G6.4), Matthew (D6.x) | S |
| A1 | Chart-of-accounts wiring — idempotent provisioning of the pharmacy income/stock/VAT mapping, recorded on the config, with a tested mapping from each document the app posts to the accounts it touches | OpenCode | M |
| A2 | The expected-reimbursement model — per endorsed claim: the expected figure, its basis, the date computed, and the band; explicitly an estimate, never the last word | OpenCode | M–L |
| A3 | Reconciliation — match expected against the actual receipt posted in ERPNext, record the variance, and surface it; unmatched receipts are visible, not dropped | OpenCode | M |
| A4 | Takings and VAT reporting — by tender and by VAT band, read from ERPNext (including item_wise_tax_detail), reconciled to the day's sales |
OpenCode | M |
| A5 | Stock valuation and expiry exposure reporting — value and quantity by item and batch, with the expiry bands from Phase 3 | OpenCode | S–M |
| A6 | Gross margin by band — NHS dispensation versus retail, from ERPNext's own figures rather than a parallel calculation | OpenCode | M |
| A7 | Period-end pack — the figures and reports produced for a bookkeeper each period, with a documented list of what remains manual | OpenCode | S–M |
| A8 | The handover pack — the written documentation for the client's bookkeeper: what posts what, which reports to run, how to prove a figure, and what the app deliberately does not do | Hermes (with OpenCode's pointers) | M |
| A9 | Role gating — who may see the financial reports (recommend: the Business Manager, and nobody else unless granted); refusals tested | OpenCode | S |
| A10 | Audit and DSPT evidence — who ran which report and who changed a mapping; no clinical or patient content | OpenCode | S |
| A11 | Synthetic seed and tests — a period of synthetic sales, an endorsed claim with an expected reimbursement, a matching and a short receipt, and assertions that every report ties to ERPNext; the boundary test that nothing financial lands centrally | OpenCode | M |
| A12 | Report back, then verification, walkthrough and the guides | OpenCode then Hermes | S + S |
Matthew settled all six on 2026-09-14, every one as recommended. Recorded as settled with the reasoning kept, as Phases 2–4 were — the reasoning is what a later reader needs when a trade-off is revisited.
| # | Decision | Settled |
|---|---|---|
| D6.1 | When a reimbursement becomes income — at endorsement (when the claim is recorded), or when the money arrives | SETTLED — When it arrives. The expected figure is tracked and visible from endorsement, but nothing is recognised as income until the receipt is posted. Recognising income on a claim that may be rejected or clawed back is how a pharmacy's accounts stop matching its bank |
| D6.2 | How the NHS prescription charges the patient pays are treated — as income, or against the NHS receivable | SETTLED — Against the NHS receivable, not income. The pharmacy collects the charge on the NHS's behalf; treating it as revenue overstates income and miscalculates VAT. Getting this wrong is a tax error with a paper trail, and it is the reason this is a decision rather than an implementation detail |
| D6.3 | Stock valuation method | SETTLED — Whatever the client's accountant requires, configured in ERPNext per item, and documented in the handover pack. The app must not invent a valuation method of its own; it reports what ERPNext does |
| D6.4 | What the app automates at period end | SETTLED — Reports only. The app never posts period-end journals or tax adjustments — a bookkeeper, or an accountant, does that. Automation that posts silently is automation nobody can audit |
| D6.5 | Who the reports are for | SETTLED — The client's bookkeeper first, with a one-page owner summary on top. Reports written for neither audience end up used by nobody |
| D6.6 | Vocabulary — the words for this phase | SETTLED — expected reimbursement (never "claim value", which is Phase 2's endorsement), actual for the posted receipt, variance for the difference, charges retained for patient charges collected on the NHS's behalf, band (the VAT band — zero-rated NHS dispensation versus standard-rated retail), period for the reporting window |
band in this phase means the VAT band — zero-rated NHS dispensation versus standard-rated retail. That is
what the phase's own work items mean by it (A4 "takings by tender and by VAT band"; A6 "gross margin by band —
NHS dispensation versus retail"), and it is the split Phase 4's Item Tax Templates already carry per line. No
new concept and no new data are needed.
The D6.6 row's "(B1/B2/B3-style, per the NHS schedule)" was wrong and has been struck. B1/B2/B3 is the
dental banding — dental UDAs — inherited from the dental programme's pages; there is no B1/B2/B3
band in English pharmacy reimbursement. OpenCode was told not to build a tariff-band field on that
wording, and the decision row now reads "the VAT band", which is what the rest of the phase already
meant by the word. Raised by OpenCode while starting A2 (session 12) — a good catch, and
exactly the sort of thing the phase's own question-and-answer pattern is meant to surface.
A second instance of the same inherited wording sits on the module map's pinned terminology list:
"NHS prescription charge — the statutory band charge, not a retail price, and not 'payment'". In England a
prescription charge is a single statutory charge per item, not a banded one — the bands are dental. The
point that line exists to make (a statutory charge, not a retail price and not "payment") is right; the word
"band" in it was not. That line has been corrected on the module map in the same pass.
What is unaffected: the counter's own usage is already correct — D4.8's "split by VAT band" and the
counter's takings by band both mean the VAT band, and the Phase 4 implementation is right.
Related, and worth keeping apart: the charge status declared at endorsement (charge due, or exemption
category plus evidence flag) is a different thing from the band. It lives on Phase 2's EndorsementRecord
already, and it is what decides charges retained under D6.2 — not the band. A2's record carries the band and
reads the charge status from the endorsement.
Applied 2026-09-15 with Matthew's confirmation. This corrected inherited dental vocabulary; it did not change
the decision.
Built as the client-tier app pharmacy_accounting (pharmacy_accounting/models.py, services.py,
reports.py, views.py, plus seed_pharmacy_accounting), with pharmacy.0013 adding the expected-reimbursement
configuration and the Phase 6 audit actions. origin/dev = 71e80e5, four commits. 266 tests OK on my own run
(was 238). Deployed: migrations applied on the apex, the client tier and the client tenant; central still holds
0 pharmacy% tables; all six /accounting/ surfaces return 302 → OIDC on the client host.
The ERPNext side had to change too, and it was deployed first. The counter now sends paid_to, so the site's
healthcare_accounting.api.create_payment_entry had to accept it (erpnext-deploy 40474b4). Note the
deployment: the compose file has no build directive, so docker compose build reports "No services to
build" — the image is built with a manual docker build -t erpnext_v15:latest . followed by
docker compose up -d. Deploying the app first would have broken the counter in production.
| What | Evidence |
|---|---|
| A2's unpriced case never invents a figure | Live: compute_expected_reimbursement on the endorsed demo token gives expected_amount=None, is_complete=False, reason "No reimbursement price for: Amoxicillin 500mg capsules, Ibuprofen 400mg tablets" — not zero, and it names the items |
| A3 never reconciles an incomplete estimate | Live: a receipt against that estimate stays UNMATCHED, variance=None. Receipts are append-only (delete() raises) |
| A4–A7 run live and read ERPNext | All four return structured data; A7 composes them plus PERIOD_END_MANUAL (6 items, D6.4). A5 gives 5 rows / 196 units with expiry bands 90/30 days and 2 expired |
| A4 will not smooth a difference | It reports ties=False, gross_variance=-15.90 and the offending basket rather than hiding it — the behaviour the phase demands |
| A9 — financial reports are not a general surface | PharmacyAccountingManageRequiredMixin, Business Manager only |
| The counter till wiring (the A1 follow-up) | Before/after, from ERPNext itself: ACC-PAY-00001 has paid_to='1010 - Clinic Till / Cash on Hand - TCP'; ACC-PAY-00002 has paid_to='1170 - Pharmacy Counter Till - TCP'. Same run: ACC-SINV-2026-00016 net 12.40 / VAT 0.50 / gross 12.90, standard 2.50/0.50 and zero 9.90/0.00, stock 60 → 59 → 60 on the refund |
ties=False, local_gross=15.90, erpnext_gross=0.00, one mismatch:{'basket': 2, 'invoice': 'DEMO-SINV-0001', 'reason': 'ERPNext invoice could not be read'}. Root cause:seed_pharmacy_counter.py sets DEMO_INVOICE = 'DEMO-SINV-0001', a placeholder that exists nowhere on theACC-SINV-2026-000NN). On seeded data A4 can therefore never tie, and every bandreimbursement_price_list defaults to NHS Reimbursement and professional_fee_per_item to 0.00, but noseed_pharmacy_accounting fabricatesbasis=PRICE_LIST_PLUS_FEE. Under D6.1 the basis exists to sayMANUAL — or the seed should create the price list.ExpectedReimbursement rows directly rather than callingservices.compute_expected_reimbursement, so it never covers A2's real path.One deviation for the record, not a defect: ActualReceipt stores amount and variance locally, while the
data-boundary table above says the actual is "ERPNext, posted as a document — not a figure held in our own tables".
Defensible as a reconciliation record (the ledger truth stays in ERPNext), but recorded as a deviation from the
wording.
Status: deployed, and acceptance tests 4, 6 and 7 hold; 1, 2, 3 and 5 are unproven. Findings 1, 3 and 4 are
small; finding 2 needs one question answered first. Reported to OpenCode on 2026-09-15.
origin/dev = 9c301ca, three commits, no migrations; 270 tests OK on my own run; deployed and the
remediation sequence run on the tenant. Findings 2, 3 and 4 are closed and proven; finding 1's fix is real —
and re-verifying turned up two new defects.
Now proven, live
| What | Evidence |
|---|---|
| Acceptance test 2 — the VAT split is ERPNext's own arithmetic | A4 reports band_net = {STANDARD: 5.00, ZERO: 9.90}, band_vat = {STANDARD: 1.00, ZERO: 0.00}, vat_total = 1.00, read from the submitted invoice's item_wise_tax_detail |
| Acceptance test 3 — expected reconciles to actual, including a short payment | Two complete estimates (353.85 and 68.25) computed through the real path from ERPNext prices (DEMO-REIMB-AMOX-500 at 3.25, DEMO-REIMB-IBU-400 at 5.10); receipts MATCHED (variance 0.00) and SHORT (variance -1.00) |
| Finding 2's fix | Confirmed the underlying claim: every Bin row is valuation_rate 0.0 / stock_value 0.0, so the Item field really is empty here. A5 now equals ERPNext's own Bin sum exactly |
| Finding 3's fix | provision_pharmacy_reimbursement reports the price list ensured and 2 items priced; a second run is a no-op |
| Finding 4's fix | The seed reports its claims were computed through A2, and carries honest prices and basis rather than fabricated figures |
| Finding 1's fix | Seeding without --reset posts a real ACC-SINV-2026-00017; the fake DEMO-SINV-0001 is gone from the seed |
NEW DEFECT 1 (blocks acceptance test 1) — seed_pharmacy_counter --reset raises ProtectedError. Three
models now PROTECT the dispensing token (PrescriptionNotification.token, CollectionRecord.token,
ExpectedReimbursement.token) and _reset() deletes the token without clearing them:
seed_pharmacy_counter.py:214 DispensingToken.objects.filter(...).delete()
django.db.models.deletion.ProtectedError
So the reset cannot run, the stale basket 2 carrying DEMO-SINV-0001 is stuck on the tenant, and A4 keeps
reporting ties=False, mismatches=1, gross_variance=-15.90, unlinked=0 (the sale is linked, to an invoice
that no longer exists). The tie is blocked by this bug, not by Finding 1's original cause.
NEW DEFECT 2 (silent, and the worse of the two) — reimbursement_summary() reports expected_total = GBP 0.00
when called without premises. Proven on the tenant: the true complete total is GBP 422.10 (2 rows, all with
premises set, none with null premises), yet reimbursement_summary() returns expected_total = 0.00 against
actual_total = 431.10, while reimbursement_summary(premises=<the pharmacy>) correctly returns GBP 422.10.
Cause: ExpectedReimbursement.objects.filter(token__premises=premises) with None compiles to
token__premises IS NULL and matches nothing, whereas the actuals path (_receipt_scope(None)) applies no such
filter. An internally inconsistent variance view that under-reports expected by the whole amount, silently —
precisely the phase's own stated risk.
Acceptance test 5 is still not demonstrable, now for a data reason. Every Bin row carries quantity
(25, 8, 55, 48, 60) and zero valuation. A5 faithfully reports ERPNext's own figure, so the code is right —
but 0.00 = 0.00 demonstrates nothing, and a stock valuation report showing GBP 0.00 for stock the pharmacy holds
is worse than useless to a bookkeeper. The demo stock needs a valuation (post the demo receipts at a rate, or set
a valuation rate on the items). This is a decision as much as a patch.
Artefact and a gap: the first verification round drove compute_expected_reimbursement by hand on
demo-token-endorsed-1, leaving an incomplete expected reimbursement on the tenant. The accounting --reset does
not remove it, and it cannot be removed through the app — ExpectedReimbursement.delete() raises by design.
Same shape as the Phase 3 dispensing tokens, which is why purge_dispensing_tokens exists: these records need a
synthetic-purge path too, or verification litter accumulates.
Recorded as agreed: ActualReceipt holding amount and variance locally is a deviation from the
data-boundary wording above — accepted as a reconciliation record, with the ledger truth still in ERPNext.
origin/dev = 96d64b3, three commits, no migrations; 275 tests OK on my own run; deployed to the app
engine, with migrations a no-op on default, sector_pharmacy and the client tenant. Round 2's second defect
(bug 2) is closed and proven; round 2's first defect is fixed as a crash but does not yet unblock acceptance
test 1, for a reason the fix itself introduced.
Bug 2 (the silent under-report) — FIXED, proven at value and SQL level
| Check | Result |
|---|---|
_expected_scope(None) |
a bare Q(), whose compiled SQL is (AND: ) — no IS NULL, no premises term |
_expected_scope(premises) |
carries ('token__premises', <the pharmacy>) |
reimbursement_summary() without premises |
expected_total = GBP 422.10 (was 0.00 — the silent bug) |
reimbursement_summary(premises=...) |
expected_total = GBP 422.10 — identical, so the two paths now agree |
awaiting_receipt() |
compiled SQL contains no IS NULL; both-ways counts agree (0 = 0) |
| Live totals after clearing my round-one litter | expected 422.10 vs actual 421.10, variance -1.00, unmatched_count = 0 |
The default call now has the same shape as _receipt_scope, which is what round 2 asked for — so the variance view
no longer under-reports expected by the whole amount.
Bug 1 — the ProtectedError is gone, but the reset still cannot clear the row that blocks test 1
purge_dispensing_tokens --reference demo-token-endorsed-1 removed the round-one verification artefact and its
trail (it works, and it is the right shape), and seed_pharmacy_counter --tenant test-client-pharmacy --reset
completed exit 0 — "Removed synthetic counter rows." then "sale basket 5 (ACC-SINV-2026-00018) with an NHS
charge and a refund." So the dependency-order purge and the queryset-delete are sound; there are no pre_delete
signals on those models, so the append-only barrier is genuinely bypassed.
But ties is still False. A4 over the full period still returns ties=False, gross_variance=-15.90,
mismatches=[{'basket': 2, 'invoice': 'DEMO-SINV-0001', 'reason': 'ERPNext invoice could not be read'}] — because
basket 2 is still on the tenant:
pk 2 status=COMPLETED gross=15.90 invoice='DEMO-SINV-0001'
counter_customer='Counter Customer' completed=2026-09-14 17:55:29
Root cause — a regression inside Finding 1's own fix (9c301ca). That commit changed the reset's identity key
from filter(erpnext_invoice=DEMO_INVOICE) to filter(counter_customer=DEMO_MARKER), and the create path's
counter_customer='Counter Customer' to DEMO_MARKER. Every basket seeded before that commit therefore carries
the old label and the old placeholder, and is invisible to both the reset and the idempotency check. The fix was
right about the invoice; it silently orphaned the very rows it was meant to clean up. A4 is behaving correctly and
honestly — the reset's scope is the defect, replicating the Phase 3 pattern that produced
purge_dispensing_tokens. Suggested fix: widen the reset to the legacy tell
(Q(counter_customer=DEMO_MARKER) | Q(erpnext_invoice='DEMO-SINV-0001')) or sweep the old rows once. Sweeping on
counter_customer='Counter Customer' is not safe: basket 3 carries that label with a real invoice
(ACC-SINV-2026-00016), so a label-based sweep would delete good data.
Proof that this one row is the only thing left. Narrowing A4's window to exclude it (2026-09-15) gives
ties=True, mismatches=[], unlinked=[], gross_variance=0.00, band_net={STANDARD: 5.00, ZERO: 9.90},
band_vat={STANDARD: 1.00, ZERO: 0.00}, vat_total=1.00. Both sides read directly:
local pk=5 gross=15.90 invoice='ACC-SINV-2026-00018'
erpnext ACC-SINV-2026-00018 net=14.90 tax=1.00 grand=15.90 docstatus=1
item_wise_tax_detail: {"DEMO-OTC-PARA-500":[20.0,1.0],"NHS-PRESCRIPTION-CHARGE":[0.0,0.0]}
items: DEMO-OTC-PARA-500 2 @ 2.50 = 5.00 ; NHS-PRESCRIPTION-CHARGE 1 @ 9.90 = 9.90
erpnext get_sales_invoice('DEMO-SINV-0001') -> None
Acceptance test 5 — SETTLED by Matthew (option a) and now PROVEN. He ruled that the demo stock should be valued
the way session 15 proposed, so I ran seed_pharmacy_stock_valuation --tenant test-client-pharmacy: "2 received, 0
already stocked" (MAT-STE-2026-00029, MAT-STE-2026-00030). A5 now reports total_value = 353.85,
total_quantity = 273.00, and ERPNext's own Bin sum for the same seven item/warehouse rows reads stock_value = 353.85 over 273.00 — ties: True, quantity ties: True, read from both sides. The two DEMO-REIMB-* rows carry
the reimbursement rates (3.25 and 5.10); the four pre-existing rows stay at 0.00, which is the outcome he ruled on.
The zeros are an artefact of Phase 3's deliberate zero-valuation goods-in, which is untouched — in production a
client's own goods-in rates make A5 substantive with no code change. Recorded as a ruled limitation, not a defect.
So exactly one item remained before acceptance: test 1's reset scope — closed in round 4, below.
origin/dev = e6a5fbb, one commit, no migrations; 276 tests OK on my own run; deployed to the app engine
(bind-mounted, containers restarted; migrations a no-op). Session 16 fixed the reset scope and, while there, a latent
bug that would have deleted a real sale's stock movement.
Acceptance test 1 — PASSES, over the FULL period. After --reset, A4 returns ties=True, mismatches=[],
unlinked=[], gross_variance=0.00, band_net={STANDARD 5.00, ZERO 9.90}, band_vat={STANDARD 1.00, ZERO 0.00},
vat_total=1.00. Both sides read: local basket 6 gross=15.90 against ERPNext ACC-SINV-2026-00019 net=14.90 tax=1.00 grand=15.90. The fix's two substantive requirements hold on the real data, not just in the test:
VOIDED, ACC-SINV-2026-00016, counter_customer='Counter Customer' — so keying theThe latent movement bug is genuinely closed: the counter writes sale:{basket.pk}:{line.pk}
(pharmacy_counter/services.py::consume_stock), the new prefix sale:{pk}: matches that real format, and after the
reset exactly one sale: movement remains — basket 6's own.
Acceptance score: 7 of 8.
| # | Criterion | State |
|---|---|---|
| 1 | every figure ties to ERPNext | PROVEN round 4, full period, both sides |
| 2 | VAT matches the templates; band split from item_wise_tax_detail |
PROVEN round 2 |
| 3 | expected reconciles to a receipt, including the short payment | PROVEN round 2 |
| 4 | the prescription charge sits against the NHS receivable, not income | PROVEN round 1 |
| 5 | stock valuation matches ERPNext's own | PROVEN — ruled option (a); 353.85 = 353.85 over 273 units |
| 6 | the handover pack lets a bookkeeper produce a VAT return from ERPNext alone | A8 WRITTEN AND VERIFIED LIVE 2026-10-02 — the pack is published (see "A8" below); acceptance is Matthew's |
| 7 | financial reports refused to roles that should not see them, by direct URL | PROVEN round 4 (below) |
| 8 | suite green; nothing financial centrally | PROVEN round 4 (below) |
Criterion 7, verified live by direct URL — not merely code-and-test level. Logged in as a real pharmacy user
(tc.rp.one, PHARMACY_RESPONSIBLE_PHARMACIST) and fetched all eight /accounting/ URLs: every one returns HTTP
403 with a permission-denied body. An anonymous caller gets a 302 to OIDC (PharmacyRoleRequiredMixin redirects
rather than 403s), so a 403 can only come from an authenticated user with the wrong role — the gate is doing
exactly what A9 claims.
Criterion 8. Central holds 0 pharmacy% tables; the apex tenant and the client tenant each hold 30,
including pharmacy_accounting_expectedreimbursement, _reimbursementline and _actualreceipt. The GP and other
client tenants hold 0. Boundary intact.
A finding that blocks the demonstration, not the build — recorded for the walkthrough phase. seed_pharmacy_demo
creates only {prefix}.rp.{suffix}. Platform-wide the pharmacy identities are 5 Responsible Pharmacists, 1
Dispensing Technician, 0 Business Managers, 0 Counter Assistants. So:
Raised with OpenCode (session 16 note) as a small synthetic-seed extension. This is recorded as a demo-readiness gap,
not a build defect: nothing in the delivered code is wrong.
Where it is. The Bookkeeper Handover Pack — Running the Pharmacy's Books
(page 680), placed with the client-facing guides because it is the document handed to the client, and linked
from the guides index. It keeps the D6.5 shape — a one-page owner summary on
top, then the bookkeeper's half — and is written for someone who has never seen the app.
What it carries. Where the money lives (one ledger, ERPNext); what posts what — the DOCUMENT_ACCOUNTS
map in plain English with the D6.2 charge rule called out and the accounts A1 created; which reports to run —
the eight /accounting/ screens and the question each answers; how to prove a figure — read both sides, the
item_wise_tax_detail caveat, the tie/unlinked/mismatch behaviour, and the receipt statuses; what the app
deliberately does not do — the six PERIOD_END_MANUAL items verbatim with the three decisions (D6.1, D6.3, D6.4)
in the bookkeeper's own terms; and a worked period on the test client's own synthetic figures.
What I read live, as tc.bm.one on test-client-pharmacy, 2026-10-02 — my own runs, not the note or the code
alone:
| What | Evidence |
|---|---|
| All eight screens open for the Business Manager | GET on /accounting/, …/expected/, …/takings/, …/stock-valuation/, …/gross-margin/, …/period-end/, …/unmatched/, …/receipt/ — 200 each, titled Accounting · Expected reimbursements · Takings & VAT · Stock valuation · Gross margin · Period-end pack · Unmatched receipts · Record a receipt |
| The VAT figures are ERPNext's, and the screen says so | Takings & VAT for 2026-09-01–2026-09-30: Standard-rated (retail) net 5.00 / VAT 1.00, Zero-rated (NHS) net 9.90 / VAT 0.00, VAT total 1.00, under the heading By VAT band (from ERPNext) |
| The report reconciles itself and states the tie | "ERPNext gross 15.90 vs linked sales 15.90 — variance 0.00. Linked figures tie." |
| The reimbursement position is the one the pack quotes | dashboard: Expected 422.10 / Actual 421.10 / Variance −1.00 / Awaiting a receipt 0 / Unmatched receipts 0 — the short payment shown, not absorbed; the two claims read 353.85 (charge due) and 68.25 (exempt), both Complete, basis "Professional fee 0 per item; prices from ERPNext price list 'NHS Reimbursement'" |
| Stock valuation is ERPNext's own | 353.85 across 252.00 units as of 2026-10-02, two batches critical. Recorded honestly in the pack: the demo items carry no ERPNext valuation rate, so cost reads 0.00 and margin 100% — the pack warns the reader not to read that as a real margin |
| Criterion 7 re-proved while writing | tc.rp.one, tc.tech.one and tc.ca.one each get 403 on all eight URLs |
| The period-end pack is the page a bookkeeper prints | it repeats the period's figures in one place and prints the six manual items verbatim |
Footprint. Reading the pages writes only the app's own REPORT_VIEWED audit rows, which is the designed
behaviour (A10). Nothing was posted to ERPNext and no receipt was recorded. The page carries no credential and
no internal address beyond the app's own paths.
One finding, raised to OpenCode rather than worked around — now FIXED and verified. The pack could not say "click
Accounting in the menu", because there was no such entry to click: pharmacy/templates/pharmacy/base.html
carried role-gated entries for Staff (BM-only) but none for the accounting section, and no page linked to
/accounting/ — so a Business Manager reached the reports only by the address. The pack said so plainly and named
the gap as a finding; the fix was a two-line menu addition mirroring the Staff block.
Fixed 2026-10-02 (bb8b5d3, deployed to VPS2) and verified live by me the same day — 11/11 checks: as
tc.bm.one the header carries href="/accounting/" on /pharmacy/ and on /stock/, /accounting/ is 200 and its
button row carries /accounting/unmatched/; as tc.rp.one, tc.tech.one and tc.ca.one there is no accounting
link and a direct /accounting/ is still 403 (so criterion 7 still holds — navigation is not a control). The
pack's "how to get there" line was updated the same day, and it no longer tells the reader to bookmark the address.
Phase 6 is done when, against the client's own ERPNext and with synthetic data:
item_wise_tax_detail and the caveat documented;