Skip to main content

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 Invoice is issued,
  • a Payment is verified,
  • an InvoicePosting grants 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
important

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_id foreign 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 becauseThe record at the root of whyWhere the numbers come from
A customer paid an invoiceInvoicePostingThe InvoiceLine rows
An admin said soCreditActionThe 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.

ContextDetails
AggregateCreditAction has no children. It is part of the Account aggregate.
LayerEntitlements Engine — the admin's way into the ledger
Upstream dependenciesAccount, Entitlement, and the Identities::Admin who instructed it. An expiry_extension also needs the EntitlementLot it extends.
Downstream dependentsLedgerEntry (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_typeWhat the admin instructedunitsexpires_atbilling_entitlement_lot_idEntry written
trial_grantFree credits so a new company can try the platformsetthe new lot's expiry, empty means neveremptyone grant
goodwill_grantFree credits to say sorry after an incident or a complaintsetthe new lot's expiry, empty means neveremptyone grant
expiry_extensionAn existing purchase batch of credits gets more timeemptythe lot's new expiry datethe lot being extendedone 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_grant and goodwill_grant set units and leave billing_entitlement_lot_id empty.
    • The EntitlementLot does not exist yet. The grant creates it.
    • The new EntitlementLot points back at this row through its own source_type / source_id.
  • expiry_extension does the reverse: it leaves units empty and sets billing_entitlement_lot_id.
    • The EntitlementLot already exists, so the row names it directly.
    • Moving a date moves no units.

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.

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​

ColumnMeaning
admin_created_byThe one Identities::Admin who instructed this. Required on every row.
reasonWhy they did it, in their own words. Required on every row. It is shown in audit reviews and on internal reports.
idempotency_keyUnique. Carrying out the same instruction twice finds the existing row instead of writing a second one.
important

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_extension only moves revenue in time and leaves an adjust entry 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

The row:

ColumnValue
action_typetrial_grant
billing_account_idAcme Foods' Billing::Account
billing_entitlement_idplacement
units20
expires_atthe last moment of 1 March 2027, Singapore time
billing_entitlement_lot_idempty — lot P-2 does not exist yet
reason"New customer trial: 20 placement credits, agreed with the sales lead"
admin_created_bythe sales admin who instructed it

What the system wrote in the same transaction:

RecordValues
One LedgerEntryentry_type: :grant
available_delta: +20
reserved_delta: 0
deferred_revenue_delta_cents: 0
recognised_revenue_cents: 0
One EntitlementLot (P-2)units_purchased: 20
units_available: 20
deferred_revenue_total_cents: 0
expires_at: end of 1 March 2027
One EntitlementLotAllocationallocation_type: :grant
units_allocated: 20
recognised_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_extension
  • units: null
    • moving a date moves no units
  • billing_entitlement_lot_id: P-1
  • expires_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​

IDUse CaseTriggerActor
UC-1Give a company free credits for a trial or an apologyAn admin decides to give a company credits nobody paid forAdmin
UC-2Give a company more time to spend a batch of creditsAn admin decides a company needs longer before its credits expireAdmin
UC-3View every credit instruction made on a companyAn 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​

FieldDetails
ActorIdentities::Admin
TriggerAn admin decides to give a company credits nobody paid for

Preconditions:

  • The company has an Account and an Entitlement row for the instrument.
  • The admin has written a reason.

System Behaviour:

  1. The system writes one CreditAction row:
    • 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 grant
    • expires_at = the date the new credits die, left empty when they never expire
    • billing_entitlement_lot_id = empty, because the EntitlementLot does not exist yet
  2. In the same transaction the system carries the instruction out (LedgerEntry UC-1):
  3. The new EntitlementLot carries deferred_revenue_total_cents: 0.

Business Rules:

  • reason is 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 EntitlementLot forever (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_key is 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 whether CreditAction would then need to name an Org::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 — LedgerEntry UC-7 holds the open questions for the whole correction topic.

UC-2: Give a company more time to spend a batch of credits​

FieldDetails
ActorIdentities::Admin
TriggerAn admin decides a company needs longer before its credits expire

Preconditions:

  • The EntitlementLot still 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:

  1. The system writes one CreditAction row:
    • action_type: :expiry_extension
    • billing_entitlement_lot_id = the EntitlementLot getting more time
    • expires_at = the new date, stored as the last moment of that day in the company's timezone
    • units = empty, because moving a date moves no units
  2. In the same transaction the system carries the instruction out (LedgerEntry UC-7):
    • it updates the EntitlementLot's expires_at
    • it writes one adjust LedgerEntry with every delta at zero, naming this CreditAction row as its source
    • it writes one EntitlementLotAllocation row with units_allocated: 0 and recognised_revenue_cents: NULL
  3. 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_at is the only value on an EntitlementLot that an admin instruction can change.
  • An EntitlementLot the nightly cleanup job already closed is never extended. Its deferred revenue already became breakage revenue. The admin gives a goodwill_grant instead (UC-1).
  • An extension delays breakage revenue. It never creates or destroys revenue, and the adjust entry makes the change visible in the statement of account (Billing D10 — A credit action needs one admin, not two).
  • The zero-unit EntitlementLotAllocation row keeps the lot's history complete in one table. Without it, every lot-history query would have to read credit_actions as a second source (Billing D7 — Allocation rows are the complete history of a lot).

Postconditions:

  • The EntitlementLot is 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​

FieldDetails
ActorIdentities::Admin
TriggerAn audit review, or support answering "why do I have these credits?"

Preconditions:

  • The Account exists.

System Behaviour:

  1. The system lists the company's CreditAction rows 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
  2. Each row links to what it produced — the LedgerEntry and, for a grant, the EntitlementLot it 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​

  1. A CreditAction row is written only when no commercial document sits behind the credits. When an Invoice is behind them, the InvoicePosting is the source instead, and no CreditAction exists.
  2. Every row names exactly one Identities::Admin in admin_created_by, and carries a reason. Both are required.
  3. 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).
  4. Rows are only added. They are never edited and never deleted, the same rule as LedgerEntry.
  5. idempotency_key is unique. The same instruction cannot be carried out twice.
  6. The two grant types set units and leave billing_entitlement_lot_id empty. expiry_extension does the reverse. Two database CHECK constraints enforce this per type.
  7. Each grant type produces exactly one grant LedgerEntry and exactly one EntitlementLot, in the same transaction as this row.
  8. An expiry_extension produces exactly one adjust LedgerEntry, which moves no units and no revenue.
  9. Every EntitlementLot a CreditAction creates carries exactly SGD 0 of deferred revenue.
  10. An expiry_extension always moves expires_at later, never earlier.
  11. Every row names exactly one instrument through billing_entitlement_id. One instruction never spans two instruments.
  12. For a grant, this row is the source of both the LedgerEntry and the EntitlementLot. 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.
  13. Opening balances are not recorded here (Billing D9 — The gig flip's opening balances are not credit actions).

Model Interactions​

Related ModelRelationshipInteraction
AccountAction belongs to AccountEvery instruction names the company it affects. The account's balance row lock serialises the ledger write it causes.
EntitlementAction scoped to one instrumentAn instruction names the instrument it touches. It never spans two.
Identities::AdminOne reference per rowadmin_created_by is the one admin who instructed it. There is no approver reference.
LedgerEntryAction causes exactly one entryA grant causes a grant entry. An expiry_extension causes a zero-movement adjust entry. Both name this row as their source.
EntitlementLotA grant creates one; an extension names oneA grant creates a lot that points back here. An expiry_extension points at a lot that already exists and moves its expires_at.
EntitlementLotAllocationThrough the entryThe 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).
CreditExpiryNoticeAn extension resets the warningsAfter 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).
InvoicePostingThe other way into the ledgerWhen 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.
OutletBudgetNo direct relationship todayA 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​

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