Billing ledger core (clients/ledger/holds, idempotent, fail-closed) (#1) #10
No reviewers
Labels
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gitea-saas-core#10
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1
Implements the shared billing core: SQLite schema + ledger operations, pure Python (stdlib only — no HTTP, no Stripe, no Gitea).
Files (only the allowed set touched):
gitea_saas/schema.sql— clients / ledger / holds, allCREATE ... IF NOT EXISTS(idempotent),idempotency_key UNIQUE NOT NULL, kind CHECK constraint, partial index on active holdsgitea_saas/db.py— WAL connection helper + idempotent schema bootstrapgitea_saas/billing_core.py—BillingCorewith all listed operations:create_client,get_client(include_balance),credit,debit,reserve_hold,release_hold,list_ledger(from_/to filters),expire_stale_holds(+get_balancehelper)tests/test_billing_core.py— 26 hermetic tests, temp-dir DBs, zero network/Stripe/Gitearequirements.txt,pyproject.tomlInvariants (fail-closed):
balance = credits + refunds - debits - active holds; debit/hold that would go negative →INSUFFICIENT_BALANCEerror, no row written (single BEGIN IMMEDIATE transaction per op)idempotency_key→ no-op returning the original entry (replayed: true), never an error; cross-client key reuse → structuredIDEMPOTENCY_CONFLICTexpires_in_minutesoverride) and auto-release on the next read/query; a stale hold can never lock balance(ok, details); errors are structured{"error": {"code", "message"}}, never raised to callerstest_concurrent_credits_are_serialized: 8 threads x 25 credits, exact balance)Verification:
python -m pytest tests/test_billing_core.py -q→ 26 passed;git diff --checkclean.[worker-lane3] Implemented on branch
issue-1; PR #10 open for review (Closes #1).Commit:
6d764d4— "gitea-saas-core#1: billing ledger core (clients/ledger/holds, idempotent, fail-closed)"Test evidence:
python -m pytest tests/test_billing_core.py -q→ 26 passed (hermetic, temp-dir SQLite, zero network/Stripe/Gitea), plus the repo-widepython -m pytest -qgate andgit diff --checkclean. Acceptance coverage: schema idempotent (create_tables twice); credit up / debit down; debit below zero → INSUFFICIENT_BALANCE with no row written; duplicate idempotency_key applied once (replayed no-op); hold reserves / release returns / expiry auto-releases on next query; refund adds back and preserves idempotency; all ops return (ok, details) with structured errors (no exceptions); thread-safety proven by concurrent-credit test (8 threads x 25, exact final balance).Merged. Verified against the merge result (
origin/issue-1+origin/main, 0 conflicts).Gate
issue-3+issue-5in one throwaway treeThe combined run matters here because four PRs are open on this repo at once; a
green
mergeableflag on each says nothing about whether they coexist. They do— except
issue-2, see below.Mutation testing — the money-correctness claims are pinned
This is the ledger a billing product will settle real money against, so I broke
each load-bearing guard rather than trusting the green suite. Every mutation
asserted it landed on disk first:
debitno longer refuses insufficient funds (negative balance permitted)reserve_holdno longer refuses insufficient fundsD is the one worth calling out.
idempotency_keyis globally UNIQUE(
schema.sql:29), not scoped per client, which in a multi-tenant ledger isnormally where a cross-tenant replay bug lives. It is handled deliberately:
billing_core.py:514refuses a key already used by a different client withIDEMPOTENCY_CONFLICTinstead of replaying another tenant's row back to thecaller, and
schema.sql:12documents the global scope as intentional. Gooddesign, and now confirmed pinned rather than incidental.
Scope / escalation check
No auth, no credentials, no network:
grepfortoken|api_key|password|secret|auth|urllib|requests|httpacrossgitea_saas/on this branch matches only the two docstrings that say the module has none.
534 + 37 lines of Python and a 49-line schema, stdlib only. That is why this
one is mergeable by the sweep while its three siblings are not.
The other three PRs on this repo — not merged, and why
credentials (
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETsignatureverification;
GITEA_TOKENin anAuthorizationheader). Both are on thesweep's escalation list — they need owner review, not an automated merge.
Raised with the owner; leaving them open.
with this PR on
requirements.txtdespite reportingmergeable=true.Bounced separately with the resolution.
!11stacks directly on this branch, so its diff shrinks to the two Stripefiles now that this has landed.