Billing::CreditAction
Purpose
CreditAction is the only way an Identities::Admin can write a LedgerEntry when no commercial document exists.
Every other way credits enter an account runs through a paid invoice.
- An
Invoiceis issued, - a
Paymentis verified, - an
InvoicePostinggrants the credits, and the amounts all come from the invoice lines.
That path cannot serve three user stories:
- As sales, I want to give a potential customer free trial placement credits, so they can see the platform working
- As business, I want to give a company free placement credits after an incident, to say sorry
- As sales, I want to give a company more time to spend placement credits it already has
Jodapp does not generally give free gig credits.
- Free credits above refer to placement credits.
In the event we do require to give free gig credits, the credit action still supports it.
billing_credit_actions.billing_entitlement_idforeign key tells us which type of credit is granted.
CreditAction is the record behind all three.
- One row is one instruction from one named admin, with a reason.
- The system carries the instruction out and writes the ledger entry it causes.
The table is deliberately not called a grant record.
- Two of its three action types add credits,
- but the third only moves a date.
What the three share is not the effect — it is the cause.
- In each one, the numbers come from an admin rather than from a document.
That is the whole design rule:
| Credits move because | The record at the root of why | Where the numbers come from |
|---|---|---|
| A customer paid an invoice | InvoicePosting | The InvoiceLine rows |
| An admin said so | CreditAction | The admin, typed into this row |
Schema truth: jodapp-api/docs/db/billing.dbml, table credit_actions.
Model Context
Legend: Lavender nodes belong to the Billing domain. The grey node is external. The yellow node is the subject of this spec. Every arrow is solid because every one of them is a structural link — the row is owned, scoped, and acted on.
| Context | Details |
|---|---|
| Aggregate | CreditAction has no children. It is part of the Account aggregate. |
| Layer | Entitlements Engine — the admin's way into the ledger |
| Upstream dependencies | Account, Entitlement, and the Identities::Admin who instructed it. An expiry_extension also needs the EntitlementLot it extends. |
| Downstream dependents | LedgerEntry (grant and adjust entries name this row as their source), EntitlementLot (free lots name this row as their source), CreditExpiryNotice (an extension makes every warning stage send again) |
The three action types
action_type | What the admin instructed | units | expires_at | billing_entitlement_lot_id | Entry written |
|---|---|---|---|---|---|
trial_grant | Free credits so a new company can try the platform | set | the new lot's expiry, empty means never | empty | one grant |
goodwill_grant | Free credits to say sorry after an incident or a complaint | set | the new lot's expiry, empty means never | empty | one grant |
expiry_extension | An existing purchase batch of credits gets more time | empty | the lot's new expiry date | the lot being extended | one adjust |
trial_grant is the type sales uses most. It is what they reach for when approaching a potential customer.
Two column rules the table shows, both enforced by database CHECK constraints:
- The two grant types
trial_grantandgoodwill_grantsetunitsand leavebilling_entitlement_lot_idempty.- The
EntitlementLotdoes not exist yet. The grant creates it. - The new
EntitlementLotpoints back at this row through its ownsource_type/source_id.
- The
expiry_extensiondoes the reverse: it leavesunitsempty and setsbilling_entitlement_lot_id.- The
EntitlementLotalready exists, so the row names it directly. - Moving a date moves no units.
- The
The constraints are check_credit_actions_units_match_type and check_credit_actions_lot_match_type. Each states exactly what one action type allows, in a CASE. Neither is an OR, so no row can slip through with a shape nobody designed.
Both grant types create an EntitlementLot carrying exactly SGD 0 of deferred revenue
- Nobody paid for these credits, so consuming or expiring them earns nothing.
- Deferred revenue here means money already collected for a service not yet delivered.
- The
EntitlementLotspec defines the amount per instrument in The deferred revenue on a lot.
- The
Opening balances for the gig flip are not recorded here. That was decided in Billing D9 — The gig flip's opening balances are not credit actions: an opening balance is read off a jodgig statement of account, not decided by an admin, and its record is designed in Phase 4.
Who this row names
| Column | Meaning |
|---|---|
admin_created_by | The one Identities::Admin who instructed this. Required on every row. |
reason | Why they did it, in their own words. Required on every row. It is shown in audit reviews and on internal reports. |
idempotency_key | Unique. Carrying out the same instruction twice finds the existing row instead of writing a second one. |
There is no approver column and no approval step.
- One admin acts, and the audit review reads who acted and why afterwards.
- The reasoning is in Billing D10 — A credit action needs one admin, not two
- free grants create SGD 0 lots, so a wrong one costs the service delivered but misstates no revenue, and
- an
expiry_extensiononly moves revenue in time and leaves anadjustentry in the statement where anyone can see it.
State Machine
A CreditAction row has no states. There is no status column. The row is written in the same transaction that carries out the instruction, so a row exists only for something that already happened. It never changes after that.
Worked example: Acme's trial credits
The same Acme Foods story as the other entitlement specs.
- On 1 February 2027 a sales admin gives Acme 20 free placement credits
- Credits
expires_at: end of 1 March 2027 - This is the instruction behind lot P-2 in the
EntitlementLotworked example.
- Credits
The row:
| Column | Value |
|---|---|
action_type | trial_grant |
billing_account_id | Acme Foods' Billing::Account |
billing_entitlement_id | placement |
units | 20 |
expires_at | the last moment of 1 March 2027, Singapore time |
billing_entitlement_lot_id | empty — lot P-2 does not exist yet |
reason | "New customer trial: 20 placement credits, agreed with the sales lead" |
admin_created_by | the sales admin who instructed it |
What the system wrote in the same transaction:
| Record | Values |
|---|---|
One LedgerEntry | entry_type: :grantavailable_delta: +20reserved_delta: 0deferred_revenue_delta_cents: 0recognised_revenue_cents: 0 |
One EntitlementLot (P-2) | units_purchased: 20units_available: 20deferred_revenue_total_cents: 0expires_at: end of 1 March 2027 |
One EntitlementLotAllocation | allocation_type: :grantunits_allocated: 20recognised_revenue_cents: NULL |
These are entry 2 and allocation row 2 of the shared Acme story. Read them in place in the LedgerEntry statement of account and the EntitlementLotAllocation rows.
A path Acme did not take.
If an admin had given lot P-1 more time, a second CreditAction row would exist with:
action_type: :expiry_extensionunits: null- moving a date moves no units
billing_entitlement_lot_id: P-1expires_at: the new date
Acme's P-1 kept its 1 February 2028 date and expired on it, so no such row exists in this story.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Give a company free credits for a trial or an apology | An admin decides to give a company credits nobody paid for | Admin |
| UC-2 | Give a company more time to spend a batch of credits | An admin decides a company needs longer before its credits expire | Admin |
| UC-3 | View every credit instruction made on a company | An audit review, or support answering "why do I have these credits?" | Admin |
UC-1: Give a company free credits for a trial or an apology
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | An admin decides to give a company credits nobody paid for |
Preconditions:
- The company has an
Accountand anEntitlementrow for the instrument. - The admin has written a reason.
System Behaviour:
- The system writes one
CreditActionrow:action_type: :trial_grant- when the credits are for a new company trying the platform
action_type: :goodwill_grant- when the credits are an apology after an incident or a complaint
units= how many units to grantexpires_at= the date the new credits die, left empty when they never expirebilling_entitlement_lot_id= empty, because theEntitlementLotdoes not exist yet
- In the same transaction the system carries the instruction out (
LedgerEntryUC-1):- one
grantLedgerEntry, naming thisCreditActionrow as its source - one
EntitlementLot, also naming thisCreditActionrow as its source - one
EntitlementLotAllocationrow joining the two (Billing D7 — Allocation rows are the complete history of a lot)
- one
- The new
EntitlementLotcarriesdeferred_revenue_total_cents: 0.
Business Rules:
reasonis required. A free grant with no stated reason cannot be written.- The two grant types differ only in why the admin acted. The mechanics are identical.
- Trial credits normally get a shorter life than paid credits. That is admin judgement, not a system rule.
- Free credits never blend with paid credits. The SGD 0 stays on its own
EntitlementLotforever (Billing D2 — All stored-value credits use entitlement lots). - One admin is enough. There is no second approver (Billing D10 — A credit action needs one admin, not two).
- The same instruction cannot be carried out twice.
idempotency_keyis unique.
Postconditions:
- The company has a new open
EntitlementLot, full, worth SGD 0. - The audit trail names the admin, the reason, and the exact units.
- The daily Xero export produces no lines for these credits. Nobody paid us.
Open Questions:
- Granting straight to an outlet is not designed. Today a grant always lands on the company, and moving part of it to one outlet is a separate
OutletBudgetTransfer. Whether an admin can do both in one step — and whetherCreditActionwould then need to name anOrg::Outlet— is undecided. - Reversing a credit action is not designed. If an admin grants the wrong company 20 credits, no action type undoes it. This is the same gap as finance corrections in the ledger —
LedgerEntryUC-7 holds the open questions for the whole correction topic.
UC-2: Give a company more time to spend a batch of credits
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | An admin decides a company needs longer before its credits expire |
Preconditions:
- The
EntitlementLotstill has available units, and the nightly cleanup job has not closed it yet. - The new date is later than the current
expires_at. Extensions only lengthen. - The admin has written a reason.
System Behaviour:
- The system writes one
CreditActionrow:action_type: :expiry_extensionbilling_entitlement_lot_id= theEntitlementLotgetting more timeexpires_at= the new date, stored as the last moment of that day in the company's timezoneunits= empty, because moving a date moves no units
- In the same transaction the system carries the instruction out (
LedgerEntryUC-7):- it updates the
EntitlementLot'sexpires_at - it writes one
adjustLedgerEntrywith every delta at zero, naming thisCreditActionrow as its source - it writes one
EntitlementLotAllocationrow withunits_allocated: 0andrecognised_revenue_cents: NULL
- it updates the
- Every warning email stage starts over for the new date, with no extra step. A stage counts as sent only when a logged row matches the lot's current
expires_at, and the old rows no longer match (Billing D3 — How credit expiry runs day to day).
Business Rules:
expires_atis the only value on anEntitlementLotthat an admin instruction can change.- An
EntitlementLotthe nightly cleanup job already closed is never extended. Its deferred revenue already became breakage revenue. The admin gives agoodwill_grantinstead (UC-1). - An extension delays breakage revenue. It never creates or destroys revenue, and the
adjustentry makes the change visible in the statement of account (Billing D10 — A credit action needs one admin, not two). - The zero-unit
EntitlementLotAllocationrow keeps the lot's history complete in one table. Without it, every lot-history query would have to readcredit_actionsas a second source (Billing D7 — Allocation rows are the complete history of a lot).
Postconditions:
- The
EntitlementLotis open until the new date. - The statement of account shows the date change, with the admin and the reason behind it.
- Every warning email stage will send again for the new date.
UC-3: View every credit instruction made on a company
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | An audit review, or support answering "why do I have these credits?" |
Preconditions:
- The
Accountexists.
System Behaviour:
- The system lists the company's
CreditActionrows in time order, showing:- the date and the action type
- the units, or the lot and its new date for an extension
- the reason
- the admin who instructed it
- Each row links to what it produced — the
LedgerEntryand, for a grant, theEntitlementLotit created.
Business Rules:
- This list is the control on manual credits. Because no second admin approves before the write, the review after the write is what an audit reads (Billing D10 — A credit action needs one admin, not two).
- It must be complete: every free grant and every date change on the account appears.
- The list is read-only. There is no edit screen and no delete screen.
Postconditions:
- Read-only operation — no data changes.
Invariants
- A
CreditActionrow is written only when no commercial document sits behind the credits. When anInvoiceis behind them, theInvoicePostingis the source instead, and noCreditActionexists. - Every row names exactly one
Identities::Admininadmin_created_by, and carries areason. Both are required. - No row has an approver. Approval before the write is not part of this table (Billing D10 — A credit action needs one admin, not two).
- Rows are only added. They are never edited and never deleted, the same rule as
LedgerEntry. idempotency_keyis unique. The same instruction cannot be carried out twice.- The two grant types set
unitsand leavebilling_entitlement_lot_idempty.expiry_extensiondoes the reverse. Two database CHECK constraints enforce this per type. - Each grant type produces exactly one
grantLedgerEntryand exactly oneEntitlementLot, in the same transaction as this row. - An
expiry_extensionproduces exactly oneadjustLedgerEntry, which moves no units and no revenue. - Every
EntitlementLotaCreditActioncreates carries exactly SGD 0 of deferred revenue. - An
expiry_extensionalways movesexpires_atlater, never earlier. - Every row names exactly one instrument through
billing_entitlement_id. One instruction never spans two instruments. - For a grant, this row is the source of both the
LedgerEntryand theEntitlementLot. This differs from the paid path, where those two records name different sources — which is why Billing D7 — Allocation rows are the complete history of a lot exists. - Opening balances are not recorded here (Billing D9 — The gig flip's opening balances are not credit actions).
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Account | Action belongs to Account | Every instruction names the company it affects. The account's balance row lock serialises the ledger write it causes. |
Entitlement | Action scoped to one instrument | An instruction names the instrument it touches. It never spans two. |
Identities::Admin | One reference per row | admin_created_by is the one admin who instructed it. There is no approver reference. |
LedgerEntry | Action causes exactly one entry | A grant causes a grant entry. An expiry_extension causes a zero-movement adjust entry. Both name this row as their source. |
EntitlementLot | A grant creates one; an extension names one | A grant creates a lot that points back here. An expiry_extension points at a lot that already exists and moves its expires_at. |
EntitlementLotAllocation | Through the entry | The entry this instruction causes writes its allocation rows — including the zero-unit row that puts a date change into the lot's history (Billing D7 — Allocation rows are the complete history of a lot). |
CreditExpiryNotice | An extension resets the warnings | After an expiry_extension, old notice rows no longer match the lot's expires_at, so every warning stage sends again for the new date (Billing D3 — How credit expiry runs day to day). |
InvoicePosting | The other way into the ledger | When credits come from a paid invoice, the posting is the source and no CreditAction is written. An admin is involved on both paths — on the invoice path an admin verifies the payment as paid, and that verification is what triggers the posting. So the difference is not who acts. It is where the numbers come from: the InvoiceLine rows, or the admin typing them into this row. |
OutletBudget | No direct relationship today | A grant lands on the company. Moving part of it to one outlet is a separate OutletBudgetTransfer. Granting straight to an outlet is an open question in UC-1. |
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
ref: < on admin_created_by declared the relationship backwards, so dbml2sql generated a foreign key on identities_admins instead of this table. | The generated SQL and any diagram drawn from the DBML showed the link the wrong way round. | Resolved. Every ref: < in billing.dbml is now ref: > — 7 tables, 16 columns, verified in the generated DDL. |