Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions modules/billing/config/billing.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ const config = {
'unpaid',
'paused',
],
/**
* Feature flag — default OFF.
* Set to true in downstream project config to enable compute-based pricing.
* When false, all compute code paths are no-ops; legacy behavior unchanged.
*/
computeMode: false,
/**
* Compute unit parameters — downstream projects may override.
* runBaseUnits: flat units charged per run (before per-feature ratios).
* dollarsToComputeRatio: how many compute units per USD of LLM cost.
* maxComputePerScrap: safety cap per single scrape run.
*/
compute: {
runBaseUnits: 1,
dollarsToComputeRatio: 1000,
maxComputePerScrap: 10000,
},
/**
* Extra compute packs — downstream projects override with actual packs.
* Example: [{ packId: 'pack_500k', computeUnits: 500000, stripePriceId: 'price_xxx' }]
*/
packs: [],
},
stripe: {
secretKey: process.env.DEVKIT_NODE_stripe_secretKey ?? '',
Expand All @@ -38,6 +60,11 @@ const config = {
monthly: process.env.DEVKIT_NODE_stripe_prices_pro_monthly ?? '',
annual: process.env.DEVKIT_NODE_stripe_prices_pro_annual ?? '',
},
/**
* Extra packs price map — downstream project override.
* Example: { pack_500k: 'price_xxx', pack_2m: 'price_yyy' }
*/
packs: {},
},
},
};
Expand Down
43 changes: 43 additions & 0 deletions modules/billing/migrations/20260501000000-add-compute-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Migration: Add compute fields to billing_usages collection
*
* Adds weekKey, computeUsed, computeQuota, planVersion, computeBreakdown,
* resetAt, alertedAt80, alertedAt100, consumedHistoryIds to existing documents.
*
* Additive only — no data backfill (existing documents keep legacy values).
* Idempotent: safe to run multiple times.
*
* @returns {Promise<void>}
*/
export async function up() {
const { db } = await import('mongoose').then((m) => ({ db: m.default.connection.db }));
const collection = db.collection('billingusages');

// Add sparse index for weekKey — only indexes docs where weekKey exists.
// createIndex is idempotent: no-op if the index already exists.
await collection.createIndex(
{ organizationId: 1, weekKey: 1 },
{ unique: true, sparse: true, name: 'organizationId_weekKey_unique_sparse' },
);

// No document backfill: new fields have defaults in the Mongoose schema
// (computeUsed: 0, computeQuota: 0, computeBreakdown: {}, consumedHistoryIds: []).
// Existing documents without these fields will use Mongoose defaults on read.
}

/**
* Down: remove the weekKey sparse index.
* Does NOT remove fields (additive migrations are one-way safe).
* @returns {Promise<void>}
*/
export async function down() {
const { db } = await import('mongoose').then((m) => ({ db: m.default.connection.db }));
const collection = db.collection('billingusages');

try {
await collection.dropIndex('organizationId_weekKey_unique_sparse');
} catch (err) {
// Index may not exist — ignore "index not found" errors
if (err?.codeName !== 'IndexNotFound' && err?.code !== 27) throw err;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Migration: Add planVersion and currentPeriodStart to subscriptions collection
*
* Also ensures the billingplans and processedstripeevents collections have
* their required indexes.
*
* Additive only — no data backfill.
* Idempotent: safe to run multiple times.
*
* @returns {Promise<void>}
*/
export async function up() {
const { db } = await import('mongoose').then((m) => ({ db: m.default.connection.db }));

// ── subscriptions: sparse index for planVersion ─────────────────────────
const subscriptions = db.collection('subscriptions');
await subscriptions.createIndex(
{ planVersion: 1 },
{ sparse: true, name: 'planVersion_sparse' },
);

// ── billingplans: create indexes if collection doesn't already have them ──
const billingplans = db.collection('billingplans');
await billingplans.createIndex(
{ planId: 1, version: 1 },
{ unique: true, name: 'planId_version_unique' },
);
await billingplans.createIndex(
{ planId: 1, active: 1, effectiveUntil: 1 },
{ name: 'planId_active_effectiveUntil' },
);

// ── processedstripeevents: TTL index ─────────────────────────────────────
const events = db.collection('processedstripeevents');
await events.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 30 * 24 * 60 * 60, name: 'processedAt_ttl_30d' },
);
}

/**
* Down: remove the added sparse index from subscriptions.
* Does NOT remove fields or drop the new collections.
* @returns {Promise<void>}
*/
export async function down() {
const { db } = await import('mongoose').then((m) => ({ db: m.default.connection.db }));
const subscriptions = db.collection('subscriptions');

try {
await subscriptions.dropIndex('planVersion_sparse');
} catch (err) {
if (err?.codeName !== 'IndexNotFound' && err?.code !== 27) throw err;
}
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
95 changes: 95 additions & 0 deletions modules/billing/models/billing.plan.model.mongoose.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Module dependencies
*/
import mongoose from 'mongoose';

const Schema = mongoose.Schema;

/**
* BillingPlan Data Model Mongoose
*
* Versioned plan definitions for compute-based pricing.
* Each (planId, version) pair is immutable after creation.
* Use bumpVersion to create a new version and deactivate the previous one.
*/
const BillingPlanMongoose = new Schema(
{
planId: {
type: String,
required: true,
trim: true,
},
version: {
type: String,
required: true,
trim: true,
},
computeQuota: {
type: Number,
required: true,
min: 0,
},
stripePriceMonthly: {
type: String,
trim: true,
sparse: true,
},
stripePriceAnnual: {
type: String,
trim: true,
sparse: true,
},
/**
* Flexible ratio map for compute unit attribution.
* Example: { scrap: 1, autofix: 2, wizard: 5 }
*/
ratios: {
type: Schema.Types.Mixed,
default: () => ({}),
},
Comment thread
PierreBrisorgueil marked this conversation as resolved.
effectiveFrom: {
type: Date,
required: true,
},
effectiveUntil: {
type: Date,
default: null,
},
active: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
},
);

/**
* Unique index per (planId, version) — immutable identity
*/
BillingPlanMongoose.index({ planId: 1, version: 1 }, { unique: true });
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Compound index to look up the active plan for a given planId efficiently
*/
BillingPlanMongoose.index({ planId: 1, active: 1, effectiveUntil: 1 });
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated

/**
* Returns the hex string representation of the document ObjectId.
* @returns {string} Hex string of the ObjectId.
*/
function addID() {
return this._id.toHexString();
}

/**
* Model configuration
*/
BillingPlanMongoose.virtual('id').get(addID);
BillingPlanMongoose.set('toJSON', {
virtuals: true,
});

mongoose.model('BillingPlan', BillingPlanMongoose);
38 changes: 38 additions & 0 deletions modules/billing/models/billing.plan.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Module dependencies
*/
import { z } from 'zod';

/**
* BillingPlan Zod schema — mirrors billing.plan.model.mongoose.js
*/

const BillingPlan = z.object({
planId: z.string().trim().min(1, 'planId is required'),
version: z.string().trim().min(1, 'version is required'),
computeQuota: z.number().int().min(0, 'computeQuota must be >= 0'),
stripePriceMonthly: z.string().trim().optional().nullable(),
stripePriceAnnual: z.string().trim().optional().nullable(),
ratios: z.record(z.string(), z.number()).default(() => ({})),
effectiveFrom: z.coerce.date(),
effectiveUntil: z.coerce.date().nullable().optional(),
active: z.boolean().default(true),
});

/**
* Schema for bumping to a new plan version.
* computeQuota and ratios are required; other fields are optional overrides.
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
*/
const BillingPlanBump = z
.object({
computeQuota: z.number().int().min(0),
ratios: z.record(z.string(), z.number()).optional(),
stripePriceMonthly: z.string().trim().optional().nullable(),
stripePriceAnnual: z.string().trim().optional().nullable(),
})
.strict();
Comment thread
PierreBrisorgueil marked this conversation as resolved.

export default {
BillingPlan,
BillingPlanBump,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Module dependencies
*/
import mongoose from 'mongoose';

const Schema = mongoose.Schema;

/**
* ProcessedStripeEvent Data Model Mongoose
*
* Idempotency store for Stripe webhook events.
* TTL of 30 days — events older than that can safely be re-processed
* (Stripe's webhook retry window is 3 days).
*/
const ProcessedStripeEventMongoose = new Schema(
{
eventId: {
type: String,
required: true,
unique: true,
trim: true,
},
type: {
type: String,
required: true,
trim: true,
},
processedAt: {
type: Date,
required: true,
default: () => new Date(),
},
},
{
timestamps: false,
// Disable _id virtuals to keep the document lean
},
);

/**
* TTL index: automatically remove documents 30 days after processedAt
*/
ProcessedStripeEventMongoose.index({ processedAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Returns the hex string representation of the document ObjectId.
* @returns {string} Hex string of the ObjectId.
*/
function addID() {
return this._id.toHexString();
}

/**
* Model configuration
*/
ProcessedStripeEventMongoose.virtual('id').get(addID);
ProcessedStripeEventMongoose.set('toJSON', {
virtuals: true,
});

mongoose.model('ProcessedStripeEvent', ProcessedStripeEventMongoose);
27 changes: 27 additions & 0 deletions modules/billing/models/billing.subscription.model.mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ const SubscriptionMongoose = new Schema(
type: Boolean,
default: false,
},

// ── Compute fields (sparse — backward-compatible additions) ─────────────

/**
* The plan version active on this subscription (e.g. "v1", "v2").
* Only populated when computeMode is enabled.
*/
planVersion: {
type: String,
sparse: true,
},
/**
* Start of the current billing period. Used to detect period changes
* in webhook handlers and trigger compute period resets.
*/
currentPeriodStart: {
type: Date,
default: null,
},
/**
* Timestamp when the subscription first entered past_due status.
* Used to enforce the 7-day grace period before degraded mode.
*/
pastDueSince: {
type: Date,
default: null,
},
},
{
timestamps: true,
Expand Down
4 changes: 4 additions & 0 deletions modules/billing/models/billing.subscription.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const baseShape = {
stripeCustomerId: optionalStripeId,
stripeSubscriptionId: optionalStripeId,
currentPeriodEnd: z.coerce.date().nullable().optional(),
// ── Compute fields (optional — backward-compatible) ──────────────────────
planVersion: z.string().trim().optional(),
currentPeriodStart: z.coerce.date().nullable().optional(),
pastDueSince: z.coerce.date().nullable().optional(),
};

const Subscription = z.object({
Expand Down
Loading
Loading