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/schema.prisma b/apps/backend/prisma/schema.prisma index 3d8320aecf..c4030de444 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -729,6 +729,11 @@ enum SubscriptionStatus { unpaid } +enum SubscriptionCreationSource { + PURCHASE_PAGE + TEST_MODE +} + model Subscription { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid @@ -736,14 +741,15 @@ model Subscription { customerType CustomerType offer Json - 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]) 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..187b98b0f3 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -0,0 +1,66 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { 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(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async ({ auth, body }) => { + const { full_code, price_id } = body; + const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code); + if (auth.tenancy.id !== data.tenancyId) { + throw new StatusError(400, "Tenancy id does not match value from code data"); + } + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const pricesMap = new Map(Object.entries(data.offer.prices)); + const selectedPrice = pricesMap.get(price_id); + if (!selectedPrice) { + throw new StatusError(400, "Price not found on offer associated with this purchase code"); + } + if (!selectedPrice.interval) { + throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); + } + await prisma.subscription.create({ + data: { + tenancyId: auth.tenancy.id, + customerId: data.customerId, + customerType: typedToUppercase(data.offer.customerType), + status: "active", + offer: data.offer, + currentPeriodStart: new Date(), + currentPeriodEnd: addInterval(new Date(), selectedPrice.interval), + cancelAtPeriodEnd: false, + creationSource: "TEST_MODE", + }, + }); + await purchaseUrlVerificationCodeHandler.revokeCode({ + tenancy: auth.tenancy, + id: codeId, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); 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 c84ec6d29d..1cebf7d0ca 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 @@ -36,7 +36,7 @@ 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); - 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); } 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..5d5adfc407 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 @@ -4,6 +4,7 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getTenancy } from "@/lib/tenancies"; export const POST = createSmartRouteHandler({ metadata: { @@ -24,7 +25,11 @@ export const POST = createSmartRouteHandler({ }), async handler({ body }) { const { full_code, price_id } = body; - const { data } = await purchaseUrlVerificationCodeHandler.validateCode(full_code); + 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 +40,7 @@ export const POST = createSmartRouteHandler({ if (!selectedPrice.interval) { throw new StackAssertionError("unimplemented; prices without an interval are currently not supported"); } + const product = await stripe.products.create({ name: data.offer.displayName ?? "Subscription", }); @@ -59,6 +65,11 @@ export const POST = createSmartRouteHandler({ 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..bd2e94f302 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,9 +1,11 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { filterUndefined, typedFromEntries, getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies"; import * as yup from "yup"; +import { getTenancy } from "@/lib/tenancies"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; const offerDataSchema = inlineOfferSchema.omit(["server_only", "included_items"]); @@ -22,14 +24,19 @@ 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, 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 +50,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/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index e177eeb8a3..fde73420d8 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"); @@ -84,6 +84,7 @@ export async function syncStripeSubscriptions(stripeAccountId: string, stripeCus currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), currentPeriodStart: new Date(subscription.items.data[0].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/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx index 3de3375b2c..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 @@ -71,7 +71,6 @@ export default function PageClient() { return ( {!paymentsConfig.stripeAccountSetupComplete && ( 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..040da81743 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, Skeleton, Typography } from "@stackframe/stack-ui"; +import { ArrowRight } 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">, 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,12 +27,24 @@ export default function PageClient({ code }: { code: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPriceId, setSelectedPriceId] = useState(null); + const user = useUser({ projectIdMustMatch: "internal" }); + const [adminApp, setAdminApp] = useState(); + + 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 currentAmount = useMemo(() => { 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 shortenedInterval = (interval: [number, string]) => { @@ -76,6 +95,16 @@ export default function PageClient({ code }: { code: string }) { return result.client_secret; }; + const handleBypass = useCallback(async () => { + if (!adminApp || !selectedPriceId) { + return; + } + await adminApp.testModePurchase({ priceId: selectedPriceId, fullCode: code }); + 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]); return (
@@ -84,16 +113,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)} + + )}
@@ -120,7 +153,12 @@ export default function PageClient({ code }: { code: string }) { )} -
+
+ {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/lib/utils.tsx b/apps/dashboard/src/lib/utils.tsx index 943d02e6b2..a6c50b572a 100644 --- a/apps/dashboard/src/lib/utils.tsx +++ b/apps/dashboard/src/lib/utils.tsx @@ -1,4 +1,5 @@ import { getPublicEnvVar } from "@/lib/env"; +import { parseJson } from "@stackframe/stack-shared/dist/utils/json"; import { clsx, type ClassValue } from "clsx"; import { redirect } from "next/navigation"; import { twMerge } from "tailwind-merge"; @@ -20,7 +21,7 @@ export function devFeaturesEnabledForProject(projectId: string) { if (projectId === "internal") { return true; } - const allowedProjectIds = JSON.parse(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]"); + const allowedProjectIds = parseJson(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]"); if (allowedProjectIds.status !== "ok" || !Array.isArray(allowedProjectIds.data)) { return false; } diff --git a/apps/dashboard/tailwind.config.ts b/apps/dashboard/tailwind.config.ts index 3b732359b0..bd2a433edf 100644 --- a/apps/dashboard/tailwind.config.ts +++ b/apps/dashboard/tailwind.config.ts @@ -69,10 +69,15 @@ const config = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + fadeIn: { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "fade-in": "fadeIn 0.3s ease-in-out forwards", }, }, }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts index 1b43a1d610..06be7d9478 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts @@ -1,5 +1,5 @@ import { it } from "../../../../../helpers"; -import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; it("should error on invalid code", async ({ expect }) => { await Project.createAndSwitch(); @@ -135,3 +135,170 @@ it("should create purchase URL with inline offer, validate code, and create purc } `); }); + +it("should error when admin tenancy differs from code tenancy", async ({ expect }) => { + const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { + method: "POST", + accessType: "admin", + body: { + full_code: code, + price_id: "monthly", + }, + }); + + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": "Tenancy id does not match value from code data", + "headers": Headers {