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/config/templates/billing-credit-exhausted.html b/config/templates/billing-credit-exhausted.html new file mode 100644 index 000000000..36723d202 --- /dev/null +++ b/config/templates/billing-credit-exhausted.html @@ -0,0 +1,18 @@ + + + + + + +

Hello,

+

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

+

+ 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.

+
+

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..1680f0ab8 --- /dev/null +++ b/config/templates/billing-credit-warning.html @@ -0,0 +1,18 @@ + + + + + + +

Hello,

+

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

+

+ 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.

+
+

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..ff79c1d0a 100644 --- a/modules/billing/README.md +++ b/modules/billing/README.md @@ -110,6 +110,37 @@ 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 `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. + +- **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. +- **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 `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..6d48bacd3 100644 --- a/modules/billing/services/billing.usage.service.js +++ b/modules/billing/services/billing.usage.service.js @@ -92,6 +92,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 +221,51 @@ 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()) { + // 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. + 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.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); + }); +}); diff --git a/modules/billing/tests/billing.usage.service.unit.tests.js b/modules/billing/tests/billing.usage.service.unit.tests.js index a09227e49..9ed16310f 100644 --- a/modules/billing/tests/billing.usage.service.unit.tests.js +++ b/modules/billing/tests/billing.usage.service.unit.tests.js @@ -603,6 +603,157 @@ 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 — 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. + // 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 } }); + + 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, 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); + }); + + 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' }), + ); + }); + }); + // ───────────────────────────────────────────────────────────────────────────── // runaway negative balance detection (Item 3 — Batch 2) // ─────────────────────────────────────────────────────────────────────────────