diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts index d8ebbdee5..62ba296ad 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts @@ -1,5 +1,5 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; -import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription, isSubscriptionInEffect } from "@/lib/payments"; +import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isSubscriptionCancelable, isSubscriptionInEffect } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan"; @@ -80,13 +80,26 @@ export const DELETE = createSmartRouteHandler({ }); const allSubs = Object.values(subMap); + const nowMillis = Date.now(); + // Explains why a matched-but-uncancelable sub can't be canceled: already + // winding down (double cancel), or in-effect in a state we don't cancel + // from (e.g. past_due). No-op when no in-effect sub matches. + const throwIfUncancelableButInEffect = (candidates: typeof allSubs): void => { + const inEffect = candidates.find(s => isSubscriptionInEffect(s, nowMillis)); + if (!inEffect) return; + if (inEffect.status === "canceled" || inEffect.cancelAtPeriodEnd) { + throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period."); + } + throw new StatusError(400, "This subscription cannot be canceled in its current state."); + }; + let subscriptions; if (query.subscription_id) { // Cancel by subscription DB ID (used for inline products that have no product_id) - subscriptions = allSubs.filter(s => - s.id === query.subscription_id && isActiveSubscription(s) - ); + const matching = allSubs.filter(s => s.id === query.subscription_id); + subscriptions = matching.filter(s => isSubscriptionCancelable(s)); if (subscriptions.length === 0) { + throwIfUncancelableButInEffect(matching); throw new StatusError(400, "No active subscription found with this ID for the given customer."); } } else { @@ -111,21 +124,13 @@ export const DELETE = createSmartRouteHandler({ throw new StatusError(400, "Customer does not have this product."); } - // Find the active subscription to cancel - subscriptions = allSubs.filter(s => - s.productId === params.product_id && isActiveSubscription(s) - ); + // Find the cancelable subscriptions for this product + const matching = allSubs.filter(s => s.productId === params.product_id); + subscriptions = matching.filter(s => isSubscriptionCancelable(s)); if (subscriptions.length === 0) { - // Owned but no active sub: either already winding down (canceled - // with a future endedAt) or owned via OTP — don't claim the former - // is a one-time purchase. - const nowMillis = Date.now(); - const windingDown = allSubs.find(s => - s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis) - ); - if (windingDown) { - throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period."); - } + // Owned but nothing cancelable: winding down / uncancelable state, + // or owned via OTP — don't claim the former is a one-time purchase. + throwIfUncancelableButInEffect(matching); throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); } } @@ -133,32 +138,46 @@ export const DELETE = createSmartRouteHandler({ const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId); const stripe = hasStripeSubscription ? await getStripeForAccount({ tenancy: auth.tenancy }) : undefined; for (const subscription of subscriptions) { + let updatedSub; if (subscription.stripeSubscriptionId) { const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation."); // Cancel at period end, not `subscriptions.cancel()` (immediate) — - // matches the confirm dialog and the local-sub branch below. The - // webhook syncs the local row when Stripe ends it. + // matches the confirm dialog and the local-sub branch below. Update + // the local row eagerly (mirroring the refund route) so the product + // list reflects the wind-down before the webhook sync arrives; the + // Stripe sub stays `active` with cancel_at_period_end until Stripe + // ends it at the period boundary. await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); - continue; - } - await prisma.subscription.update({ - where: { - tenancyId_id: { - tenancyId: auth.tenancy.id, - id: subscription.id, + updatedSub = await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, }, - }, - data: { - status: SubscriptionStatus.canceled, - cancelAtPeriodEnd: true, - canceledAt: new Date(), - endedAt: new Date(subscription.currentPeriodEndMillis), - }, - }); + data: { + cancelAtPeriodEnd: true, + canceledAt: new Date(), + endedAt: new Date(subscription.currentPeriodEndMillis), + }, + }); + } else { + updatedSub = await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, + }, + data: { + status: SubscriptionStatus.canceled, + cancelAtPeriodEnd: true, + canceledAt: new Date(), + endedAt: new Date(subscription.currentPeriodEndMillis), + }, + }); + } // dual write - prisma and bulldozer - const updatedSub = await prisma.subscription.findUniqueOrThrow({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: subscription.id } }, - }); await bulldozerWriteSubscription(updatedSub); } diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index 1829010ac..6aa360020 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -1,5 +1,6 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionInEffect, productToInlineProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionCancelable, isSubscriptionInEffect, productToInlineProduct } from "@/lib/payments"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import type { SubscriptionRow } from "@/lib/payments/schema/types"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@hexclave/shared"; @@ -70,10 +71,19 @@ export const GET = createSmartRouteHandler({ // so we normalize subscription productIds to match. In-effect (not // active): a canceled-at-period-end sub still backs its owned product // and must not fall through to the one-time-purchase branch below. + // When several in-effect subs share a productId (e.g. one quantity of a + // stackable product was canceled by subscription_id), prefer an active + // one so the winding-down sub can't shadow it and hide the cancel button. const nowMillis = Date.now(); - const inEffectSubByProductId = new Map( - Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const) - ); + const inEffectSubByProductId = new Map(); + for (const s of Object.values(subMap)) { + if (!isSubscriptionInEffect(s, nowMillis)) continue; + const key = s.productId ?? "__null__"; + const existing = inEffectSubByProductId.get(key); + if (existing == null || (!isActiveSubscription(existing) && isActiveSubscription(s))) { + inEffectSubByProductId.set(key, s); + } + } // Build switch options per product line (available plan upgrades/downgrades) const switchOptionsByProductLineId = new Map }>>(); @@ -131,8 +141,7 @@ export const GET = createSmartRouteHandler({ subscription_id: sub.id, current_period_end: sub.currentPeriodEndMillis ? new Date(sub.currentPeriodEndMillis).toISOString() : null, cancel_at_period_end: sub.cancelAtPeriodEnd, - // Already-winding-down subs can't be canceled again - is_cancelable: isActiveSubscription(sub) && !sub.cancelAtPeriodEnd, + is_cancelable: isSubscriptionCancelable(sub), } : null, switch_options: switchOptions, }, diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 5b7f4365a..882787686 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -267,6 +267,11 @@ export const POST = createSmartRouteHandler({ payment_behavior: "error_if_incomplete", payment_settings: { save_default_payment_method: "on_subscription" }, default_payment_method: resolvedPaymentMethodId, + // Paying to switch plans is an explicit "keep me subscribed": clear a + // pending cancel-at-period-end, otherwise the new plan the customer + // just paid for still dies at the period boundary. Stripe persists + // the flag across item updates unless reset. + cancel_at_period_end: false, items: [{ id: existingItem.id, price_data: { @@ -310,6 +315,10 @@ export const POST = createSmartRouteHandler({ currentPeriodStart: sanitizedUpdateDates.start, currentPeriodEnd: sanitizedUpdateDates.end, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end, + // Clear the wind-down marks the cancel route wrote eagerly — + // without this the reactivated sub still ends at the old boundary. + canceledAt: null, + endedAt: null, }, }); // dual write - prisma and bulldozer diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 35328a726..0e7dbdaeb 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,7 +1,7 @@ import { KnownErrors } from '@hexclave/shared'; import { generateUuid } from '@hexclave/shared/dist/utils/uuids'; import { describe, expect, it } from 'vitest'; -import { validatePurchaseSession } from './payments'; +import { isSubscriptionCancelable, isSubscriptionInEffect, validatePurchaseSession } from './payments'; import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { globalPrismaClient } from "@/prisma-client"; @@ -233,5 +233,63 @@ describe.sequential('validatePurchaseSession - purchase guards (real DB)', () => { priceId: 'nonexistent' }, )).rejects.toThrowError('Price not found'); }); + + it('treats a canceled-at-period-end sub in the same line as a replaceable conflict, not an OTP', async () => { + const lineId = `line-winddown-${testId}`; + const subId = generateUuid(); + const now = new Date(); + const periodEnd = new Date(now.getTime() + 86400000); + const product = makeProduct({ productLineId: lineId }); + await prisma.subscription.create({ + data: { + id: subId, tenancyId, customerId, customerType: 'CUSTOM', + productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1, + stripeSubscriptionId: null, status: 'canceled', + currentPeriodStart: now, currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd, + creationSource: 'TEST_MODE', + }, + }); + await bulldozerWriteSubscription({ + id: subId, tenancyId, customerId, customerType: 'CUSTOM', + productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1, + stripeSubscriptionId: null, status: 'canceled', + currentPeriodStart: now, currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd, refundedAt: null, productRevokedAt: null, + creationSource: 'TEST_MODE', createdAt: now, + }); + const res = await callValidate(product, { productId: `prod-after-winddown-${testId}` }); + expect(res.conflictingSubscriptions.map(s => s.id)).toContain(subId); + }); +}); + +describe('isSubscriptionInEffect', () => { + const nowMillis = 1_800_000_000_000; + + it('treats endedAt=null as in effect regardless of status', () => { + expect(isSubscriptionInEffect({ endedAtMillis: null }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAt: null }, nowMillis)).toBe(true); + }); + + it('is in effect strictly before endedAt, not at or after it', () => { + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis + 1 }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis }, nowMillis)).toBe(false); + expect(isSubscriptionInEffect({ endedAtMillis: nowMillis - 1 }, nowMillis)).toBe(false); + }); + + it('accepts the Prisma Date shape', () => { + expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis + 1) }, nowMillis)).toBe(true); + expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis) }, nowMillis)).toBe(false); + }); +}); + +describe('isSubscriptionCancelable', () => { + it('is true only for active/trialing subs not already winding down', () => { + expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: false })).toBe(true); + expect(isSubscriptionCancelable({ status: 'trialing', cancelAtPeriodEnd: false })).toBe(true); + expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: true })).toBe(false); + expect(isSubscriptionCancelable({ status: 'canceled', cancelAtPeriodEnd: true })).toBe(false); + expect(isSubscriptionCancelable({ status: 'past_due', cancelAtPeriodEnd: false })).toBe(false); + }); }); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index eb77d0bd6..8d3338124 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -126,6 +126,16 @@ export function isActiveSubscription(subscription: { status: string }): boolean return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing; } +/** + * The subscription can be canceled (again): it is active and not already + * winding down. Keep this in sync between the product list's `is_cancelable` + * field and the cancel route's filter, so `is_cancelable: false` always + * implies the DELETE returns 400. + */ +export function isSubscriptionCancelable(subscription: { status: string, cancelAtPeriodEnd: boolean }): boolean { + return isActiveSubscription(subscription) && !subscription.cancelAtPeriodEnd; +} + /** * The subscription still confers its product/items, regardless of whether it * will renew. Deliberately status-agnostic and `endedAt`-based, mirroring @@ -133,10 +143,10 @@ export function isActiveSubscription(subscription: { status: string }): boolean * `endedAtMillis`; every terminal writer sets `endedAt`). */ export function isSubscriptionInEffect( - subscription: { endedAtMillis?: number | null, endedAt?: Date | null }, + subscription: { endedAtMillis: number | null } | { endedAt: Date | null }, nowMillis: number, ): boolean { - const endedAtMillis = subscription.endedAtMillis != null + const endedAtMillis = "endedAtMillis" in subscription ? subscription.endedAtMillis : subscription.endedAt != null ? subscription.endedAt.getTime() : null; return endedAtMillis == null || endedAtMillis > nowMillis; @@ -462,12 +472,20 @@ export async function validatePurchaseSession(options: { // a duplicate request during lag would slip past it and silently re-grant. // We query Prisma directly so the sub guard is symmetric with the OTP guard // and works for products with no productLineId. - const activeSubs = await prisma.subscription.findMany({ + // Includes canceled-at-period-end subs (canceled with a future endedAt) — + // they still back their product, so a purchase in the same line must treat + // them as replaceable conflicts, not as OTPs. Deliberately narrower than + // isSubscriptionInEffect: incomplete/past_due subs are excluded so a retry + // after a failed payment isn't rejected as ProductAlreadyGranted below. + const replaceableSubs = await prisma.subscription.findMany({ where: { tenancyId, customerType: typedToUppercase(customerType), customerId, - status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + OR: [ + { status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] } }, + { status: SubscriptionStatus.canceled, endedAt: { gt: new Date() } }, + ], }, select: { id: true, stripeSubscriptionId: true, productId: true, product: true }, }); @@ -483,13 +501,13 @@ export async function validatePurchaseSession(options: { }, select: { id: true }, }); - if (activeOtp || activeSubs.some(s => s.productId === productId)) { + if (activeOtp || replaceableSubs.some(s => s.productId === productId)) { throw new KnownErrors.ProductAlreadyGranted(productId, customerId); } } if (productLineId) { - conflictingSubscriptions = activeSubs + conflictingSubscriptions = replaceableSubs .filter(s => (s.product as Product).productLineId === productLineId && !addOnBaseProductIds.includes(s.productId ?? "") @@ -501,14 +519,14 @@ export async function validatePurchaseSession(options: { // backing it, the customer owns via OTP — which can't be replaced by a switch. // (We still rely on bulldozer here because OTPs aren't part of the duplicate- // request race we're guarding above and OTP propagation lag is low-stakes.) - const activeSubProductIds = new Set(activeSubs.map(s => s.productId ?? "__null__")); + const subBackedProductIds = new Set(replaceableSubs.map(s => s.productId ?? "__null__")); const otpInProductLine = Object.entries(ownedProducts).some( ([pid, p]) => p.productLineId === productLineId && p.quantity > 0 && !addOnBaseProductIds.includes(pid) && !isStackableSelfMatch(pid) - && !activeSubProductIds.has(pid), + && !subBackedProductIds.has(pid), ); if (otpInProductLine && conflictingSubscriptions.length === 0) { // TODO: reconsider the coupling here between products and purchases. OTPs can diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 411726e90..2254a0915 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -151,11 +151,7 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi // comment for why gating on `status` here would (and did) cause the free // plan to be double-granted on top of a just-created incomplete paid sub). const nowMillis = Date.now(); - const productLineStillOccupiedBy = (sub: { - product: ProductSnapshot, - endedAtMillis?: number | null, - endedAt?: Date | null, - }): boolean => { + const productLineStillOccupiedBy = (sub: { product: ProductSnapshot } & ({ endedAtMillis: number | null } | { endedAt: Date | null })): boolean => { if (sub.product.productLineId !== freeProductLineId) return false; if (isAddOnProduct(sub.product)) return false; return isSubscriptionInEffect(sub, nowMillis); diff --git a/apps/backend/src/lib/plan-usage.test.ts b/apps/backend/src/lib/plan-usage.test.ts index cc85aceaa..78c61b288 100644 --- a/apps/backend/src/lib/plan-usage.test.ts +++ b/apps/backend/src/lib/plan-usage.test.ts @@ -1,6 +1,6 @@ import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans"; import type { SubscriptionRow } from "./payments/schema/types"; -import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip } from "./plan-usage"; +import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip, resolveInEffectPlanSubscription } from "./plan-usage"; import { describe, expect, it } from "vitest"; function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow { @@ -189,3 +189,44 @@ describe("billing period selection", () => { `); }); }); + +describe("resolveInEffectPlanSubscription", () => { + const futureMillis = Date.now() + 30 * 86400000; + const pastMillis = Date.now() - 86400000; + + it("keeps resolving a canceled-at-period-end plan sub until its endedAt passes", () => { + const windingDownTeam: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_team", + productId: "team", + status: "canceled", + cancelAtPeriodEnd: true, + canceledAtMillis: pastMillis, + endedAtMillis: futureMillis, + }; + const activeFree: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_free", + productId: "free", + }; + expect(resolveInEffectPlanSubscription({ sub_team: windingDownTeam, sub_free: activeFree })?.id).toBe("sub_team"); + }); + + it("falls back to the free plan sub once the canceled sub has ended", () => { + const endedTeam: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis - 86400000, pastMillis), + id: "sub_team", + productId: "team", + status: "canceled", + cancelAtPeriodEnd: true, + canceledAtMillis: pastMillis - 86400000, + endedAtMillis: pastMillis, + }; + const activeFree: SubscriptionRow = { + ...createSubscriptionPeriod(pastMillis, futureMillis), + id: "sub_free", + productId: "free", + }; + expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree })?.id).toBe("sub_free"); + }); +}); diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index 83e1f803e..756a74140 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -157,7 +157,7 @@ export async function readBillingSubscriptionMapOrSkip( } } -function resolveActivePlanSubscription(subscriptions: Record): SubscriptionRow | null { +export function resolveInEffectPlanSubscription(subscriptions: Record): SubscriptionRow | null { // In-effect (not active): a canceled-at-period-end plan sub keeps its item // grants until `endedAt`, so usage must be judged against that plan. const nowMillis = Date.now(); @@ -424,9 +424,9 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D customerType: "team", customerId: ownerTeamId, })); - const activePlanSubscription = resolveActivePlanSubscription(subscriptions); - const planId = resolveActivePlanId(activePlanSubscription); - const period = getPlanUsagePeriod(activePlanSubscription, now); + const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions); + const planId = resolveActivePlanId(inEffectPlanSubscription); + const period = getPlanUsagePeriod(inEffectPlanSubscription, now); const [ownerTeamDisplayName, ownedScope, dashboardAdmins] = await Promise.all([ getOwnerTeamDisplayName(internalTenancy, ownerTeamId), @@ -444,7 +444,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D owner_team_id: ownerTeamId, owner_team_display_name: ownerTeamDisplayName, plan_id: planId, - plan_display_name: activePlanSubscription?.product.displayName ?? getPlanLabel(planId), + plan_display_name: inEffectPlanSubscription?.product.displayName ?? getPlanLabel(planId), period_start_millis: period.start.getTime(), period_end_millis: period.end.getTime(), next_plan_id: getNextPlanId(planId), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index 40f602c14..48a9f3471 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -296,6 +296,128 @@ it("should report a canceled-at-period-end subscription as still in effect, and `); }); +it("should keep the active sub's cancel button when one quantity of a stackable product is canceled", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await configureProduct({ + products: { + "seats-plan": { + displayName: "Seats Plan", + customerType: "user", + serverOnly: false, + stackable: true, + prices: { + monthly: { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const firstGrant = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { product_id: "seats-plan" }, + }); + await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { product_id: "seats-plan" }, + }); + + // Cancel only the first quantity by subscription id — the second sub stays active + const firstSubscriptionId = (firstGrant.body as { subscription_id: string }).subscription_id; + const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/seats-plan?subscription_id=${encodeURIComponent(firstSubscriptionId)}`, { + method: "DELETE", + accessType: "client", + }); + expect(cancelResponse.status).toBe(200); + + // The winding-down sub must not shadow the active one: the product stays cancelable + const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + }); + const item = (listResponse.body as { items: Array<{ id: string, type: string, quantity: number, subscription: { subscription_id: string, cancel_at_period_end: boolean, is_cancelable: boolean } | null }> }).items.find((i) => i.id === "seats-plan"); + expect(item?.type).toBe("subscription"); + expect(item?.subscription?.cancel_at_period_end).toBe(false); + expect(item?.subscription?.is_cancelable).toBe(true); + expect(item?.subscription?.subscription_id).not.toBe(firstSubscriptionId); + + // Double-canceling the already-canceled quantity returns the dedicated error + const secondCancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/seats-plan?subscription_id=${encodeURIComponent(firstSubscriptionId)}`, { + method: "DELETE", + accessType: "client", + }); + expect(secondCancelResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": "This subscription is already canceled and ends at the end of the current billing period.", + "headers": Headers {