Address review findings on subscription wind-down handling

- Clear cancel_at_period_end when switching plans on a Stripe sub —
  paying to switch is an explicit "keep me subscribed", and Stripe
  otherwise persists the flag so the newly paid plan would still die
  at the period boundary. Also reset the locally written canceledAt /
  endedAt wind-down marks so the reactivated sub doesn't end early.
- Prefer active subs over winding-down ones when several in-effect
  subs share a productId in the product list (partial cancel of a
  stackable product could otherwise hide the cancel button).
- Extract isSubscriptionCancelable and use it in both the product
  list's is_cancelable field and the cancel route's filter, so
  is_cancelable: false always implies the DELETE returns 400 — for
  Stripe subs too, which previously re-ran the cancel silently. The
  cancel route now also updates the local row eagerly (mirroring the
  refund route) instead of waiting for the webhook sync.
- Give the by-subscription-id cancel branch the same already-canceled
  error, and stop claiming past_due/incomplete subs are "already
  canceled" — they get their own message.
- Treat canceled-at-period-end subs as replaceable conflicts in
  validatePurchaseSession, so buying another plan in the same product
  line during a wind-down is no longer rejected as "already has a
  one-time purchase". Scoped narrower than isSubscriptionInEffect so
  payment retries of incomplete subs aren't rejected as duplicates.
- Make isSubscriptionInEffect's parameter a union so callers must
  actually have one of the endedAt fields, and rename
  resolveActivePlanSubscription to resolveInEffectPlanSubscription to
  match its new semantics.
- Tests: unit tests for both predicates and the plan-usage wind-down
  resolution, a validatePurchaseSession wind-down conflict test, and
  e2e coverage for stackable partial-cancel shadowing, by-id double
  cancel, and same-line replacement during wind-down.
This commit is contained in:
Bilal Godil 2026-07-16 14:03:08 -07:00
parent 8c6a7e77b3
commit 7f4a15078d
9 changed files with 336 additions and 64 deletions

View File

@ -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);
}

View File

@ -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<string, SubscriptionRow>();
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<string, Array<{ product_id: string, product: ReturnType<typeof productToInlineProduct> }>>();
@ -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,
},

View File

@ -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

View File

@ -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);
});
});

View File

@ -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

View File

@ -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);

View File

@ -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");
});
});

View File

@ -157,7 +157,7 @@ export async function readBillingSubscriptionMapOrSkip(
}
}
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
export function resolveInEffectPlanSubscription(subscriptions: Record<string, SubscriptionRow>): 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),

View File

@ -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 { <some fields may have been hidden> },
}
`);
});
it("should allow granting another plan in the same product line while the old one winds down", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await configureProduct({
productLines: {
plans: { displayName: "Plans", customerType: "user" },
},
products: {
"plan-a": {
displayName: "Plan A",
customerType: "user",
productLineId: "plans",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
"plan-b": {
displayName: "Plan B",
customerType: "user",
productLineId: "plans",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "2000", interval: [1, "month"] } },
includedItems: {},
},
},
});
const { userId } = await Auth.fastSignUp();
await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "plan-a" },
});
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/plan-a`, {
method: "DELETE",
accessType: "client",
});
expect(cancelResponse.status).toBe(200);
// The winding-down plan-a sub must count as a replaceable conflict, not as
// a one-time purchase blocking the line
const grantBResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "plan-b" },
});
expect(grantBResponse.status).toBe(200);
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
const items = (listResponse.body as { items: Array<{ id: string, type: string }> }).items;
expect(items.find((i) => i.id === "plan-b")?.type).toBe("subscription");
});
it("should reject a client canceling someone else's subscription product", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();