first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
110
apps/web/modules/saas/payments/components/ActivePlan.tsx
Normal file
110
apps/web/modules/saas/payments/components/ActivePlan.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { usePurchases } from "@saas/payments/hooks/purchases";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import {} from "@ui/components/table";
|
||||
import { BadgeCheckIcon, CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CustomerPortalButton } from "../../settings/components/CustomerPortalButton";
|
||||
import { SubscriptionStatusBadge } from "../../settings/components/SubscriptionStatusBadge";
|
||||
|
||||
export function ActivePlan({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
seats?: number;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { planData } = usePlanData();
|
||||
const { activePlan } = usePurchases(organizationId);
|
||||
|
||||
if (!activePlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePlanData = planData[activePlan.id as keyof typeof planData];
|
||||
|
||||
if (!activePlanData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const price = "price" in activePlan ? activePlan.price : null;
|
||||
|
||||
return (
|
||||
<SettingsItem title={t("settings.billing.activePlan.title")}>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheckIcon className="size-6 text-primary" />
|
||||
<h4 className="font-bold text-lg text-primary">
|
||||
<span>{activePlanData.title}</span>
|
||||
</h4>
|
||||
{activePlan.status && (
|
||||
<SubscriptionStatusBadge
|
||||
status={activePlan.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!activePlanData.features?.length && (
|
||||
<ul className="mt-2 grid list-none gap-2 text-sm">
|
||||
{activePlanData.features.map((feature, key) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex items-center justify-start"
|
||||
>
|
||||
<CheckIcon className="mr-2 size-4 text-primary" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{price && (
|
||||
<strong
|
||||
className="mt-2 block font-medium text-2xl lg:text-3xl"
|
||||
data-test="price-table-plan-price"
|
||||
>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.amount)}
|
||||
{"interval" in price && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{price.interval === "month"
|
||||
? t("pricing.month", {
|
||||
count: price.intervalCount ?? 1,
|
||||
})
|
||||
: t("pricing.year", {
|
||||
count: price.intervalCount ?? 1,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{organizationId &&
|
||||
"seatBased" in price &&
|
||||
price.seatBased && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{t("pricing.perSeat")}
|
||||
</span>
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{"purchaseId" in activePlan && activePlan.purchaseId && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="flex w-full flex-col flex-wrap gap-2 md:flex-row">
|
||||
<CustomerPortalButton
|
||||
purchaseId={activePlan.purchaseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { usePurchases } from "@saas/payments/hooks/purchases";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
|
||||
export function ActivePlanBadge({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const { planData } = usePlanData();
|
||||
const { activePlan } = usePurchases(organizationId);
|
||||
|
||||
if (!activePlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePlanData = planData[activePlan.id as keyof typeof planData];
|
||||
|
||||
if (!activePlanData) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
status="info"
|
||||
className="flex items-center gap-1 px-1.5 text-xs normal-case"
|
||||
>
|
||||
{activePlanData.title}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
29
apps/web/modules/saas/payments/components/ChangePlan.tsx
Normal file
29
apps/web/modules/saas/payments/components/ChangePlan.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
import { PricingTable } from "@saas/payments/components/PricingTable";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ChangePlan({
|
||||
organizationId,
|
||||
userId,
|
||||
activePlanId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
userId?: string;
|
||||
activePlanId?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.billing.changePlan.title")}
|
||||
description={t("settings.billing.changePlan.description")}
|
||||
>
|
||||
<PricingTable
|
||||
organizationId={organizationId}
|
||||
userId={userId}
|
||||
activePlanId={activePlanId}
|
||||
/>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
300
apps/web/modules/saas/payments/components/PricingTable.tsx
Normal file
300
apps/web/modules/saas/payments/components/PricingTable.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"use client";
|
||||
import { LocaleLink } from "@i18n/routing";
|
||||
import { type Config, config } from "@repo/config";
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { useCreateCheckoutLinkMutation } from "@saas/payments/lib/api";
|
||||
import type { PlanId } from "@saas/payments/types";
|
||||
import { useLocaleCurrency } from "@shared/hooks/locale-currency";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { cn } from "@ui/lib";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BadgePercentIcon,
|
||||
CheckIcon,
|
||||
PhoneIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
const plans = config.payments.plans as Config["payments"]["plans"];
|
||||
|
||||
export function PricingTable({
|
||||
className,
|
||||
userId,
|
||||
organizationId,
|
||||
activePlanId,
|
||||
}: {
|
||||
className?: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
activePlanId?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const localeCurrency = useLocaleCurrency();
|
||||
const [loading, setLoading] = useState<PlanId | false>(false);
|
||||
const [interval, setInterval] = useState<"month" | "year">("month");
|
||||
|
||||
const { planData } = usePlanData();
|
||||
|
||||
const createCheckoutLinkMutation = useCreateCheckoutLinkMutation();
|
||||
|
||||
const onSelectPlan = async (planId: PlanId, productId?: string) => {
|
||||
if (!(userId || organizationId)) {
|
||||
router.push("/auth/signup");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = plans[planId];
|
||||
const price = plan.prices?.find(
|
||||
(price) => price.productId === productId,
|
||||
);
|
||||
|
||||
if (!price) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(planId);
|
||||
|
||||
try {
|
||||
const { checkoutLink } =
|
||||
await createCheckoutLinkMutation.mutateAsync({
|
||||
type:
|
||||
price.type === "one-time" ? "one-time" : "subscription",
|
||||
productId: price.productId,
|
||||
organizationId,
|
||||
redirectUrl: window.location.href,
|
||||
});
|
||||
|
||||
window.location.href = checkoutLink;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPlans = Object.entries(plans).filter(
|
||||
([planId]) =>
|
||||
planId !== activePlanId && (!activePlanId || planId !== "free"),
|
||||
);
|
||||
|
||||
const hasSubscriptions = filteredPlans.some(([_, plan]) =>
|
||||
plan.prices?.some((price) => price.type === "recurring"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("@container", className)}>
|
||||
{hasSubscriptions && (
|
||||
<div className="mb-6 flex @xl:justify-center">
|
||||
<Tabs
|
||||
value={interval}
|
||||
onValueChange={(value) =>
|
||||
setInterval(value as typeof interval)
|
||||
}
|
||||
data-test="price-table-interval-tabs"
|
||||
>
|
||||
<TabsList className="border-foreground/10">
|
||||
<TabsTrigger value="month">
|
||||
{t("pricing.monthly")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="year">
|
||||
{t("pricing.yearly")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("grid grid-cols-1 gap-4", {
|
||||
"@xl:grid-cols-2": filteredPlans.length >= 2,
|
||||
"@3xl:grid-cols-3": filteredPlans.length >= 3,
|
||||
"@4xl:grid-cols-4": filteredPlans.length >= 4,
|
||||
})}
|
||||
>
|
||||
{filteredPlans
|
||||
.filter(([planId]) => planId !== activePlanId)
|
||||
.map(([planId, plan]) => {
|
||||
const { isFree, isEnterprise, prices, recommended } =
|
||||
plan;
|
||||
const { title, description, features } =
|
||||
planData[planId as keyof typeof planData];
|
||||
|
||||
let price = prices?.find(
|
||||
(price) =>
|
||||
!price.hidden &&
|
||||
(price.type === "one-time" ||
|
||||
price.interval === interval) &&
|
||||
price.currency === localeCurrency,
|
||||
);
|
||||
|
||||
if (isFree) {
|
||||
price = {
|
||||
amount: 0,
|
||||
currency: localeCurrency,
|
||||
interval,
|
||||
productId: "",
|
||||
type: "recurring",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(price || isEnterprise)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={planId}
|
||||
className={cn("rounded-3xl border p-6", {
|
||||
"border-2 border-primary": recommended,
|
||||
})}
|
||||
data-test="price-table-plan"
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-4">
|
||||
<div>
|
||||
{recommended && (
|
||||
<div className="-mt-9 flex justify-center">
|
||||
<div className="mb-2 flex h-6 w-auto items-center gap-1.5 rounded-full bg-primary px-2 py-1 font-semibold text-primary-foreground text-xs">
|
||||
<StarIcon className="size-3" />
|
||||
{t("pricing.recommended")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
className={cn(
|
||||
"my-0 font-semibold text-2xl",
|
||||
{
|
||||
"font-bold text-primary":
|
||||
recommended,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<div className="prose mt-2 text-foreground/60 text-sm">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!features?.length && (
|
||||
<ul className="mt-4 grid list-none gap-2 text-sm">
|
||||
{features.map(
|
||||
(feature, key) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex items-center justify-start"
|
||||
>
|
||||
<CheckIcon className="mr-2 size-4 text-primary" />
|
||||
<span>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{price &&
|
||||
"trialPeriodDays" in price &&
|
||||
price.trialPeriodDays && (
|
||||
<div className="mt-4 flex items-center justify-start font-medium text-primary text-sm opacity-80">
|
||||
<BadgePercentIcon className="mr-2 size-4" />
|
||||
{t("pricing.trialPeriod", {
|
||||
days: price.trialPeriodDays,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{price && (
|
||||
<strong
|
||||
className="block font-medium text-2xl lg:text-3xl"
|
||||
data-test="price-table-plan-price"
|
||||
>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.amount)}
|
||||
{"interval" in price && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{interval === "month"
|
||||
? t(
|
||||
"pricing.month",
|
||||
{
|
||||
count:
|
||||
price.intervalCount ??
|
||||
1,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"pricing.year",
|
||||
{
|
||||
count:
|
||||
price.intervalCount ??
|
||||
1,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{organizationId &&
|
||||
"seatBased" in price &&
|
||||
price.seatBased && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{t(
|
||||
"pricing.perSeat",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
|
||||
{isEnterprise ? (
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant="light"
|
||||
asChild
|
||||
>
|
||||
<LocaleLink href="/contact">
|
||||
<PhoneIcon className="mr-2 size-4" />
|
||||
{t("pricing.contactSales")}
|
||||
</LocaleLink>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant={
|
||||
recommended
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={() =>
|
||||
onSelectPlan(
|
||||
planId as PlanId,
|
||||
price?.productId,
|
||||
)
|
||||
}
|
||||
loading={loading === planId}
|
||||
>
|
||||
{userId || organizationId
|
||||
? t("pricing.choosePlan")
|
||||
: t("pricing.getStarted")}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/modules/saas/payments/hooks/plan-data.tsx
Normal file
53
apps/web/modules/saas/payments/hooks/plan-data.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { config } from "@repo/config";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ProductReferenceId = keyof (typeof config)["payments"]["plans"];
|
||||
|
||||
export function usePlanData() {
|
||||
const t = useTranslations();
|
||||
|
||||
const planData: Record<
|
||||
ProductReferenceId,
|
||||
{
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
features: ReactNode[];
|
||||
}
|
||||
> = {
|
||||
free: {
|
||||
title: t("pricing.products.free.title"),
|
||||
description: t("pricing.products.free.description"),
|
||||
features: [
|
||||
t("pricing.products.free.features.anotherFeature"),
|
||||
t("pricing.products.free.features.limitedSupport"),
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
title: t("pricing.products.pro.title"),
|
||||
description: t("pricing.products.pro.description"),
|
||||
features: [
|
||||
t("pricing.products.pro.features.anotherFeature"),
|
||||
t("pricing.products.pro.features.fullSupport"),
|
||||
],
|
||||
},
|
||||
enterprise: {
|
||||
title: t("pricing.products.enterprise.title"),
|
||||
description: t("pricing.products.enterprise.description"),
|
||||
features: [
|
||||
t("pricing.products.enterprise.features.unlimitedProjects"),
|
||||
t("pricing.products.enterprise.features.enterpriseSupport"),
|
||||
],
|
||||
},
|
||||
lifetime: {
|
||||
title: t("pricing.products.lifetime.title"),
|
||||
description: t("pricing.products.lifetime.description"),
|
||||
features: [
|
||||
t("pricing.products.lifetime.features.noRecurringCosts"),
|
||||
t("pricing.products.lifetime.features.extendSupport"),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return { planData };
|
||||
}
|
||||
20
apps/web/modules/saas/payments/hooks/purchases.tsx
Normal file
20
apps/web/modules/saas/payments/hooks/purchases.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { type Config, config } from "@repo/config";
|
||||
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||
import { usePurchasesQuery } from "@saas/payments/lib/api";
|
||||
|
||||
const plans = config.payments.plans as Config["payments"]["plans"];
|
||||
|
||||
export const usePurchases = (organizationId?: string) => {
|
||||
const { data: purchases } = usePurchasesQuery(organizationId);
|
||||
|
||||
const { activePlan, hasSubscription, hasPurchase } = createPurchasesHelper(
|
||||
purchases ?? [],
|
||||
);
|
||||
|
||||
return { purchases, activePlan, hasSubscription, hasPurchase };
|
||||
};
|
||||
|
||||
export const useUserPurchases = () => usePurchases();
|
||||
|
||||
export const useOrganizationPurchases = (organizationId: string) =>
|
||||
usePurchases(organizationId);
|
||||
80
apps/web/modules/saas/payments/lib/api.ts
Normal file
80
apps/web/modules/saas/payments/lib/api.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { InferRequestType } from "hono";
|
||||
|
||||
export const createCustomerPortalLinkMutationKey = [
|
||||
"create-customer-portal-link",
|
||||
] as const;
|
||||
export const useCreateCustomerPortalLinkMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createCustomerPortalLinkMutationKey,
|
||||
mutationFn: async (
|
||||
query: InferRequestType<
|
||||
(typeof apiClient.payments)["create-customer-portal-link"]["$post"]
|
||||
>["query"],
|
||||
) => {
|
||||
const response = await apiClient.payments[
|
||||
"create-customer-portal-link"
|
||||
].$post({
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create customer portal link");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createCheckoutLinkMutationKey = ["create-checkout-link"] as const;
|
||||
export const useCreateCheckoutLinkMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createCheckoutLinkMutationKey,
|
||||
mutationFn: async (
|
||||
query: InferRequestType<
|
||||
(typeof apiClient.payments)["create-checkout-link"]["$post"]
|
||||
>["query"],
|
||||
) => {
|
||||
const response = await apiClient.payments[
|
||||
"create-checkout-link"
|
||||
].$post({
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create checkout link");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const purchasesQueryKey = (organizationId?: string) =>
|
||||
organizationId
|
||||
? ["organization", "purchases", organizationId]
|
||||
: (["user", "purchases"] as const);
|
||||
export const usePurchasesQuery = (
|
||||
organizationId?: string,
|
||||
options?: { enabled?: boolean },
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: purchasesQueryKey(organizationId),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
16
apps/web/modules/saas/payments/lib/purchases-context.ts
Normal file
16
apps/web/modules/saas/payments/lib/purchases-context.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { PlanId } from "@saas/payments/types";
|
||||
import React from "react";
|
||||
|
||||
export const PurchasesContext = React.createContext<
|
||||
| {
|
||||
activeSubscription: {
|
||||
id: string;
|
||||
purchaseId?: string;
|
||||
} | null;
|
||||
loaded: boolean;
|
||||
hasSubscription: (planIds?: PlanId[] | PlanId) => boolean;
|
||||
refetchPurchases: () => Promise<void>;
|
||||
hasPurchase: (planId: PlanId) => boolean;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
17
apps/web/modules/saas/payments/lib/server.ts
Normal file
17
apps/web/modules/saas/payments/lib/server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getServerApiClient } from "@shared/lib/server";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getPurchases = cache(async (organizationId?: string) => {
|
||||
const apiClient = await getServerApiClient();
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
3
apps/web/modules/saas/payments/types.ts
Normal file
3
apps/web/modules/saas/payments/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { config } from "@repo/config";
|
||||
|
||||
export type PlanId = keyof typeof config.payments.plans;
|
||||
Loading…
Add table
Add a link
Reference in a new issue