SaaSClerk Blog · How the billing ledger is built
When will you charge me next? A billing page owes that answer.
Short answer: the subscription period has to be stored as data, not inferred at render time. The payment provider sends period start, period end and a cancellation timestamp as optional fields. Store whatever arrived, show nothing when nothing arrived, and keep "the day coverage ends" separate from "the day the card is charged".
Every subscription interface eventually gets the same two questions: when is the next charge, and what happens if I cancel right now. A page that only shows a plan name and a status word answers neither. It also hides the case that matters most: the customer who believes a date that was never real.
The fields are optional, and the webhook knows it
Webhook payloads for subscription events carry the period, but not as required fields. A cancellation event might carry the end of the period and no charge date. A plan change might carry a new period and nothing else. Code that assumes all four fields exist will throw on the first partial event; code that fills the gaps with "today" or "same day next month" will silently print a date the provider never promised.
A guessed renewal date is worse than an empty row. An empty row produces the question "when am I charged?". A made-up date produces quiet confidence, and the person most likely to trust it is the one who wanted to cancel before it renewed.
Rule one: parse without guessing
Keep the parser pure and let it return null for anything it cannot read. A date-shaped string is not automatically a date, and a period token with punctuation in it is not a period.
export function parseBillingPeriod(source) {
const token = typeof source?.billingPeriod === 'string'
? source.billingPeriod.trim().toLowerCase()
: ''
return {
billingPeriod: /^[a-z]{3,20}$/.test(token) ? token : null,
currentPeriodStart: parseInstant(source?.currentPeriodStart),
currentPeriodEnd: parseInstant(source?.currentPeriodEnd),
canceledAt: parseInstant(source?.canceledAt),
}
}The regex is a reject-list, not an allow-list. If the provider adds a new period word tomorrow, the value should still be stored. The function only refuses values that cannot be a period at all.
Rule two: write without erasing
A later webhook often carries fewer fields than an earlier one. If the update writes every column, the period that arrived yesterday is replaced by null. Build the update object from the fields that actually arrived.
export function billingPeriodUpdate(source) {
const p = parseBillingPeriod(source)
const out = {}
if (p.billingPeriod) out.billingPeriod = p.billingPeriod
if (p.currentPeriodStart) out.currentPeriodStart = p.currentPeriodStart
if (p.currentPeriodEnd) out.currentPeriodEnd = p.currentPeriodEnd
if (p.canceledAt) out.canceledAt = p.canceledAt
return out
}This function never produces null. That is the whole point: null means "nothing to change", and a separate explicit path exists for the rare case where a value really should be cleared.
Rule three: canceling is not a charge date
After a cancellation is scheduled, the period end is the last day of coverage, not the next charge. Two different questions, two different fields on the screen. Printing the end-of-term date under "next charge" tells a customer the opposite of what just happened.
const WILL_CHARGE = new Set(['active', 'past_due'])
export function nextChargeAt(status, periodEnd) {
if (!periodEnd) return null
return WILL_CHARGE.has(String(status).toLowerCase()) ? periodEnd : null
}
export function activeUntil(status, periodEnd) {
if (!periodEnd) return null
return String(status).toLowerCase() === 'canceling' ? periodEnd : null
}Make it fail in tests, not in the invoice
Because the parsing is a pure function, every failure can be reproduced locally without a payment provider, a webhook tunnel or a test card. Each case gets a counter-case that must produce the other answer, otherwise the test only proves the function returns something:
- an empty payload returns all nulls, not today's date;
- a payload with one valid period token still accepts that token;
- a garbage date produces an empty update object, not a null column;
- a canceling subscription returns an end date and no charge date, and the reverse for an active one.
What the page shows when it knows nothing
A workspace that has never paid has no period row at all. That is a normal state, not an error. The billing page renders a sentence saying there is no charge scheduled, and the next-charge field stays empty. The same rule covers a paid workspace whose provider has not yet sent a period: the status is shown, the date is not.
Cancellation follows the same discipline. The button calls the provider's merchant API and mirrors what the API returns; the local plan is not downgraded early, because a customer who paid through the end of the month keeps the features through the end of the month. If the provider rejects the request, nothing local changes and the customer is told to try again instead of seeing a success that did not happen.
Why this matters beyond one page
The same three rules apply to any system that receives dates from someone else: parse without guessing, write without erasing, and keep related-but-different dates in different fields. Applied to a spend audit, that is the difference between a renewal calendar and a decoration.
Related reading
Run the arithmetic first
Before changing how you track anything, price the waste you already have: the unused-seat calculator takes seat counts and prices and returns the monthly and yearly figure, with no account required.