Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ Use this file as a compact memory of recurring AI mistakes.
- [2026-09-24] billing/meter: overflow units (usage past `meterQuota`) are debited from extras, which may go negative, but the weekly reset only zeroed `meterUsed` and never touched that debt -> on a plan with a weekly quota the debt cut every later week by the same amount and locked the org out for good once it reached the quota; `resetWeek` now settles overflow debt once per week from the target week's REMAINING quota. Four traps on the way: (1) writing `meterUsed = settle` into the inserted week doc lost the settlement whenever the doc already existed (concurrent reset, `incrementMeter` first) -> fixed by charging the STORED credit (idempotent `adjustment`, refId `settle:<weekKey>`) with one guarded `$inc` keyed `settle:<weekKey>` in `consumedAttributionKeys`, on every call; (2) counting every `refund` entry as refund debt excluded overflow debt forever after any refund -> only the part of a refund that takes the replayed balance below zero counts, and a pack purchase repays it; (3) the below-zero part of a pack `expiration` is treated like refund debt: never settled from quota, only a new pack repays it (known limitation, unchanged: the expiry sweep removes a pack's full amount even when part was consumed), and the replay follows ledger array order (the `$push` commit order), not `at`; (4) capping `settle` at the plan quota assumed a fresh week, but the cron anchors on `now`, so the target week is usually already partly used -> the part of the credit pushing `meterUsed` past the quota was forgiven debt; now the week doc is read (or created) FIRST and `settle = min(quota − meterUsed, overflowDebt)`, the rest stays as debt for the next reset; see pierreb-devkit/Node#3914
- [2026-09-24] billing: `billing.meter.service.js unitsFromCosts` documented `ratios.default` as the per-key fallback (`billing.config.zod.js` JSDoc) but the code never read it, hardcoding `1` for any cost key absent from the plan's ratio map -> a plan configuring `ratios: { default: 2 }` silently billed every unlisted feature at `1`, not `2`; latent because the shipped example used `default: 1`, which happened to match the hardcoded fallback. Fix: compute the fallback once as `ratios.default` when it is a number `>= 0`, else `1`, and use it in place of the literal `1`; see pierreb-devkit/Node#4025
- [2026-09-25] billing/extras: the expiry sweep (`addExpirationEntries`) removed a pack's FULL amount even when part or all of it was already consumed or refunded -> phantom debt the next pack paid twice. Now the ledger is replayed in array order, debits (and a pack's own refunds, matched by `stripeSessionId`) are attributed to live credits earliest-expiry-first (no-expiry credits last, uncovered debt repaid by the next credit), and an expiring pack removes only its own remainder at its `expiresAt`; a fully spent pack gets a zero-amount `expiration` marker (schemas allow 0 for that kind only, hidden from `listLedgerPage`) so the `expire-<topupId>` guard still holds. The write is one `findOneAndUpdate` guarded by the snapshot's ledger `$size` (append-only ⇒ unchanged length = unchanged ledger), retried on a concurrent write. Legacy full-amount entries are left as is; see pierreb-devkit/Node#4120
- [2026-09-25] billing/stripe: `billing.plans.service.js fetchPlansFromStripe` fell back to the raw Stripe product id (`product.metadata?.planId || product.id`) when a product carried no `planId` metadata -> ANY active Stripe product (a one-time pack, a recurring product sold outside the plans catalogue via a Payment Link) advertised itself as a public plan via `GET /api/billing/plans`, often with a null price id; fix = filter to `product.metadata?.planId` truthy BEFORE mapping, drop the id fallback entirely — a product now needs `metadata.planId` to be listed. Left every OTHER `metadata?.planId ||` fallback untouched (`billing.planResolver.js`, `billing.webhook.service.js`, `billing.admin.service.js`) since those resolve an EXISTING subscription's plan, not the public catalogue, and must keep working for a recurring product sold outside it; see pierreb-devkit/Node#4113
16 changes: 16 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ Breaking changes and upgrade notes for downstream projects.

---

## Billing: public plans listing now requires a plan tag (2026-09-25)

`GET /api/billing/plans` no longer falls back to a Stripe product's raw id when
`metadata.planId` is missing. Any active Stripe product without that metadata key —
a one-time pack, a recurring product sold outside the plans catalogue via a Payment
Link, or anything else active in the Stripe account — disappears from the listing
instead of showing up as a plan with a null price. Subscription/webhook plan
resolution is unchanged: such a product still works as a normal purchase, it just no
longer appears in `GET /api/billing/plans`.

**What you will see:** any Stripe product you want listed as a plan needs
`metadata.planId` set. `createCheckout` validates the requested `priceId` against the
same listing, so self-serve checkout also accepts only prices of tagged plans. An
untagged catalogue logs a `[billing.plans]` warning and lists no plans. No schema
change, no migration to run.

## Billing: weekly reset now repays overflow debt (2026-09-24)

In `meterMode`, units consumed past the weekly quota are debited from extras, which
Expand Down
79 changes: 46 additions & 33 deletions modules/billing/services/billing.plans.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,41 +69,46 @@ const fetchPlansFromStripe = async (stripe) => {
// billing misrouting — warn explicitly with both plan IDs + the duplicate price ID.
const priceIdToPlanIds = {};

const plans = products.map((product) => {
const productPrices = pricesByProduct[product.id] || [];
const planId = product.metadata?.planId || product.id;

let monthlyPrice = 0;
let annualPrice = 0;
let stripePriceMonthly = null;
let stripePriceAnnual = null;

// Expects one active price per interval per product; last match wins if duplicates exist
for (const price of productPrices) {
const amount = typeof price.unit_amount === 'number' ? price.unit_amount : 0;
if (price.recurring?.interval === 'month') {
monthlyPrice = amount / 100;
stripePriceMonthly = price.id;
} else if (price.recurring?.interval === 'year') {
annualPrice = amount / 100;
stripePriceAnnual = price.id;
// Only products explicitly tagged with metadata.planId are plans. Everything else
// active in the Stripe account (one-time packs, products sold outside the plans
// catalogue via a Payment Link, ...) is not part of the public plans listing.
const plans = products
.filter((product) => Boolean(product.metadata?.planId))
.map((product) => {
const productPrices = pricesByProduct[product.id] || [];
const planId = product.metadata.planId;

let monthlyPrice = 0;
let annualPrice = 0;
let stripePriceMonthly = null;
let stripePriceAnnual = null;

// Expects one active price per interval per product; last match wins if duplicates exist
for (const price of productPrices) {
const amount = typeof price.unit_amount === 'number' ? price.unit_amount : 0;
if (price.recurring?.interval === 'month') {
monthlyPrice = amount / 100;
stripePriceMonthly = price.id;
} else if (price.recurring?.interval === 'year') {
annualPrice = amount / 100;
stripePriceAnnual = price.id;
}
// Track price-to-plan mapping for duplicate detection
if (price.id) {
if (!priceIdToPlanIds[price.id]) priceIdToPlanIds[price.id] = [];
priceIdToPlanIds[price.id].push(planId);
}
}
// Track price-to-plan mapping for duplicate detection
if (price.id) {
if (!priceIdToPlanIds[price.id]) priceIdToPlanIds[price.id] = [];
priceIdToPlanIds[price.id].push(planId);
}
}

return {
planId,
name: product.name,
monthlyPrice,
annualPrice,
stripePriceMonthly,
stripePriceAnnual,
};
});
return {
planId,
name: product.name,
monthlyPrice,
annualPrice,
stripePriceMonthly,
stripePriceAnnual,
};
});

// Emit explicit warn for any Stripe price ID mapped to more than one plan.
// This is a Stripe account configuration error that causes silent billing misrouting.
Expand All @@ -116,6 +121,14 @@ const fetchPlansFromStripe = async (stripe) => {
}
}

// Active products but no tagged plan: most likely a catalogue that was never tagged
// with metadata.planId. Surface it instead of silently serving an empty listing.
if (products.length > 0 && plans.length === 0) {
logger.warn('[billing.plans] no active Stripe product carries metadata.planId — plans listing is empty', {
activeProducts: products.length,
});
}

return plans.sort((a, b) => a.monthlyPrice - b.monthlyPrice);
};

Expand Down
78 changes: 75 additions & 3 deletions modules/billing/tests/billing.plans.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,50 @@ describe('Billing plans service unit tests:', () => {
expect(plans[1].planId).toBe('pro');
});

test('should fall back to product id when metadata planId is missing', async () => {
mockStripeInstance.products.list.mockReturnValue(mockListResult([{ id: 'prod_basic', name: 'Basic', metadata: {} }]));
test('should drop products without metadata.planId instead of falling back to the raw product id', async () => {
mockStripeInstance.products.list.mockReturnValue(
mockListResult([
{ id: 'prod_basic', name: 'Basic', metadata: {} },
{ id: 'prod_untagged', name: 'Untagged', metadata: { other: 'value' } },
]),
);
mockStripeInstance.prices.list.mockReturnValue(mockListResult([]));

const mod = await import('../services/billing.plans.service.js');
BillingPlansService = mod.default;

const plans = await BillingPlansService.getPlans();
expect(plans[0].planId).toBe('prod_basic');
expect(plans).toHaveLength(0);
});

test('mixed catalogue: plan products + a one-time product + a recurring product without planId → only planId products returned', async () => {
mockStripeInstance.products.list.mockReturnValue(
mockListResult([
{ id: 'prod_pro', name: 'Pro', metadata: { planId: 'pro' } },
{ id: 'prod_starter', name: 'Starter', metadata: { planId: 'starter' } },
// One-time product sold through the extras endpoint (e.g. a compute pack) — no planId.
{ id: 'prod_pack', name: 'Compute Pack', metadata: {} },
// Recurring product sold outside the plans catalogue (e.g. via a Payment Link) — no planId.
{ id: 'prod_manual_recurring', name: 'Manually Sold Enterprise', metadata: {} },
]),
);
mockStripeInstance.prices.list.mockReturnValue(
mockListResult([
{ product: 'prod_pro', recurring: { interval: 'month' }, unit_amount: 2900, id: 'price_pro_m' },
{ product: 'prod_starter', recurring: { interval: 'month' }, unit_amount: 900, id: 'price_starter_m' },
// One-time price — no `recurring` field.
{ product: 'prod_pack', unit_amount: 5000, id: 'price_pack' },
{ product: 'prod_manual_recurring', recurring: { interval: 'month' }, unit_amount: 9900, id: 'price_manual_m' },
]),
);

const mod = await import('../services/billing.plans.service.js');
BillingPlansService = mod.default;

const plans = await BillingPlansService.getPlans();
expect(plans).toHaveLength(2);
expect(plans.map((p) => p.planId).sort()).toEqual(['pro', 'starter']);
expect(plans.every((p) => Boolean(p.planId))).toBe(true);
});

test('should use cached plans on second call', async () => {
Expand Down Expand Up @@ -470,4 +505,41 @@ describe('Billing plans service unit tests:', () => {
);
expect(warnCalls).toHaveLength(0);
});

// ── Untagged catalogue warn ────────────────────────────────────────────
test('warns when active products exist but none carries metadata.planId', async () => {
mockStripeInstance.products.list.mockReturnValue(
mockListResult([{ id: 'prod_untagged', name: 'Untagged', metadata: {} }]),
);
mockStripeInstance.prices.list.mockReturnValue(
mockListResult([{ product: 'prod_untagged', recurring: { interval: 'month' }, unit_amount: 900, id: 'price_u_m' }]),
);

const mod = await import('../services/billing.plans.service.js');
BillingPlansService = mod.default;
const loggerMod = await import('../../../lib/services/logger.js');
const mockLogger = loggerMod.default;

const plans = await BillingPlansService.getPlans();

expect(plans).toEqual([]);
expect(mockLogger.warn).toHaveBeenCalledWith(
'[billing.plans] no active Stripe product carries metadata.planId — plans listing is empty',
expect.objectContaining({ activeProducts: 1 }),
);
});

test('does not warn about an untagged catalogue when at least one plan is tagged', async () => {
const mod = await import('../services/billing.plans.service.js');
BillingPlansService = mod.default;
const loggerMod = await import('../../../lib/services/logger.js');
const mockLogger = loggerMod.default;

await BillingPlansService.getPlans();

const warnCalls = mockLogger.warn.mock.calls.filter((args) =>
String(args[0]).includes('no active Stripe product carries metadata.planId'),
);
expect(warnCalls).toHaveLength(0);
});
});
Loading