diff --git a/AGENTS.md b/AGENTS.md index 17d4120b9a..608a0158d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co #### Extra commands These commands are usually already called by the user, but you can remind them to run it for you if they forgot to. - **Build packages**: `pnpm build:packages` -- **Generate code**: `pnpm codegen` - **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user) -- **Run development**: `pnpm dev` (starts all services on different ports. Usually already started by the user in the background) +- **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. - **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems) ### Testing @@ -69,15 +68,14 @@ The API follows a RESTful design with routes organized by resource type: To see all development ports, refer to the index.html of `apps/dev-launchpad/public/index.html`. ## Important Notes +- NEVER UPDATE packages/stack OR packages/js. Instead, update packages/template, as the others are simply copies of that package. +- For blocking alerts and errors, never use `toast`, as they are easily missed by the user. Instead, use alerts. - Environment variables are pre-configured in `.env.development` files - Always run typecheck, lint, and test to make sure your changes are working as expected. You can save time by only linting and testing the files you've changed (and/or related E2E tests). - The project uses a custom route handler system in the backend for consistent API responses -- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, stop and tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. -- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the .claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). Note that it's not 100% accurate and you may have to update it later if you find that something is wrong. +- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass. +- When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. +- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). ### Code-related - Use ES6 maps instead of records wherever you can. -- Use `performance.now()` where appropriate for timing deltas, not `Date.now()` - -### Testing-related -- When writing tests, prefer .toMatchInlineSnapshot over other matchers, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. diff --git a/CLAUDE.md b/CLAUDE.md index 22f00845e6..d16eeba094 100644 Binary files a/CLAUDE.md and b/CLAUDE.md differ diff --git a/apps/backend/prisma/migrations/20250821212828_subscription_quantity/migration.sql b/apps/backend/prisma/migrations/20250821212828_subscription_quantity/migration.sql new file mode 100644 index 0000000000..051b61ff52 --- /dev/null +++ b/apps/backend/prisma/migrations/20250821212828_subscription_quantity/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1; diff --git a/apps/backend/prisma/migrations/20250822203223_subscription_offer_id/migration.sql b/apps/backend/prisma/migrations/20250822203223_subscription_offer_id/migration.sql new file mode 100644 index 0000000000..53968c7f08 --- /dev/null +++ b/apps/backend/prisma/migrations/20250822203223_subscription_offer_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "offerId" TEXT; diff --git a/apps/backend/prisma/migrations/20250825221947_stripe_account_id/migration.sql b/apps/backend/prisma/migrations/20250825221947_stripe_account_id/migration.sql new file mode 100644 index 0000000000..0736d7aba6 --- /dev/null +++ b/apps/backend/prisma/migrations/20250825221947_stripe_account_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "stripeAccountId" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 667ce0ce40..dc699b846a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -24,6 +24,7 @@ model Project { fullLogoUrl String? projectConfigOverride Json? + stripeAccountId String? apiKeySets ApiKeySet[] projectUsers ProjectUser[] @@ -739,7 +740,9 @@ model Subscription { tenancyId String @db.Uuid customerId String customerType CustomerType + offerId String? offer Json + quantity Int @default(1) stripeSubscriptionId String? status SubscriptionStatus diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 21f742d93e..537232c22c 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -91,8 +91,14 @@ async function seed() { branchId: DEFAULT_BRANCH_ID, environmentConfigOverrideOverride: { payments: { + groups: { + plans: { + displayName: "Plans", + } + }, offers: { team: { + groupId: "plans", displayName: "Team", customerType: "team", serverOnly: false, @@ -106,13 +112,14 @@ async function seed() { }, includedItems: { dashboard_admins: { - quantity: 2, + quantity: 3, repeat: "never", expires: "when-purchase-expires" } } }, growth: { + groupId: "plans", displayName: "Growth", customerType: "team", serverOnly: false, @@ -126,21 +133,56 @@ async function seed() { }, includedItems: { dashboard_admins: { - quantity: 4, + quantity: 5, + repeat: "never", + expires: "when-purchase-expires" + } + } + }, + free: { + groupId: "plans", + displayName: "Free", + customerType: "team", + serverOnly: false, + stackable: false, + prices: "include-by-default", + includedItems: { + dashboard_admins: { + quantity: 1, + repeat: "never", + expires: "when-purchase-expires" + } + } + }, + "extra-admins": { + groupId: "plans", + displayName: "Extra Admins", + customerType: "team", + serverOnly: false, + stackable: true, + prices: { + monthly: { + USD: "49", + interval: [1, "month"] as any, + serverOnly: false + } + }, + includedItems: { + dashboard_admins: { + quantity: 1, repeat: "never", expires: "when-purchase-expires" } + }, + isAddOnTo: { + team: true, + growth: true, } } }, items: { dashboard_admins: { displayName: "Dashboard Admins", - default: { - quantity: 1, - expires: "never", - repeat: "never" - }, customerType: "team" } }, diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index dfabb782b9..6264fab937 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,4 +1,4 @@ -import { getStackStripe, syncStripeAccountStatus, syncStripeSubscriptions } from "@/lib/stripe"; +import { getStackStripe, syncStripeSubscriptions } from "@/lib/stripe"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; @@ -65,7 +65,6 @@ export const POST = createSmartRouteHandler({ if (!event.account) { throw new StackAssertionError("Stripe webhook account id missing", { event }); } - await syncStripeAccountStatus(event.account); } else if (isSubscriptionChangedEvent(event)) { const accountId = event.account; const customerId = (event.data.object as any).customer; diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts index b77e23b0b2..41938830f2 100644 --- a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -1,5 +1,5 @@ -import { overrideEnvironmentConfigOverride } from "@/lib/config"; import { getStackStripe } from "@/lib/stripe"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; @@ -24,7 +24,13 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ auth }) => { const stripe = getStackStripe(); - let stripeAccountId = auth.tenancy.config.payments.stripeAccountId; + + const project = await globalPrismaClient.project.findUnique({ + where: { id: auth.project.id }, + select: { stripeAccountId: true }, + }); + + let stripeAccountId = project?.stripeAccountId || null; const returnToUrl = new URL(`/projects/${auth.project.id}/payments`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")).toString(); if (!stripeAccountId) { @@ -42,12 +48,10 @@ export const POST = createSmartRouteHandler({ } }); stripeAccountId = account.id; - await overrideEnvironmentConfigOverride({ - projectId: auth.project.id, - branchId: auth.tenancy.branchId, - environmentConfigOverrideOverride: { - [`payments.stripeAccountId`]: stripeAccountId, - }, + + await globalPrismaClient.project.update({ + where: { id: auth.project.id }, + data: { stripeAccountId }, }); } diff --git a/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts b/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts index 2b318185b7..556ecc4e06 100644 --- a/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts @@ -1,4 +1,5 @@ import { getStackStripe } from "@/lib/stripe"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -23,12 +24,18 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ auth }) => { const stripe = getStackStripe(); - if (!auth.tenancy.config.payments.stripeAccountId) { + + const project = await globalPrismaClient.project.findUnique({ + where: { id: auth.project.id }, + select: { stripeAccountId: true }, + }); + + if (!project?.stripeAccountId) { throw new StatusError(400, "Stripe account ID is not set"); } const accountSession = await stripe.accountSessions.create({ - account: auth.tenancy.config.payments.stripeAccountId, + account: project.stripeAccountId, components: { payments: { enabled: true, diff --git a/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts b/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts new file mode 100644 index 0000000000..959627ce1b --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts @@ -0,0 +1,56 @@ +import { getStackStripe } from "@/lib/stripe"; +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + account_id: yupString().defined(), + charges_enabled: yupBoolean().defined(), + details_submitted: yupBoolean().defined(), + payouts_enabled: yupBoolean().defined(), + }).nullable(), + }), + handler: async ({ auth }) => { + const project = await globalPrismaClient.project.findUnique({ + where: { id: auth.project.id }, + select: { stripeAccountId: true }, + }); + + if (!project?.stripeAccountId) { + return { + statusCode: 200, + bodyType: "json", + body: null, + }; + } + + const stripe = getStackStripe(); + const account = await stripe.accounts.retrieve(project.stripeAccountId); + + return { + statusCode: 200, + bodyType: "json", + body: { + account_id: account.id, + charges_enabled: account.charges_enabled || false, + details_submitted: account.details_submitted || false, + payouts_enabled: account.payouts_enabled || false, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx index 187b98b0f3..276e9d7de0 100644 --- a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -1,9 +1,13 @@ +import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; +import { isActiveSubscription, validatePurchaseSession } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { SubscriptionCreationSource, SubscriptionStatus } from "@prisma/client"; import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; -import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getPrismaClientForTenancy } from "@/prisma-client"; import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; export const POST = createSmartRouteHandler({ @@ -19,6 +23,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ full_code: yupString().defined(), price_id: yupString().defined(), + quantity: yupNumber().integer().min(1).default(1), }), }), response: yupObject({ @@ -26,33 +31,87 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["success"]).defined(), }), handler: async ({ auth, body }) => { - const { full_code, price_id } = body; + const { full_code, price_id, quantity } = body; const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code); if (auth.tenancy.id !== data.tenancyId) { throw new StatusError(400, "Tenancy id does not match value from code data"); } const prisma = await getPrismaClientForTenancy(auth.tenancy); - const pricesMap = new Map(Object.entries(data.offer.prices)); - const selectedPrice = pricesMap.get(price_id); - if (!selectedPrice) { - throw new StatusError(400, "Price not found on offer associated with this purchase code"); + const { selectedPrice, groupId, subscriptions } = await validatePurchaseSession({ + prisma, + tenancy: auth.tenancy, + codeData: data, + priceId: price_id, + quantity, + }); + if (groupId) { + for (const subscription of subscriptions) { + if ( + subscription.id && + subscription.offerId && + subscription.offer.groupId === groupId && + isActiveSubscription(subscription) && + subscription.offer.prices !== "include-by-default" && + (!data.offer.isAddOnTo || !typedKeys(data.offer.isAddOnTo).includes(subscription.offerId)) + ) { + if (!selectedPrice?.interval) { + continue; + } + if (subscription.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + await stripe.subscriptions.cancel(subscription.stripeSubscriptionId); + } + await retryTransaction(prisma, async (tx) => { + if (!subscription.stripeSubscriptionId && subscription.id) { + await tx.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, + }, + data: { + status: SubscriptionStatus.canceled, + }, + }); + } + await tx.subscription.create({ + data: { + tenancyId: auth.tenancy.id, + customerId: data.customerId, + customerType: typedToUppercase(data.offer.customerType), + status: SubscriptionStatus.active, + offerId: data.offerId, + offer: data.offer, + quantity, + currentPeriodStart: new Date(), + currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!), + cancelAtPeriodEnd: false, + creationSource: SubscriptionCreationSource.TEST_MODE, + }, + }); + }); + } + } } - if (!selectedPrice.interval) { - throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); + + if (selectedPrice?.interval) { + await prisma.subscription.create({ + data: { + tenancyId: auth.tenancy.id, + customerId: data.customerId, + customerType: typedToUppercase(data.offer.customerType), + status: "active", + offerId: data.offerId, + offer: data.offer, + quantity, + currentPeriodStart: new Date(), + currentPeriodEnd: addInterval(new Date(), selectedPrice.interval), + cancelAtPeriodEnd: false, + creationSource: SubscriptionCreationSource.TEST_MODE, + }, + }); } - await prisma.subscription.create({ - data: { - tenancyId: auth.tenancy.id, - customerId: data.customerId, - customerType: typedToUppercase(data.offer.customerType), - status: "active", - offer: data.offer, - currentPeriodStart: new Date(), - currentPeriodEnd: addInterval(new Date(), selectedPrice.interval), - cancelAtPeriodEnd: false, - creationSource: "TEST_MODE", - }, - }); await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy: auth.tenancy, id: codeId, diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts index 44e8e5bac4..da5f86b4ca 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts @@ -1,9 +1,10 @@ +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; export const POST = createSmartRouteHandler({ metadata: { diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index 1cebf7d0ca..cdd4faa16b 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,12 +1,13 @@ -import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { ensureOfferIdOrInlineOffer } from "@/lib/payments"; import { getStripeForAccount } from "@/lib/stripe"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { CustomerType } from "@prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { CustomerType } from "@prisma/client"; -import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; export const POST = createSmartRouteHandler({ metadata: { @@ -34,7 +35,7 @@ export const POST = createSmartRouteHandler({ }), handler: async (req) => { const { tenancy } = req.auth; - const stripe = getStripeForAccount({ tenancy }); + const stripe = await getStripeForAccount({ tenancy }); const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); const customerType = offerConfig.customerType; if (req.body.customer_type !== customerType) { @@ -54,15 +55,21 @@ export const POST = createSmartRouteHandler({ }); } + const project = await globalPrismaClient.project.findUnique({ + where: { id: tenancy.project.id }, + select: { stripeAccountId: true }, + }); + const { code } = await purchaseUrlVerificationCodeHandler.createCode({ tenancy, expiresInMs: 1000 * 60 * 60 * 24, data: { tenancyId: tenancy.id, customerId: req.body.customer_id, + offerId: req.body.offer_id, offer: offerConfig, stripeCustomerId: stripeCustomer.id, - stripeAccountId: tenancy.config.payments.stripeAccountId ?? throwErr("Stripe account not configured"), + stripeAccountId: project?.stripeAccountId ?? throwErr("Stripe account not configured"), }, method: {}, callbackUrl: undefined, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 5d5adfc407..66036fff27 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -1,10 +1,12 @@ -import Stripe from "stripe"; +import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments"; import { getStripeForAccount } from "@/lib/stripe"; -import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getTenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { SubscriptionStatus } from "@prisma/client"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { getTenancy } from "@/lib/tenancies"; +import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; export const POST = createSmartRouteHandler({ metadata: { @@ -14,6 +16,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ full_code: yupString().defined(), price_id: yupString().defined(), + quantity: yupNumber().integer().min(1).default(1), }), }), response: yupObject({ @@ -24,53 +27,111 @@ export const POST = createSmartRouteHandler({ }), }), async handler({ body }) { - const { full_code, price_id } = body; + const { full_code, price_id, quantity } = body; const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code); const tenancy = await getTenancy(data.tenancyId); if (!tenancy) { throw new StackAssertionError("No tenancy found from purchase code data tenancy id. This should never happen."); } - const stripe = getStripeForAccount({ accountId: data.stripeAccountId }); - const pricesMap = new Map(Object.entries(data.offer.prices)); - const selectedPrice = pricesMap.get(price_id); + const stripe = await getStripeForAccount({ accountId: data.stripeAccountId }); + if (data.offer.prices === "include-by-default") { + throw new StatusError(400, "This offer does not have any prices"); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + const { selectedPrice, conflictingGroupSubscriptions } = await validatePurchaseSession({ + prisma, + tenancy, + codeData: data, + priceId: price_id, + quantity, + }); + if (!selectedPrice) { - throw new StatusError(400, "Price not found on offer associated with this purchase code"); + throw new StackAssertionError("Price not resolved for purchase session"); } - // TODO: prices with no interval should be allowed and work without a subscription - if (!selectedPrice.interval) { - throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); + + let clientSecret: string | undefined; + + // Handle upgrades/downgrades within a group + if (conflictingGroupSubscriptions.length > 0) { + const conflicting = conflictingGroupSubscriptions[0]; + if (conflicting.stripeSubscriptionId) { + const existingStripeSub = await stripe.subscriptions.retrieve(conflicting.stripeSubscriptionId); + const existingItem = existingStripeSub.items.data[0]; + const product = await stripe.products.create({ name: data.offer.displayName ?? "Subscription" }); + const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, { + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret'], + items: [{ + id: existingItem.id, + price_data: { + currency: "usd", + unit_amount: Number(selectedPrice.USD) * 100, + product: product.id, + recurring: { + interval_count: selectedPrice.interval![0], + interval: selectedPrice.interval![1], + }, + }, + quantity, + }], + metadata: { + offerId: data.offerId ?? null, + offer: JSON.stringify(data.offer), + }, + }); + clientSecret = getClientSecretFromStripeSubscription(updated); + } else if (conflicting.id) { + // Cancel DB-only subscription and create a new Stripe subscription as normal + await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: conflicting.id, + }, + }, + data: { + status: SubscriptionStatus.canceled, + }, + }); + } } - const product = await stripe.products.create({ - name: data.offer.displayName ?? "Subscription", - }); - const subscription = await stripe.subscriptions.create({ - customer: data.stripeCustomerId, - payment_behavior: 'default_incomplete', - payment_settings: { save_default_payment_method: 'on_subscription' }, - expand: ['latest_invoice.confirmation_secret'], - items: [{ - price_data: { - currency: "usd", - unit_amount: Number(selectedPrice.USD) * 100, - product: product.id, - recurring: { - interval_count: selectedPrice.interval[0], - interval: selectedPrice.interval[1], + if (!clientSecret) { + const product = await stripe.products.create({ + name: data.offer.displayName ?? "Subscription", + }); + const created = await stripe.subscriptions.create({ + customer: data.stripeCustomerId, + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret'], + items: [{ + price_data: { + currency: "usd", + unit_amount: Number(selectedPrice.USD) * 100, + product: product.id, + recurring: { + interval_count: selectedPrice.interval![0], + interval: selectedPrice.interval![1], + }, }, + quantity, + }], + metadata: { + offerId: data.offerId ?? null, + offer: JSON.stringify(data.offer), }, - quantity: 1, - }], - metadata: { - offer: JSON.stringify(data.offer), - }, - }); + }); + clientSecret = getClientSecretFromStripeSubscription(created); + } await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId, }); - const clientSecret = (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret; // stripe-mock returns an empty string here if (typeof clientSecret !== "string") { throwErr(500, "No client secret returned from Stripe for subscription"); diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index bd2e94f302..1807b101f7 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -1,13 +1,19 @@ -import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { filterUndefined, typedFromEntries, getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; -import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; -import * as yup from "yup"; +import { getSubscriptions, isActiveSubscription } from "@/lib/payments"; import { getTenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { inlineOfferSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { filterUndefined, getOrUndefined, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import * as yup from "yup"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -const offerDataSchema = inlineOfferSchema.omit(["server_only", "included_items"]); +const offerDataSchema = inlineOfferSchema + .omit(["server_only", "included_items"]) + .concat(yupObject({ + stackable: yupBoolean().defined(), + })); export const POST = createSmartRouteHandler({ metadata: { @@ -25,6 +31,11 @@ export const POST = createSmartRouteHandler({ offer: offerDataSchema, stripe_account_id: yupString().defined(), project_id: yupString().defined(), + already_bought_non_stackable: yupBoolean().defined(), + conflicting_group_offers: yupArray(yupObject({ + offer_id: yupString().defined(), + display_name: yupString().defined(), + }).defined()).defined(), }).defined(), }), async handler({ body }) { @@ -37,13 +48,45 @@ export const POST = createSmartRouteHandler({ const offerData: yup.InferType = { display_name: offer.displayName ?? "Offer", customer_type: offer.customerType, - prices: Object.fromEntries(Object.entries(offer.prices).map(([key, value]) => [key, filterUndefined({ + stackable: offer.stackable === true, + prices: offer.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(offer.prices).map(([key, value]) => [key, filterUndefined({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, free_trial: value.freeTrial, })])), }; + // Compute purchase context info + const prisma = await getPrismaClientForTenancy(tenancy); + const subscriptions = await getSubscriptions({ + prisma, + tenancy, + customerType: offer.customerType, + customerId: verificationCode.data.customerId, + }); + + const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.offerId === verificationCode.data.offerId) && offer.stackable !== true); + + const groups = tenancy.config.payments.groups; + const groupId = Object.keys(groups).find((g) => offer.groupId === g); + let conflictingGroupOffers: { offer_id: string, display_name: string }[] = []; + if (groupId) { + const isSubscribable = offer.prices !== "include-by-default" && Object.values(offer.prices).some((p: any) => p && p.interval); + if (isSubscribable) { + const conflicts = subscriptions.filter((subscription) => ( + subscription.offerId && + subscription.offer.groupId === groupId && + isActiveSubscription(subscription) && + subscription.offer.prices !== "include-by-default" && + (!offer.isAddOnTo || !Object.keys(offer.isAddOnTo).includes(subscription.offerId)) + )); + conflictingGroupOffers = conflicts.map((s) => ({ + offer_id: s.offerId!, + display_name: s.offer.displayName ?? s.offerId!, + })); + } + } + return { statusCode: 200, bodyType: "json", @@ -51,6 +94,8 @@ export const POST = createSmartRouteHandler({ offer: offerData, stripe_account_id: verificationCode.data.stripeAccountId, project_id: tenancy.project.id, + already_bought_non_stackable: alreadyBoughtNonStackable, + conflicting_group_offers: conflictingGroupOffers, }, }; }, diff --git a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx index 817577ba00..f20e9f684b 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx @@ -8,6 +8,7 @@ export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler( data: yupObject({ tenancyId: yupString().defined(), customerId: yupString().defined(), + offerId: yupString(), offer: offerSchema, stripeCustomerId: yupString().defined(), stripeAccountId: yupString().defined(), diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx new file mode 100644 index 0000000000..2af7027ba7 --- /dev/null +++ b/apps/backend/src/lib/payments.test.tsx @@ -0,0 +1,719 @@ +import type { PrismaClientTransaction } from '@/prisma-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getItemQuantityForCustomer } from './payments'; +import type { Tenancy } from './tenancies'; + +function createMockPrisma(overrides: Partial = {}): PrismaClientTransaction { + return { + subscription: { + findMany: async () => [], + }, + itemQuantityChange: { + findMany: async () => [], + findFirst: async () => null, + }, + projectUser: { + findUnique: async () => null, + }, + team: { + findUnique: async () => null, + }, + ...(overrides as any), + } as any; +} + +function createMockTenancy(config: Partial, id: string = 'tenancy-1'): Tenancy { + return { + id, + config: { + payments: { + ...config, + }, + } as any, + branchId: 'main', + organization: null, + project: { id: 'project-1' }, + } as any; +} + +describe('getItemQuantityForCustomer - manual changes (no subscription)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('manual changes: expired positives ignored; negatives applied', async () => { + const now = new Date('2025-02-01T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'manualA'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { + displayName: 'Manual', + customerType: 'custom', + }, + }, + offers: {}, + groups: {}, + }); + + const prisma = createMockPrisma({ + itemQuantityChange: { + findMany: async () => [ + // +10 expired + { quantity: 10, createdAt: new Date('2025-01-27T00:00:00.000Z'), expiresAt: new Date('2025-01-31T23:59:59.000Z') }, + // +11 active + { quantity: 11, createdAt: new Date('2025-01-29T12:00:00.000Z'), expiresAt: null }, + // -3 active + { quantity: -3, createdAt: new Date('2025-01-30T00:00:00.000Z'), expiresAt: null }, + // -2 expired (should be ignored) + { quantity: -2, createdAt: new Date('2025-01-25T00:00:00.000Z'), expiresAt: new Date('2025-01-26T00:00:00.000Z') }, + ], + findFirst: async () => null, + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'custom-1', + customerType: 'custom', + }); + // Expired +10 absorbs earlier -3; active +11 remains => 11 + expect(qty).toBe(11); + vi.useRealTimers(); + }); + + it('manual changes: multiple active negatives reduce to zero', async () => { + const now = new Date('2025-02-01T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'manualB'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { + displayName: 'Manual', + customerType: 'custom', + }, + }, + offers: {}, + groups: {}, + }); + + const prisma = createMockPrisma({ + itemQuantityChange: { + findMany: async () => [ + // +5 active + { quantity: 5, createdAt: new Date('2025-01-29T12:00:00.000Z'), expiresAt: null }, + // -3 active + { quantity: -3, createdAt: new Date('2025-01-30T00:00:00.000Z'), expiresAt: null }, + // -2 active + { quantity: -2, createdAt: new Date('2025-01-25T00:00:00.000Z'), expiresAt: null }, + ], + findFirst: async () => null, + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'custom-1', + customerType: 'custom', + }); + // Active +5 minus active -3 and -2 => 0 + expect(qty).toBe(0); + vi.useRealTimers(); + }); +}); + + +describe('getItemQuantityForCustomer - subscriptions', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('repeat=never, expires=when-purchase-expires → one grant within period', async () => { + const now = new Date('2025-02-05T12:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemA'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + off1: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 3, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'off1', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-28T23:59:59.000Z'), + quantity: 2, + status: 'active', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // 3 per period * subscription quantity 2 => 6 within period + expect(qty).toBe(6); + vi.useRealTimers(); + }); + + it('repeat=weekly, expires=when-purchase-expires → accumulate within period until now', async () => { + const now = new Date('2025-02-15T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemWeekly'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offW: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 4, repeat: [1, 'week'], expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offW', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + // From 2025-02-01 to 2025-02-15: elapsed weeks = 2 → occurrences = 3 → 3 * 4 = 12 + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Accumulate 3 occurrences * 4 each within current period => 12 + expect(qty).toBe(12); + vi.useRealTimers(); + }); + + it('repeat=weekly, expires=never → accumulate items until now', async () => { + const now = new Date('2025-02-15T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemWeekly'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offW: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 4, repeat: [1, 'week'], expires: 'never' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offW', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + // From 2025-02-01 to 2025-02-15: elapsed weeks = 2 → occurrences = 3 → 3 * 4 = 12 + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Accumulate 3 occurrences * 4 each within current period => 12 + expect(qty).toBe(12); + vi.useRealTimers(); + }); + + it('repeat=weekly, expires=when-repeated → one grant per billing period', async () => { + const now = new Date('2025-02-15T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemWeeklyWindow'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offR: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offR', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + createdAt: new Date('2025-02-01T00:00:00.000Z'), + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // when-repeated: single grant per billing period regardless of repeat windows => 7 + expect(qty).toBe(7); + vi.useRealTimers(); + }); + + it('repeat=never, expires=never → one persistent grant from period start', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemPersistent'; + + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offN: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 2, repeat: 'never', expires: 'never' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offN', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 3, + status: 'active', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Persistent grant: 2 per period * subscription quantity 3 => 6 + expect(qty).toBe(6); + vi.useRealTimers(); + }); + + it('when-repeated yields constant base within a billing period at different times', async () => { + const itemId = 'subItemWeeklyWindowConst'; + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offRC: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offRC', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + createdAt: new Date('2025-02-01T00:00:00.000Z'), + }], + }, + } as any); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-02-02T00:00:00.000Z')); + const qtyEarly = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // when-repeated: within the period, base stays constant at any instant => 7 + expect(qtyEarly).toBe(7); + + vi.setSystemTime(new Date('2025-02-23T00:00:00.000Z')); + const qtyLate = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Still within the same period; remains 7 (new weekly window, same base) + expect(qtyLate).toBe(7); + vi.useRealTimers(); + }); + + it('when-repeated grants again on renewal period boundary', async () => { + const itemId = 'subItemWeeklyWindowRenew'; + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offRR: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => { + const now = new Date(); + const inFirstPeriod = now < new Date('2025-03-01T00:00:00.000Z'); + const start = inFirstPeriod ? new Date('2025-02-01T00:00:00.000Z') : new Date('2025-03-01T00:00:00.000Z'); + const end = inFirstPeriod ? new Date('2025-03-01T00:00:00.000Z') : new Date('2025-04-01T00:00:00.000Z'); + return [{ + offerId: 'offRR', + currentPeriodStart: start, + currentPeriodEnd: end, + quantity: 1, + status: 'active', + createdAt: start, + }]; + }, + }, + } as any); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-02-15T00:00:00.000Z')); + const qtyFirst = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // First billing period grant => 7 + expect(qtyFirst).toBe(7); + + vi.setSystemTime(new Date('2025-03-15T00:00:00.000Z')); + const qtySecond = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Renewal grants again for next period => 7 + expect(qtySecond).toBe(7); + vi.useRealTimers(); + }); + + it('when-repeated (weekly): manual negative reduces within window and resets at next window without renewal', async () => { + const itemId = 'subItemManualDebits'; + const tenancy = createMockTenancy({ + items: { + [itemId]: { displayName: 'S', customerType: 'user' }, + }, + groups: { g1: { displayName: 'G' } }, + offers: { + offMD: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 10, repeat: [1, 'week'], expires: 'when-repeated' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offMD', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + createdAt: new Date('2025-02-01T00:00:00.000Z'), + }], + }, + itemQuantityChange: { + findMany: async () => [ + // Negative within the week of Feb 9-15, expires at end of that week + { quantity: -3, createdAt: new Date('2025-02-10T00:00:00.000Z'), expiresAt: new Date('2025-02-16T00:00:00.000Z') }, + ], + findFirst: async () => null, + }, + } as any); + + vi.useFakeTimers(); + // During week with negative active: 10 - 3 = 7 + vi.setSystemTime(new Date('2025-02-12T00:00:00.000Z')); + const qtyDuring = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qtyDuring).toBe(7); + + // Next week (negative expired): resets without renewal => 10 + vi.setSystemTime(new Date('2025-02-20T00:00:00.000Z')); + const qtyNextWeek = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qtyNextWeek).toBe(10); + vi.useRealTimers(); + }); + + it('repeat=never with expires=when-repeated → treated as persistent (no expiry)', async () => { + const itemId = 'subPersistentWhenRepeated'; + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'S', customerType: 'user' } }, + groups: { g1: { displayName: 'G' } }, + offers: { + offBF: { + displayName: 'O', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-repeated' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offBF', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }], + }, + itemQuantityChange: { + findMany: async () => [ + // Manual positive persists + { quantity: 3, createdAt: new Date('2025-01-10T00:00:00.000Z'), expiresAt: null }, + // Manual negative persists + { quantity: -6, createdAt: new Date('2025-01-15T00:00:00.000Z'), expiresAt: null }, + ], + findFirst: async () => null, + }, + } as any); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-02-15T00:00:00.000Z')); + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + // Persistent: 5 (grant) + 3 (manual +) - 6 (manual -) => 2 + expect(qty).toBe(2); + vi.useRealTimers(); + }); + + it('aggregates multiple subscriptions with different quantities', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemAggregate'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'S', customerType: 'user' } }, + groups: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } }, + offers: { + off1: { + displayName: 'O1', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 2, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + off2: { + displayName: 'O2', groupId: 'g2', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [ + { + offerId: 'off1', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 3, + status: 'active', + }, + { + offerId: 'off2', + currentPeriodStart: new Date('2025-01-15T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-15T00:00:00.000Z'), + quantity: 5, + status: 'active', + }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(11); + vi.useRealTimers(); + }); + + it('one subscription with two items works for both items', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemA = 'bundleItemA'; + const itemB = 'bundleItemB'; + + const tenancy = createMockTenancy({ + items: { [itemA]: { displayName: 'A', customerType: 'user' }, [itemB]: { displayName: 'B', customerType: 'user' } }, + groups: { g1: { displayName: 'G' } }, + offers: { + offBundle: { + displayName: 'OB', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { + [itemA]: { quantity: 2, repeat: 'never', expires: 'when-purchase-expires' }, + [itemB]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' }, + }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offBundle', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 2, + status: 'active', + }], + }, + } as any); + + const qtyA = await getItemQuantityForCustomer({ prisma, tenancy, itemId: itemA, customerId: 'u1', customerType: 'user' }); + const qtyB = await getItemQuantityForCustomer({ prisma, tenancy, itemId: itemB, customerId: 'u1', customerType: 'user' }); + expect(qtyA).toBe(4); + expect(qtyB).toBe(8); + vi.useRealTimers(); + }); + + it('trialing subscription behaves like active', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'trialItem'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'T', customerType: 'user' } }, + groups: { g1: { displayName: 'G' } }, + offers: { + offT: { + displayName: 'OT', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offT', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 3, + status: 'trialing', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(15); + vi.useRealTimers(); + }); + + it('canceled subscription contributes only expired transactions (no active quantity)', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'canceledItem'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'C', customerType: 'user' } }, + groups: { g1: { displayName: 'G' } }, + offers: { + offC: { + displayName: 'OC', groupId: 'g1', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 9, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offC', + currentPeriodStart: new Date('2024-12-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-01-01T00:00:00.000Z'), + quantity: 1, + status: 'canceled', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(0); + vi.useRealTimers(); + }); + + it('ungrouped offer works without tenancy groups', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'ungroupedItem'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'U', customerType: 'user' } }, + groups: {}, + offers: { + offU: { + displayName: 'OU', + groupId: undefined, + customerType: 'user', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: {}, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + offerId: 'offU', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 2, + status: 'active', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(8); + vi.useRealTimers(); + }); +}); + + diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 54c48f1d5a..bd153e583b 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,15 +1,19 @@ import { PrismaClientTransaction } from "@/prisma-client"; import { SubscriptionStatus } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import { inlineOfferSchema, offerSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; -import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrUndefined, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import type { inlineOfferSchema, offerSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; +import { addInterval, FAR_FUTURE_DATE, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; +import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import Stripe from "stripe"; import * as yup from "yup"; import { Tenancy } from "./tenancies"; +const DEFAULT_OFFER_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday + export async function ensureOfferIdOrInlineOffer( tenancy: Tenancy, accessType: "client" | "server" | "admin", @@ -36,6 +40,8 @@ export async function ensureOfferIdOrInlineOffer( throw new StackAssertionError("Inline offer does not exist, this should never happen", { inlineOffer, offerId }); } return { + groupId: undefined, + isAddOnTo: false, displayName: inlineOffer.display_name, customerType: inlineOffer.customer_type, freeTrial: inlineOffer.free_trial, @@ -56,6 +62,79 @@ export async function ensureOfferIdOrInlineOffer( } } +type LedgerTransaction = { + amount: number, + grantTime: Date, + expirationTime: Date, +}; + + +function computeLedgerBalanceAtNow(transactions: LedgerTransaction[], now: Date): number { + const grantedAt = new Map(); + const expiredAt = new Map(); + const usedAt = new Map(); + const timeSet = new Set(); + + for (const t of transactions) { + const grantTime = t.grantTime.getTime(); + if (t.grantTime <= now && t.amount < 0 && t.expirationTime > now) { + usedAt.set(grantTime, (-1 * t.amount) + (usedAt.get(grantTime) ?? 0)); + } + if (t.grantTime <= now && t.amount > 0) { + grantedAt.set(grantTime, (grantedAt.get(grantTime) ?? 0) + t.amount); + } + if (t.expirationTime <= now && t.amount > 0) { + const time2 = t.expirationTime.getTime(); + expiredAt.set(time2, (expiredAt.get(time2) ?? 0) + t.amount); + timeSet.add(time2); + } + timeSet.add(grantTime); + } + const times = Array.from(timeSet.values()).sort((a, b) => a - b); + if (times.length === 0) { + return 0; + } + + let grantedSum = 0; + let expiredSum = 0; + let usedSum = 0; + let usedOrExpiredSum = 0; + for (const t of times) { + const g = grantedAt.get(t) ?? 0; + const e = expiredAt.get(t) ?? 0; + const u = usedAt.get(t) ?? 0; + grantedSum += g; + expiredSum += e; + usedSum += u; + usedOrExpiredSum = Math.max(usedOrExpiredSum + u, expiredSum); + } + return grantedSum - usedOrExpiredSum; +} + +function addWhenRepeatedItemWindowTransactions(options: { + baseQty: number, + repeat: [number, 'day' | 'week' | 'month' | 'year'], + anchor: Date, + nowClamped: Date, + hardEnd: Date | null, +}): LedgerTransaction[] { + const { baseQty, repeat, anchor, nowClamped } = options; + const endLimit = options.hardEnd ?? FAR_FUTURE_DATE; + const finalNow = nowClamped < endLimit ? nowClamped : endLimit; + if (finalNow < anchor) return []; + + const entries: LedgerTransaction[] = []; + const elapsed = getIntervalsElapsed(anchor, finalNow, repeat); + + for (let i = 0; i <= elapsed; i++) { + const windowStart = addInterval(new Date(anchor), [repeat[0] * i, repeat[1]]); + const windowEnd = addInterval(new Date(windowStart), repeat); + entries.push({ amount: baseQty, grantTime: windowStart, expirationTime: windowEnd }); + } + + return entries; +} + export async function getItemQuantityForCustomer(options: { prisma: PrismaClientTransaction, tenancy: Tenancy, @@ -63,40 +142,163 @@ export async function getItemQuantityForCustomer(options: { customerId: string, customerType: "user" | "team" | "custom", }) { - const itemConfig = getOrUndefined(options.tenancy.config.payments.items, options.itemId); - const defaultQuantity = itemConfig?.default.quantity ?? 0; - const subscriptions = await options.prisma.subscription.findMany({ + const now = new Date(); + const transactions: LedgerTransaction[] = []; + + // Quantity changes → ledger entries + const changes = await options.prisma.itemQuantityChange.findMany({ where: { tenancyId: options.tenancy.id, - customerType: typedToUppercase(options.customerType), customerId: options.customerId, - status: { - in: [SubscriptionStatus.active, SubscriptionStatus.trialing], - } + itemId: options.itemId, }, + orderBy: { createdAt: "asc" }, }); + for (const c of changes) { + transactions.push({ + amount: c.quantity, + grantTime: c.createdAt, + expirationTime: c.expiresAt ?? FAR_FUTURE_DATE, + }); + } + + // Subscriptions → ledger entries + const subscriptions = await getSubscriptions({ + prisma: options.prisma, + tenancy: options.tenancy, + customerType: options.customerType, + customerId: options.customerId, + }); + for (const s of subscriptions) { + const offer = s.offer; + const inc = getOrUndefined(offer.includedItems, options.itemId); + if (!inc) continue; + const baseQty = inc.quantity * s.quantity; + if (baseQty <= 0) continue; + const pStart = s.currentPeriodStart; + const pEnd = s.currentPeriodEnd ?? FAR_FUTURE_DATE; + const nowClamped = now < pEnd ? now : pEnd; + if (nowClamped < pStart) continue; + + if (!inc.repeat || inc.repeat === "never") { + if (inc.expires === "when-purchase-expires") { + transactions.push({ amount: baseQty, grantTime: pStart, expirationTime: pEnd }); + } else if (inc.expires === "when-repeated") { + // repeat=never + expires=when-repeated → treat as no expiry + transactions.push({ amount: baseQty, grantTime: pStart, expirationTime: FAR_FUTURE_DATE }); + } else { + transactions.push({ amount: baseQty, grantTime: pStart, expirationTime: FAR_FUTURE_DATE }); + } + } else { + const repeat = inc.repeat; + if (inc.expires === "when-purchase-expires") { + const elapsed = getIntervalsElapsed(pStart, nowClamped, repeat); + const occurrences = elapsed + 1; + const amount = occurrences * baseQty; + transactions.push({ amount, grantTime: pStart, expirationTime: pEnd }); + } else if (inc.expires === "when-repeated") { + const entries = addWhenRepeatedItemWindowTransactions({ + baseQty, + repeat, + anchor: s.createdAt, + nowClamped, + hardEnd: s.currentPeriodEnd, + }); + transactions.push(...entries); + } else { + const elapsed = getIntervalsElapsed(pStart, nowClamped, repeat); + const occurrences = elapsed + 1; + const amount = occurrences * baseQty; + transactions.push({ amount, grantTime: pStart, expirationTime: FAR_FUTURE_DATE }); + } + } + } - const subscriptionQuantity = subscriptions.reduce((acc, subscription) => { - const offer = subscription.offer as yup.InferType; - const item = getOrUndefined(offer.includedItems, options.itemId); - return acc + (item?.quantity ?? 0); - }, 0); + return computeLedgerBalanceAtNow(transactions, now); +} - const { _sum } = await options.prisma.itemQuantityChange.aggregate({ +type Subscription = { + /** + * `null` for default subscriptions + */ + id: string | null, + /** + * `null` for inline offers + */ + offerId: string | null, + /** + * `null` for test mode purchases and group default offers + */ + stripeSubscriptionId: string | null, + offer: yup.InferType, + quantity: number, + currentPeriodStart: Date, + currentPeriodEnd: Date | null, + status: SubscriptionStatus, + createdAt: Date, +}; + +export function isActiveSubscription(subscription: Subscription): boolean { + return subscription.status === SubscriptionStatus.active || subscription.status === SubscriptionStatus.trialing; +} + +export async function getSubscriptions(options: { + prisma: PrismaClientTransaction, + tenancy: Tenancy, + customerType: "user" | "team" | "custom", + customerId: string, +}) { + const groups = options.tenancy.config.payments.groups; + const offers = options.tenancy.config.payments.offers; + const subscriptions: Subscription[] = []; + const dbSubscriptions = await options.prisma.subscription.findMany({ where: { tenancyId: options.tenancy.id, + customerType: typedToUppercase(options.customerType), customerId: options.customerId, - itemId: options.itemId, - OR: [ - { expiresAt: null }, - { expiresAt: { gt: new Date() } }, - ], - }, - _sum: { - quantity: true, }, }); - return subscriptionQuantity + (_sum.quantity ?? 0) + defaultQuantity; + + const groupsWithDbSubscriptions = new Set(); + for (const s of dbSubscriptions) { + const offer = s.offerId ? getOrUndefined(offers, s.offerId) : s.offer as yup.InferType; + if (!offer) continue; + subscriptions.push({ + id: s.id, + offerId: s.offerId, + offer, + quantity: s.quantity, + currentPeriodStart: s.currentPeriodStart, + currentPeriodEnd: s.currentPeriodEnd, + status: s.status, + createdAt: s.createdAt, + stripeSubscriptionId: s.stripeSubscriptionId, + }); + if (offer.groupId !== undefined) { + groupsWithDbSubscriptions.add(offer.groupId); + } + } + + for (const groupId of Object.keys(groups)) { + if (groupsWithDbSubscriptions.has(groupId)) continue; + const offersInGroup = typedEntries(offers).filter(([_, offer]) => offer.groupId === groupId); + const defaultGroupOffer = offersInGroup.find(([_, offer]) => offer.prices === "include-by-default"); + if (defaultGroupOffer) { + subscriptions.push({ + id: null, + offerId: defaultGroupOffer[0], + offer: defaultGroupOffer[1], + quantity: 1, + currentPeriodStart: DEFAULT_OFFER_START_DATE, + currentPeriodEnd: null, + status: SubscriptionStatus.active, + createdAt: DEFAULT_OFFER_START_DATE, + stripeSubscriptionId: null, + }); + } + } + + return subscriptions; } export async function ensureCustomerExists(options: { @@ -137,3 +339,86 @@ export async function ensureCustomerExists(options: { } } } + +type Offer = yup.InferType; +type SelectedPrice = Exclude[string]; + +export async function validatePurchaseSession(options: { + prisma: PrismaClientTransaction, + tenancy: Tenancy, + codeData: { + tenancyId: string, + customerId: string, + offerId?: string, + offer: Offer, + }, + priceId: string, + quantity: number, +}): Promise<{ + selectedPrice: SelectedPrice | undefined, + groupId: string | undefined, + subscriptions: Subscription[], + conflictingGroupSubscriptions: Subscription[], +}> { + const { prisma, tenancy, codeData, priceId, quantity } = options; + + const offer = codeData.offer; + let selectedPrice: SelectedPrice | undefined = undefined; + if (offer.prices !== "include-by-default") { + const pricesMap = new Map(typedEntries(offer.prices)); + selectedPrice = pricesMap.get(priceId) as SelectedPrice | undefined; + if (!selectedPrice) { + throw new StatusError(400, "Price not found on offer associated with this purchase code"); + } + if (!selectedPrice.interval) { + throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); + } + } + if (quantity !== 1 && offer.stackable !== true) { + throw new StatusError(400, "This offer is not stackable; quantity must be 1"); + } + + const subscriptions = await getSubscriptions({ + prisma, + tenancy, + customerType: offer.customerType, + customerId: codeData.customerId, + }); + + if (subscriptions.find((s) => s.offerId === codeData.offerId) && offer.stackable !== true) { + throw new StatusError(400, "Customer already has a subscription for this offer; this offer is not stackable"); + } + + const groups = tenancy.config.payments.groups; + const groupId = typedKeys(groups).find((g) => offer.groupId === g); + + let conflictingGroupSubscriptions: Subscription[] = []; + if (groupId && selectedPrice?.interval) { + conflictingGroupSubscriptions = subscriptions.filter((subscription) => ( + subscription.id && + subscription.offerId && + subscription.offer.groupId === groupId && + isActiveSubscription(subscription) && + subscription.offer.prices !== "include-by-default" && + (!offer.isAddOnTo || !typedKeys(offer.isAddOnTo).includes(subscription.offerId)) + )); + } + + return { selectedPrice, groupId, subscriptions, conflictingGroupSubscriptions }; +} + +export function getClientSecretFromStripeSubscription(subscription: Stripe.Subscription): string { + const latestInvoice = subscription.latest_invoice; + if (latestInvoice && typeof latestInvoice !== "string") { + type InvoiceWithExtras = Stripe.Invoice & { + confirmation_secret?: { client_secret?: string }, + payment_intent?: string | (Stripe.PaymentIntent & { client_secret?: string }) | null, + }; + const invoice = latestInvoice as InvoiceWithExtras; + const confirmationSecret = invoice.confirmation_secret?.client_secret; + const piSecret = typeof invoice.payment_intent !== "string" ? invoice.payment_intent?.client_secret : undefined; + if (typeof confirmationSecret === "string") return confirmationSecret; + if (typeof piSecret === "string") return piSecret; + } + throwErr(500, "No client secret returned from Stripe for subscription"); +} diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index fde73420d8..506a4a4ac8 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,10 +1,9 @@ -import Stripe from "stripe"; import { getTenancy, Tenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { CustomerType } from "@prisma/client"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { overrideEnvironmentConfigOverride } from "./config"; +import Stripe from "stripe"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY"); const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); @@ -16,11 +15,21 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { export const getStackStripe = () => new Stripe(stripeSecretKey, stripeConfig); -export const getStripeForAccount = (options: { tenancy?: Tenancy, accountId?: string }) => { +export const getStripeForAccount = async (options: { tenancy?: Tenancy, accountId?: string }) => { if (!options.tenancy && !options.accountId) { throwErr(400, "Either tenancy or stripeAccountId must be provided"); } - const accountId = options.accountId ?? options.tenancy?.config.payments.stripeAccountId; + + let accountId = options.accountId; + + if (!accountId && options.tenancy) { + const project = await globalPrismaClient.project.findUnique({ + where: { id: options.tenancy.project.id }, + select: { stripeAccountId: true }, + }); + accountId = project?.stripeAccountId || undefined; + } + if (!accountId) { throwErr(400, "Payments are not set up in this Stack Auth project. Please go to the Stack Auth dashboard and complete the Payments onboarding."); } @@ -28,7 +37,7 @@ export const getStripeForAccount = (options: { tenancy?: Tenancy, accountId?: st }; export async function syncStripeSubscriptions(stripeAccountId: string, stripeCustomerId: string) { - const stripe = getStripeForAccount({ accountId: stripeAccountId }); + const stripe = await getStripeForAccount({ accountId: stripeAccountId }); const account = await stripe.accounts.retrieve(stripeAccountId); if (!account.metadata?.tenancyId) { throwErr(500, "Stripe account metadata missing tenancyId"); @@ -60,6 +69,7 @@ export async function syncStripeSubscriptions(stripeAccountId: string, stripeCus if (subscription.items.data.length === 0) { continue; } + const item = subscription.items.data[0]; await prisma.subscription.upsert({ where: { tenancyId_stripeSubscriptionId: { @@ -70,39 +80,25 @@ export async function syncStripeSubscriptions(stripeAccountId: string, stripeCus update: { status: subscription.status, offer: JSON.parse(subscription.metadata.offer), - currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), - currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000), + quantity: item.quantity ?? 1, + currentPeriodEnd: new Date(item.current_period_end * 1000), + currentPeriodStart: new Date(item.current_period_start * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, create: { tenancyId: tenancy.id, customerId, customerType, + offerId: subscription.metadata.offerId, offer: JSON.parse(subscription.metadata.offer), + quantity: item.quantity ?? 1, stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), - currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000), + currentPeriodEnd: new Date(item.current_period_end * 1000), + currentPeriodStart: new Date(item.current_period_start * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, creationSource: "PURCHASE_PAGE" }, }); } } - -export async function syncStripeAccountStatus(stripeAccountId: string) { - const stripe = getStackStripe(); - const account = await stripe.accounts.retrieve(stripeAccountId); - if (!account.metadata?.tenancyId) { - throwErr(500, "Stripe account metadata missing tenancyId"); - } - const tenancy = await getTenancy(account.metadata.tenancyId) ?? throwErr(500, "Tenancy not found"); - const setupComplete = !account.requirements?.past_due?.length; - await overrideEnvironmentConfigOverride({ - projectId: tenancy.project.id, - branchId: tenancy.branchId, - environmentConfigOverrideOverride: { - [`payments.stripeAccountSetupComplete`]: setupComplete, - }, - }); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 892eeaa369..ad08cf0761 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -122,12 +122,11 @@ function TeamAddUserDialog(props: { }) { const users = props.team.useUsers(); const { quantity } = props.team.useItem("dashboard_admins"); - const router = useRouter(); const onSubmit = async (values: yup.InferType) => { if (users.length + 1 > quantity) { alert("You have reached the maximum number of dashboard admins. Please upgrade your plan to add more admins."); - const checkoutUrl = await props.team.createCheckoutUrl("team"); + const checkoutUrl = await props.team.createCheckoutUrl({ offerId: "team" }); window.open(checkoutUrl, "_blank", "noopener"); return "prevent-close-and-prevent-reset"; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index 67fb27c68a..625a693f95 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -1,30 +1,99 @@ "use client"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { SelectField } from "@/components/form-fields"; +import { Link } from "@/components/link"; import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider"; import { cn } from "@/lib/utils"; +import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Typography } from "@stackframe/stack-ui"; import { ConnectNotificationBanner } from "@stripe/react-connect-js"; +import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; import { useState } from "react"; +import * as yup from "yup"; import { useAdminApp } from "../use-admin-app"; export default function PaymentsLayout({ children }: { children: React.ReactNode }) { const [bannerHasItems, setBannerHasItems] = useState(false); const stackAdminApp = useAdminApp(); - const project = stackAdminApp.useProject(); - const config = project.useConfig(); - const stripeAccountId = config.payments.stripeAccountId; + const stripeAccountInfo = stackAdminApp.useStripeAccountInfo(); - if (!stripeAccountId) { - return children; + const setupPayments = async () => { + const { url } = await stackAdminApp.setupPayments(); + window.location.href = url; + await wait(2000); + }; + + if (!stripeAccountInfo) { + return ( +
+ + +
+ +
+ Setup Payments + + Let your users pay seamlessly and securely. + +
    +
  • + + No webhooks or syncing +
  • +
  • + + One-time and recurring +
  • +
  • + + Usage-based billing +
  • +
+
+ +
+
+ + Powered by Stripe +
+
+
+
+ ); } return ( + {!stripeAccountInfo.details_submitted && ( +
+ + Incomplete setup + + Stripe account is not fully setup. + You can test your application, but please{" "} + runAsynchronouslyWithAlert(setupPayments)} + > + complete the setup process + + {" "}to{" "} + {[ + ...!stripeAccountInfo.charges_enabled ? ["receive payments"] : [], + ...!stripeAccountInfo.payouts_enabled ? ["send payouts"] : [], + ].join(" and ")}. + + +
+ )}
setBannerHasItems(total > 0)} collectionOptions={{ - fields: "currently_due", + fields: "eventually_due", }} />
@@ -33,3 +102,43 @@ export default function PaymentsLayout({ children }: { children: React.ReactNode ); } + +function SetupPaymentsButton({ setupPayments }: { setupPayments: () => Promise }) { + return ( + ( + + ), + }), + })} + cancelButton + okButton={{ label: "Continue" }} + trigger={ + + } + onSubmit={async (values) => { + if (values.country !== "US") { + alert("Payments are currently only available for businesses or individuals in the United States."); + return "prevent-close"; + } + await setupPayments(); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx index 5e134671d4..892f9a931d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx @@ -31,5 +31,3 @@ export default function PageClient() { ); } - - diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx index bc4329aecf..92b6998f86 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx @@ -1,81 +1,12 @@ "use client"; -import { SmartFormDialog } from "@/components/form-dialog"; -import { SelectField } from "@/components/form-fields"; -import { wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { - Button, - Card, - CardContent, - Typography, - toast -} from "@stackframe/stack-ui"; import { ConnectPayments } from "@stripe/react-connect-js"; -import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; -import * as yup from "yup"; import { PageLayout } from "../page-layout"; -import { useAdminApp } from "../use-admin-app"; export default function PageClient() { - const stackAdminApp = useAdminApp(); - const project = stackAdminApp.useProject(); - const config = project.useConfig(); - const stripeAccountId = config.payments.stripeAccountId; - const paymentsConfig = config.payments; - - const setupPayments = async () => { - const { url } = await stackAdminApp.setupPayments(); - window.location.href = url; - await wait(2000); - }; - - if (!stripeAccountId) { - return ( -
- - -
- -
- Setup Payments - - Let your users pay seamlessly and securely. - -
    -
  • - - No webhooks or syncing -
  • -
  • - - One-time and recurring -
  • -
  • - - Usage-based billing -
  • -
-
- -
-
- - Powered by Stripe -
-
-
-
- ); - } - return ( - {!paymentsConfig.stripeAccountSetupComplete && ( - - )} -
} >
@@ -85,43 +16,3 @@ export default function PageClient() { ); } - -function SetupPaymentsButton({ setupPayments }: { setupPayments: () => Promise }) { - return ( - ( - - ), - }), - })} - cancelButton - okButton={{ label: "Continue" }} - trigger={ - - } - onSubmit={async (values) => { - if (values.country !== "US") { - toast({ title: "Payments is currently only available in the United States", variant: "destructive" }); - return "prevent-close"; - } - await setupPayments(); - }} - /> - ); -} diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 040da81743..3e3a457cc6 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -8,15 +8,17 @@ import { inlineOfferSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { Button, Card, CardContent, Skeleton, Typography } from "@stackframe/stack-ui"; -import { ArrowRight } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Input, Skeleton, Typography } from "@stackframe/stack-ui"; +import { ArrowRight, Minus, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; type OfferData = { - offer?: Omit, "included_items" | "server_only">, + offer?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string, project_id: string, + already_bought_non_stackable?: boolean, + conflicting_group_offers?: { offer_id: string, display_name: string }[], }; const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); @@ -27,6 +29,7 @@ export default function PageClient({ code }: { code: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPriceId, setSelectedPriceId] = useState(null); + const [quantityInput, setQuantityInput] = useState("1"); const user = useUser({ projectIdMustMatch: "internal" }); const [adminApp, setAdminApp] = useState(); @@ -40,13 +43,36 @@ export default function PageClient({ code }: { code: string }) { })); }, [user, data]); - const currentAmount = useMemo(() => { + const quantityNumber = useMemo((): number => { + const n = parseInt(quantityInput, 10); + if (Number.isNaN(n)) { + return 0; + } + return n; + }, [quantityInput]); + + const unitCents = useMemo((): number => { if (!selectedPriceId || !data?.offer?.prices) { return 0; } return Number(data.offer.prices[selectedPriceId].USD) * 100; }, [data, selectedPriceId]); + const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; + + const rawAmountCents = useMemo(() => { + return unitCents * Math.max(0, quantityNumber); + }, [unitCents, quantityNumber]); + + const isTooLarge = rawAmountCents > MAX_STRIPE_AMOUNT_CENTS; + + const elementsAmountCents = useMemo(() => { + if (!unitCents) return 0; + if (rawAmountCents < 1) return unitCents; + if (isTooLarge) return MAX_STRIPE_AMOUNT_CENTS; + return rawAmountCents; + }, [unitCents, rawAmountCents, isTooLarge, MAX_STRIPE_AMOUNT_CENTS]); + const shortenedInterval = (interval: [number, string]) => { if (interval[0] === 1) { return interval[1]; @@ -86,7 +112,7 @@ export default function PageClient({ code }: { code: string }) { const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ full_code: code, price_id: selectedPriceId }), + body: JSON.stringify({ full_code: code, price_id: selectedPriceId, quantity: quantityNumber }), }); const result = await response.json(); if (!result.client_secret) { @@ -99,12 +125,15 @@ export default function PageClient({ code }: { code: string }) { if (!adminApp || !selectedPriceId) { return; } - await adminApp.testModePurchase({ priceId: selectedPriceId, fullCode: code }); + if (quantityNumber < 1 || isTooLarge) { + return; + } + await adminApp.testModePurchase({ priceId: selectedPriceId, fullCode: code, quantity: quantityNumber }); const url = new URL(`/purchase/return`, window.location.origin); url.searchParams.set("bypass", "1"); url.searchParams.set("purchase_full_code", code); window.location.assign(url.toString()); - }, [code, adminApp, selectedPriceId]); + }, [code, adminApp, selectedPriceId, quantityNumber, isTooLarge]); return (
@@ -124,6 +153,25 @@ export default function PageClient({ code }: { code: string }) { {data?.offer?.display_name || "Plan"}
+ {data?.already_bought_non_stackable ? ( + + Already purchased + + You already have this offer. + + + ) : data?.conflicting_group_offers && data.conflicting_group_offers.length > 0 ? ( + + Plan change + + {data.conflicting_group_offers.length === 1 ? ( + <>This purchase will change your plan from {data.conflicting_group_offers[0].display_name}. + ) : ( + <>This purchase will change your plan from one of your existing plans. + )} + + + ) : null} {data?.offer?.prices && typedEntries(data.offer.prices).map(([priceId, priceData]) => ( ))} + {data?.offer?.stackable && selectedPriceId && ( +
+
+ Quantity +
+ + { + const digitsOnly = e.target.value.replace(/[^0-9]/g, ""); + setQuantityInput(digitsOnly); + }} + /> + +
+
+
+ + {quantityNumber < 1 ? + "Enter a quantity of at least 1." : + isTooLarge ? + "Amount exceeds maximum of $999,999" : + " " + } + +
+
+ Total + + ${selectedPriceId ? (Number(data.offer.prices[selectedPriceId].USD) * Math.max(0, quantityNumber)) : 0} + {selectedPriceId && data.offer.prices[selectedPriceId].interval && ( + + {" "}/ {shortenedInterval(data.offer.prices[selectedPriceId].interval!)} + + )} + +
+
+ )}
)} @@ -162,12 +268,13 @@ export default function PageClient({ code }: { code: string }) { {data && ( )} diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx index be678bdc7b..e4d228dd26 100644 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -34,26 +34,6 @@ const columns: ColumnDef[] = [ cell: ({ row }) => {row.original.customerType}, enableSorting: false, }, - { - accessorKey: "default.quantity", - header: ({ column }) => , - cell: ({ row }) => {row.original.default.quantity}, - enableSorting: false, - }, - { - accessorKey: "default.repeat", - header: ({ column }) => , - cell: ({ row }) => - {row.original.default.repeat === "never" ? "Never" : row.original.default.repeat?.join(" ") ?? ""} - , - enableSorting: false, - }, - { - accessorKey: "default.expires", - header: ({ column }) => , - cell: ({ row }) => {row.original.default.expires || "Never"}, - enableSorting: false, - }, { id: "actions", cell: ({ row }) => , diff --git a/apps/dashboard/src/components/form-fields/keyed-record-editor-field.tsx b/apps/dashboard/src/components/form-fields/keyed-record-editor-field.tsx index 941b6c5b62..3c13110e78 100644 --- a/apps/dashboard/src/components/form-fields/keyed-record-editor-field.tsx +++ b/apps/dashboard/src/components/form-fields/keyed-record-editor-field.tsx @@ -19,6 +19,7 @@ type Props = { name: Path, label: React.ReactNode, required?: boolean, + disabled?: boolean, entryLabel: string, addButtonLabel: string, @@ -108,7 +109,7 @@ export function KeyedRecordEditorField
- {rows.map(row => ( + {!props.disabled && rows.map(row => ( @@ -128,7 +129,7 @@ export function KeyedRecordEditorField
-
diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx index 0f9a3dc215..badd2ee50e 100644 --- a/apps/dashboard/src/components/payments/checkout.tsx +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -21,9 +21,10 @@ type Props = { setupSubscription: () => Promise, stripeAccountId: string, fullCode: string, + disabled?: boolean, }; -export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode }: Props) { +export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode, disabled }: Props) { const stripe = useStripe(); const elements = useElements(); const [message, setMessage] = useState(null); @@ -64,12 +65,14 @@ export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode }: P
- {message &&
{message}
} + {message && ( +
{message}
+ )}
); } diff --git a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx index 09692a8f08..4385fcfbc6 100644 --- a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx +++ b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx @@ -31,7 +31,7 @@ export function CreateCheckoutDialog(props: Props) { const shownOffers = Object.keys(offers).filter(id => offers[id].customerType === (props.user ? "user" : "team")); const createCheckoutUrl = async (data: { offerId: string }) => { - const result = await Result.fromPromise(customer.createCheckoutUrl(data.offerId)); + const result = await Result.fromPromise(customer.createCheckoutUrl({ offerId: data.offerId })); if (result.status === "ok") { setCheckoutUrl(result.data); return; diff --git a/apps/dashboard/src/components/payments/item-dialog.tsx b/apps/dashboard/src/components/payments/item-dialog.tsx index a3a887e0c5..2e47f80ff8 100644 --- a/apps/dashboard/src/components/payments/item-dialog.tsx +++ b/apps/dashboard/src/components/payments/item-dialog.tsx @@ -2,12 +2,11 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField, SelectField } from "@/components/form-fields"; -import { DayIntervalSelectorField } from "@/components/form-fields/day-interval-selector-field"; import { AdminProject } from "@stackframe/stack"; import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; -import { AccordionContent, AccordionItem, Accordion, toast, AccordionTrigger } from "@stackframe/stack-ui"; -import { dayIntervalOrNeverSchema, userSpecifiedIdSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { userSpecifiedIdSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; +import { toast } from "@stackframe/stack-ui"; import * as yup from "yup"; type Props = { @@ -32,9 +31,6 @@ export function ItemDialog({ open, onOpenChange, project, mode, initial }: Props itemId: userSpecifiedIdSchema("itemId").defined().label("Item ID"), displayName: yup.string().optional().label("Display Name"), customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), - defaultQuantity: yup.number().min(0).default(0).label("Default Quantity"), - defaultRepeat: dayIntervalOrNeverSchema.optional().label("Default Repeat"), - defaultExpires: yup.string().oneOf(["never", "when-repeated"]).optional().label("Default Expires"), }); return ( @@ -62,11 +58,6 @@ export function ItemDialog({ open, onOpenChange, project, mode, initial }: Props [`payments.items.${values.itemId}`]: { displayName: values.displayName, customerType: values.customerType, - default: { - quantity: values.defaultQuantity, - repeat: values.defaultRepeat, - expires: values.defaultExpires, - }, }, }); }} @@ -79,20 +70,6 @@ export function ItemDialog({ open, onOpenChange, project, mode, initial }: Props { value: "custom", label: "Custom" }, ]} /> - - - - Defaults - - - - - - -
)} /> diff --git a/apps/dashboard/src/components/payments/offer-dialog.tsx b/apps/dashboard/src/components/payments/offer-dialog.tsx index a99832e80c..fdb984659b 100644 --- a/apps/dashboard/src/components/payments/offer-dialog.tsx +++ b/apps/dashboard/src/components/payments/offer-dialog.tsx @@ -5,9 +5,9 @@ import { CheckboxField, InputField, SelectField } from "@/components/form-fields import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; import { PriceEditorField } from "@/components/payments/price-editor"; import { AdminProject } from "@stackframe/stack"; -import { offerPriceSchema, offerSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; +import { offerSchema, priceOrIncludeByDefaultSchema, userSpecifiedIdSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, toast } from "@stackframe/stack-ui"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, FormLabel, FormItem, FormMessage, toast, FormField, Checkbox, FormControl } from "@stackframe/stack-ui"; import * as yup from "yup"; type Props = { @@ -33,10 +33,12 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop offerId: userSpecifiedIdSchema("offerId").defined().label("Offer ID"), displayName: yup.string().defined().label("Display Name"), customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), - prices: yupRecord(userSpecifiedIdSchema("priceId"), offerPriceSchema) - .defined("At least one price is required") - .label("Prices") - .test("at-least-one-price", "At least one price is required", (value) => Object.keys(value as any).length > 0), + prices: priceOrIncludeByDefaultSchema.defined().label("Prices").test("at-least-one-price", (value, context) => { + if (value !== "include-by-default" && Object.keys(value).length === 0) { + return context.createError({ message: "At least one price is required" }); + } + return true; + }), includedItems: yupRecord( userSpecifiedIdSchema("itemId"), yup.object({ @@ -89,7 +91,7 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop { value: "custom", label: "Custom" }, ]} /> - + @@ -107,6 +109,29 @@ export function OfferDialog({ open, onOpenChange, project, mode, initial }: Prop label={"Stackable"} description="Allow user to purchase multiple" /> + ( + + + field.onChange(checked ? "include-by-default" : {})} + /> + +
+ + Include by default + +

+ The default offer that is included in the group. +

+
+ +
+ )} + />
diff --git a/apps/dashboard/src/components/payments/price-editor.tsx b/apps/dashboard/src/components/payments/price-editor.tsx index 5d2fa0d943..cf7818c1b9 100644 --- a/apps/dashboard/src/components/payments/price-editor.tsx +++ b/apps/dashboard/src/components/payments/price-editor.tsx @@ -20,6 +20,7 @@ export function PriceEditorField(props: { name: Path, label: React.ReactNode, required?: boolean, + disabled?: boolean, }) { return ( (props: { name={props.name} label={props.label} required={props.required} + disabled={props.disabled} entryLabel="Price ID" addButtonLabel="Add Price" renderSummary={(id, price) => { @@ -53,7 +55,7 @@ export function PriceEditorField(props: { USD: yup.string().defined().label("Price (USD)"), interval: dayIntervalSchema.optional().label("Interval"), }), - toFormValue: (id: string, value: OfferPrice) => ({ + toFormValue: (id: string, value: OfferPrice) => typeof value === "string" ? value : ({ id, USD: value.USD, interval: value.interval, diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 5ab403fda5..743ad2ebe3 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1399,11 +1399,21 @@ export namespace Webhook { } export namespace Payments { + export async function setup() { + const response = await niceBackendFetch("/api/latest/internal/payments/setup", { + accessType: "admin", + method: "POST", + body: {}, + }); + expect(response.status).toBe(200); + return response.body; + } + export async function createPurchaseUrlAndGetCode() { await Project.createAndSwitch(); + await Payments.setup(); await Project.updateConfig({ payments: { - stripeAccountId: "acct_test123", offers: { "test-offer": { displayName: "Test Offer", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 34b77d4515..6744a4ba98 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -102,7 +102,6 @@ it("should not work for non-admins", async ({ expect }) => { } `); - await ensureAnonymousUsersAreStillExcluded(response); }); it("should exclude anonymous users from metrics", async ({ expect }) => { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/payments/setup.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/payments/setup.test.ts new file mode 100644 index 0000000000..a69d455015 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/payments/setup.test.ts @@ -0,0 +1,144 @@ +import { describe } from "vitest"; +import { it } from "../../../../../../helpers"; +import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers"; + +describe("POST /api/v1/internal/payments/setup", () => { + describe("without project access", () => { + backendContext.set({ + projectKeys: 'no-project' + }); + + it("should not have access to payment setup", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/internal/payments/setup", { + method: "POST", + accessType: "client", + body: {} + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "ACCESS_TYPE_WITHOUT_PROJECT_ID", + "details": { "request_type": "client" }, + "error": deindent\` + The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided. + + For more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/overview#authentication + \`, + }, + "headers": Headers { + "x-stack-known-error": "ACCESS_TYPE_WITHOUT_PROJECT_ID", +