From 9561dd549127e08a7746c43ed017c190750bc77d Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 12:00:59 +0200 Subject: [PATCH 1/6] feat(billing): credit-balance alerts for plans without a weekly quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-shot signup-grant plan (meterQuota=0) has no weekly usage doc to alert against — every unit debits BillingExtraBalance.cachedBalance directly, and that balance IS the real limit, so users ran out silently. incrementMeter now detects extras-balance threshold crossings (80/100, stateless — no alertedAtN dedup field, since a lifetime balance would falsely dedup a weekly-scoped field) against the debit's own pre/post balance and emits billing.extras.balance_threshold_crossed. billing.email.js sends a credit-warning or credit-exhausted email off of it, in absolute credits left, never a percentage. Scoped to meterQuota=0 plans with a valid signupGrant only — once a pack tops up the same balance, percent-of-grant is ambiguous (documented in README.md, along with the pack-expiry/refund alerting gap). Refs #3536 Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- .../templates/billing-credit-exhausted.html | 18 +++ config/templates/billing-credit-warning.html | 18 +++ modules/billing/README.md | 23 +++ modules/billing/billing.email.js | 42 ++++- modules/billing/lib/events.js | 4 + .../billing/services/billing.usage.service.js | 50 ++++++ .../billing.init.email-alerts.unit.tests.js | 143 ++++++++++++++++++ .../tests/billing.usage.service.unit.tests.js | 99 ++++++++++++ 8 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 config/templates/billing-credit-exhausted.html create mode 100644 config/templates/billing-credit-warning.html diff --git a/config/templates/billing-credit-exhausted.html b/config/templates/billing-credit-exhausted.html new file mode 100644 index 000000000..c2768d094 --- /dev/null +++ b/config/templates/billing-credit-exhausted.html @@ -0,0 +1,18 @@ + + + + + + +

Hello,

+

Your {{appName}} account is out of credits.

+

+ Runs are paused until you add more credits or upgrade your plan — nothing is charged automatically. +

+

Add credits or upgrade your plan on your billing dashboard.

+
+

The {{appName}} Team.

+
+ Please do not reply to this email, you can contact us here. + + diff --git a/config/templates/billing-credit-warning.html b/config/templates/billing-credit-warning.html new file mode 100644 index 000000000..f63107d16 --- /dev/null +++ b/config/templates/billing-credit-warning.html @@ -0,0 +1,18 @@ + + + + + + +

Hello,

+

Your {{appName}} account has {{remaining}} credits left.

+

+ Runs keep working while credits remain. Once they run out, runs pause until you add credits — nothing is charged automatically. +

+

Add more credits or upgrade your plan on your billing dashboard.

+
+

The {{appName}} Team.

+
+ Please do not reply to this email, you can contact us here. + + diff --git a/modules/billing/README.md b/modules/billing/README.md index 6c5d90414..3fe7b13bc 100644 --- a/modules/billing/README.md +++ b/modules/billing/README.md @@ -110,6 +110,29 @@ Consumers wanting clean-break behavior on downgrade should pass `{ preserveUsage - **Runaway detector:** the negative-balance alert (`billing.extras.runaway_debit`) only fires on plans with a weekly quota (`meterQuota > 0`). - **Overflow debt is repaid once per week.** Units consumed past the quota are debited from extras, which may go negative. On each `resetWeek` (weeks with a quota > 0 only) it repays debt from the target week's REMAINING quota: `settle = min(meterQuota − meterUsed, overflowDebt)` — the week is often already partly used, since the cron anchors on the current time. Extras are credited `settle` via an `adjustment` entry with refId `settle:`, then the week doc is charged that stored credit once (`meterUsed += settle`, guarded by the `settle:` key in `consumedAttributionKeys`) — a re-run, retry or concurrent reset never credits or charges twice. What does not fit stays as debt for the next reset. Refund debt and pack-expiry shortfall (the part of a pack clawback or a pack expiry that took the balance below zero) are never settled from quota — only a new pack repays them. The expiry sweep removes only a pack's own unspent units at its `expiresAt` (spending is attributed earliest-expiry-first; a fully spent pack gets a zero-amount marker, hidden from the customer ledger), so new expiries only create debt for usage recorded between a pack's `expiresAt` and the sweep (that part is never settled from quota); legacy full-amount expiration entries are left as is. Plans with `meterQuota = 0` are unchanged. +## Credit-balance alerts (plans without a weekly quota) + +A plan with `meterQuota: 0` and a one-shot `signupGrant` (e.g. the stack default `free` +plan) has no weekly usage doc to alert against — every unit is debited straight from +`BillingExtraBalance.cachedBalance`, so that balance IS the limit. `incrementMeter` detects +crossings of the configured `billing.alerts.thresholdPercents` (filtered to 80/100, same +supported set as the weekly-quota alerts) against `(1 - threshold/100) * plan.signupGrant`, +comparing the debit's own pre/post balance, and emits `billing.extras.balance_threshold_crossed` +(`{ organizationId, threshold, remaining, planId }`). `billing.email.js` sends a +credit-warning (80%) or credit-exhausted (100%) email off of it. + +- **Stateless — no `alertedAtN` field.** The weekly `alertedAt80`/`alertedAt100` fields + are scoped to a week and would re-fire every week against a lifetime balance, so this + path re-derives the crossing from the debit itself each time. A pack or referral credit + that pushes the balance back above a level means the next crossing alerts again — intended. +- **Accepted limitation — "% of grant" only fits the signup grant.** Once a pack purchase + tops up the same `cachedBalance`, "percent of grant remaining" is ambiguous, so this only + applies when `meterQuota === 0` and the plan has a valid `signupGrant`. Copy always speaks + in absolute credits left, never a percentage. +- **Accepted limitation — expiry and refunds don't alert.** A balance drop from a pack + expiring (`crons/billing.extrasExpiration.js`) or a refund (`billing.refund.service.js`) + does not go through `incrementMeter`'s debit path, so it never triggers this crossing check. + ## Extras debit reliability `attribute()` returns optimistically after usage increment + outbox row insert. Extras debit happens out of band; if it fails, cron `retry-pending-extras-debit` reconciles on the configured retry interval. After the configured failed-attempt limit, the outbox row is marked `failed` and the configured exhausted event is emitted for alerting. diff --git a/modules/billing/billing.email.js b/modules/billing/billing.email.js index 17b0ccec5..0de37c825 100644 --- a/modules/billing/billing.email.js +++ b/modules/billing/billing.email.js @@ -51,8 +51,10 @@ export const sendBillingEmail = (mailOpts, context) => { * Call once from billing.init.js after config is ready. * * Listeners registered: - * - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners - * - payment.failed — sends payment-failed email prompting card update + * - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners + * - billing.extras.balance_threshold_crossed — sends a credit-warning or credit-exhausted email + * (one-shot signup-grant plans, no weekly quota — see README.md § Credit-balance alerts) + * - payment.failed — sends payment-failed email prompting card update * * Template resolution: devkit ships generic templates in config/templates/billing-*.html. * Downstream projects override by placing same-named files in their own config/templates/ @@ -93,6 +95,42 @@ export const setupBillingEmails = () => { }); }); + // ── billing.extras.balance_threshold_crossed — credit-balance alerts (#4117) ── + // One-shot signup-grant plans (no weekly quota): 80%-consumed warning or fully-out + // email. Copy speaks in absolute credits left, never "% of grant" — a pack or + // referral top-up can raise the balance again, at which point "% of grant" is + // meaningless (see README.md § Credit-balance alerts). No org name in the copy. + + billingEvents.on('billing.extras.balance_threshold_crossed', ({ organizationId, threshold, remaining }) => { + if (threshold !== 80 && threshold !== 100) return; + + const appName = config.app?.title ?? ''; + const billingUrl = config.app?.url ? `${config.app.url}/billing` : ''; + const isWarning = threshold === 80; + + resolveOrgAdminEmails(organizationId).then((emails) => { + if (!emails.length) return; + for (const email of emails) { + sendBillingEmail( + { + to: email, + subject: isWarning + ? `${appName} — your credits are running low` + : `${appName} — you are out of credits`, + template: isWarning ? 'billing-credit-warning' : 'billing-credit-exhausted', + params: { + remaining: remaining ?? 0, + billingUrl, + appName, + appContact: config.app?.contact ?? '', + }, + }, + isWarning ? 'billing.extras.balance_threshold_crossed@80' : 'billing.extras.balance_threshold_crossed@100', + ); + } + }); + }); + // ── payment.failed — update card prompt ───────────────────────────────────── billingEvents.on('payment.failed', ({ organizationId }) => { diff --git a/modules/billing/lib/events.js b/modules/billing/lib/events.js index e8c8e7d90..9a02f8c15 100644 --- a/modules/billing/lib/events.js +++ b/modules/billing/lib/events.js @@ -22,6 +22,10 @@ import { EventEmitter } from 'events'; * cannot be resolved to a plan (unmapped priceId AND no valid metadata.planId) — the write * retains the last-known plan (or 'free' for a genuinely new org) instead of forcing 'free'. * Payload: { organizationId, stripeSubscriptionId, priceId, retainedPlan, hadKnownPlan, eventId } + * - `billing.extras.balance_threshold_crossed` — emitted when an extras-balance debit on a + * one-shot signup-grant plan (meterQuota=0) crosses a configured percent-of-grant level + * (stateless — no alertedAtN dedup field; a later credit lets the next crossing alert again). + * Payload: { organizationId, threshold, remaining, planId } * * NOTE: The 'error' event listener is registered in billing.init.js (after config is ready) * to avoid module-load-time config reads in this low-level singleton. diff --git a/modules/billing/services/billing.usage.service.js b/modules/billing/services/billing.usage.service.js index e0563eb72..0010336b3 100644 --- a/modules/billing/services/billing.usage.service.js +++ b/modules/billing/services/billing.usage.service.js @@ -27,6 +27,11 @@ const thresholdFields = { 100: 'alertedAt100', }; +// Credit-balance alerts (#4117) support only 80%/100% — same supported set as the +// weekly-quota alertedAtN schema fields above (billing.init.js warns at boot on any +// other configured value already; this mirrors that filter for the stateless path). +const SUPPORTED_CREDIT_ALERT_THRESHOLDS = new Set([80, 100]); + /** * @desc Increment a usage counter for the given organization (current month). * Hardens the repository's silent-null anomaly (#3991 follow-up): @@ -92,6 +97,9 @@ const reset = (organizationId) => UsageRepository.reset(organizationId, currentM * 4. If quota is exceeded, debits extras balance directly (atomic single-doc). * On debit failure, logs a warning — usage is already counted. * 5. Detects configured threshold crossings (emits meter.threshold_crossed event, once per cycle). + * 6. On a one-shot signup-grant plan (meterQuota=0), detects extras-balance + * threshold crossings against the debit's own pre/post balance and emits + * billing.extras.balance_threshold_crossed (stateless, re-fires after a credit). * * Returns applied=false when the idempotencyKey was already consumed (replay). * @@ -218,6 +226,48 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey) }); } } + + // Credit-balance alerts (#4117) — plans without a weekly quota (meterQuota=0, + // a one-shot signupGrant). There is no weekly usage doc to dedup against here + // (alertedAt80/alertedAt100 are scoped to a week and would re-fire every week + // against a lifetime balance — see billing.email.js), so this is stateless: + // detect a crossing purely from THIS debit's own pre/post balance snapshot. + // A later credit (pack, referral) that pushes the balance back above a level + // lets the next crossing alert again — intended. + // Limitation: "% of grant" only makes sense for the one-shot signup grant; once + // a pack tops up the same cachedBalance the denominator is ambiguous, so this is + // scoped to the signup-grant case only (see README.md). + if ( + effectiveQuota === 0 + && Number.isFinite(activePlan?.signupGrant) + && activePlan.signupGrant > 0 + ) { + const post = currentBalance; + const pre = post + extrasConsumed; + // DESC order (100 before 80, from getAlertThresholdPercents()) — emit only the + // deepest crossing per debit (one debit crossing both levels → one email). + for (const threshold of getAlertThresholdPercents()) { + if (!SUPPORTED_CREDIT_ALERT_THRESHOLDS.has(threshold)) continue; + // `signupGrant * (100 - threshold) / 100`, not `(1 - threshold/100) * signupGrant` — + // the latter hits float imprecision at common values (e.g. 500 * (1 - 80/100) = + // 99.99999999999997, not 100), which would silently miss an exact boundary crossing. + const level = (activePlan.signupGrant * (100 - threshold)) / 100; + if (!(pre > level && post <= level)) continue; + try { + billingEvents.emit('billing.extras.balance_threshold_crossed', { + organizationId, + threshold, + remaining: Math.max(0, post), + planId, + }); + } catch (evtErr) { + logger.error('[billing.usage] billing.extras.balance_threshold_crossed listener failed', { + error: evtErr?.message ?? String(evtErr), + }); + } + break; + } + } } } catch (err) { // Usage is already counted. Log for monitoring — a retry cron or manual backfill diff --git a/modules/billing/tests/billing.init.email-alerts.unit.tests.js b/modules/billing/tests/billing.init.email-alerts.unit.tests.js index 0bdb3a7e9..b62f5f7b7 100644 --- a/modules/billing/tests/billing.init.email-alerts.unit.tests.js +++ b/modules/billing/tests/billing.init.email-alerts.unit.tests.js @@ -299,6 +299,149 @@ describe('billing.email setupBillingEmails listeners:', () => { }); }); + // ── billing.extras.balance_threshold_crossed ───────────────────────────── + + describe('billing.extras.balance_threshold_crossed listener', () => { + test('sends credit-warning email when threshold=80 and mailer configured', async () => { + expect(typeof listeners['billing.extras.balance_threshold_crossed']).toBe('function'); + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 80, + remaining: 100, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMembershipRepository.list).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: orgId }), + ); + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@test.com', + subject: expect.stringContaining('credits are running low'), + template: 'billing-credit-warning', + params: expect.objectContaining({ remaining: 100 }), + }), + ); + }); + + test('sends credit-exhausted email when threshold=100', async () => { + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 100, + remaining: 0, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@test.com', + subject: expect.stringContaining('out of credits'), + template: 'billing-credit-exhausted', + params: expect.objectContaining({ remaining: 0 }), + }), + ); + }); + + test('subject includes appName from config.app.title, never the org id/name', async () => { + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 80, + remaining: 50, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + const call = mockMailer.sendMail.mock.calls[0][0]; + expect(call.subject).toEqual(expect.stringContaining('MyApp')); + expect(call.subject).not.toEqual(expect.stringContaining(orgId)); + expect(JSON.stringify(call.params)).not.toContain(orgId); + }); + + test('skips email when threshold is neither 80 nor 100', async () => { + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 60, + remaining: 200, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('skips email when mailer is not configured', async () => { + mockMailer.isConfigured.mockReturnValue(false); + + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 80, + remaining: 100, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('skips email when no owner/admin emails found', async () => { + mockMembershipRepository.list.mockResolvedValue([]); + + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 80, + remaining: 100, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('includes billingUrl built from config.app.url', async () => { + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 80, + remaining: 100, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ billingUrl: 'https://myapp.example.com/billing' }), + }), + ); + }); + + test('logs error but does not throw when sendMail rejects', async () => { + mockMailer.sendMail.mockRejectedValue(new Error('SMTP error')); + + listeners['billing.extras.balance_threshold_crossed']({ + organizationId: orgId, + threshold: 100, + remaining: 0, + planId: 'free', + }); + + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('email failed'), + expect.objectContaining({ error: 'SMTP error' }), + ); + }); + }); + // ── payment.failed ──────────────────────────────────────────────────────── describe('payment.failed listener', () => { diff --git a/modules/billing/tests/billing.usage.service.unit.tests.js b/modules/billing/tests/billing.usage.service.unit.tests.js index a09227e49..aa2086df7 100644 --- a/modules/billing/tests/billing.usage.service.unit.tests.js +++ b/modules/billing/tests/billing.usage.service.unit.tests.js @@ -603,6 +603,105 @@ describe('BillingUsageService — meter extensions unit tests:', () => { }); }); + // ───────────────────────────────────────────────────────────────────────────── + // Credit-balance alerts — plans without a weekly quota (#4117) + // ───────────────────────────────────────────────────────────────────────────── + describe('incrementMeter — credit-balance alerts (no weekly quota, #4117):', () => { + const balanceCrossedEmits = () => + mockBillingEventsEmit.mock.calls.filter(([name]) => name === 'billing.extras.balance_threshold_crossed'); + + test('boundary — post === level fires the crossing (inclusive on post)', async () => { + // signupGrant=500, threshold=80 → level = 0.2 * 500 = 100. pre=150 (post + extrasConsumed), post=100. + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + const updatedDoc = makeUsageDoc({ meterUsed: 50, meterQuota: 0 }); + mockUsageRepository.incrementMeter.mockResolvedValue(updatedDoc); + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: 100 } }); + + await BillingUsageService.incrementMeter(orgId, 50, {}, 'hist_credit_boundary'); + + const emits = balanceCrossedEmits(); + expect(emits).toHaveLength(1); + expect(emits[0][1]).toMatchObject({ organizationId: orgId, threshold: 80, remaining: 100, planId: 'free' }); + }); + + test('two adjacent debits — exactly one crosses (the other stays below the level already)', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); + + // Debit A: pre=110, post=90 → crosses level 80% (=100). + mockExtraService.debit.mockResolvedValueOnce({ applied: true, doc: { cachedBalance: 90 } }); + await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_adjacent_a'); + + // Debit B: pre=90, post=80 → already below the level before this debit — no NEW crossing. + mockExtraService.debit.mockResolvedValueOnce({ applied: true, doc: { cachedBalance: 80 } }); + await BillingUsageService.incrementMeter(orgId, 10, {}, 'hist_adjacent_b'); + + const emits = balanceCrossedEmits(); + expect(emits).toHaveLength(1); + expect(emits[0][1]).toMatchObject({ threshold: 80, remaining: 90 }); + }); + + test('one debit crossing both 80% and 100% emits only the deepest (100)', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 600, meterQuota: 0 })); + // pre = 600 (post + extrasConsumed), post = -50 → crosses level 100% (=0) AND level 80% (=100). + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: -50 } }); + + await BillingUsageService.incrementMeter(orgId, 650, {}, 'hist_double_cross'); + + const emits = balanceCrossedEmits(); + expect(emits).toHaveLength(1); + expect(emits[0][1]).toMatchObject({ threshold: 100, remaining: 0 }); + }); + + test('re-cross after a credit tops the balance back up — alerts again (stateless)', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); + + // First debit crosses level 80% (=100): pre=110, post=90. + mockExtraService.debit.mockResolvedValueOnce({ applied: true, doc: { cachedBalance: 90 } }); + await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_recross_1'); + + // A pack/referral credit (outside incrementMeter) tops the balance back above the level. + // Next debit starts above 100 again and crosses it again: pre=150, post=95. + mockExtraService.debit.mockResolvedValueOnce({ applied: true, doc: { cachedBalance: 95 } }); + await BillingUsageService.incrementMeter(orgId, 55, {}, 'hist_recross_2'); + + const emits = balanceCrossedEmits(); + expect(emits).toHaveLength(2); + expect(emits[0][1]).toMatchObject({ threshold: 80, remaining: 90 }); + expect(emits[1][1]).toMatchObject({ threshold: 80, remaining: 95 }); + }); + + test('quota>0 overflow — no credit-balance event (meterQuota>0 is out of scope)', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'pro' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ meterQuota: 500000, signupGrant: 500 })); + const updatedDoc = makeUsageDoc({ meterUsed: 510000, meterQuota: 500000 }); + mockUsageRepository.incrementMeter.mockResolvedValue(updatedDoc); + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: -5 } }); + + await BillingUsageService.incrementMeter(orgId, 10000, {}, 'hist_quota_overflow_no_credit_alert'); + + expect(balanceCrossedEmits()).toHaveLength(0); + }); + + test('quota=0 plan without a signupGrant — no credit-balance event', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + // No signupGrant on the plan — the guard must not fire. + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 5, meterQuota: 0 })); + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: -5 } }); + + await BillingUsageService.incrementMeter(orgId, 5, {}, 'hist_no_grant_no_alert'); + + expect(balanceCrossedEmits()).toHaveLength(0); + }); + }); + // ───────────────────────────────────────────────────────────────────────────── // runaway negative balance detection (Item 3 — Batch 2) // ───────────────────────────────────────────────────────────────────────────── From 5a827d5d4ea3cc90535d3fb11ee38982a28c25ce Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 12:04:36 +0200 Subject: [PATCH 2/6] docs(billing): fix README's percent-of-grant formula + ERRORS.md note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README documented the level formula as (1 - threshold/100) * signupGrant — the exact form the code comment (previous commit) warns against: it hits float imprecision at common values (500 * (1 - 80/100) = 99.99999999999997, not 100), silently missing an exact boundary crossing. Correct it to signupGrant * (100 - threshold) / 100, and add the ERRORS.md entry. Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- ERRORS.md | 1 + modules/billing/README.md | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ERRORS.md b/ERRORS.md index 0bf024ae7..bcf8c7f91 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -48,3 +48,4 @@ Use this file as a compact memory of recurring AI mistakes. - [2026-09-25] billing/extras: the expiry sweep (`addExpirationEntries`) removed a pack's FULL amount even when part or all of it was already consumed or refunded -> phantom debt the next pack paid twice. Now the ledger is replayed in array order, debits (and a pack's own refunds, matched by `stripeSessionId`) are attributed to live credits earliest-expiry-first (no-expiry credits last, uncovered debt repaid by the next credit), and an expiring pack removes only its own remainder at its `expiresAt`; a fully spent pack gets a zero-amount `expiration` marker (schemas allow 0 for that kind only, hidden from `listLedgerPage`) so the `expire-` guard still holds. The write is one `findOneAndUpdate` guarded by the snapshot's ledger `$size` (append-only ⇒ unchanged length = unchanged ledger), retried on a concurrent write. Legacy full-amount entries are left as is; see pierreb-devkit/Node#4120 - [2026-09-25] billing/stripe: `billing.plans.service.js fetchPlansFromStripe` fell back to the raw Stripe product id (`product.metadata?.planId || product.id`) when a product carried no `planId` metadata -> ANY active Stripe product (a one-time pack, a recurring product sold outside the plans catalogue via a Payment Link) advertised itself as a public plan via `GET /api/billing/plans`, often with a null price id; fix = filter to `product.metadata?.planId` truthy BEFORE mapping, drop the id fallback entirely — a product now needs `metadata.planId` to be listed. Left every OTHER `metadata?.planId ||` fallback untouched (`billing.planResolver.js`, `billing.webhook.service.js`, `billing.admin.service.js`) since those resolve an EXISTING subscription's plan, not the public catalogue, and must keep working for a recurring product sold outside it; see pierreb-devkit/Node#4113 - [2026-09-25] auth: `oauthCallback` never provisioned an org, and review of the first fix caught that gating on `!user.currentOrganization` would also fire for an EXISTING org-less user (removed from org, pending join) on every login -> gate on `info.created` instead (set by `checkOAuthUserProfile`'s create branch, relayed via passport's verify-callback `info`), so only a genuine new signup provisions. `oauthCallback` also needed an outer try/catch (passport invokes it fire-and-forget) and a `headersSent` guard before any fallback redirect; see pierreb-devkit/Node#4115 +- [2026-09-25] billing: computing a percent-of-grant level as `(1 - threshold/100) * signupGrant` (e.g. `500 * (1 - 80/100)`) lands on `99.99999999999997`, not `100`, due to float imprecision -> silently misses an exact `post === level` boundary crossing; use `signupGrant * (100 - threshold) / 100` instead, which is exact at common values; see pierreb-devkit/Node#4117 diff --git a/modules/billing/README.md b/modules/billing/README.md index 3fe7b13bc..47e3176c2 100644 --- a/modules/billing/README.md +++ b/modules/billing/README.md @@ -116,7 +116,9 @@ A plan with `meterQuota: 0` and a one-shot `signupGrant` (e.g. the stack default plan) has no weekly usage doc to alert against — every unit is debited straight from `BillingExtraBalance.cachedBalance`, so that balance IS the limit. `incrementMeter` detects crossings of the configured `billing.alerts.thresholdPercents` (filtered to 80/100, same -supported set as the weekly-quota alerts) against `(1 - threshold/100) * plan.signupGrant`, +supported set as the weekly-quota alerts) against `plan.signupGrant * (100 - threshold) / 100` +(not `(1 - threshold/100) * signupGrant` — that form hits float imprecision at common values, +e.g. `500 * (1 - 80/100) = 99.99999999999997`, silently missing an exact boundary crossing), comparing the debit's own pre/post balance, and emits `billing.extras.balance_threshold_crossed` (`{ organizationId, threshold, remaining, planId }`). `billing.email.js` sends a credit-warning (80%) or credit-exhausted (100%) email off of it. From 8bb3cae6add6a8ec58c06582c47a2f453eb07034 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 12:15:37 +0200 Subject: [PATCH 3/6] test(billing): credit alert integration test + neutral template copy Rewrite billing-credit-warning/exhausted template copy to stack-neutral "usage" wording (was "Runs are paused"/"Runs keep working"), matching the billing-quota-* templates' register. Add an integration test driving incrementMeter on a meterQuota=0, signupGrant plan against the real test DB: a debit crossing the 80% level emits billing.extras.balance_threshold_crossed once with the expected {threshold, remaining}; a later debit that stays above zero emits nothing new. Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- .../templates/billing-credit-exhausted.html | 4 +- config/templates/billing-credit-warning.html | 2 +- ...ing.usage.creditAlert.integration.tests.js | 117 ++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 modules/billing/tests/billing.usage.creditAlert.integration.tests.js diff --git a/config/templates/billing-credit-exhausted.html b/config/templates/billing-credit-exhausted.html index c2768d094..f75373251 100644 --- a/config/templates/billing-credit-exhausted.html +++ b/config/templates/billing-credit-exhausted.html @@ -5,9 +5,9 @@

Hello,

-

Your {{appName}} account is out of credits.

+

Your {{appName}} account is out of credits ({{remaining}} credits left).

- Runs are paused until you add more credits or upgrade your plan — nothing is charged automatically. + Usage is paused until you add more credits or upgrade your plan — nothing is charged automatically.

Add credits or upgrade your plan on your billing dashboard.


diff --git a/config/templates/billing-credit-warning.html b/config/templates/billing-credit-warning.html index f63107d16..1680f0ab8 100644 --- a/config/templates/billing-credit-warning.html +++ b/config/templates/billing-credit-warning.html @@ -7,7 +7,7 @@

Hello,

Your {{appName}} account has {{remaining}} credits left.

- Runs keep working while credits remain. Once they run out, runs pause until you add credits — nothing is charged automatically. + Usage keeps working while credits remain. Once they run out, usage is paused until you add credits — nothing is charged automatically.

Add more credits or upgrade your plan on your billing dashboard.


diff --git a/modules/billing/tests/billing.usage.creditAlert.integration.tests.js b/modules/billing/tests/billing.usage.creditAlert.integration.tests.js new file mode 100644 index 000000000..020d97b4c --- /dev/null +++ b/modules/billing/tests/billing.usage.creditAlert.integration.tests.js @@ -0,0 +1,117 @@ +/** + * Module dependencies. + */ +import mongoose from 'mongoose'; +import { describe, beforeAll, beforeEach, afterAll, afterEach, test, expect, jest } from '@jest/globals'; + +import config from '../../../config/index.js'; +import mongooseService from '../../../lib/services/mongoose.js'; + +/** + * Integration tests for the credit-balance alert (#4117). + * + * On a one-shot signup-grant plan (meterQuota=0), incrementMeter's extras debit is + * checked against the debit's own pre/post balance for a configured percent-of-grant + * crossing (stateless — no alertedAtN dedup field). Validates against the real + * BillingExtraBalance doc + the real billingEvents emitter (spied, not mocked). + */ +describe('BillingUsage credit-balance alert integration tests:', () => { + let BillingUsage; + let BillingExtraBalance; + let Subscription; + let BillingUsageService; + let billingEvents; + let originalMeterMode; + let originalPlanDefinitions; + + beforeAll(async () => { + originalMeterMode = config.billing.meterMode; + originalPlanDefinitions = config.billing.planDefinitions; + config.billing.meterMode = true; + await mongooseService.loadModels(); + await mongooseService.connect(); + + BillingUsage = mongoose.model('BillingUsage'); + BillingExtraBalance = mongoose.model('BillingExtraBalance'); + Subscription = mongoose.model('Subscription'); + + await Subscription.syncIndexes(); + + BillingUsageService = (await import('../services/billing.usage.service.js')).default; + billingEvents = (await import('../lib/events.js')).default; + }); + + beforeEach(async () => { + await Promise.all([ + BillingUsage.deleteMany({}), + BillingExtraBalance.deleteMany({}), + Subscription.deleteMany({}), + ]); + config.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: { scrap: 1 } }, + ]; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + config.billing.meterMode = originalMeterMode; + config.billing.planDefinitions = originalPlanDefinitions; + await mongooseService.disconnect(); + }); + + test('extras debit crossing the 80% signup-grant level emits balance_threshold_crossed once; a later debit that stays above zero emits nothing new', async () => { + const organizationId = new mongoose.Types.ObjectId(); + + await Subscription.create({ organization: organizationId, plan: 'free', status: 'active' }); + + // Seed the org's extras balance at the full signup grant (500), as if freshly credited. + await BillingExtraBalance.create({ + organization: organizationId, + ledger: [{ kind: 'topup', amount: 500, stripeSessionId: 'cs_signup_grant_seed' }], + cachedBalance: 500, + }); + + const emitSpy = jest.spyOn(billingEvents, 'emit'); + const crossedEmits = () => + emitSpy.mock.calls.filter(([name]) => name === 'billing.extras.balance_threshold_crossed'); + + // First debit: 401 units on a meterQuota=0 plan → every unit goes to extras. + // pre=500 (post + extrasConsumed), post=99 → crosses the 80% level (500 * 20 / 100 = 100): + // pre(500) > 100 && post(99) <= 100. + await BillingUsageService.incrementMeter( + organizationId.toString(), + 401, + { scrap: 401 }, + 'step-credit-alert-1', + ); + + expect(crossedEmits()).toHaveLength(1); + expect(crossedEmits()[0][1]).toMatchObject({ + organizationId: organizationId.toString(), + threshold: 80, + remaining: 99, + planId: 'free', + }); + + const balanceAfterFirst = await BillingExtraBalance.findOne({ organization: organizationId }).lean(); + expect(balanceAfterFirst.cachedBalance).toBe(99); + + // Second debit: 50 more units → extrasConsumed=50. pre=99, post=49 — stays above zero. + // pre(99) is already at/below the 80% level (100), so no NEW crossing is emitted, and the + // 100% level (0) is not reached either. + await BillingUsageService.incrementMeter( + organizationId.toString(), + 50, + { scrap: 50 }, + 'step-credit-alert-2', + ); + + expect(crossedEmits()).toHaveLength(1); + + const balanceAfterSecond = await BillingExtraBalance.findOne({ organization: organizationId }).lean(); + expect(balanceAfterSecond.cachedBalance).toBe(49); + }); +}); From 0835292129a4633046563fc8cd12523728ae9ea3 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 12:51:16 +0200 Subject: [PATCH 4/6] test(billing): tighten credit alert tests + doc retry-path limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback /critical-review (Opus, CodeRabbit rate-limited) findings: - the "no signupGrant" unit test used numbers that could never cross regardless of the guard (pre=0); reseed it so it WOULD cross without the guard, and add a signupGrant=0 case — the one that actually isolates the `> 0` half of the check (undefined already short-circuits via NaN arithmetic on its own). Verified both fail with the guard removed, then restored it. - add a duplicate_step replay case: a replayed debit (applied=false) must not re-derive a crossing. - drop the second, duplicated SUPPORTED_CREDIT_ALERT_THRESHOLDS set; filter with the existing thresholdFields map instead (single source). - document the real accepted limitation on a debit that throws outright: the outbox/retry-cron this module's own docs describe was removed (see crons/README.md), so an unreconciled failed debit never reaches the crossing check either — not "reconciled by a retry cron" as originally suggested, since that cron no longer exists. - drop the redundant "(0 credits left)" from the exhausted template. Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- .../templates/billing-credit-exhausted.html | 2 +- modules/billing/README.md | 6 +++ .../billing/services/billing.usage.service.js | 10 ++--- .../tests/billing.usage.service.unit.tests.js | 38 +++++++++++++++++-- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/config/templates/billing-credit-exhausted.html b/config/templates/billing-credit-exhausted.html index f75373251..36723d202 100644 --- a/config/templates/billing-credit-exhausted.html +++ b/config/templates/billing-credit-exhausted.html @@ -5,7 +5,7 @@

Hello,

-

Your {{appName}} account is out of credits ({{remaining}} credits left).

+

Your {{appName}} account is out of credits.

Usage is paused until you add more credits or upgrade your plan — nothing is charged automatically.

diff --git a/modules/billing/README.md b/modules/billing/README.md index 47e3176c2..ff79c1d0a 100644 --- a/modules/billing/README.md +++ b/modules/billing/README.md @@ -134,6 +134,12 @@ credit-warning (80%) or credit-exhausted (100%) email off of it. - **Accepted limitation — expiry and refunds don't alert.** A balance drop from a pack expiring (`crons/billing.extrasExpiration.js`) or a refund (`billing.refund.service.js`) does not go through `incrementMeter`'s debit path, so it never triggers this crossing check. +- **Accepted limitation — a debit that fails outright never alerts either.** The crossing + check only runs on `debitResult.applied && debitResult.doc` (see below); if + `BillingExtraService.debit()` itself throws, the catch block logs a warning and the + usage stays counted but unreconciled — there is no retry cron anymore (the outbox + pattern was dropped, see "Extras debit reliability" below), so that debit never reaches + the crossing check and never alerts. ## Extras debit reliability diff --git a/modules/billing/services/billing.usage.service.js b/modules/billing/services/billing.usage.service.js index 0010336b3..6d48bacd3 100644 --- a/modules/billing/services/billing.usage.service.js +++ b/modules/billing/services/billing.usage.service.js @@ -27,11 +27,6 @@ const thresholdFields = { 100: 'alertedAt100', }; -// Credit-balance alerts (#4117) support only 80%/100% — same supported set as the -// weekly-quota alertedAtN schema fields above (billing.init.js warns at boot on any -// other configured value already; this mirrors that filter for the stateless path). -const SUPPORTED_CREDIT_ALERT_THRESHOLDS = new Set([80, 100]); - /** * @desc Increment a usage counter for the given organization (current month). * Hardens the repository's silent-null anomaly (#3991 follow-up): @@ -247,7 +242,10 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey) // DESC order (100 before 80, from getAlertThresholdPercents()) — emit only the // deepest crossing per debit (one debit crossing both levels → one email). for (const threshold of getAlertThresholdPercents()) { - if (!SUPPORTED_CREDIT_ALERT_THRESHOLDS.has(threshold)) continue; + // Credit-balance alerts support only 80%/100% — reuse the weekly-quota + // alertedAtN schema fields (thresholdFields, above) as the single source + // of truth for the supported set, instead of a second duplicated list. + if (!thresholdFields[threshold]) continue; // `signupGrant * (100 - threshold) / 100`, not `(1 - threshold/100) * signupGrant` — // the latter hits float imprecision at common values (e.g. 500 * (1 - 80/100) = // 99.99999999999997, not 100), which would silently miss an exact boundary crossing. diff --git a/modules/billing/tests/billing.usage.service.unit.tests.js b/modules/billing/tests/billing.usage.service.unit.tests.js index aa2086df7..4e8318556 100644 --- a/modules/billing/tests/billing.usage.service.unit.tests.js +++ b/modules/billing/tests/billing.usage.service.unit.tests.js @@ -689,14 +689,44 @@ describe('BillingUsageService — meter extensions unit tests:', () => { expect(balanceCrossedEmits()).toHaveLength(0); }); - test('quota=0 plan without a signupGrant — no credit-balance event', async () => { + test('quota=0 plan without a signupGrant — would otherwise cross 80%, but the missing-grant guard suppresses it', async () => { mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); - // No signupGrant on the plan — the guard must not fire. + // No signupGrant on the plan. pre=110, post=90 WOULD cross the 80% level (=100 on a + // 500-credit grant, per the 'two adjacent debits' test above) if a signupGrant existed — + // only the missing-signupGrant guard keeps this silent. mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0 })); - mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 5, meterQuota: 0 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: 90 } }); + + await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_no_grant_no_alert'); + + expect(balanceCrossedEmits()).toHaveLength(0); + }); + + test('quota=0 plan with signupGrant=0 — no credit-balance event (a zero grant is not a valid grant)', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + // signupGrant=0 is finite (unlike undefined) but not a valid grant. Without the + // `> 0` half of the guard, level collapses to 0 for every threshold and this debit + // (pre=15, post=-5) WOULD look like a crossing (15 > 0 && -5 <= 0) — only the + // `> 0` check keeps it silent. + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 0 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: -5 } }); - await BillingUsageService.incrementMeter(orgId, 5, {}, 'hist_no_grant_no_alert'); + await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_zero_grant_no_alert'); + + expect(balanceCrossedEmits()).toHaveLength(0); + }); + + test('debit returns applied=false (duplicate_step replay) — no credit-balance event', async () => { + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); + // Idempotent replay — the debit already applied on the original call; this pass must + // not re-derive a crossing (the crossing check only runs on debitResult.applied && doc). + mockExtraService.debit.mockResolvedValue({ applied: false, reason: 'duplicate_step' }); + + await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_credit_alert_replay'); expect(balanceCrossedEmits()).toHaveLength(0); }); From c4b9282c793b044ca0976bc8a03987e35adb4f27 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 13:04:21 +0200 Subject: [PATCH 5/6] test(billing): cover the balance_threshold_crossed emit-failure path Codecov patch check flagged 2 uncovered added lines: the catch block around billingEvents.emit('billing.extras.balance_threshold_crossed', ...) had no test forcing the emit to throw (mirrors the existing coverage of the sibling runaway_debit emit-failure path). Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- .../tests/billing.usage.service.unit.tests.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/modules/billing/tests/billing.usage.service.unit.tests.js b/modules/billing/tests/billing.usage.service.unit.tests.js index 4e8318556..58d1022a1 100644 --- a/modules/billing/tests/billing.usage.service.unit.tests.js +++ b/modules/billing/tests/billing.usage.service.unit.tests.js @@ -730,6 +730,26 @@ describe('BillingUsageService — meter extensions unit tests:', () => { expect(balanceCrossedEmits()).toHaveLength(0); }); + + test('billing.extras.balance_threshold_crossed emit throws — logs error, does not propagate', async () => { + const loggerMod = await import('../../../lib/services/logger.js'); + const mockLoggerError = loggerMod.default.error; + mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); + mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0, signupGrant: 500 })); + mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); + mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: 90 } }); + mockBillingEventsEmit.mockImplementationOnce(() => { + throw new Error('listener exploded'); + }); + + const result = await BillingUsageService.incrementMeter(orgId, 20, {}, 'hist_credit_alert_emit_throws'); + + expect(result.applied).toBe(true); + expect(mockLoggerError).toHaveBeenCalledWith( + '[billing.usage] billing.extras.balance_threshold_crossed listener failed', + expect.objectContaining({ error: 'listener exploded' }), + ); + }); }); // ───────────────────────────────────────────────────────────────────────────── From b56cc683e0c6cbb4e7a14b149459ab89c36c41d9 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 25 Sep 2026 13:10:03 +0200 Subject: [PATCH 6/6] test(billing): correct the no-grant test comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment on the "without a signupGrant" test claimed the missing-signupGrant guard alone kept it silent. Verified experimentally (guard removed, test still passed) that undefined already short-circuits via Number.isFinite/NaN arithmetic on its own — it's the signupGrant=0 case added alongside it that isolates the guard's `> 0` half. Comment was misleading; fix it rather than ship a self-contradicting note. Claude-Session: https://claude.ai/code/session_01TTK9g6SFCfjfuWvB3MLFr3 --- modules/billing/tests/billing.usage.service.unit.tests.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/billing/tests/billing.usage.service.unit.tests.js b/modules/billing/tests/billing.usage.service.unit.tests.js index 58d1022a1..9ed16310f 100644 --- a/modules/billing/tests/billing.usage.service.unit.tests.js +++ b/modules/billing/tests/billing.usage.service.unit.tests.js @@ -692,8 +692,10 @@ describe('BillingUsageService — meter extensions unit tests:', () => { test('quota=0 plan without a signupGrant — would otherwise cross 80%, but the missing-grant guard suppresses it', async () => { mockSubscriptionRepository.findPlan.mockResolvedValue({ plan: 'free' }); // No signupGrant on the plan. pre=110, post=90 WOULD cross the 80% level (=100 on a - // 500-credit grant, per the 'two adjacent debits' test above) if a signupGrant existed — - // only the missing-signupGrant guard keeps this silent. + // 500-credit grant, per the 'two adjacent debits' test above) if a signupGrant existed. + // Here `undefined` is caught by the `Number.isFinite` half of the guard (which also + // short-circuits before `level` is computed from NaN) — the signupGrant=0 case below + // is the one that isolates the `> 0` half specifically. mockPlanService.getActivePlan.mockReturnValue(makePlan({ planId: 'free', meterQuota: 0 })); mockUsageRepository.incrementMeter.mockResolvedValue(makeUsageDoc({ meterUsed: 20, meterQuota: 0 })); mockExtraService.debit.mockResolvedValue({ applied: true, doc: { cachedBalance: 90 } });