Thanks to visit codestin.com
Credit goes to github.com

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
17 changes: 12 additions & 5 deletions apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PrismaClientTransaction } from "@/prisma-client";
import { PurchaseCreationSource, SubscriptionStatus } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import type { inlineProductSchema, productSchema } from "@stackframe/stack-shared/dist/schema-fields";
import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields";
import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
Expand All @@ -16,14 +16,15 @@ import { getStripeForAccount } from "./stripe";
const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday

type Product = yup.InferType<typeof productSchema>;
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
type SelectedPrice = Exclude<Product["prices"], "include-by-default">[string];

export async function ensureProductIdOrInlineProduct(
tenancy: Tenancy,
accessType: "client" | "server" | "admin",
productId: string | undefined,
inlineProduct: yup.InferType<typeof inlineProductSchema> | undefined
): Promise<Tenancy["config"]["payments"]["products"][string]> {
): Promise<ProductWithMetadata> {
if (productId && inlineProduct) {
throw new StatusError(400, "Cannot specify both product_id and product_inline!");
}
Expand Down Expand Up @@ -61,6 +62,9 @@ export async function ensureProductIdOrInlineProduct(
freeTrial: value.free_trial,
serverOnly: true,
}])),
clientMetadata: inlineProduct.client_metadata ?? undefined,
clientReadOnlyMetadata: inlineProduct.client_read_only_metadata ?? undefined,
serverMetadata: inlineProduct.server_metadata ?? undefined,
includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, {
repeat: value.repeat ?? "never",
quantity: value.quantity ?? 0,
Expand Down Expand Up @@ -420,13 +424,16 @@ export async function ensureCustomerExists(options: {
}
}

export function productToInlineProduct(product: Product): yup.InferType<typeof inlineProductSchema> {
export function productToInlineProduct(product: ProductWithMetadata): yup.InferType<typeof inlineProductSchema> {
return {
display_name: product.displayName ?? "Product",
customer_type: product.customerType,
stackable: product.stackable === true,
server_only: product.serverOnly === true,
included_items: product.includedItems,
client_metadata: product.clientMetadata ?? null,
client_read_only_metadata: product.clientReadOnlyMetadata ?? null,
server_metadata: product.serverMetadata ?? null,
prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({
...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])),
interval: value.interval,
Expand Down Expand Up @@ -552,7 +559,7 @@ export async function grantProductToCustomer(options: {
tenancy: Tenancy,
customerType: "user" | "team" | "custom",
customerId: string,
product: Product,
product: ProductWithMetadata,
quantity: number,
productId: string | undefined,
priceId: string | undefined,
Expand Down Expand Up @@ -691,7 +698,7 @@ export async function getOwnedProductsForCustomer(options: {
}

for (const purchase of oneTimePurchases) {
const product = purchase.product as Product;
const product = purchase.product as ProductWithMetadata;
ownedProducts.push({
id: purchase.productId ?? null,
type: "one_time",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ it("should allow valid code and return offer data", async ({ expect }) => {
"charges_enabled": false,
"conflicting_products": [],
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Test Product",
"included_items": {},
Expand All @@ -53,6 +55,7 @@ it("should allow valid code and return offer data", async ({ expect }) => {
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand Down Expand Up @@ -221,6 +224,8 @@ it("should include conflicting_group_offers when switching within the same group
},
],
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Offer B",
"included_items": {},
Expand All @@ -233,6 +238,7 @@ it("should include conflicting_group_offers when switching within the same group
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,89 @@ it("should allow product_inline when calling from server", async ({ expect }) =>
expect(response.body.url).toMatch(new RegExp(`^https?:\\/\\/localhost:${withPortPrefix("01")}\/purchase\/[a-z0-9-_]+$`));
});

it("should return inline product metadata when validating purchase code", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();

const { userId } = await Auth.Otp.signIn();
const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "server",
body: {
customer_type: "user",
customer_id: userId,
product_inline: {
display_name: "Metadata Inline Product",
customer_type: "user",
server_only: true,
prices: {
"monthly-metadata": {
USD: "1500",
interval: [1, "month"],
},
},
included_items: {},
server_metadata: {
reference_id: "ref-123",
features: ["priority-support", "analytics"],
},
},
},
});
expect(createResponse.status).toBe(200);
const url = (createResponse.body as { url: string }).url;
const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/);
const fullCode = codeMatch ? codeMatch[1] : undefined;
expect(fullCode).toBeDefined();

const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: {
full_code: fullCode,
},
});
expect(validateResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [],
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Metadata Inline Product",
"included_items": {},
"prices": {
"monthly-metadata": {
"USD": "1500",
"interval": [
1,
"month",
],
},
},
"server_metadata": {
"features": [
"priority-support",
"analytics",
],
"reference_id": "ref-123",
},
"server_only": true,
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should allow valid product_id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
Expand Down
34 changes: 34 additions & 0 deletions apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ it("should grant configured subscription product and expose it via listing", asy
{
"id": "pro-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Pro Plan",
"included_items": {},
Expand All @@ -120,6 +122,7 @@ it("should grant configured subscription product and expose it via listing", asy
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand Down Expand Up @@ -200,6 +203,8 @@ it("should hide server-only products from clients while exposing them to servers
{
"id": "server-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Server Plan",
"included_items": {},
Expand All @@ -212,6 +217,7 @@ it("should hide server-only products from clients while exposing them to servers
],
},
},
"server_metadata": null,
"server_only": true,
"stackable": false,
},
Expand Down Expand Up @@ -327,6 +333,8 @@ it("should allow granting stackable product with custom quantity", async ({ expe
{
"id": "stackable-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Stackable Plan",
"included_items": {},
Expand All @@ -339,6 +347,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": true,
},
Expand Down Expand Up @@ -372,6 +381,10 @@ it("should grant inline product without needing configuration", async ({ expect
},
},
included_items: {},
server_metadata: {
cohort: "beta",
flags: ["inline-grant"],
},
},
},
});
Expand All @@ -389,6 +402,8 @@ it("should grant inline product without needing configuration", async ({ expect
{
"id": null,
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Inline Access",
"included_items": {},
Expand All @@ -401,6 +416,10 @@ it("should grant inline product without needing configuration", async ({ expect
],
},
},
"server_metadata": {
"cohort": "beta",
"flags": ["inline-grant"],
},
"server_only": true,
"stackable": false,
},
Expand Down Expand Up @@ -685,6 +704,8 @@ it("listing products should list both subscription and one-time products", async
{
"id": "subscription-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Subscription Plan",
"included_items": {},
Expand All @@ -697,6 +718,7 @@ it("listing products should list both subscription and one-time products", async
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand All @@ -705,10 +727,13 @@ it("listing products should list both subscription and one-time products", async
{
"id": "lifetime-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Lifetime Add-on",
"included_items": {},
"prices": { "lifetime": { "USD": "5000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand Down Expand Up @@ -813,6 +838,8 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "subscription-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Subscription Plan",
"included_items": {},
Expand All @@ -825,6 +852,7 @@ it("listing products should support cursor pagination", async ({ expect }) => {
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand All @@ -850,10 +878,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "lifetime-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Lifetime Add-on",
"included_items": {},
"prices": { "lifetime": { "USD": "5000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand All @@ -862,10 +893,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "pro-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Pro Add-on",
"included_items": {},
"prices": { "standard": { "USD": "7000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},
Expand Down
Loading