Skip to main content

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.

Updated 2026-08-18

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::EntitlementHold was 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::CreditGrant was replaced by Billing::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

FieldTypeNotes
instrumentenum, uniqueplacement | gig | workforce
unit_nameenumcredit | cent | seat
allocation_policyenumlots — the only value. All stored-value credits use lots; the column stays so a future instrument can add its own value.
recognition_policyenumlot_based — the only value today. A future workforce subscription would recognise by time, adding a new value, not reusing lots.
is_reservablebooleanGates whether a company can reserve credits ahead of consuming them
is_refundablebooleantrue 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

FieldTypeNotes
units_availablebigintSpendable now. Up on grant/release, down on reserve/consume/expire
units_reservedbigintHeld for pending work. Up on reserve, down on consume/release
deferred_revenue_centsbigintOne 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

FieldTypeNotes
entry_typeenumgrant | 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_deltabigintApplied to the balance projection in the same transaction
deferred_revenue_delta_cents / recognised_revenue_centsbigintPopulated on grant, consume, and expire (breakage revenue)
source_type / source_idpolymorphicRenamed 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_keystring, uniquePrevents 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

FieldTypeNotes
purchased_attimestamptzWhen this batch was granted. Part of the spend order.
expires_attimestamptz, nullableSet at creation from the product's validity_months (paid) or the admin's chosen date (free). Null = never expires.
source_type / source_idpolymorphicRenamed from source_reference_type / _id. Billing::InvoiceLine (paid) or Billing::CreditAction (free)
units_purchased / units_available / units_reserved / units_consumed / units_expired / units_refundedbigintFive 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_centsbigintNot 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_bpsinteger, nullableGig 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

FieldTypeNotes
billing_ledger_entry_id / billing_entitlement_lot_idbigint, FKThe entry and the lot it touched
units_allocatedbigint> 0 for every type except adjust, which is always 0 — a date change moves no units
allocation_typeenumgrant | reserve | consume | release | expire | refund | adjust — always equals the entry's entry_type
recognised_revenue_centsbigint, nullableSet 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

FieldTypeNotes
action_typeenumtrial_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.
unitsbigint, nullableSet for the two grant types. NULL for expiry_extension — moving a date moves no units. Enforced by a database CHECK.
expires_attimestamptz, nullableGrants: 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_idbigint, FK, nullableEmpty 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.
reasonstring, not nullWhy the admin acted. Required — this is the control on manual credits, since there is no second-admin approval.
admin_created_bybigint, FKThe one admin who instructed this. There is no approver column.

→ See full spec: Billing::CreditAction