diff --git a/ERRORS.md b/ERRORS.md index 4b53928bd..1587fa895 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -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:`) with one guarded `$inc` keyed `settle:` 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-` 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 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index c8d5c793b..7a9ddf3dd 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -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 diff --git a/modules/billing/services/billing.plans.service.js b/modules/billing/services/billing.plans.service.js index aac018321..d143f9f3a 100644 --- a/modules/billing/services/billing.plans.service.js +++ b/modules/billing/services/billing.plans.service.js @@ -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. @@ -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); }; diff --git a/modules/billing/tests/billing.plans.unit.tests.js b/modules/billing/tests/billing.plans.unit.tests.js index 5794ff3fb..2a86ba4d5 100644 --- a/modules/billing/tests/billing.plans.unit.tests.js +++ b/modules/billing/tests/billing.plans.unit.tests.js @@ -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 () => { @@ -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); + }); });