'adjustment' credit-event slot has existed in the ledger since migration 0137 but
no writer backs it — so the only way to grant credits is a raw UPDATE
on billing.firm_credit_balance. That is every money bug at once: no audit of who did
it or why, no idempotency (the on-call runs it twice, the firm gets double), no cap (a typo grants
₹10,00,000 instead of ₹10,000), and it mutates a projection the append-only ledger is
supposed to be the truth for. pramaan-docs ADR-158 says money moves by appending a signed
ledger row, never mutating one. Story-2164 (RFD-BILL-001 S3 / §5.8, goal G7) builds
the one missing writer so a staff grant is a first-class, audited, exactly-once ledger event
— and makes the pilot manually billable while unblocking the S4 cap-override.
A single record_adjustment writer appends one signed 'adjustment' row to
the append-only billing.credit_ledger — positive grants, negative claws back
— idempotent by a caller-supplied adjustment_id, scoped to the firm by RLS,
bounded by a config cap, stamped with the staff actor and a mandatory reason, and surfaced as a
staff-only POST /admin/firms/{firm_id}/billing/credits/adjustment.
Picture the old option as erasing a number in a paper ledger and writing a bigger one:
no trace of the change, and if your hand slips you can’t prove what it was. This Story replaces
the eraser with a deposit slip. Each grant is a fresh slip (an appended row) that
says who (the staff id, taken from their login, never typed into the form), why
(a mandatory reason), how much and into which pocket (grant vs top-up bucket). The
slip carries a reference number — the adjustment_id — and
the teller’s rule is: one slip per reference number, ever. Hand in the same slip twice
(a double-click) and the second is filed as a no-op; the balance moves once. And there’s a
ceiling stamped on the counter: a single slip over the config cap is refused before
any ink touches the ledger.
The 'adjustment' enum existed; three small pieces did not. Migration 0159 adds them
without touching one existing row’s meaning:
credit_ledger.adjustment_id (UUID, nullable) — the idempotency key, backed
by a partial unique index (firm_id, adjustment_id) WHERE
event_type='adjustment'. This is the same structural idempotency the run-debit and
stripe-event rows already use: the database itself refuses a duplicate, so exactly-once survives
even a race, not just a well-behaved caller.credit_ledger.staff_user_id (UUID, nullable) — the platform-staff actor,
kept distinct from user_id (the firm lawyer on a run debit) so the two
identities never blur in an audit read.pricing_config.staff_adjust_cap_usd (NUMERIC, default 1000) — the
per-adjustment ceiling as a config row, not a Python constant (the S1/ADR-151
discipline), with a >= 0 CHECK.The entity models mirror it exactly — alembic check reports
“No new upgrade operations detected.”
credits.pyNo I/O, so the money rules are unit-tested without a database. credits_to_millicredits
is the signed unit bridge (a +1,000-credit grant is +1,000,000 mc; a −250 clawback is
−250,000 mc — no FX, credits are already ₹). adjustment_exceeds_cap
bounds the magnitude of both signs against the USD cap (|delta| > cap), so a
clawback is capped just like a grant.
record_adjustment, one append, same path as record_topupDeliberately no new machinery: it reuses the exact append + firm-scope-reset + projection-update shape the top-up writer already uses. The order is the whole correctness story:
reason present? bucket valid? delta != 0? magnitude <= cap? → else 422 (nothing written) set app.current_firm_id = firm_id (staff sessions carry no firm scope; RLS WITH CHECK needs it) INSERT ... 'adjustment' ... WHERE NOT EXISTS (same firm_id + adjustment_id) RETURNING id → None: already applied, return False (idempotent no-op, no double-credit) UPDATE the targeted projection bucket by the signed delta restore app.current_firm_id (finally)
Two subtleties worth their titles. Why set the firm scope at all: a staff session
runs with is_staff=true but no current_firm_id, and the ledger’s
firm_isolation policy’s WITH CHECK would reject a firm-scoped INSERT
— so the writer sets the target firm itself and restores it, exactly as
seed_starter_credit does. Why a NOT EXISTS guard, not a read-then-write:
the idempotency lives in the single atomic INSERT, never in a separate SELECT that a concurrent call
could slip between (the ticket’s explicit “DB guard only”). The cap is checked
before the insert so a rejected grant writes nothing.
POST /admin/firms/{firm_id}/billing/credits/adjustment sits on the existing
firm_billing.py router (StaffDbDep). The handler is thin: the service guards
the firm exists (404 for a ghost firm, so a phantom balance can never be conjured), calls the writer,
and reads back the resulting two-bucket balance for the admin form. The staff id comes from
the authenticated session (current_user.user_id), never the request body —
audit authority is session-derived, not caller-claimed. The pramaan-admin grant form is a separate
fast-follow.
| Skipped | Why it’s correct to skip |
|---|---|
A dedicated billing.adjustment.write manual span |
The write is already covered by the HTTP request span (perimeter T1) + SQLAlchemy DB spans
(T1). A new manual span name extends the ADR-124 closed registry, which needs an ADR-124
amendment the RFD flags as a prerequisite — same posture as the not-yet-built
billing.topup.record span. Documented exemption, not an omission. |
A separate ops.audit_log row |
The append-only credit_ledger row is the audit artifact (staff id +
reason + timestamp, immutable), matching the sibling credit writers (record_topup /
record_run_debit), which also emit no audit_log entry. |
| A “bonus” bucket distinct from top-up | The projection has two buckets today: allowance and top-up. The RFD “bonus” bucket is the never-expiring top-up until the S5 expiry split lands; the writer targets the two real buckets and the DB CHECK is the backstop. |
0159 down/up roundtrips, alembic check clean.adjustment_id returns applied=false with the balance unchanged and one row
only; a negative delta claws back; an over-cap grant is 422 with zero rows written; a zero delta is
rejected; an unknown firm is 404 with no leaked row.