Billing ledger core (clients/ledger/holds, idempotent, fail-closed) (#1) #10

Merged
pbwraith merged 1 commits from issue-1 into main 2026-08-25 12:23:01 +02:00
Owner

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, all CREATE ... IF NOT EXISTS (idempotent), idempotency_key UNIQUE NOT NULL, kind CHECK constraint, partial index on active holds
  • gitea_saas/db.py — WAL connection helper + idempotent schema bootstrap
  • gitea_saas/billing_core.py — BillingCore with all listed operations: create_client, get_client(include_balance), credit, debit, reserve_hold, release_hold, list_ledger (from_/to filters), expire_stale_holds (+ get_balance helper)
  • tests/test_billing_core.py — 26 hermetic tests, temp-dir DBs, zero network/Stripe/Gitea
  • requirements.txt, pyproject.toml

Invariants (fail-closed):

  • balance = credits + refunds - debits - active holds; debit/hold that would go negative → INSUFFICIENT_BALANCE error, no row written (single BEGIN IMMEDIATE transaction per op)
  • duplicate idempotency_key → no-op returning the original entry (replayed: true), never an error; cross-client key reuse → structured IDEMPOTENCY_CONFLICT
  • holds expire (default 30 min, expires_in_minutes override) and auto-release on the next read/query; a stale hold can never lock balance
  • all operations return (ok, details); errors are structured {"error": {"code", "message"}}, never raised to callers
  • amounts are integer minor units (cents); currency default USD
  • thread-safe: one re-entrant lock + WAL (test_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 --check clean.

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, all `CREATE ... IF NOT EXISTS` (idempotent), `idempotency_key UNIQUE NOT NULL`, kind CHECK constraint, partial index on active holds - `gitea_saas/db.py` — WAL connection helper + idempotent schema bootstrap - `gitea_saas/billing_core.py` — `BillingCore` with all listed operations: `create_client`, `get_client(include_balance)`, `credit`, `debit`, `reserve_hold`, `release_hold`, `list_ledger` (from_/to filters), `expire_stale_holds` (+ `get_balance` helper) - `tests/test_billing_core.py` — 26 hermetic tests, temp-dir DBs, zero network/Stripe/Gitea - `requirements.txt`, `pyproject.toml` Invariants (fail-closed): - `balance = credits + refunds - debits - active holds`; debit/hold that would go negative → `INSUFFICIENT_BALANCE` error, no row written (single BEGIN IMMEDIATE transaction per op) - duplicate `idempotency_key` → no-op returning the original entry (`replayed: true`), never an error; cross-client key reuse → structured `IDEMPOTENCY_CONFLICT` - holds expire (default 30 min, `expires_in_minutes` override) and auto-release on the next read/query; a stale hold can never lock balance - all operations return `(ok, details)`; errors are structured `{"error": {"code", "message"}}`, never raised to callers - amounts are integer minor units (cents); currency default USD - thread-safe: one re-entrant lock + WAL (`test_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 --check` clean.
pbwraith added 1 commit 2026-08-25 11:16:54 +02:00
SQLite schema + ledger operations in gitea_saas/billing_core.py (+ db.py,
schema.sql). balance = credits + refunds - debits - active holds, never
negative; every mutation idempotent via UNIQUE idempotency_key; holds
expire (default 30 min) and auto-release on the next read/query. All ops
return (ok, details) with structured errors; never raise. Thread-safe via
single lock + WAL. 26 hermetic pytest tests (no network/Stripe/Gitea).
Author
Owner

[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-wide python -m pytest -q gate and git diff --check clean. 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).

[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-wide `python -m pytest -q` gate and `git diff --check` clean. 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).
Author
Owner

Merged. Verified against the merge result (origin/issue-1 + origin/main, 0 conflicts).

Gate

tree result
merge result, full suite 26 passed
combined with issue-3 + issue-5 in one throwaway tree 53 passed, 0 conflicts

The combined run matters here because four PRs are open on this repo at once; a
green mergeable flag 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:

mutation result
A: debit no longer refuses insufficient funds (negative balance permitted) CAUGHT (1 failed)
B: reserve_hold no longer refuses insufficient funds CAUGHT (1 failed)
C: idempotent replay disabled — a retried request double-charges CAUGHT (6 failed)
D: cross-client idempotency-key reuse guard removed CAUGHT (1 failed)

D is the one worth calling out. idempotency_key is globally UNIQUE
(schema.sql:29), not scoped per client, which in a multi-tenant ledger is
normally where a cross-tenant replay bug lives. It is handled deliberately:
billing_core.py:514 refuses a key already used by a different client with
IDEMPOTENCY_CONFLICT instead of replaying another tenant's row back to the
caller, and schema.sql:12 documents the global scope as intentional. Good
design, and now confirmed pinned rather than incidental.

Scope / escalation check

No auth, no credentials, no network: grep for
token|api_key|password|secret|auth|urllib|requests|http across gitea_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

  • !11 (#3, Stripe adapter) and !12 (#5, review gate) handle live
    credentials (STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET signature
    verification; GITEA_TOKEN in an Authorization header). Both are on the
    sweep's escalation list — they need owner review, not an automated merge.
    Raised with the owner; leaving them open.
  • !13 (#2, billing HTTP API) additionally does not merge: it conflicts
    with this PR on requirements.txt despite reporting mergeable=true.
    Bounced separately with the resolution.

!11 stacks directly on this branch, so its diff shrinks to the two Stripe
files now that this has landed.

Merged. Verified against the **merge result** (`origin/issue-1` + `origin/main`, 0 conflicts). ### Gate | tree | result | |---|---| | merge result, full suite | **26 passed** | | combined with `issue-3` + `issue-5` in one throwaway tree | **53 passed**, 0 conflicts | The combined run matters here because four PRs are open on this repo at once; a green `mergeable` flag 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: | mutation | result | |---|---| | A: `debit` no longer refuses insufficient funds (negative balance permitted) | **CAUGHT** (1 failed) | | B: `reserve_hold` no longer refuses insufficient funds | **CAUGHT** (1 failed) | | C: idempotent replay disabled — a retried request double-charges | **CAUGHT** (6 failed) | | D: cross-client idempotency-key reuse guard removed | **CAUGHT** (1 failed) | D is the one worth calling out. `idempotency_key` is **globally** UNIQUE (`schema.sql:29`), not scoped per client, which in a multi-tenant ledger is normally where a cross-tenant replay bug lives. It is handled deliberately: `billing_core.py:514` refuses a key already used by a different client with `IDEMPOTENCY_CONFLICT` instead of replaying another tenant's row back to the caller, and `schema.sql:12` documents the global scope as intentional. Good design, and now confirmed pinned rather than incidental. ### Scope / escalation check No auth, no credentials, no network: `grep` for `token|api_key|password|secret|auth|urllib|requests|http` across `gitea_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 - **!11 (#3, Stripe adapter)** and **!12 (#5, review gate)** handle live credentials (`STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` signature verification; `GITEA_TOKEN` in an `Authorization` header). Both are on the sweep's escalation list — they need owner review, not an automated merge. Raised with the owner; leaving them open. - **!13 (#2, billing HTTP API)** additionally does **not** merge: it conflicts with this PR on `requirements.txt` despite reporting `mergeable=true`. Bounced separately with the resolution. `!11` stacks directly on this branch, so its diff shrinks to the two Stripe files now that this has landed.
pbwraith merged commit 59c3d035b1 into main 2026-08-25 12:23:01 +02:00
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: pbwraith/gitea-saas-core#10
No description provided.