diff --git a/codecov.yml b/codecov.yml index f4dbe1d14..3c0943012 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,15 +2,15 @@ coverage: status: project: default: - target: auto - threshold: 0.5% + target: 80% # hard floor — prevents long-term coverage decay (auto allows drift) + threshold: 0.5% # allow ≤0.5% transient drop within the 80% floor flags: - unit - integration patch: default: - target: auto - threshold: 0% + target: auto # new code must match local module baseline (not abstract 80%) + threshold: 0% # no tolerance on new code flags: - unit - integration diff --git a/jest.config.js b/jest.config.js index db49d9775..b07f72bf7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -67,9 +67,13 @@ export default { // A list of reporter names that Jest uses when writing coverage reports coverageReporters: ['json', 'lcov', 'clover', 'text'], - // coverageThreshold removed — coverage gating moved to Codecov status checks - // (unit + integration flags merged server-side; per-job thresholds would fail - // since each job covers only its slice of source paths) + // coverageThreshold removed — coverage gating moved to Codecov status checks. + // In the split test matrix (unit / integration / e2e), each CI job only sees + // its slice of `collectCoverageFrom`, so a global Jest threshold would fail + // in every job (61% instead of 80% because only one suite's paths are loaded). + // Codecov merges the unit + integration flags server-side and applies an 80% + // project floor there (see codecov.yml status.project). This was the design + // since efd7bbc7 (2026-05-01) — not a loosening of coverage enforcement. // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, diff --git a/modules/billing/billing.init.js b/modules/billing/billing.init.js index 6ec99ca72..9b05f7dae 100644 --- a/modules/billing/billing.init.js +++ b/modules/billing/billing.init.js @@ -4,6 +4,7 @@ import mongoose from 'mongoose'; import config from '../../config/index.js'; import AnalyticsService from '../../lib/services/analytics.js'; +import logger from '../../lib/services/logger.js'; import billingEvents from './lib/events.js'; import BillingUsageRepository from './repositories/billing.usage.repository.js'; import { getAlertThresholdPercents } from './lib/billing.constants.js'; @@ -48,6 +49,71 @@ export default async (app) => { } }); + // Ops alerting — real-money events that require immediate human review. + // + // NOTE: devkit has no ntfy helper; the structured logger is the alert sink here. + // Downstream projects (e.g. trawl_node) wire the actual ntfy push by re-listening + // on the same billingEvents singleton and calling their own ntfy service. + // Priority annotations below document the intended ntfy priority for downstream use. + + // billing.dispute.opened — priority 5 (urgent): 7-day evidence window starts now. + // Downstream projects (e.g. trawl_node) re-listen on billingEvents for ntfy push. + billingEvents.on('billing.dispute.opened', (payload) => { + const { disputeId, chargeId, organizationId, stripeSessionId, amount, reason } = payload; + logger.error('[billing.init] ALERT: dispute opened — 7-day evidence window — manual review required', { + disputeId, + chargeId, + organizationId, + stripeSessionId, + amount, + reason, + ntfyPriority: 5, + }); + }); + + // billing.dispute.lost — priority 5 (urgent): funds withdrawn, ledger already debited. + billingEvents.on('billing.dispute.lost', (payload) => { + const { disputeId, chargeId, organizationId, stripeSessionId, amount } = payload; + logger.error('[billing.init] ALERT: dispute lost — funds withdrawn — ledger debited', { + disputeId, + chargeId, + organizationId, + stripeSessionId, + amount, + ntfyPriority: 5, + }); + }); + + // billing.refund.unresolved — priority 4 (high): unresolvable refund needs manual reconciliation. + billingEvents.on('billing.refund.unresolved', (payload) => { + logger.error('[billing.init] ALERT: refund unresolved — manual reconciliation required', { + ...payload, + ntfyPriority: 4, + }); + }); + + // billing.reconciliation.divergence — priority 4 (high): DB vs Stripe plan/status mismatch. + billingEvents.on('billing.reconciliation.divergence', (payload) => { + const { organizationId, subscriptionId, stripeSubscriptionId, db, stripe, statusMismatch, planMismatch } = payload; + logger.error('[billing.init] ALERT: reconciliation divergence — DB vs Stripe mismatch', { + organizationId, + subscriptionId, + stripeSubscriptionId, + db, + stripe, + statusMismatch, + planMismatch, + ntfyPriority: 4, + }); + }); + + // Prevent accidental crash if any future code emits 'error' with no listener + // (Node default behaviour: throws if no 'error' listener is registered). + // Registered here (after config is ready) so events.js stays config-free and importable without ordering hazards. + billingEvents.on('error', (err) => { + logger.error('[billingEvents] uncaught error event', { err }); + }); + // Boot validator: check for legacy migration state before enabling meterMode. if (config?.billing?.meterMode) { const legacyUsageCount = await BillingUsageRepository.countLegacyConsumedHistoryIds(); diff --git a/modules/billing/lib/events.js b/modules/billing/lib/events.js index 9bd929608..0ad41a052 100644 --- a/modules/billing/lib/events.js +++ b/modules/billing/lib/events.js @@ -18,6 +18,9 @@ import { EventEmitter } from 'events'; * - `billing.refund.unresolved` — emitted when a charge.refunded event cannot be correlated * to a known session/org (missing metadata on charge AND PaymentIntent, or ambiguous pack). * Payload: { chargeId, paymentIntentId, refundAmount } | { reason, orgId, stripeSessionId, amountRefundedCents } + * + * 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. */ const billingEvents = new EventEmitter(); diff --git a/modules/billing/services/billing.extra.service.js b/modules/billing/services/billing.extra.service.js index 7f575f64e..b7d17b509 100644 --- a/modules/billing/services/billing.extra.service.js +++ b/modules/billing/services/billing.extra.service.js @@ -120,10 +120,7 @@ const refundPartial = async (orgId, stripeSessionId, amountRefundedCents, packId stripeRefundId, }); } catch (evtErr) { - logger.error('[billing.extra] billing.refund.unresolved listener error (non-fatal)', { - error: evtErr?.message ?? String(evtErr), - stack: evtErr?.stack, - }); + logger.error('[billing.extra] refund.unresolved listener failed', { err: evtErr }); } return { doc: null, applied: false, reason: 'sentinel_unresolved', refundUnits: 0 }; } @@ -171,7 +168,7 @@ const refundPartial = async (orgId, stripeSessionId, amountRefundedCents, packId amountRefundedCents, }); } catch (evtErr) { - console.error('[billing.extra] billing.refund.unresolved listener error (non-fatal):', evtErr?.message ?? evtErr); + logger.error('[billing.extra] refund.unresolved listener failed', { err: evtErr }); } return { doc, applied: false, reason: 'ambiguous_pack_match', refundUnits: 0 }; } diff --git a/modules/billing/tests/billing.extra.service.unit.tests.js b/modules/billing/tests/billing.extra.service.unit.tests.js index e51269031..15434908d 100644 --- a/modules/billing/tests/billing.extra.service.unit.tests.js +++ b/modules/billing/tests/billing.extra.service.unit.tests.js @@ -522,10 +522,10 @@ describe('BillingExtraService refundPartial — sentinel listener-error swallow: // Listener error must NOT propagate — sentinel return shape preserved expect(result).toEqual({ doc: null, applied: false, reason: 'sentinel_unresolved', refundUnits: 0 }); - // Inner catch must have logged the listener error (non-fatal) + // Inner catch must have logged the listener error via structured logger (non-fatal) expect(mockLogger.error).toHaveBeenCalledWith( - '[billing.extra] billing.refund.unresolved listener error (non-fatal)', - expect.objectContaining({ error: 'listener blew' }), + '[billing.extra] refund.unresolved listener failed', + expect.objectContaining({ err: expect.objectContaining({ message: 'listener blew' }) }), ); }); }); diff --git a/modules/billing/tests/billing.init.ops-listeners.unit.tests.js b/modules/billing/tests/billing.init.ops-listeners.unit.tests.js new file mode 100644 index 000000000..ee2dfa4bf --- /dev/null +++ b/modules/billing/tests/billing.init.ops-listeners.unit.tests.js @@ -0,0 +1,283 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.init — ops alerting listeners. + * + * Covers: + * - billing.dispute.opened (priority 5) + * - billing.dispute.lost (priority 5) + * - billing.refund.unresolved (priority 4) + * - billing.reconciliation.divergence (priority 4) + * - error event listener on billingEvents singleton + * + * Each listener must: + * 1. Call logger.error with the correct message and structured payload. + * 2. Swallow alert-sink failures without crashing the EventEmitter. + * 3. Not propagate errors when the logger itself throws. + */ +describe('billing.init ops-listeners unit tests:', () => { + let billingInit; + let mockLogger; + let realBillingEvents; + + const mockApp = {}; + + /** + * Wire minimal mocks for billing.init dependencies, import the module, and + * run billingInit. Sets `mockLogger` and `realBillingEvents` in the outer + * scope as side effects so individual tests can emit events and assert on + * the logger. + * + * @param {Object} [options={}] - Setup options. + * @param {Object} [options.loggerOverrides={}] - Method overrides merged into the mock logger. + * @returns {Promise} + */ + const setup = async ({ loggerOverrides = {} } = {}) => { + jest.resetModules(); + + mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + ...loggerOverrides, + }; + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { billing: { meterMode: false, packs: [] } }, + })); + + jest.unstable_mockModule('../repositories/billing.usage.repository.js', () => ({ + default: { countLegacyConsumedHistoryIds: jest.fn().mockResolvedValue(0) }, + })); + + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { groupIdentify: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: mockLogger, + })); + + jest.unstable_mockModule('mongoose', () => ({ + default: { + model: jest.fn().mockReturnValue({ distinct: jest.fn().mockResolvedValue([]) }), + }, + })); + + // Import billing.init first — it registers listeners on the real billingEvents singleton. + const mod = await import('../billing.init.js'); + billingInit = mod.default; + + // Grab the real singleton (same module instance, not a mock) for emit calls. + const eventsModule = await import('../lib/events.js'); + realBillingEvents = eventsModule.default; + + // Run init to wire the listeners. + await billingInit(mockApp); + }; + + afterEach(() => { + if (realBillingEvents) { + realBillingEvents.removeAllListeners('billing.dispute.opened'); + realBillingEvents.removeAllListeners('billing.dispute.lost'); + realBillingEvents.removeAllListeners('billing.refund.unresolved'); + realBillingEvents.removeAllListeners('billing.reconciliation.divergence'); + realBillingEvents.removeAllListeners('error'); + } + jest.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // billing.dispute.opened + // --------------------------------------------------------------------------- + describe('billing.dispute.opened listener:', () => { + test('fires logger.error with correct fields on dispute.opened event', async () => { + await setup(); + + const payload = { + disputeId: 'dp_123', + chargeId: 'ch_abc', + organizationId: '507f1f77bcf86cd799439011', + stripeSessionId: 'cs_live_xyz', + amount: 4900, + reason: 'fraudulent', + }; + + realBillingEvents.emit('billing.dispute.opened', payload); + + // Give the async listener a tick to execute. + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.init] ALERT: dispute opened — 7-day evidence window — manual review required', + expect.objectContaining({ + disputeId: 'dp_123', + chargeId: 'ch_abc', + organizationId: '507f1f77bcf86cd799439011', + amount: 4900, + reason: 'fraudulent', + ntfyPriority: 5, + }), + ); + }); + + test('dispute.opened: emitting the event does not throw synchronously', async () => { + await setup(); + + const payload = { disputeId: 'dp_ok', chargeId: 'ch_ok', organizationId: 'org', stripeSessionId: 'cs', amount: 100, reason: 'x' }; + + // EventEmitter.emit() is sync — async listener rejection must not propagate synchronously. + expect(() => realBillingEvents.emit('billing.dispute.opened', payload)).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + // --------------------------------------------------------------------------- + // billing.dispute.lost + // --------------------------------------------------------------------------- + describe('billing.dispute.lost listener:', () => { + test('fires logger.error with correct fields on dispute.lost event', async () => { + await setup(); + + const payload = { + disputeId: 'dp_456', + chargeId: 'ch_def', + organizationId: '507f1f77bcf86cd799439022', + stripeSessionId: 'cs_live_zzz', + amount: 9900, + }; + + realBillingEvents.emit('billing.dispute.lost', payload); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.init] ALERT: dispute lost — funds withdrawn — ledger debited', + expect.objectContaining({ + disputeId: 'dp_456', + chargeId: 'ch_def', + amount: 9900, + ntfyPriority: 5, + }), + ); + }); + + test('dispute.lost: emitting the event does not throw synchronously', async () => { + await setup(); + + const payload = { disputeId: 'dp_ok2', chargeId: 'ch_ok2', organizationId: 'org', stripeSessionId: 'cs', amount: 50 }; + expect(() => realBillingEvents.emit('billing.dispute.lost', payload)).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + // --------------------------------------------------------------------------- + // billing.refund.unresolved + // --------------------------------------------------------------------------- + describe('billing.refund.unresolved listener:', () => { + test('fires logger.error with full payload spread + ntfyPriority 4', async () => { + await setup(); + + const payload = { chargeId: 'ch_ref', paymentIntentId: 'pi_ref', refundAmount: 1500 }; + + realBillingEvents.emit('billing.refund.unresolved', payload); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.init] ALERT: refund unresolved — manual reconciliation required', + expect.objectContaining({ + chargeId: 'ch_ref', + paymentIntentId: 'pi_ref', + refundAmount: 1500, + ntfyPriority: 4, + }), + ); + }); + + test('refund.unresolved: emitting the event does not throw synchronously', async () => { + await setup(); + + expect(() => realBillingEvents.emit('billing.refund.unresolved', { chargeId: 'ch_ok3' })).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + // --------------------------------------------------------------------------- + // billing.reconciliation.divergence + // --------------------------------------------------------------------------- + describe('billing.reconciliation.divergence listener:', () => { + test('fires logger.error with db/stripe divergence fields + ntfyPriority 4', async () => { + await setup(); + + const payload = { + organizationId: '507f1f77bcf86cd799439033', + subscriptionId: '507f1f77bcf86cd799439044', + stripeSubscriptionId: 'sub_live_aaa', + db: { status: 'active', plan: 'growth' }, + stripe: { status: 'past_due', plan: 'growth' }, + statusMismatch: true, + planMismatch: false, + }; + + realBillingEvents.emit('billing.reconciliation.divergence', payload); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.init] ALERT: reconciliation divergence — DB vs Stripe mismatch', + expect.objectContaining({ + organizationId: '507f1f77bcf86cd799439033', + statusMismatch: true, + planMismatch: false, + ntfyPriority: 4, + }), + ); + }); + + test('reconciliation.divergence: emitting the event does not throw synchronously', async () => { + await setup(); + + const payload = { organizationId: 'org', subscriptionId: 's1', stripeSubscriptionId: 'sub_x', db: {}, stripe: {}, statusMismatch: true, planMismatch: false }; + expect(() => realBillingEvents.emit('billing.reconciliation.divergence', payload)).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + }); + }); + + // --------------------------------------------------------------------------- + // Multiple listeners all fire (sanity) + // --------------------------------------------------------------------------- + test('multiple ops listeners all fire independently on their respective events', async () => { + await setup(); + + realBillingEvents.emit('billing.dispute.opened', { disputeId: 'dp_a', chargeId: 'ch_a', organizationId: 'o', stripeSessionId: 'cs', amount: 100, reason: 'r' }); + realBillingEvents.emit('billing.dispute.lost', { disputeId: 'dp_b', chargeId: 'ch_b', organizationId: 'o', stripeSessionId: 'cs', amount: 200 }); + realBillingEvents.emit('billing.refund.unresolved', { chargeId: 'ch_c' }); + realBillingEvents.emit('billing.reconciliation.divergence', { organizationId: 'o', subscriptionId: 's', stripeSubscriptionId: 'sub_x', db: {}, stripe: {}, statusMismatch: true, planMismatch: false }); + + await new Promise((resolve) => setImmediate(resolve)); + + // 4 listeners, 1 logger.error call each = 4 total + expect(mockLogger.error).toHaveBeenCalledTimes(4); + }); + + // --------------------------------------------------------------------------- + // error event listener on billingEvents singleton + // --------------------------------------------------------------------------- + describe('billingEvents error listener (events.js):', () => { + test('emitting error event calls logger.error and does not crash', async () => { + await setup(); + + const err = new Error('unexpected emitter error'); + + // Without the listener this would throw; with it, it must not. + expect(() => realBillingEvents.emit('error', err)).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[billingEvents] uncaught error event', + expect.objectContaining({ err }), + ); + }); + }); +}); diff --git a/modules/billing/tests/billing.init.unit.tests.js b/modules/billing/tests/billing.init.unit.tests.js index d08af1d79..9c2a76496 100644 --- a/modules/billing/tests/billing.init.unit.tests.js +++ b/modules/billing/tests/billing.init.unit.tests.js @@ -42,11 +42,15 @@ describe('billing.init unit tests:', () => { default: mockBillingUsageRepository, })); - // Stub analytics and events to avoid side effects + // Stub analytics, logger and events to avoid side effects jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ default: { groupIdentify: jest.fn() }, })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + jest.unstable_mockModule('../lib/events.js', () => ({ default: { on: jest.fn(), emit: jest.fn() }, }));