Billing::Entitlement
Purpose
A Billing::Entitlement row defines one instrument — one kind of credit Jod sells. The word instrument is the schema's own word (column billing_entitlements.instrument). Two instruments exist today:
- placement — credits spent on ads, Careers job posts, and boosts.
- gig — prepaid wage value for shift work, counted in cents.
A third value, workforce, is reserved in the enum for a future subscription service. It gets a row only when that service launches.
The row is data, not behaviour. It answers the five questions the entitlement engine asks about every movement:
- What is one unit? (
unit_name) - How are granted units tracked? (
allocation_policy) - How does the money attached to units become revenue? (
recognition_policy) - Can units be reserved for pending work? (
is_reservable) - Can unused units be paid back to the customer? (
is_refundable)
Why a table instead of code: every entitlement-engine table carries a billing_entitlement_id foreign key, and the engine reads its rules from the row that key points at. One engine serves every instrument. Launching a new instrument means adding a row, not forking the engine. D2 fixes the policy values: all stored-value credits use lots.
Billing::Entitlement rows are reference data. The system seeds them at deploy time. No user screen creates or edits them.
Schema truth: jodapp-api/docs/db/billing.dbml, table billing_entitlements.
Model Context
Legend: Lavender nodes belong to the Billing domain. Grey nodes are external. The yellow node is the subject of this spec. Dotted arrows are reads; solid arrows are foreign keys pointing back at the instrument.
| Context | Details |
|---|---|
| Aggregate | Billing::Entitlement has no children and belongs to no aggregate. It is standalone reference data. |
| Layer | Entitlement engine — the rows the whole engine reads its rules from |
| Upstream dependencies | None. Rows are seeded at deploy time. |
| Downstream dependents | Every table that scopes to an instrument: products, agreement terms, balances, lots, ledger entries, credit actions, outlet budgets |
The instruments and their policies
The target state of the table, one column per instrument:
| Column | placement | gig | workforce (future) |
|---|---|---|---|
unit_name | credit | cent | seat |
| One unit means | one credit spent on ads, posts, and boosts | one cent of prepaid wage value | one seat in a subscription |
allocation_policy | lots | lots | a new value, decided at launch |
recognition_policy | lot_based | lot_based | a new value — revenue by time, not by consumed units |
is_reservable | true | true | decided at launch |
is_refundable | false — placement credits expire instead | true — the wage value is paid back on a full exit (D4) | decided at launch |
Two rules sit behind the policy columns:
- Once an instrument has
Billing::LedgerEntryrows, its two policies cannot change. Every entry was written under those rules, so changing them would rewrite the meaning of history (D2). - A future instrument that behaves differently adds new enum values. It never reuses
allocation_policy: :lotswith a second meaning.
State Machine
A Billing::Entitlement row has no states. There is no status column. The row is seeded once, and only UC-2's one-time policy change edits it before any history exists.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Seed a new instrument when a new kind of credit launches | Jod decides to sell a new kind of credit | System |
| UC-2 | Flip placement's policies to lots before its first ledger entry | The entitlement engine ships | System |
| UC-3 | View the instruments and their policies | An admin reviews what Jod sells and how it behaves | Admin |
| UC-4 | Read an instrument's policies to route a movement | Every grant, reserve, consume, expire, and refund | System |
UC-1: Seed a new instrument when a new kind of credit launches
| Field | Details |
|---|---|
| Actor | System (a deploy-time seed) |
| Trigger | Jod decides to sell a new kind of credit |
Preconditions:
- The enum values for the instrument exist in code.
- No row exists for the instrument yet.
System Behaviour:
- The seed creates one row:
instrument= the new valueunit_name= what one unit isallocation_policy= how granted units are trackedrecognition_policy= how attached money becomes revenueis_reservableandis_refundable= the flags the spending and refund code will obey
- The seed is safe to run twice. It skips any instrument that already has a row.
Business Rules:
instrumentandunit_namenever change after creation.- At launch the seed created two rows: placement and gig.
Postconditions:
- The instrument exists. Every new
Billing::Accountgets a zeroBilling::EntitlementBalancerow for it.
Open Questions:
- Accounts created before the new row have no balance row for it. Backfill at seed time, or create the balance on first use? Undecided — the answer belongs to the
Billing::EntitlementBalancespec.
UC-2: Flip placement's policies to lots before its first ledger entry
| Field | Details |
|---|---|
| Actor | System (a one-time deploy-time seed change) |
| Trigger | The entitlement engine ships |
Preconditions:
- No
Billing::LedgerEntryexists for placement.
System Behaviour:
- Rename the enum value
fifo_lotstolots, in code and in the gig row. - Update placement's row:
allocation_policy:pooled→lotsrecognition_policy:proportional_avg→lot_based
- Remove
pooledandproportional_avgfrom the enums. No instrument uses them.
Business Rules:
- This change must land before the first grant ledger entry ships. After an instrument has entries, its policies are frozen forever (D2).
Postconditions:
- Every instrument row carries
allocation_policy: :lotsandrecognition_policy: :lot_based. The engine has one code path.
UC-3: View the instruments and their policies
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | An admin reviews what Jod sells and how it behaves |
Preconditions:
- None.
System Behaviour:
- The system lists every row with its columns:
- the instrument and its unit name
- the two policies
- the two flags
Business Rules:
- The list is read-only. There is no create, edit, or delete screen.
Postconditions:
- Read-only operation — no data changes.
UC-4: Read an instrument's policies to route a movement
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Every grant, reserve, consume, expire, and refund |
Preconditions:
- The movement's account and instrument are known.
System Behaviour:
- The engine reads the instrument's row and obeys it:
| The engine asks | Column | What the answer does |
|---|---|---|
| How do granted units land? | allocation_policy | lots — every grant creates one Billing::EntitlementLot |
| Can this spend reserve credits first? | is_reservable | true — reserve-then-consume is allowed; false — direct consume only |
| How is revenue earned? | recognition_policy | lot_based — consume, expire, and refund recognise per lot with the one formula |
| Can the customer be paid back? | is_refundable | true (gig) — a full-exit refund is allowed; false (placement) — credits expire instead |
- The movement itself is written by the entitlement engine — see the
Billing::LedgerEntryspec's write path.
Business Rules:
- The shared Acme example is placement behaving by this row: lots P-1 and P-2 exist because placement's row carries
allocation_policy: :lots.
Postconditions:
- Read-only operation — no data changes.
Invariants
instrumentis unique. One row per instrument, ever.- Rows change only through deploy-time seeds. No user screen creates, edits, or deletes them.
instrumentandunit_namecannot change after creation.- Once an instrument has
Billing::LedgerEntryrows,allocation_policyandrecognition_policycannot change (D2). - Every stored-value instrument uses
allocation_policy: :lotsandrecognition_policy: :lot_based(D2). New behaviour means new enum values. - A
Billing::Entitlementrow is never deleted. Every entitlement-engine table points at it.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Product | Product belongs to one | A product sells units of exactly one instrument. Its validity_months sets the expiry of that instrument's lots. |
Billing::AgreementTerm | Term scoped to one | A term negotiates one instrument's rate. One term per agreement, instrument, and term_key. |
Billing::EntitlementBalance | One per account per instrument | Created at zero for every instrument when a Billing::Account is created. |
Billing::EntitlementLot | Lot scoped to one | Lots carry the instrument's units and money. The instrument's policies say lots exist at all. |
Billing::LedgerEntry | Entry scoped to one | Every entry names its instrument. Statements and exports group by it. |
Billing::CreditAction | Action scoped to one | An admin decision about credits names the instrument it touches. |
Billing::OutletBudget | Budget scoped to one | Outlet budgets split one instrument's balance across outlets. Gig uses this today. |
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
The database has no is_refundable column. The DBML has it. | The refund rule (D4) cannot be read from data. | Open. Add the column, default is_refundable: false. Set gig's row to is_refundable: true. |
The code enums still carry pooled, fifo_lots, and proportional_avg. The placement row still uses allocation_policy: :pooled and recognition_policy: :proportional_avg. | The engine would branch on policy values that no instrument should have. | Open. UC-2: rename fifo_lots to lots, flip the placement row, remove the dead values — before the first grant ledger entry. |
The database and code have a uuid column with a unique index. The DBML does not list it. | The schema truth file is missing a real column. | Open. Add uuid to billing.dbml. |