From 2b19be0ab58a2ed5b430040795b9c64058b3ee19 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 Aug 2025 17:11:09 -0700 Subject: [PATCH 1/5] fix copy --- .../(protected)/projects/[projectId]/payments/page-client.tsx | 1 - 1 file changed, 1 deletion(-) 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 && ( From 03c06a2e68a61283628fcf259969dd6cdb1a7883 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 21 Aug 2025 11:48:31 -0700 Subject: [PATCH 2/5] payments test mode --- .../migration.sql | 12 +++ apps/backend/prisma/schema.prisma | 12 ++- .../test-mode-purchase-session/route.tsx | 63 ++++++++++++ .../payments/purchases/validate-code/route.ts | 12 ++- apps/backend/src/lib/stripe.tsx | 3 +- .../(main)/purchase/[code]/page-client.tsx | 73 ++++++++++++-- .../(main)/purchase/return/page-client.tsx | 11 ++- .../src/app/(main)/purchase/return/page.tsx | 2 + apps/dashboard/src/lib/utils.tsx | 3 +- apps/dashboard/tailwind.config.ts | 5 + .../api/v1/payments/purchase-session.test.ts | 96 ++++++++++++++++++- packages/stack-shared/src/config/schema.ts | 2 +- .../src/interface/admin-interface.ts | 12 +++ packages/stack-shared/src/schema-fields.ts | 2 +- .../apps/implementations/admin-app-impl.ts | 4 + .../stack-app/apps/interfaces/admin-app.ts | 1 + 16 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250821175509_test_mode_subscriptions/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx 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..1ae058f334 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -0,0 +1,63 @@ +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"; +import { KnownErrors } from "@stackframe/stack-shared"; + +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, 500]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async ({ auth, body }) => { + const { full_code, price_id } = body; + const { data } = 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", + }, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); 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)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 778ca123f0..50e6d12e47 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 (
@@ -90,10 +119,10 @@ export default function PageClient({ code }: { code: string }) { ) : ( <>
- {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 +151,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..a606fa2b3e 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,97 @@ 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 {