Main Branch Alignment — 2026-08-18
While this branch (billing-integration-phase-3-entitlement-engine-docs) was in progress, main
merged a further round of design work on the same schema — from a different branch, also called
billing-phase-3-placement-uses-entitlement-lots:
jodapp-apiPR #1938 — "docs(db): billing entitlement schema for lot-based credits (D2-D5)", merged 2026-08-17.docsPR #106 — "Billing phase 3 placement uses entitlement lots", merged 2026-08-17.
This page records what that meant for this branch and what changed today to catch up.
What happened
- Merged
maininto both branches (jodapp-api:billing-entitlement-hold-partial-unique-index-fix;docs:billing-integration-phase-3-entitlement-engine-docs).main's version was taken for every conflicting file — it is the later, more thorough, CTO/finance-reviewed design (dated through 2026-08-14 across Billing domain decisions D2 through D12), and its own D2 entry explicitly states it supersedes this branch's original design. - Confirmed the
Billing::CreditActionrename.mainhad already replaced theBilling::CreditGrantmodel this branch drafted withBilling::CreditAction— a broader, domain-level mechanism coveringtrial_grant,goodwill_grant, andexpiry_extension, not Ads-specific. No further naming decision was needed; this branch's docs were updated to match the shipped name. - Rewrote the Ads Phase 3 folder (
38-ads/billing-integration-phase-3/) to align withmain's schema and decisions — full rewrite, not a link-out, per the explicit choice made this session over a lighter patch-only option. - Deleted two superseded model spec pages:
billing-entitlement-hold.md(thebilling_entitlement_holdstable was removed from the design entirely — D8) andbilling-credit-grant.md(superseded bybilling-credit-action.md, already present from themainmerge). - Trimmed a stale duplicate.
38-ads/billing-integration-phase-2/use-cases/index.mdhad a full "Phase 3 Use Cases" handoff section (UC-9–UC-12) still describing the old hold-based mechanics. Replaced with a pointer to the Phase 3 use-cases page — one source of truth instead of two documents to keep in sync.
What changed in the design, in plain terms
- Placement credits and gig credits share one lot mechanism. This branch already had this
(that was this branch's whole point) —
main's version refines it further. - No hold table.
billing_entitlement_holdsis gone. A placement's or shift's reserved credits are now a computed read overBilling::LedgerEntry, not a stored row — Billing D8. reference_type/reference_idrenamed tosource_type/source_ideverywhere they appear (billing_ledger_entries,billing_entitlement_lots,billing_credit_actions).- A lot tracks five unit counters, not two, with a database CHECK enforcing they always sum to
units_purchased:units_available,units_reserved,units_consumed,units_expired,units_refunded. - Revenue recognition is recomputed on every movement, not fixed at lot creation:
units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved)— notdeferred_revenue_total_cents ÷ units_purchasedas this branch originally designed. Both reach the same end state (every lot settles to exactly zero), but the new formula needs no last-unit-absorbs-the-remainder special case. - Spend order is soonest-expiry-first (FEFO), not oldest-purchase-first (FIFO). A lot with no expiry sorts last.
- A lot has no
expired_atcolumn and no swept flag. "Expired" is a state read off the row (expires_atin the past), not something a job sets —Billing::EntitlementLot— Derived states. - A lot's expiry comes from the product (
billing_products.validity_months) for paid lots, or from the admin directly on theBilling::CreditActionrow for free grants — not from an entitlement-leveldefault_expiry_monthscolumn, which does not exist. Billing::CreditAction'saction_typeenum istrial_grant | goodwill_grant | expiry_extensiononly. Thereasonfree-text field stays required on every row, but there is nopromotional,backfill,correction, oropening_granttype — those were never built.admin_created_byis required on every row, not nullable.- The planned Ads production backfill (UC-14) does not exist. Billing D6 decided the placement launch differently: drain free campaigns before launch, start every company at zero, no migration code. This use case is dropped, not rewritten.
- The Phase 2 posting backfill (UC-13) is kept, since it addresses a different, narrower gap
(a real
Billing::InvoicePostingrow the live system already created, with no ledger entry yet) that D6 does not cover — whether it ever actually fires depends on the real rollout timeline. - New mechanics not previously designed on this branch at all:
entry_type: :refund(gig only, full-exit only),billing_credit_expiry_notices(the 30/7/3/1-day warning email send log), andentry_type: :adjust(anexpiry_extension's zero-movement ledger entry — previously reserved but unused).
What did not change
- Placement credits still move through grant → reserve → consume → release, still exercised only by Ads in this phase.
- The ledger is still the single source of truth; the balance is still a derived projection.
- Free credits still never blend with paid credits — a free lot still always carries
deferred_revenue_total_cents: 0. - The numeric outcome of the walkthrough's worked example (Acme Pte Ltd, 100 credits, 7-day campaign, cancelled after 3 days) is unchanged — lot 301's price is a clean $10.00/credit with no rounding, so the old fixed-rate formula and the new remaining-based formula produce identical cents at every step. Only the explanation of why changed.
Left undone
- The Phase 3 domain diagram (
overview/phase-3-domain.drawio.svg) still needs a manual pass through the draw.io editor to drop the hold node and renameBilling::CreditGranttoBilling::CreditAction. No draw.io CLI was available in this environment to automate it — see the Design Plan's Next Steps checklist. - The two repo merges are committed locally, not pushed, and every content change made after resolving the merge conflicts is uncommitted — per explicit instruction, so it can be reviewed before committing.