Skip to content

Commit 003b693

Browse files
authored
inline product cancelling (#1199)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Subscription IDs are now included in product listings and UI data for clearer subscription tracking. * Cancellation can be performed by subscription ID as well as by product; client and template APIs support passing a subscription identifier. * **Tests** * End-to-end tests added/updated to cover canceling subscriptions via subscription ID and updated listing snapshots. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 77787c3 commit 003b693

10 files changed

Lines changed: 198 additions & 55 deletions

File tree

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export const DELETE = createSmartRouteHandler({
2525
customer_id: yupString().defined(),
2626
product_id: yupString().defined(),
2727
}).defined(),
28+
query: yupObject({
29+
subscription_id: yupString().optional(),
30+
}).default(() => ({})).defined(),
2831
}),
2932
response: yupObject({
3033
statusCode: yupNumber().oneOf([200]).defined(),
@@ -33,7 +36,7 @@ export const DELETE = createSmartRouteHandler({
3336
success: yupBoolean().oneOf([true]).defined(),
3437
}).defined(),
3538
}),
36-
handler: async ({ auth, params }, fullReq) => {
39+
handler: async ({ auth, params, query }, fullReq) => {
3740
if (auth.type === "client") {
3841
const currentUser = fullReq.auth?.user;
3942
if (!currentUser) {
@@ -59,49 +62,67 @@ export const DELETE = createSmartRouteHandler({
5962
}
6063

6164
const prisma = await getPrismaClientForTenancy(auth.tenancy);
62-
const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, params.product_id, undefined);
63-
if (params.customer_type !== product.customerType) {
64-
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(
65-
params.product_id,
66-
params.customer_id,
67-
product.customerType,
68-
params.customer_type,
69-
);
70-
}
7165

72-
const ownedProducts = await getOwnedProductsForCustomer({
73-
prisma,
74-
tenancy: auth.tenancy,
75-
customerType: params.customer_type,
76-
customerId: params.customer_id,
77-
});
78-
const ownedProductsForProduct = ownedProducts.filter((p) => p.id === params.product_id);
79-
if (ownedProductsForProduct.length === 0) {
80-
throw new StatusError(400, "Customer does not have this product.");
81-
}
82-
if (ownedProductsForProduct.some((product) => product.type === "one_time")) {
83-
throw new StatusError(400, "This product is a one time purchase and cannot be canceled.");
84-
}
66+
let subscriptions;
67+
if (query.subscription_id) {
68+
// Cancel by subscription DB ID (used for inline products that have no product_id)
69+
subscriptions = await prisma.subscription.findMany({
70+
where: {
71+
tenancyId: auth.tenancy.id,
72+
id: query.subscription_id,
73+
customerType: typedToUppercase(params.customer_type),
74+
customerId: params.customer_id,
75+
status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] },
76+
},
77+
});
78+
if (subscriptions.length === 0) {
79+
throw new StatusError(400, "No active subscription found with this ID for the given customer.");
80+
}
81+
} else {
82+
const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, params.product_id, undefined);
83+
if (params.customer_type !== product.customerType) {
84+
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(
85+
params.product_id,
86+
params.customer_id,
87+
product.customerType,
88+
params.customer_type,
89+
);
90+
}
8591

86-
const subscriptions = await prisma.subscription.findMany({
87-
where: {
88-
tenancyId: auth.tenancy.id,
89-
customerType: typedToUppercase(params.customer_type),
92+
const ownedProducts = await getOwnedProductsForCustomer({
93+
prisma,
94+
tenancy: auth.tenancy,
95+
customerType: params.customer_type,
9096
customerId: params.customer_id,
91-
productId: params.product_id,
92-
status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] },
93-
},
94-
});
95-
if (subscriptions.length === 0) {
96-
captureError("cancel-subscription-missing", new StackAssertionError(
97-
"Owned subscription product missing active/trialing subscription record.",
98-
{
99-
customerType: params.customer_type,
97+
});
98+
const ownedProductsForProduct = ownedProducts.filter((p) => p.id === params.product_id);
99+
if (ownedProductsForProduct.length === 0) {
100+
throw new StatusError(400, "Customer does not have this product.");
101+
}
102+
if (ownedProductsForProduct.some((product) => product.type === "one_time")) {
103+
throw new StatusError(400, "This product is a one time purchase and cannot be canceled.");
104+
}
105+
106+
subscriptions = await prisma.subscription.findMany({
107+
where: {
108+
tenancyId: auth.tenancy.id,
109+
customerType: typedToUppercase(params.customer_type),
100110
customerId: params.customer_id,
101111
productId: params.product_id,
112+
status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] },
102113
},
103-
));
104-
throw new StatusError(400, "This subscription cannot be canceled.");
114+
});
115+
if (subscriptions.length === 0) {
116+
captureError("cancel-subscription-missing", new StackAssertionError(
117+
"Owned subscription product missing active/trialing subscription record.",
118+
{
119+
customerType: params.customer_type,
120+
customerId: params.customer_id,
121+
productId: params.product_id,
122+
},
123+
));
124+
throw new StatusError(400, "This subscription cannot be canceled.");
125+
}
105126
}
106127

107128
const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId);

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export const GET = createSmartRouteHandler({
9696
product: productToInlineProduct(product.product),
9797
type: product.type,
9898
subscription: product.subscription ? {
99+
subscription_id: product.subscription.subscriptionId,
99100
current_period_end: product.subscription.currentPeriodEnd ? product.subscription.currentPeriodEnd.toISOString() : null,
100101
cancel_at_period_end: product.subscription.cancelAtPeriodEnd,
101102
is_cancelable: product.subscription.isCancelable,

apps/backend/src/lib/payments.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,7 @@ export type OwnedProduct = {
810810
createdAt: Date,
811811
sourceId: string,
812812
subscription: null | {
813+
subscriptionId: string | null,
813814
currentPeriodEnd: Date | null,
814815
cancelAtPeriodEnd: boolean,
815816
isCancelable: boolean,
@@ -862,9 +863,10 @@ export async function getOwnedProductsForCustomer(options: {
862863
createdAt: subscription.createdAt,
863864
sourceId,
864865
subscription: {
866+
subscriptionId: subscription.id,
865867
currentPeriodEnd: subscription.currentPeriodEnd,
866868
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
867-
isCancelable: subscription.id !== null && subscription.productId !== null,
869+
isCancelable: subscription.id !== null,
868870
},
869871
});
870872
}

apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ it("should grant configured subscription product and expose it via listing", asy
132132
"cancel_at_period_end": false,
133133
"current_period_end": <stripped field 'current_period_end'>,
134134
"is_cancelable": true,
135+
"subscription_id": "<stripped UUID>",
135136
},
136137
"type": "subscription",
137138
},
@@ -533,6 +534,7 @@ it("should hide server-only products from clients while exposing them to servers
533534
"cancel_at_period_end": false,
534535
"current_period_end": <stripped field 'current_period_end'>,
535536
"is_cancelable": true,
537+
"subscription_id": "<stripped UUID>",
536538
},
537539
"type": "subscription",
538540
},
@@ -670,6 +672,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe
670672
"cancel_at_period_end": false,
671673
"current_period_end": <stripped field 'current_period_end'>,
672674
"is_cancelable": true,
675+
"subscription_id": "<stripped UUID>",
673676
},
674677
"type": "subscription",
675678
},
@@ -747,7 +750,8 @@ it("should grant inline product without needing configuration", async ({ expect
747750
"subscription": {
748751
"cancel_at_period_end": false,
749752
"current_period_end": <stripped field 'current_period_end'>,
750-
"is_cancelable": false,
753+
"is_cancelable": true,
754+
"subscription_id": "<stripped UUID>",
751755
},
752756
"type": "subscription",
753757
},
@@ -759,6 +763,110 @@ it("should grant inline product without needing configuration", async ({ expect
759763
`);
760764
});
761765

766+
it("should allow canceling an inline product subscription via subscription_id", async ({ expect }) => {
767+
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
768+
await Payments.setup();
769+
const { userId, accessToken, refreshToken } = await Auth.fastSignUp();
770+
771+
const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
772+
method: "POST",
773+
accessType: "server",
774+
body: {
775+
product_inline: {
776+
display_name: "Inline Sub",
777+
customer_type: "user",
778+
server_only: false,
779+
prices: {
780+
monthly: {
781+
USD: "500",
782+
interval: [1, "month"],
783+
},
784+
},
785+
included_items: {},
786+
},
787+
},
788+
});
789+
expect(grantResponse.status).toBe(200);
790+
791+
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
792+
accessType: "client",
793+
userAuth: { accessToken, refreshToken },
794+
});
795+
expect(listResponse).toMatchInlineSnapshot(`
796+
NiceResponse {
797+
"status": 200,
798+
"body": {
799+
"is_paginated": true,
800+
"items": [
801+
{
802+
"id": null,
803+
"product": {
804+
"client_metadata": null,
805+
"client_read_only_metadata": null,
806+
"customer_type": "user",
807+
"display_name": "Inline Sub",
808+
"included_items": {},
809+
"prices": {
810+
"monthly": {
811+
"USD": "500",
812+
"interval": [
813+
1,
814+
"month",
815+
],
816+
},
817+
},
818+
"server_metadata": null,
819+
"server_only": false,
820+
"stackable": false,
821+
},
822+
"quantity": 1,
823+
"subscription": {
824+
"cancel_at_period_end": false,
825+
"current_period_end": <stripped field 'current_period_end'>,
826+
"is_cancelable": true,
827+
"subscription_id": "<stripped UUID>",
828+
},
829+
"type": "subscription",
830+
},
831+
],
832+
"pagination": { "next_cursor": null },
833+
},
834+
"headers": Headers { <some fields may have been hidden> },
835+
}
836+
`);
837+
const items = listResponse.body.items;
838+
839+
const subscriptionId = items[0].subscription.subscription_id;
840+
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/_inline?subscription_id=${encodeURIComponent(subscriptionId)}`, {
841+
method: "DELETE",
842+
accessType: "client",
843+
userAuth: { accessToken, refreshToken },
844+
});
845+
expect(cancelResponse).toMatchInlineSnapshot(`
846+
NiceResponse {
847+
"status": 200,
848+
"body": { "success": true },
849+
"headers": Headers { <some fields may have been hidden> },
850+
}
851+
`);
852+
853+
const afterCancelList = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
854+
accessType: "client",
855+
userAuth: { accessToken, refreshToken },
856+
});
857+
expect(afterCancelList).toMatchInlineSnapshot(`
858+
NiceResponse {
859+
"status": 200,
860+
"body": {
861+
"is_paginated": true,
862+
"items": [],
863+
"pagination": { "next_cursor": null },
864+
},
865+
"headers": Headers { <some fields may have been hidden> },
866+
}
867+
`);
868+
});
869+
762870
it("should reject requests missing product details", async ({ expect }) => {
763871
await Project.createAndSwitch();
764872
await Payments.setup();
@@ -1055,6 +1163,7 @@ it("listing products should list both subscription and one-time products", async
10551163
"cancel_at_period_end": false,
10561164
"current_period_end": <stripped field 'current_period_end'>,
10571165
"is_cancelable": true,
1166+
"subscription_id": "<stripped UUID>",
10581167
},
10591168
"type": "subscription",
10601169
},
@@ -1199,6 +1308,7 @@ it("listing products should support cursor pagination", async ({ expect }) => {
11991308
"cancel_at_period_end": false,
12001309
"current_period_end": <stripped field 'current_period_end'>,
12011310
"is_cancelable": true,
1311+
"subscription_id": "<stripped UUID>",
12021312
},
12031313
"type": "subscription",
12041314
},

packages/stack-shared/src/interface/client-interface.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,11 +1968,16 @@ export class StackClientInterface {
19681968
customer_type: "user" | "team" | "custom",
19691969
customer_id: string,
19701970
product_id: string,
1971+
subscription_id?: string,
19711972
},
19721973
session: InternalSession | null,
19731974
): Promise<void> {
1975+
const queryParams = new URLSearchParams(filterUndefined({
1976+
subscription_id: options.subscription_id,
1977+
}));
1978+
const path = urlString`/payments/products/${options.customer_type}/${options.customer_id}/${options.product_id}`;
19741979
await this.sendClientRequest(
1975-
urlString`/payments/products/${options.customer_type}/${options.customer_id}/${options.product_id}`,
1980+
`${path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
19761981
{
19771982
method: "DELETE",
19781983
},
@@ -2019,7 +2024,7 @@ export class StackClientInterface {
20192024
): Promise<string> {
20202025
const productBody = typeof productIdOrInline === "string" ?
20212026
{ product_id: productIdOrInline } :
2022-
{ inline_product: productIdOrInline };
2027+
{ product_inline: productIdOrInline };
20232028
const sendRequest = (requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest as never).bind(this);
20242029
const response = await sendRequest(
20252030
"/payments/purchases/create-purchase-url",

packages/stack-shared/src/interface/crud/products.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const customerProductReadSchema = yupObject({
1212
product: inlineProductSchema.defined(),
1313
type: yupString().oneOf(["one_time", "subscription"]).defined(),
1414
subscription: yupObject({
15+
subscription_id: yupString().nullable().defined(),
1516
current_period_end: yupString().nullable().defined(),
1617
cancel_at_period_end: yupBoolean().defined(),
1718
is_cancelable: yupBoolean().defined(),

0 commit comments

Comments
 (0)