Skip to main content

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.

ContextDetails
AggregateBilling::Entitlement has no children and belongs to no aggregate. It is standalone reference data.
LayerEntitlement engine — the rows the whole engine reads its rules from
Upstream dependenciesNone. Rows are seeded at deploy time.
Downstream dependentsEvery 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:

Columnplacementgigworkforce (future)
unit_namecreditcentseat
One unit meansone credit spent on ads, posts, and boostsone cent of prepaid wage valueone seat in a subscription
allocation_policylotslotsa new value, decided at launch
recognition_policylot_basedlot_baseda new value — revenue by time, not by consumed units
is_reservabletruetruedecided at launch
is_refundablefalse — placement credits expire insteadtrue — 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::LedgerEntry rows, 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: :lots with 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​

IDUse CaseTriggerActor
UC-1Seed a new instrument when a new kind of credit launchesJod decides to sell a new kind of creditSystem
UC-2Flip placement's policies to lots before its first ledger entryThe entitlement engine shipsSystem
UC-3View the instruments and their policiesAn admin reviews what Jod sells and how it behavesAdmin
UC-4Read an instrument's policies to route a movementEvery grant, reserve, consume, expire, and refundSystem

UC-1: Seed a new instrument when a new kind of credit launches​

FieldDetails
ActorSystem (a deploy-time seed)
TriggerJod 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:

  1. The seed creates one row:
    • instrument = the new value
    • unit_name = what one unit is
    • allocation_policy = how granted units are tracked
    • recognition_policy = how attached money becomes revenue
    • is_reservable and is_refundable = the flags the spending and refund code will obey
  2. The seed is safe to run twice. It skips any instrument that already has a row.

Business Rules:

  • instrument and unit_name never change after creation.
  • At launch the seed created two rows: placement and gig.

Postconditions:

  • The instrument exists. Every new Billing::Account gets a zero Billing::EntitlementBalance row 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::EntitlementBalance spec.

UC-2: Flip placement's policies to lots before its first ledger entry​

FieldDetails
ActorSystem (a one-time deploy-time seed change)
TriggerThe entitlement engine ships

Preconditions:

  • No Billing::LedgerEntry exists for placement.

System Behaviour:

  1. Rename the enum value fifo_lots to lots, in code and in the gig row.
  2. Update placement's row:
    • allocation_policy: pooled → lots
    • recognition_policy: proportional_avg → lot_based
  3. Remove pooled and proportional_avg from 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: :lots and recognition_policy: :lot_based. The engine has one code path.

UC-3: View the instruments and their policies​

FieldDetails
ActorIdentities::Admin
TriggerAn admin reviews what Jod sells and how it behaves

Preconditions:

  • None.

System Behaviour:

  1. 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​

FieldDetails
ActorSystem
TriggerEvery grant, reserve, consume, expire, and refund

Preconditions:

  • The movement's account and instrument are known.

System Behaviour:

  1. The engine reads the instrument's row and obeys it:
The engine asksColumnWhat the answer does
How do granted units land?allocation_policylots — every grant creates one Billing::EntitlementLot
Can this spend reserve credits first?is_reservabletrue — reserve-then-consume is allowed; false — direct consume only
How is revenue earned?recognition_policylot_based — consume, expire, and refund recognise per lot with the one formula
Can the customer be paid back?is_refundabletrue (gig) — a full-exit refund is allowed; false (placement) — credits expire instead
  1. The movement itself is written by the entitlement engine — see the Billing::LedgerEntry spec'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​

  1. instrument is unique. One row per instrument, ever.
  2. Rows change only through deploy-time seeds. No user screen creates, edits, or deletes them.
  3. instrument and unit_name cannot change after creation.
  4. Once an instrument has Billing::LedgerEntry rows, allocation_policy and recognition_policy cannot change (D2).
  5. Every stored-value instrument uses allocation_policy: :lots and recognition_policy: :lot_based (D2). New behaviour means new enum values.
  6. A Billing::Entitlement row is never deleted. Every entitlement-engine table points at it.

Model Interactions​

Related ModelRelationshipInteraction
Billing::ProductProduct belongs to oneA product sells units of exactly one instrument. Its validity_months sets the expiry of that instrument's lots.
Billing::AgreementTermTerm scoped to oneA term negotiates one instrument's rate. One term per agreement, instrument, and term_key.
Billing::EntitlementBalanceOne per account per instrumentCreated at zero for every instrument when a Billing::Account is created.
Billing::EntitlementLotLot scoped to oneLots carry the instrument's units and money. The instrument's policies say lots exist at all.
Billing::LedgerEntryEntry scoped to oneEvery entry names its instrument. Statements and exports group by it.
Billing::CreditActionAction scoped to oneAn admin decision about credits names the instrument it touches.
Billing::OutletBudgetBudget scoped to oneOutlet budgets split one instrument's balance across outlets. Gig uses this today.

Schema Gaps​

GapImpactSuggested 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.