Skip to main content

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

ContextDetails
AggregateCreditExpiryNotice has no children. Each row belongs to the EntitlementLot it warns about, inside the Account aggregate.
LayerEntitlement engine — the warning side of credit expiry
Upstream dependenciesThe EntitlementLot being warned about, and the Org::Membership rows whose addresses the email goes to
Downstream dependentsNone. 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 tonightStage that sends
8–30days_before: 30
4–7days_before: 7
2–3days_before: 3
0–1days_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_available of the EntitlementLot
  • the exact expiry date
  • that unused credits are not refunded
info

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​

ColumnMeaning
billing_entitlement_lot_idThe EntitlementLot the email warned about.
days_beforeWhich of the four warning emails was sent: 30, 7, 3, or 1. It names the email, not the real days left.
expires_atThe expiry date the email warned about, copied from the EntitlementLot at send time.
units_availableThe credit count stated in the email, copied at send time.
recipient_emailsThe addresses the email actually went to, copied at send time.
sent_atWhen the email was sent.

Three of the columns are copies made at send time, and they never change after:

  • expires_at, units_available, and recipient_emails record 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_manager members 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 EntitlementLot enters 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_at minus sent_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_before value: 30, 7, 3, or 1
  • the EntitlementLot's current expires_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's expires_at changes
  • every old row now names a date the EntitlementLot no 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:

RowLotdays_beforeexpires_at warned aboutunits_availableSent on
1P-230end of 1 March 2027202 February 2027
2P-130end of 1 February 2028852 January 2028
3P-17end of 1 February 20288525 January 2028
4P-13end of 1 February 20288529 January 2028
5P-11end of 1 February 20288531 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_before names 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:
  • 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 LedgerEntry statement 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_at is 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​

IDUse CaseTriggerActor
UC-1Warn a company before a batch of credits expiresThe nightly warning job runsSystem
UC-2Send every warning email again after a lot gets more timeAn admin extends an EntitlementLot through a CreditActionSystem
UC-3View the warnings a company receivedSupport answers a complaint, or an audit reviews expiryAdmin

UC-1: Warn a company before a batch of credits expires​

FieldDetails
ActorSystem (the nightly warning job)
TriggerThe job runs shortly after midnight in the company's timezone

Preconditions:

  • None. The job runs every night for every company.

System Behaviour:

  1. The job selects every EntitlementLot that can still lose credits:
    • expires_at is set and has not passed
    • units_available is above zero
  2. For each selected EntitlementLot it counts the days left until expires_at, in the company's timezone.
  3. The days left fall into at most one window — the stage table above. No window means no email tonight.
  4. The job checks this table for a row matching the EntitlementLot, the window's days_before value, and the current expires_at. A match means that email was sent, and the job moves on.
  5. With no match, the job sends the window's warning email to every hq_manager member of the company, and inserts one row in the same transaction that records the send:
    • days_before = the window's value
    • expires_at = copied from the EntitlementLot
    • units_available = copied from the EntitlementLot
    • recipient_emails = the addresses used
    • sent_at = now

Business Rules:

  • At most one warning per EntitlementLot per night. The windows do not overlap.
  • A short-lived EntitlementLot enters 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 EntitlementLot whose remaining units are all reserved is skipped, because nothing on it can be lost:
  • 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:


UC-2: Send every warning email again after a lot gets more time​

FieldDetails
ActorSystem
TriggerAn Identities::Admin extends an EntitlementLot — CreditAction UC-2

Preconditions:

  • The EntitlementLot's expires_at moved to a later date.

System Behaviour:

  1. At extension time, nothing in this table changes. No row is edited, cancelled, or deleted.
  2. From the next nightly run, no row matches the EntitlementLot's new expires_at, so UC-1 treats every warning email as unsent.
  3. 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​

FieldDetails
ActorIdentities::Admin
TriggerSupport answers a complaint, or an audit reviews expiry handling

Preconditions:

  • The Account exists.

System Behaviour:

  1. The system lists the rows for the company's lots in sent_at order, 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
  2. 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​

  1. One row per EntitlementLot, per days_before value, per warned expires_at. The database unique index on those three columns enforces it.
  2. A row exists only for an email that was sent. The row is inserted in the same transaction that records the send.
  3. Rows are never edited and never deleted. There is no cancel logic.
  4. expires_at, units_available, and recipient_emails are copied at send time and never change after.
  5. A warning email counts as sent only while its row matches the EntitlementLot's current expires_at.
  6. 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.
  7. Only an EntitlementLot with an expires_at and units_available above zero can trigger a warning.
  8. days_before names which of the four warning emails was sent, never the real days left. The real days left derive from expires_at minus sent_at.
  9. Sending warnings and expiring lots are separate jobs (Billing D3 — How credit expiry runs day to day).

Model Interactions​

Related ModelRelationshipInteraction
EntitlementLotEach row warns about oneThe 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.
CreditActionAn extension restarts the warningsA 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::MembershipRecipients of the emailToday 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::AdminReads the logSupport and audit reviews read what the customer was told (UC-3). No admin writes to this table.
LedgerEntryNo relationshipA warning moves no units and no money. Nothing reaches the ledger or the Xero export.