Update how billing is presented for license keys

This commit is contained in:
Owen
2026-09-16 11:05:36 -04:00
parent 1dfe0124be
commit 76a4f50ccf
9 changed files with 170 additions and 39 deletions
+3
View File
@@ -234,6 +234,9 @@
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"licenseBillingBannerTitle": "Manage License Billing",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
</EmailSection>
<EmailText>
If you need to purchase additional license keys or
modify your existing license, please reach out to
our support team at{" "}
For any questions or concerns regarding your license
or billing, please reach out to support at{" "}
<a
href="mailto:support@pangolin.net"
className="text-primary font-medium"
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
);
// Grace period of 5 days added on top of the current billing
// period end (usually ~1 year out) before the license expires
const currentPeriodEnd =
fullSubscription.items.data[0]?.current_period_end;
const expiresAt =
(currentPeriodEnd ?? subscription.created) +
5 * 24 * 60 * 60;
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
paidFor: true,
users: numUsers,
sites: numSites,
tier: tier
tier: tier,
expiresAt: expiresAt
})
}
);
@@ -125,8 +125,6 @@ export async function handleSubscriptionDeleted(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
@@ -139,6 +137,7 @@ export async function handleSubscriptionDeleted(
},
body: JSON.stringify({
orgId: customer.orgId,
licenseKeyId: subscription.metadata.licenseKeyId
})
}
);
@@ -23,7 +23,10 @@ import {
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
import {
getFeatureIdByMetricId,
getFeatureIdByPriceId
} from "@server/lib/billing/features";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType, SubscriptionType } from "./getSubType";
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
.limit(1);
const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null;
const previousType =
existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it.
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
);
await handleTierChange(customer.orgId, type, previousType ?? undefined);
await handleTierChange(
customer.orgId,
type,
previousType ?? undefined
);
}
// Upsert subscription items
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
let featureId: string | null =
getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId
if (!featureId) {
@@ -302,13 +311,19 @@ export async function handleSubscriptionUpdated(
logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
);
await handleTierChange(customer.orgId, null, previousType ?? undefined);
await handleTierChange(
customer.orgId,
null,
previousType ?? undefined
);
}
} else if (type === "license") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
if (
effectiveStatus === "canceled" ||
effectiveStatus == "unpaid" ||
effectiveStatus == "incomplete_expired"
) {
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
@@ -320,7 +335,9 @@ export async function handleSubscriptionUpdated(
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId
orgId: customer.orgId,
licenseKeyId:
subscription.metadata.licenseKeyId
})
}
);
@@ -330,6 +347,69 @@ export async function handleSubscriptionUpdated(
error
);
}
} else if (effectiveStatus === "active" && previousAttributes) {
// Detect a successful renewal: the billing period rolled
// forward (the invoice was paid and the new period began
// right where the previous one ended).
const currentItem = fullSubscription.items.data[0];
const prevItems = previousAttributes.items?.data;
const prevItem = Array.isArray(prevItems)
? prevItems.find(
(pi: any) => pi.id === currentItem?.id
)
: undefined;
const renewed =
currentItem &&
prevItem?.current_period_end &&
currentItem.current_period_start ===
prevItem.current_period_end &&
currentItem.current_period_start >
prevItem.current_period_start;
if (renewed) {
const licenseKeyId =
subscription.metadata.licenseKeyId;
if (!licenseKeyId) {
logger.error(
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
);
} else {
// Grace period of 5 days added on top of the new
// billing period end (usually ~1 year out)
const expiresAt =
currentItem.current_period_end +
5 * 24 * 60 * 60;
try {
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseKeyId),
expiresAt: expiresAt
})
}
);
logger.info(
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
error
);
}
}
}
}
}
}
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
);
}
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
// check if we already have a customer for this org
const [customer] = await db
.select()
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
], // Start with the standard feature set that matches the free limits
customer: customer.customerId,
mode: "subscription",
subscription_data: {
description: licenseKeyValue
? `License ${licenseKeyValue}`
: `License key ID ${keyId}`,
metadata: {
licenseKeyId: keyId.toString(),
licenseKey: licenseKeyValue ?? "",
tier: licenseData.tier
}
},
allow_promotion_codes: true,
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0;
};
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (
targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection>
{/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && (
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data;
} catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;