The whole credit machine already existed - the append-only ledger, the O(1)
balance projection, the atomic per-run debit, the pre-flight gate, the pure
money math. All of it was wired but switched off
(CREDIT_METERING_ENABLED defaulted false), on purpose:
Stories A1+A2 first had to make the broker bill the real provider cost.
Metering wrong numbers into real wallets is worse than metering nothing.
A2 has merged, so real cost now flows. Two gaps remained before we could throw the switch:
firm created ──► seed_starter_credit ($50 → 5,000,000 mc top-up, D-11) run launches ──► _credit_gate (block if balance ≤ 0 and overage off) [now enforced] run completes ─► record_run_debit (real cost, keyed firm_id+run_id) [now recording]
The gate and the debit code did not change. This story flips their default and guarantees a firm has money on day one.
| Surface | Change |
|---|---|
_build_deps() (clerk_worker/handler.py) |
Both rollout switches now default true:
CREDIT_METERING_ENABLED (record the debit) and
CREDIT_GATE_ENFORCED (block a zero-balance firm before egress).
Still per-function env-overridable - set either to "false" to
roll back without a code deploy. The unit-test seam in
runner.ClerkDeps stays False so the loop remains
DB-free to test. |
usd_to_millicredits() (shared/billing/credits.py) |
New pure helper: a raw grant conversion at the display peg (100 credits = $1) with no platform fee - a $50 grant is exactly 5,000 credits, unlike a run debit which is marked up. |
seed_starter_credit() (shared/billing/credit_ledger_writes.py) |
New write. Appends one grant/topup ledger row
(period NULL) and lands the credits in the never-expiring
top-up bucket. Idempotent per firm via a NOT EXISTS guard
(the ledger is append-only, so no ON CONFLICT). Runs under
staff context, so it sets/restores app.current_firm_id itself
- the same idiom as the other new-firm seeds. |
firm_starter_credit_usd (shared/settings.py) |
New config field, default 50.0, alias
FIRM_STARTER_CREDIT_USD. The amount is config, never
hardcoded; set 0 to disable seeding. |
create_firm() (domains/firm/service.py) |
Calls seed_starter_credit alongside the existing new-firm
seeds (labels, matter types, roles), firm-wide. |
period that saves the allowance
The subtle trap: the balance-projection row carries a period
('YYYY-MM') that drives the lazy monthly allowance grant. If the
starter seed pre-created that row with the current period, the first
run would see "already granted this month" and skip the seat allowance
entirely - a new firm would silently lose its
seats × 10,000-credit allowance for its creation month.
So the seed writes period = '' (never a real month). The first
run's ensure_allowance_and_gate reads it as stale, grants the seat
allowance, and leaves the top-up bucket untouched (D-11). Proven end-to-end: a
fresh 10-seat firm reads back 105,000,000 mc =
100,000,000 allowance + 5,000,000 starter.
This is the truth layer only. The 3× markup, the firm wallet UI, and the monthly per-firm report surface are a separate Billing epic. This story records accurate per-firm consumption; it does not display or mark it up.
uv run ruff check src/shared/billing src/domains/firm/service.py src/clerk_worker/handler.py src/shared/settings.py make test-db && uv run pytest tests/unit/test_firm_support_staff.py tests/unit/billing tests/unit/clerk_worker tests/integration/test_app_firm_credits.py -q # full suite: 2251 passed, 2 skipped
Key assertions: test_usd_to_millicredits_at_peg_no_markup
($50 → 5,000,000 mc, no fee) and
test_create_firm_seeds_starter_credit_firm_wide (a real firm
created against a real DB reads back a $50 top-up, one period-NULL grant row,
and - after the first-run allowance grant - the full 105,000,000 mc balance
with the starter preserved).
In one sentence: metering is on, every run debits its real cost against a firm-wide balance, a broke firm is stopped before it spends, and no new firm starts empty.