mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-06 18:56:39 +00:00
Billing licenses working
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user