Charge tracking split from the payments rail, plus forward-plan phases 0-6 - #19
Open
Shaan-Shoukath wants to merge 26 commits into
Open
Charge tracking split from the payments rail, plus forward-plan phases 0-6#19Shaan-Shoukath wants to merge 26 commits into
Shaan-Shoukath wants to merge 26 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
paymentsmodule. Previously acash-only space lost the debt entirely — no pending row, nothing to settle, nothing in
reports.
charge_tracking_enableddecides whether a debt is recorded and has no moduleor credential clause;
online_payments_enabledstill governs the Stripe/Razorpay railalone.
paymentsbecame opt-in and buys the online rail only. A charge raised with nogateway is stamped
provider=unclaimedand claimed exactly once by the first checkoutthat 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_railowns the removable half — checkout, native payment sheet,Connect, refunds, credential settings, every webhook. Existing
TOMBSTONED_APPS=paymentskeeps working, translated viaRENAMED_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_driftif the source settled or raised adebt after the freeze.
D8 — member dashboard behind the
membershipmodule (off by default). Notices arederived from the member's own rows, never from
notifications.Notification, which ismakerspace-wide with no recipient and one shared
read_at. Payment visibility isdeliberately 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_TERMrenewals could never be reconciledPayment.save()would have made every unclaimed charge permanently unclaimableReview
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
providerwhile holding no live handle (acheckout whose creation failed, or one that expired), and
status/amount/providerallsit 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_driftmust recapture rather than retry.That is deliberate — the artifact cannot be merged forward.
Tests
tsc --noEmitclean, production build succeedsThe 17 failures are all
tests/backup/*plus onetenant_migrationtest, and they areenvironmental, not regressions: host
pg_dumpis 18.6 against a 16.14 server with noversioned client directory, so
postgres_client.client_binaryfails closed by design. Thefailing 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