mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-26 14:56:39 +00:00
Handle license lifecycle
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user