diff --git a/apps/backend/prisma/migrations/20250911230246_one_time_purchase/migration.sql b/apps/backend/prisma/migrations/20250911230246_one_time_purchase/migration.sql new file mode 100644 index 0000000000..001ecb0832 --- /dev/null +++ b/apps/backend/prisma/migrations/20250911230246_one_time_purchase/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +ALTER TYPE "SubscriptionCreationSource" RENAME TO "PurchaseCreationSource"; + +-- CreateTable +CREATE TABLE "OneTimePurchase" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "customerId" TEXT NOT NULL, + "customerType" "CustomerType" NOT NULL, + "offerId" TEXT, + "offer" JSONB NOT NULL, + "quantity" INTEGER NOT NULL, + "stripePaymentIntentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "creationSource" "PurchaseCreationSource" NOT NULL, + + CONSTRAINT "OneTimePurchase_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OneTimePurchase_tenancyId_stripePaymentIntentId_key" ON "OneTimePurchase"("tenancyId", "stripePaymentIntentId"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 6839dd6a30..085e0cd0c4 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -753,7 +753,7 @@ enum SubscriptionStatus { unpaid } -enum SubscriptionCreationSource { +enum PurchaseCreationSource { PURCHASE_PAGE TEST_MODE } @@ -773,7 +773,7 @@ model Subscription { currentPeriodStart DateTime cancelAtPeriodEnd Boolean - creationSource SubscriptionCreationSource + creationSource PurchaseCreationSource createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -795,6 +795,22 @@ model ItemQuantityChange { @@index([tenancyId, customerId, expiresAt]) } +model OneTimePurchase { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + customerId String + customerType CustomerType + offerId String? + offer Json + quantity Int + stripePaymentIntentId String? + createdAt DateTime @default(now()) + creationSource PurchaseCreationSource + + @@id([tenancyId, id]) + @@unique([tenancyId, stripePaymentIntentId]) +} + model DataVaultEntry { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid 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 6264fab937..9c425662c9 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,8 +1,12 @@ -import { getStackStripe, syncStripeSubscriptions } from "@/lib/stripe"; +import { getStackStripe, getStripeForAccount, syncStripeSubscriptions } from "@/lib/stripe"; +import { getTenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { typedIncludes } from '@stackframe/stack-shared/dist/utils/arrays'; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; import Stripe from "stripe"; const subscriptionChangedEvents = [ @@ -30,6 +34,74 @@ const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event return subscriptionChangedEvents.includes(event.type as any); }; +async function processStripeWebhookEvent(event: Stripe.Event): Promise { + const mockData = (event.data.object as any).stack_stripe_mock_data; + if (event.type === "payment_intent.succeeded" && event.data.object.metadata.purchaseKind === "ONE_TIME") { + const metadata = event.data.object.metadata; + const accountId = event.account; + if (!accountId) { + throw new StackAssertionError("Stripe webhook account id missing", { event }); + } + console.log("Processing1", mockData); + const stripe = getStackStripe(mockData); + const account = await stripe.accounts.retrieve(accountId); + const tenancyId = account.metadata?.tenancyId; + if (!tenancyId) { + throw new StackAssertionError("Stripe account metadata missing tenancyId", { event }); + } + const tenancy = await getTenancy(tenancyId); + if (!tenancy) { + throw new StackAssertionError("Tenancy not found", { event }); + } + const prisma = await getPrismaClientForTenancy(tenancy); + const offer = JSON.parse(metadata.offer || "{}"); + const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); + const stripePaymentIntentId = event.data.object.id; + if (!metadata.customerId || !metadata.customerType) { + throw new StackAssertionError("Missing customer metadata for one-time purchase", { event }); + } + if (!typedIncludes(["user", "team", "custom"] as const, metadata.customerType)) { + throw new StackAssertionError("Invalid customer type for one-time purchase", { event }); + } + await prisma.oneTimePurchase.upsert({ + where: { + tenancyId_stripePaymentIntentId: { + tenancyId: tenancy.id, + stripePaymentIntentId, + }, + }, + create: { + tenancyId: tenancy.id, + customerId: metadata.customerId, + customerType: typedToUppercase(metadata.customerType), + offerId: metadata.offerId || null, + stripePaymentIntentId, + offer, + quantity: qty, + creationSource: "PURCHASE_PAGE", + }, + update: { + offerId: metadata.offerId || null, + offer, + quantity: qty, + } + }); + } + + if (isSubscriptionChangedEvent(event)) { + const accountId = event.account; + const customerId = event.data.object.customer; + if (!accountId) { + throw new StackAssertionError("Stripe webhook account id missing", { event }); + } + if (typeof customerId !== 'string') { + throw new StackAssertionError("Stripe webhook bad customer id", { event }); + } + const stripe = await getStripeForAccount({ accountId }, mockData); + await syncStripeSubscriptions(stripe, accountId, customerId); + } +} + export const POST = createSmartRouteHandler({ metadata: { hidden: true, @@ -47,38 +119,27 @@ export const POST = createSmartRouteHandler({ body: yupMixed().defined(), }), handler: async (req, fullReq) => { + const stripe = getStackStripe(); + let event: Stripe.Event; try { - const stripe = getStackStripe(); const signature = req.headers["stripe-signature"][0]; - if (!signature) { - throw new StackAssertionError("Missing stripe-signature header"); - } - const textBody = new TextDecoder().decode(fullReq.bodyBuffer); - const event = stripe.webhooks.constructEvent( + event = stripe.webhooks.constructEvent( textBody, signature, getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET"), ); + } catch { + throw new StatusError(400, "Invalid stripe-signature header"); + } - if (event.type === "account.updated") { - if (!event.account) { - throw new StackAssertionError("Stripe webhook account id missing", { event }); - } - } else if (isSubscriptionChangedEvent(event)) { - const accountId = event.account; - const customerId = (event.data.object as any).customer; - if (!accountId) { - throw new StackAssertionError("Stripe webhook account id missing", { event }); - } - if (typeof customerId !== 'string') { - throw new StackAssertionError("Stripe webhook bad customer id", { event }); - } - await syncStripeSubscriptions(accountId, customerId); - } + try { + await processStripeWebhookEvent(event); } catch (error) { captureError("stripe-webhook-receiver", error); + throw error; } + return { statusCode: 200, bodyType: "json", 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 276e9d7de0..f0dde7f07f 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,13 +1,12 @@ import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; -import { isActiveSubscription, validatePurchaseSession } from "@/lib/payments"; +import { validatePurchaseSession } from "@/lib/payments"; import { getStripeForAccount } from "@/lib/stripe"; -import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; +import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { SubscriptionCreationSource, SubscriptionStatus } from "@prisma/client"; +import { SubscriptionStatus } from "@prisma/client"; import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; 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 { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; export const POST = createSmartRouteHandler({ @@ -37,65 +36,50 @@ export const POST = createSmartRouteHandler({ throw new StatusError(400, "Tenancy id does not match value from code data"); } const prisma = await getPrismaClientForTenancy(auth.tenancy); - const { selectedPrice, groupId, subscriptions } = await validatePurchaseSession({ + + const { selectedPrice, conflictingGroupSubscriptions } = 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: { + if (!selectedPrice) { + throw new StackAssertionError("Price not resolved for test mode purchase session"); + } + + if (!selectedPrice.interval) { + await prisma.oneTimePurchase.create({ + data: { + tenancyId: auth.tenancy.id, + customerId: data.customerId, + customerType: typedToUppercase(data.offer.customerType), + offerId: data.offerId, + offer: data.offer, + quantity, + creationSource: "TEST_MODE", + }, + }); + } else { + // Cancel conflicting subscriptions for TEST_MODE as well, then create new TEST_MODE subscription + if (conflictingGroupSubscriptions.length > 0) { + const conflicting = conflictingGroupSubscriptions[0]; + if (conflicting.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId); + } else if (conflicting.id) { + await prisma.subscription.update({ + where: { + tenancyId_id: { 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, + id: conflicting.id, }, - }); + }, + data: { status: SubscriptionStatus.canceled }, }); } } - } - if (selectedPrice?.interval) { await prisma.subscription.create({ data: { tenancyId: auth.tenancy.id, @@ -106,9 +90,9 @@ export const POST = createSmartRouteHandler({ offer: data.offer, quantity, currentPeriodStart: new Date(), - currentPeriodEnd: addInterval(new Date(), selectedPrice.interval), + currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!), cancelAtPeriodEnd: false, - creationSource: SubscriptionCreationSource.TEST_MODE, + creationSource: "TEST_MODE", }, }); } 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 66036fff27..df770396f4 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 @@ -5,7 +5,7 @@ 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 { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; export const POST = createSmartRouteHandler({ @@ -34,10 +34,6 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("No tenancy found from purchase code data tenancy id. This should never happen."); } 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, @@ -46,45 +42,49 @@ export const POST = createSmartRouteHandler({ priceId: price_id, quantity, }); - if (!selectedPrice) { throw new StackAssertionError("Price not resolved for purchase session"); } - 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], + if (selectedPrice.interval) { + 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), }, - quantity, - }], - metadata: { - offerId: data.offerId ?? null, - offer: JSON.stringify(data.offer), - }, - }); - clientSecret = getClientSecretFromStripeSubscription(updated); + }); + const clientSecretUpdated = getClientSecretFromStripeSubscription(updated); + await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId }); + if (typeof clientSecretUpdated !== "string") { + throwErr(500, "No client secret returned from Stripe for subscription"); + } + return { statusCode: 200, bodyType: "json", body: { client_secret: clientSecretUpdated } }; + } else { + await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId); + } } else if (conflicting.id) { - // Cancel DB-only subscription and create a new Stripe subscription as normal await prisma.subscription.update({ where: { tenancyId_id: { @@ -98,44 +98,66 @@ export const POST = createSmartRouteHandler({ }); } } - - if (!clientSecret) { - const product = await stripe.products.create({ - name: data.offer.displayName ?? "Subscription", - }); - const created = await stripe.subscriptions.create({ + // One-time payment path after conflicts handled + if (!selectedPrice.interval) { + const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity); + const paymentIntent = await stripe.paymentIntents.create({ + amount: amountCents, + currency: "usd", 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, - }], + automatic_payment_methods: { enabled: true }, metadata: { - offerId: data.offerId ?? null, + offerId: data.offerId || "", offer: JSON.stringify(data.offer), + customerId: data.customerId, + customerType: data.offer.customerType, + purchaseQuantity: String(quantity), + purchaseKind: "ONE_TIME", + tenancyId: data.tenancyId, }, }); - clientSecret = getClientSecretFromStripeSubscription(created); + const clientSecret = paymentIntent.client_secret; + if (typeof clientSecret !== "string") { + throwErr(500, "No client secret returned from Stripe for payment intent"); + } + await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId }); + return { statusCode: 200, bodyType: "json", body: { client_secret: clientSecret } }; } - await purchaseUrlVerificationCodeHandler.revokeCode({ - tenancy, - id: codeId, - }); - // stripe-mock returns an empty string here + 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), + }, + }); + const clientSecret = getClientSecretFromStripeSubscription(created); if (typeof clientSecret !== "string") { throwErr(500, "No client secret returned from Stripe for subscription"); } + + await purchaseUrlVerificationCodeHandler.revokeCode({ + tenancy, + id: codeId, + }); return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 2af7027ba7..7db9cb30aa 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,6 +1,6 @@ import type { PrismaClientTransaction } from '@/prisma-client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getItemQuantityForCustomer } from './payments'; +import { getItemQuantityForCustomer, validatePurchaseSession } from './payments'; import type { Tenancy } from './tenancies'; function createMockPrisma(overrides: Partial = {}): PrismaClientTransaction { @@ -12,6 +12,9 @@ function createMockPrisma(overrides: Partial = {}): Pri findMany: async () => [], findFirst: async () => null, }, + oneTimePurchase: { + findMany: async () => [], + }, projectUser: { findUnique: async () => null, }, @@ -717,3 +720,216 @@ describe('getItemQuantityForCustomer - subscriptions', () => { }); +describe('getItemQuantityForCustomer - one-time purchases', () => { + it('adds included item quantity multiplied by purchase quantity', async () => { + const itemId = 'otpItemA'; + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'I', customerType: 'custom' } }, + offers: {}, + groups: {}, + }); + + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [{ + offerId: 'off-otp', + offer: { includedItems: { [itemId]: { quantity: 5 } } }, + quantity: 2, + createdAt: new Date('2025-02-10T00:00:00.000Z'), + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'custom-1', + customerType: 'custom', + }); + expect(qty).toBe(10); + }); + + it('aggregates multiple one-time purchases across different offers', async () => { + const itemId = 'otpItemB'; + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'I', customerType: 'custom' } }, + offers: {}, + groups: {}, + }); + + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [ + { offerId: 'off-1', offer: { includedItems: { [itemId]: { quantity: 3 } } }, quantity: 1, createdAt: new Date('2025-02-10T00:00:00.000Z') }, + { offerId: 'off-2', offer: { includedItems: { [itemId]: { quantity: 5 } } }, quantity: 2, createdAt: new Date('2025-02-11T00:00:00.000Z') }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ + prisma, + tenancy, + itemId, + customerId: 'custom-1', + customerType: 'custom', + }); + expect(qty).toBe(13); + }); +}); + + +describe('validatePurchaseSession - one-time purchase rules', () => { + it('blocks duplicate one-time purchase for same offerId', async () => { + const tenancy = createMockTenancy({ items: {}, offers: {}, groups: {} }); + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [{ offerId: 'offer-dup', offer: { groupId: undefined }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + }, + subscription: { findMany: async () => [] }, + } as any); + + await expect(validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-dup', + offer: { + displayName: 'X', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 1, + })).rejects.toThrowError('Customer already has a one-time purchase for this offer'); + }); + + it('blocks one-time purchase when another one exists in the same group', async () => { + const tenancy = createMockTenancy({ items: {}, offers: {}, groups: { g1: { displayName: 'G1' } } }); + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [{ offerId: 'other-offer', offer: { groupId: 'g1' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + }, + subscription: { findMany: async () => [] }, + } as any); + + await expect(validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-y', + offer: { + displayName: 'Y', + groupId: 'g1', + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 1, + })).rejects.toThrowError('Customer already has a one-time purchase in this offer group'); + }); + + it('allows purchase when existing one-time is in a different group', async () => { + const tenancy = createMockTenancy({ items: {}, offers: {}, groups: { g1: { displayName: 'G1' }, g2: { displayName: 'G2' } } }); + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [{ offerId: 'other-offer', offer: { groupId: 'g2' }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }], + }, + subscription: { findMany: async () => [] }, + } as any); + + const res = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-z', + offer: { + displayName: 'Z', + groupId: 'g1', + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 1, + }); + expect(res.groupId).toBe('g1'); + expect(res.conflictingGroupSubscriptions.length).toBe(0); + }); +}); + +describe('combined sources - one-time purchases + manual changes + subscriptions', () => { + it('computes correct balance with all sources', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-02-15T00:00:00.000Z')); + + const itemId = 'comboItem'; + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'Combo', customerType: 'user' } }, + groups: { g1: { displayName: 'G' } }, + offers: { + offSub: { + displayName: 'Sub', 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({ + itemQuantityChange: { + findMany: async () => [ + { quantity: 3, createdAt: new Date('2025-02-10T00:00:00.000Z'), expiresAt: null }, + { quantity: -1, createdAt: new Date('2025-02-12T00:00:00.000Z'), expiresAt: null }, + ], + findFirst: async () => null, + }, + oneTimePurchase: { + findMany: async () => [ + { offerId: 'offA', offer: { includedItems: { [itemId]: { quantity: 4 } } }, quantity: 1, createdAt: new Date('2025-02-09T00:00:00.000Z') }, + { offerId: 'offB', offer: { includedItems: { [itemId]: { quantity: 2 } } }, quantity: 3, createdAt: new Date('2025-02-11T00:00:00.000Z') }, + ], + }, + subscription: { + findMany: async () => [{ + offerId: 'offSub', + 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: 'user-1', customerType: 'user' }); + // OTP: 4 + (2*3)=6 => 10; Manual: +3 -1 => +2; Subscription: 5 * 2 => 10; Total => 22 + expect(qty).toBe(22); + vi.useRealTimers(); + }); +}); + diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index bd153e583b..c9f4b3a5f5 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -161,6 +161,25 @@ export async function getItemQuantityForCustomer(options: { expirationTime: c.expiresAt ?? FAR_FUTURE_DATE, }); } + const oneTimePurchases = await options.prisma.oneTimePurchase.findMany({ + where: { + tenancyId: options.tenancy.id, + customerId: options.customerId, + customerType: typedToUppercase(options.customerType), + }, + }); + for (const p of oneTimePurchases) { + const offer = p.offer as yup.InferType; + const inc = getOrUndefined(offer.includedItems, options.itemId); + if (!inc) continue; + const baseQty = inc.quantity * p.quantity; + if (baseQty <= 0) continue; + transactions.push({ + amount: baseQty, + grantTime: p.createdAt, + expirationTime: FAR_FUTURE_DATE, + }); + } // Subscriptions → ledger entries const subscriptions = await getSubscriptions({ @@ -361,46 +380,76 @@ export async function validatePurchaseSession(options: { conflictingGroupSubscriptions: Subscription[], }> { const { prisma, tenancy, codeData, priceId, quantity } = options; - const offer = codeData.offer; + await ensureCustomerExists({ + prisma, + tenancyId: tenancy.id, + customerType: offer.customerType, + customerId: codeData.customerId, + }); + 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; + selectedPrice = pricesMap.get(priceId); 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"); } + // Block based on prior one-time purchases for same customer and customerType + const existingOneTimePurchases = await prisma.oneTimePurchase.findMany({ + where: { + tenancyId: tenancy.id, + customerId: codeData.customerId, + customerType: typedToUppercase(offer.customerType), + }, + }); + + if (codeData.offerId && existingOneTimePurchases.some((p) => p.offerId === codeData.offerId)) { + throw new StatusError(400, "Customer already has a one-time purchase for this offer"); + } + 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 addOnOfferIds = offer.isAddOnTo ? typedKeys(offer.isAddOnTo) : []; + if (offer.isAddOnTo && !subscriptions.some((s) => s.offerId && addOnOfferIds.includes(s.offerId))) { + throw new StatusError(400, "This offer is an add-on to an offer that the customer does not have"); + } const groups = tenancy.config.payments.groups; const groupId = typedKeys(groups).find((g) => offer.groupId === g); + // Block purchasing any offer in the same group if a one-time purchase exists in that group + if (groupId) { + const hasOneTimeInGroup = existingOneTimePurchases.some((p) => { + const offer = p.offer as yup.InferType; + return offer.groupId === groupId; + }); + if (hasOneTimeInGroup) { + throw new StatusError(400, "Customer already has a one-time purchase in this offer group"); + } + } + let conflictingGroupSubscriptions: Subscription[] = []; - if (groupId && selectedPrice?.interval) { + if (groupId) { 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)) + (!offer.isAddOnTo || !addOnOfferIds.includes(subscription.offerId)) )); } diff --git a/apps/backend/src/lib/stripe-proxy.tsx b/apps/backend/src/lib/stripe-proxy.tsx new file mode 100644 index 0000000000..046530095d --- /dev/null +++ b/apps/backend/src/lib/stripe-proxy.tsx @@ -0,0 +1,52 @@ +import Stripe from "stripe"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +export type StripeOverridesMap = Record>; + +export function createStripeProxy( + target: T, + overrides: StripeOverridesMap = {}, + path: string[] = [] +): T { + return new Proxy(target, { + get(currTarget, prop, receiver) { + if (typeof prop === "symbol") { + return Reflect.get(currTarget, prop, receiver); + } + + const value = Reflect.get(currTarget, prop, receiver); + + if (typeof value === "function") { + return (...args: any[]) => { + const result = value.apply(currTarget, args); + const key = [...path, String(prop)].join("."); + + if (result && typeof (result as any).then === "function") { + return (result as Promise).then((resolved) => + applyOverrideForKey(key, resolved, overrides) + ); + } + + // sync method + return applyOverrideForKey(key, result, overrides); + }; + } + + // Recurse into sub-objects + if (typeof value === "object") { + return createStripeProxy(value as object, overrides, [...path, String(prop)]); + } + + return value; + }, + }) as T; +} + +function applyOverrideForKey(key: string, result: any, overrides: StripeOverridesMap) { + const override = getOrUndefined(overrides, key); + if (!override || !result || typeof result !== "object") return result; + + return { + ...result, + ...override, + }; +} diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 506a4a4ac8..c1bcbbdd9a 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,8 +1,10 @@ import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { CustomerType } from "@prisma/client"; +import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; import Stripe from "stripe"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY"); @@ -13,9 +15,17 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { port: 8123, } : {}; -export const getStackStripe = () => new Stripe(stripeSecretKey, stripeConfig); +export const getStackStripe = (overrides?: StripeOverridesMap) => { + if (overrides && !useStripeMock) { + throw new StackAssertionError("Stripe overrides are not supported in production"); + } + return createStripeProxy(new Stripe(stripeSecretKey, stripeConfig), overrides); +}; -export const getStripeForAccount = async (options: { tenancy?: Tenancy, accountId?: string }) => { +export const getStripeForAccount = async (options: { tenancy?: Tenancy, accountId?: string }, overrides?: StripeOverridesMap) => { + if (overrides && !useStripeMock) { + throw new StackAssertionError("Stripe overrides are not supported in production"); + } if (!options.tenancy && !options.accountId) { throwErr(400, "Either tenancy or stripeAccountId must be provided"); } @@ -33,11 +43,10 @@ export const getStripeForAccount = async (options: { tenancy?: Tenancy, accountI 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."); } - return new Stripe(stripeSecretKey, { stripeAccount: accountId, ...stripeConfig }); + return createStripeProxy(new Stripe(stripeSecretKey, { stripeAccount: accountId, ...stripeConfig }), overrides); }; -export async function syncStripeSubscriptions(stripeAccountId: string, stripeCustomerId: string) { - const stripe = await getStripeForAccount({ accountId: stripeAccountId }); +export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: string, stripeCustomerId: string) { const account = await stripe.accounts.retrieve(stripeAccountId); if (!account.metadata?.tenancyId) { throwErr(500, "Stripe account metadata missing tenancyId"); @@ -51,7 +60,7 @@ export async function syncStripeSubscriptions(stripeAccountId: string, stripeCus if (!customerId || !customerType) { throw new StackAssertionError("Stripe customer metadata missing customerId or customerType"); } - if (customerType !== CustomerType.USER && customerType !== CustomerType.TEAM) { + if (!typedIncludes(Object.values(CustomerType), customerType)) { throw new StackAssertionError("Stripe customer metadata has invalid customerType"); } const tenancy = await getTenancy(account.metadata.tenancyId); 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 3e3a457cc6..5100d0c2b5 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -73,6 +73,12 @@ export default function PageClient({ code }: { code: string }) { return rawAmountCents; }, [unitCents, rawAmountCents, isTooLarge, MAX_STRIPE_AMOUNT_CENTS]); + const elementsMode = useMemo<"subscription" | "payment">(() => { + if (!selectedPriceId || !data?.offer?.prices) return "subscription"; + const price = data.offer.prices[selectedPriceId]; + return price.interval ? "subscription" : "payment"; + }, [data, selectedPriceId]); + const shortenedInterval = (interval: [number, string]) => { if (interval[0] === 1) { return interval[1]; @@ -269,6 +275,7 @@ export default function PageClient({ code }: { code: string }) { { `); }); +it("should return client secret for one-time price (no interval)", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + offers: { + "ot-offer": { + displayName: "One Time Offer", + customerType: "user", + serverOnly: false, + stackable: true, + prices: { + one: { + USD: "1500", + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await User.create(); + const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + offer_id: "ot-offer", + }, + }); + expect(urlRes.status).toBe(200); + const code = (urlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + + const res = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", { + method: "POST", + accessType: "client", + body: { + full_code: code, + price_id: "one", + quantity: 2, + }, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ client_secret: expect.any(String) }); +}); + +it("should error on one-time price quantity > 1 when offer is not stackable", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + offers: { + "ot-non-stack": { + displayName: "One Time Non-Stackable", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + one: { USD: "1200" }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await User.create(); + const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + offer_id: "ot-non-stack", + }, + }); + expect(urlRes.status).toBe(200); + const code = (urlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + + const res = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", { + method: "POST", + accessType: "client", + body: { + full_code: code, + price_id: "one", + quantity: 2, + }, + }); + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": "This offer is not stackable; quantity must be 1", + "headers": Headers {