Skip to content

Commit 9b46842

Browse files
fix(billing): hard-fail missing planVersion + zero-cost short-circuit + meter API options shape (#3581)
🟠 D: in meterMode, missing (planId, ratioVersion) snapshot now throws instead of silently using ratio=1. For consumers like trawl with wizard×5/generate×3, silent fallback to ×1 would substantially under-charge. Backfill migration ensures (planId, version) snapshots exist for all configured plans. 🟠 E: unitsFromCosts({}) returns 0 instead of METER_RUN_BASE. attribute() with zero-cost costsOverride skips the idempotency key write entirely, preserving the key for a future non-zero attribution at the same step. 🟡 E: attribute() now accepts {costs, planId, ratioVersion, stepKey, costsOverride} options for explicit per-call overrides. Backward compatible — history-object path still works. ensureSeeded() now detects version drift on existing active plans and re-seeds rather than silently skipping. WARN log on drift for audit trail.
1 parent 00ea494 commit 9b46842

6 files changed

Lines changed: 505 additions & 53 deletions
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
import config from '../../../config/index.js';
6+
7+
const isPlainObject = (value) =>
8+
value != null && typeof value === 'object' && !Array.isArray(value);
9+
10+
const isNonEmptyRatios = (value) => isPlainObject(value) && Object.keys(value).length > 0;
11+
12+
const configuredPlans = () => {
13+
const byPlanId = new Map();
14+
15+
const definitions = config?.billing?.planDefinitions;
16+
if (Array.isArray(definitions)) {
17+
for (const def of definitions) {
18+
if (def?.planId) byPlanId.set(def.planId, def);
19+
}
20+
}
21+
22+
const legacyPlans = config?.billing?.plans;
23+
if (isPlainObject(legacyPlans)) {
24+
for (const [planId, def] of Object.entries(legacyPlans)) {
25+
byPlanId.set(planId, { ...(def ?? {}), planId });
26+
}
27+
}
28+
29+
return byPlanId;
30+
};
31+
32+
const canonicalSnapshot = (planId, plansById) => {
33+
const plan = plansById.get(planId);
34+
if (!plan) return null;
35+
36+
const version = plan.version ?? config?.billing?.meter?.ratioVersion ?? null;
37+
if (!version || !isNonEmptyRatios(plan.ratios) || typeof plan.meterQuota !== 'number') {
38+
return null;
39+
}
40+
41+
return {
42+
version,
43+
ratios: plan.ratios,
44+
meterQuota: plan.meterQuota,
45+
};
46+
};
47+
48+
/**
49+
* Migration: Backfill BillingPlan version snapshots from billing config.
50+
*
51+
* Repairs legacy BillingPlan docs missing a version or frozen ratios by applying
52+
* the canonical configured snapshot for the same planId. Idempotent: documents
53+
* already at the canonical version with non-empty ratios are skipped.
54+
*
55+
* @returns {Promise<void>}
56+
*/
57+
export async function up() {
58+
const collection = mongoose.connection.db.collection('billingplans');
59+
const plansById = configuredPlans();
60+
const BATCH_SIZE = 500;
61+
let processed = 0;
62+
let skipped = 0;
63+
64+
const cursor = collection.find(
65+
{
66+
$or: [
67+
{ version: { $exists: false } },
68+
{ version: null },
69+
{ ratios: { $exists: false } },
70+
{ ratios: null },
71+
{ ratios: {} },
72+
],
73+
},
74+
{ projection: { _id: 1, planId: 1, version: 1, ratios: 1 } },
75+
);
76+
77+
const ops = [];
78+
79+
for await (const doc of cursor) {
80+
const canonical = canonicalSnapshot(doc.planId, plansById);
81+
if (!canonical) {
82+
console.warn(
83+
`[migration] backfill-plan-version-snapshots: missing canonical config for ${doc.planId}; skipping`,
84+
);
85+
skipped += 1;
86+
continue;
87+
}
88+
89+
if (doc.version === canonical.version && isNonEmptyRatios(doc.ratios)) {
90+
skipped += 1;
91+
continue;
92+
}
93+
94+
ops.push({
95+
updateOne: {
96+
filter: { _id: doc._id },
97+
update: {
98+
$set: {
99+
version: canonical.version,
100+
ratios: canonical.ratios,
101+
meterQuota: canonical.meterQuota,
102+
},
103+
},
104+
},
105+
});
106+
107+
if (ops.length >= BATCH_SIZE) {
108+
await collection.bulkWrite(ops, { ordered: false });
109+
processed += ops.length;
110+
ops.length = 0;
111+
}
112+
}
113+
114+
if (ops.length > 0) {
115+
await collection.bulkWrite(ops, { ordered: false });
116+
processed += ops.length;
117+
}
118+
119+
if (processed > 0 || skipped > 0) {
120+
console.info(
121+
`[migration] backfill-plan-version-snapshots: backfilled ${processed} documents, skipped ${skipped}`,
122+
);
123+
}
124+
}
125+
126+
/**
127+
* Down: intentionally no-op.
128+
*
129+
* Reverting version snapshots can corrupt historical attribution because meter
130+
* calculations depend on immutable (planId, version) ratios. Keep consistency
131+
* and require manual intervention for any rollback.
132+
*
133+
* @returns {void}
134+
*/
135+
export function down() {
136+
console.warn(
137+
'[migration] backfill-plan-version-snapshots DOWN: no-op; preserving BillingPlan snapshots',
138+
);
139+
}

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

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import BillingPlanService from './billing.plan.service.js';
66

77
/**
88
* Floor charge per run — configurable via config.billing.meter.runBaseUnits.
9-
* Guarantees every attributed event costs at least 1 unit regardless of costs.
9+
* Applies only when no cost data is available at all.
1010
*/
1111
export const METER_RUN_BASE = config?.billing?.meter?.runBaseUnits ?? 1;
1212

@@ -17,11 +17,12 @@ export const METER_RUN_BASE = config?.billing?.meter?.runBaseUnits ?? 1;
1717
* dollar for each feature key.
1818
*
1919
* Formula per key: floor(cost[key] * ratio[key] * dollarsToUnitRatio)
20-
* Total: max(sum(per-key units), METER_RUN_BASE)
20+
* Total: sum(per-key units). Empty or zero-only cost maps return 0.
21+
* METER_RUN_BASE applies only when costs is null/undefined.
2122
*
22-
* When getPlanByVersion returns null (version mismatch), logs a WARN and
23-
* falls back to ratio=1 for all features. Check logs if charges look flat —
24-
* this indicates meter.ratioVersion vs planDefinitions version drift.
23+
* When meterMode is enabled and getPlanByVersion returns null
24+
* (version mismatch), throws so attribution cannot silently bill with
25+
* ratio=1. When meterMode is disabled, keeps the legacy ratio=1 fallback.
2526
*
2627
* @param {Object} costs - Feature-keyed cost map: { featureKey: usdCost }.
2728
* @param {string} planId - Logical plan identifier (e.g. "pro").
@@ -30,15 +31,26 @@ export const METER_RUN_BASE = config?.billing?.meter?.runBaseUnits ?? 1;
3031
*/
3132
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
3233
const unitsFromCosts = async (costs, planId, ratioVersion) => {
33-
if (!costs || typeof costs !== 'object') {
34+
if (costs == null || typeof costs !== 'object') {
3435
return { totalUnits: METER_RUN_BASE, breakdown: {} };
3536
}
3637

3738
const dollarsToUnitRatio = config?.billing?.meter?.dollarsToUnitRatio ?? 1000;
3839

40+
const hasBillableCost = Object.values(costs).some(
41+
(cost) => typeof cost === 'number' && Number.isFinite(cost) && cost > 0,
42+
);
43+
if (!hasBillableCost) {
44+
return { totalUnits: 0, breakdown: {} };
45+
}
46+
3947
// Fetch the frozen plan snapshot for the given version
4048
const plan = await BillingPlanService.getPlanByVersion(planId, ratioVersion);
4149
if (!plan) {
50+
if (config?.billing?.meterMode) {
51+
throw new Error(`[billing.meter] missing plan version snapshot for (${planId}, ${ratioVersion})`);
52+
}
53+
4254
// WARN: version mismatch likely — costs.config emits a version that has no matching BillingPlan.
4355
// Charges will use ratio=1 (flat) for all features. Align meter.ratioVersion + planDefinitions[].version
4456
// + downstream cost-config emitted version to resolve. See billing README — Version Namespace Contract.
@@ -63,9 +75,7 @@ const unitsFromCosts = async (costs, planId, ratioVersion) => {
6375
}
6476
}
6577

66-
const totalUnits = Math.max(rawTotal, METER_RUN_BASE);
67-
68-
return { totalUnits, breakdown };
78+
return { totalUnits: rawTotal, breakdown };
6979
};
7080

7181
/**
@@ -136,23 +146,45 @@ const capBreakdown = (breakdown, cappedUnits, originalTotal) => {
136146
* Use 'initial' for the first (and often only) charge per history.
137147
* Use a distinct value (e.g. 'digest', 'fix:1', 'fix:2') for subsequent attributions on
138148
* the same history after cost-impacting mutations (setDigest, setFixCost).
139-
* Downstream callers must pass ONLY the incremental cost delta in history.costs for each step.
149+
* Downstream callers must pass ONLY the incremental cost delta in history.costs for each step,
150+
* or use options.costsOverride to pass the delta directly.
140151
* Format: alphanumeric, colon, hyphen, underscore only, 1-64 chars (e.g. 'initial', 'digest', 'fix:1').
141152
* null/undefined → defaults to 'initial'. Any other invalid value → throws Error.
142153
* No-op when config.billing.meterMode is false (validation is skipped in that case).
154+
* @param {Object|null|undefined} [options.costsOverride=null] - Optional cost map to charge
155+
* instead of history.costs. Use for delta charging:
156+
* `attribute(history, orgId, { stepKey: 'digest', costsOverride: { digest: 0.5 } })`.
157+
* Empty or zero-only overrides return `{ applied: false, reason: 'zero_cost_skipped' }`
158+
* before writing the idempotency key.
159+
* @param {Object|null|undefined} [options.costs=null] - Alias cost map used when
160+
* options.costsOverride is not provided. Prefer costsOverride for new delta-charge callers.
161+
* @param {string|null|undefined} [options.planId=null] - Optional planId override for paths
162+
* where the history plan should not be used:
163+
* `attribute(history, orgId, { planId: 'override', ratioVersion: '2026.05' })`.
164+
* @param {string|null|undefined} [options.ratioVersion=null] - Optional ratio snapshot override
165+
* paired with options.planId. Falls back to history.planVersion when omitted.
143166
* @returns {Promise<{applied: boolean, meterUsed: number, extrasConsumed: number, reason?: string}>}
144-
* `reason` is present only when `applied` is true but extras were exhausted:
145-
* `{ applied: true, meterUsed, extrasConsumed: 0, reason: 'extras_exhausted' }`.
167+
* `reason` is present for zero-cost skips and exhausted extras:
168+
* `{ applied: false, meterUsed: 0, extrasConsumed: 0, reason: 'zero_cost_skipped' }`
169+
* or `{ applied: true, meterUsed, extrasConsumed: 0, reason: 'extras_exhausted' }`.
146170
* @throws {Error} If stepKey is non-null/undefined and does not match the expected format.
147171
*/
148172
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
149-
const attribute = async (history, organizationId, { stepKey = 'initial' } = {}) => {
173+
const attribute = async (history, organizationId, options = {}) => {
150174
// Fast-path: when metering is disabled, skip all validation and return immediately.
151175
// This preserves the documented no-op behavior for callers passing any stepKey when meterMode=false.
152176
if (!config?.billing?.meterMode) {
153177
return { applied: false, meterUsed: 0, extrasConsumed: 0 };
154178
}
155179

180+
const {
181+
stepKey = 'initial',
182+
costs: explicitCosts = null,
183+
costsOverride = null,
184+
planId: explicitPlanId = null,
185+
ratioVersion: explicitRatioVersion = null,
186+
} = options ?? {};
187+
156188
// Validate stepKey: must be a non-empty alphanumeric+colon+hyphen+underscore string (1-64 chars).
157189
// Silent fallback to 'initial' was removed — it collides with the actual initial attribution
158190
// and silently drops subsequent step charges (e.g. 'digest', 'fix:1').
@@ -170,14 +202,15 @@ const attribute = async (history, organizationId, { stepKey = 'initial' } = {})
170202
const { default: BillingUsageService } = await import('./billing.usage.service.js');
171203
const { default: BillingExtraService } = await import('./billing.extra.service.js');
172204

173-
const planId = history.planId ?? config?.billing?.plans?.[0] ?? 'pro';
174-
const ratioVersion = history.planVersion ?? null;
205+
const planId = explicitPlanId ?? history.planId ?? config?.billing?.plans?.[0] ?? 'pro';
206+
const ratioVersion = explicitRatioVersion ?? history.planVersion ?? null;
207+
const costs = costsOverride ?? explicitCosts ?? history.costs;
175208

176209
let totalUnits = METER_RUN_BASE;
177210
let breakdown = {};
178211

179-
if (history.costs && ratioVersion) {
180-
({ totalUnits, breakdown } = await unitsFromCosts(history.costs, planId, ratioVersion));
212+
if (costs && ratioVersion) {
213+
({ totalUnits, breakdown } = await unitsFromCosts(costs, planId, ratioVersion));
181214
}
182215

183216
const maxUnits = config?.billing?.meter?.maxUnitsPerOperation ?? Infinity;
@@ -190,6 +223,10 @@ const attribute = async (history, organizationId, { stepKey = 'initial' } = {})
190223
);
191224
}
192225

226+
if (cappedUnits === 0) {
227+
return { applied: false, meterUsed: 0, extrasConsumed: 0, reason: 'zero_cost_skipped' };
228+
}
229+
193230
const idempotencyKey = `${history._id?.toString?.() ?? String(history._id)}:${validatedStepKey}`;
194231

195232
const result = await BillingUsageService.incrementMeter(

modules/billing/services/billing.plan.service.js

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,44 @@ const bumpVersionWithRetry = async (planId, fields, { maxAttempts = 3 } = {}) =>
162162
throw lastErr;
163163
};
164164

165+
const isDuplicateKeyError = (err) =>
166+
err.code === 11000 || (err.message && err.message.includes('E11000'));
167+
168+
/**
169+
* @desc Resolve the configured immutable version for a plan definition.
170+
* Returns null when config does not pin a version and count-derived
171+
* backward-compat behavior should be used for first-time seeding.
172+
* @param {Object} planDef - Configured plan definition without planId.
173+
* @returns {string|null} Configured version, or null.
174+
*/
175+
const configuredVersion = (planDef) => planDef.version ?? config?.billing?.meter?.ratioVersion ?? null;
176+
177+
/**
178+
* @desc Create a configured plan snapshot, deriving a legacy vN version only
179+
* when config did not specify one.
180+
* @param {string} planId - Logical plan identifier.
181+
* @param {Object} planDef - Configured plan definition without planId.
182+
* @param {string|null} [version=null] - Pre-resolved version to use.
183+
* @returns {Promise<Object>} The created BillingPlan document.
184+
*/
185+
const createConfiguredPlan = async (planId, planDef, version = null) => {
186+
let resolvedVersion = version;
187+
if (!resolvedVersion) {
188+
const total = await BillingPlanRepository.count(planId);
189+
resolvedVersion = `v${total + 1}`;
190+
}
191+
192+
return BillingPlanRepository.create({
193+
planId,
194+
version: resolvedVersion,
195+
meterQuota: planDef.meterQuota ?? 0,
196+
ratios: planDef.ratios ?? { default: 1 },
197+
effectiveFrom: new Date(),
198+
effectiveUntil: null,
199+
active: true,
200+
});
201+
};
202+
165203
/**
166204
* @function ensureSeeded
167205
* @description Upsert BillingPlan docs from config.billing.planDefinitions.
@@ -196,38 +234,39 @@ const ensureSeeded = async () => {
196234

197235
for (const def of definitions) {
198236
const { planId, ...planDef } = def;
237+
const targetVersion = configuredVersion(planDef);
199238
const existing = await BillingPlanRepository.findActive(planId);
200239
if (existing) {
201-
skipped += 1;
240+
if (!targetVersion || existing.version === targetVersion) {
241+
skipped += 1;
242+
continue;
243+
}
244+
245+
console.info(
246+
`[billing.plan] version drift detected for ${planId}: active=${existing.version}, config=${targetVersion}; re-seeding`,
247+
);
248+
try {
249+
await BillingPlanRepository.deactivateAll(planId, new Date());
250+
await createConfiguredPlan(planId, planDef, targetVersion);
251+
cache.delete(planId);
252+
seeded += 1;
253+
} catch (err) {
254+
if (isDuplicateKeyError(err)) {
255+
skipped += 1;
256+
continue;
257+
}
258+
throw err;
259+
}
202260
continue;
203261
}
204262

205263
try {
206-
// Version resolution priority:
207-
// 1. Explicit version in planDefinitions entry (e.g. '2026.05' — YYYY.MM contract)
208-
// 2. config.billing.meter.ratioVersion (canonical version emitted by attribute())
209-
// 3. Derived from count (v${total + 1}) — full backward compat for projects without version config
210-
let version = planDef.version ?? config?.billing?.meter?.ratioVersion ?? null;
211-
if (!version) {
212-
const total = await BillingPlanRepository.count(planId);
213-
version = `v${total + 1}`;
214-
}
215-
216-
await BillingPlanRepository.create({
217-
planId,
218-
version,
219-
meterQuota: planDef.meterQuota ?? 0,
220-
ratios: planDef.ratios ?? { default: 1 },
221-
effectiveFrom: new Date(),
222-
effectiveUntil: null,
223-
active: true,
224-
});
264+
await createConfiguredPlan(planId, planDef, targetVersion);
225265
cache.delete(planId);
226266
seeded += 1;
227267
} catch (err) {
228268
// E11000: concurrent pod beat us to the insert — treat as skip, not fatal.
229-
const isE11000 = err.code === 11000 || (err.message && err.message.includes('E11000'));
230-
if (isE11000) {
269+
if (isDuplicateKeyError(err)) {
231270
skipped += 1;
232271
continue;
233272
}

0 commit comments

Comments
 (0)