Phase 3 — Model Specifications
This section documents the six models that make up the Entitlement Engine — two already
implemented (Billing::Entitlement, Billing::EntitlementBalance), four new to this phase
(Billing::LedgerEntry, Billing::EntitlementLot, Billing::EntitlementLotAllocation,
Billing::CreditAction).
For full per-model documentation including all use cases and invariants, see the individual spec pages under Billing Model Specifications.
This page originally listed seven models, including Billing::EntitlementHold and
Billing::CreditGrant. Both are gone from the final design that shipped to main
(jodapp-api PR #1938,
docs PR #106):
Billing::EntitlementHoldwas removed entirely. There is no hold table. A placement's reserved credits are computed from the ledger, not stored in a summary row — see Billing D8.Billing::CreditGrantwas replaced byBilling::CreditAction, a broader, domain-level mechanism (not Ads-specific) covering free grants and expiry extensions — see the model's own section below.
Billing::EntitlementLot and Billing::EntitlementLotAllocation are unchanged in scope: both
moved into Phase 3 from "Phase 4 — gig only," exercised by placement credits first — see
Overview and
Billing domain decisions D2.
Scope note: Every model below is exercised by placement credits only in Phase 3.
Billing::Entitlement also defines a gig row (also lots / lot_based, unchanged by this
revision), but gig's actual grant/reserve/consume flow — invoices with a :platform_fee line,
Gig::Shift as the source, platform_fee_* columns on the lot — is a later phase, not exercised
in Phase 3. Outlet Budget (Billing::OutletBudget) is also out of scope — it only partitions
gig-instrument balances (see
Billing::OutletBudget § Entitlement
Type Considerations), and Phase 3 grants/reserves/consumes/releases/expires placement credits
only. Careers::Job consuming placement credits is also out of scope this phase (Ads only).
Entitlement Engine Aggregate Diagram
Legend: Lavender nodes are new/central Entitlement Engine models. Grey nodes are external references (upstream commercial documents, the admin who instructs a credit action, or the Ads consumer). Yellow nodes are this phase's primary subjects. There is no hold node — a placement's reserved credits are a computed read over
Billing::LedgerEntry, not a stored row (Billing D8).
Model Summary
Billing::Entitlement
Implemented; policy changed this phase. The type registry — one row per instrument
(placement, gig; workforce reserved for later). Defines allocation_policy and
recognition_policy that the rest of the engine reads.
Table: billing_entitlements
| Field | Type | Notes |
|---|---|---|
instrument | enum, unique | placement | gig | workforce |
unit_name | enum | credit | cent | seat |
allocation_policy | enum | lots — the only value. All stored-value credits use lots; the column stays so a future instrument can add its own value. |
recognition_policy | enum | lot_based — the only value today. A future workforce subscription would recognise by time, adding a new value, not reusing lots. |
is_reservable | boolean | Gates whether a company can reserve credits ahead of consuming them |
is_refundable | boolean | true for gig (the principal is refundable stored value); false for placement (credits expire instead) |
Note: default_expiry_months does not live here. A lot's expiry comes from the product that
was purchased (billing_products.validity_months), or, for a free grant, from the admin directly
on the Billing::CreditAction row — see below.
→ See full spec: Billing::Entitlement
Billing::EntitlementBalance
Implemented (schema); Phase 3 adds the write paths. The fast-read projection of a company's
credit position per instrument — units_available, units_reserved, and deferred_revenue_cents.
Maintained transactionally alongside every Billing::LedgerEntry write; never an independent
source of truth. deferred_revenue_cents is a display total only — the sum of all the
account's lot-level deferred_revenue_remaining_cents for this instrument — recognition itself is
computed per lot, not from this account-wide figure.
Table: billing_entitlement_balances
| Field | Type | Notes |
|---|---|---|
units_available | bigint | Spendable now. Up on grant/release, down on reserve/consume/expire |
units_reserved | bigint | Held for pending work. Up on reserve, down on consume/release |
deferred_revenue_cents | bigint | One column for both instruments now — placement: the credit sale price; gig: the platform fee only. Down on consume, expire, and refund. |
→ See full spec: Billing::EntitlementBalance
Billing::LedgerEntry
New this phase. The single source of truth for every credit movement — grant, reserve, consume, release, expire, refund, adjust. Append-only, immutable. Every balance and lot update traces back to exactly one ledger entry.
Table: billing_ledger_entries
| Field | Type | Notes |
|---|---|---|
entry_type | enum | grant | reserve | release | consume | expire | refund | adjust — :expire, :refund, and :adjust are new. Placement exercises grant | reserve | consume | release | expire this phase; refund is gig-only and adjust is the Billing::CreditAction expiry-extension entry. |
available_delta / reserved_delta | bigint | Applied to the balance projection in the same transaction |
deferred_revenue_delta_cents / recognised_revenue_cents | bigint | Populated on grant, consume, and expire (breakage revenue) |
source_type / source_id | polymorphic | Renamed from reference_type / reference_id. Billing::InvoicePosting or Billing::CreditAction (grant), Ads::CampaignPlacement (reserve/consume/release), Billing::EntitlementLot (expire), Billing::CreditAction of type expiry_extension (adjust) |
idempotency_key | string, unique | Prevents duplicate execution |
Removed: pool_units_before, pool_deferred_revenue_before_cents — dead once recognition is
lot-based for both instruments; the authoritative per-lot record is
Billing::EntitlementLotAllocation. See
Billing D2.
→ See full spec: Billing::LedgerEntry
Billing::EntitlementLot
Pulled forward from "Phase 4 — gig only." Tracks one purchase batch of credits — a paid
invoice line, or a free Billing::CreditAction grant — with its own expiry date and money
attached. This is what makes purchase-date-relative expiry possible, and what keeps free and paid
credit revenue separate: a free lot always carries deferred_revenue_total_cents: 0.
Table: billing_entitlement_lots
| Field | Type | Notes |
|---|---|---|
purchased_at | timestamptz | When this batch was granted. Part of the spend order. |
expires_at | timestamptz, nullable | Set at creation from the product's validity_months (paid) or the admin's chosen date (free). Null = never expires. |
source_type / source_id | polymorphic | Renamed from source_reference_type / _id. Billing::InvoiceLine (paid) or Billing::CreditAction (free) |
units_purchased / units_available / units_reserved / units_consumed / units_expired / units_refunded | bigint | Five moving counters, plus units_purchased. A database CHECK enforces units_purchased = units_available + units_reserved + units_consumed + units_expired + units_refunded at all times — every row proves itself. |
deferred_revenue_total_cents / _remaining_cents | bigint | Not nullable — every lot (placement or gig) carries these. Recognition formula: units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved), recomputed fresh on every movement — not a fixed rate set at creation. |
platform_fee_rate_bps | integer, nullable | Gig only — not exercised in Phase 3 |
Spend order — the order lots are drawn from at reserve or direct consume time: expires_at
ascending (lots with no expiry sort last), then purchased_at ascending, then id. This is
first-expiry-first-out, not plain first-in-first-out.
→ See full spec: Billing::EntitlementLot
Billing::EntitlementLotAllocation
New this phase. The join table recording exactly which lot(s) a ledger entry applies to, and how many units moved on each. A reserve entry's allocation rows lock the lot composition for that placement — every later consume/release for the same placement draws from that same locked set, never re-run through the spend order. A grant entry's allocation row is what links it (sourced from the posting or credit action) to the lot it created (sourced from the invoice line or credit action itself) — without it, the two records share no key.
Table: billing_entitlement_lot_allocations
| Field | Type | Notes |
|---|---|---|
billing_ledger_entry_id / billing_entitlement_lot_id | bigint, FK | The entry and the lot it touched |
units_allocated | bigint | > 0 for every type except adjust, which is always 0 — a date change moves no units |
allocation_type | enum | grant | reserve | consume | release | expire | refund | adjust — always equals the entry's entry_type |
recognised_revenue_cents | bigint, nullable | Set only for consume, expire, and refund — this lot's share of the entry's recognised revenue. NULL for grant, reserve, release, and adjust. |
→ See full spec: Billing::EntitlementLotAllocation
Billing::CreditAction
Replaces the Phase 3 draft's Billing::CreditGrant. The only way an admin writes a ledger
entry when no commercial document exists — not Ads-specific, but placement credits are one of the
instruments it can act on. One row is one instruction from one named admin, with a required
reason. Unlike the draft it replaces, it is not shaped like a grant record with a reason enum of
free-text-ish categories: it has exactly three action_type values, and one of them moves a date,
not units.
Table: billing_credit_actions
| Field | Type | Notes |
|---|---|---|
action_type | enum | trial_grant | goodwill_grant | expiry_extension — no promotional, backfill, correction, or opening_grant. The two grant types create a lot; expiry_extension changes the date on a lot that already exists. |
units | bigint, nullable | Set for the two grant types. NULL for expiry_extension — moving a date moves no units. Enforced by a database CHECK. |
expires_at | timestamptz, nullable | Grants: the new lot's expiry, chosen by the admin directly (there is no product involved). expiry_extension: the lot's new expiry — must be later than the current one. |
billing_entitlement_lot_id | bigint, FK, nullable | Empty for the two grant types (the lot does not exist yet). Set for expiry_extension (the lot being extended). Enforced by a database CHECK, the reverse of the units rule. |
reason | string, not null | Why the admin acted. Required — this is the control on manual credits, since there is no second-admin approval. |
admin_created_by | bigint, FK | The one admin who instructed this. There is no approver column. |
→ See full spec: Billing::CreditAction