mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
@@ -41,6 +41,5 @@ vscode:
|
||||
- dbaeumer.vscode-eslint
|
||||
- bradlc.vscode-tailwindcss
|
||||
- ban.spellright
|
||||
- stripe.vscode-stripe
|
||||
- Prisma.prisma
|
||||
- ms-dotnettools.vscode-dotnet-runtime
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+114
-192
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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -98,8 +98,6 @@ export const AuditSchema = z.object({
|
||||
"stakeholder",
|
||||
"accessToken",
|
||||
"bucket",
|
||||
"stripeSession",
|
||||
"stripeBillingPortalSession",
|
||||
"dataroom",
|
||||
"role",
|
||||
"template",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
Reference in New Issue
Block a user