diff --git a/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql b/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql new file mode 100644 index 0000000000..2e6eb8bfca --- /dev/null +++ b/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - Added the required column `customerType` to the `ItemQuantityChange` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterEnum +ALTER TYPE "CustomerType" ADD VALUE 'CUSTOM'; + +-- AlterTable +ALTER TABLE "ItemQuantityChange" ADD COLUMN "customerType" "CustomerType" NOT NULL, +ALTER COLUMN "customerId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Subscription" ALTER COLUMN "customerId" SET DATA TYPE TEXT; diff --git a/apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql b/apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql new file mode 100644 index 0000000000..89b9fd1778 --- /dev/null +++ b/apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `creationSource` to the `Subscription` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "SubscriptionCreationSource" AS ENUM ('PURCHASE_PAGE', 'TEST_MODE'); + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "creationSource" "SubscriptionCreationSource" NOT NULL, +ALTER COLUMN "stripeSubscriptionId" DROP NOT NULL; 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/schema.prisma b/apps/backend/prisma/schema.prisma index c2f8ff2c4f..646e710511 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -19,7 +19,7 @@ model Project { displayName String description String @default("") isProductionMode Boolean - ownerTeamId String? @db.Uuid + ownerTeamId String? @db.Uuid logoUrl String? fullLogoUrl String? @@ -715,6 +715,7 @@ model ThreadMessage { enum CustomerType { USER TEAM + CUSTOM } enum SubscriptionStatus { @@ -728,35 +729,44 @@ enum SubscriptionStatus { unpaid } +enum SubscriptionCreationSource { + PURCHASE_PAGE + TEST_MODE +} + model Subscription { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid - customerId String @db.Uuid + customerId String customerType CustomerType + offerId String? offer Json + quantity Int @default(1) - stripeSubscriptionId String + stripeSubscriptionId String? status SubscriptionStatus currentPeriodEnd DateTime currentPeriodStart DateTime cancelAtPeriodEnd Boolean - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + creationSource SubscriptionCreationSource + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@id([tenancyId, id]) @@unique([tenancyId, stripeSubscriptionId]) } model ItemQuantityChange { - id String @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - customerId String @db.Uuid - itemId String - quantity Int - description String? - expiresAt DateTime? - createdAt DateTime @default(now()) + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + customerType CustomerType + customerId String + itemId String + quantity Int + description String? + expiresAt DateTime? + createdAt DateTime @default(now()) @@id([tenancyId, id]) @@index([tenancyId, customerId, expiresAt]) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index d1d5caee8b..4bd9dc4e91 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,27 +133,62 @@ 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" } }, } } - }) + }); await updatePermissionDefinition( globalPrismaClient, 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 new file mode 100644 index 0000000000..b3b829c268 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -0,0 +1,76 @@ +import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; +import { getPrismaClientForTenancy } 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 { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + full_code: yupString().defined(), + price_id: yupString().defined(), + quantity: yupNumber().integer().min(1).default(1), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async ({ auth, 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"); + } + if (!data.offer.prices || data.offer.prices === "include-by-default") { + throw new StatusError(400, "This offer does not have any prices"); + } + 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"); + } + if (!selectedPrice.interval) { + throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); + } + if (quantity !== 1 && data.offer.stackable !== true) { + throw new StatusError(400, "This offer is not stackable; quantity must be 1"); + } + + 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: "TEST_MODE", + }, + }); + await purchaseUrlVerificationCodeHandler.revokeCode({ + tenancy: auth.tenancy, + id: codeId, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts similarity index 75% rename from apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts rename to apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts index 2eae2e60b1..9c45afeb59 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts @@ -1,8 +1,8 @@ -import { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments"; import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; @@ -17,6 +17,7 @@ export const GET = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), params: yupObject({ + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), customer_id: yupString().defined(), item_id: yupString().defined(), }).defined(), @@ -38,16 +39,23 @@ export const GET = createSmartRouteHandler({ if (!itemConfig) { throw new KnownErrors.ItemNotFound(req.params.item_id); } - - await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy); + if (req.params.customer_type !== itemConfig.customerType) { + throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type); + } const prisma = await getPrismaClientForTenancy(tenancy); + await ensureCustomerExists({ + prisma, + tenancyId: tenancy.id, + customerType: req.params.customer_type, + customerId: req.params.customer_id, + }); const totalQuantity = await getItemQuantityForCustomer({ prisma, tenancy, itemId: req.params.item_id, customerId: req.params.customer_id, + customerType: req.params.customer_type, }); - return { statusCode: 200, bodyType: "json", @@ -59,3 +67,5 @@ export const GET = createSmartRouteHandler({ }; }, }); + + diff --git a/apps/backend/src/app/api/latest/payments/items/[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 similarity index 78% rename from apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/update-quantity/route.ts rename to apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts index 0d2efe5394..9a44eb02d3 100644 --- a/apps/backend/src/app/api/latest/payments/items/[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 { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments"; +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: { @@ -16,6 +17,7 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), params: yupObject({ + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), customer_id: yupString().defined(), item_id: yupString().defined(), }).defined(), @@ -44,8 +46,16 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.ItemNotFound(req.params.item_id); } - await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy); + if (req.params.customer_type !== itemConfig.customerType) { + throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type); + } const prisma = await getPrismaClientForTenancy(tenancy); + await ensureCustomerExists({ + prisma, + tenancyId: tenancy.id, + customerType: req.params.customer_type, + customerId: req.params.customer_id, + }); const changeId = await retryTransaction(prisma, async (tx) => { const totalQuantity = await getItemQuantityForCustomer({ @@ -53,6 +63,7 @@ export const POST = createSmartRouteHandler({ tenancy, itemId: req.params.item_id, customerId: req.params.customer_id, + customerType: req.params.customer_type, }); if (!allowNegative && (totalQuantity + req.body.delta < 0)) { throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta); @@ -61,6 +72,7 @@ export const POST = createSmartRouteHandler({ data: { tenancyId: tenancy.id, customerId: req.params.customer_id, + customerType: typedToUppercase(req.params.customer_type), itemId: req.params.item_id, quantity: req.body.delta, description: req.body.description, @@ -77,3 +89,5 @@ export const POST = createSmartRouteHandler({ }; }, }); + + 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 f8bdd1c377..2737f0e2ce 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,11 +1,12 @@ -import { ensureOfferCustomerTypeMatches, ensureOfferIdOrInlineOffer } from "@/lib/payments"; +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 { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -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: { @@ -18,7 +19,8 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), body: yupObject({ - customer_id: yupString().uuid().defined(), + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), + customer_id: yupString().defined(), offer_id: yupString().optional(), offer_inline: inlineOfferSchema.optional(), }), @@ -34,8 +36,13 @@ export const POST = createSmartRouteHandler({ const { tenancy } = req.auth; const stripe = getStripeForAccount({ tenancy }); const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); - await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy); - const customerType = offerConfig.customerType ?? throwErr("Customer type not found"); + const customerType = offerConfig.customerType; + if (req.body.customer_type !== customerType) { + throw new KnownErrors.OfferCustomerTypeDoesNotMatch(req.body.offer_id, req.body.customer_id, customerType, req.body.customer_type); + } + if (offerConfig.serverOnly && req.auth.type === "client") { + throw new KnownErrors.OfferDoesNotExist(req.body.offer_id ?? "inline", "client"); + } const stripeCustomerSearch = await stripe.customers.search({ query: `metadata['customerId']:'${req.body.customer_id}'`, @@ -56,6 +63,7 @@ export const POST = createSmartRouteHandler({ 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"), 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 eee990b6d7..d69476434e 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,9 +1,10 @@ -import Stripe from "stripe"; 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 { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import Stripe from "stripe"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; export const POST = createSmartRouteHandler({ metadata: { @@ -13,6 +14,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ full_code: yupString().defined(), price_id: yupString().defined(), + quantity: yupNumber().integer().min(1).default(1), }), }), response: yupObject({ @@ -23,8 +25,12 @@ export const POST = createSmartRouteHandler({ }), }), async handler({ body }) { - const { full_code, price_id } = body; - const { data } = await purchaseUrlVerificationCodeHandler.validateCode(full_code); + 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); @@ -35,6 +41,10 @@ export const POST = createSmartRouteHandler({ if (!selectedPrice.interval) { throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); } + if (quantity !== 1 && data.offer.stackable !== true) { + throw new StatusError(400, "This offer is not stackable; quantity must be 1"); + } + const product = await stripe.products.create({ name: data.offer.displayName ?? "Subscription", }); @@ -53,12 +63,18 @@ export const POST = createSmartRouteHandler({ interval: selectedPrice.interval[1], }, }, - quantity: 1, + quantity, }], metadata: { + offerId: data.offerId ?? null, offer: JSON.stringify(data.offer), }, }); + 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") { 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 37e404929b..492846ed12 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,11 +1,17 @@ -import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getTenancy } from "@/lib/tenancies"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { filterUndefined, typedFromEntries, getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { inlineOfferSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { filterUndefined, getOrUndefined, 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: { @@ -22,14 +28,20 @@ export const POST = createSmartRouteHandler({ body: yupObject({ offer: offerDataSchema, stripe_account_id: yupString().defined(), + project_id: yupString().defined(), }).defined(), }), async handler({ body }) { const verificationCode = await purchaseUrlVerificationCodeHandler.validateCode(body.full_code); + const tenancy = await getTenancy(verificationCode.data.tenancyId); + if (!tenancy) { + throw new StackAssertionError(`No tenancy found for given tenancyId`); + } const offer = verificationCode.data.offer; const offerData: yup.InferType = { display_name: offer.displayName ?? "Offer", - customer_type: offer.customerType ?? "user", + customer_type: offer.customerType, + stackable: offer.stackable === true, prices: Object.fromEntries(Object.entries(offer.prices).map(([key, value]) => [key, filterUndefined({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, @@ -43,6 +55,7 @@ export const POST = createSmartRouteHandler({ body: { offer: offerData, stripe_account_id: verificationCode.data.stripeAccountId, + project_id: tenancy.project.id, }, }; }, 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/app/api/latest/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx index d0c74e2170..2ecceb4bc9 100644 --- a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx @@ -87,6 +87,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ tenancy, customerId: data.team_id, itemId: "dashboard_admins", + customerType: "team", }); if (currentMemberCount + 1 > maxDashboardAdmins) { throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", data.team_id, -1); diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx new file mode 100644 index 0000000000..495912674d --- /dev/null +++ b/apps/backend/src/lib/payments.test.tsx @@ -0,0 +1,350 @@ +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: any, id: string = 'tenancy-1'): Tenancy { + return { + id, + config: { + ...config, + } as any, + branchId: 'main', + organization: null, + project: { id: 'project-1' } as any, + } as any; +} + +describe('getItemQuantityForCustomer - defaults and manual changes', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('returns default quantity for non-repeating item', async () => { + const now = new Date('2025-01-01T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'itemA'; + + const tenancy = createMockTenancy({ + payments: { + items: { + [itemId]: { + displayName: 'Item A', + customerType: 'team', + default: { quantity: 2, repeat: 'never', expires: 'never' }, + }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + team: { + findUnique: async () => ({ createdAt: new Date('2024-12-01T00:00:00.000Z') }), + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'team-1', + customerType: 'team', + }); + expect(qty).toBe(2); + vi.useRealTimers(); + }); + + it('weekly default with expires="when-repeated" yields only current window amount', async () => { + const now = new Date('2025-01-29T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'weeklyItem'; + + const tenancy = createMockTenancy({ + payments: { + items: { + [itemId]: { + displayName: 'Weekly', + customerType: 'user', + default: { quantity: 10, repeat: [1, 'week'], expires: 'when-repeated' }, + }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + projectUser: { + findUnique: async () => ({ createdAt: new Date('2025-01-01T00:00:00.000Z') }), + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'user-1', + customerType: 'user', + }); + expect(qty).toBe(10); + vi.useRealTimers(); + }); + + it('weekly default with expires="never" accumulates across elapsed intervals', async () => { + const now = new Date('2025-01-29T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'accumItem'; + + const tenancy = createMockTenancy({ + payments: { + items: { + [itemId]: { + displayName: 'Accum', + customerType: 'user', + default: { quantity: 10, repeat: [1, 'week'], expires: 'never' }, + }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + projectUser: { + findUnique: async () => ({ createdAt: new Date('2025-01-01T00:00:00.000Z') }), + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'user-1', + customerType: 'user', + }); + // From 2025-01-01 to 2025-01-29 is exactly 4 weeks; occurrences = 4 + 1 = 5 → 50 + expect(qty).toBe(50); + vi.useRealTimers(); + }); + + 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({ + payments: { + items: { + [itemId]: { + displayName: 'Manual', + customerType: 'custom', + default: { quantity: 0, repeat: 'never', expires: 'never' }, + }, + }, + offers: {}, + }, + }); + + 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') }, + // +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 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', + }); + expect(qty).toBe(5); + 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({ + payments: { + items: { + [itemId]: { displayName: 'S', customerType: 'user', default: { quantity: 0, repeat: 'never', expires: 'never' } }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-28T23:59:59.000Z'), + quantity: 2, + status: 'active', + offer: { + displayName: 'O', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { + [itemId]: { quantity: 3, repeat: 'never', expires: 'when-purchase-expires' }, + }, + }, + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + 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({ + payments: { + items: { + [itemId]: { displayName: 'S', customerType: 'user', default: { quantity: 0, repeat: 'never', expires: 'never' } }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + offer: { + displayName: 'O', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { + [itemId]: { quantity: 4, repeat: [1, 'week'], expires: 'when-purchase-expires' }, + }, + }, + }], + }, + } 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' }); + expect(qty).toBe(12); + vi.useRealTimers(); + }); + + it('repeat=weekly, expires=when-repeated → only current repeat window amount', async () => { + const now = new Date('2025-02-15T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'subItemWeeklyWindow'; + + const tenancy = createMockTenancy({ + payments: { + items: { + [itemId]: { displayName: 'S', customerType: 'user', default: { quantity: 0, repeat: 'never', expires: 'never' } }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + offer: { + displayName: 'O', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { + [itemId]: { quantity: 7, repeat: [1, 'week'], expires: 'when-repeated' }, + }, + }, + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + 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({ + payments: { + items: { + [itemId]: { displayName: 'S', customerType: 'user', default: { quantity: 0, repeat: 'never', expires: 'never' } }, + }, + offers: {}, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 3, + status: 'active', + offer: { + displayName: 'O', customerType: 'user', freeTrial: undefined, serverOnly: false, stackable: false, + prices: {}, + includedItems: { + [itemId]: { quantity: 2, repeat: 'never', expires: 'never' }, + }, + }, + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(6); + vi.useRealTimers(); + }); +}); + + diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 952b201f28..ee384f16fe 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,14 +1,17 @@ -import { teamsCrudHandlers } from "@/app/api/latest/teams/crud"; -import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +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 type { inlineOfferSchema, offerSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; +import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed, getWindowStart } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrUndefined, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { getOrUndefined, typedEntries, typedFromEntries } 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 * as yup from "yup"; import { Tenancy } from "./tenancies"; -import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; -import { SubscriptionStatus } from "@prisma/client"; -import { PrismaClientTransaction } from "@/prisma-client"; + +const DEFAULT_OFFER_START_DATE = new Date("2024-01-01T12:00:00.000Z"); export async function ensureOfferIdOrInlineOffer( tenancy: Tenancy, @@ -36,6 +39,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, @@ -49,74 +54,67 @@ export async function ensureOfferIdOrInlineOffer( }])), includedItems: typedFromEntries(Object.entries(inlineOffer.included_items).map(([key, value]) => [key, { repeat: value.repeat ?? "never", - quantity: value.quantity, + quantity: value.quantity ?? 0, expires: value.expires ?? "never", }])), }; } } -export async function ensureItemCustomerTypeMatches(itemId: string, itemCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { - const actualCustomerType = await getCustomerType(tenancy, customerId); - if (itemCustomerType !== actualCustomerType) { - throw new KnownErrors.ItemCustomerTypeDoesNotMatch(itemId, customerId, itemCustomerType, actualCustomerType); - } -} +type PositiveLedgerTransaction = { + amount: number, + grantTime: Date, + expirationTime: Date, +}; -export async function ensureOfferCustomerTypeMatches(offerId: string | undefined, offerCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { - const actualCustomerType = await getCustomerType(tenancy, customerId); - if (offerCustomerType !== actualCustomerType) { - throw new KnownErrors.OfferCustomerTypeDoesNotMatch(offerId, customerId, offerCustomerType, actualCustomerType); - } -} +type NegativeLedgerTransaction = { + amount: number, + grantTime: Date, +}; -export async function getCustomerType(tenancy: Tenancy, customerId: string) { - let user; - try { - user = await usersCrudHandlers.adminRead( - { - user_id: customerId, - tenancy, - allowedErrorTypes: [ - KnownErrors.UserNotFound, - ], - } - ); - } catch (e) { - if (KnownErrors.UserNotFound.isInstance(e)) { - user = null; - } else { - throw e; +function computeLedgerBalanceAtNow(pos: PositiveLedgerTransaction[], neg: NegativeLedgerTransaction[], now: Date): number { + const grantedAt = new Map(); + const expiredAt = new Map(); + const usedAt = new Map(); + const timeSet = new Set(); + + for (const p of pos) { + if (p.grantTime <= now) { + const t = p.grantTime.getTime(); + grantedAt.set(t, (grantedAt.get(t) ?? 0) + p.amount); + timeSet.add(t); + } + if (p.expirationTime <= now) { + const t2 = p.expirationTime.getTime(); + expiredAt.set(t2, (expiredAt.get(t2) ?? 0) + p.amount); + timeSet.add(t2); } } - let team; - try { - team = await teamsCrudHandlers.adminRead({ - team_id: customerId, - tenancy, - allowedErrorTypes: [ - KnownErrors.TeamNotFound, - ], - }); - } catch (e) { - if (KnownErrors.TeamNotFound.isInstance(e)) { - team = null; - } else { - throw e; + for (const n of neg) { + if (n.grantTime <= now) { + const t = n.grantTime.getTime(); + usedAt.set(t, (usedAt.get(t) ?? 0) + n.amount); + timeSet.add(t); } } - if (user && team) { - throw new StackAssertionError("Found a customer that is both user and team at the same time? This should never happen!", { customerId, user, team, tenancy }); - } + const times = Array.from(timeSet.values()).sort((a, b) => a - b); + if (times.length === 0) return 0; - if (user) { - return "user"; - } - if (team) { - return "team"; + 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); } - throw new KnownErrors.CustomerDoesNotExist(customerId); + return grantedSum - usedOrExpiredSum; } export async function getItemQuantityForCustomer(options: { @@ -124,38 +122,176 @@ export async function getItemQuantityForCustomer(options: { tenancy: Tenancy, itemId: string, customerId: string, + customerType: "user" | "team" | "custom", }) { + const now = new Date(); const itemConfig = getOrUndefined(options.tenancy.config.payments.items, options.itemId); - const defaultQuantity = itemConfig?.default.quantity ?? 0; - const subscriptions = await options.prisma.subscription.findMany({ + const pos: PositiveLedgerTransaction[] = []; + const neg: NegativeLedgerTransaction[] = []; + + // Manual 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) { + if (c.quantity > 0) { + pos.push({ amount: c.quantity, grantTime: c.createdAt, expirationTime: c.expiresAt ?? FAR_FUTURE_DATE }); + } else if (c.quantity < 0 && (!c.expiresAt || c.expiresAt > now)) { + // If a negative change has an expiresAt in the past, it's irrelevant; if in the future or null, treat as active. + neg.push({ amount: -c.quantity, grantTime: c.createdAt }); + } + } + + // 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 as yup.InferType; + 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") { + pos.push({ amount: baseQty, grantTime: pStart, expirationTime: pEnd }); + } else if (inc.expires === "when-repeated") { + pos.push({ amount: baseQty, grantTime: pStart, expirationTime: pEnd < FAR_FUTURE_DATE ? pEnd : FAR_FUTURE_DATE }); + } else { + pos.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; + pos.push({ amount, grantTime: pStart, expirationTime: pEnd }); + } else if (inc.expires === "when-repeated") { + const start = getWindowStart(pStart, repeat, nowClamped); + const end = addInterval(new Date(start), repeat); + const exp = end < pEnd ? end : pEnd; + pos.push({ amount: baseQty, grantTime: start, expirationTime: exp }); + } else { + const elapsed = getIntervalsElapsed(pStart, nowClamped, repeat); + const occurrences = elapsed + 1; + const amount = occurrences * baseQty; + pos.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(pos, neg, now); +} - const { _sum } = await options.prisma.itemQuantityChange.aggregate({ +export async function ensureCustomerExists(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + customerType: "user" | "team" | "custom", + customerId: string, +}) { + if (options.customerType === "user") { + if (!isUuid(options.customerId)) { + throw new KnownErrors.UserNotFound(); + } + const user = await options.prisma.projectUser.findUnique({ + where: { + tenancyId_projectUserId: { + tenancyId: options.tenancyId, + projectUserId: options.customerId, + }, + }, + }); + if (!user) { + throw new KnownErrors.UserNotFound(); + } + } else if (options.customerType === "team") { + if (!isUuid(options.customerId)) { + throw new KnownErrors.TeamNotFound(options.customerId); + } + const team = await options.prisma.team.findUnique({ + where: { + tenancyId_teamId: { + tenancyId: options.tenancyId, + teamId: options.customerId, + }, + }, + }); + if (!team) { + throw new KnownErrors.TeamNotFound(options.customerId); + } + } +} + +type Subscription = { + offerId: string, + offer: yup.InferType, + quantity: number, + currentPeriodStart: Date, + currentPeriodEnd: Date | null, + status: SubscriptionStatus, +}; + +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, + status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, }, }); - return subscriptionQuantity + (_sum.quantity ?? 0) + defaultQuantity; + + for (const groupId of Object.keys(groups)) { + const offersInGroup = typedEntries(offers).filter(([_, offer]) => offer.groupId === groupId); + for (const [offerId, offer] of offersInGroup) { + const subscription = dbSubscriptions.find(s => s.offerId === offerId); + if (subscription) { + subscriptions.push({ + offerId, + offer, + quantity: subscription.quantity, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + status: subscription.status, + }); + continue; + } + } + const defaultGroupOffer = offersInGroup.find(([_, offer]) => offer.prices === "include-by-default"); + if (defaultGroupOffer) { + subscriptions.push({ + offerId: defaultGroupOffer[0], + offer: defaultGroupOffer[1], + quantity: 1, + currentPeriodStart: DEFAULT_OFFER_START_DATE, + currentPeriodEnd: null, + status: SubscriptionStatus.active, + }); + } + } + + return subscriptions; } diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index e177eeb8a3..80d9a5995e 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,9 +1,9 @@ +import Stripe from "stripe"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } 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 Stripe from "stripe"; import { overrideEnvironmentConfigOverride } from "./config"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY"); @@ -60,6 +60,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,20 +71,24 @@ 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" }, }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx new file mode 100644 index 0000000000..0e4480c6e6 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { PaymentItemTable } from "@/components/data-table/payment-item-table"; +import { ItemDialog } from "@/components/payments/item-dialog"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { DialogOpener } from "@/components/dialog-opener"; + + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig = config.payments; + + return ( + + {state => ( + + )} + + } + > + + + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx new file mode 100644 index 0000000000..9bb59067a1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx @@ -0,0 +1,23 @@ +import { devFeaturesEnabledForProject } from "@/lib/utils"; +import { notFound } from "next/navigation"; +import PageClient from "./page-client"; + +export const metadata = { + title: "Items", +}; + +type Params = { + projectId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + const { projectId } = await params; + if (!devFeaturesEnabledForProject(projectId)) { + notFound(); + } + return ( + + ); +} + + 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 new file mode 100644 index 0000000000..892f9a931d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; +import { OfferDialog } from "@/components/payments/offer-dialog"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { DialogOpener } from "@/components/dialog-opener"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig = config.payments; + + return ( + + {state => ( + + )} + } + > + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx new file mode 100644 index 0000000000..ec3ea24924 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx @@ -0,0 +1,23 @@ +import { devFeaturesEnabledForProject } from "@/lib/utils"; +import { notFound } from "next/navigation"; +import PageClient from "./page-client"; + +export const metadata = { + title: "Offers", +}; + +type Params = { + projectId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + const { projectId } = await params; + if (!devFeaturesEnabledForProject(projectId)) { + notFound(); + } + return ( + + ); +} + + 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 3b63ed38c9..bc4329aecf 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,32 +1,18 @@ "use client"; -import * as yup from "yup"; -import { useState } from "react"; -import { PaymentItemTable } from "@/components/data-table/payment-item-table"; -import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; -import { FormDialog, SmartFormDialog } from "@/components/form-dialog"; -import { InputField, SelectField, SwitchField } 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 { KnownErrors } from "@stackframe/stack-shared"; -import { - offerPriceSchema, - userSpecifiedIdSchema, - yupRecord -} from "@stackframe/stack-shared/dist/schema-fields"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { SelectField } from "@/components/form-fields"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { - ActionDialog, Button, Card, CardContent, - InlineCode, 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"; @@ -37,9 +23,6 @@ export default function PageClient() { const stripeAccountId = config.payments.stripeAccountId; const paymentsConfig = config.payments; - const [isCreateOfferOpen, setIsCreateOfferOpen] = useState(false); - const [isCreateItemOpen, setIsCreateItemOpen] = useState(false); - const setupPayments = async () => { const { url } = await stackAdminApp.setupPayments(); window.location.href = url; @@ -88,31 +71,17 @@ export default function PageClient() { return ( {!paymentsConfig.stripeAccountSetupComplete && ( )} } > - } - /> - } - /> - - +
+
+ +
+
); } @@ -156,124 +125,3 @@ function SetupPaymentsButton({ setupPayments }: { setupPayments: () => Promise ); } - - -function CreateOfferDialog({ - open, - onOpenChange, - project, -}: { - open: boolean, - project: AdminProject, - onOpenChange: (open: boolean) => void, -}) { - const config = project.useConfig(); - - const offerSchema = yup.object({ - offerId: yup.string().defined().label("Offer ID"), - displayName: yup.string().defined().label("Display Name"), - customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type"), - prices: yupRecord(userSpecifiedIdSchema("priceId"), offerPriceSchema).defined().label("Prices").test("at-least-one-price", "At least one price is required", (value) => { - return Object.keys(value).length > 0; - }), - includedItems: yupRecord(userSpecifiedIdSchema("itemId"), yup.object({ - quantity: yup.number().defined(), - repeat: yup.mixed().optional(), - expires: yup.string().oneOf(["never", "when-purchase-expires", "when-repeated"]).optional(), - })).default({}).label("Included Items"), - freeTrialDays: yup.number().min(0).optional().label("Free Trial (days)"), - serverOnly: yup.boolean().default(false).label("Server Only"), - stackable: yup.boolean().default(false).label("Stackable"), - }); - - return ( - ) => { - await project.updateConfig({ - [`payments.offers.${values.offerId}`]: { - prices: values.prices, - includedItems: values.includedItems, - customerType: values.customerType, - displayName: values.displayName, - serverOnly: values.serverOnly, - stackable: values.stackable, - freeTrial: values.freeTrialDays ? [values.freeTrialDays, "day"] : undefined, - }, - }); - }} - render={(form) => ( -
- - - - - - - - {/* */} - - -
- )} - /> - ); -} - - -function CreateItemDialog({ open, onOpenChange, project }: { open: boolean, onOpenChange: (open: boolean) => void, project: AdminProject }) { - const itemSchema = yup.object({ - itemId: yup.string().defined().label("Item ID"), - displayName: yup.string().optional().label("Display Name"), - customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type").meta({ - stackFormFieldRender: (props) => ( - - ), - }), - defaultQuantity: yup.number().min(0).default(0).label("Default Quantity"), - defaultRepeatDays: yup.number().min(1).optional().label("Default Repeat (days)"), - defaultExpires: yup.string().oneOf(["never", "when-repeated"]).optional().label("Default Expires").meta({ - stackFormFieldRender: (props) => ( - - ), - }), - }); - - return ( - { - await project.updateConfig({ - [`payments.items.${values.itemId}`]: { - displayName: values.displayName, - customerType: values.customerType, - default: { - quantity: values.defaultQuantity, - repeat: values.defaultRepeatDays ? [values.defaultRepeatDays, "day"] : undefined, - expires: values.defaultExpires, - }, - }, - }); - }} - /> - ); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 8374ab6cf6..a62cc598bf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -24,6 +24,7 @@ import { } from "@stackframe/stack-ui"; import { Book, + Box, CreditCard, Globe, KeyRound, @@ -226,6 +227,22 @@ const navigationItems: (Label | Item | Hidden)[] = [ type: 'item', requiresDevFeatureFlag: true, }, + { + name: "Offers", + href: "/payments/offers", + regex: /^\/projects\/[^\/]+\/payments\/offers$/, + icon: SquarePen, + type: 'item', + requiresDevFeatureFlag: true, + }, + { + name: "Items", + href: "/payments/items", + regex: /^\/projects\/[^\/]+\/payments\/items$/, + icon: Box, + type: 'item', + requiresDevFeatureFlag: true, + }, { name: "Configuration", type: 'label' 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 778ca123f0..f1a9830dc9 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -3,13 +3,20 @@ import { CheckoutForm } from "@/components/payments/checkout"; import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; import { getPublicEnvVar } from "@/lib/env"; +import { StackAdminApp, useUser } from "@stackframe/stack"; +import { inlineOfferSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { Card, CardContent, Skeleton, Typography } from "@stackframe/stack-ui"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { 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?: any, + offer?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string, + project_id: string, }; const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); @@ -20,14 +27,50 @@ 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(); - const currentAmount = useMemo(() => { + useEffect(() => { + if (!user || !data) return; + runAsynchronouslyWithAlert(user.listOwnedProjects().then(projects => { + const project = projects.find(p => p.id === data.project_id); + if (project) { + setAdminApp(project.app); + } + })); + }, [user, data]); + + 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 data.offer.prices[selectedPriceId]?.USD * 100; + 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]; @@ -67,7 +110,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) { @@ -76,6 +119,19 @@ export default function PageClient({ code }: { code: string }) { return result.client_secret; }; + const handleBypass = useCallback(async () => { + if (!adminApp || !selectedPriceId) { + return; + } + 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, quantityNumber, isTooLarge]); return (
@@ -84,16 +140,18 @@ export default function PageClient({ code }: { code: string }) { ) : error ? ( <> - The following error occurred: - {error} + Invalid URL + + The purchase code is invalid or has expired. + ) : ( <>
- {data?.offer?.displayName || "Plan"} + {data?.offer?.display_name || "Plan"}
-
- {data?.offer?.prices && Object.entries(data.offer.prices).map(([priceId, priceData]: [string, any]) => ( +
+ {data?.offer?.prices && typedEntries(data.offer.prices).map(([priceId, priceData]) => ( ${priceData.USD} - - {" "}/ {shortenedInterval(priceData.interval)} - + {priceData.interval && ( + + {" "}/ {shortenedInterval(priceData.interval)} + + )}
))} + {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!)} + + )} + +
+
+ )}
)} -
+
+ {adminApp && ( +
+ +
+ )} {data && ( )}
-
); } + +function BypassInfo({ handleBypass }: { handleBypass: () => Promise }) { + return ( + + +
+
+ Test mode bypass + Not shown to customers +
+ +
+
+
+ ); +} + diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index 0de1e457e4..61230a2f15 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -5,7 +5,7 @@ import { getPublicEnvVar } from "@/lib/env"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { Typography } from "@stackframe/stack-ui"; import { loadStripe } from "@stripe/stripe-js"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; type Props = { redirectStatus?: string, @@ -13,6 +13,7 @@ type Props = { clientSecret?: string, stripeAccountId?: string, purchaseFullCode?: string, + bypass?: string, }; type ViewState = @@ -22,11 +23,15 @@ type ViewState = const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY") ?? ""; -export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode }: Props) { +export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass }: Props) { const [state, setState] = useState({ kind: "loading" }); const updateViewState = useCallback(async (): Promise => { try { + if (bypass === "1") { + setState({ kind: "success", message: "Bypassed in test mode. No payment processed." }); + return; + } const stripe = await loadStripe(stripePublicKey, { stripeAccount: stripeAccountId }); if (!stripe) throw new Error("Stripe failed to initialize"); if (!clientSecret) return; @@ -59,7 +64,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const message = e instanceof Error ? e.message : "Unexpected error retrieving payment."; setState({ kind: "error", message }); } - }, [clientSecret, stripeAccountId]); + }, [clientSecret, stripeAccountId, bypass]); useEffect(() => { runAsynchronously(updateViewState()); diff --git a/apps/dashboard/src/app/(main)/purchase/return/page.tsx b/apps/dashboard/src/app/(main)/purchase/return/page.tsx index ccb4003ab4..fcce9bf6b1 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page.tsx @@ -8,6 +8,7 @@ type Props = { payment_intent_client_secret?: string, stripe_account_id?: string, purchase_full_code?: string, + bypass?: string, }>, }; @@ -20,6 +21,7 @@ export default async function Page({ searchParams }: Props) { clientSecret={params.payment_intent_client_secret} stripeAccountId={params.stripe_account_id} purchaseFullCode={params.purchase_full_code} + bypass={params.bypass} /> ); } 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 0a120f37ba..fe381f52ba 100644 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -1,10 +1,12 @@ 'use client'; import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { SmartFormDialog } from "@/components/form-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { KnownErrors } from "@stackframe/stack-shared"; import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { has } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { ActionCell, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; +import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; import { useState } from "react"; import * as yup from "yup"; @@ -32,39 +34,13 @@ 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 }) => , + cell: ({ row }) => , } ]; -export function PaymentItemTable({ - items, - toolbarRender, -}: { - items: Record["items"][string]>, - toolbarRender: () => React.ReactNode, -}) { +export function PaymentItemTable({ items }: { items: Record["items"][string]> }) { const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({ id, ...item, @@ -76,41 +52,92 @@ export function PaymentItemTable({ defaultColumnFilters={[]} defaultSorting={[]} showDefaultToolbar={false} - toolbarRender={toolbarRender} />; } -function ActionsCell({ itemId }: { itemId: string }) { +function ActionsCell({ item }: { item: PaymentItem }) { const [open, setOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); return ( <> setOpen(true), }, + { + item: "Edit", + onClick: () => setIsEditOpen(true), + }, + '-', { item: "Delete", - disabled: true, - onClick: () => { }, + onClick: () => setIsDeleteOpen(true), + danger: true, }, ]} /> + + { + const config = await project.getConfig(); + for (const [offerId, offer] of Object.entries(config.payments.offers)) { + if (has(offer.includedItems, item.id)) { + toast({ + title: "Item is included in offer", + description: `Please remove it from the offer "${offerId}" before deleting.`, + variant: "destructive", + }); + return "prevent-close"; + } + } + await project.updateConfig({ + [`payments.items.${item.id}`]: null, + }); + toast({ title: "Item deleted" }); + } + }} /> ); } -function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open: boolean, onOpenChange: (open: boolean) => void, itemId: string }) { +type CreateItemQuantityChangeDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + itemId: string, + customerType: "user" | "team" | "custom" | undefined, +} + +function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId, customerType }: CreateItemQuantityChangeDialogProps) { const stackAdminApp = useAdminApp(); const schema = yup.object({ - customerId: yup.string().uuid().defined().label("Customer ID"), + customerId: yup.string().defined().label("Customer ID"), quantity: yup.number().defined().label("Quantity"), description: yup.string().optional().label("Description"), expiresAt: yup.date().optional().label("Expires At"), @@ -118,7 +145,12 @@ function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open: const submit = async (values: yup.InferType) => { const result = await Result.fromPromise(stackAdminApp.createItemQuantityChange({ - customerId: values.customerId, + ...(customerType === "user" ? + { userId: values.customerId } : + customerType === "team" ? + { teamId: values.customerId } : + { customId: values.customerId } + ), itemId, quantity: values.quantity, expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, @@ -130,10 +162,10 @@ function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open: } if (result.error instanceof KnownErrors.ItemNotFound) { toast({ title: "Item not found", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.ItemCustomerTypeDoesNotMatch) { - toast({ title: "Customer type does not match expected type for this item", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) { - toast({ title: "Customer does not exist", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.UserNotFound) { + toast({ title: "No user found with the given ID", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.TeamNotFound) { + toast({ title: "No team found with the given ID", variant: "destructive" }); } else { toast({ title: "An unknown error occurred", variant: "destructive" }); } diff --git a/apps/dashboard/src/components/data-table/payment-offer-table.tsx b/apps/dashboard/src/components/data-table/payment-offer-table.tsx index 96e4cf180a..3e3f4c8b40 100644 --- a/apps/dashboard/src/components/data-table/payment-offer-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-offer-table.tsx @@ -1,8 +1,11 @@ 'use client'; -import { ActionCell, Button, DataTable, DataTableColumnHeader, TextCell } from "@stackframe/stack-ui"; +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { OfferDialog } from "@/components/payments/offer-dialog"; +import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; +import { useState } from "react"; import * as yup from "yup"; -import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; type PaymentOffer = { id: string, @@ -41,25 +44,11 @@ const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => { }, - }, - ]} - />, + cell: ({ row }) => , } ]; -export function PaymentOfferTable({ - offers, - toolbarRender, -}: { - offers: Record["offers"][string]>, - toolbarRender: () => React.ReactNode, -}) { +export function PaymentOfferTable({ offers }: { offers: Record["offers"][string]> }) { const data: PaymentOffer[] = Object.entries(offers).map(([id, offer]) => ({ id, ...offer, @@ -71,6 +60,53 @@ export function PaymentOfferTable({ defaultColumnFilters={[]} defaultSorting={[]} showDefaultToolbar={false} - toolbarRender={toolbarRender} />; } + +function ActionsCell({ offer }: { offer: PaymentOffer }) { + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + + return ( + <> + setIsEditOpen(true), + }, + '-', + { + item: "Delete", + onClick: () => setIsDeleteOpen(true), + danger: true, + }, + ]} + /> + + { + await project.updateConfig({ [`payments.offers.${offer.id}`]: null }); + toast({ title: "Offer deleted" }); + }, + }} + /> + + ); +} diff --git a/apps/dashboard/src/components/dialog-opener.tsx b/apps/dashboard/src/components/dialog-opener.tsx new file mode 100644 index 0000000000..c754972d92 --- /dev/null +++ b/apps/dashboard/src/components/dialog-opener.tsx @@ -0,0 +1,27 @@ +import React, { useState, ReactNode } from 'react'; +import { Button } from "@stackframe/stack-ui"; + +type DialogState = { + isOpen: boolean, + setIsOpen: (open: boolean) => void, +} + +type DialogOpenerProps = { + triggerLabel?: string, + children: (state: DialogState) => ReactNode, +} + +export function DialogOpener({ triggerLabel, children }: DialogOpenerProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + {triggerLabel && ( + + )} + {children({ isOpen, setIsOpen })} + + ); +}; diff --git a/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx b/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx index 8ada9a426e..54ac5fb9c9 100644 --- a/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx +++ b/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx @@ -15,6 +15,7 @@ export function DayIntervalSelectorField(props: { label: React.ReactNode, required?: boolean, includeNever?: boolean, + unsetLabel?: string, }) { const convertToDayInterval = (value: string): DayInterval | undefined => { @@ -40,13 +41,14 @@ export function DayIntervalSelectorField(props: {