@@ -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 */
1111export 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
3233const 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 (
0 commit comments