Skip to content

fix(billing): mega hardening v3 — atomicity + DRY + config#3583

Merged
PierreBrisorgueil merged 3 commits into
masterfrom
fix/billing-hardening-v3
May 3, 2026
Merged

fix(billing): mega hardening v3 — atomicity + DRY + config#3583
PierreBrisorgueil merged 3 commits into
masterfrom
fix/billing-hardening-v3

Conversation

@PierreBrisorgueil

Copy link
Copy Markdown
Contributor

Summary

Comprehensive billing module hardening bundling 3 logical groupings on overlapping surfaces. Goal: module the simplest, cleanest, most configurable possible.

Source: 4-agent audit 2026-05-02 found 22 backend findings (1 🔴 + 7 🟠 + 8 🟡 + 4 🔵 + 2 ⚪).

Sections

A — Atomicity hardening (🔴 + 7 issues)

  • markFailedAttempt: single atomic aggregation pipeline op — closes TOCTOU where 2 concurrent crons both win the $inc phase then both flip status: failed, double-emitting billing.extras_debit.exhausted
  • incrementMeterWithOutbox: try/catch + E11000 fetch for outbox create crash recovery — closes free compute leak where meter incremented but outbox row missing
  • Zero-cost attribute(): now writes idempotency key even with units=0 — closes double-charge replay when later retry has non-zero costs
  • forceRotateForPlanChange: planChangeResetTriggered set BEFORE try — closes double-rotate via fallback resetWeek on throw
  • E11000 retry: legacy consumedHistoryIds filter added symmetrically to retry branch
  • unitsFromCosts: null ratioVersion + non-null costs → hard-fail in meterMode, warn otherwise
  • BillingMeterOutbox: timestamps:true, drop manual createdAt
  • billing.policy.js: drop stale ESLint no-unused-vars disable

B — DRY + lib extraction (3 🟠 + 5 🟡 + 3 🔵)

  • billing.webhook.controller: all 3 raw res.status().json()responses.error() canonical envelope
  • billing.controller: direct repo call → BillingExtraService.getOrgBalanceContext() (layering fix)
  • _ensureStripeCustomer: extracted private helper deduplicating ~50 lines of find-or-create logic between createCheckout + createExtrasCheckout
  • billing.processedStripeEvent.schema.js: NEW — Zod schema parity with Mongoose model
  • lib/billing.isoWeek.js: NEW — currentWeekKey() + isoWeekKey(date) single source (was duplicated in usage.service + reset.service with DST divergence risk)
  • findAllDueForReset deprecated: removed from repo + test mocks
  • lib/billing.cron-utils.js: NEW — applyJitter(maxMs) using crypto.randomInt, applied to all 3 crons
  • billing.plans.controller: unit tests added (success + Stripe down paths)
  • lib/billing.errors.js: NEW — isDuplicateKeyError(err) single source (was 3 inline duplicates)
  • lib/billing.constants.js: NEW — all shared getters/constants, kills dynamic imports in hot path

C — Config knobs surfacing (NEW)

All magic numbers extracted to config.billing.* with safe defaults. Backward-compat: all defaults preserve current behavior. runBaseUnits kept as deprecated alias.

  • billing.meter.{runBase, fallbackPlanId, maxUnitsPerOperation}
  • billing.outbox.{maxRetryAttempts, retryIntervalSec}
  • billing.crons.jitterMaxMs
  • billing.planChange.preserveUsageDefault
  • billing.alerts.thresholdPercents
  • billing.events.extrasExhausted

README documents each knob with downstream override examples.

Boot migration guard (B.7)

Dual-read consumedHistoryIds removed. Boot now asserts countDocuments({consumedHistoryIds: {$exists: true}}) === 0 when meterMode: true — aborts deploy if unmigrated docs remain.

Test plan

  • Unit: 88 suites, 1078 tests green
  • Integration: 24 suites, 323 tests green (including lifecycle, refund, usageHeader)
  • Lint clean
  • Critical-review: 0 critical/high (2 nit, 1 medium boot-timing accepted pattern)
  • CI green
  • Concurrent cron simulation: 1 exhausted event for 2 racing pods (billing.lifecycle.integration.tests.js)
  • Outbox E11000 recovery (billing.lifecycle.integration.tests.js)
  • forceRotate throw fallback (billing.webhook.subscription.unit.tests.js)
  • All new config knobs verifiable via override (billing.config-knobs.unit.tests.js)

Bundles 3 logical groupings on overlapping surfaces to avoid rebase hell:

ATOMICITY (🔴 + 4× 🟠 + 2× 🟡 + 🔵 + ⚪)
- markFailedAttempt: single atomic op via aggregation pipeline (close TOCTOU
  on concurrent crons that double-emit billing.extras_debit.exhausted)
- incrementMeterWithOutbox: try/catch outbox create, E11000 fetch existing
- zero-cost attribute(): writes idempotency key (close double-charge replay)
- forceRotateForPlanChange: planChangeResetTriggered set BEFORE try (close
  double-rotate via fallback resetWeek)
- E11000 retry: legacy consumedHistoryIds filter symmetric (close mid-migration
  double-charge)
- unitsFromCosts: hard-fail null ratioVersion + non-null costs in meterMode
- BillingMeterOutbox: timestamps:true (drop manual createdAt)
- billing.policy.js: drop stale ESLint disable

DRY + lib EXTRACTION (3× 🟠 + 5× 🟡 + 3× 🔵)
- webhook.controller: responses.error() canonical envelope (3 sites)
- billing.controller: route via BillingExtraService.getOrgBalanceContext()
- _ensureStripeCustomer private: dedup find-or-create between checkout flows
- billing.processedStripeEvent.schema.js: NEW Zod schema parity
- lib/billing.isoWeek.js: NEW currentWeekKey + isoWeekKey single source
- findAllDueForReset deprecated: removed
- lib/billing.cron-utils.js: NEW applyJitter helper, applied to all 3 crons
- billing.plans.controller: unit tests added
- lib/billing.errors.js: NEW isDuplicateKeyError single source
- lib/billing.constants.js: NEW shared types/constants — kills dynamic imports

CONFIG KNOBS SURFACING (NEW)
- config.billing.{meter,outbox,crons,planChange,alerts,events}.* knobs
- All magic numbers (METER_RUN_BASE, retry limits, jitter window, threshold
  percentages, fallback plan id, event names) now config-driven with safe
  defaults. README documents each with downstream override examples.
Copilot AI review requested due to automatic review settings May 3, 2026 07:25
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

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

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ 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: 944cc8fc-829f-4baf-9f4b-d19a5fa886ea

📥 Commits

Reviewing files that changed from the base of the PR and between fcc7183 and d18eda7.

📒 Files selected for processing (53)
  • modules/billing/README.md
  • modules/billing/billing.init.js
  • modules/billing/config/billing.development.config.js
  • modules/billing/controllers/billing.controller.js
  • modules/billing/controllers/billing.webhook.controller.js
  • modules/billing/crons/README.md
  • modules/billing/crons/billing.dunningSweep.js
  • modules/billing/crons/billing.extrasExpiration.js
  • modules/billing/crons/retry-pending-extras-debit.cron.js
  • modules/billing/lib/billing.constants.js
  • modules/billing/lib/billing.cron-utils.js
  • modules/billing/lib/billing.errors.js
  • modules/billing/lib/billing.isoWeek.js
  • modules/billing/lib/events.js
  • modules/billing/models/billing.meter.outbox.model.mongoose.js
  • modules/billing/models/billing.meter.outbox.schema.js
  • modules/billing/models/billing.processedStripeEvent.schema.js
  • modules/billing/policies/billing.policy.js
  • modules/billing/repositories/billing.meter.outbox.repository.js
  • modules/billing/repositories/billing.processedStripeEvent.repository.js
  • modules/billing/repositories/billing.subscription.repository.js
  • modules/billing/repositories/billing.usage.repository.js
  • modules/billing/services/billing.extra.service.js
  • modules/billing/services/billing.meter.outbox.service.js
  • modules/billing/services/billing.meter.service.js
  • modules/billing/services/billing.plan.service.js
  • modules/billing/services/billing.reset.service.js
  • modules/billing/services/billing.service.js
  • modules/billing/services/billing.usage.service.js
  • modules/billing/services/billing.webhook.service.js
  • modules/billing/tests/billing.config-knobs.unit.tests.js
  • modules/billing/tests/billing.controller.unit.tests.js
  • modules/billing/tests/billing.cron-utils.unit.tests.js
  • modules/billing/tests/billing.cron.retryPendingExtrasDebit.unit.tests.js
  • modules/billing/tests/billing.cron.weeklyReset.unit.tests.js
  • modules/billing/tests/billing.extra.service.unit.tests.js
  • modules/billing/tests/billing.extras.controller.unit.tests.js
  • modules/billing/tests/billing.init.unit.tests.js
  • modules/billing/tests/billing.isoWeek.unit.tests.js
  • modules/billing/tests/billing.lifecycle.integration.tests.js
  • modules/billing/tests/billing.meter.outbox.unit.tests.js
  • modules/billing/tests/billing.meter.service.unit.tests.js
  • modules/billing/tests/billing.plans.controller.unit.tests.js
  • modules/billing/tests/billing.processedStripeEvent.unit.tests.js
  • modules/billing/tests/billing.reset.service.unit.tests.js
  • modules/billing/tests/billing.routes.integration.tests.js
  • modules/billing/tests/billing.subscription.repository.unit.tests.js
  • modules/billing/tests/billing.usage.repository.integration.tests.js
  • modules/billing/tests/billing.usage.repository.unit.tests.js
  • modules/billing/tests/billing.usage.service.unit.tests.js
  • modules/billing/tests/billing.usageHeader.integration.tests.js
  • modules/billing/tests/billing.webhook.refund.integration.tests.js
  • modules/billing/tests/billing.webhook.subscription.unit.tests.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-hardening-v3

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
Review rate limit: 0/1 reviews remaining, refill in 41 minutes and 38 seconds.

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

@codacy-production

codacy-production Bot commented May 3, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 9 high

Alerts:
⚠ 9 issues (≤ 0 issues of at least minor severity)

Results:
9 new issues

Category Results
ErrorProne 7 high
Security 2 high

View in Codacy

🟢 Metrics 71 complexity · 26 duplication

Metric Results
Complexity 71
Duplication 26

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codecov

codecov Bot commented May 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.84967% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.20%. Comparing base (fcc7183) to head (d18eda7).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3583      +/-   ##
==========================================
+ Coverage   87.03%   87.20%   +0.17%     
==========================================
  Files         133      138       +5     
  Lines        3873     3917      +44     
  Branches     1158     1176      +18     
==========================================
+ Hits         3371     3416      +45     
+ Misses        388      385       -3     
- Partials      114      116       +2     
Flag Coverage Δ
integration 63.72% <52.28%> (-0.03%) ⬇️
unit 59.50% <88.88%> (+0.51%) ⬆️

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 fcc7183...d18eda7. 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.

@codacy-production codacy-production 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.

Pull Request Overview

This PR successfully implements several core hardening requirements, such as atomic aggregation for failed attempts and idempotency keys for zero-cost attributions. However, the current implementation contains a high-severity logic flaw in the usage overflow detection: users on zero-unit plans (like 'Free' or 'Pay-as-you-go') will bypass billing for their usage because the system incorrectly skips extra calculations when the quota is zero.

Furthermore, the PR has been flagged by Codacy as 'not up to standards,' primarily due to a high volume of code clones and linting issues. Specifically, the initialization logic across multiple cron scripts is identical and should be centralized. There are also several false-positive linter warnings related to Qwik that need to be suppressed to clean up the CI output.

While the extraction of shared utilities (ISO weeks, jitter) is a step in the right direction, addressing the overflow logic and the systemic duplication is necessary before merging.

About this PR

  • There is significant duplication in the boot sequence of the billing cron scripts. Consider centralizing the environment setup, dynamic imports, and jitter application into a shared 'cronBoot' utility in 'modules/billing/lib/'. This will reduce boilerplate and make the boot logic easier to maintain.
  • Several Node.js service and test files are triggering false-positive 'useQwikValidLexicalScope' warnings. These should be suppressed using the biome-ignore comment to prevent noise in the quality reports.
1 comment outside of the diff
modules/billing/services/billing.usage.service.js

line 125 🔴 HIGH RISK
Remove the effectiveQuota > 0 condition from the overflow detection logic. For plans with zero included units, every unit consumed should be treated as an overflow. The current code results in 0 extrasConsumed for these users, effectively giving them free usage.

Test suggestions

  • Concurrent markFailedAttempt calls emit exactly one exhausted event
  • Outbox creation handles E11000 by returning the existing row instead of failing
  • Zero-cost attribution writes the idempotency key and blocks later non-zero replays for that step
  • Boot fails when legacy consumedHistoryIds are detected in meterMode
  • Handle partial refunds correctly by identifying the latest refund delta via timestamp
  • Config overrides for jitter and retry intervals are correctly resolved through constants
  • ISO week key handles year rollover and DST transitions correctly

TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback

Comment thread modules/billing/tests/billing.isoWeek.unit.tests.js
Comment thread modules/billing/services/billing.meter.outbox.service.js
Comment thread modules/billing/lib/billing.isoWeek.js Outdated
Comment thread modules/billing/crons/billing.dunningSweep.js

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

Hardens the billing module’s metering/outbox flows and surfaces configuration knobs, while reducing duplication and aligning controllers/services with the project’s layering conventions.

Changes:

  • Improves outbox failure accounting + adds E11000 recovery for outbox create after meter increment.
  • Extracts shared billing utilities (ISO week, jitter, duplicate-key detection, config getters) and deprecates/removes old reset query paths.
  • Expands/updates unit + integration test coverage around plan-change rotation, refunds, outbox retries, and config knobs.

Reviewed changes

Copilot reviewed 53 out of 53 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
modules/billing/tests/billing.webhook.subscription.unit.tests.js Adds plan-change fallback test coverage
modules/billing/tests/billing.webhook.refund.integration.tests.js Tests “latest refund” delta selection
modules/billing/tests/billing.usageHeader.integration.tests.js Updates mocks for layering change
modules/billing/tests/billing.usage.service.unit.tests.js Adds outbox E11000 + threshold config tests
modules/billing/tests/billing.usage.repository.unit.tests.js Adds legacy-field count test
modules/billing/tests/billing.usage.repository.integration.tests.js Validates legacy-field detection post-migration
modules/billing/tests/billing.subscription.repository.unit.tests.js Removes deprecated reset query tests
modules/billing/tests/billing.routes.integration.tests.js Updates mocks for controller layering fix
modules/billing/tests/billing.reset.service.unit.tests.js Tests preserveUsage default config
modules/billing/tests/billing.processedStripeEvent.unit.tests.js Switches to shared Zod schema import
modules/billing/tests/billing.plans.controller.unit.tests.js New controller unit tests
modules/billing/tests/billing.meter.service.unit.tests.js Adds runBase/ratioVersion/zero-cost + fallbackPlan tests
modules/billing/tests/billing.meter.outbox.unit.tests.js Updates markFailedAttempt + adds findByIdempotencyKey test
modules/billing/tests/billing.lifecycle.integration.tests.js Adds concurrency + E11000 lifecycle integration tests
modules/billing/tests/billing.isoWeek.unit.tests.js New ISO-week utility tests
modules/billing/tests/billing.init.unit.tests.js Tests boot abort when legacy field remains
modules/billing/tests/billing.extras.controller.unit.tests.js Updates mocks for layering fix
modules/billing/tests/billing.extra.service.unit.tests.js Adds getOrgBalanceContext unit test
modules/billing/tests/billing.cron.weeklyReset.unit.tests.js Removes deprecated repo mock usage
modules/billing/tests/billing.cron.retryPendingExtrasDebit.unit.tests.js Tests configured retry limit + event name
modules/billing/tests/billing.cron-utils.unit.tests.js New jitter util tests
modules/billing/tests/billing.controller.unit.tests.js Updates webhook controller error envelopes
modules/billing/tests/billing.config-knobs.unit.tests.js New tests for config getters + defaults
modules/billing/services/billing.webhook.service.js Plan-change rotation ordering + refund delta selection
modules/billing/services/billing.usage.service.js Adds config-driven thresholds + outbox E11000 recovery
modules/billing/services/billing.service.js Extracts _ensureStripeCustomer helper
modules/billing/services/billing.reset.service.js Uses shared isoWeek + config default + dup-key helper
modules/billing/services/billing.plan.service.js Uses shared dup-key helper
modules/billing/services/billing.meter.service.js Uses shared constants + enforces ratioVersion behavior + zero-cost idempotency
modules/billing/services/billing.meter.outbox.service.js Makes exhausted event name + retry attempts configurable
modules/billing/services/billing.extra.service.js Adds getOrgBalanceContext service method
modules/billing/repositories/billing.usage.repository.js Adds legacy-field count + uses dup-key helper
modules/billing/repositories/billing.subscription.repository.js Removes deprecated findAllDueForReset
modules/billing/repositories/billing.processedStripeEvent.repository.js Uses dup-key helper
modules/billing/repositories/billing.meter.outbox.repository.js Adds idempotency lookup + atomic failure transition
modules/billing/policies/billing.policy.js Removes unused-vars suppression
modules/billing/models/billing.processedStripeEvent.schema.js New Zod schema for processed events
modules/billing/models/billing.meter.outbox.schema.js Adds updatedAt in schema
modules/billing/models/billing.meter.outbox.model.mongoose.js Enables timestamps on outbox model
modules/billing/lib/events.js Updates exhausted-event doc wording
modules/billing/lib/billing.isoWeek.js New shared ISO-week key utilities
modules/billing/lib/billing.errors.js New shared duplicate-key detector
modules/billing/lib/billing.cron-utils.js New jitter helper
modules/billing/lib/billing.constants.js New central config getters/defaults
modules/billing/crons/retry-pending-extras-debit.cron.js Adds jitter + config-driven retry interval
modules/billing/crons/billing.extrasExpiration.js Adds jitter before cron work
modules/billing/crons/billing.dunningSweep.js Adds jitter before cron work
modules/billing/crons/README.md Documents built-in jitter + knobs
modules/billing/controllers/billing.webhook.controller.js Switches to canonical error envelope helper
modules/billing/controllers/billing.controller.js Fixes controller→service layering for extras balance
modules/billing/config/billing.development.config.js Surfaces new billing config knobs + defaults
modules/billing/billing.init.js Adds boot-time legacy-field guard + clearer warnings
modules/billing/README.md Documents new hardening config knobs
Comments suppressed due to low confidence (2)

modules/billing/crons/billing.extrasExpiration.js:42

  • This cron connects to Mongo without calling mongooseService.loadModels() before importing repositories/services. Because the repositories resolve Mongoose models at module scope, this can crash with MissingSchemaError in standalone cron execution. Call mongooseService.loadModels() before connect(), and only import Billing repositories/services after models are loaded.
try {
  await applyJitter(getCronJitterMaxMs());
  await mongooseService.connect();

  const [{ default: BillingExtraService }, { default: BillingExtraBalanceRepository }] =
    await Promise.all([
      import('../services/billing.extra.service.js'),
      import('../repositories/billing.extraBalance.repository.js'),
    ]);

modules/billing/crons/billing.dunningSweep.js:44

  • This cron connects to Mongo without calling mongooseService.loadModels() before importing repositories (and Org repository). In a standalone CronJob, importing repositories that do mongoose.model(...) at module scope can throw MissingSchemaError if models weren’t loaded. Call mongooseService.loadModels() before connect(), and only import repositories/services after models are loaded.
try {
  await applyJitter(getCronJitterMaxMs());
  await mongooseService.connect();

  const [{ default: BillingSubscriptionRepository }, { default: OrganizationRepository }] = await Promise.all([
    import('../repositories/billing.subscription.repository.js'),
    import('../../organizations/repositories/organizations.repository.js'),
  ]);

Comment thread modules/billing/services/billing.usage.service.js Outdated
Comment thread modules/billing/README.md Outdated
Comment thread modules/billing/repositories/billing.meter.outbox.repository.js
@PierreBrisorgueil
PierreBrisorgueil merged commit 5f12d2a into master May 3, 2026
7 of 8 checks passed
@PierreBrisorgueil
PierreBrisorgueil deleted the fix/billing-hardening-v3 branch May 3, 2026 07:39
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