diff --git a/messages/en-US.json b/messages/en-US.json index 3095c539a..003595b7b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index 82154ab7d..dc3890abe 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({ - 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{" "} ${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 + ); + } + } + } } } } diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 2951a7b6e..b882d7170 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -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` diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 0529622e2..7869374cd 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -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() { {/* Paid License Keys Section */} - {(licenseSubscription || getLicenseKeyCount() > 0) && ( + {licenseSubscription && ( @@ -1561,22 +1555,6 @@ export default function BillingPage() {
-
-
- {t("billingCurrentKeys") || - "Current Keys"} -
-
- - {getLicenseKeyCount()} - - - {getLicenseKeyCount() === 1 - ? "key" - : "keys"} - -
-
+ + + ); +}; + +export default LicenseBillingBanner;