Billing domain decisions
This page records the ratified design decisions for the Billing domain. Read it before you design anything in this domain.
Each decision states what we decided, why, and where the evidence is. Decisions live only on this page. Other docs must link here instead of repeating the text. When a decision changes, update this page the same day.
Schema truth is jodapp-api/docs/db/billing.dbml. Verify columns there before you rely on this page.
D1 — Agreement ends_at is inclusive of the entered day (2026-07-15)
When an admin enters "effective to 4 July", the agreement covers all of 4 July. Invoices can still be issued on 4 July itself. Confirmed by ops (Ali, 2026-07-15) — see jodapp-api#1850.
How it is stored: the server anchors the entered day in the company's timezone, then stores the last moment of that day (parse_civil_date(...).end_of_day) in ends_at. Because the stored value already carries the inclusivity, read-side checks are plain instant comparisons — no timezone math when reading. The general anchoring mechanism lives in the datetime conventions.
Column names: renamed from effective_from / effective_to on 2026-08-04 (jodapp-api#1863).
D2 — All stored-value credits use entitlement lots; placement is not pooled (2026-08-06)
What we decided:
- Placement credits and gig credits use one lot mechanism (
billing_entitlement_lots). Every grant creates one lot. The lot carries units, the money attached to those units, and an optional expiry date. - The placement row in
billing_entitlementsbecomesallocation_policy: :lots(value renamed fromfifo_lots) andrecognition_policy: :lot_based. Thepooledandproportional_avgvalues are removed from the enums. The two policy columns stay — a future workforce instrument recognises revenue by time, not by consumed units, so it will need a new policy value, not lots. - Spend order: earliest
expires_atfirst (lots with no expiry last), then earliestpurchased_at, thenid. Gig lots have no expiry, so for gig this is plain first-in-first-out. - Credit expiry: when a lot passes
expires_at, anexpireledger entry removes the leftover available units. The lot's remaining money becomes revenue at that moment ("breakage"). We recognise breakage at the expiry date, not gradually by estimate, because we have no usage history to estimate with. Auditor confirmation is queued and must arrive before the first Xero export — see Xero Integration. - Free trial credits are lots with zero attached money and no invoice behind them. Consuming or expiring them recognises zero revenue.
- Reserved units never expire. Units released back into an expired lot expire immediately, in the same transaction.
- Gig lots keep
expires_atempty. Expiring refundable stored value is a legal question, not a schema default.
Why:
- Credit expiry is a confirmed product requirement (Ali, 2026-08-06): push employers to use their credits instead of refunding them or letting them sit idle. Expiry needs purchase batches — a single pool cannot say which credits die on which date.
- Free trial credits break pooled averaging even without expiry: zero-dollar credits inside a paid pool distort every later recognition.
- One mechanism for both instruments means one tested code path, and gig needs lots for per-purchase fee rates in the next phase anyway.
- We lose nothing that finance may still ask for: the pooled-average numbers can be recomputed exactly by replaying the ledger in per-account
idorder. The reverse is not true — pooled-only storage cannot recover lot-level answers.
Timing rule: the placement seed must be flipped before the first grant ledger entry ships. Policies cannot change once ledger history exists for an instrument. No grant entries exist in production today.
Evidence: adversarial architect review in the design session of 2026-08-06. This supersedes the pooled-placement specs in docs PR #84. The schema changes (expires_at, expire entry type, generalised lot money columns) ship with the entitlement engine work on branch billing-phase-3-placement-uses-entitlement-lots; until then billing.dbml still shows the old gig-only lot shape. Older pages in this domain that still say "placement is pooled" are being updated as part of that same work and must link here instead of repeating the rule.
D3 — Credit expiry mechanics: product validity, nightly cleanup, warning emails (2026-08-06)
D2 decided that credits expire and that breakage is recognised at the expiry date. This entry decides how expiry runs day to day.
What we decided:
- Products state how long their credits live. New column
billing_products.validity_months. NULL = never expires. At grant time the system adds the months to the purchase date in the company's timezone, then stores the end of that day as the lot'sexpires_at— the same civil-date convention as D1. We chose months over days because the customer promise is "valid 12 months". Month math lands on the same date every year. Day counts drift across leap years. Trial and goodwill lots do not use a product — the admin setsexpires_aton theCreditActiondirectly. - The spending code is the enforcement. On every reserve and consume, it refuses lots whose
expires_athas passed. The cleanup job is only bookkeeping. A late or broken job can never let dead credits be spent. - A nightly cleanup job closes expired lots. It runs shortly after midnight in the company's timezone (Singapore today). For each expired lot with leftover available units, in its own transaction: write one
expireledger entry, remove the units, recognise the lot's remaining attached money as revenue (breakage, per D2). One lot per transaction, so one bad row cannot block other companies. Safe to re-run: a closed lot has no available units left, and the entry carries an idempotency key. - Warning emails at 30, 7, 3, and 1 day(s) before expiry. Each night the sender computes the days left for every lot that still has credits and an expiry date. That number falls into exactly one window: 8–30 → the 30-day email, 4–7 → 7, 2–3 → 3, 0–1 → 1. It sends that window's email unless it was already sent. A short-lived lot simply enters at its window — no special cases. Each email states the current unit count, the exact date, and that unused credits are not refunded.
- Recipients today: all
hq_managermembers of the company. Temporary — the org membership roles are not firmed up yet. The addresses actually used are copied onto the notice row, so history stays explainable after the policy changes. - A send log records every warning:
billing_credit_expiry_notices— the lot, the stage (days_before), theexpires_atwarned about, the unit count stated, the recipient addresses, andsent_at. Unique on (lot, stage, warned date). A stage counts as sent only when a row matches the lot's currentexpires_at, so after an admin movesexpires_atlater, every stage counts as unsent and sends again for the new date. No cancel logic. - The sender and the cleanup are separate jobs. A mail outage must not delay breakage. A cleanup bug must not stop warnings.
Why:
- Employers do not log in daily. Expiry without warnings creates the refund fights the feature exists to avoid.
- With enforcement at spend time, job timing is only a bookkeeping question — so nightly is enough.
- A send log beats a send schedule: stored future plans go stale when lots are spent or extended; deriving what is due from the lot each night cannot go stale.
Evidence: design session 2026-08-06 (continuation of the D2 session). Schema: billing_products.validity_months and billing_credit_expiry_notices in jodapp-api/docs/db/billing.dbml, branch billing-phase-3-placement-uses-entitlement-lots.
D4 — Gig refunds are a full exit; outlet closures are transfers, not refunds (2026-08-06)
What we decided:
- No partial refunds. A refund pays back the company's whole remaining principal: every available gig credit, from every lot.
- Preconditions. Reserved units must be zero — scheduled shifts finish or are cancelled first, which releases their reserved credits. The refund process then deallocates all outlet budgets back to the company pool as its first step, so no credits are left labelled for an outlet.
- Ledger shape. One
refundentry for the whole exit, with one allocation row per lot. Each lot gives up all its available units. - The platform fee is not paid back. Per lot, the remaining fee money is recognised as revenue at refund time, using the standard recognition formula. Only the wage value goes back to the customer.
- The bank repayment is not a ledger amount. It is recorded by a commercial-layer refund record — the mirror of
billing_payments, with amount, bank proof, and verification. That record is thesourceof the refund ledger entry. The record itself is still to spec. - Nothing needs closing. After the refund the balance is zero and every lot is settled. There is no "refunded" account status. The company can buy credits again later.
- "The outlet closed, refund those credits" is not a refund. Outlet budgets are a label on company credits, not a separate wallet. The fix is a transfer (
billing_outlet_budget_transfers, typedeallocate) back to the company pool, or to another outlet. No ledger entry, no money movement, no revenue event. - Refunds do not touch GST. GST was charged on the fee only, and the fee is not returned. The principal never carried GST. So a refund needs no credit note and no tax reversal. Queued for auditor confirmation — see the Xero questions list.
Why:
- Business rule (Ali, 2026-08-06): partial refunds are not offered. Credits are used, or fully refunded when the company leaves.
- Full-exit refunds delete the draw-order problem. A partial refund must choose which lots give up units, and each gig lot has its own fee rate — so the choice decides which fee becomes revenue, a timing call someone must own and defend to an auditor. A full exit recognises every lot's remaining fee at once. No ordering, no gaming, no allocator code.
- Simple to tell customers: use your credits, or leave and get the wage value back. The fee is never returned.
Evidence: design session 2026-08-06. The schema already supports this with no changes: billing_entitlements.is_refundable, entry_type: :refund, units_refunded, and billing_entitlement_lot_allocations (committed d9adbcc6). Open work: the commercial refund record model, and the refund rows in the Xero mapping table. Update 2026-08-12: the hold table was removed — see D8 — no hold table: a deliverable's reserved credits are computed from the ledger. The refund rules in this entry are unchanged.
D5 — Spending mechanics: reservations name their lots, a daily amount from the campaign total, direct consume for instant delivery (2026-08-06)
What we decided:
- Reservations name their lots. Setting credits aside picks the exact lots at that moment, in the spend order (soonest expiry first, skipping expired lots). The per-lot split of a deliverable's reserved credits is derived from the allocation rows of the ledger entries naming that deliverable as source — it is not stored a second time.
- Why this matters: "reserved units never expire" (D2) must know which units are protected. A stored count alone cannot answer that.
- Every movement writes the same shape: one ledger entry, plus one allocation row per lot touched. A consume from reserved credits takes from the lots the deliverable reserved, soonest-dying first. Release returns units to the same lots they came from. Units returned to a lot that expired in the meantime expire immediately, in the same transaction (D3).
- The replay invariant: every ledger write happens while holding the account's balance row lock. Two movements can never run at the same moment for one account. This is what makes per-account id-order replay valid — the guarantee D2's "finance can still derive pooled numbers" promise stands on.
- A time-spread campaign consumes a daily amount computed from its total. For a campaign of
costcredits overdaysdays: consume today = round(cost × days elapsed ÷ days) − consumed so far. Whole numbers, the total is always exact, a re-run the same day consumes zero, and a missed day is caught up by the next run. - A normal day, run once, on time, can still consume zero. The rounding tracks the running total since the campaign started, not each day on its own. A campaign of 4 credits over 7 days consumes 1 credit on day 1, 0 on day 2, 1 on day 3, 0 on day 4, and so on — every other day looks like a "missed" day even though the job ran correctly each time. Nothing is lost: the shortfall carries forward inside the running total and is picked up automatically on whichever day the total next crosses a whole number, so day 7 always lands on exactly 4 consumed, no matter how the individual days split it.
- Instant delivery consumes directly. Careers posts and boosts deliver the moment they are paid, so they write one consume entry straight from available units. Nothing is ever reserved for them. Two documented spending patterns; each product uses the one that matches its delivery.
- Stuck campaigns are Ads's job, not billing's. A campaign that sits unfinished for 14 days (a config value) is rejected automatically by the Ads system. The rejection triggers a completely normal release. Reserved credits never expire on their own clock (D2). Careers posts reserve nothing; a gig shift has its own date and cannot be parked.
Why:
- Without the Ads timer, an employer can park expiring credits inside a reservation forever and defeat the expiry feature.
- Dividing cost by days strands fractional credits (10 ÷ 3 = 3.33) and breaks on re-runs and missed days. Computing from the total stores the goal, not the steps, so every failure mode is repaired by the same subtraction.
- Forcing Careers posts through reserve-then-consume writes two rows to tell a one-row story — and Careers is where the paying customers are today.
Evidence: design session 2026-08-06. No schema changes; two clarifying notes added to billing.dbml (reserved credits, entry_type: :consume). Follow-up work: the Ads campaign spec must state the 14-day auto-reject; the Careers direct-consume use case ships with the spec rewrite. Update 2026-08-12: the hold table was removed — see D8 — no hold table: a deliverable's reserved credits are computed from the ledger. The derivation rule in this entry is unchanged.
D6 — Opening balances: placement launches at zero; the gig flip carries the real balances (2026-08-07)
Background fact this decision rests on: there is no old placement system. jodapp is the new system. Until billing ships, business invoices job postings by hand (for example $99 per posting) and those invoices live in Xero only. The only real old system with credit balances is jodgig, and its migration is the Phase 4 gig flip.
What we decided for the placement launch:
- Everyone starts at zero credits. No migration, no opening lots.
- No history backfill of the manual careers invoices. Writing them into the ledger either attaches their money (which counts revenue twice — Xero already recognised it) or attaches $0 (which records that the postings were free — not true). Both are wrong stories. The ledger's first row for a company is the first event billing was actually responsible for. Everything earlier belongs to Xero.
- Companies with unused prepaid postings are handled manually, outside billing. They are few, and representing them as credits needs the pricing plan, which does not exist yet.
- Running free ad campaigns: pause and drain. Some days before launch, business stops accepting new free campaigns. Running ones finish naturally. Launch day has nothing in flight, so no migration code exists for it.
- New
action_type: :opening_grantonbilling_credit_actions— the balance a company carries in on the day an instrument goes live. At most one per company per instrument, ever. Rare on purpose.
What we decided for the gig flip (agreed in principle 2026-08-07; details to be walked again closer to Phase 4):
- One
opening_grantper company. Units come from the company's jodgig statement of account. Attached money is finance's stated share of the Xero "Deferred income" balance — in practice remaining credits × the company's latest fee rate, the same calculation finance already does monthly. Gig opening lots have no expiry date. - Before anything is written, two totals must be reconciled against Xero: all opening units against "Jod credits", all attached money against "Deferred income". The unexplained remainder (today's SGD 1k–5k drift) is written off by finance as one correcting journal — our per-company numbers become the truth, because each line is provable from a statement and the lump total never was. Per-company amounts are never scaled to force a match.
- Order per company: opening lot → outlet budget allocate transfers → reserve entries for scheduled shifts. Each step is a normal operation through the normal services; one transaction per company; idempotency keys so a crashed run resumes safely.
- Cut-off rule: jodgig finishes processing all deductions for worked shifts before statements are pulled. A shift worked after the flip consumes in the new system only. One shift, one system.
- The dry-run report (per company: units, money, source evidence, in-flight reservations) is signed by ops and finance before the live run, regenerated from the real rows after it, and kept. It is the audit evidence for the year-one opening balances the auditor said they will test.
Why:
- The placement launch should be a launch, not a migration. Draining short-lived campaigns by calendar costs nothing; migration tooling for them would be code we use once and keep forever.
- The gig flip is the one chance to turn an unexplainable lump difference into provable per-customer lines. Spreading the difference across customers would put fake cents into statements a customer can dispute.
Evidence: design session 2026-08-07, including the auditor's written audit requirements (see the audit schedules section in Xero Integration). Schema: opening_grant added to billing_credit_actions.action_type in billing.dbml. Update 2026-08-12: opening_grant was removed from billing_credit_actions. Everything this entry decided about opening balances still holds. Only the record that stores them is undecided again, and it is designed in Phase 4 — see D9.
D7 — Allocation rows are the complete history of a lot (2026-08-10)
What we decided:
- Every ledger entry writes one
billing_entitlement_lot_allocationsrow per lot the entry applies to — not only entries that move units:- A
grantwrites one row for the lot it creates. reserve,consume,release,expire, andrefundwrite one row per lot whose units move.- An
adjust— written when aBilling::CreditActionof typeexpiry_extensionruns — writes one row for the lot whoseexpires_atit moves, withunits_allocated = 0.
- A
allocation_typealways equals theentry_typeof the entry the row belongs to. The enum gainsgrant.- A database CHECK enforces the unit rule per type: an
adjustrow carries exactly zero units, and every other row carries at least one.CASE WHEN allocation_type = 'adjust' THEN units_allocated = 0 ELSE units_allocated > 0 END. recognised_revenue_centsis NULL ongrant,reserve,release, andadjustrows. It is set only where revenue is recognised:consume,expire, andrefund.
Why:
- A grant entry and its lot have no shared key without the allocation row. The entry's source is the
Billing::InvoicePosting. The lot's source is theBilling::InvoiceLine. An invoice with two principal lines produces two grant entries pointing at one posting, and two lots pointing at two different lines. Only the allocation rows state which entry created which lot. - One source of history. A lot's allocation rows, read in entry order, are everything that ever happened to it — the grant that created it, every spend, every
expires_atchange, and the entry that emptied it. Without the zero-unit adjust row,billing_credit_actionsbecomes a second source that every lot-history query has to union in. - One write path. Every ledger write is one entry plus its allocation rows, in one transaction. No branch per entry type, in the writer or in any reader. Every lot counter is replayable from allocation rows alone — the same way the balance projection is replayable from ledger entries (D5).
Evidence: design discussion of 2026-08-10 (allocation-spec session). Schema: billing_entitlement_lot_allocations in jodapp-api/docs/db/billing.dbml, branch billing-phase-3-placement-uses-entitlement-lots.
D8 — No hold table: a deliverable's reserved credits are computed from the ledger (2026-08-12)
What we decided:
- The
billing_entitlement_holdstable is removed from the design. No hold rows are stored. - The word hold is retired with the table. The concept's name is a deliverable's reserved credits: the credits currently set aside for one deliverable — here an ad campaign or a gig shift, the two deliverables that reserve. They are the net of the
Billing::LedgerEntryrows that name that deliverable as their source: reserves add, consumes and releases subtract. The name matches what the reader can grep in the schema:entry_type: :reserve,reserved_delta,units_reserved. - The per-lot split of a deliverable's reserved credits derives from those entries'
billing_entitlement_lot_allocationsrows, netted per lot. This is D5's rule with new words — the mechanics are unchanged. - A deliverable that still has reserved credits cannot reserve again. This is an application rule inside the reserve flow. The check runs under the account's balance row lock (D5), so two racing reserves cannot both pass. There is no database constraint for it.
- Account-level and batch-level reserved counts stay cached and instant:
billing_entitlement_balances.units_reserved— per account and instrumentbilling_entitlement_lots.units_reserved— per purchase batch
Why:
- The table was a cache from the start. Its original note (phase 1, jodapp-api#1378, 2026-03-31) says: "For convenience for reserved credits ... cache like billing_entitlement_balances." It was designed while placement was pooled — a placement reservation had no lots behind it then, so a summary row was the only handle.
- D2, D5, and D7 removed the need. Every reservation now writes allocation rows, and a deliverable's history is fully readable from the ledger. Every question the hold row answered is now a small indexed query.
- The row was not free. Its held count would change on every nightly consume, could drift, and would need its own repair job.
- The database uniqueness guard it carried was narrow. Races are already impossible — every ledger write holds the balance row lock. The guard only caught the normal reserve flow running twice. Even then the books self-heal: the release at campaign end returns everything still held, and no revenue number is ever touched.
- The daily campaign consume job never needed the row. Its formula needs "consumed so far", which is a sum over the campaign's consume entries — the hold row never stored that, so the job queries the ledger either way.
- Dropping is reversible. A hold row is a pure summary of ledger entries. If ops screens ever need one, a cache table can be added later and backfilled completely from history.
Evidence: design discussion of 2026-08-12 (hold-spec session). Schema: billing_entitlement_holds removed from jodapp-api/docs/db/billing.dbml, branch billing-phase-3-placement-uses-entitlement-lots.
D9 — The gig flip's opening balances are not credit actions (2026-08-12)
What we decided:
opening_grantis removed frombilling_credit_actions.action_type. Three action types remain:trial_grant,goodwill_grant, andexpiry_extension.- The record that carries a company's opening balance into billing is designed in Phase 4, together with the rest of the gig flip. Until then the schema says nothing about opening balances.
billing_credit_actionskeeps one job: the way anIdentities::Adminwrites a ledger entry when no commercial document exists.- Everything D6 decided about opening balances still holds. Only the place the record lives is undecided again.
Why:
opening_grantfailed the table's own test. The table exists for one authorised human decision, about one company, with no document behind it. An opening balance is none of those three. Nobody decides that a company has 4,312 credits — that number is read off a jodgig statement of account. The human act is approving a reconciliation report, not choosing an amount. And there is a document: D6 calls the signed dry-run report the audit evidence for the year-one opening balances.- It needed four things nothing else on the table needs. A money column, because an opening lot carries real deferred revenue while trial and goodwill lots carry SGD 0. A second meaning for
reason, which everywhere else is why the admin acted and here is which statement the number came from. A uniqueness rule of one per company per instrument. And a different approval shape — ops and finance signing one report for all companies, not one admin acting for one company. - D6's real controls cannot live on a per-company row. Reconciling total units against the Xero "Jod credits" balance, reconciling total deferred revenue against "Deferred income", writing off the unexplained remainder, and the ops and finance sign-off are all properties of one migration run.
billing_credit_actionshas no concept of a run, so the controls D6 cares most about had nowhere to live. Adding a money column would have hidden that, not fixed it. - Removing it costs nothing today. Placement launches at zero (D6), so no
opening_grantrow exists and none will before Phase 4. The enum value was dead in the schema. - Removing it is reversible. If the Phase 4 walkthrough concludes that a credit action row is the right home after all, the value and its column come back during a phase that is writing migrations anyway.
- Keeping it had a real cost. A nullable money column, a column note that taught the opposite of D6, a three-shape CHECK, and a stated purpose that was false — all carried for months to serve zero rows. Worse, Phase 4 would likely have designed around the shape because the shape was already there.
Evidence: design discussion of 2026-08-12 (credit action spec session). Schema: opening_grant removed from billing_credit_actions.action_type in jodapp-api/docs/db/billing.dbml, branch billing-phase-3-placement-uses-entitlement-lots. Follow-up work: the Phase 4 gig flip needs records for the migration run and the per-company opening balance, including the two Xero reconciliation totals and the ops and finance sign-off.
D10 — A credit action needs one admin, not two (2026-08-12)
What we decided:
- The
admin_approved_bycolumn is removed frombilling_credit_actions. There is no second-admin approval before a credit action is carried out. admin_created_byandreasonstay required. They are the record of who acted and why.- The control on manual credits is the audit review after the fact, not a gate before the write. The
(billing_account_id, created_at)index exists for exactly that review.
Why:
- The approval was never designed. The column carried the note "the approval policy itself lives in the application, not the schema". No policy was ever written. There is no status column and no approved-at time, so the schema could not tell a decision waiting for approval from one already carried out. A control nobody specified is not a control.
- The row cannot exist before approval anyway. A
billing_credit_actionsrow is written in the same transaction that writes its ledger entry. So the column could only ever record an approval that happened outside billing, before the write. Whether a company needs two people to agree before sales gives away credits is a question for the admin tool, not for this table. - The money at risk is small and it misstates nothing. A
trial_grantand agoodwill_grantboth create a lot with SGD 0 attached. Consuming or expiring those credits recognises no revenue. A wrong free grant costs us the service delivered, not a wrong number in the books. That is a very different risk from a wrong invoice. - The one revenue effect is a timing effect, and it is visible. An
expiry_extensiondelays breakage revenue by moving the date out. It never creates or destroys revenue. Every extension writes anadjustledger entry, so it appears in the statement of account and in the audit review list. - Sales uses
trial_grantoften. It is the type sales reaches for when approaching a potential customer. A second-approver gate on the most frequent action would be worked around, not followed.
Evidence: design discussion of 2026-08-12 (credit action spec session). Schema: admin_approved_by removed from billing_credit_actions in jodapp-api/docs/db/billing.dbml, branch billing-phase-3-placement-uses-entitlement-lots.
D11 — A row on an invoice is a line, not an item (2026-06-12)
What we decided:
- The model is
Billing::InvoiceLineand its table isbilling_invoice_lines. - The name
Billing::InvoiceItemand the tablebilling_invoice_itemsare not used anywhere.
Why:
- "Line" is the industry word for a row on a commercial document. Xero, Stripe and QuickBooks all call it a line. Our engineers read those APIs and their docs every week.
- "Item" sounds like a record that stands on its own. A row on an invoice never does. It exists only as part of its document, and the name should say that.
- Matching Xero's vocabulary removes a translation step. Every time someone maps our data to the accounting system, one fewer word has to be converted in their head.
Evidence: Phase 2 model spec work (Imam Septian, 2026-06-12). The rename shipped in jodapp-api on 2026-06-19, and both the schema and the code use billing_invoice_lines today.
D12 — A running service cannot be cancelled, and released credits get no extra time (2026-08-14)
What we decided:
- A service with a time duration cannot be cancelled once it is delivering. An ad campaign, a job boost, and a Careers job post all run to their end. The credits a delivering campaign reserved always end in a consume.
- A campaign that has not started delivering can still end without consuming. It is rejected by an admin, cancelled while still pending, or auto-rejected by Ads after 14 days (D5 — spending mechanics and the 14-day auto-reject). Each of these ends in a completely normal release.
- A release into an expired lot follows the normal rule, with no softening. The released units expire immediately, in the same transaction (D2 — all stored-value credits use entitlement lots). There is no waiting period for released units, and no automatic extension.
- The rare unfair case is handled by an admin, not by a mechanism.
- Before rejecting: an admin extends the lot through a
Billing::CreditActionof typeexpiry_extension. The nightly cleanup job only closes lots that still have available units, so it can never close a fully reserved lot. The window to extend stays open for as long as the campaign is pending. - After the release: an admin gives a
goodwill_grant— the same remedy as for any lot the cleanup job already closed (D3 — how credit expiry runs day to day).
- Before rejecting: an admin extends the lot through a
- The Ads rejection screen states the consequence. When rejecting a campaign would release units into an expired lot, the screen says so. The admin can extend the lot first, or plan the goodwill grant. This rule ships with the Ads campaign spec.
Why:
- Releasing into a live balance would undo D5's protection. If released units came back spendable after the expiry date, an employer could park expiring credits inside a reservation, cancel it, and reserve again. Dead credits would stay usable forever, and the expiry feature would be defeated by its own reserve flow.
- A waiting period for released units needs a second clock. The lot stores counters, and its states are derived from them. Released units with their own private expiry date would be a new state, and a new job to close them.
- An automatic extension fails the credit action table's own test.
billing_credit_actionsrecords one authorised human decision with a stated reason (D10 — a credit action needs one admin, not two). A system-written extension has no admin and no reason. - The money at risk is small, and the books stay exact. The collision needs a lot to expire inside a pending window that the 14-day auto-reject bounds, and then a rejection. The shortest-lived lots are trial lots, whose credits carry SGD 0 — making the customer whole costs nothing. Every step writes ordinary ledger entries, and breakage keeps its expiry date.
Evidence: design discussion of 2026-08-14 (credit expiry notice spec review). No schema change. Follow-up work: the Ads campaign spec states the no-cancellation rule, the 14-day auto-reject, and the rejection-screen message.
D13 — No Phase-3 grant backfill: Phase 1-3 billing data is reset, not caught up (2026-09-15)
What we decided:
- The Phase-3 grant backfill is dropped. This use case granted entitlements once for any
Billing::InvoicePostingrow created before Phase 3's grant logic existed. Billing has not shipped to production yet, so every such posting today came from manual testing in development or QA, not from a real paying customer. jodapp-apishipsbin/rails billing:reset_phase_1_3_test_datainstead. It clears every Phase 1-3 billing table that only holds test data — legal entities, products, product prices, invoices, invoice lines, payments, invoice postings, ledger entries, credit actions, entitlement lots, entitlement lot allocations, credit expiry notices — and resetsBilling::EntitlementBalanceto zero, so an engineer can start a clean test or demo pass.Billing::Account,Billing::BillToProfile,Billing::Agreement,Billing::AgreementTerm, andBilling::Entitlementare untouched by the reset. A company's billing setup is not test data.- This holds only while nothing is live in production. The day a real paying customer's invoice posts, a reset task must never run against that data, and whether a genuine backfill is still needed becomes a real question again — the same distinction D6 already draws for the placement launch.
Why:
- A backfill task exists to make an already-created real row correct after the fact. Every row it would have touched here came from a manual test, not a real transaction — so removing the row is simpler and safer than repairing it.
- Keeping a backfill task around for data that is never real invites someone to run it later against a database that does hold real rows, without the review this decision had.
Evidence: engineering session 2026-09-14/15. jodapp-api PR #2030, issue #2022. Replaces lib/tasks/billing/backfill_phase_3_grants.rake with lib/tasks/billing/reset_phase_1_3_test_data.rake.