Stripe-based billing with per-plan quota management and meter-based compute pricing.
Each downstream project defines its quotas in the billing config:
// config/defaults/development.config.js
billing: {
quotas: {
free: { documents: { create: 10, export: 50 } },
starter: { documents: { create: 100, export: 500 } },
pro: { documents: { create: Infinity, export: Infinity } },
},
}Enforces per-plan limits on routes. Returns 429 when quota exceeded.
import requireQuota from '../billing/middlewares/billing.requireQuota.js';
app.route('/api/documents')
.post(passport.authenticate('jwt', { session: false }),
organization.resolveOrganization,
requireQuota('documents', 'create'),
documents.create);Increment counters after successful operations:
import BillingUsageService from '../billing/services/billing.usage.service.js';
// After creating a document
await BillingUsageService.increment(organizationId, 'documents_create', 1);GET /api/billing/usage — returns current usage, limits, and plan for the authenticated org.
Listen for plan changes in downstream modules:
import billingEvents from '../billing/lib/events.js';
billingEvents.on('plan.changed', ({ organizationId, previousPlan, newPlan, isDowngrade }) => {
if (isDowngrade) {
// Handle downgrade (e.g. disable premium features)
}
});Charges compute units for a completed history run. Idempotent per (history._id, stepKey) pair.
import BillingMeterService from '../billing/services/billing.meter.service.js';
// Initial charge — default stepKey='initial'
await BillingMeterService.attribute(history, organizationId);
// Delta charge after setDigest (pass ONLY the digest cost delta, not cumulative)
await BillingMeterService.attribute(historyWithDigestDelta, organizationId, { stepKey: 'digest' });
// Delta charge per fix attempt
await BillingMeterService.attribute(historyWithFixDelta, organizationId, { stepKey: 'fix:1' });
await BillingMeterService.attribute(historyWithFixDelta, organizationId, { stepKey: 'fix:2' });Per-step semantics: each stepKey is independently idempotent. Replaying the same
(history._id, stepKey) is a safe no-op. Passing a new stepKey charges the delta once.
Cost composition rule: always pass ONLY the incremental cost delta in history.costs for each
step. Passing the cumulative total will double-charge costs already attributed in prior steps.
Backward compat: callers that only ever attribute once (no multi-step) continue to work
unchanged — the default stepKey='initial' makes the idempotency key ${history._id}:initial.
When Stripe plan.changed webhook fires, devkit calls forceRotateForPlanChange(orgId, { preserveUsage: true }) by default:
- Updates
meterQuotaandplanVersionsnapshot to the new plan - Preserves
meterUsed(no refund, no double-charge)
Consumers wanting clean-break behavior on downgrade should pass { preserveUsage: false }.
attribute() returns optimistically after usage increment + outbox row insert. Extras debit happens out of band; if it fails, cron retry-pending-extras-debit reconciles on the configured retry interval. After the configured failed-attempt limit, the outbox row is marked failed and the configured exhausted event is emitted for alerting.
Consumers should NOT retry on applied: true — the outbox handles eventual consistency.
| Knob | Type | Devkit default | Notes |
|---|---|---|---|
billing.meter.runBase |
number | 1 | METER_RUN_BASE base unit cost |
billing.meter.fallbackPlanId |
string | null | null | Fallback plan when active not resolvable |
billing.meter.dollarsToUnitRatio |
number | 1000 | Dollar → unit conversion. DOWNSTREAM-OVERRIDE-REQUIRED. Constant fallback: getDollarsToUnitRatio() |
billing.meter.maxUnitsPerOperation |
number | 10000 | Cap per single attribute call (dev config). Constant fallback: Infinity via getMaxUnitsPerOperation() |
billing.meter.ratioVersion |
string | null | '2026.05' | DOWNSTREAM-OVERRIDE-REQUIRED — pricing version namespace. Read directly from config, no constant wrapper |
billing.outbox.maxRetryAttempts |
number | 5 | Outbox retry limit before exhausted |
billing.outbox.retryIntervalSec |
number | 300 | Cron retry interval |
billing.crons.jitterMaxMs |
number | 60000 | Cron startup jitter max. Constant fallback: getCronJitterMaxMs() |
billing.planChange.preserveUsageDefault |
boolean | true | forceRotateForPlanChange default |
billing.alerts.thresholdPercents |
number[] | [80, 100] | Schema-supported only — others warn at boot, alert silently skipped. Constant fallback: getAlertThresholdPercents() |
billing.events.extrasExhausted |
string | 'billing.extras_debit.exhausted' | Event name for downstream alerting |
billing.defaultPlan |
string | 'free' | Default plan ID for fallback. Constant fallback: getDefaultPlanId() |
Canonical constant fallbacks live in modules/billing/lib/billing.constants.js. Downstream project overrides go in modules/billing/config/billing.development.config.js:
billing: {
meter: {
runBase: 1,
maxUnitsPerOperation: 10000,
fallbackPlanId: null,
},
outbox: {
maxRetryAttempts: 5,
retryIntervalSec: 300,
},
crons: {
jitterMaxMs: 60_000,
},
planChange: {
preserveUsageDefault: true,
},
alerts: {
thresholdPercents: [80, 100], // only 80 and 100 are supported schema fields; other values warn and are skipped
},
events: {
extrasExhausted: 'billing.extras_debit.exhausted',
},
}Example override:
// config/defaults/production.config.js
export default {
billing: {
meter: {
runBase: 2,
maxUnitsPerOperation: 25000,
fallbackPlanId: 'starter',
},
outbox: {
maxRetryAttempts: 8,
retryIntervalSec: 120,
},
crons: {
jitterMaxMs: 30_000,
},
planChange: {
preserveUsageDefault: false,
},
alerts: {
thresholdPercents: [80, 100],
},
events: {
extrasExhausted: 'billing.extras_debit.exhausted',
},
},
};meter.runBaseUnits is still accepted as a backward-compatible alias for meter.runBase.
// config/defaults/development.config.js (downstream project override)
stripe: {
automaticTax: true, // set true once Stripe Tax product is enabled in Dashboard
}Default is false (devkit default). When false, checkout sessions are created without
automatic_tax or customer_update fields, which is safe for merchants not using Stripe Tax
(e.g. auto-entrepreneur FR, franchise TVA art. 293 B).
V1 note: do NOT set automaticTax: true in production until the Stripe Tax product is
activated in the Stripe Dashboard. In LIVE mode without Tax setup Stripe returns:
invalid_request_error — The 'automatic_tax' parameter requires the Stripe Tax product.