From 48dd4d5913c02744c6796b2f6fb266203f011676 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 18:15:46 -0800 Subject: [PATCH] Billing licenses working --- messages/en-US.json | 9 ++ .../routers/billing/getOrgSubscriptions.ts | 11 -- src/app/[orgId]/layout.tsx | 2 +- .../settings/(private)/billing/page.tsx | 132 +++++++++++++++--- src/providers/SubscriptionStatusProvider.tsx | 32 +++-- 5 files changed, 139 insertions(+), 47 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index eb01bdee..81e6ae71 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1436,6 +1436,15 @@ "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingLicenseKeys": "License Keys", + "billingLicenseKeysDescription": "Manage your license key subscriptions", + "billingLicenseSubscription": "License Subscription", + "billingInactive": "Inactive", + "billingLicenseItem": "License Item", + "billingQuantity": "Quantity", + "billingTotal": "total", + "billingModifyLicenses": "Modify License Subscription", + "billingPricingCalculatorLink": "View Pricing Calculator", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", diff --git a/server/private/routers/billing/getOrgSubscriptions.ts b/server/private/routers/billing/getOrgSubscriptions.ts index 989e27d8..40b029e4 100644 --- a/server/private/routers/billing/getOrgSubscriptions.ts +++ b/server/private/routers/billing/getOrgSubscriptions.ts @@ -37,17 +37,6 @@ const getOrgSchema = z.strictObject({ orgId: z.string() }); -registry.registerPath({ - method: "get", - path: "/org/{orgId}/billing/subscription", - description: "Get an organization", - tags: [OpenAPITags.Org], - request: { - params: getOrgSchema - }, - responses: {} -}); - export async function getOrgSubscriptions( req: Request, res: Response, diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index c307efcb..7d99fc0d 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -86,7 +86,7 @@ export default async function OrgLayout(props: { try { const getSubscription = cache(() => internal.get>( - `/org/${orgId}/billing/subscription`, + `/org/${orgId}/billing/subscriptions`, cookie ) ); diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index e63eebcc..e1879aa6 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -43,15 +43,18 @@ import Link from "next/link"; export default function GeneralPage() { const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); + const envContext = useEnvContext(); + const api = createApiClient(envContext); const t = useTranslations(); - // Subscription state - const [subscription, setSubscription] = - useState(null); - const [subscriptionItems, setSubscriptionItems] = useState< - GetOrgSubscriptionResponse["items"] + // Subscription state - now handling multiple subscriptions + const [allSubscriptions, setAllSubscriptions] = useState< + GetOrgSubscriptionResponse["subscriptions"] >([]); + const [tierSubscription, setTierSubscription] = + useState(null); + const [licenseSubscription, setLicenseSubscription] = + useState(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); // Example usage data (replace with real usage data if available) @@ -68,12 +71,41 @@ export default function GeneralPage() { try { const res = await api.get< AxiosResponse - >(`/org/${org.org.orgId}/billing/subscription`); - const { subscription, items } = res.data.data; - setSubscription(subscription); - setSubscriptionItems(items); + >(`/org/${org.org.orgId}/billing/subscriptions`); + const { subscriptions } = res.data.data; + setAllSubscriptions(subscriptions); + + // Import tier and license price sets + const { getTierPriceSet } = await import("@server/lib/billing/tiers"); + const { getLicensePriceSet } = await import("@server/lib/billing/licenses"); + + const tierPriceSet = getTierPriceSet( + envContext.env.app.environment, + envContext.env.app.sandbox_mode + ); + const licensePriceSet = getLicensePriceSet( + envContext.env.app.environment, + envContext.env.app.sandbox_mode + ); + + // Find tier subscription (subscription with items matching tier prices) + const tierSub = subscriptions.find(({ items }) => + items.some((item) => + item.priceId && Object.values(tierPriceSet).includes(item.priceId) + ) + ); + setTierSubscription(tierSub || null); + + // Find license subscription (subscription with items matching license prices) + const licenseSub = subscriptions.find(({ items }) => + items.some((item) => + item.priceId && Object.values(licensePriceSet).includes(item.priceId) + ) + ); + setLicenseSubscription(licenseSub || null); + setHasSubscription( - !!subscription && subscription.status === "active" + !!tierSub?.subscription && tierSub.subscription.status === "active" ); } catch (error) { toast({ @@ -302,6 +334,10 @@ export default function GeneralPage() { return { usage: usage ?? 0, item, limit }; } + // Get tier subscription items + const tierSubscriptionItems = tierSubscription?.items || []; + const tierSubscriptionData = tierSubscription?.subscription || null; + // Helper to check if usage exceeds limit function isOverLimit(usage: any, limit: any, usageType: any) { if (!limit || !usage) return false; @@ -388,15 +424,15 @@ export default function GeneralPage() {
- {subscription?.status === "active" && ( + {tierSubscriptionData?.status === "active" && ( )} - {subscription - ? subscription.status.charAt(0).toUpperCase() + - subscription.status.slice(1) + {tierSubscriptionData + ? tierSubscriptionData.status.charAt(0).toUpperCase() + + tierSubscriptionData.status.slice(1) : t("billingFreeTier")} { const { usage, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -441,7 +477,7 @@ export default function GeneralPage() { {usageTypes.map((type) => { const { usage, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -530,7 +566,7 @@ export default function GeneralPage() { {usageTypes.map((type) => { const { item, limit } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -614,7 +650,7 @@ export default function GeneralPage() { const { usage, item } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -636,7 +672,7 @@ export default function GeneralPage() { ); })} {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} - {subscriptionItems + {tierSubscriptionItems .filter( (item) => item.unitAmount && @@ -672,7 +708,7 @@ export default function GeneralPage() { const { usage, item } = getUsageItemAndLimit( usageData, - subscriptionItems, + tierSubscriptionItems, limitsData, type.id ); @@ -687,7 +723,7 @@ export default function GeneralPage() { return sum + cost; }, 0) + // Add recurring charges - subscriptionItems + tierSubscriptionItems .filter( (item) => item.unitAmount && @@ -749,6 +785,56 @@ export default function GeneralPage() { )} + + {/* License Keys Section */} + {licenseSubscription && ( + + + + {t("billingLicenseKeys") || "License Keys"} + + + {t("billingLicenseKeysDescription") || "Manage your license key subscriptions"} + + + +
+
+ + + {t("billingLicenseSubscription") || "License Subscription"} + +
+ + {licenseSubscription.subscription?.status === "active" && ( + + )} + {licenseSubscription.subscription?.status + ? licenseSubscription.subscription.status + .charAt(0) + .toUpperCase() + + licenseSubscription.subscription.status.slice(1) + : t("billingInactive") || "Inactive"} + +
+ + + +
+
+ )} ); } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index 85802cfa..eecafce8 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -33,8 +33,11 @@ export function SubscriptionStatusProvider({ }; const isActive = () => { - if (subscriptionStatus?.subscription?.status === "active") { - return true; + if (subscriptionStatus?.subscriptions) { + // Check if any subscription is active + return subscriptionStatus.subscriptions.some( + (sub) => sub.subscription?.status === "active" + ); } return false; }; @@ -42,15 +45,20 @@ export function SubscriptionStatusProvider({ const getTier = () => { const tierPriceSet = getTierPriceSet(env, sandbox_mode); - if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) { - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = subscriptionStatus.items.find( - (item) => item.priceId === priceId - ); - if (matchingItem) { - return tierId; + if (subscriptionStatus?.subscriptions) { + // Iterate through all subscriptions + for (const { subscription, items } of subscriptionStatus.subscriptions) { + if (items && items.length > 0) { + // Iterate through tiers in order (earlier keys are higher tiers) + for (const [tierId, priceId] of Object.entries(tierPriceSet)) { + // Check if any subscription item matches this tier's price ID + const matchingItem = items.find( + (item) => item.priceId === priceId + ); + if (matchingItem) { + return tierId; + } + } } } } @@ -83,4 +91,4 @@ export function SubscriptionStatusProvider({ ); } -export default SubscriptionStatusProvider; +export default SubscriptionStatusProvider; \ No newline at end of file