From 16ba17895939232303fe84306adbde5578a6b823 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 16 Feb 2026 11:53:19 -0800 Subject: [PATCH] inline product cancelling --- .../[customer_id]/[product_id]/route.ts | 97 +++++++++------ .../[customer_type]/[customer_id]/route.ts | 1 + apps/backend/src/lib/payments.tsx | 4 +- .../api/v1/payments/products.test.ts | 112 +++++++++++++++++- .../src/interface/client-interface.ts | 9 +- .../src/interface/crud/products.ts | 1 + .../payments/payments-panel.tsx | 22 ++-- .../apps/implementations/client-app-impl.ts | 4 +- .../stack-app/apps/interfaces/client-app.ts | 2 +- .../src/lib/stack-app/customers/index.ts | 1 + 10 files changed, 198 insertions(+), 55 deletions(-) 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 635d23ca5c..31a1b33c41 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 @@ -25,6 +25,9 @@ export const DELETE = createSmartRouteHandler({ customer_id: yupString().defined(), product_id: yupString().defined(), }).defined(), + query: yupObject({ + subscription_id: yupString().optional(), + }).default(() => ({})).defined(), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), @@ -33,7 +36,7 @@ export const DELETE = createSmartRouteHandler({ success: yupBoolean().oneOf([true]).defined(), }).defined(), }), - handler: async ({ auth, params }, fullReq) => { + handler: async ({ auth, params, query }, fullReq) => { if (auth.type === "client") { const currentUser = fullReq.auth?.user; if (!currentUser) { @@ -59,49 +62,67 @@ export const DELETE = createSmartRouteHandler({ } const prisma = await getPrismaClientForTenancy(auth.tenancy); - const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, params.product_id, undefined); - if (params.customer_type !== product.customerType) { - throw new KnownErrors.ProductCustomerTypeDoesNotMatch( - params.product_id, - params.customer_id, - product.customerType, - params.customer_type, - ); - } - const ownedProducts = await getOwnedProductsForCustomer({ - prisma, - tenancy: auth.tenancy, - customerType: params.customer_type, - customerId: params.customer_id, - }); - const ownedProductsForProduct = ownedProducts.filter((p) => p.id === params.product_id); - if (ownedProductsForProduct.length === 0) { - throw new StatusError(400, "Customer does not have this product."); - } - if (ownedProductsForProduct.some((product) => product.type === "one_time")) { - throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); - } + let subscriptions; + if (query.subscription_id) { + // Cancel by subscription DB ID (used for inline products that have no product_id) + subscriptions = await prisma.subscription.findMany({ + where: { + tenancyId: auth.tenancy.id, + id: query.subscription_id, + customerType: typedToUppercase(params.customer_type), + customerId: params.customer_id, + status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + }, + }); + if (subscriptions.length === 0) { + throw new StatusError(400, "No active subscription found with this ID for the given customer."); + } + } else { + const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, params.product_id, undefined); + if (params.customer_type !== product.customerType) { + throw new KnownErrors.ProductCustomerTypeDoesNotMatch( + params.product_id, + params.customer_id, + product.customerType, + params.customer_type, + ); + } - const subscriptions = await prisma.subscription.findMany({ - where: { - tenancyId: auth.tenancy.id, - customerType: typedToUppercase(params.customer_type), + const ownedProducts = await getOwnedProductsForCustomer({ + prisma, + tenancy: auth.tenancy, + customerType: params.customer_type, customerId: params.customer_id, - productId: params.product_id, - status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, - }, - }); - if (subscriptions.length === 0) { - captureError("cancel-subscription-missing", new StackAssertionError( - "Owned subscription product missing active/trialing subscription record.", - { - customerType: params.customer_type, + }); + const ownedProductsForProduct = ownedProducts.filter((p) => p.id === params.product_id); + if (ownedProductsForProduct.length === 0) { + throw new StatusError(400, "Customer does not have this product."); + } + if (ownedProductsForProduct.some((product) => product.type === "one_time")) { + throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); + } + + subscriptions = await prisma.subscription.findMany({ + where: { + tenancyId: auth.tenancy.id, + customerType: typedToUppercase(params.customer_type), customerId: params.customer_id, productId: params.product_id, + status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, }, - )); - throw new StatusError(400, "This subscription cannot be canceled."); + }); + if (subscriptions.length === 0) { + captureError("cancel-subscription-missing", new StackAssertionError( + "Owned subscription product missing active/trialing subscription record.", + { + customerType: params.customer_type, + customerId: params.customer_id, + productId: params.product_id, + }, + )); + throw new StatusError(400, "This subscription cannot be canceled."); + } } const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId); 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 bb9fb4a0b9..438c6e425b 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 @@ -96,6 +96,7 @@ export const GET = createSmartRouteHandler({ product: productToInlineProduct(product.product), type: product.type, subscription: product.subscription ? { + subscription_id: product.subscription.subscriptionId, current_period_end: product.subscription.currentPeriodEnd ? product.subscription.currentPeriodEnd.toISOString() : null, cancel_at_period_end: product.subscription.cancelAtPeriodEnd, is_cancelable: product.subscription.isCancelable, diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index f9445ec4fe..4d14b9e23a 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -810,6 +810,7 @@ export type OwnedProduct = { createdAt: Date, sourceId: string, subscription: null | { + subscriptionId: string | null, currentPeriodEnd: Date | null, cancelAtPeriodEnd: boolean, isCancelable: boolean, @@ -862,9 +863,10 @@ export async function getOwnedProductsForCustomer(options: { createdAt: subscription.createdAt, sourceId, subscription: { + subscriptionId: subscription.id, currentPeriodEnd: subscription.currentPeriodEnd, cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, - isCancelable: subscription.id !== null && subscription.productId !== null, + isCancelable: subscription.id !== null, }, }); } 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 76af5a6e93..142a38121c 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 @@ -132,6 +132,7 @@ it("should grant configured subscription product and expose it via listing", asy "cancel_at_period_end": false, "current_period_end": , "is_cancelable": true, + "subscription_id": "", }, "type": "subscription", }, @@ -533,6 +534,7 @@ it("should hide server-only products from clients while exposing them to servers "cancel_at_period_end": false, "current_period_end": , "is_cancelable": true, + "subscription_id": "", }, "type": "subscription", }, @@ -670,6 +672,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe "cancel_at_period_end": false, "current_period_end": , "is_cancelable": true, + "subscription_id": "", }, "type": "subscription", }, @@ -747,7 +750,8 @@ it("should grant inline product without needing configuration", async ({ expect "subscription": { "cancel_at_period_end": false, "current_period_end": , - "is_cancelable": false, + "is_cancelable": true, + "subscription_id": "", }, "type": "subscription", }, @@ -759,6 +763,110 @@ it("should grant inline product without needing configuration", async ({ expect `); }); +it("should allow canceling an inline product subscription via subscription_id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Payments.setup(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); + + const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { + product_inline: { + display_name: "Inline Sub", + customer_type: "user", + server_only: false, + prices: { + monthly: { + USD: "500", + interval: [1, "month"], + }, + }, + included_items: {}, + }, + }, + }); + expect(grantResponse.status).toBe(200); + + const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + userAuth: { accessToken, refreshToken }, + }); + expect(listResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "is_paginated": true, + "items": [ + { + "id": null, + "product": { + "client_metadata": null, + "client_read_only_metadata": null, + "customer_type": "user", + "display_name": "Inline Sub", + "included_items": {}, + "prices": { + "monthly": { + "USD": "500", + "interval": [ + 1, + "month", + ], + }, + }, + "server_metadata": null, + "server_only": false, + "stackable": false, + }, + "quantity": 1, + "subscription": { + "cancel_at_period_end": false, + "current_period_end": , + "is_cancelable": true, + "subscription_id": "", + }, + "type": "subscription", + }, + ], + "pagination": { "next_cursor": null }, + }, + "headers": Headers {