Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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) {
Expand All @@ -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.");
}
Comment thread
BilalG1 marked this conversation as resolved.
} 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ export type OwnedProduct = {
createdAt: Date,
sourceId: string,
subscription: null | {
subscriptionId: string | null,
currentPeriodEnd: Date | null,
cancelAtPeriodEnd: boolean,
isCancelable: boolean,
Expand Down Expand Up @@ -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,
},
});
}
Expand Down
112 changes: 111 additions & 1 deletion apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ it("should grant configured subscription product and expose it via listing", asy
"cancel_at_period_end": false,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand Down Expand Up @@ -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": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand Down Expand Up @@ -670,6 +672,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe
"cancel_at_period_end": false,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand Down Expand Up @@ -747,7 +750,8 @@ it("should grant inline product without needing configuration", async ({ expect
"subscription": {
"cancel_at_period_end": false,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": false,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand All @@ -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": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const items = listResponse.body.items;

const subscriptionId = items[0].subscription.subscription_id;
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/_inline?subscription_id=${encodeURIComponent(subscriptionId)}`, {
method: "DELETE",
accessType: "client",
userAuth: { accessToken, refreshToken },
});
expect(cancelResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);

const afterCancelList = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
userAuth: { accessToken, refreshToken },
});
expect(afterCancelList).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": true,
"items": [],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should reject requests missing product details", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
Expand Down Expand Up @@ -1055,6 +1163,7 @@ it("listing products should list both subscription and one-time products", async
"cancel_at_period_end": false,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand Down Expand Up @@ -1199,6 +1308,7 @@ it("listing products should support cursor pagination", async ({ expect }) => {
"cancel_at_period_end": false,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": true,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
Expand Down
9 changes: 7 additions & 2 deletions packages/stack-shared/src/interface/client-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1968,11 +1968,16 @@ export class StackClientInterface {
customer_type: "user" | "team" | "custom",
customer_id: string,
product_id: string,
subscription_id?: string,
},
session: InternalSession | null,
): Promise<void> {
const queryParams = new URLSearchParams(filterUndefined({
subscription_id: options.subscription_id,
}));
const path = urlString`/payments/products/${options.customer_type}/${options.customer_id}/${options.product_id}`;
await this.sendClientRequest(
urlString`/payments/products/${options.customer_type}/${options.customer_id}/${options.product_id}`,
`${path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
{
method: "DELETE",
},
Expand Down Expand Up @@ -2019,7 +2024,7 @@ export class StackClientInterface {
): Promise<string> {
const productBody = typeof productIdOrInline === "string" ?
{ product_id: productIdOrInline } :
{ inline_product: productIdOrInline };
{ product_inline: productIdOrInline };
Comment thread
BilalG1 marked this conversation as resolved.
const sendRequest = (requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest as never).bind(this);
const response = await sendRequest(
"/payments/purchases/create-purchase-url",
Expand Down
1 change: 1 addition & 0 deletions packages/stack-shared/src/interface/crud/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const customerProductReadSchema = yupObject({
product: inlineProductSchema.defined(),
type: yupString().oneOf(["one_time", "subscription"]).defined(),
subscription: yupObject({
subscription_id: yupString().nullable().defined(),
current_period_end: yupString().nullable().defined(),
cancel_at_period_end: yupBoolean().defined(),
is_cancelable: yupBoolean().defined(),
Expand Down
Loading
Loading