Skip to content

Charge tracking split from the payments rail, plus forward-plan phases 0-6 - #19

Open
Shaan-Shoukath wants to merge 26 commits into
mainfrom
dev
Open

Charge tracking split from the payments rail, plus forward-plan phases 0-6#19
Shaan-Shoukath wants to merge 26 commits into
mainfrom
dev

Conversation

@Shaan-Shoukath

Copy link
Copy Markdown
Collaborator

25 commits: the forward-plan phases 0–6, then the charge-tracking programme, then the
three deferred owner decisions D3/D5/D8 and the review-gate fixes over them.

The load-bearing change

Money owed is now tracked independently of the payments module. Previously a
cash-only space lost the debt entirely — no pending row, nothing to settle, nothing in
reports. charge_tracking_enabled decides whether a debt is recorded and has no module
or credential clause; online_payments_enabled still governs the Stripe/Razorpay rail
alone. payments became opt-in and buys the online rail only. A charge raised with no
gateway is stamped provider=unclaimed and claimed exactly once by the first checkout
that reaches a provider (DB-enforced).

D3 — the ledger is permanently core; only the rail is separable. apps.payments
(Payment, ManualSettlement, reconciliation, member history, receipts, reports) can never
be tombstoned: a deployment that cannot read or settle money it is holding has lost data.
New apps.payments_rail owns the removable half — checkout, native payment sheet,
Connect, refunds, credential settings, every webhook. Existing
TOMBSTONED_APPS=payments keeps working, translated via RENAMED_LABELS.

D5 — pending payments travel in a portable dump. A single pending row used to refuse
the whole dump, which became untenable once debts were recorded by default. Three things
replace the refusal: the preflight refuses a pending row with a live rail, the projection
clears every provider handle on pending rows, and the capture records a money fingerprint
that publication revalidates, refusing money_drift if the source settled or raised a
debt after the freeze.

D8 — member dashboard behind the membership module (off by default). Notices are
derived from the member's own rows, never from notifications.Notification, which is
makerspace-wide with no recipient and one shared read_at. Payment visibility is
deliberately not behind that gate, so an account-only loan borrower can read their own
debt; a revoked member is still refused.

Three production bugs fixed along the way

  • MEMBERSHIP_TERM renewals could never be reconciled
  • the member area offered a payment link that could not work
  • Payment.save() would have made every unclaimed charge permanently unclaimable

Review

Four Codex Stage-4 rounds: 10 findings → 4 → 1 → 4 → clean. The last round's P1 is worth
calling out, because it was a path to billing a member twice inside the very feature built
to prevent that: a pending payment can carry a provider while holding no live handle (a
checkout whose creation failed, or one that expired), and status/amount/provider all
sit still when the reopened source mints a fresh session. The digest matched, publication
shipped an artifact whose handles were stripped while the source stayed payable, and both
deployments could collect the same debt. The fingerprint now covers the same fields the
preflight guards, plus the rail label and expiry.

Consequence worth knowing before merging: publication now refuses on any rail activity
after the freeze, so an operator who hits money_drift must recapture rather than retry.
That is deliberate — the artifact cannot be merged forward.

Tests

  • Backend 6205 passed, 17 failed
  • Frontend 277 passed, tsc --noEmit clean, production build succeeds

The 17 failures are all tests/backup/* plus one tenant_migration test, and they are
environmental, not regressions: host pg_dump is 18.6 against a 16.14 server with no
versioned client directory, so postgres_client.client_binary fails closed by design. The
failing set is byte-identical before and after these changes. They still need a Docker
run to be verified green
— that is the one gap in this PR's verification.

🤖 Generated with Claude Code

Shaan-Shoukath and others added 26 commits September 3, 2026 21:19
…cs, perf close-out

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… no behaviour change

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ntract, lazy public routes

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e2e, CI e2e job

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…or failed deliveries

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…all shape

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nvitation requests, report provenance

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carves the 293-line reconciliation module into three files ahead of the
manual-settlement work that would push it over the hard ceiling:

* reconciliation_authority.py -- SUBJECT_ACTIONS and the subject/machine
  authority checks
* reconciliation_rail.py -- best-effort expiry of a live online rail
* reconciliation.py -- the transactional core, plus a re-export barrel so
  every existing `from apps.payments.reconciliation import X` keeps
  resolving (views_refunds imports the private _require_subject_authority).

Pure refactor: all 11 definitions preserved, no behaviour change. Test
monkeypatch targets that named apps.payments.reconciliation.stripe_client
are repointed at reconciliation_rail, where the function body now resolves
those names; the assertions themselves are untouched.

Verified: apps.payments.reconciliation is not pinned by
tenant_migration/authority_guards.py AUTHORIZATION_SOURCES, which would
have gone silently blind on a move.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…renewals

Recording what a member owes is now separate from being able to collect it
online. create_payment no longer raises when no Stripe/Razorpay credentials
resolve: it writes a PENDING row stamped provider=unclaimed. Previously the
debt was simply lost -- no pending row, nothing for staff to settle, nothing
in reports -- for any space without a configured gateway.

A new Provider.UNCLAIMED state carries this. It matters because provider
provenance is immutable: stamping such a row "stripe" would both lie about
where the money went and make it unclaimable if a gateway were configured
later. The first checkout that actually reaches a provider claims the row
(_claim_provider) inside the transaction already holding the settings and
Payment locks, and migration 0015 extends the terminal-guard trigger so that
transition is permitted exactly once and is never reversible.

Lock ordering is preserved: an unclaimed row may resolve to Connect, so it
takes the platform-settings lock up front rather than discovering it needs
one after taking the makerspace lock, which would invert the documented
platform -> makerspace -> Payment order.

Also fixes a pre-existing production bug: MEMBERSHIP_TERM was missing from
SUBJECT_ACTIONS, so _require_subject_authority fell through to its
unsupported-subject refusal and NO actor could reconcile a membership
renewal -- a space collecting renewals in cash could never mark one paid.

services.py is split at the 300-line ceiling into services_checkout.py (rail)
plus a re-export barrel. Test monkeypatch targets follow the moved names;
one test asserted the old fail-closed contract and is rewritten to assert the
unclaimed row it now produces.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splits "is this debt recorded" from "can we collect it online". A new
charge_tracking_enabled(makerspace, domain) predicate carries the first and
deliberately has NO module clause and NO credential clause;
online_payments_enabled is left byte-identical and still governs the rail
only, keeping the A6 rule that master switches are additive ANDs.

All six charge seams now record first and add a rail second: bookings,
events, machine service, membership dues, scheduled membership renewals
(previously missed -- it gates independently in membership_plan_services)
and loan deposits/late fees. A space with the payments module uninstalled,
or with no gateway at all, keeps a full pending ledger to settle by hand.

New charges.* capability family governs it: charges.enabled is a standalone
master switch (off = the space charges for nothing), and each domain key
keeps its real DOMAIN parent -- bookings, events, machines+machine_service,
membership, and request_workflow for loans -- but never a payments one, so
uninstalling payments cannot prune them. Migration makerspaces/0072
backfills all six onto existing rows, since enabled_features is per-row and
a default_enabled flip alone would read as OFF for every existing space.

loans_enabled now means TRACKING. Keying it off credentials made
loan_deposit_blocks_issue silently meaningless in a cash-only space: staff
could switch it on and it would never block, because no deposit was ever
raised to be unpaid.

The member checkout and native payment-intent endpoints previously created
provider objects without consulting online_payments_enabled at all. Both now
refuse when the charge has no live rail, so a cash-only debt cannot mint a
payment link the space has no way to honour.

Tests: fixtures that assign enabled_features wholesale now include the
charges keys (the same trap the payments master switch hit), a shared
enable_online_rail helper covers tests that genuinely exercise a rail, and
four tests that encoded the old "no rail means no debt" contract are
rewritten to assert the pending row it now produces.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marking a charge paid offline now records HOW and WHEN the money arrived,
not just that it did. ManualSettlement carries method (cash / UPI / bank
transfer / card machine / cheque / other), an optional short reference, the
received date, the amount, and who recorded it.

It is a separate table rather than four columns on Payment because the
payment terminal-guard trigger only protects `status` and `amount`: a
receipt reference living on that row could be rewritten afterwards with
nothing to show for it. This ledger is append-only in the ORM and by its own
Postgres trigger, and corrections are an appended `amends` row -- a
OneToOne, so a chain cannot branch -- leaving both the error and the fix
visible. Amount and currency are copied from the payment, never from the
caller, so the cash book cannot disagree with the ledger it explains.

The receipt is written inside the same transaction as the status flip, so a
settled charge can never exist without the row explaining it, and it is
required at the API boundary on all three mark-offline surfaces: the single
and bulk reconciliation routes and the legacy machine-service route, which
took no body at all. Waiving deliberately takes no receipt -- no money moved.

Adding a model here means satisfying the registries that fail the build
closed, all of which are updated: the data-export dataset, exact field
snapshot and User edges; the tenant-dump field snapshot, model catalog,
authority dispositions (the cash book is the space's own record, so it
PRESERVEs and travels with its payments) and the deployment-global
uniqueness registry, where `amends` is REMAPped as the reference it is
rather than regenerated. The Lane D catalog digest is re-blessed for it.

Verified: 1618 passed. The one remaining tenant_migration failure is the
documented Arch pg-client-major limitation and fails identically on the
commit before this one.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three member-facing gaps that only mattered once a charge could exist with
no online rail behind it.

Amount and currency are now on the member serializer. They were staff-private
under the old invariant, which was survivable while every payable charge
carried a Stripe link showing the figure -- but a member asked to settle at
the desk had no way to learn what to bring, making the charge unpayable. The
queryset is already scoped to the caller's own charges.

online_payment_available fixes a real dead end: MemberPayments.tsx offered
"Generate payment link" on ANY pending row without a URL, so a cash-only
space showed a button calling an endpoint that could not succeed. Those rows
now read "Pay at the space".

Members also see the receipt once staff book their cash -- method and date --
so there is a record on their side that it was taken.

Both new fields resolve in bulk in the view rather than per row: rail
availability is one answer per makerspace, and settlements are one query,
so a page of charges does not re-read settings and credentials per line.

StaffPaymentSerializer now inherits amount/currency instead of re-adding
them. OpenAPI snapshot and generated TypeScript regenerated with the pinned
drf-spectacular 0.30; the diff is proportionate to these changes, with none
of the unrelated churn an older toolchain produces.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…split

The module stopped being default-enabled now that charge TRACKING lives
outside it. It buys exactly one thing: the online rail -- Stripe/Razorpay
checkout, Connect, webhooks, the native payment sheet. A space that takes
cash needs none of that and still keeps a full ledger of money owed, so
installing a payment-provider integration into every new makerspace bought
nothing and implied a gateway nobody had configured.

Existing makerspaces are unaffected: they carry the key from migration 0057.
The module profiles that name payments keep it, since choosing one of those
profiles is an explicit request for the rail.

Docs: CLAUDE.md and AGENTS.md updated identically in this commit (diff prints
nothing), docs/MODULES.md rewritten for the payments entry -- required in the
same commit as a module meaning change, since nothing regenerates that page --
and docs/INVARIANTS.md gains the tracking-vs-rail rule. That entry also flags
the pre-existing stale line calling payments.enabled standalone: it is
parented to the payments module, and the per-domain payments.* keys hang off
their own domain modules.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payment reconciliation report gains `settlement_method`: the manual
method (cash / UPI / bank transfer / card machine / cheque) for an offline
settlement, taken from the effective unamended receipt, and the vendor for an
online one, so one column answers "where did this money come from" for both
rails. Rows settled before the ledger existed report "unknown" rather than
being folded into cash.

Currency stays a grouping key throughout, so a space taking both INR and USD
gets a row per currency and never a total that silently adds them. The same
rule drives the new dashboard figure: outstanding money is returned as a map
per currency beside the existing pending count, not as one ambiguous number.

The report is bumped to v2, since both its column set and its row grain
changed and a provenance row must say which shape an export carries. The org
aggregation strategy declares the new field as part of the GRAIN rather than
a measure -- two spaces both taking cash aggregate into one cash row, and
cash never merges with a card total.

Frontend adds a settled-by-method pie per currency, reusing the PieChart that
already existed rather than adding a chart dependency, plus the method column
in the table.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes a break introduced with the manual-settlement ledger: the API now
requires a receipt on mark-offline, but both staff surfaces still posted an
empty body, so every "Mark offline" click would have 400'd.

Marking paid offline becomes two steps. The button opens a form for method
(cash / UPI / bank transfer / card machine / cheque / other), an optional
reference and the received date -- defaulted to now, since staff usually
record the money as they take it -- and only the confirmation sends the
request. Waiving is untouched and still fires immediately: no money moved, so
there is nothing to receipt.

One dialog serves both the payments panel (single and bulk, where one receipt
covers the batch because the handover happened once) and the machine-service
handover console.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven P1s and three P2s, the first of which made the whole unclaimed-charge
mechanism inert.

* Payment.save() rejected ANY provider change, so _claim_provider always
  raised and the relaxed database trigger was never reached: an unclaimed
  debt could never be claimed even after valid credentials were added. The
  ORM guard now permits exactly the one-time unclaimed -> vendor transition.
* Checkout demanded a PlatformStripeConnectSettings row for every unclaimed
  payment, so a self-hosted deployment with raw Stripe or Razorpay keys was
  refused with "Stripe Connect is not configured". The speculative platform
  lock stays, for lock ordering, but its absence is only fatal for a row
  already stamped Connect.
* The native payment-intent path never claimed the provider, so a cash-raised
  charge could settle online while still stamped unclaimed -- misreported,
  and with no vendor for a refund to dispatch to. It now claims like checkout.
* ManualSettlement.payment and .amends were PROTECT, which made the lifecycle
  purge raise ProtectedError and masked Payment's own immutability trigger
  outside a purge. Both are CASCADE now: a receipt explains exactly one
  payment and has no meaning without it, and deletion stays impossible in
  normal operation because the settlement table's own trigger refuses DELETE
  unless the purge GUC is set. This also removes the dependency-ordered
  deletion passes the PROTECT version needed.
* reconcile_payments accepted paid_offline with no receipt, and the exported
  mark_offline() called it exactly that way, so the receipt rule could be
  bypassed outside the HTTP serializers. It is enforced in the service now.
* charge_tracking_enabled returned true on a deployment built with
  TOMBSTONED_APPS=payments, where every payment surface is spliced out --
  accruing debts nobody could see or settle. It fails closed there. This is
  the deployment-level question, distinct from the per-makerspace toggle.
* Rail availability was resolved once from rows[0], but a member list can mix
  charges owned by different spaces through via_makerspace, each with its own
  modules and credentials. It is cached per owning makerspace and subject.
* Both checkout endpoints raised PaymentsUnavailable outside the handler that
  converts it, so an ordinary cash-only charge answered 500 instead of the
  documented 503. Returned directly now.
* outstanding_by_currency is declared on DashboardSerializer, so the schema
  and generated TypeScript carry a field the endpoint already emitted.
* The settlement date input could be cleared, and confirming then threw a
  RangeError before the mutation ran; submission is blocked with a message.

Verified: 938 passed across payments/makerspaces/machines/modules/operations,
1299 across tenant_migration/data_export/separability, 243 frontend. The one
remaining failure is the documented Arch pg-client-major limitation, which
fails identically on the base commit.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex GPT-5.6 <noreply@openai.com>
…, amendments

The first round's ten findings are cleared; these four are new.

* The backfill enabled all six charges.* keys unconditionally, so a space
  that had deliberately kept payments.<domain> OFF would suddenly start
  accruing debts it never had -- and with charges.loans could start BLOCKING
  issue. Each domain key now inherits the state of the payment feature it
  replaces, so an existing space charges for exactly what it charged for
  yesterday. Only the master switch goes on for everyone, which enables
  nothing by itself. New makerspaces still get all six, having no prior
  intent to carry.
* Organization aggregation still grouped on the three old keys, merging cash
  and UPI rows that shared a currency, subject and status and then emitting a
  null method -- silently undoing the split the report exists for. The strategy
  and the aggregator now agree on the grain.
* `amends` was unreachable: every reconciliation endpoint refuses a terminal
  payment and nothing ever supplied the field, so a mistyped method or
  reference was permanent. Adds an authorized, audited amend-settlement
  endpoint that appends a corrected receipt and leaves the payment untouched,
  which is the append-only correction flow the ledger promises.
* Loan configuration was gated on payments.loans, which is pruned when the
  payments module is uninstalled -- hiding deposit amounts, caps and the
  blocking switch from exactly the cash-only spaces that now raise those
  charges. It keys on charges.loans.

Verified: 775 passed across payments/operations/makerspaces/modules, 195
frontend staff tests, OpenAPI and TypeScript regenerated.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex GPT-5.6 <noreply@openai.com>
The settlement and amendment primary keys recorded in payment audit entries
are semantic references like any other id. Left undeclared they would survive
a Lane D import as stale SOURCE ids inside immutable audit metadata, pointing
at whatever row happened to take that number on the target.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex GPT-5.6 <noreply@openai.com>
Both surfaced only in the full-suite run.

The ops dashboard test asserted every value was an integer;
`outstanding_by_currency` is deliberately a currency -> amount map, since
outstanding money cannot be summed across currencies into one number.

The claim-acceptance test configured Stripe credentials and expected checkout
to succeed, but the member checkout endpoint now also requires a live rail
for the charge's domain.

Full suite: 6183 passed. The 17 remaining failures are the documented Arch
pg-client-major limitation (tests/backup and one tenant_migration test), which
fail identically before these changes and need Docker.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ling

It was already one of the five over-ceiling files, and the payments comment
had pushed it from 318 to 327. Same explanation, fewer lines.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payment LEDGER becomes permanently core and the RAIL becomes the
separable half. Before this, `TOMBSTONED_APPS=payments` withdrew the
reconciliation console and the member's own payment history along with
Stripe — which was survivable when no gateway meant no charges, and is not
now that debts are recorded without one. Such a deployment would have accrued
money owed that nobody could read or settle.

`apps.payments` keeps every model, migration and table (so identity, content
types and the retention registry are untouched) plus the ledger surfaces:
listing, mark-offline, waive, bulk, amend-settlement, member payment history
and archived discovery. All are mounted unconditionally.

New `apps.payments_rail` owns what a tombstone removes: member checkout, the
native payment intent, Connect onboarding and callback, refunds, credential
settings and every webhook. It has no models. The `payments` MODULE key is
reassigned to it, so `module_available` drops the key exactly when the rail
is gone.

Machine-service mark-offline/waive are now unconditional too: they SETTLE a
job's charge rather than collecting it through a provider.

Existing deployments have `TOMBSTONED_APPS=payments` written down, so
`RENAMED_LABELS` translates it to `payments_rail` rather than failing startup
with separability.E007 — and it carries the intent across faithfully, since
it always meant "ship no online payments".

charge_tracking_enabled loses the tombstone clause added last round: the
ledger is always present now, so a recorded debt is always settleable.

The tombstone test's contract inverts and asserts both halves. Verified: 176
tombstone-profile tests and 1038 normal-profile tests pass; the single
remaining failure is the documented Arch pg-client limitation.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A single pending Payment used to refuse the entire portable dump, in two
places. That was tenable while a charge could only exist where a gateway was
configured; now that money owed is tracked by default and without one, it made
a dump impossible for any space that keeps a ledger.

Three mechanisms replace the refusal.

The preflight refuses any pending row with a LIVE rail, and now counts a
native PaymentIntent as live rather than only a hosted session -- a member can
still confirm an intent in the app, so omitting it let a row through a check
whose entire purpose is "nothing is in flight".

The projection holds pending rows to the same cleared-handle standard as
terminal ones: no order id, session, intent, checkout URL, connected account or
routing survives, so an imported debt cannot resume the source's rail and
collect a second time.

New money_digest binds a capture to the tenant's unsettled money. The capture
freezes the database under an exclusive gate and then REOPENS the source, so
between the freeze and publication the space can settle a captured debt or
raise a new one -- and publishing then would hand out an artifact that bills a
member for money already taken. The digest is computed inside the gate and
revalidated by publish_tenant_dump under the custody lock, refusing with
`money_drift`. It cannot be merged forward, so the honest answer is recapture.

Deliberately narrow: terminal payments are immutable and cannot drift, and
including them would make every ordinary settlement look like drift. A blank
digest is not revalidated, since captures predating the field recorded nothing
and comparing against a blank would refuse all of them.

Verified: 1010 passed across tenant_migration and payments, including five new
drift tests; the one failure is the documented Arch pg-client limitation.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gated on the `membership` module and therefore off by default: managed
hosting installs it, a self-host deployment may add it, and there is one code
path rather than deployment-edition branching.

Adds the three things the member area could not answer. Request history --
previously only ACTIVE self-checkout loans were returned, so a member could
not see a request they submitted, whether it was accepted, or what they gave
back. Returned-item history, with whether it came back late and what was
recorded damaged or missing, which is the same record an accountability
restriction is based on. And the membership fee with what is outstanding,
grouped BY CURRENCY and never summed across them, read through the payments
ledger rather than recomputed.

Notices are DERIVED from the member's own rows. notifications.Notification is
makerspace-wide -- no recipient column, one shared read_at -- so serving it to
members would hand them staff alerts and let one member's read mark speak for
everyone. A derived feed cannot leak, needs no migration, and says only
things true of the reader.

Two gating fixes. The frontend activity query ran without checking
membershipModuleOn, so it called a module-gated endpoint and errored whenever
membership was off; the flag now also has to be declared before the queries
that read it. And the payments section moved OUT of the membership branch,
because a loan deposit is raised against a borrower who needs an active
account rather than a membership -- nesting it meant the person who owed the
money was the one person who could not see it.

member_may_see_own_charges backs that on the server. Narrow deliberately:
the account must still be live and unrestricted, it must hold NO membership
in the space (so a revoked member is still refused -- an existing contract,
not mine to reverse), and ownership is read through a queryset already
filtered to member=user. The first, wider version let revoked, restricted and
suspended accounts read history; three existing tests caught it.

New sections live in their own module rather than pushing
member_activity_service past the 300-line ceiling.

Verified: 1045 passed across payments, member_activity, makerspaces, modules,
hardware_requests and accounts, plus 277 frontend tests, with five new
dashboard tests.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The money fingerprint could miss a live rail opened after the freeze. A pending
row can carry a provider while holding no handle -- a checkout whose creation
failed, or one that expired -- and status/amount/provider all sit still when the
reopened source mints a session for it. The digest matched, publication shipped an
artifact whose handles were stripped while the source stayed payable, and both
deployments could then collect the same debt. The digest now covers the same
fields `preflight._check_live_checkouts` guards, plus the rail label and the
expiry that makes a handle inert.

An unreadable ledger no longer renders as "Nothing outstanding": `None` means the
amount could not be read, `{}` means the member is clear, and the two stay apart
through serializer, schema, generated client and screen. A member must never be
told they owe nothing on the strength of a failed query.

`due_soon` compares a whole timedelta instead of `.days`, which floored -- a loan
due in 47 hours was announced as due within a day.

Also: tests for the admit path of `member_may_see_own_charges`, which was the
whole behaviour change in D8 and had only its refusals covered; module_registry
back to 322 lines rather than growing an over-ceiling file; and two comments that
described mechanisms the code does not have (projection strips provider handles,
not the provider label; the ceiling figure in CLAUDE.md/AGENTS.md was stale).

Findings 1-4 came from the Codex review gate; the fixes and tests are Claude's,
so Codex takes no authorship trailer here.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two traps, both of which cost a session an afternoon and both of which report
green rather than failing loudly.

`spaceworks-backend` mounts `~/Projects/SpaceInventory/SpaceWorks`, a second clone
on this machine, so `dev-docker.sh exec` runs pytest against a tree the session
never edited — on 2026-09-07 that was `b15c4e11`, old enough to have no
`money_digest.py` at all. It reported a clean `tests/backup` for code it had not
loaded, and its flood of `relation ... does not exist` teardown errors was just a
tree predating dozens of migrations. Check the mounts before trusting a result.

`API_CLIENT_ENC_KEY` and `AUDIT_MAC_MASTER_KEY` are empty in the image; without
them 19 `tests/tenant_migration` tests fail as `ImproperlyConfigured` wrapped in a
`PairingError` from `deployment_keys.py`, which reads like a custody regression.

Documents the one-off isolated container that actually verifies the current tree,
including the `--entrypoint pytest` bypass the production admission gate needs.
Verified at `6629e60a`: tests/backup + tests/tenant_migration, 1559 passed, 0
failed — so the host's `PostgresClientUnavailable` failures are environmental.

Co-Authored-By: Shaan-Shoukath <shaanshoukath4522@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant