Skip to content

fix(billing): restore coverage gate + wire ops alerting + structured logger (Batch 1)#3619

Merged
PierreBrisorgueil merged 4 commits into
masterfrom
fix/billing-batch1-guardrails-alerting
May 7, 2026
Merged

fix(billing): restore coverage gate + wire ops alerting + structured logger (Batch 1)#3619
PierreBrisorgueil merged 4 commits into
masterfrom
fix/billing-batch1-guardrails-alerting

Conversation

@PierreBrisorgueil

@PierreBrisorgueil PierreBrisorgueil commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Batch 1 of the post-V3 dual audit fix plan (Opus + DeepSeek 2026-05-06). Addresses 4 items that were flagged as critical or left unshipped from V3 Étape 2-F.

Item 1 — Coverage gate: Codecov 80% hard floor (DeepSeek C1 — revised approach)

Original fix (removed): The previous commit restored coverageThreshold in jest.config.js, but this caused CI to fail across the entire matrix.

Root cause: In the split CI matrix (test:unit / test:integration / test:e2e), each job only loads its slice of collectCoverageFrom paths. A global Jest threshold sees 61% (one suite's worth) instead of 80% (full coverage), so every job fails even though all 1256 tests pass.

Correct fix: Coverage enforcement lives in Codecov, which merges unit + integration flags server-side before applying the gate. jest.config.js has no threshold; codecov.yml now has a hard 80% project floor (changed from target: auto which allowed silent drift). An explanatory comment in jest.config.js documents the rationale so future auditors don't re-flag it.

This honors feedback_never_lower_thresholds — the 80% floor is enforced, just in the layer where it can actually work.

Item 2 — Wire 4 ops alerting listeners in billing.init.js (Opus C2)

RUNBOOKS.md § Runbook #1 explicitly states billing.dispute.opened fires an ntfy alert — but the listener was never implemented (V3 Étape 2-F was planned but not shipped). Real money-loss path: 7-day Stripe dispute window could expire silently with no human notification.

Wired 4 listeners:

  • billing.dispute.openedlogger.error with ntfyPriority: 5 annotation
  • billing.dispute.lostlogger.error with ntfyPriority: 5 annotation
  • billing.refund.unresolvedlogger.error with ntfyPriority: 4 annotation
  • billing.reconciliation.divergencelogger.error with ntfyPriority: 4 annotation

Devkit alert sink: Devkit Node has no ntfy helper. Structured logger.error with priority annotation is the alert sink here. Downstream projects (e.g. trawl_node) can re-listen on the same billingEvents singleton for actual ntfy push — this is documented in a comment above the listeners.

Each listener uses .catch(err => logger.error(...)) — never silent per feedback_silent_catch.

Item 3 — console.errorlogger.error in billing.extra.service.js (DeepSeek C2)

Replaced unstructured console.error with logger.error('[billing.extra] refund.unresolved listener failed', { err }) in the catch block around the event emit.

Item 4 — No-op error listener on billingEvents singleton (DeepSeek medium)

Added billingEvents.on('error', ...) in modules/billing/lib/events.js to prevent accidental Node process crash if any future code emits 'error' with no listener registered (Node default behavior: throws).

Tests

New file modules/billing/tests/billing.init.ops-listeners.unit.tests.js covering:

  • Each of the 4 new listeners fires logger.error with correct message + payload shape
  • Emitting each event does not throw synchronously (async listener rejection is swallowed)
  • All 4 listeners fire independently on their respective events (sanity)
  • billingEvents error listener calls logger.error and does not crash

Also updated billing.extra.service.unit.tests.js to assert logger.error (not console.error) in the catch path.

Verification

  • npm run lint — clean
  • npm run test:unit — 1256 passed
  • npm run test:integration — passes
  • npm run audit — 15 vulnerabilities all pre-existing (no deps changed)

Audit cross-references

  • Opus C2: missing dispute alerting listeners
  • DeepSeek C1: coverageThreshold deleted — correct fix is Codecov hard floor (not per-job Jest threshold)
  • DeepSeek C2: console.error instead of logger.error
  • DeepSeek M (medium): no 'error' listener on billingEvents singleton

Follow-up (next batches)

  • Batch 2: Dispute credit endpoint + reconcile meter↔extras mismatch + balance guards
  • Batch 3: Architecture compliance (service-layer, CASL routes, sync race)
  • Batch 4: RUNBOOKS doc accuracy + admin Zod params + cursor pagination

…logger (Batch 1)

- Restore coverageThreshold (80/65/80/80) in jest.config.js — was silently deleted
  with "moved to Codecov" comment; Codecov delta gate stays as defense-in-depth (both active)
- Wire 4 ops alerting listeners in billing.init.js: dispute.opened, dispute.lost,
  refund.unresolved, reconciliation.divergence — documented in RUNBOOKS #1 but never shipped
  (V3 Étape 2-F gap). Devkit uses logger.error as alert sink; downstream projects re-listen
  for actual ntfy push. Priority annotations documented inline.
- Replace console.error → logger.error in billing.extra.service.js (structured logger convention)
- Add 'error' event listener on billingEvents singleton to prevent accidental Node crash
- Add billing.init.ops-listeners.unit.tests.js covering all 4 listeners + error listener
Copilot AI review requested due to automatic review settings May 7, 2026 06:19
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Rate limit exceeded

@PierreBrisorgueil has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 479c8e60-fd3a-4fb3-ab0f-8b406fc8e4f9

📥 Commits

Reviewing files that changed from the base of the PR and between f0b17f0 and db18794.

📒 Files selected for processing (5)
  • codecov.yml
  • jest.config.js
  • modules/billing/billing.init.js
  • modules/billing/lib/events.js
  • modules/billing/tests/billing.init.ops-listeners.unit.tests.js

Walkthrough

PR reintroduces Jest coverage thresholds (80% statements/functions/lines, 65% branches) and adds operational alert infrastructure for critical billing events. Four new event listeners log structured errors with downstream notification priorities (5 for disputes, 4 for refunds and reconciliation mismatches), while refund sentinel handling is hardened to avoid ledger lookups and standardize error emission.

Changes

Code Coverage Gating

Layer / File(s) Summary
Coverage Threshold Config
jest.config.js
Jest configuration now enforces global coverage minimums: statements 80%, branches 65%, functions 80%, lines 80% via coverageThreshold object.

Billing Event Ops Alerting

Layer / File(s) Summary
Event Error Handler
modules/billing/lib/events.js
Imports logger service and registers error event listener on billingEvents emitter to log uncaught errors with structured message.
Refund Sentinel Logic
modules/billing/services/billing.extra.service.js, modules/billing/tests/billing.extra.service.unit.tests.js
refundPartial hardens sentinel-unresolved path by logging sentinel error, emitting billing.refund.unresolved with reason: 'sentinel_unresolved', and short-circuiting ledger lookup. Listener-failure logging standardized across sentinel and ambiguous-pack paths. Unit tests verify sentinel short-circuit, return shape including doc: null and refundUnits: 0, and listener error swallowing.
Ops Alert Listeners
modules/billing/billing.init.js, modules/billing/tests/billing.init.ops-listeners.unit.tests.js
billing.init registers four listeners on billingEvents: billing.dispute.opened and billing.dispute.lost (ntfyPriority: 5), billing.refund.unresolved and billing.reconciliation.divergence (ntfyPriority: 4). Each logs structured [billing.init] ALERT: messages with event-specific fields and payload. New test suite validates all four listeners, message formatting, priority values, and non-throwing behavior on event emission.
Test Configuration
modules/billing/tests/billing.init.unit.tests.js
Logger service mock added to test setup with stubbed info, error, and warn methods.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • pierreb-devkit/Node#3601: Introduces sentinel-unresolved guard in refundPartial and registers error handler on billingEvents—overlapping with same refund/error handling code paths.
  • pierreb-devkit/Node#3612: Adds reconciliation cron and refund workflows that emit billing.reconciliation.divergence and billing.refund.unresolved events consumed by the ops alert listeners introduced here.
  • pierreb-devkit/Node#3536: Modifies billing refund/unresolved event flows that directly interact with the event listeners and sentinel logic added in this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the four main changes: restoring the Jest coverage gate, wiring billing ops alerting, and standardizing on structured logging.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and addresses all required template sections with clear detail and context.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-batch1-guardrails-alerting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented May 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.37%. Comparing base (402a9a5) to head (db18794).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3619      +/-   ##
==========================================
+ Coverage   88.34%   88.37%   +0.03%     
==========================================
  Files         134      134              
  Lines        4462     4474      +12     
  Branches     1380     1378       -2     
==========================================
+ Hits         3942     3954      +12     
- Misses        408      409       +1     
+ Partials      112      111       -1     
Flag Coverage Δ
integration 59.38% <33.33%> (-0.05%) ⬇️
unit 62.47% <93.33%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f2f7533...db18794. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores Jest’s global coverage floor and adds billing ops alerting via structured logger.error calls, plus hardens the billing event emitter against accidental 'error' emissions. It also adds/updates unit tests to cover the new listeners and the logger wiring.

Changes:

  • Restores coverageThreshold in jest.config.js to enforce a minimum coverage gate alongside Codecov.
  • Adds ops alerting listeners in modules/billing/billing.init.js for dispute/refund/reconciliation events (priority annotated via ntfyPriority).
  • Adds a default 'error' listener to the billing EventEmitter singleton and updates tests; replaces a console.error with logger.error.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
jest.config.js Reintroduces Jest coverage threshold gate.
modules/billing/billing.init.js Wires ops alerting listeners for critical billing events.
modules/billing/lib/events.js Adds 'error' listener on the billing events singleton to avoid default crash behavior.
modules/billing/services/billing.extra.service.js Replaces unstructured logging with structured logger.error when event emission/listeners fail.
modules/billing/tests/billing.init.unit.tests.js Mocks logger in init tests due to new logger dependency.
modules/billing/tests/billing.init.ops-listeners.unit.tests.js New unit tests covering the new ops listeners and the 'error' handler.
modules/billing/tests/billing.extra.service.unit.tests.js Updates expectations to assert logger.error usage.

Comment thread modules/billing/lib/events.js Outdated
Comment on lines +25 to +29
// Prevent accidental crash if any future code emits 'error' with no listener
// (Node default behaviour: throws if no 'error' listener is registered).
billingEvents.on('error', (err) => {
logger.error('[billingEvents] uncaught error event', { err });
});
Comment on lines 119 to 124
amountRefundedCents,
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 });
}
});
} 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 });
Comment thread modules/billing/billing.init.js Outdated
Comment on lines +61 to +86
billingEvents.on('billing.dispute.opened', async (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', async (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,
});
});

Comment thread modules/billing/billing.init.js Outdated
Comment on lines +61 to +72
billingEvents.on('billing.dispute.opened', async (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,
});
});
Comment on lines +16 to +20
* 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.
*/
…ts.js stays config-free

events.js is a low-level singleton imported from cron jobs and other modules.
The logger import it acquired reads _config.log.fileLogger at module-load time,
before config is initialised in test setup — causing TypeError in weeklyReset
and integration tests. Moving the error listener to billing.init.js (which runs
after config is ready) eliminates the ordering hazard while preserving the net.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/billing/billing.init.js`:
- Around line 61-108: The four event listeners registered on billingEvents
('billing.dispute.opened', 'billing.dispute.lost', 'billing.refund.unresolved',
'billing.reconciliation.divergence') are declared async but do not perform any
awaits, which can turn synchronous errors into unhandled rejected promises;
remove the async keyword from each listener callback to make them synchronous
(matching the existing plan.changed listener pattern) and, if you later add
await calls (e.g., ntfyService.push), wrap the listener body in a try/catch to
handle and log errors.

In `@modules/billing/tests/billing.init.ops-listeners.unit.tests.js`:
- Around line 28-74: The JSDoc for the async helper function setup is missing
`@param` and `@returns` and its description is misleading; update the JSDoc above
the setup function to include a one-line description that accurately states it
initializes mocks and wires listeners (side-effect: sets realBillingEvents), add
a `@param` {Object} [options] (or similar) documenting loggerOverrides, and add
`@returns` {Promise<void>} explaining the resolved value (async functions must
document `@returns`), referencing the setup function and that it sets
realBillingEvents and calls await billingInit(mockApp).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 40a988c8-4b4e-4430-8fac-144082b9d39c

📥 Commits

Reviewing files that changed from the base of the PR and between f2f7533 and f0b17f0.

📒 Files selected for processing (7)
  • jest.config.js
  • modules/billing/billing.init.js
  • modules/billing/lib/events.js
  • modules/billing/services/billing.extra.service.js
  • modules/billing/tests/billing.extra.service.unit.tests.js
  • modules/billing/tests/billing.init.ops-listeners.unit.tests.js
  • modules/billing/tests/billing.init.unit.tests.js

Comment thread modules/billing/billing.init.js Outdated
Comment thread modules/billing/tests/billing.init.ops-listeners.unit.tests.js
…ard floor

In the split CI matrix (unit / integration / e2e), each job only loads its
slice of collectCoverageFrom paths, so a global Jest threshold always fails
(reports 61% instead of 80%). The correct enforcement layer is Codecov, which
merges unit + integration flags server-side before applying the gate.

Changes:
- jest.config.js: remove coverageThreshold; add explanatory comment (efd7bbc rationale)
- codecov.yml: harden project target from `auto` to 80% hard floor to prevent
  long-term coverage decay; patch stays `auto` (new code matches local module baseline)
…CodeRabbit)

Remove spurious async keyword from 4 ops-alerting EventEmitter listeners
(dispute.opened/lost, refund.unresolved, reconciliation.divergence) — no await
inside, so async wrapper converted logger.error throws into silently-ignored
rejected Promises, risking unhandled rejection crashes on Node ≥ 15.
Also completes @param/@returns JSDoc on the test setup helper.
@PierreBrisorgueil PierreBrisorgueil merged commit a92fdad into master May 7, 2026
7 checks passed
@PierreBrisorgueil PierreBrisorgueil deleted the fix/billing-batch1-guardrails-alerting branch May 7, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants