Billing licenses working

This commit is contained in:
Owen
2026-02-04 18:15:46 -08:00
parent 72d46b7352
commit 48dd4d5913
5 changed files with 139 additions and 47 deletions

View File

@@ -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.", "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.", "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.", "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", "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.", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed", "failed": "Failed",

View File

@@ -37,17 +37,6 @@ const getOrgSchema = z.strictObject({
orgId: z.string() 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( export async function getOrgSubscriptions(
req: Request, req: Request,
res: Response, res: Response,

View File

@@ -86,7 +86,7 @@ export default async function OrgLayout(props: {
try { try {
const getSubscription = cache(() => const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>( internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
`/org/${orgId}/billing/subscription`, `/org/${orgId}/billing/subscriptions`,
cookie cookie
) )
); );

View File

@@ -43,15 +43,18 @@ import Link from "next/link";
export default function GeneralPage() { export default function GeneralPage() {
const { org } = useOrgContext(); const { org } = useOrgContext();
const api = createApiClient(useEnvContext()); const envContext = useEnvContext();
const api = createApiClient(envContext);
const t = useTranslations(); const t = useTranslations();
// Subscription state // Subscription state - now handling multiple subscriptions
const [subscription, setSubscription] = const [allSubscriptions, setAllSubscriptions] = useState<
useState<GetOrgSubscriptionResponse["subscription"]>(null); GetOrgSubscriptionResponse["subscriptions"]
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
>([]); >([]);
const [tierSubscription, setTierSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [licenseSubscription, setLicenseSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(true); const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available) // Example usage data (replace with real usage data if available)
@@ -68,12 +71,41 @@ export default function GeneralPage() {
try { try {
const res = await api.get< const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse> AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscription`); >(`/org/${org.org.orgId}/billing/subscriptions`);
const { subscription, items } = res.data.data; const { subscriptions } = res.data.data;
setSubscription(subscription); setAllSubscriptions(subscriptions);
setSubscriptionItems(items);
// 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( setHasSubscription(
!!subscription && subscription.status === "active" !!tierSub?.subscription && tierSub.subscription.status === "active"
); );
} catch (error) { } catch (error) {
toast({ toast({
@@ -302,6 +334,10 @@ export default function GeneralPage() {
return { usage: usage ?? 0, item, limit }; 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 // Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) { function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false; if (!limit || !usage) return false;
@@ -388,15 +424,15 @@ export default function GeneralPage() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Badge <Badge
variant={ variant={
subscription?.status === "active" ? "green" : "outline" tierSubscriptionData?.status === "active" ? "green" : "outline"
} }
> >
{subscription?.status === "active" && ( {tierSubscriptionData?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" /> <CheckCircle className="h-3 w-3 mr-1" />
)} )}
{subscription {tierSubscriptionData
? subscription.status.charAt(0).toUpperCase() + ? tierSubscriptionData.status.charAt(0).toUpperCase() +
subscription.status.slice(1) tierSubscriptionData.status.slice(1)
: t("billingFreeTier")} : t("billingFreeTier")}
</Badge> </Badge>
<Link <Link
@@ -413,7 +449,7 @@ export default function GeneralPage() {
{usageTypes.some((type) => { {usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -441,7 +477,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -530,7 +566,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit( const { item, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -614,7 +650,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -636,7 +672,7 @@ export default function GeneralPage() {
); );
})} })}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */} {/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{subscriptionItems {tierSubscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -672,7 +708,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -687,7 +723,7 @@ export default function GeneralPage() {
return sum + cost; return sum + cost;
}, 0) + }, 0) +
// Add recurring charges // Add recurring charges
subscriptionItems tierSubscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -749,6 +785,56 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* License Keys Section */}
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingLicenseKeys") || "License Keys"}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("billingLicenseKeysDescription") || "Manage your license key subscriptions"}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
<span className="font-semibold">
{t("billingLicenseSubscription") || "License Subscription"}
</span>
</div>
<Badge
variant={
licenseSubscription.subscription?.status === "active"
? "green"
: "outline"
}
>
{licenseSubscription.subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{licenseSubscription.subscription?.status
? licenseSubscription.subscription.status
.charAt(0)
.toUpperCase() +
licenseSubscription.subscription.status.slice(1)
: t("billingInactive") || "Inactive"}
</Badge>
</div>
<SettingsSectionFooter>
<Button
variant="secondary"
onClick={() => handleModifySubscription()}
disabled={isLoading}
>
{t("billingModifyLicenses") || "Modify License Subscription"}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -33,8 +33,11 @@ export function SubscriptionStatusProvider({
}; };
const isActive = () => { const isActive = () => {
if (subscriptionStatus?.subscription?.status === "active") { if (subscriptionStatus?.subscriptions) {
return true; // Check if any subscription is active
return subscriptionStatus.subscriptions.some(
(sub) => sub.subscription?.status === "active"
);
} }
return false; return false;
}; };
@@ -42,15 +45,20 @@ export function SubscriptionStatusProvider({
const getTier = () => { const getTier = () => {
const tierPriceSet = getTierPriceSet(env, sandbox_mode); const tierPriceSet = getTierPriceSet(env, sandbox_mode);
if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) { if (subscriptionStatus?.subscriptions) {
// Iterate through tiers in order (earlier keys are higher tiers) // Iterate through all subscriptions
for (const [tierId, priceId] of Object.entries(tierPriceSet)) { for (const { subscription, items } of subscriptionStatus.subscriptions) {
// Check if any subscription item matches this tier's price ID if (items && items.length > 0) {
const matchingItem = subscriptionStatus.items.find( // Iterate through tiers in order (earlier keys are higher tiers)
(item) => item.priceId === priceId for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
); // Check if any subscription item matches this tier's price ID
if (matchingItem) { const matchingItem = items.find(
return tierId; (item) => item.priceId === priceId
);
if (matchingItem) {
return tierId;
}
}
} }
} }
} }
@@ -83,4 +91,4 @@ export function SubscriptionStatusProvider({
); );
} }
export default SubscriptionStatusProvider; export default SubscriptionStatusProvider;