Billing Thresholds — Technical Design
- Decided: a subscription can carry a spend cap (
amount_gte) or per-item usage caps (usage_gte). When the period’s usage reaches one, Hardcount invoices that usage early and sendssubscription.threshold_crossed. The period keeps its dates, and the base fee is billed as it would be with no threshold.- Shipped: v0.1.0, in #29 (migration
0056_subscription_billing_thresholds).- Current docs: Billing thresholds in the feature list and the threshold event. The decisions made since are listed below. Where this page and the code disagree, the code wins.
Design document, last revised 2026-04-26 (the pre-build RFC). The body below is kept as written and is not the current design.
Current design: ADR-065 (scan kernel: boundary-skip, fire-once probe, full drain), ADR-066 (money semantics: what the fire and the close each bill, the one-transaction fire), ADR-115 (a threshold fire and the cycle close cannot both bill one period), and ADR-029 section 2 (the threshold scan skips test-clock subscriptions; they are scanned when the clock is advanced). Code:
internal/billing/threshold_scan.go,internal/subscription/.Where the body is out of date (verified against code 2026-08-23):
- Validation failures return
422 validation_error, not 400.reset_billing_cycleis removed (2026-10-23). A fire always keeps the billing period (Stripe’s default), and a body that sends the field is refused with 422. Every part of the body below about resetting the cycle no longer applies.usage_gtemust be more than 0, under 10^26, with at most 12 decimal places: the column’sCHECK (usage_gte > 0)andNUMERIC(38,12). A value outside them is a 422 namingitem_thresholds[i].usage_gte(2026-10-24, #1378). Before, the service accepted 0 and the write failed in Postgres.- The planned base-fee-only PATCH-time rejection was not shipped (validation checks item membership, duplicates, and decimal parse).
- The once-fired guard is the partial unique index alone, surfacing
errs.ErrAlreadyExistson a retried tick. There is no invoice source-key column, andON CONFLICT DO NOTHINGreturns no row.- The scheduler tick is now reconcilers → auto-charge retry → dunning enrollment → threshold scan → cycle scan.
amount_gteis integer cents in the currency’s minor unit; the body’s “major-unit-cents” contradicts itself.- A threshold counts and bills usage only (2026-10-23).
amount_gteis compared with the period’s usage so far, and the base fee, in advance or in arrears, is billed by the period’s own invoice, not the threshold invoice.Related design docs:
docs/dev/design/create-preview.md(same wire conventions),docs/dev/design/multi-dim-meters.md(usage.AggregateByPricingRules).
Motivation
Section titled “Motivation”Stripe ships billing thresholds as Tier 1 (top-priority in Hardcount’s Stripe-parity feature ranking) — a tenant configures a per-subscription cap (amount_gte in cents, or usage_gte per item in units). When the in-cycle running total crosses the threshold, the engine finalizes an invoice early (issues it mid-cycle instead of waiting for period end), optionally resets the billing cycle (so the next cycle starts at zero), and emits a webhook event. This shields the tenant’s customers from runaway bills mid-cycle, and shields Hardcount tenants from collection risk on customers whose usage suddenly explodes.
Hardcount already has every primitive to do this:
- The cycle scan (the scheduler pass that bills each subscription at period end:
Engine.RunCycle→Engine.billSubscription→CreateInvoiceWithLineItems→advanceCycleOrCancel) already builds line items from the partial-cycle window, prices them viadomain.ComputeAmountCents, finalizes the invoice, and rolls the cycle forward. usage.AggregateByPricingRulesalready computes the running total over an arbitrary window, including for multi-dim (multi-dimensional) meters — one meter carrying differently-priced slices of usage, split by dimension labels.- The scheduler already runs a billing tick under an advisory lock (a Postgres application-level lock, here
s.billingLockKey) that gates exactly-one finalize per (subscription, cycle).
The slice (the unit of work here) is composition: a mid-cycle scan tick. For every subscription with thresholds configured, it computes the partial-cycle total via the same aggregation path the cycle scan uses; when the threshold is crossed, it calls into the existing finalize-cycle code path early with billing_reason="threshold". We do not duplicate the build-line-items or advance-cycle code — same code path, different entry point.
The unblock for Track B (the dashboard/UI workstream): the cost dashboard’s projected-bill line gets a new “approaching threshold” indicator (red bar at 80%, lock icon at 100%) reading from a new subscription.billing_thresholds field on the existing GET endpoint.
- Per-subscription threshold configuration. Tenants set
billing_thresholdson a subscription viaPATCH /v1/subscriptions/{id}. Two threshold types:amount_gte(currency-major-unit-cents int64) anditem_thresholds[](per-itemusage_gtedecimal). Either alone or both — first to fire wins. - Mid-cycle scan that fires the existing finalize path. When a threshold crosses, the engine emits the same line set the next cycle scan would have emitted, finalizes with
billing_reason="threshold", and (whenreset_billing_cycle=true) rolls the cycle forward as if the period had ended naturally. - Idempotent under retry. The scheduler’s existing advisory-lock leader gating (only the instance holding the lock runs a tick) already prevents two ticks from doing the same work; the threshold scan inherits this. Within a single tick, a subscription whose threshold has already fired in the current cycle is skipped (no second invoice).
- Multi-dim parity.
item_thresholds[]useusage.AggregateByPricingRulesso a multi-dim meter’s running total respects rule priority + claim semantics (each event is counted by at most one rule, highest priority first), identical to the cycle scan. - Webhook event.
subscription.threshold_crossedfires when the threshold causes the early finalize. Standard webhook outbox path (the event row is written to the database and delivered by a background dispatcher); standard signature. - Wire-contract clean. Snake-case JSON, decimal-string
usage_gte, integer-centsamount_gte, always-arrayitem_thresholds. Same ergonomics as preview/customer-usage.
Non-goals (deferred)
Section titled “Non-goals (deferred)”- Plan-level thresholds. Stripe lets you configure thresholds on a price; Hardcount v1 attaches them to a subscription only. Per-plan defaults are a tenant-level concern that doesn’t have a good home yet. Reproducing the precedence rule (sub overrides plan overrides product) invites the kind of decision tree that has to be unwound when wrong. Real consumer first.
- Currency conversion for cross-currency thresholds. A subscription with multiple line currencies cannot have a single
amount_gte; v1 rejects multi-currency subscriptions withamount_gteset (validated at PATCH time). Multi-currency tenants surface as a documented edge in the changelog. - Threshold-driven email/dashboard alerts (the soft-warning path). That’s Week 5d (billing alerts); separate slice, separate sibling worktree, separate event family. The threshold here is the hard cap that fires an invoice, not the “you’ve used 80% of your monthly $X” soft notification.
- Resetting the cycle to a partial-period boundary. When
reset_billing_cycle=true, the new cycle starts at the moment the threshold fired and runs for one full cycle’s duration (calendar-month-equivalent). We do not preserve the original anniversary day (the day-of-month the cycle renewed on before the reset) — that’s a Stripe quirk we can add later if a real consumer asks. - Coupon / credit application to the threshold-fired invoice. Same boundary as create_preview (the upcoming-invoice preview endpoint): v1 finalizes subtotal-only thresholds. Discounts and credits apply at finalize via the existing pipeline if configured on the subscription, but the threshold check is computed against subtotal. Checking net of discount+credit could prevent a true threshold cross from firing — the wrong shape, because the customer’s raw spend is what tenants want capped. Documented in changelog.
Today’s surface (in repo)
Section titled “Today’s surface (in repo)”Everything we need already exists; this slice composes:
internal/billing/engine.go::Engine.billSubscription(ctx, sub, period, billingReason)— already builds line items from base fee + per-meter aggregation, callsCreateInvoiceWithLineItems, applies tax + coupon. Threshold scan reuses this withbillingReason="threshold".internal/billing/engine.go::Engine.advanceCycleOrCancel(ctx, sub, periodEnd, ...)— rolls the subscription’s cycle forward to the next period. Threshold scan reuses with the crossed-at time as the newcurrent_billing_period_start.internal/usage/service.go::Service.AggregateByPricingRules(ctx, tenantID, customerID, meterID, defaultMode, from, to)— partial-cycle aggregation. Same window as the cycle scan, just computed at tick-time-now instead of period-end.internal/billing/scheduler.go::Scheduler.runBillingCycleForMode(ctx, livemode)— leader-gated unders.billingLockKey. New step inserted between step 0 (reconcile) and step 1 (RunCycle): scan thresholds.internal/subscription/store.go::Store— extended withSetBillingThresholds,ClearBillingThresholds, andListWithThresholds(the candidate-set query for the scan).internal/domain/webhook_outbound.go— extended withEventSubscriptionThresholdCrossed.
The only new code is:
- A
BillingThresholdsdomain type + JSON/wire shape. - A migration adding columns to
subscriptionsand asubscription_item_thresholdsaux table. - The new
billing_reasoncolumn oninvoices(with'threshold'in the CHECK constraint — also covers the existing implicit reasons'subscription_cycle'and'manual'). - The threshold scan loop in
internal/billing/threshold_scan.go. - The
PATCH /v1/subscriptions/{id}route (the existing handler doesn’t have an item-level PATCH for the subscription itself yet; only items have one).
Wire contract
Section titled “Wire contract”Conventions (consistent with
/v1/*, seedocs/dev/design/customer-usage.md§ wire-contract anddocs/dev/design/create-preview.md):
- Snake-case JSON keys.
- Decimal
usage_gtemarshals as a precise string ("1000000.000000000000") per ADR-005.- Integer-cents
amount_gte.- Always-array
item_thresholds. Even when zero or one entry. (The dashboard’s.map()over the array works in all cases.)
PATCH /v1/subscriptions/{id}
Section titled “PATCH /v1/subscriptions/{id}”PATCH /v1/subscriptions/{id}Authorization: Bearer <secret_key>Content-Type: application/json
{ "billing_thresholds": { "amount_gte": 100000, "reset_billing_cycle": true, "item_thresholds": [ { "subscription_item_id": "hc_subi_abc", "usage_gte": "1000000" } ] }}To clear: {"billing_thresholds": null}.
amount_gte is the running cycle subtotal (integer cents in the subscription’s currency). When the partial-cycle subtotal crosses this threshold (i.e., this tick the running total is >= amount_gte and the previous tick was < or there was no previous tick), the engine fires an early finalize.
item_thresholds[].usage_gte is the running cycle quantity for the single meter rated by the item’s plan. When the item’s running quantity crosses this, same early-finalize fires. Item thresholds are scoped to the item’s plan’s meter — a base-fee-only plan cannot have an item threshold (validated at PATCH time).
reset_billing_cycle (default true) controls whether the cycle resets after the early finalize. false keeps the cycle running on its original schedule; the threshold-fired invoice is one extra invoice within the cycle, and the cycle’s natural-boundary invoice still fires at period end (billing whatever residual usage accumulated between the threshold fire and the period end).
item_thresholds is an array; subscriptions with multiple items can configure per-item caps.
Response: the full updated subscription, hydrated with items and thresholds.
{ "id": "hc_sub_xyz", "customer_id": "hc_cus_abc", "status": "active", ..., "billing_thresholds": { "amount_gte": 100000, "reset_billing_cycle": true, "item_thresholds": [ { "subscription_item_id": "hc_subi_abc", "usage_gte": "1000000.000000000000" } ] }}Error shapes
Section titled “Error shapes”404 subscription_not_found— subscription ID does not exist for the tenant.400 invalid_request—amount_gte <= 0, orusage_gtenon-positive, oritem_thresholds[].subscription_item_idnot on this subscription, orbilling_thresholdsset while the subscription has multiple line currencies (multi-currency cap not supported in v1).409 invalid_state— subscription is canceled (no point setting a threshold on a terminal subscription).
Webhook event
Section titled “Webhook event”subscription.threshold_crossed fires immediately after the threshold-fired invoice transitions to finalized. Payload includes the subscription, the invoice ID, and the threshold definition that fired. Standard outbox path; same signing.
Internals
Section titled “Internals”Threshold scan tick
Section titled “Threshold scan tick”// internal/billing/threshold_scan.go (sketch)func (e *Engine) ScanThresholds(ctx context.Context, batchSize int) error { subs, err := e.subs.ListWithThresholds(ctx, e.livemode, batchSize) if err != nil { return err }
for _, sub := range subs { if err := e.scanOneThreshold(ctx, sub); err != nil { // log + continue; next tick will retry slog.Error("threshold scan failed", "sub", sub.ID, "err", err) } } return nil}
func (e *Engine) scanOneThreshold(ctx context.Context, sub domain.Subscription) error { // Skip subs that don't bill mid-cycle (no current period) if sub.CurrentBillingPeriodStart == nil { return nil }
now := e.effectiveNow(sub) from := *sub.CurrentBillingPeriodStart to := now
// Partial-cycle subtotal across all priced lines (same shape as billSubscription). runningSubtotal, runningPerItem, err := e.computePartialCycleTotals(ctx, sub, from, to) if err != nil { return err }
crossed, reason := evaluateThresholds(sub.BillingThresholds, runningSubtotal, runningPerItem) if !crossed { return nil }
// Re-fetch sub under tx to avoid double-fire in the rare case the // previous tick crossed the threshold but failed before advance. return e.fireThreshold(ctx, sub, from, to, reason)}evaluateThresholds compares running totals against the configured thresholds. It returns (crossed, reason) where reason documents which threshold tripped (used in the line item description and the webhook payload).
fireThreshold opens a tx, claims the subscription via the existing advisory lock keyed on (billing, sub.ID), calls Engine.billSubscription(ctx, sub, period, "threshold"), and when reset_billing_cycle=true calls advanceCycleOrCancel(ctx, sub, now, ...) so the new cycle starts at now.
The advisory-lock claim is the idempotency seam — if a previous tick already fired the threshold for the current cycle, the cycle has already advanced; this tick’s from is the new (post-advance) period start, and the running totals start at zero. The threshold can fire again in the new cycle, which is correct.
Per-cycle once-fired guard
Section titled “Per-cycle once-fired guard”The naive failure mode: a tick crosses the threshold, billSubscription succeeds, but advanceCycleOrCancel fails (DB blip). The next tick would re-aggregate the same window, see the threshold still crossed, and try to fire again — producing a duplicate invoice.
The guard: Engine.fireThreshold uses the existing invoice-source-key dedup column on the invoices table. We add a new natural key (tenant, subscription, cycle_start, billing_reason='threshold') via a partial unique index — at most one threshold-fired invoice per cycle. The next tick’s INSERT … ON CONFLICT DO NOTHING returns the existing row; the engine notices “already fired this cycle” and short-circuits before computing line items. Cycle reset is idempotent because it’s keyed on current_billing_period_start <= now (re-running on an already-advanced row leaves it unchanged).
We don’t need a new “threshold_fired_at” column — the existence of a finalized invoice with billing_reason='threshold' and the matching billing_period_start IS the marker.
Scheduler wiring
Section titled “Scheduler wiring”Scheduler.runBillingCycleForMode already runs under s.billingLockKey. We add a step 0.5 between reconcile and the cycle scan:
// step 0.5: threshold scan (mid-cycle early finalize)if err := s.engine.ScanThresholds(ctx, 100); err != nil { slog.Error("threshold scan", "err", err) // continue — cycle scan still runs}Same lock, same fan-out per livemode. If one tick of the threshold scan takes longer than the tick interval, the next tick is gated by the leader lock — no double-execution.
Migration
Section titled “Migration”0056_subscription_billing_thresholds.up.sql:
-- Per-subscription billing thresholds. amount_gte is integer cents-- (subscription currency); reset_billing_cycle controls whether the-- cycle resets after the threshold fires. NULL on either column = no-- amount-based threshold configured.ALTER TABLE subscriptions ADD COLUMN billing_threshold_amount_gte BIGINT, ADD COLUMN billing_threshold_reset_cycle BOOLEAN NOT NULL DEFAULT TRUE, ADD CONSTRAINT subscriptions_billing_threshold_amount_gte_check CHECK (billing_threshold_amount_gte IS NULL OR billing_threshold_amount_gte > 0);
-- Per-item usage thresholds. One row per (subscription, item) pair-- with a quantity threshold. NUMERIC(38,12) keeps decimal precision-- consistent with the rest of usage rating.CREATE TABLE subscription_item_thresholds ( subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, subscription_item_id TEXT NOT NULL REFERENCES subscription_items(id) ON DELETE CASCADE, tenant_id TEXT NOT NULL, usage_gte NUMERIC(38, 12) NOT NULL CHECK (usage_gte > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (subscription_item_id));
CREATE INDEX idx_subscription_item_thresholds_subscription ON subscription_item_thresholds (subscription_id);
ALTER TABLE subscription_item_thresholds ENABLE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON subscription_item_thresholds FOR ALL TO hardcount_app USING (tenant_id = current_setting('app.tenant_id', TRUE)) WITH CHECK (tenant_id = current_setting('app.tenant_id', TRUE));
-- Threshold-fired invoices. New billing_reason column + the existing-- implicit reasons. CHECK constraint allows NULL for legacy rows.ALTER TABLE invoices ADD COLUMN billing_reason TEXT, ADD CONSTRAINT invoices_billing_reason_check CHECK (billing_reason IS NULL OR billing_reason IN ( 'subscription_cycle', 'subscription_create', 'manual', 'threshold' ));
-- Partial unique index: at most one threshold-fired invoice per-- (tenant, subscription, cycle_start) pair. Idempotency seam for the-- re-tick-after-failed-advance case.CREATE UNIQUE INDEX idx_invoices_threshold_unique_per_cycle ON invoices (tenant_id, subscription_id, billing_period_start) WHERE billing_reason = 'threshold';
-- Scan candidate index: subscriptions with thresholds configured.CREATE INDEX idx_subscriptions_billing_thresholds_amount ON subscriptions (id) WHERE billing_threshold_amount_gte IS NOT NULL;0056_subscription_billing_thresholds.down.sql:
DROP INDEX IF EXISTS idx_subscriptions_billing_thresholds_amount;DROP INDEX IF EXISTS idx_invoices_threshold_unique_per_cycle;ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_billing_reason_check, DROP COLUMN IF EXISTS billing_reason;DROP TABLE IF EXISTS subscription_item_thresholds;ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_billing_threshold_amount_gte_check, DROP COLUMN IF EXISTS billing_threshold_reset_cycle, DROP COLUMN IF EXISTS billing_threshold_amount_gte;Invoice domain extension
Section titled “Invoice domain extension”internal/domain/invoice.go:
type InvoiceBillingReason string
const ( BillingReasonSubscriptionCycle InvoiceBillingReason = "subscription_cycle" BillingReasonSubscriptionCreate InvoiceBillingReason = "subscription_create" BillingReasonManual InvoiceBillingReason = "manual" BillingReasonThreshold InvoiceBillingReason = "threshold")
// In Invoice struct:BillingReason InvoiceBillingReason `json:"billing_reason,omitempty"`The existing billSubscription accepts a new billingReason parameter; existing callers pass BillingReasonSubscriptionCycle and BillingReasonSubscriptionCreate (the proration paths become BillingReasonSubscriptionCycle for now — they’re cycle-driven). The threshold scan passes BillingReasonThreshold.
Unit tests
Section titled “Unit tests”evaluateThresholdstable-driven: amount-only crossed, amount-only not crossed, item-only crossed (single item), item-only crossed (multi-item, only second crosses), both configured, multi-currency rejected.Service.SetBillingThresholdsvalidation:amount_gte <= 0→ invalid_request,usage_gtenon-positive-decimal → invalid_request,subscription_item_idnot on sub → invalid_request, multi-currency sub withamount_gte→ invalid_request, terminal sub → invalid_state.decodeBillingThresholds(handler-level JSON parse): null clears, emptyitem_thresholdsaccepted, decimal-stringusage_gteround-trips precisely.TestWireShape_SnakeCase(the merge gate): marshal a real Subscription with thresholds set, assert every required snake_case key (billing_thresholds,amount_gte,reset_billing_cycle,item_thresholds,subscription_item_id,usage_gte), assert no PascalCase leaks, assert emptyitem_thresholdsmarshals as[]notnull, assertusage_gtemarshals as a string with exact value"1000000.000000000000"(no float coercion).
Integration tests (real Postgres)
Section titled “Integration tests (real Postgres)”TestThresholdAmountCrossesFinalizesEarly— seed a sub withamount_gte=1000, push usage events totaling 1500 cents, run a tick. Assert: finalized invoice exists withbilling_reason='threshold', cycle has advanced (current_billing_period_startis now>= tick-time), webhook outbox hassubscription.threshold_crossedrow.TestThresholdItemCrossesFinalizesEarly— same as above but withitem_thresholds[0].usage_gte=1000units, push 1500 units across the meter.TestThresholdReTickIsIdempotent— fire the threshold once, run the scan again immediately. Assert: only one invoice was created (same ID), no duplicate webhook event.TestThresholdResetFalseKeepsOriginalCycle—reset_billing_cycle=false. Threshold fires,current_billing_period_startis unchanged, the cycle scan at period end still produces a second invoice.TestThresholdMultiDimRespectsRules— multi-dim meter with two rules (input @3¢, output @5¢),amount_gte=1000. Push usage that totals 1100 across both rules; threshold fires. Assert the threshold-fired invoice’s lines mirror the cycle scan’s per-rule line set.TestThresholdSkipsTerminalSubscription— a canceled sub with thresholds set is skipped by the scan tick.TestThresholdSkipsTrialingSubscription— a trialing sub with thresholds set is skipped (no current period until activated).
Performance
Section titled “Performance”- Per-tick: 1 indexed query for candidate subs (filtered by
billing_threshold_amount_gte IS NOT NULL OR EXISTS (item_thresholds)), then 1AggregateByPricingRulescall per meter per candidate sub. For a tenant with 10K subs and a 100-batch tick, that’s at most 100 aggregation calls per minute — well under the cycle scan’s existing budget. - Tick interval: piggybacks on the existing scheduler tick (
s.tickInterval, default 30s). No new ticker. - The “did the threshold cross since last tick?” check is not implemented — we just check “is the threshold currently crossed?”. A tick that ran during the cross window will fire it; a tick that runs after
fireThresholdalready advanced the cycle will see the new (post-advance) running total, which starts at zero. The unique partial index on(tenant, subscription, billing_period_start)is the duplicate-prevention seam, not a “since last tick” check.
Decimal & numeric considerations
Section titled “Decimal & numeric considerations”usage_gte is NUMERIC(38, 12) in Postgres and decimal.Decimal in Go, marshaled as a JSON string per ADR-005. amount_gte is BIGINT in Postgres and int64 in Go; integer cents.
The item_thresholds array is stored as an aux (auxiliary) table because per-item config doesn’t compose cleanly into a single row’s columns, and JSONB on a hot-path-aggregation row is a footgun (no per-item indexes; query plans surprise people). One row per item is the same shape as subscription_items itself.
Open questions
Section titled “Open questions”- Should
reset_billing_cycle=falsebe supported in v1? Stripe allows it. Proposal: yes. Simple to implement (just skip theadvanceCycleOrCancelcall), and it covers the tenant case “I want a hard cap plus a normal monthly bill” — the threshold-fire becomes an extra invoice rather than a cycle boundary. The integration test confirms the second invoice fires at period end. - Should the threshold-fired invoice’s
billing_period_endbe the original cycle end or the fire time? Proposal: the fire time. Stripe-equivalent: the invoice’s billing period is the partial cycle that produced the spend. The new cycle (when reset_billing_cycle=true) starts at fire time and gets a fresh natural cycle end. When reset_billing_cycle=false, the second invoice’s billing period is(fire_time, original_period_end), which means the period covered by the threshold-fired invoice is(period_start, fire_time). - Should we support
usage_gteas an integer instead of a decimal? Stripe uses integer “quantity” for usage thresholds. Proposal: decimal. Hardcount already exposes decimal quantities in customer-usage and create_preview; consistency matters. A tenant who wants integer behavior just sets"1000.000000000000". The aggregation always returns a decimal anyway. - Should we preview the threshold-fire in
create_preview? Proposal: defer to v2. The preview’s job is “what will the natural-cycle invoice look like”; a threshold-fired invoice is a separate event. We could add awould_threshold_fire: trueflag but it muddles the response shape. Real consumer first. - Should the threshold scan run independently of the cycle scan tick interval? Proposal: same tick. A separate ticker would mean a second advisory-lock domain, more code paths to debug. The existing 30s tick is granular enough that the worst-case delay between threshold-cross and finalize is 30s — well within tenant tolerance for “early finalize”, which is a soft-real-time event not a hard-real-time one.
- Should we emit
subscription.threshold_warningat 80% of threshold? That’s Week 5d (billing alerts) — different feature. Linked, but not co-shipped. - Should
amount_gtebe denominated in the subscription’s currency or platform-canonical USD? Proposal: subscription currency. A multi-currency tenant configures different thresholds per currency (different subscriptions). Avoids FX conversion in the hot scan path.
Implementation checklist
Section titled “Implementation checklist”-
internal/domain/subscription.go—BillingThresholdsstruct +SubscriptionItemThreshold. -
internal/domain/invoice.go—InvoiceBillingReasonenum +BillingReasonfield onInvoice. -
internal/platform/migrate/sql/0056_subscription_billing_thresholds.{up,down}.sql. -
internal/subscription/store.go—SetBillingThresholds,ClearBillingThresholds,ListWithThresholds. -
internal/subscription/postgres.go— implementations. -
internal/subscription/service.go— validation +SetBillingThresholdsmethod. -
internal/subscription/handler.go—PATCH /v1/subscriptions/{id}route. -
internal/billing/threshold_scan.go—Engine.ScanThresholds+scanOneThreshold+fireThreshold. -
internal/billing/scheduler.go—ScanThresholdswired intorunBillingCycleForMode. -
internal/billing/engine.go—billSubscriptionacceptsbilling_reason. -
internal/invoice/postgres.go—CreateInvoiceWithLineItemspersistsbilling_reason. -
internal/domain/webhook_outbound.go—EventSubscriptionThresholdCrossed. -
internal/subscription/wire_shape_test.go—TestWireShape_SnakeCaseregression. - Unit tests for service validation + handler PATCH path +
evaluateThresholds. - Integration tests covering the seven scenarios listed above.
- CHANGELOG entry + public changelog rollup.
Track B unblock
Section titled “Track B unblock”Track B can wire the cost dashboard’s “approaching threshold” indicator off the existing GET /v1/subscriptions/{id} response (which now includes billing_thresholds) plus the existing customer-usage projected-bill total. Math is 100 * customer_usage.totals[0].amount_cents / billing_thresholds.amount_gte for the percentage.
The dashboard call:
const sub = await api.getSubscription(subscriptionID)const usage = await api.customerUsage({ customer_id: sub.customer_id })const pct = sub.billing_thresholds ? Math.min(100, 100 * usage.totals[0].amount_cents / sub.billing_thresholds.amount_gte) : nullThe dashboard shape:
- Threshold progress bar under the projected-bill line. Color: green < 50%, amber 50-80%, red > 80%, lock icon at 100%.
- Edit-threshold modal (gear icon next to the bar) lets tenants set/clear the threshold via the new PATCH route.
Track B implements the UI; the API stabilizes in this slice.
Review status
Section titled “Review status”- Track A author: drafted 2026-04-26 alongside the implementation
- Track B review: pending — UI wedge can scaffold against this design without waiting
- Human review: pending — flag any open question to revisit