Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions modules/billing/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
7 changes: 2 additions & 5 deletions modules/billing/services/billing.extra.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 };
}
Expand Down
6 changes: 3 additions & 3 deletions modules/billing/tests/billing.extra.service.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }) }),
);
});
});
Loading
Loading