Billing::CreditExpiryNotice
Purpose
CreditExpiryNotice is the send log of the credit expiry warning emails. One row is one warning email actually sent, about one EntitlementLot.
The model serves three user stories:
- As an employer, I want a warning before my credits expire, so I can spend them instead of losing them.
- As business, I want expiry to push companies to use their credits, so expiry creates spending and not refund fights.
- As support, I want to read exactly what a company was told, so I can answer a complaint with facts.
The table is a log, not a schedule. Nothing about future emails is stored anywhere:
| The nightly warning job asks | The answer comes from |
|---|---|
| Which lots need a warning tonight? | the EntitlementLot rows, computed fresh each night |
| Was this warning already sent? | this table |
A stored plan of future emails becomes wrong the moment a lot is spent, emptied, or given a new date. A log of emails that already went out cannot become wrong. This split was decided in Billing D3 — How credit expiry runs day to day.
Schema truth: jodapp-api/docs/db/billing.dbml, table credit_expiry_notices.
Model Context
Legend: Lavender nodes belong to the Billing domain. Grey nodes are external. The yellow node is the subject of this spec. Solid arrows are the send and the values it copies; dotted arrows are reads and indirect effects.
| Context | Details |
|---|---|
| Aggregate | CreditExpiryNotice has no children. Each row belongs to the EntitlementLot it warns about, inside the Account aggregate. |
| Layer | Entitlement engine — the warning side of credit expiry |
| Upstream dependencies | The EntitlementLot being warned about, and the Org::Membership rows whose addresses the email goes to |
| Downstream dependents | None. The nightly warning job reads it to block repeat sends, and support reads it in reviews. No other table depends on it. |
The four warning stages
A stage is one of the four warning emails a dying EntitlementLot can trigger. Each stage is named by its days_before value.
Each night, the number of days left until the EntitlementLot's expires_at falls into at most one window:
| Days left tonight | Stage that sends |
|---|---|
| 8–30 | days_before: 30 |
| 4–7 | days_before: 7 |
| 2–3 | days_before: 3 |
| 0–1 | days_before: 1 |
More than 30 days left means no window, and no email. The stage list lives in application config, so adding a stage later needs no migration.
Every warning email states three things:
- the current
units_availableof theEntitlementLot - the exact expiry date
- that unused credits are not refunded
Gig lots never trigger warnings today. A gig EntitlementLot keeps expires_at empty until a legal review allows expiring refundable stored value — see Billing D2 — All stored-value credits use entitlement lots. Nothing in this table is placement-only; the day a gig lot gets a date, the same four warning emails apply.
What one row records
| Column | Meaning |
|---|---|
billing_entitlement_lot_id | The EntitlementLot the email warned about. |
days_before | Which of the four warning emails was sent: 30, 7, 3, or 1. It names the email, not the real days left. |
expires_at | The expiry date the email warned about, copied from the EntitlementLot at send time. |
units_available | The credit count stated in the email, copied at send time. |
recipient_emails | The addresses the email actually went to, copied at send time. |
sent_at | When the email was sent. |
Three of the columns are copies made at send time, and they never change after:
expires_at,units_available, andrecipient_emailsrecord what the customer was told, not what is true now.- The
EntitlementLot's live values move on. When support answers "why did my credits disappear?", the copies are the evidence of what we said and when. - Recipients today are all
hq_managermembers of the company. That policy is temporary, and the copy is what keeps old rows explainable after it changes (Billing D3 — How credit expiry runs day to day).
The real days left at send time can be smaller than days_before:
- A short-lived
EntitlementLotenters at whichever window matches its first night. - A job catching up after downtime sends the current window's warning email, whatever the date is.
- When the real number matters, derive it from
expires_atminussent_at.
A warning email counts as sent only for the current date
The nightly warning job treats one of the four warning emails as already sent when one row matches three things at once:
- the
EntitlementLot - the email's
days_beforevalue:30,7,3, or1 - the
EntitlementLot's currentexpires_at
The unique index on those three columns is also the duplicate guard: one send per lot, per warning email, per warned date.
Matching on the warned date is what makes an extension work with no extra code. When an Identities::Admin extends an EntitlementLot through a CreditAction of type expiry_extension:
- the
EntitlementLot'sexpires_atchanges - every old row now names a date the
EntitlementLotno longer has, so no row matches - every warning email counts as unsent, and sends again as its window arrives for the new date
There is no cancel logic. Old rows are never edited and never deleted — they stay as the history of what was sent for the old date.
State Machine
A CreditExpiryNotice row has no states. There is no status column. The row is written when the email is sent, in the same transaction that records the send, and it never changes after that.
Worked example: the warnings Acme received
The same Acme Foods story as the other entitlement specs, seen from the warning emails. The two placement lots from the EntitlementLot worked example:
- Lot P-1 (paid): 100 credits,
expires_at: end of 1 February 2028. - Lot P-2 (trial): 20 credits,
expires_at: end of 1 March 2027.
The nightly warning job wrote five rows across the year:
| Row | Lot | days_before | expires_at warned about | units_available | Sent on |
|---|---|---|---|---|---|
| 1 | P-2 | 30 | end of 1 March 2027 | 20 | 2 February 2027 |
| 2 | P-1 | 30 | end of 1 February 2028 | 85 | 2 January 2028 |
| 3 | P-1 | 7 | end of 1 February 2028 | 85 | 25 January 2028 |
| 4 | P-1 | 3 | end of 1 February 2028 | 85 | 29 January 2028 |
| 5 | P-1 | 1 | end of 1 February 2028 | 85 | 31 January 2028 |
Every row copied the same recipient_emails: ["hq@acme-foods.sg"], the address of Acme's one hq_manager member.
What the five rows show:
- Row 1 is the 30-day warning email sent with only 27 real days left. P-2 lived 28 days in total, so its first night already fell inside the 8–30 window.
days_beforenames the email, not the real days. - P-2 never received the 7, 3, or 1-day emails, because from 16 February it had nothing left to lose:
- the Careers post consumed 5 credits on 10 February
- the ad campaign reserved the other 15 on 15 February, leaving
units_available: 0 - reserved units never expire, so the reserved 15 were safe — see Billing D2 — All stored-value credits use entitlement lots
- P-1's four rows all state 85 credits. Nothing touched P-1 between the campaign consume and its expiry, so the stated count never moved.
- The night of 1 February 2028 sent nothing: days left was 0, the window was 0–1, and row 5 already matched. The next night, the nightly cleanup job expired P-1 — entry 6 of the
LedgerEntrystatement of account.
A path Acme did not take
If an Identities::Admin had extended P-1 to the end of 1 May 2028 during January, the log would grow, not change:
- rows 2 to 5 stop matching, because P-1's
expires_atis now a date they do not name - every warning email counts as unsent for the new date
- around 2 April 2028 the 30-day email sends again, and the later emails follow as their windows arrive
- the old rows stay in the log as what was sent for the old date
The instruction side of this same path is the CreditAction spec's worked example of an extension Acme never made.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Warn a company before a batch of credits expires | The nightly warning job runs | System |
| UC-2 | Send every warning email again after a lot gets more time | An admin extends an EntitlementLot through a CreditAction | System |
| UC-3 | View the warnings a company received | Support answers a complaint, or an audit reviews expiry | Admin |
UC-1: Warn a company before a batch of credits expires
| Field | Details |
|---|---|
| Actor | System (the nightly warning job) |
| Trigger | The job runs shortly after midnight in the company's timezone |
Preconditions:
- None. The job runs every night for every company.
System Behaviour:
- The job selects every
EntitlementLotthat can still lose credits:expires_atis set and has not passedunits_availableis above zero
- For each selected
EntitlementLotit counts the days left untilexpires_at, in the company's timezone. - The days left fall into at most one window — the stage table above. No window means no email tonight.
- The job checks this table for a row matching the
EntitlementLot, the window'sdays_beforevalue, and the currentexpires_at. A match means that email was sent, and the job moves on. - With no match, the job sends the window's warning email to every
hq_managermember of the company, and inserts one row in the same transaction that records the send:days_before= the window's valueexpires_at= copied from theEntitlementLotunits_available= copied from theEntitlementLotrecipient_emails= the addresses usedsent_at= now
Business Rules:
- At most one warning per
EntitlementLotper night. The windows do not overlap. - A short-lived
EntitlementLotenters at whichever window matches its first night. There are no special cases. - After downtime, the job sends only the current window's warning email. An email whose window passed during the downtime stays unsent, and the log shows what really happened.
- An
EntitlementLotwhose remaining units are all reserved is skipped, because nothing on it can be lost:- Reserved units never expire (Billing D2 — All stored-value credits use entitlement lots).
- Reserved credits end in a consume or a normal release. A delivering campaign cannot be cancelled and always consumes what it reserved, and a release into an expired lot expires immediately (Billing D12 — A running service cannot be cancelled, and released credits get no extra time).
- The email states the current unit count, the exact expiry date, and that unused credits are not refunded.
- Safe to run twice in one night: the second run finds every row already in place and sends nothing.
- The nightly warning job and the nightly cleanup job are separate jobs. A mail outage must not delay breakage, and a cleanup bug must not stop warnings (Billing D3 — How credit expiry runs day to day).
Postconditions:
- Every company with credits at risk inside a window has one logged warning email for that window and date.
Open Questions:
- Delivery failure handling is not specified here. The intent is to reuse the delivery flow the
Invoicespec defines, so failure and bounce handling is decided once for all billing email:
UC-2: Send every warning email again after a lot gets more time
| Field | Details |
|---|---|
| Actor | System |
| Trigger | An Identities::Admin extends an EntitlementLot — CreditAction UC-2 |
Preconditions:
- The
EntitlementLot'sexpires_atmoved to a later date.
System Behaviour:
- At extension time, nothing in this table changes. No row is edited, cancelled, or deleted.
- From the next nightly run, no row matches the
EntitlementLot's newexpires_at, so UC-1 treats every warning email as unsent. - Each email sends again as its window arrives for the new date.
Business Rules:
- The restart needs no code in this table. It is a consequence of UC-1 matching on the warned date.
- The old rows stay in the log as the history of what was sent for the old date.
Postconditions:
- The company receives the full warning sequence again, timed to the new date.
UC-3: View the warnings a company received
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Support answers a complaint, or an audit reviews expiry handling |
Preconditions:
- The
Accountexists.
System Behaviour:
- The system lists the rows for the company's lots in
sent_atorder, showing:- the
EntitlementLot, with a link to it - which of the four warning emails it was (
days_before), and the expiry date warned about - the credit count stated in the email
- the addresses the email went to
- the
- Beside an expired
EntitlementLot, this list answers the complaint in one view: what we told the customer, how many times, and how early.
Business Rules:
- The stated values may differ from the
EntitlementLot's live counters, and that is the point. The row shows what the customer was told at that moment. - The list is read-only. There is no edit screen and no delete screen.
Postconditions:
- Read-only operation — no data changes.
Invariants
- One row per
EntitlementLot, perdays_beforevalue, per warnedexpires_at. The database unique index on those three columns enforces it. - A row exists only for an email that was sent. The row is inserted in the same transaction that records the send.
- Rows are never edited and never deleted. There is no cancel logic.
expires_at,units_available, andrecipient_emailsare copied at send time and never change after.- A warning email counts as sent only while its row matches the
EntitlementLot's currentexpires_at. - Each night, an
EntitlementLot's days left fall into at most one window, so at most one warning email can send per lot per night. - Only an
EntitlementLotwith anexpires_atandunits_availableabove zero can trigger a warning. days_beforenames which of the four warning emails was sent, never the real days left. The real days left derive fromexpires_atminussent_at.- Sending warnings and expiring lots are separate jobs (Billing D3 — How credit expiry runs day to day).
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
EntitlementLot | Each row warns about one | The EntitlementLot supplies the date and the unit count at send time. The nightly warning job finds dying lots through the lot table's expires_at index. |
CreditAction | An extension restarts the warnings | A CreditAction of type expiry_extension moves the EntitlementLot's expires_at. Old rows stop matching, and every warning email sends again for the new date (UC-2). |
Org::Membership | Recipients of the email | Today every hq_manager member of the company receives the warning. The addresses used are copied onto the row, so history stays explainable after the recipient policy changes. |
Identities::Admin | Reads the log | Support and audit reviews read what the customer was told (UC-3). No admin writes to this table. |
LedgerEntry | No relationship | A warning moves no units and no money. Nothing reaches the ledger or the Xero export. |