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/payments.tsx b/apps/backend/src/lib/payments.tsx index 993a50a6f3..570685c779 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -49,7 +49,7 @@ 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", }])), }; 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/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..5e134671d4 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx @@ -0,0 +1,35 @@ +"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 ed97f06ee1..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,17 +1,7 @@ "use client"; -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 { - 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 { Button, @@ -20,8 +10,8 @@ import { Typography, toast } from "@stackframe/stack-ui"; +import { ConnectPayments } from "@stripe/react-connect-js"; import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; -import { useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -33,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; @@ -84,31 +71,17 @@ export default function PageClient() { return ( {!paymentsConfig.stripeAccountSetupComplete && ( )} } > - } - /> - } - /> - - +
+
+ +
+
); } @@ -152,126 +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", "custom"]).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", "custom"]).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..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/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx index 5356fccd58..06715b8a65 100644 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -1,14 +1,15 @@ '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"; -import { SelectField } from "../form-fields"; type PaymentItem = { id: string, @@ -59,13 +60,7 @@ const columns: ColumnDef[] = [ } ]; -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, @@ -77,24 +72,32 @@ export function PaymentItemTable({ defaultColumnFilters={[]} defaultSorting={[]} showDefaultToolbar={false} - toolbarRender={toolbarRender} />; } 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, }, ]} /> @@ -104,6 +107,41 @@ function ActionsCell({ item }: { item: PaymentItem }) { itemId={item.id} customerType={item.customerType} /> + + { + 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" }); + } + }} + /> ); } 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: {