ADR-037: Trial-end and activation period anchoring
Date: 2026-05-18
Status: Accepted (amended 2026-05-18 — invoice-shape verification for in_advance + metered usage; hard-pause API removed in PR-8 with corrected industry framing)
Summary
Section titled “Summary”Subscription period math now goes through two shared helpers, firstPeriodAfterTrial and firstPeriodForActivate. Create, Activate, EndTrial and ExtendTrial all compute the first billing period with them. Before this, each entry point did its own period math, and the differences caused unbilled days and wrong first cycles. Monthly calendar billing starts a short first period at trial end or activation that runs to the next month start. Monthly anniversary billing runs one full month from that point. Yearly plans always use anniversary billing, whatever billing_time says. All items on a subscription must share one billing interval. An early EndTrial cuts the trial short to now and re-anchors the period in one atomic update, and ExtendTrial re-anchors on the new trial end. The expired-trial jobs switch a trial to active when trial_end_at passes, without waiting for the next cycle close. In the same step they bill the in_advance first paid period. Amended 2026-05-18, twice. First, an in_advance plan with metered usage gets one combined invoice at each cycle close: the next period’s base fee in advance plus the elapsed period’s usage in arrears. Second, the pause and resume API and the paused status were removed.
Context
Section titled “Context”A 2026-05-18 deep audit of every time-aware subscription flow surfaced a
related cluster of period-anchoring bugs that share one root cause: the
service layer’s trial-end and activation paths each had their own
inline period math, and none of them honored the full
(billing_time, plan.billing_interval) matrix correctly. The
operator-visible symptoms covered the full lifecycle:
| # | Bug | Effect (calendar billing, sub created Nov 29, 14d trial) |
|---|---|---|
| 1 | Create + trial + calendar dropped the stub period |
trial_end=Dec 13, but current_period_start=Jan 1 — 19 unbilled days (revenue leak) |
| 2 | EndTrial-early didn’t reset period boundaries |
Operator EndTrial Dec 5 → status flips to active but next charge still fires Jan 1 (8 unbilled days Dec 5 → Dec 13) |
| 6 | in_advance + trial first paid period was never billed |
engine auto-flip at cycle close bills in_advance items for the NEXT period; the trial-end period went unbilled (revenue leak specific to in_advance) |
| 8 | status='trialing' hung past trial_end_at |
Status hung at trialing for the gap between actual trial-end and the first chargeable cycle close — up to ~30 days for calendar billing |
| 10 | Yearly + trial first cycle was 1 month, not 1 year | Customer paid 1/12 of yearly fee for an off-cycle first invoice, then full year invoices thereafter — 13 months of service for 13/12 of yearly fee |
| 11 | Activate (draft → active) backdated period_start to month-start, ignored billing_time |
Sub activated Nov 29 was billed for the full Nov cycle including days it was a draft; anniversary draft activated mid-month still got calendar periods |
Each bug looked self-contained but they all sat on the same defect: the period-anchoring helpers were duplicated inline in each entry point with subtle differences. Fix scope crossed Create / Activate / EndTrial / ExtendTrial + the engine’s auto-flip path + the catchup orchestrator + the wall-clock cron tick.
Industry verification (the period-anchoring shapes were verified across two reference platforms before settling on the contract):
-
Stripe — Subscription billing-cycle: “Subscriptions with trials use the
trial_endtimestamp as the billing cycle anchor.” First chargeable cycle starts at trial-end, prorated through the next billing-cycle anchor (e.g. month start for calendar). Trial-end transitions emitcustomer.subscription.trial_will_endat trial-end minus 3 days andcustomer.subscription.updatedwithstatus='active'at trial-end exactly. -
Lago — Billing time: “For calendar billing-time, the first chargeable period anchors at the start of the next calendar period.” Trial-end equivalent (
ending_at) flips status without waiting for cycle close. -
Both platforms force anniversary semantics for yearly billing — neither ships a “calendar yearly” model where a stub bridges to a fixed annual anchor.
Decision
Section titled “Decision”Centralize period-anchor computation in two helpers, gated on
(billing_time, plan.billing_interval), and route every entry point
through them. Pair the anchor computation with state-machine
guarantees: trial transitions are atomic and stamp their period at
the same instant the status flips.
Helpers (in internal/subscription/service.go):
-
firstPeriodAfterTrial(trialEnd, billingTime, interval, loc) → (ps, pe)- Yearly:
ps = trialEnd (day-snapped), pe = ps + 1 year.billingTimeis ignored — Stripe and Lago both force anniversary on yearly. - Monthly + calendar:
ps = trialEnd (day-snapped), pe = next-month-start. Edge case:ps == pe(trial_end already on a calendar boundary) collapses the stub and promotespeto the following month. - Monthly + anniversary:
ps = trialEnd, pe = ps + 1 month.
- Yearly:
-
firstPeriodForActivate(at, billingTime, interval, loc) → (ps, pe)- Same matrix as above but anchored on the activation instant
atinstead oftrialEnd.
- Same matrix as above but anchored on the activation instant
interval is resolved per-sub from items[0].plan.BillingInterval
via a new PlanReader interface on subscription.Service (wired in
router.go to the pricing store). Mixed-interval items on a single
sub are rejected at Create and AddItem (Stripe / Lago / Chargebee
all reject this).
State-machine guarantees:
Service.Create— the trial branch and the start-now branch use the helpers (since 2026-10-23 a create starts now unless it asks for a draft;start_nowis gone).Service.Activate(draft → active) — usesfirstPeriodForActivate. Pre-fix hardcodedbeginningOfMonth(now)forperiod_start, backdating to first-of-month and ignoringbilling_time.Service.EndTrial— early end resets period: new store atomicEndTrialEarly(at, ps, pe, next_billing)truncatestrial_end_at=at, flips status, and re-anchors the period in one UPDATE.Service.ExtendTrial— recomputes anchor onnew_trial_end. Store signature expanded:ExtendTrial(at, ps, pe, next_billing).Service.ProcessExpiredTrialsForClock(catchup Phase 0.5) andService.ProcessExpiredTrials(wall-clock cron phase 0.9) — both callActivateAfterTrial(at=trial_end_at)so the status flips at the actual trial-end instant, not at the deferred cycle close. Both also fireBillOnCreateto cover the in_advance first paid period and dispatchsubscription.trial_endedto match the engine auto-flip path’s webhook.engine.billOnePeriodtrial-elapsed branch — kept as a defensive fallback. With Phases 0.5 / 0.9 wired, the engine branch’sstatus='trialing'guard naturally skips already-flipped subs.
Why this design
Section titled “Why this design”- One source of truth. Pre-fix every entry point computed its own
anchor with subtle differences; the bugs came from drift. The
helpers are pure functions; tests pin every cell of the
(billing_time × interval)matrix exactly. - Trial transitions happen at
trial_end_at, not at cycle close. The catchup-orchestrator and wall-clock-cron phases (Phase 0.5/phase 0.9) ensure status flips when the trial actually ends, matching Stripe / Lago’ssubscription.trial_endedsemantics. The engine’s existing cycle-close auto-flip branch is the defensive fallback. feedback_billing_accuracy— financial logic must be correct first time; the full matrix is exercised byTestPeriodAnchoring’s 11 scenarios.feedback_stripe_parity_framing— the chosen design is a superset that contains Stripe’s behavior as a configuration: Hardcount’sbilling_timelets the operator pick calendar vs anniversary, but for yearly plans (where neither platform ships a meaningful calendar variant) anniversary is forced.
Alternatives considered
Section titled “Alternatives considered”- A. Keep inline math at every entry point, fix each bug
separately. Rejected: drift is the root cause, fixing each
symptom would leave the next entry point exposed to the same class
of bug (see
feedback_long_term_means_cross_flow_audit). - B. Cycle-close-only trial transitions (status=‘trialing’ until
next_billing_at fires). Rejected: Bug #8 — dashboard lies about
lifecycle state for the gap between trial-end and cycle close (up
to ~30 days for calendar billing). Webhook consumers would also
receive
subscription.trial_endedweeks late. - C. Stub-to-next-Jan-1 for yearly + calendar. Rejected: no industry analog. Stripe and Lago both force anniversary for yearly. A partial-year first invoice followed by full-year cycles is a Hardcount-specific oddity with no operator demand.
- D. Reject
billing_time=calendarfor yearly plans at Create. Rejected: would force operators to think about the interaction when only one combination is meaningful. Forcing anniversary semantics silently and ignoring thebilling_timefield for yearly is friendlier and matches the implementations of reference platforms.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Revenue leaks closed for calendar+trial (Bug #1), early-EndTrial (Bug #2), in_advance+trial (Bug #6), and yearly+trial (Bug #10).
- Status correctness for the trial → active transition (Bug #8), ending the gap between actual trial-end and visible status flip.
- Operator UX improvements (PR-3): the subscription detail page
renders trial-aware timeline dots and a “Trial period” stat card
while
status='trialing'. - Webhook consumers see
subscription.trial_endedevents fired attrial_end_at(or at activation for operator-EndTrial), regardless of which path activated the sub.
Risks / open items
Section titled “Risks / open items”- The
firstPlanIntervalhelper falls open toBillingMonthlyon any PlanReader error (RLS gap, deleted plan, unwired reader). This preserves narrow-test behavior at the cost of masking configuration mistakes — a deleted plan id mid-life would silently re-anchor as monthly. Considered acceptable because the period helpers run BEFORE the sub is persisted at Create time (plan FK failure surfaces downstream), and the runtime entry points (Activate,EndTrial,ExtendTrial) operate on already-persisted subs whose plan ids are FK-validated. - Mixed-interval validation is currently first-item-wins for already-
persisted subs (
UpdateItemplan-change doesn’t yet re-validate). Separate follow-up. - True cycle-skip “hard pause” semantics are not implemented (Bug
#12). The dashboard’s “Pause subscription” radio option was
removed in PR-6 rather than fixing the implementation, per
feedback_pre_launch_scoping— no named pressure for cycle-skip pause semantics. - The wall-clock cron’s
ProcessExpiredTrialsruns at the scheduler’s tick interval (typically 5 minutes), so wall-clock status flips lag the actualtrial_end_atby up to one tick. The catchup-orchestrator path is exact (fires on operator Advance).
Amendment 2026-05-18: in_advance + metered usage invoice shape
Section titled “Amendment 2026-05-18: in_advance + metered usage invoice shape”Verified industry behavior for the case where a plan’s base fee is
in_advance AND the sub has metered/usage charges — that mix is
what engine.billOnePeriod emits as a single hybrid invoice at every
cycle close (next-period base prepayment + just-elapsed-period usage
in arrears). Recorded here because the post-fix Billing Period UI
copy on SubscriptionDetail.tsx surfaced operator confusion (“the
period’s invoice is already generated — why is it labeled Billing
Period?”), which led to digging into how reference platforms
structure the same invoice. Three platforms checked:
- Stripe — Advanced usage-based billing — “When a billing interval ends, all accrued charges since the last billing interval are compiled into an invoice and sent to the customer.” / “License fees are billed one service interval in advance regardless of the billing interval.” / “Customers are only billed for completed service intervals.” (usage rule). Result: one invoice per cycle close, combining in-advance base for the upcoming period + arrears usage for the just-completed period.
- Lago — Plan model / Lago — Usage-based charge — “Additional charges for per-usage Billable metrics are always paid in arrears because they are linked to a past consumption of your customers.” Combined with their pay-in-advance subscription-fee rule: same hybrid invoice shape.
- Chargebee — Metered Billing — diverges. “Processing the final invoice amount for subscriptions with metered items requires the finalized usage data, which is not available in advance. As a result, scheduling advanced invoices for subscriptions with metered items is not possible.” Chargebee disallows the combination — metered subs cannot bill the base in advance.
Hardcount follows the Stripe + Lago pattern — the dominant convention
across the two platforms operators most often compare us against.
Base line items stamp billing_period_start/end = [periodEnd, nextPeriodEnd] (internal/billing/engine.go:1494-1495); usage line
items stamp billing_period_start/end = [periodStart, periodEnd]
(engine.go:1601-1602). Both pre-trial first-invoice (BillOnCreate)
and steady-state cycle invoices follow this shape.
Not adopted from Chargebee: a Plan-create validation that rejects
base_bill_timing=in_advance when the plan has meter_ids. Defensible
to add later if an operator surveys produce a named need; per
feedback_pre_launch_scoping it’s not justified pre-launch since
Hardcount’s reference platforms allow the combination.
Operator-facing implication: a customer on an in_advance metered
plan sees their period-N invoice combining period-(N+1) base lines
with period-N usage lines. The dashboard’s invoice-detail view should
surface the per-line billing_period_start/end so this isn’t
ambiguous when an operator inspects a mixed-period invoice. Currently
the data flows through; a follow-up check that the rendering is clear
is on the open-items list. The SubscriptionDetail.tsx Current period
stat card (renamed 2026-05-18 from “Billing Period” — same data,
clearer label) refers to current_period_start/end which is the
consumption window, agnostic of when each line on the corresponding
invoice fires.
Amendment 2026-05-18 (PR-8): hard-pause API removed; industry framing corrected
Section titled “Amendment 2026-05-18 (PR-8): hard-pause API removed; industry framing corrected”PR-6 removed the dashboard’s “Pause subscription” radio option on the grounds that the implementation’s “freezes the cycle entirely” description was a lie: paused subs were filtered out of the cycle scan, but on resume the engine caught up by billing every “missed” cycle — the opposite of a freeze. The earlier framing said “no industry analog” for true cycle-skip hard pause. That claim was wrong. Re-verification on 2026-05-18:
- Chargebee — Pause Subscription: ships full hard-pause with cycle skip. “Pause at the end of the current term, and resume automatically after the set number of billing cycles (in
skip_billing_cycles) have been skipped.” Three pause-options (end_of_term / scheduled / immediate); three resume-options (after-cycles / on-date / indefinite); three unbilled-charges strategies (invoice / defer / discard). - Recurly — Pause subscription: also ships it. “This allows you to freeze billing for a specified number of cycles at the next renewal date.” Auto-resume after
remaining_pause_cycles; cycle anchor resets on resume. - Stripe: only
pause_collection(keep_as_draft / void / mark_uncollectible). No cycle-skip hard pause. - Lago: no documented pause feature.
So the honest framing: hard pause exists in industry (Chargebee, Recurly), Hardcount deferred for scope, not absence.
PR-8 removes the entire hard-pause surface:
- Routes:
POST /v1/subscriptions/:id/pause+/resume - Handlers:
Handler.pause+Handler.resume - Service:
Service.Pause+Service.Resume - Store:
Store.PauseAtomic+Store.ResumeAtomic(interface + postgres + mem) - Events:
EventSubscriptionPaused+EventSubscriptionResumedenum constants - SDK:
api.pauseSubscription+api.resumeSubscriptioninweb-v2/src/lib/api.ts - Dashboard: dead
resumeMutation+status === 'paused'Resume button block - Status enum:
domain.SubscriptionPausedremoved - Schema: migration 0090 drops
'paused'fromsubscriptions.statusCHECK (with a safety guard that errors loudly if any row IS in ‘paused’ state — pre-launch zero data, so moot in practice) - OpenAPI spec: removed
pausedfrom the status enum (and re-added missingtrialing, fixing a stale gap from migration 0053) - Generated types regenerated via
make gen
The Chargebee/Recurly e2e shape (paused_at + resume_at + remaining_pause_cycles + unbilled-charges decision tree) is documented in the body of this ADR as the future re-implementation target when a design partner names the need. The pre-launch decision is to ship truth over feature-count: pause_collection covers the legitimate “operator wants to freeze charging” use case for Hardcount’s wedge market (AI infra Series A-B), and rebuilding hard-pause properly is a 3-5 day project that should be designed against a real customer’s requirements.
Updated risks/open items:
- If a DP asks for hard pause, the design starting point is Chargebee/Recurly’s API shape outlined above. Don’t reinherit the old
/pauseendpoint — design the new one to takepause_option/resume_option/unbilled_chargesupfront. - The in_advance scheduled-cancel-at-period-end overcharge (flagged separately in the cancel-flow audit) is the same shape of bug that a future hard-pause implementation would need to guard against: in_advance base lines should NOT be emitted at a cycle close where the sub is about to transition out (cancel or pause activation). Single fix would cover both cases.
References
Section titled “References”- Related ADRs: ADR-028 (disjoint flows — clock-pinned vs wall-clock subs go through separate scan paths), ADR-029 (catchup orchestrator phase ordering), ADR-031 (in_advance bill-timing — Bug #6’s BillOnCreate trigger ties back to this), ADR-035 (per-fact sim-time anchoring — the bind-effective-now pattern this ADR builds on).
- Commits implementing this contract:
2233600(Bug #1, #2, #11 + ExtendTrial re-anchor),7d26023(Bug #6 — in_advance + trial day-1),e51face(Bug #8 — catchup Phase 0.5),af47996(Bug #8 — wall-clock cron phase 0.9),786e299(Bug #10 — yearly). - Memory pointers:
feedback_billing_accuracy,feedback_long_term_means_cross_flow_audit,feedback_stripe_parity_framing,feedback_design_for_production,feedback_verify_stripe_parity_claims. - Stripe — Subscription billing-cycle
- Lago — Billing time
- Stripe — Cancel a subscription (Bug #13 — trial-cancel parity)