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:

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.