Send billing to the hosted payment pages

The Billing settings page opens billing.hanzo.ai to manage a subscription and
pay.hanzo.ai for the plan catalog, each in a new tab. Both URLs live in
src/constants/billing.ts and read NEXT_PUBLIC_BILLING_URL / NEXT_PUBLIC_PAY_URL
when a deployment needs different hosts.

The hosted surface owns payment state, so the app stores no products, prices,
subscriptions or customers and serves no payment webhook.
This commit is contained in:
zooqueen
2026-08-14 20:21:15 -07:00
parent a9371a6955
commit 70d599218c
28 changed files with 178 additions and 1353 deletions
-1
View File
@@ -41,6 +41,5 @@ vscode:
- dbaeumer.vscode-eslint
- bradlc.vscode-tailwindcss
- ban.spellright
- stripe.vscode-stripe
- Prisma.prisma
- ms-dotnettools.vscode-dotnet-runtime
+4 -5
View File
@@ -11,7 +11,7 @@ Captable, Inc. is an open-source cap table management platform designed as an al
- **Styling**: Tailwind CSS with shadcn/ui components
- **Email**: React Email with Nodemailer
- **File Storage**: AWS S3 compatible (Minio for local dev)
- **Payments**: Stripe integration
- **Payments**: hosted on billing.hanzo.ai / pay.hanzo.ai — the app only links out
- **PDF**: PDF-lib for generation, React-PDF for viewing
- **Queue**: pg-boss for job processing
- **Monitoring**: Sentry for error tracking
@@ -52,7 +52,7 @@ src/
│ ├── (authenticated)/ # Protected routes (dashboard, etc.)
│ ├── (unauthenticated)/ # Public routes (login, signup, etc.)
│ ├── (documents)/ # Document-related pages
│ └── api/ # API routes (auth, stripe, trpc)
│ └── api/ # API routes (auth, trpc)
├── components/ # React components organized by feature
├── server/ # Server-side utilities and services
├── trpc/ # tRPC setup and routers
@@ -92,7 +92,6 @@ src/
- **Audit**: Activity log entries
- **Update**: Investor updates
- **BankAccount**: Company bank accounts
- **Billing**: Stripe integration entities
## Key API Routes (tRPC)
@@ -124,7 +123,6 @@ src/
### Other
- `audit`: Audit log access
- `billing`: Stripe subscription management
- `bucket`: File storage operations
- `common`: Shared utilities
@@ -163,7 +161,8 @@ Key environment variables:
- `NEXTAUTH_URL/SECRET`: Authentication config
- `EMAIL_*`: SMTP configuration
- `UPLOAD_*`: S3-compatible storage config
- `STRIPE_*`: Payment processing
- `NEXT_PUBLIC_BILLING_URL` / `NEXT_PUBLIC_PAY_URL`: override the hosted billing
and plan pages (defaults in `src/constants/billing.ts`)
- `NEXT_PUBLIC_*`: Client-side variables
## Security Considerations
-2
View File
@@ -66,7 +66,6 @@
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@sindresorhus/slugify": "^2.2.1",
"@stripe/stripe-js": "^4.1.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.20.1",
@@ -112,7 +111,6 @@
"react-pdf": "^8.0.2",
"sharp": "^0.33.3",
"sonner": "^1.5.0",
"stripe": "^15.8.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
+114 -192
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
-- DropTable
DROP TABLE IF EXISTS "BillingSubscription";
-- DropTable
DROP TABLE IF EXISTS "BillingPrice";
-- DropTable
DROP TABLE IF EXISTS "BillingProduct";
-- DropTable
DROP TABLE IF EXISTS "BillingCustomer";
-- DropEnum
DROP TYPE IF EXISTS "SubscriptionStatus";
-- DropEnum
DROP TYPE IF EXISTS "PricingPlanInterval";
-- DropEnum
DROP TYPE IF EXISTS "PricingType";
-141
View File
@@ -156,7 +156,6 @@ model Company {
convertibleNotes ConvertibleNote[]
dataRooms DataRoom[]
eSignAudits EsignAudit[]
billingCustomers BillingCustomer[]
customRoles CustomRole[]
BankAccount BankAccount[]
@@ -978,146 +977,6 @@ model EsignAudit {
@@index([recipientId])
}
enum PricingType {
one_time
recurring
}
enum PricingPlanInterval {
day
week
month
year
}
enum SubscriptionStatus {
trialing
active
canceled
incomplete
incomplete_expired
past_due
unpaid
paused
}
model BillingProduct {
id String @id
// Product ID from Stripe, e.g. prod_1234.
active Boolean
// Whether the product is currently available for purchase.
name String
// The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions.
description String?
// The product's description.
metadata Json?
// additional data.
prices BillingPrice[]
}
model BillingPrice {
id String @id
// Price ID from Stripe, e.g. price_1234.
productId String
active Boolean
// Whether the price can be used for new purchases.
description String?
// A brief description of the price.
unitAmount BigInt?
// The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency).
currency String @db.Char(3)
// Three-letter ISO currency code, in lowercase.
type PricingType
interval PricingPlanInterval?
intervalCount Int?
// The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
trialPeriodDays Int?
// Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan).
metadata Json?
// additional data.
product BillingProduct @relation(fields: [productId], references: [id])
Subscription BillingSubscription[]
@@index([productId])
}
model BillingSubscription {
id String @id
// Subscription ID from Stripe, e.g. sub_1234.
priceId String
// ID of the price that created this subscription.
quantity Int
// Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
status SubscriptionStatus
cancelAtPeriodEnd Boolean
// If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
created DateTime @default(now())
// Time at which the subscription was created.
currentPeriodStart DateTime @default(now())
// Start of the current period that the subscription has been invoiced for.
currentPeriodEnd DateTime @default(now())
// End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
endedAt DateTime?
// If the subscription has ended, the timestamp of the date the subscription ended.
cancelAt DateTime?
/// A date in the future at which the subscription will automatically get canceled.
canceledAt DateTime?
// If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.
trialStart DateTime?
// If the subscription has a trial, the beginning of that trial.
trialEnd DateTime?
// If the subscription has a trial, the end of that trial.
metadata Json?
// additional data.
price BillingPrice @relation(fields: [priceId], references: [id])
customer BillingCustomer @relation(fields: [customerId], references: [id])
customerId String
@@index([priceId])
@@index([customerId])
}
model BillingCustomer {
id String @id
companyId String?
company Company? @relation(fields: [companyId], references: [id])
subscriptions BillingSubscription[]
@@index([companyId])
}
enum AccessTokenType {
sig @map("Signature token")
doc @map("Data room token")
@@ -1,20 +1,36 @@
import { PlanDetails } from "@/components/billing/plan-details";
import { PageLayout } from "@/components/dashboard/page-layout";
import { api } from "@/trpc/server";
import { buttonVariants } from "@/components/ui/button";
import { BILLING_URL, PAY_URL } from "@/constants/billing";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Billing",
};
const BillingPage = async () => {
const [{ products }, { subscription }] = await Promise.all([
api.billing.getProducts.query(),
api.billing.getSubscription.query(),
]);
const BillingPage = () => {
return (
<PageLayout title="Billing" description="Manage payments and billing">
<PlanDetails products={products} subscription={subscription} />
<PageLayout
title="Billing"
description="Subscriptions, invoices and payment methods live on your Hanzo account."
>
<div className="flex flex-wrap gap-3">
<a
className={buttonVariants()}
href={BILLING_URL}
rel="noopener noreferrer"
target="_blank"
>
Manage billing
</a>
<a
className={buttonVariants({ variant: "secondary" })}
href={PAY_URL}
rel="noopener noreferrer"
target="_blank"
>
View plans
</a>
</div>
</PageLayout>
);
};
@@ -1,14 +1,13 @@
import { PageLayout } from "@/components/dashboard/page-layout";
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
import { Card } from "@/components/ui/card";
import { IS_BILLING_ENABLED } from "@/constants/stripe";
const SettingsLayout = ({ children }: { children: React.ReactNode }) => {
return (
<PageLayout title="Settings">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12 md:col-span-3">
<SettingsSidebar isBillingEnabled={IS_BILLING_ENABLED} />
<SettingsSidebar />
</div>
<div className="col-span-12 md:col-span-9">
<Card className="p-5">{children}</Card>
-104
View File
@@ -1,104 +0,0 @@
import { env } from "@/env";
import {
deletePriceRecord,
deleteProductRecord,
manageSubscriptionStatusChange,
stripe,
upsertPriceRecord,
upsertProductRecord,
} from "@/server/stripe";
import type Stripe from "stripe";
const relevantEvents = new Set([
"product.created",
"product.updated",
"product.deleted",
"price.created",
"price.updated",
"price.deleted",
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
]);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature") as string;
const webhookSecret = env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
if (!sig || !webhookSecret)
return new Response("Webhook secret not found.", { status: 400 });
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
console.log(`🔔 Webhook received: ${event.type}`);
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (err: any) {
console.log(`❌ Error message: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (relevantEvents.has(event.type)) {
try {
switch (event.type) {
case "product.created":
case "product.updated":
await upsertProductRecord(event.data.object as Stripe.Product);
break;
case "price.created":
case "price.updated":
await upsertPriceRecord(event.data.object as Stripe.Price);
break;
case "price.deleted":
await deletePriceRecord(event.data.object as Stripe.Price);
break;
case "product.deleted":
await deleteProductRecord(event.data.object as Stripe.Product);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await manageSubscriptionStatusChange(
subscription.id,
subscription.customer as string,
event.type === "customer.subscription.created",
);
break;
}
case "checkout.session.completed": {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (checkoutSession.mode === "subscription") {
const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange(
subscriptionId as string,
checkoutSession.customer as string,
true,
);
}
break;
}
default:
throw new Error("Unhandled relevant event!");
}
} catch (error) {
console.log(error);
// We dealing with webhooks return a 200 to acknowledge receipt of the event
return new Response(
"Webhook handler failed. View your Next.js function logs.",
{
status: 200,
},
);
}
} else {
console.log(`❌ Unhandled event type: ${event.type}`);
// We dealing with webhooks return a 200 to acknowledge receipt of the event
return new Response(`Unsupported event type: ${event.type}`, {
status: 200,
});
}
return new Response(JSON.stringify({ received: true }));
}
-12
View File
@@ -1,12 +0,0 @@
import { env } from "@/env";
import { type Stripe, loadStripe } from "@stripe/stripe-js";
let stripePromise: Promise<Stripe | null>;
export const getStripeClient = () => {
if (!stripePromise) {
stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "");
}
return stripePromise;
};
@@ -1,46 +0,0 @@
import { dayjsExt } from "@/common/dayjs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { badgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import Link from "next/link";
import { PricingModal, type PricingModalProps } from "../pricing-modal";
interface PlanDetailsProps extends PricingModalProps {}
export function PlanDetails({ subscription, products }: PlanDetailsProps) {
return (
<div>
<Alert>
<AlertTitle>Plan Details</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-y-3">
<p>
You are currently on the{" "}
<span className={badgeVariants()}>
{subscription ? subscription.price.product.name : "Free"}
</span>{" "}
plan.{" "}
{subscription ? (
<>
Current billing cycle:{" "}
{dayjsExt(subscription?.currentPeriodStart).format("ll")} -{" "}
{dayjsExt(subscription?.currentPeriodEnd).format("ll")}
</>
) : null}
</p>
<div className="flex items-center justify-center">
<Link
className={buttonVariants({ variant: "secondary" })}
href="?upgrade=true"
>
Upgrade or manage plans
</Link>
<PricingModal subscription={subscription} products={products} />
</div>
</div>
</AlertDescription>
</Alert>
</div>
);
}
@@ -1,21 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
export function EmptyPlans() {
return (
<Alert variant="destructive">
<AlertTitle>Empty Subscription Plans</AlertTitle>
<AlertDescription>
No subscription pricing plans found. Create them in your{" "}
<a
className="underline underline-offset-2 text-primary"
href="https://dashboard.stripe.com/products"
rel="noopener noreferrer"
target="_blank"
>
Stripe Dashboard
</a>
.
</AlertDescription>
</Alert>
);
}
@@ -1,211 +0,0 @@
"use client";
import { getStripeClient } from "@/client-only/stripe";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { PricingPlanInterval, PricingType } from "@/prisma/enums";
import { api } from "@/trpc/react";
import type { TypeZodStripePortalMutationSchema } from "@/trpc/routers/billing-router/schema";
import type { RouterOutputs } from "@/trpc/shared";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { EmptyPlans } from "./empty-plans";
import { PricingButton } from "./pricing-button";
import { PricingCard } from "./pricing-card";
type Products = RouterOutputs["billing"]["getProducts"]["products"];
type TSubscription =
RouterOutputs["billing"]["getSubscription"]["subscription"];
interface PricingProps {
products: Products;
subscription: TSubscription;
}
interface handleStripeCheckoutOptions {
priceId: string;
priceType: PricingType;
}
function Plans({ products, subscription }: PricingProps) {
const router = useRouter();
const intervals = Array.from(
new Set(
products.flatMap((product) =>
product?.prices?.map((price) => price?.interval),
),
),
);
const [billingInterval, setBillingInterval] =
useState<PricingPlanInterval>("month");
const { mutateAsync: checkoutWithStripe, isLoading: checkoutLoading } =
api.billing.checkout.useMutation({
onSuccess: async ({ stripeSessionId }) => {
const stripe = await getStripeClient();
await stripe?.redirectToCheckout({ sessionId: stripeSessionId });
},
});
const { mutateAsync: stripePortal, isLoading: stripePortalLoading } =
api.billing.stripePortal.useMutation({
onSuccess: ({ url }) => {
router.push(url);
},
});
const handleBilling = (interval: PricingPlanInterval) => {
setBillingInterval(interval);
};
const handleStripeCheckout = async (price: handleStripeCheckoutOptions) => {
await checkoutWithStripe(price);
};
const handleBillingPortal = async (
data: TypeZodStripePortalMutationSchema,
) => {
await stripePortal(data);
};
const isSubmitting = checkoutLoading || stripePortalLoading;
return (
<div className="flex flex-col items-center justify-center">
<div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
{intervals.includes("month") && (
<PricingButton
onClick={() => handleBilling("month")}
active={billingInterval === "month"}
label="monthly"
/>
)}
{intervals.includes("year") && (
<PricingButton
onClick={() => handleBilling("year")}
active={billingInterval === "year"}
label="yearly"
/>
)}
</div>
<section className="flex flex-col sm:flex-row sm:flex-wrap gap-8 pt-8">
<PricingCard
title="Free"
description=""
price="$0"
interval="month"
subscribedUnitAmount={subscription?.price.unitAmount}
unitAmount={0}
isSubmitting={isSubmitting}
{...(subscription && {
handleClick: () => {
return handleBillingPortal({
type: "cancel",
subscription: subscription.id,
});
},
})}
/>
{products.map((product) => {
const price = product?.prices?.find(
(price) => price.interval === billingInterval,
);
if (!price) return null;
const unitAmount = Number(price?.unitAmount) ?? 0;
const priceString = new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
}).format(unitAmount / 100);
const active = subscription?.priceId === price.id;
return (
<PricingCard
key={product.id}
title={product.name}
description={product.description}
price={priceString}
interval={billingInterval}
handleClick={() => {
if (subscription) {
return handleBillingPortal({
...(active
? {
type: "cancel",
subscription: subscription.id,
}
: {
type: "update",
subscription: subscription.id,
}),
});
}
return handleStripeCheckout({
priceId: price.id,
priceType: price.type,
});
}}
subscribedUnitAmount={subscription?.price.unitAmount}
unitAmount={unitAmount}
isSubmitting={isSubmitting}
/>
);
})}
</section>
</div>
);
}
export function Pricing({ products, subscription }: PricingProps) {
return products.length ? (
<Plans products={products} subscription={subscription} />
) : (
<EmptyPlans />
);
}
export type PricingModalProps = PricingProps;
export function PricingModal({ products, subscription }: PricingModalProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const path = usePathname();
const upgrade = searchParams.get("upgrade");
useEffect(() => {
const isOpen = upgrade === "true";
if (isOpen) {
setOpen(true);
}
return () => {
setOpen(false);
};
}, [upgrade]);
return (
<Dialog
open={open}
onOpenChange={() => {
router.push(path);
}}
>
<DialogContent className="max-w-[95vw]">
<DialogHeader className="flex items-center justify-center">
<DialogTitle>Upgrade or manage plans</DialogTitle>
</DialogHeader>
<div className="overflow-scroll no-scrollbar max-h-[80vh]">
<Pricing products={products} subscription={subscription} />
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,28 +0,0 @@
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
interface PricingButtonProps
extends Omit<ComponentProps<"button">, "children"> {
label: string;
active: boolean;
}
export function PricingButton({
label,
active,
className,
...rest
}: PricingButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
active && "bg-background text-foreground shadow-sm",
className,
)}
{...rest}
>
{label}
</button>
);
}
@@ -1,91 +0,0 @@
import { Button, type ButtonProps } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PricingPlanInterval } from "@/prisma/enums";
import { useState } from "react";
interface PricingCardProps {
title: string;
description?: string | null;
price: string;
interval: PricingPlanInterval;
subscribedUnitAmount?: bigint | null;
unitAmount: number;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
handleClick?: () => Promise<any>;
isSubmitting: boolean;
}
const humanizedInterval: Record<PricingPlanInterval, string> = {
day: "Daily",
month: "Monthly",
week: "Weekly",
year: "Yearly",
};
export function PricingCard({
description,
title,
interval,
price,
subscribedUnitAmount: subscribedUnitAmount_,
unitAmount,
handleClick,
isSubmitting,
}: PricingCardProps) {
const [isLoading, setIsLoading] = useState(false);
const subscribedUnitAmount = subscribedUnitAmount_
? Number(subscribedUnitAmount_)
: null;
const active = unitAmount === subscribedUnitAmount;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
<div className="flex gap-0.5">
<h3 className="text-3xl font-bold">{price}</h3>
{unitAmount !== 0 && (
<span className="flex flex-col justify-end text-sm mb-1">
/{humanizedInterval[interval]}
</span>
)}
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardFooter>
<Button
className={cn(!active && "bg-teal-500 hover:bg-teal-500/80")}
{...(active && { variant: "destructive" })}
onClick={async () => {
if (handleClick) {
setIsLoading(true);
await handleClick();
setIsLoading(false);
}
}}
loading={isLoading}
{...(!isLoading && { disabled: isSubmitting })}
{...(unitAmount === 0 && !subscribedUnitAmount && { disabled: true })}
>
{subscribedUnitAmount
? unitAmount < subscribedUnitAmount
? "Downgrade Plan"
: unitAmount > subscribedUnitAmount
? "Upgrade Plan"
: "Cancel Current Plan"
: !subscribedUnitAmount && unitAmount === 0
? "Active plan"
: "Subscribe"}
</Button>
</CardFooter>
</Card>
);
}
+1 -9
View File
@@ -88,11 +88,7 @@ const accountNav = [
},
];
interface SettingsSidebarProps {
isBillingEnabled: boolean;
}
export function SettingsSidebar({ isBillingEnabled }: SettingsSidebarProps) {
export function SettingsSidebar() {
const currentPath = usePathname();
const { data } = useSession();
const companyPublicId = data?.user.companyPublicId;
@@ -103,10 +99,6 @@ export function SettingsSidebar({ isBillingEnabled }: SettingsSidebarProps) {
const href = `/${companyPublicId}${item.href}`;
const isActive = currentPath === href;
if (item.name === "Billing" && !isBillingEnabled) {
return null;
}
return (
<li key={item.name} className="rounded py-1">
<NavLink
+6
View File
@@ -0,0 +1,6 @@
import { env } from "@/env";
export const BILLING_URL =
env.NEXT_PUBLIC_BILLING_URL ?? "https://billing.hanzo.ai";
export const PAY_URL = env.NEXT_PUBLIC_PAY_URL ?? "https://pay.hanzo.ai";
-7
View File
@@ -1,7 +0,0 @@
import { env } from "@/env";
export const IS_BILLING_ENABLED = !!(
env.STRIPE_API_KEY &&
env.STRIPE_WEBHOOK_SECRET &&
env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
);
+7 -11
View File
@@ -53,10 +53,6 @@ export const env = createEnv({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// stripe
STRIPE_API_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
// sentry
SENTRY_ORG: z.string().optional(),
SENTRY_PROJECT: z.string().optional(),
@@ -74,8 +70,9 @@ export const env = createEnv({
NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_UPLOAD_DOMAIN: z.string().optional(),
// stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
// billing
NEXT_PUBLIC_BILLING_URL: z.string().url().optional(),
NEXT_PUBLIC_PAY_URL: z.string().url().optional(),
// sentry
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
@@ -108,12 +105,11 @@ export const env = createEnv({
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
/// stripe
STRIPE_API_KEY: process.env.STRIPE_API_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: readRuntimePublicEnvVariable(
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
/// billing
NEXT_PUBLIC_BILLING_URL: readRuntimePublicEnvVariable(
"NEXT_PUBLIC_BILLING_URL",
),
NEXT_PUBLIC_PAY_URL: readRuntimePublicEnvVariable("NEXT_PUBLIC_PAY_URL"),
/// sentry
NEXT_PUBLIC_SENTRY_DSN: readRuntimePublicEnvVariable(
-2
View File
@@ -98,8 +98,6 @@ export const AuditSchema = z.object({
"stakeholder",
"accessToken",
"bucket",
"stripeSession",
"stripeBillingPortalSession",
"dataroom",
"role",
"template",
-228
View File
@@ -1,228 +0,0 @@
import { env } from "@/env";
import { invariant } from "@/lib/error";
import Stripe from "stripe";
import { type TPrismaOrTransaction, db } from "./db";
const toDateTime = (secs: number) => {
const t = new Date(+0); // Unix epoch start.
t.setSeconds(secs);
return t;
};
export const stripe = new Stripe(env.STRIPE_API_KEY ?? "", {
typescript: true,
apiVersion: "2024-04-10",
});
export { Stripe };
export async function upsertProductRecord(product: Stripe.Product) {
const productData = {
id: product.id,
active: product.active,
name: product.name,
description: product.description,
metadata: product.metadata,
};
await db.billingProduct.upsert({
create: productData,
update: productData,
where: {
id: product.id,
},
});
}
export async function deleteProductRecord(product: Stripe.Product) {
await db.billingProduct.delete({
where: {
id: product.id,
},
});
}
export async function deletePriceRecord(price: Stripe.Price) {
await db.billingPrice.delete({
where: {
id: price.id,
},
});
}
const TRIAL_PERIOD_DAYS = 0;
export async function upsertPriceRecord(price: Stripe.Price) {
const priceData = {
id: price.id,
productId: typeof price.product === "string" ? price.product : "",
active: price.active,
currency: price.currency,
type: price.type,
unitAmount: price.unit_amount,
interval: price.recurring?.interval,
intervalCount: price.recurring?.interval_count,
trialPeriodDays: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS,
};
await db.billingPrice.upsert({
create: priceData,
update: priceData,
where: {
id: price.id,
},
});
}
export const manageSubscriptionStatusChange = async (
subscriptionId: string,
customerId: string,
createAction = false,
) => {
const customer = await db.billingCustomer.findFirst({
where: { id: customerId },
});
invariant(customer?.id, "Customer lookup failed");
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["default_payment_method"],
});
const item = subscription.items.data[0];
invariant(item, "item not found");
const id = subscription.id;
const data = {
customerId: customer.id,
metadata: subscription.metadata,
status: subscription.status,
priceId: item.price.id,
quantity: item.quantity ?? 1,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
cancelAt: subscription.cancel_at
? toDateTime(subscription.cancel_at)
: undefined,
canceledAt: subscription.canceled_at
? toDateTime(subscription.canceled_at)
: undefined,
currentPeriodStart: subscription.current_period_start
? toDateTime(subscription.current_period_start)
: undefined,
currentPeriodEnd: subscription.current_period_end
? toDateTime(subscription.current_period_end)
: undefined,
created: toDateTime(subscription.created),
endedAt: subscription.ended_at
? toDateTime(subscription.ended_at)
: undefined,
trialStart: subscription.trial_start
? toDateTime(subscription.trial_start)
: undefined,
trialEnd: subscription.trial_end
? toDateTime(subscription.trial_end)
: undefined,
};
await db.billingSubscription.upsert({
create: { ...data, id },
update: data,
where: {
id,
},
});
};
interface upsertCustomerOptions {
customerId: string;
companyId: string;
tx: TPrismaOrTransaction;
}
async function upsertCustomer({
tx,
companyId,
customerId,
}: upsertCustomerOptions) {
const data = { companyId, id: customerId };
const customer = await tx.billingCustomer.upsert({
where: data,
update: data,
create: data,
});
return customer.id;
}
interface createCustomerInStripeOptions {
companyId: string;
email: string;
}
const createCustomerInStripe = async ({
email,
companyId,
}: createCustomerInStripeOptions) => {
const customerData = { metadata: { companyId }, email: email };
const newCustomer = await stripe.customers.create(customerData);
invariant(newCustomer, "Stripe customer creation failed.");
return newCustomer.id;
};
interface createOrRetrieveCustomerOptions {
companyId: string;
tx: TPrismaOrTransaction;
email: string;
}
export async function createOrRetrieveCustomer({
companyId,
tx,
email,
}: createOrRetrieveCustomerOptions) {
const existingCustomer = await tx.billingCustomer.findFirst({
where: { companyId },
});
let stripeCustomerId: string | undefined;
if (existingCustomer?.id) {
const existingStripeCustomer = await stripe.customers.retrieve(
existingCustomer.id,
);
stripeCustomerId = existingStripeCustomer.id;
} else {
const stripeCustomers = await stripe.customers.list({ email: email });
stripeCustomerId = stripeCustomers?.data?.[0]
? stripeCustomers.data[0].id
: undefined;
}
const stripeId = stripeCustomerId
? stripeCustomerId
: await createCustomerInStripe({ email, companyId });
if (existingCustomer && stripeCustomerId) {
await tx.billingCustomer.update({
where: {
id: existingCustomer.id,
},
data: {
id: stripeCustomerId,
},
});
return stripeCustomerId;
}
const upsertedStripeCustomer = await upsertCustomer({
tx,
companyId,
customerId: stripeId,
});
return upsertedStripeCustomer;
}
-2
View File
@@ -3,7 +3,6 @@ import { accessTokenRouter } from "../routers/access-token/router";
import { auditRouter } from "../routers/audit-router/router";
import { authRouter } from "../routers/auth/router";
import { bankAccountsRouter } from "../routers/bank-accounts/router";
import { billingRouter } from "../routers/billing-router/router";
import { bucketRouter } from "../routers/bucket-router/router";
import { commonRouter } from "../routers/common/router";
import { companyRouter } from "../routers/company-router/router";
@@ -50,7 +49,6 @@ export const appRouter = createTRPCRouter({
common: commonRouter,
passkey: passkeyRouter,
security: securityRouter,
billing: billingRouter,
rbac: rbacRouter,
accessToken: accessTokenRouter,
bankAccounts: bankAccountsRouter,
@@ -1,58 +0,0 @@
import { env } from "@/env";
import { invariant } from "@/lib/error";
import { checkMembership } from "@/server/auth";
import { createOrRetrieveCustomer, stripe } from "@/server/stripe";
import { withAuth } from "@/trpc/api/trpc";
import type Stripe from "stripe";
import { ZodCheckoutMutationSchema } from "../schema";
export const checkoutProcedure = withAuth
.input(ZodCheckoutMutationSchema)
.mutation(async ({ ctx, input }) => {
const { priceId, priceType } = input;
const { db, session } = ctx;
const { stripeSessionId } = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
let customer: string;
try {
customer = await createOrRetrieveCustomer({
tx,
companyId,
email: "",
});
} catch (err) {
console.error(err);
throw new Error("Unable to access customer record.");
}
const params: Stripe.Checkout.SessionCreateParams = {
allow_promotion_codes: true,
billing_address_collection: "required",
customer,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: priceType === "recurring" ? "subscription" : "payment",
success_url: `${env.NEXT_PUBLIC_BASE_URL}/${session.user.companyPublicId}/settings/billing`,
};
let stripeSession: Stripe.Checkout.Session | undefined;
try {
stripeSession = await stripe.checkout.sessions.create(params);
} catch (err) {
console.error(err);
throw new Error("Unable to create checkout session.");
}
invariant(stripeSession, "session not found");
return { stripeSessionId: stripeSession.id };
});
return { stripeSessionId };
});
@@ -1,31 +0,0 @@
import { withAuth } from "@/trpc/api/trpc";
export const getProductsProcedure = withAuth.query(async ({ ctx }) => {
const { db } = ctx;
const { products } = await db.$transaction(async (tx) => {
const products = await tx.billingProduct.findMany({
where: {
active: true,
},
include: {
prices: {
where: {
active: true,
},
select: {
id: true,
interval: true,
unitAmount: true,
currency: true,
type: true,
},
},
},
});
return { products };
});
return { products };
});
@@ -1,48 +0,0 @@
import { checkMembership } from "@/server/auth";
import { withAuth } from "@/trpc/api/trpc";
export const getSubscriptionProcedure = withAuth.query(async ({ ctx }) => {
const { db, session } = ctx;
const { subscription } = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
const customer = await tx.billingCustomer.findFirst({
where: {
companyId,
},
select: {
id: true,
},
});
if (!customer) {
return { subscription: null };
}
const subscription = await tx.billingSubscription.findFirst({
where: {
customerId: customer.id,
status: {
in: ["active", "trialing"],
},
},
include: {
price: {
select: {
product: {
select: {
name: true,
},
},
unitAmount: true,
},
},
},
});
return { subscription };
});
return { subscription };
});
@@ -1,60 +0,0 @@
import { env } from "@/env";
import { invariant } from "@/lib/error";
import { checkMembership } from "@/server/auth";
import { createOrRetrieveCustomer, stripe } from "@/server/stripe";
import { withAuth } from "@/trpc/api/trpc";
import { ZodStripePortalMutationSchema } from "../schema";
export const stripePortalProcedure = withAuth
.input(ZodStripePortalMutationSchema)
.mutation(async ({ ctx, input }) => {
const { db, session } = ctx;
const { url } = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
let customer: string;
try {
customer = await createOrRetrieveCustomer({
tx,
companyId,
email: "",
});
} catch (_error) {
throw new Error("Unable to access customer record.");
}
invariant(customer, "Could not get customer.");
console.log({ input });
try {
const { url } = await stripe.billingPortal.sessions.create({
customer,
return_url: `${env.NEXT_PUBLIC_BASE_URL}/${session.user.companyPublicId}/settings/billing`,
flow_data: {
...(input.type === "cancel"
? {
type: "subscription_cancel",
subscription_cancel: {
subscription: input.subscription,
},
}
: {
type: "subscription_update",
subscription_update: {
subscription: input.subscription,
},
}),
},
});
return { url };
} catch (_error) {
console.log({ _error });
throw new Error("Could not create billing portal.");
}
});
return { url };
});
-12
View File
@@ -1,12 +0,0 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { checkoutProcedure } from "./procedures/checkout";
import { getProductsProcedure } from "./procedures/get-products";
import { getSubscriptionProcedure } from "./procedures/get-subscription";
import { stripePortalProcedure } from "./procedures/stripe-portal";
export const billingRouter = createTRPCRouter({
getProducts: getProductsProcedure,
getSubscription: getSubscriptionProcedure,
checkout: checkoutProcedure,
stripePortal: stripePortalProcedure,
});
-20
View File
@@ -1,20 +0,0 @@
import { PricingType } from "@/prisma/enums";
import { z } from "zod";
export const ZodCheckoutMutationSchema = z.object({
priceId: z.string(),
priceType: z.nativeEnum(PricingType),
});
export type TypeZodCheckoutMutationSchema = z.infer<
typeof ZodCheckoutMutationSchema
>;
export const ZodStripePortalMutationSchema = z.object({
type: z.enum(["cancel", "update"]),
subscription: z.string(),
});
export type TypeZodStripePortalMutationSchema = z.infer<
typeof ZodStripePortalMutationSchema
>;