Skip to content

Keep every credit grant as a lot and draw debits oldest-first - #107

Merged
rafiki270 merged 1 commit into
mainfrom
claude/credit-lots
Oct 12, 2026
Merged

rafiki270 merged 1 commit into
mainfrom
claude/credit-lots

Conversation

@rafiki270

Copy link
Copy Markdown
Contributor

Summary

Every grant of credits is now a lot that keeps what was paid for it. Debits draw lots oldest-first, so a balance can be valued in the currency it was bought in.

  • Lots (billing_credit_lots, billing_credit_lot_movements, migration 20261012210000_credit_lots): each top-up, automatic top-up and admin credit opens a lot. A lot records its currency, the amount paid, the USD per unit its credits were granted at (the checkout's stored rate; 1 for USD) and what is left.
  • One writer (billing_credit_entries_apply_lots trigger, billing_credit_lots_apply_entry): lots change only inside the entry insert, under the account row lock the balance trigger already holds.
    • A debit draws oldest-first and writes one movement row per lot.
    • A refund or dispute draws the refunded purchase's own lot first.
    • Credits given back (a usage correction or payment reversal) go first to debt, then to the reversed purchase's own lot, then to the newest lot that gave credits.
    • A debit beyond the lots is debt (a movement with no lot), and the next grant pays it first.
    • After every entry, lots minus debt must equal the account balance, or the entry is refused.
    • Lot terms and movements are immutable.
  • Holds (prepaid AI, SMS reservations and standing holds) do not move lots: settlement draws then, and a release changes nothing.
  • Backfill (billing_credit_lots_backfill): every existing account gets lots.
    • Each earlier grant becomes a rate-1 USD lot (every earlier purchase was USD), consumed oldest-first by what was spent.
    • Credits that no grant explains open one USD lot, and a negative balance becomes debt.
    • An account with no lot history (only a balance written outside entries has one) is backfilled by its first entry.
  • Value (billing-credit-lot-value.service.ts): readCreditBalanceValue values the available balance in the organisation's region currency.
    • Holds come out of the oldest lots.
    • A region-currency lot is valued at its own rate, so its value does not move with exchange rates.
    • Credits bought in USD are valued at one rate captured on the region row when the region is chosen (billing_organisation_regions.usd_per_unit, Stripe's live rate; 1 for US). A region that could not read a rate captures it on its first valuation. This is the only update a region row accepts.
    • creditMovementValue values one debit from the lots it drew.
    • Products see these values from the display release.
  • Docs: Docs/Requirements/billing-tariffs.md (Credit lots).

Unchanged: balances, every existing debit and credit path, and the entry rules. The lot trigger only adds rows next to them.

Testing

On Maxis (Windows) at 9f6d83e (the same change before the rebase onto main):

  • build, API/Admin/protocol typecheck and lint, API unit tests (all files), protocol tests: all exit 0.
  • New unit test billing-credit-lot-value.service.test.ts. It covers:
    • value stability across region rates;
    • USD lots at the captured rate, and no guessed rate;
    • holds from the oldest lot and their release;
    • debt;
    • one usage line valued from its lots.
  • New PostgreSQL test billing-credit-lots.persistence.test.ts (6 tests). It covers:
    • oldest-first draws across a USD and a EUR lot, one movement per lot;
    • balance value before and after a USD purchase in a Europe region;
    • a refund drawing its own lot and its reversal returning there;
    • debt paid by the next purchase;
    • six concurrent debits serialized;
    • immutability.
  • All 55 billing-* PostgreSQL files, run serially through the Fedora Postgres tunnel: 53 files and 206 tests pass.
    • 4 tests time out, in billing-prepaid-reservation (30 s) and billing-stripe-corrections (40 s).
    • Neither file writes lot-tracked entries in the failing tests. I am re-running both against main on Maxis; CI is the reference.

Not verified: valuation shown to a product (the display PR) and refunds per lot (the refunds PR).

🤖 Generated with Claude Code

Each top-up, automatic top-up and admin credit opens a lot with its currency,
amount paid and USD per unit. The credit entry trigger draws debits from lots
oldest-first, one movement per lot, under the account lock; refunds draw their
own purchase's lot first, give-backs return to debt, the reversed lot and then
the newest lot, and debt beyond the lots is paid by the next grant. Lots and
balance must agree after every entry. Existing accounts are backfilled as
rate-1 USD lots. The balance is valued in the region currency at each lot's own
rate, with USD lots at a rate the region captures once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 19:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@rafiki270

Copy link
Copy Markdown
Contributor Author

The 4 Maxis timeouts reproduce identically on main (a369af4), in the same tunnelled environment: billing-prepaid-reservation (2) and billing-stripe-corrections (2). They are environmental, not caused by this PR.

@rafiki270
rafiki270 merged commit 5c3e50b into main Oct 12, 2026
2 checks passed
@rafiki270
rafiki270 deleted the claude/credit-lots branch October 12, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants