Skip to content

✨ Credit-balance alerts for plans without a weekly quota #4117

Description

@PierreBrisorgueil

What — Users on a plan without a weekly quota (a one-shot credit grant) get an email when their credits run low and when they run out.
Why — Today those users get no warning at all: their runs simply stop when the balance hits zero.

Scope

  • Root cause: modules/billing/services/billing.usage.service.js:236 wraps the whole 80 %/100 % alert block in if (effectiveQuota > 0). A plan with meterQuota: 0 sends every unit to extras (168-169), debited from BillingExtraBalance.cachedBalance; the real limit is that balance, and no alert ever fires. The reference config ships this exact shape (billing.development.config.js:86: meterQuota: 0, signupGrant: 500, oneShot: true).
  • Fix: in incrementMeter, inside the existing extrasConsumed > 0 branch that already reads debitResult.doc.cachedBalance (195-221), stateless crossing detection: pre = cachedBalance + extrasConsumed, post = cachedBalance; for each threshold in config.billing.alerts.thresholdPercents (default [80,100]), fire when remaining credits cross (1 - T/100) * plan.signupGrant between pre and post. Atomic debits make exactly one caller cross each threshold.
  • Do NOT reuse alertedAt80/alertedAt100 (weekly usage doc, would re-fire every week against a lifetime balance), and do NOT reuse billing-quota-*.html (they say "weekly quota"). Add a new event (e.g. billing.extras.balance_threshold_crossed) and new templates (e.g. billing-credit-warning.html, billing-credit-exhausted.html), wired in billing.email.js.

Limitation — using signupGrant as the denominator only fits the one-shot grant; once a pack purchase tops up the same balance, "% of grant" is ambiguous. Scope to the signup-grant case and document it.

Tests — billing.usage.service.unit.tests.js (crossing on a meterQuota: 0 plan), billing.extra.service.unit.tests.js, new listener case in billing.init.email-alerts.unit.tests.js.

Refs: #3536 (meter/extras/threshold design)

Update (scope re-validated)

  • Explicit guard: run the check only when effectiveQuota === 0 && Number.isFinite(plan.signupGrant) && plan.signupGrant > 0 (the extrasConsumed > 0 branch also runs for quota plans that overflow).
  • Crossing rule: level L = (1 - T/100) * signupGrant, fire when pre > L && post <= L. Thresholds from billing.alerts.thresholdPercents, filtered to 80/100 (as billing.email.js:65 does).
  • One debit crossing both levels → emit only the deepest (one email).
  • Emit inside its own try/catch, like the runaway block (208-219).
  • Copy speaks in absolute credits left ("N credits left" / "you are out of credits") + link to plans/packs — never "% of grant" (wrong once packs or referral grants top up the balance). No org name.
  • Payload: { organizationId, threshold, remaining: max(0, post), planId }.
  • A credit (pack, referral) that pushes the balance back above a level means the next crossing alerts again — intended, test it.
  • Accepted limitation (document in README): balance drops from pack expiry or refunds don't alert.
  • Tests: drop billing.extra.service.unit.tests.js (service unchanged). Add: boundary post === L; two adjacent concurrent debits → exactly one crosser; double crossing → one event; re-cross after credit; quota>0 overflow → no event; quota-0 plan without grant → no event.
  • fix(billing): weekly reset repays overflow debt once #4118 (weekly reset) did not change the debit shape — refs still accurate.

Scope: validated 2026-09-25
Created via /dev:issue

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions