Skip to content

Commit 0f2320d

Browse files
feat(billing): compute layer foundation - PR-N1 (no behavior change) (#3535)
* feat(billing): compute layer foundation - pr-n1 (no behavior change) lays the foundational data layer for compute-based pricing behind the billing.computeMode: false feature flag (default off). all new code paths are dormant for existing downstream projects. new models: billing.plan.model.mongoose.js + schema, billing.processedStripeEvent.model.mongoose.js modified models (additive, sparse, backward-compat): billing.usage (weekKey, computeUsed/Quota, computeBreakdown, resetAt, alertedAt80/100, consumedHistoryIds), billing.subscription (planVersion, currentPeriodStart, pastDueSince) new service: billing.plan.service.js - getActivePlan (1h cache), getPlanByVersion, bumpVersion (mongo transaction), invalidateCache; billing.plans.service.js kept as legacy facade config: billing.computeMode (default false), billing.compute.*, billing.packs, stripe.prices.packs (all downstream override slots) migrations: 20260501000000 (weekKey sparse index), 20260501000100 (plan/event indexes) - idempotent tests: +43 tests across 3 new suites + 3 extended existing files closes checklist item pr-n1 in #3533 * fix(billing): remove duplicate index creation from migrations Migrations were creating indexes with custom names (planId_version_unique, organizationId_weekKey_unique_sparse) that conflicted with the auto-names Mongoose assigns at bootstrap (planId_1_version_1, organizationId_1_weekKey_1). MongoDB code 85 IndexOptionsConflict was crashing the app on first run in each fresh test DB, causing all integration tests to fail. These indexes are owned by the Mongoose schemas and auto-synced at connect time — the migration up() functions must not duplicate them. * fix(billing): make migrations pure no-ops - all indexes are Mongoose-managed The subscriptions.planVersion sparse index and processedstripeevents.processedAt TTL index are both defined in Mongoose schemas with autoIndex:true. When migrations run post-connect, Mongoose has already created them with auto-names (planVersion_1, processedAt_1). The migrations tried to create the same key patterns with different names → IndexOptionsConflict code 85. Both migrations are now pure markers (up/down no-ops). The schema + index lifecycle is fully owned by Mongoose model definitions. * test(billing): remove done callbacks from synchronous Zod schema tests New Subscription compute-field tests used the done callback pattern from surrounding legacy tests, but the operations are synchronous (safeParse). Using done with sync code masks exceptions as timeouts rather than failures. Replaced with plain synchronous test functions to fix 6 Codacy ErrorProne high findings. * fix(billing): address CodeRabbit major + minor findings on billing.plan.service Major fixes: - bumpVersion: remove withTransaction/startSession (requires replica set). Now uses updateMany + countDocuments + create without sessions, matching the standalone-MongoDB pattern in organizations.membership.service.js. - getActivePlan: do not cache null results (plan not found) — a null miss should not block visibility of a newly-created plan until TTL expiry. Reduce CACHE_TTL from 1h to 5min to limit stale-read window. - billing.plan.model: add Mongoose validator on ratios (must be object with finite non-negative values). Mirror in Zod schema with .min(0). Minor fixes: - Add JSDoc header to makeDoc test helper. - Fix misleading test title for fetchPlansFromStripe (not exported). - Add test coverage for negative ratio rejection. - Fix BillingPlanBump JSDoc: ratios is optional, not required. - Update bumpVersion tests to reflect no-session implementation. * fix(billing): suppress Codacy false-positive useQwikValidLexicalScope on Node service Codacy applies Biome's useQwikValidLexicalScope rule to Node.js arrow function declarations, producing 6 ErrorProne high findings. This rule is designed for Qwik SSR serialization and is a false positive in a Node.js context. Add biome-ignore suppressions to the affected top-level function declarations in billing.plan.service.js and its unit test file. No behavior change. * refactor(billing): apply critical-review fixes — repository pattern + meter rename - Extract BillingPlanRepository (findActive, findByVersion, deactivateAll, count, create) so billing.plan.service.js no longer imports mongoose directly (ERRORS.md arch rule) - Rename compute → meter throughout billing module for devkit neutrality: computeMode → meterMode, compute.* → meter.*, computeQuota → meterQuota, computeUsed/computeQuota/computeBreakdown → meterUsed/meterQuota/meterBreakdown - git mv migration to 20260501000000-add-meter-fields.js (history preserved) - Update unit tests to mock repository instead of mongoose; add lean() pass-through test - Add JSDoc: bumpVersion concurrency/activation-gap guidance, getActivePlan null semantics, dollarsToUnitRatio downstream-override warning, multi-pod cache stale-window TODO - Backward compat: meterMode default false, zero behavior change for non-meter downstream * fix(billing): add biome-ignore suppressions for useQwikValidLexicalScope on repository + service Codacy erroneously applies the Qwik rule on Node.js module-level arrow functions. Suppress in billing.plan.repository.js (6 declarations) and restore suppressions removed from billing.plan.service.js (4 declarations) during the refactor → 0 high ErrorProne issues expected. * fix(billing): address CodeRabbit Major/Critical findings on indexes + schema 1. BillingPlanMongoose: make (planId, active, effectiveUntil) a partial unique index (active=true, effectiveUntil=null) — enforces single active plan per planId at the DB level, guards concurrent bumpVersion() races. 2. BillingUsage: scope (organizationId, month) unique index to non-meter docs only (partialFilterExpression: weekKey $exists false) — allows multiple weekly records per month in meter mode. 3. BillingPlanBump schema: add .min(0) to ratios values — matches BillingPlan validation contract, prevents negative ratio overrides bypassing Zod. 4. billing.plan.repository.js: add JSDoc to BillingPlan() model accessor. 5. Add test: BillingPlanBump.ratios rejects negative values. * test(billing): add unit tests for billing.plan.repository.js Add 10 tests covering all 5 repository methods (findActive, findByVersion, deactivateAll, count, create) to fix Codecov patch coverage below 80% threshold on the new repository file. * docs(billing): align stale 'Compute fields' comment with meter rename
1 parent a229bcb commit 0f2320d

19 files changed

Lines changed: 1487 additions & 2 deletions

modules/billing/config/billing.development.config.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,38 @@ const config = {
2525
'unpaid',
2626
'paused',
2727
],
28+
/**
29+
* Feature flag — default OFF.
30+
* Set to true in downstream project config to enable meter-based pricing.
31+
* When false, all meter code paths are no-ops; legacy behavior unchanged.
32+
*/
33+
meterMode: false,
34+
/**
35+
* Meter unit parameters — downstream projects must override with their
36+
* actual unit economics before enabling meterMode in production.
37+
*
38+
* runBaseUnits: flat units charged per run (before per-feature ratios).
39+
* maxUnitsPerOperation: safety cap per single operation run.
40+
*/
41+
meter: {
42+
runBaseUnits: 1,
43+
/**
44+
* Conversion ratio: 1 unit = 1 / dollarsToUnitRatio USD of underlying cost.
45+
*
46+
* DOWNSTREAM-OVERRIDE-REQUIRED — the devkit default (1000) is illustrative.
47+
* Each downstream project must set this based on their unit economics
48+
* (cost-target × margin multiplier). Setting this wrong directly affects
49+
* gross margin: a value of N means each $1 of cost consumes N units, so
50+
* lowering N halves the margin coverage.
51+
*/
52+
dollarsToUnitRatio: 1000,
53+
maxUnitsPerOperation: 10000,
54+
},
55+
/**
56+
* Extra meter packs — downstream projects override with actual packs.
57+
* Example: [{ packId: 'pack_500k', meterUnits: 500000, stripePriceId: 'price_xxx' }]
58+
*/
59+
packs: [],
2860
},
2961
stripe: {
3062
secretKey: process.env.DEVKIT_NODE_stripe_secretKey ?? '',
@@ -38,6 +70,11 @@ const config = {
3870
monthly: process.env.DEVKIT_NODE_stripe_prices_pro_monthly ?? '',
3971
annual: process.env.DEVKIT_NODE_stripe_prices_pro_annual ?? '',
4072
},
73+
/**
74+
* Extra packs price map — downstream project override.
75+
* Example: { pack_500k: 'price_xxx', pack_2m: 'price_yyy' }
76+
*/
77+
packs: {},
4178
},
4279
},
4380
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Migration: Add meter fields to billing_usages collection
3+
*
4+
* Adds weekKey, meterUsed, meterQuota, planVersion, meterBreakdown,
5+
* resetAt, alertedAt80, alertedAt100, consumedHistoryIds to existing documents.
6+
*
7+
* The (organizationId, weekKey) sparse unique index is owned by the Mongoose
8+
* schema and synced at bootstrap — do not duplicate it here (name mismatch
9+
* causes IndexOptionsConflict).
10+
*
11+
* Additive only — no data backfill (existing documents keep legacy values).
12+
* Idempotent: safe to run multiple times.
13+
*
14+
* @returns {Promise<void>}
15+
*/
16+
export async function up() {
17+
// No raw index creation needed: all billingusages indexes are managed by the
18+
// Mongoose BillingUsage model and auto-synced on connection.
19+
// No document backfill: new fields have defaults in the Mongoose schema
20+
// (meterUsed: 0, meterQuota: 0, meterBreakdown: {}, consumedHistoryIds: []).
21+
// Existing documents without these fields will use Mongoose defaults on read.
22+
}
23+
24+
/**
25+
* Down: no-op — indexes are Mongoose-managed; fields are additive (one-way safe).
26+
* @returns {void}
27+
*/
28+
export function down() {}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Migration: Add planVersion and currentPeriodStart to subscriptions collection
3+
*
4+
* Schema changes (new fields + indexes on subscriptions, billingplans,
5+
* processedstripeevents) are owned by the respective Mongoose models and
6+
* auto-synced at bootstrap (autoIndex: true).
7+
*
8+
* This migration is a marker: it records the point at which these fields
9+
* were introduced so rollback tooling can target the correct state.
10+
*
11+
* Additive only — no data backfill.
12+
* Idempotent: safe to run multiple times.
13+
*
14+
* @returns {void}
15+
*/
16+
export function up() {}
17+
18+
/**
19+
* Down: no-op — indexes are Mongoose-managed; fields are additive (one-way safe).
20+
* @returns {void}
21+
*/
22+
export function down() {}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const Schema = mongoose.Schema;
7+
8+
/**
9+
* BillingPlan Data Model Mongoose
10+
*
11+
* Versioned plan definitions for meter-based pricing.
12+
* Each (planId, version) pair is immutable after creation.
13+
* Use bumpVersion to create a new version and deactivate the previous one.
14+
*/
15+
const BillingPlanMongoose = new Schema(
16+
{
17+
planId: {
18+
type: String,
19+
required: true,
20+
trim: true,
21+
},
22+
version: {
23+
type: String,
24+
required: true,
25+
trim: true,
26+
},
27+
meterQuota: {
28+
type: Number,
29+
required: true,
30+
min: 0,
31+
},
32+
stripePriceMonthly: {
33+
type: String,
34+
trim: true,
35+
sparse: true,
36+
},
37+
stripePriceAnnual: {
38+
type: String,
39+
trim: true,
40+
sparse: true,
41+
},
42+
/**
43+
* Flexible ratio map for meter unit attribution.
44+
* Each key is a feature name; each value is a non-negative finite number.
45+
* Example: { scrap: 1, autofix: 2, wizard: 5 }
46+
*/
47+
ratios: {
48+
type: Schema.Types.Mixed,
49+
default: () => ({}),
50+
validate: {
51+
validator(value) {
52+
return (
53+
value != null &&
54+
typeof value === 'object' &&
55+
!Array.isArray(value) &&
56+
Object.values(value).every((n) => Number.isFinite(n) && n >= 0)
57+
);
58+
},
59+
message: 'ratios must be an object whose values are finite numbers >= 0',
60+
},
61+
},
62+
effectiveFrom: {
63+
type: Date,
64+
required: true,
65+
},
66+
effectiveUntil: {
67+
type: Date,
68+
default: null,
69+
},
70+
active: {
71+
type: Boolean,
72+
default: true,
73+
index: true,
74+
},
75+
},
76+
{
77+
timestamps: true,
78+
},
79+
);
80+
81+
/**
82+
* Unique index per (planId, version) — immutable identity
83+
*/
84+
BillingPlanMongoose.index({ planId: 1, version: 1 }, { unique: true });
85+
86+
/**
87+
* Compound index to look up the active plan for a given planId efficiently.
88+
* Partial unique constraint: only one active plan per planId at a time
89+
* (effectiveUntil: null means currently active). Guards against concurrent
90+
* bumpVersion() races producing two simultaneously-active versions.
91+
*/
92+
BillingPlanMongoose.index(
93+
{ planId: 1, active: 1, effectiveUntil: 1 },
94+
{ unique: true, partialFilterExpression: { active: true, effectiveUntil: null } },
95+
);
96+
97+
/**
98+
* Returns the hex string representation of the document ObjectId.
99+
* @returns {string} Hex string of the ObjectId.
100+
*/
101+
function addID() {
102+
return this._id.toHexString();
103+
}
104+
105+
/**
106+
* Model configuration
107+
*/
108+
BillingPlanMongoose.virtual('id').get(addID);
109+
BillingPlanMongoose.set('toJSON', {
110+
virtuals: true,
111+
});
112+
113+
mongoose.model('BillingPlan', BillingPlanMongoose);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { z } from 'zod';
5+
6+
/**
7+
* BillingPlan Zod schema — mirrors billing.plan.model.mongoose.js
8+
*/
9+
10+
const BillingPlan = z.object({
11+
planId: z.string().trim().min(1, 'planId is required'),
12+
version: z.string().trim().min(1, 'version is required'),
13+
meterQuota: z.number().int().min(0, 'meterQuota must be >= 0'),
14+
stripePriceMonthly: z.string().trim().optional().nullable(),
15+
stripePriceAnnual: z.string().trim().optional().nullable(),
16+
ratios: z.record(z.string(), z.number().min(0, 'ratio values must be >= 0')).default(() => ({})),
17+
effectiveFrom: z.coerce.date(),
18+
effectiveUntil: z.coerce.date().nullable().optional(),
19+
active: z.boolean().default(true),
20+
});
21+
22+
/**
23+
* Schema for bumping to a new plan version.
24+
* meterQuota is required; all other fields are optional overrides.
25+
*/
26+
const BillingPlanBump = z
27+
.object({
28+
meterQuota: z.number().int().min(0),
29+
ratios: z.record(z.string(), z.number().min(0, 'ratio values must be >= 0')).optional(),
30+
stripePriceMonthly: z.string().trim().optional().nullable(),
31+
stripePriceAnnual: z.string().trim().optional().nullable(),
32+
})
33+
.strict();
34+
35+
export default {
36+
BillingPlan,
37+
BillingPlanBump,
38+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const Schema = mongoose.Schema;
7+
8+
/**
9+
* ProcessedStripeEvent Data Model Mongoose
10+
*
11+
* Idempotency store for Stripe webhook events.
12+
* TTL of 30 days — events older than that can safely be re-processed
13+
* (Stripe's webhook retry window is 3 days).
14+
*/
15+
const ProcessedStripeEventMongoose = new Schema(
16+
{
17+
eventId: {
18+
type: String,
19+
required: true,
20+
unique: true,
21+
trim: true,
22+
},
23+
type: {
24+
type: String,
25+
required: true,
26+
trim: true,
27+
},
28+
processedAt: {
29+
type: Date,
30+
required: true,
31+
default: () => new Date(),
32+
},
33+
},
34+
{
35+
timestamps: false,
36+
// Disable _id virtuals to keep the document lean
37+
},
38+
);
39+
40+
/**
41+
* TTL index: automatically remove documents 30 days after processedAt
42+
*/
43+
ProcessedStripeEventMongoose.index({ processedAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });
44+
45+
/**
46+
* Returns the hex string representation of the document ObjectId.
47+
* @returns {string} Hex string of the ObjectId.
48+
*/
49+
function addID() {
50+
return this._id.toHexString();
51+
}
52+
53+
/**
54+
* Model configuration
55+
*/
56+
ProcessedStripeEventMongoose.virtual('id').get(addID);
57+
ProcessedStripeEventMongoose.set('toJSON', {
58+
virtuals: true,
59+
});
60+
61+
mongoose.model('ProcessedStripeEvent', ProcessedStripeEventMongoose);

modules/billing/models/billing.subscription.model.mongoose.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ const SubscriptionMongoose = new Schema(
4444
type: Boolean,
4545
default: false,
4646
},
47+
48+
// ── Meter fields (sparse — backward-compatible additions) ────────────────
49+
50+
/**
51+
* The plan version active on this subscription (e.g. "v1", "v2").
52+
* Only populated when meterMode is enabled.
53+
*/
54+
planVersion: {
55+
type: String,
56+
sparse: true,
57+
},
58+
/**
59+
* Start of the current billing period. Used to detect period changes
60+
* in webhook handlers and trigger meter period resets.
61+
*/
62+
currentPeriodStart: {
63+
type: Date,
64+
default: null,
65+
},
66+
/**
67+
* Timestamp when the subscription first entered past_due status.
68+
* Used to enforce the 7-day grace period before degraded mode.
69+
*/
70+
pastDueSince: {
71+
type: Date,
72+
default: null,
73+
},
4774
},
4875
{
4976
timestamps: true,

modules/billing/models/billing.subscription.schema.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const baseShape = {
2020
stripeCustomerId: optionalStripeId,
2121
stripeSubscriptionId: optionalStripeId,
2222
currentPeriodEnd: z.coerce.date().nullable().optional(),
23+
// ── Meter fields (optional — backward-compatible) ────────────────────────
24+
planVersion: z.string().trim().optional(),
25+
currentPeriodStart: z.coerce.date().nullable().optional(),
26+
pastDueSince: z.coerce.date().nullable().optional(),
2327
};
2428

2529
const Subscription = z.object({

0 commit comments

Comments
 (0)