Handle license lifecycle

This commit is contained in:
Owen
2026-02-04 17:37:31 -08:00
parent 1bc4480d84
commit 4613aae47d
4 changed files with 169 additions and 64 deletions

View File

@@ -28,6 +28,9 @@ import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType"; import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
import { sendEmail } from "@server/emails";
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
import config from "@server/lib/config";
export async function handleSubscriptionCreated( export async function handleSubscriptionCreated(
subscription: Stripe.Subscription subscription: Stripe.Subscription
@@ -211,7 +214,7 @@ export async function handleSubscriptionCreated(
); );
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, // this says enterprise but it does both `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -231,7 +234,32 @@ export async function handleSubscriptionCreated(
const data = await response.json(); const data = await response.json();
logger.debug("Fossorial API response:", { data }); logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (customer.email) {
logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
);
await sendEmail(
EnterpriseEditionKeyGenerated({
keyValue: data.data.licenseKey,
personalUseOnly: false,
users: numUsers,
sites: numSites,
}),
{
to: customer.email,
from: config.getNoReplyEmail(),
subject:
"Your Enterprise Edition license key is ready"
}
);
} else {
logger.error(
`No email found for customer ${customer.customerId} to send license key.`
);
}
return data; return data;
} catch (error) { } catch (error) {
console.error("Error creating new license:", error); console.error("Error creating new license:", error);

View File

@@ -26,6 +26,7 @@ import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType"; import { getSubType } from "./getSubType";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionDeleted( export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription subscription: Stripe.Subscription
@@ -76,9 +77,14 @@ export async function handleSubscriptionDeleted(
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
if (type === "saas") { if (type === "saas") {
logger.debug(`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`); logger.debug(
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
await handleSubscriptionLifesycle(customer.orgId, subscription.status); await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
const [orgUserRes] = await db const [orgUserRes] = await db
.select() .select()
@@ -99,8 +105,33 @@ export async function handleSubscriptionDeleted(
} }
} }
} else if (type === "license") { } else if (type === "license") {
logger.debug(`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`); logger.debug(
`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`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId,
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
} }
} catch (error) { } catch (error) {
logger.error( logger.error(

View File

@@ -27,6 +27,7 @@ import { getFeatureIdByMetricId } from "@server/lib/billing/features";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType } from "./getSubType"; import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionUpdated( export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
@@ -57,7 +58,7 @@ export async function handleSubscriptionUpdated(
} }
// get the customer // get the customer
const [existingCustomer] = await db const [customer] = await db
.select() .select()
.from(customers) .from(customers)
.where(eq(customers.customerId, subscription.customer as string)) .where(eq(customers.customerId, subscription.customer as string))
@@ -150,7 +151,7 @@ export async function handleSubscriptionUpdated(
continue; continue;
} }
const orgId = existingCustomer.orgId; const orgId = customer.orgId;
if (!orgId) { if (!orgId) {
logger.warn( logger.warn(
@@ -235,16 +236,41 @@ export async function handleSubscriptionUpdated(
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
if (type === "saas") { if (type === "saas") {
logger.debug(`Handling SAAS subscription lifecycle for org ${existingCustomer.orgId}`); logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses // we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle( await handleSubscriptionLifesycle(
existingCustomer.orgId, customer.orgId,
subscription.status subscription.status
); );
} else { } else {
logger.debug( if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
`Subscription ${subscription.id} is of type ${type}. No lifecycle handling needed.` 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`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
}
} }
} }
} catch (error) { } catch (error) {

View File

@@ -178,20 +178,42 @@ export default function NewPricingLicenseForm({
): Promise<void> => { ): Promise<void> => {
setLoading(true); setLoading(true);
try { try {
const response = await api.put< // Check if this is a business/enterprise license request
AxiosResponse<GenerateNewLicenseResponse> if (!personalUseOnly) {
>(`/org/${orgId}/license`, payload); const response = await api.put<AxiosResponse<string>>(
`/org/${orgId}/license/enterprise`,
{ ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] }
);
if (response.data.data?.licenseKey?.licenseKey) { console.log("Checkout session response:", response.data);
setGeneratedKey(response.data.data.licenseKey.licenseKey); const checkoutUrl = response.data.data;
onGenerated?.(); if (checkoutUrl) {
toast({ window.location.href = checkoutUrl;
title: t("generateLicenseKeyForm.toasts.success.title"), } else {
description: t( toast({
"generateLicenseKeyForm.toasts.success.description" title: "Failed to get checkout URL",
), description: "Please try again later",
variant: "default" variant: "destructive"
}); });
setLoading(false);
}
} else {
// Personal license flow
const response = await api.put<
AxiosResponse<GenerateNewLicenseResponse>
>(`/org/${orgId}/license`, payload);
if (response.data.data?.licenseKey?.licenseKey) {
setGeneratedKey(response.data.data.licenseKey.licenseKey);
onGenerated?.();
toast({
title: t("generateLicenseKeyForm.toasts.success.title"),
description: t(
"generateLicenseKeyForm.toasts.success.description"
),
variant: "default"
});
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -229,44 +251,38 @@ export default function NewPricingLicenseForm({
}); });
}; };
const handleContinueToCheckout = async () => { const onSubmitBusiness = async (values: BusinessFormData) => {
const valid = await businessForm.trigger(); const payload = {
if (!valid) return; email: values.email,
useCaseType: "business",
const values = businessForm.getValues(); personal: undefined,
setLoading(true); business: {
try { firstName: values.firstName,
const tier = TIER_TO_LICENSE_ID[selectedTier]; lastName: values.lastName,
const response = await api.post<AxiosResponse<string>>( jobTitle: "N/A",
`/org/${orgId}/billing/create-checkout-session-license`, aboutYou: {
{ tier } primaryUse: values.primaryUse,
); industry: values.industry,
const checkoutUrl = response.data.data; prospectiveUsers: 100,
if (checkoutUrl) { prospectiveSites: 100
window.location.href = checkoutUrl; },
} else { companyInfo: {
toast({ companyName: values.companyName,
title: t( countryOfResidence: "N/A",
"newPricingLicenseForm.toasts.checkoutError.title" stateProvinceRegion: "N/A",
), postalZipCode: "N/A",
description: t( companyWebsite: values.companyWebsite || "",
"newPricingLicenseForm.toasts.checkoutError.description" companyPhoneNumber: values.companyPhoneNumber || ""
), }
variant: "destructive" },
}); consent: {
setLoading(false); agreedToTerms: values.agreedToTerms,
acknowledgedPrivacyPolicy: values.agreedToTerms,
complianceConfirmed: values.complianceConfirmed
} }
} catch (error) { };
toast({
title: t("newPricingLicenseForm.toasts.checkoutError.title"), await submitLicenseRequest(payload);
description: formatAxiosError(
error,
t("newPricingLicenseForm.toasts.checkoutError.description")
),
variant: "destructive"
});
setLoading(false);
}
}; };
const handleClose = () => { const handleClose = () => {
@@ -608,6 +624,9 @@ export default function NewPricingLicenseForm({
{!personalUseOnly && ( {!personalUseOnly && (
<Form {...businessForm}> <Form {...businessForm}>
<form <form
onSubmit={businessForm.handleSubmit(
onSubmitBusiness
)}
className="space-y-4" className="space-y-4"
id="new-pricing-license-business-form" id="new-pricing-license-business-form"
> >
@@ -877,7 +896,8 @@ export default function NewPricingLicenseForm({
{!generatedKey && !personalUseOnly && ( {!generatedKey && !personalUseOnly && (
<Button <Button
onClick={handleContinueToCheckout} type="submit"
form="new-pricing-license-business-form"
disabled={loading} disabled={loading}
loading={loading} loading={loading}
> >