diff --git a/apps/webapp/app/components/admin/ApplyCouponDialog.tsx b/apps/webapp/app/components/admin/ApplyCouponDialog.tsx new file mode 100644 index 00000000000..9e2ac8eddf6 --- /dev/null +++ b/apps/webapp/app/components/admin/ApplyCouponDialog.tsx @@ -0,0 +1,104 @@ +import { Form, useNavigation } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, +} from "~/components/primitives/Dialog"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; + +export type ApplyCouponTarget = { + orgId: string; + orgSlug: string; + orgTitle: string; + planCode: string | null; + subscriptionId: string | null; + stripeCustomerId: string; + stripeCustomerEmail: string; + dealKey: string; + dealLabel: string; + dealCategory: string; +}; + +type ApplyCouponDialogProps = { + target: ApplyCouponTarget | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function ApplyCouponDialog({ target, open, onOpenChange }: ApplyCouponDialogProps) { + const navigation = useNavigation(); + const isSubmitting = navigation.state !== "idle"; + + return ( + + + + {target + ? `Apply ${target.dealLabel} to ${target.orgTitle}?` + : "Apply coupon deal"} + + + {target && ( + <> + + Re-read the org and Stripe customer below before applying. The + coupon will be added to the org's active Stripe subscription. + + + + + Org + + {target.orgSlug} · {target.planCode ?? "Free"} + + + + Stripe customer + + {target.stripeCustomerId} ({target.stripeCustomerEmail}) + + + + Stripe sub + {target.subscriptionId ?? "—"} + + + Coupon + + {target.dealKey} · {target.dealCategory} + + + + +
+ + + + + + + + +
+ + )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.coupons.tsx b/apps/webapp/app/routes/admin.back-office.coupons.tsx new file mode 100644 index 00000000000..40deb0a5648 --- /dev/null +++ b/apps/webapp/app/routes/admin.back-office.coupons.tsx @@ -0,0 +1,394 @@ +import { Form } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useState } from "react"; +import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { + ApplyCouponDialog, + type ApplyCouponTarget, +} from "~/components/admin/ApplyCouponDialog"; +import { Button } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { + applyCouponDeal, + listCouponDeals, + refreshCouponDeals, + resolveCouponCustomer, +} from "~/services/platform.v3.server"; +import { requireUser } from "~/services/session.server"; + +type CouponDeal = { + key: string; + label: string; + category: string; + couponId: string; +}; + +type CouponMatch = { + orgId: string; + slug: string; + title: string; + planCode: string | null; + subscriptionId: string | null; + activeDealKey: string | null; + stripeCustomerId: string; + stripeCustomerEmail: string; + primaryUserEmail: string | null; +}; + +type LoaderData = { + email: string | null; + deals: CouponDeal[]; + matches: CouponMatch[] | null; + appliedDealKey: string | null; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const url = new URL(request.url); + const emailParam = url.searchParams.get("email"); + const email = emailParam && emailParam.trim().length > 0 ? emailParam.trim() : null; + const appliedDealKey = url.searchParams.get("applied"); + + const dealsResult = await listCouponDeals(); + + let matches: CouponMatch[] | null = null; + if (email) { + const resolveResult = await resolveCouponCustomer(email); + matches = resolveResult.matches as CouponMatch[]; + } + + const data: LoaderData = { + email, + deals: dealsResult.deals as CouponDeal[], + matches, + appliedDealKey, + }; + return typedjson(data); +} + +const ApplySchema = z.object({ + intent: z.literal("apply"), + orgId: z.string().min(1), + dealKey: z.string().min(1), +}); + +const RefreshSchema = z.object({ + intent: z.literal("refresh"), +}); + +type ActionResponse = + | { + error: string; + code?: string; + currentDealKey?: string | null; + } + | undefined; + +export async function action({ request }: ActionFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const payload = Object.fromEntries(await request.formData()); + + const refreshAttempt = RefreshSchema.safeParse(payload); + if (refreshAttempt.success) { + try { + await refreshCouponDeals(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to refresh deals."; + return typedjson({ error: message }, { status: 500 }); + } + + const url = new URL(request.url); + return redirect(`${url.pathname}${url.search}`); + } + + const applyAttempt = ApplySchema.safeParse(payload); + if (applyAttempt.success) { + const { orgId, dealKey } = applyAttempt.data; + + try { + const result = await applyCouponDeal({ orgId, dealKey }); + if (!result.success) { + // Cast to read `code` and `currentDealKey` from the wire body. The + // platform's generic ErrorSchema currently strips these to `error` + // only, so they arrive as undefined for now; the cast keeps the route + // forward-compatible with a future schema loosening that preserves + // them, at which point the precise UI messages will start rendering + // automatically. + const err = result as { + success: false; + error: string; + code?: string; + currentDealKey?: string; + }; + return typedjson( + { + error: err.error, + code: err.code, + currentDealKey: err.currentDealKey ?? null, + }, + { status: 400 } + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to apply coupon deal."; + return typedjson({ error: message }, { status: 500 }); + } + + const url = new URL(request.url); + const search = new URLSearchParams(url.search); + search.set("applied", dealKey); + return redirect(`${url.pathname}?${search.toString()}`); + } + + return typedjson( + { error: "Unrecognized form submission." }, + { status: 400 } + ); +} + +function groupDealsByCategory(deals: CouponDeal[]): Array<[string, CouponDeal[]]> { + const groups = new Map(); + for (const deal of deals) { + const existing = groups.get(deal.category); + if (existing) { + existing.push(deal); + } else { + groups.set(deal.category, [deal]); + } + } + return Array.from(groups.entries()); +} + +export default function CouponsPage() { + const { email, deals, matches, appliedDealKey } = + useTypedLoaderData(); + const actionData = useTypedActionData(); + + const dealsByKey = new Map(deals.map((d) => [d.key, d])); + const dealGroups = groupDealsByCategory(deals); + + const [dialogTarget, setDialogTarget] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const openDialog = (target: ApplyCouponTarget) => { + setDialogTarget(target); + setDialogOpen(true); + }; + + // Close the dialog after a successful apply: the action redirects with + // ?applied=, the loader echoes that as appliedDealKey, and we + // dismiss the modal so the success banner underneath is visible. + useEffect(() => { + if (appliedDealKey) setDialogOpen(false); + }, [appliedDealKey]); + + const appliedDeal = appliedDealKey ? dealsByKey.get(appliedDealKey) : null; + + return ( +
+
+ Coupon Deals + + Apply a Stripe-tagged coupon to a customer's subscription. Lookup is + by Stripe customer email — often different from the user's + Trigger.dev email. Catalog is built from coupons in Stripe whose + metadata carries{" "} + trigger_deal_key. + +
+ + {appliedDeal && ( +
+ + Applied: {appliedDeal.label}. + +
+ )} + + {actionData && "error" in actionData && actionData.error && ( +
+ + {actionData.error} + {actionData.code === "already_applied" && actionData.currentDealKey + ? ` (currently has: ${ + dealsByKey.get(actionData.currentDealKey)?.label ?? + actionData.currentDealKey + })` + : null} + +
+ )} + +
+ +
+
+ +
+ +
+ + This is the email on the Stripe customer record, not necessarily the + user's Trigger.dev email. + +
+ + {matches === null ? ( + + Enter a Stripe customer email above to find matching Trigger.dev orgs. + + ) : matches.length === 0 ? ( + No Trigger.dev orgs found for this Stripe email. + ) : ( + <> + {matches.length > 1 && ( +
+ + Multiple Stripe customers share this email. Verify the org by + Stripe customer ID before applying. + +
+ )} + +
+ {matches.length} match{matches.length === 1 ? "" : "es"} + + + + Org + Stripe customer + Trigger.dev user + Plan + Active deal + Apply + + + + {matches.map((match) => { + const activeDeal = match.activeDealKey + ? dealsByKey.get(match.activeDealKey) + : null; + const isFree = match.subscriptionId === null; + const hasActive = match.activeDealKey !== null; + const disabledReason = isFree + ? "Org is on free plan — no subscription to discount." + : hasActive + ? `Already has: ${activeDeal?.label ?? match.activeDealKey}` + : null; + + return ( + + +
+ {match.title} + + + +
+
+ +
+ + + {match.stripeCustomerEmail} + +
+
+ {match.primaryUserEmail ?? "—"} + {match.planCode ?? "Free"} + {activeDeal?.label ?? "None"} + +
+ {dealGroups.map(([category, dealsInCategory]) => ( +
+ + {category} + +
+ {dealsInCategory.map((deal) => { + const button = ( + + ); + return disabledReason ? ( + + ) : ( + {button} + ); + })} +
+
+ ))} +
+
+
+ ); + })} +
+
+
+ + )} + + +
+ ); +} diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx index 026fc13fdc5..a1629e01579 100644 --- a/apps/webapp/app/routes/admin.back-office.tsx +++ b/apps/webapp/app/routes/admin.back-office.tsx @@ -1,6 +1,7 @@ import { Outlet } from "@remix-run/react"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect, typedjson } from "remix-typedjson"; +import { Tabs } from "~/components/primitives/Tabs"; import { requireUser } from "~/services/session.server"; export async function loader({ request }: LoaderFunctionArgs) { @@ -17,6 +18,17 @@ export default function BackOfficeLayout() { aria-labelledby="primary-heading" className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last" > +
+ +
); diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 51075c1b87d..d91de7b1a10 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -17,6 +17,10 @@ import { type UsageResult, type UsageSeriesParams, type CurrentPlan, + type ApplyCouponDealResult, + type CouponDiagnosticsResponse, + type ListCouponDealsResponse, + type ResolveCouponCustomerResponse, } from "@trigger.dev/platform"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { createLRUMemoryStore } from "@internal/cache"; @@ -778,6 +782,103 @@ export async function triggerInitialDeployment( } } +export async function listCouponDeals(): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.listCouponDeals()); + + if (error) { + logger.error("Error listing coupon deals", { error }); + throw error; + } + + if (!result.success) { + logger.error("Error listing coupon deals - no success", { error: result.error }); + throw new Error(result.error ?? "Failed to list coupon deals"); + } + + return result; +} + +export async function refreshCouponDeals(): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.refreshCouponDeals()); + + if (error) { + logger.error("Error refreshing coupon deals", { error }); + throw error; + } + + if (!result.success) { + logger.error("Error refreshing coupon deals - no success", { error: result.error }); + throw new Error(result.error ?? "Failed to refresh coupon deals"); + } + + return result; +} + +export async function resolveCouponCustomer( + stripeEmail: string +): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.resolveCouponCustomer(stripeEmail)); + + if (error) { + logger.error("Error resolving coupon customer", { error }); + throw error; + } + + if (!result.success) { + logger.error("Error resolving coupon customer - no success", { error: result.error }); + throw new Error(result.error ?? "Failed to resolve coupon customer"); + } + + return result; +} + +// Returns the full discriminated result rather than throwing on !success so the +// admin route can branch on `code` ("already_applied", "no_subscription", +// "unknown_deal", etc.) and surface precise UI messages. +export async function applyCouponDeal(input: { + orgId: string; + dealKey: string; +}): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.applyCouponDeal(input)); + + if (error) { + logger.error("Error applying coupon deal", { input, error }); + throw error; + } + + if (!result.success) { + logger.warn("Coupon deal apply unsuccessful", { input, error: result.error }); + } + + return result; +} + +export async function getCouponDiagnostics(): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.getCouponDiagnostics()); + + if (error) { + logger.error("Error getting coupon diagnostics", { error }); + throw error; + } + + if (!result.success) { + logger.error("Error getting coupon diagnostics - no success", { error: result.error }); + throw new Error(result.error ?? "Failed to get coupon diagnostics"); + } + + return result; +} + function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev",