Skip to content

Commit 83dfc56

Browse files
fix(billing): v4 hardening — finish v3 oversights + cohérence API (#3584)
* fix(billing): v4 hardening — finish v3 oversights + cohérence API constants - weeklyReset cron: applyJitter (les 3 autres l'ont) - constants extraction: dollarsToUnitRatio + maxUnitsPerOperation + getDefaultPlanId - reset.service: drop isoWeekKey re-export (lib leak) - usage.service: 80% threshold no longer skipped on >100% jump (drop break) - usage.service + init: thresholdFields config validation at boot (meterMode-gated) - README: full config knobs table - nits: cron-utils fractional-input guard restored, Date.now comment, errors.js extension comment - alertCrossed JSDoc: document last-threshold-emitted semantics on multi-crossing * fix(billing): address Opus review — requireQuota uses getDefaultPlanId - middleware: consolidate plan fallback via getDefaultPlanId() - usage.service: invariant comment on threshold loop ordering - usage.service: stale-memory comment on updatedDoc[field] - README: thresholdPercents notes complete - errors.js: drop YAGNI extension comment * fix(billing): address CodeRabbit/Codacy/Copilot v4 review comments - constants: add type guards to getDollarsToUnitRatio (0/NaN → 1000), getMaxUnitsPerOperation (invalid → Infinity), getDefaultPlanId (empty → 'free') - constants: guard getAlertThresholdPercents against string env-override (e.g. DEVKIT_NODE_billing_alerts_thresholdPercents=80 — coerce to [80]) - weeklyReset cron: add mongooseService.loadModels() before connect() to match retry-pending-extras-debit.cron.js pattern (Copilot LYE-) - service: add random suffix to extras_checkout idempotency key to reduce collision risk on concurrent clicks (Codacy LXAx / CodeRabbit LXwS) - README: point defaults source to billing.constants.js, fix maxUnitsPerOperation default (10000 from dev config, Infinity from constant fallback), note ratioVersion is not wrapped in a constant (Copilot LYFL, CodeRabbit LXwQ) - tests: guard tests for new constant fallback behaviours (0/NaN/empty) - tests: add getAlertThresholdPercents string coercion test - tests: weeklyReset cron — replace export parity checks with contract test (applyJitter(getCronJitterMaxMs()) produces valid delay in [0, maxMs)) - tests: 0%→150% regression — add event emit assertions (LXwU) * fix(billing): use crypto.randomBytes for idempotency key nonce Math.random() in the idempotency key was flagged by Codacy as insecure random in a security-sensitive context. Switch to node:crypto randomBytes(4) which produces a cryptographically secure 8-char hex suffix.
1 parent 5f12d2a commit 83dfc56

18 files changed

Lines changed: 297 additions & 24 deletions

modules/billing/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,24 @@ Consumers should NOT retry on `applied: true` — the outbox handles eventual co
105105

106106
## Meter hardening configuration
107107

108-
Defaults live in `modules/billing/config/billing.development.config.js` and can be overridden by downstream project config:
108+
### Configuration knobs
109+
110+
| Knob | Type | Devkit default | Notes |
111+
|------|------|----------------|-------|
112+
| `billing.meter.runBase` | number | 1 | METER_RUN_BASE base unit cost |
113+
| `billing.meter.fallbackPlanId` | string \| null | null | Fallback plan when active not resolvable |
114+
| `billing.meter.dollarsToUnitRatio` | number | 1000 | Dollar → unit conversion. DOWNSTREAM-OVERRIDE-REQUIRED. Constant fallback: `getDollarsToUnitRatio()` |
115+
| `billing.meter.maxUnitsPerOperation` | number | 10000 | Cap per single attribute call (dev config). Constant fallback: `Infinity` via `getMaxUnitsPerOperation()` |
116+
| `billing.meter.ratioVersion` | string \| null | '2026.05' | DOWNSTREAM-OVERRIDE-REQUIRED — pricing version namespace. Read directly from config, no constant wrapper |
117+
| `billing.outbox.maxRetryAttempts` | number | 5 | Outbox retry limit before exhausted |
118+
| `billing.outbox.retryIntervalSec` | number | 300 | Cron retry interval |
119+
| `billing.crons.jitterMaxMs` | number | 60000 | Cron startup jitter max. Constant fallback: `getCronJitterMaxMs()` |
120+
| `billing.planChange.preserveUsageDefault` | boolean | true | forceRotateForPlanChange default |
121+
| `billing.alerts.thresholdPercents` | number[] | [80, 100] | Schema-supported only — others warn at boot, alert silently skipped. Constant fallback: `getAlertThresholdPercents()` |
122+
| `billing.events.extrasExhausted` | string | 'billing.extras_debit.exhausted' | Event name for downstream alerting |
123+
| `billing.defaultPlan` | string | 'free' | Default plan ID for fallback. Constant fallback: `getDefaultPlanId()` |
124+
125+
Canonical constant fallbacks live in `modules/billing/lib/billing.constants.js`. Downstream project overrides go in `modules/billing/config/billing.development.config.js`:
109126

110127
```js
111128
billing: {

modules/billing/billing.init.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AnalyticsService from '../../lib/services/analytics.js';
77
import billingEvents from './lib/events.js';
88
import BillingPlanService from './services/billing.plan.service.js';
99
import BillingUsageRepository from './repositories/billing.usage.repository.js';
10+
import { getAlertThresholdPercents } from './lib/billing.constants.js';
1011

1112
/**
1213
* Billing module initialisation.
@@ -26,6 +27,19 @@ export default async (app) => {
2627
}
2728
}
2829

30+
// Validate alert threshold percents (meterMode only) — warn on configured values with no schema field.
31+
// Only 80 and 100 have matching alertedAtN fields in BillingUsage; other values are silently skipped.
32+
if (config?.billing?.meterMode) {
33+
const SUPPORTED_THRESHOLD_PERCENTS = new Set([80, 100]);
34+
for (const threshold of getAlertThresholdPercents()) {
35+
if (!SUPPORTED_THRESHOLD_PERCENTS.has(threshold)) {
36+
console.warn(
37+
`[billing] Configured alert threshold ${threshold}% is not in schema-supported set [80, 100] — alert will be silently skipped`,
38+
);
39+
}
40+
}
41+
}
42+
2943
// Update analytics group properties when a subscription plan changes
3044
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
3145
try {

modules/billing/crons/billing.weeklyReset.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,27 @@
1313

1414
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
1515

16-
const [{ default: config }, { default: mongooseService }] = await Promise.all([
16+
const [
17+
{ default: config },
18+
{ default: mongooseService },
19+
{ applyJitter },
20+
{ getCronJitterMaxMs },
21+
] = await Promise.all([
1722
import('../../../config/index.js'),
1823
import('../../../lib/services/mongoose.js'),
24+
import('../lib/billing.cron-utils.js'),
25+
import('../lib/billing.constants.js'),
1926
]);
2027

2128
if (!config?.billing?.meterMode) {
2229
console.log('[billing.weeklyReset] meterMode disabled — skipping.');
2330
process.exit(0);
2431
}
2532

33+
await applyJitter(getCronJitterMaxMs());
34+
2635
try {
36+
await mongooseService.loadModels();
2737
await mongooseService.connect();
2838

2939
const { default: BillingResetService } = await import('../services/billing.reset.service.js');

modules/billing/lib/billing.constants.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,50 @@ export const getPlanChangePreserveUsageDefault = () =>
7878
* @returns {number[]} Sorted threshold percentages.
7979
*/
8080
export const getAlertThresholdPercents = () => {
81-
const thresholds = config?.billing?.alerts?.thresholdPercents ?? DEFAULT_ALERT_THRESHOLD_PERCENTS;
81+
const raw = config?.billing?.alerts?.thresholdPercents ?? DEFAULT_ALERT_THRESHOLD_PERCENTS;
82+
// Guard against env-override delivering a string (e.g. DEVKIT_NODE_billing_alerts_thresholdPercents=80)
83+
const thresholds = Array.isArray(raw) ? raw : [raw].map(Number).filter(Number.isFinite);
8284
return thresholds
85+
.map(Number)
8386
.filter((threshold) => Number.isFinite(threshold) && threshold > 0)
8487
.sort((a, b) => b - a);
8588
};
8689

90+
/**
91+
* @function getDollarsToUnitRatio
92+
* @description Resolve the configured conversion factor from dollar amounts to meter units.
93+
* Returns the raw config value when valid; falls back to 1000.
94+
* @returns {number} Dollar-to-unit ratio (e.g. 1000 means $1 = 1000 units).
95+
*/
96+
export const getDollarsToUnitRatio = () => {
97+
const ratio = Number(config?.billing?.meter?.dollarsToUnitRatio);
98+
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1000;
99+
};
100+
101+
/**
102+
* @function getMaxUnitsPerOperation
103+
* @description Resolve the configured per-operation unit cap. Infinity means no cap.
104+
* Returns the raw config value when valid (positive finite or Infinity); falls back to Infinity.
105+
* @returns {number} Maximum units allowed for a single attribute call.
106+
*/
107+
export const getMaxUnitsPerOperation = () => {
108+
const raw = config?.billing?.meter?.maxUnitsPerOperation;
109+
if (raw === undefined || raw === null) return Infinity;
110+
const cap = Number(raw);
111+
return Number.isFinite(cap) && cap > 0 ? cap : Infinity;
112+
};
113+
114+
/**
115+
* @function getDefaultPlanId
116+
* @description Resolve the default plan ID used as a fallback when no active subscription exists.
117+
* Returns the raw config value when non-empty string; falls back to 'free'.
118+
* @returns {string} Default plan identifier.
119+
*/
120+
export const getDefaultPlanId = () => {
121+
const planId = config?.billing?.defaultPlan;
122+
return typeof planId === 'string' && planId.trim() ? planId : 'free';
123+
};
124+
87125
/**
88126
* @function getExtrasExhaustedEventName
89127
* @description Resolve the event name emitted when extras debit retries are exhausted.

modules/billing/lib/billing.cron-utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { randomInt } from 'node:crypto';
1212
export const applyJitter = async (maxMs) => {
1313
if (!Number.isFinite(maxMs) || maxMs <= 0) return 0;
1414
const jitterMaxMs = Math.floor(maxMs);
15-
if (jitterMaxMs <= 0) return 0;
15+
if (jitterMaxMs <= 0) return 0; // guard fractional inputs (e.g. 0.4) — randomInt(0,0) throws
1616
const delayMs = randomInt(0, jitterMaxMs);
1717
await new Promise((resolve) => setTimeout(resolve, delayMs));
1818
return delayMs;

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.
77
import BillingPlanService from '../services/billing.plan.service.js';
88

99
import { activeStatuses } from '../lib/constants.js';
10+
import { getDefaultPlanId } from '../lib/billing.constants.js';
1011
import config from '../../../config/index.js';
1112
import responses from '../../../lib/helpers/responses.js';
1213

@@ -81,7 +82,7 @@ function requireQuota(resource, action) {
8182
// Don't create the doc here — let incrementMeter do it on first attribution.
8283
// Fall back to the plan quota so first-run requests are not blocked.
8384
// Reuse the `subscription` already fetched by the degraded-mode gate above.
84-
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
85+
const planId = subscription?.plan ?? getDefaultPlanId();
8586
const activePlan = await BillingPlanService.getActivePlan(planId);
8687

8788
// Plan missing (seeding / version bump in progress) → fail safe with 503

modules/billing/services/billing.meter.service.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import BillingPlanService from './billing.plan.service.js';
66
import BillingUsageService from './billing.usage.service.js';
77
import BillingExtraService from './billing.extra.service.js';
88
import BillingMeterOutboxRepository from '../repositories/billing.meter.outbox.repository.js';
9-
import { getMeterFallbackPlanId, getMeterRunBase, METER_RUN_BASE } from '../lib/billing.constants.js';
9+
import {
10+
getMeterFallbackPlanId,
11+
getMeterRunBase,
12+
getDollarsToUnitRatio,
13+
getMaxUnitsPerOperation,
14+
METER_RUN_BASE,
15+
} from '../lib/billing.constants.js';
1016

1117
export { METER_RUN_BASE };
1218

@@ -35,7 +41,7 @@ const unitsFromCosts = async (costs, planId, ratioVersion) => {
3541
return { totalUnits: getMeterRunBase(), breakdown: {} };
3642
}
3743

38-
const dollarsToUnitRatio = config?.billing?.meter?.dollarsToUnitRatio ?? 1000;
44+
const dollarsToUnitRatio = getDollarsToUnitRatio();
3945

4046
const hasBillableCost = Object.values(costs).some(
4147
(cost) => typeof cost === 'number' && Number.isFinite(cost) && cost > 0,
@@ -223,7 +229,7 @@ const attribute = async (history, organizationId, options = {}) => {
223229
({ totalUnits, breakdown } = await unitsFromCosts(costs, planId, ratioVersion));
224230
}
225231

226-
const maxUnits = config?.billing?.meter?.maxUnitsPerOperation ?? Infinity;
232+
const maxUnits = getMaxUnitsPerOperation();
227233
const cappedUnits = Math.min(totalUnits, maxUnits);
228234
const isCapped = cappedUnits < totalUnits;
229235
const cappedBreakdown = isCapped ? capBreakdown(breakdown, cappedUnits, totalUnits) : breakdown;

modules/billing/services/billing.reset.service.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import BillingSubscriptionRepository from '../repositories/billing.subscription.
77
import BillingPlanService from './billing.plan.service.js';
88
import billingEvents from '../lib/events.js';
99
import { isoWeekKey } from '../lib/billing.isoWeek.js';
10-
import { getPlanChangePreserveUsageDefault } from '../lib/billing.constants.js';
10+
import { getPlanChangePreserveUsageDefault, getDefaultPlanId } from '../lib/billing.constants.js';
1111
import { isDuplicateKeyError } from '../lib/billing.errors.js';
1212

1313
/**
@@ -42,7 +42,7 @@ const resetWeek = async (orgId, periodStart) => {
4242

4343
// Step 2 — Fetch the active plan to snapshot quota/planVersion — lean projection (plan only, no populate).
4444
const subscription = await BillingSubscriptionRepository.findPlan(orgId);
45-
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
45+
const planId = subscription?.plan ?? getDefaultPlanId();
4646
const activePlan = await BillingPlanService.getActivePlan(planId);
4747
const meterQuota = activePlan?.meterQuota ?? 0;
4848
const planVersion = activePlan?.version ?? null;
@@ -102,7 +102,7 @@ const forceRotateForPlanChange = async (organizationId, options = {}) => {
102102
if (!existingDoc) return null;
103103

104104
const subscription = await BillingSubscriptionRepository.findPlan(organizationId);
105-
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
105+
const planId = subscription?.plan ?? getDefaultPlanId();
106106
const activePlan = await BillingPlanService.getActivePlan(planId);
107107
const newQuota = activePlan?.meterQuota ?? 0;
108108
const newVersion = activePlan?.version ?? null;
@@ -181,5 +181,4 @@ export default {
181181
resetWeek,
182182
forceRotateForPlanChange,
183183
resetAllDue,
184-
isoWeekKey,
185184
};

modules/billing/services/billing.service.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Module dependencies
33
*/
4+
import { randomBytes } from 'node:crypto';
45
import config from '../../../config/index.js';
56
import getStripe from '../lib/stripe.js';
67
import BillingPlansService from './billing.plans.service.js';
@@ -193,8 +194,9 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
193194

194195
const subscription = await _ensureStripeCustomer(stripe, organization);
195196

196-
// Use a timestamped idempotency key (debounce double-click within ~1s granularity)
197-
const idempotencyKey = `extras_checkout_${String(organization._id)}_${packId}_${Date.now()}`;
197+
// Per-intent idempotency key: timestamp + crypto-random suffix reduces collision risk under concurrent clicks.
198+
// Full deduplication would require a caller-provided stable intent id — deferred to a future improvement.
199+
const idempotencyKey = `extras_checkout_${String(organization._id)}_${packId}_${Date.now()}_${randomBytes(4).toString('hex')}`;
198200

199201
const extrasCheckoutParams = {
200202
customer: subscription.stripeCustomerId,

modules/billing/services/billing.usage.service.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import BillingMeterOutboxRepository from '../repositories/billing.meter.outbox.r
88
import BillingPlanService from './billing.plan.service.js';
99
import billingEvents from '../lib/events.js';
1010
import { currentWeekKey } from '../lib/billing.isoWeek.js';
11-
import { getAlertThresholdPercents } from '../lib/billing.constants.js';
11+
import { getAlertThresholdPercents, getDefaultPlanId } from '../lib/billing.constants.js';
1212
import { isDuplicateKeyError } from '../lib/billing.errors.js';
1313

1414
/**
@@ -70,6 +70,8 @@ const reset = (organizationId) => UsageRepository.reset(organizationId, currentM
7070
* @param {Object} breakdown - Feature-keyed breakdown: { featureKey: units }.
7171
* @param {string} idempotencyKey - Unique key for replay protection (usually history._id).
7272
* @returns {Promise<{applied: boolean, meterUsed: number, meterQuota: number, extrasConsumed: number, alertCrossed: string|null}>}
73+
* `alertCrossed` is the last threshold emitted this call (lowest value when multiple thresholds crossed in one jump,
74+
* e.g. 0%→150% emits both 80 and 100 — alertCrossed='80'). Informational only; events are the authoritative signal.
7375
*/
7476
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
7577
const incrementMeter = async (organizationId, units, breakdown, idempotencyKey) => {
@@ -82,7 +84,7 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
8284

8385
// Fetch active plan for quota snapshot — lean projection (plan field only, no populate)
8486
const subscription = await BillingSubscriptionRepository.findPlan(organizationId);
85-
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
87+
const planId = subscription?.plan ?? getDefaultPlanId();
8688
const activePlan = await BillingPlanService.getActivePlan(planId);
8789
const meterQuota = activePlan?.meterQuota ?? 0;
8890
const planVersion = activePlan?.version ?? null;
@@ -133,12 +135,14 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
133135

134136
if (effectiveQuota > 0) {
135137
const pct = (newMeterUsed / effectiveQuota) * 100;
138+
// loop runs DESC (e.g. [100, 80] from getAlertThresholdPercents()); alertCrossed retains the last (lowest) marked threshold by design.
136139
for (const threshold of getAlertThresholdPercents()) {
137140
const field = thresholdFields[threshold];
138141
if (!field) {
139142
console.warn(`[billing.usage] threshold ${threshold}% has no schema field (only 80/100 are supported) — skipping`);
140143
continue;
141144
}
145+
// updatedDoc is pre-mark snapshot; DB-side dedup enforced by markThreshold conditional update.
142146
if (pct < threshold || updatedDoc[field]) continue;
143147

144148
let marked = false;
@@ -157,7 +161,6 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
157161
meterUsed: newMeterUsed,
158162
meterQuota: effectiveQuota,
159163
});
160-
break;
161164
}
162165
}
163166
}

0 commit comments

Comments
 (0)