From 158d7b23d89b937fa106923341d87e0c177c1a82 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 14:13:25 -0800 Subject: [PATCH 01/15] Add test button to launch stripe --- server/lib/billing/licenses.ts | 37 ++++++ .../billing/createCheckoutSessionLicense.ts | 113 ++++++++++++++++++ ...ession.ts => createCheckoutSessionSAAS.ts} | 4 +- server/private/routers/billing/index.ts | 3 +- server/private/routers/external.ts | 12 +- .../settings/(private)/billing/page.tsx | 2 +- src/components/GenerateLicenseKeyForm.tsx | 61 ++++++++-- 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 server/lib/billing/licenses.ts create mode 100644 server/private/routers/billing/createCheckoutSessionLicense.ts rename server/private/routers/billing/{createCheckoutSession.ts => createCheckoutSessionSAAS.ts} (96%) diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts new file mode 100644 index 00000000..a481527e --- /dev/null +++ b/server/lib/billing/licenses.ts @@ -0,0 +1,37 @@ +export enum LicenseId { + SMALL_LICENSE = "small_license", + BIG_LICENSE = "big_license" +} + +export type LicensePriceSet = { + [key in LicenseId]: string; +}; + +export const licensePriceSet: LicensePriceSet = { + // Free license matches the freeLimitSet + [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", + [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" +}; + +export const licensePriceSetSandbox: LicensePriceSet = { + // Free license matches the freeLimitSet + // when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value + [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", + [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" +}; + +export function getLicensePriceSet( + environment?: string, + sandbox_mode?: boolean +): LicensePriceSet { + if ( + (process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true") || + (environment === "prod" && sandbox_mode !== true) + ) { + // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE + return licensePriceSet; + } else { + return licensePriceSetSandbox; + } +} diff --git a/server/private/routers/billing/createCheckoutSessionLicense.ts b/server/private/routers/billing/createCheckoutSessionLicense.ts new file mode 100644 index 00000000..045f1797 --- /dev/null +++ b/server/private/routers/billing/createCheckoutSessionLicense.ts @@ -0,0 +1,113 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { customers, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { fromError } from "zod-validation-error"; +import stripe from "#private/lib/stripe"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; + +const createCheckoutSessionParamsSchema = z.strictObject({ + orgId: z.string(), +}); + +const createCheckoutSessionBodySchema = z.strictObject({ + tier: z.enum([LicenseId.BIG_LICENSE, LicenseId.SMALL_LICENSE]), +}); + +export async function createCheckoutSessionoLicense( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = createCheckoutSessionParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { tier } = parsedBody.data; + + // check if we already have a customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + // If we don't have a customer, create one + if (!customer) { + // error + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + const tierPrice = getLicensePriceSet()[tier] + + const session = await stripe!.checkout.sessions.create({ + client_reference_id: orgId, // So we can look it up the org later on the webhook + billing_address_collection: "required", + line_items: [ + { + price: tierPrice, // Use the standard tier + quantity: 1 + }, + ], // Start with the standard feature set that matches the free limits + customer: customer.customerId, + mode: "subscription", + 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` + }); + + return response(res, { + data: session.url, + success: true, + error: false, + message: "Checkout session created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSessionSAAS.ts similarity index 96% rename from server/private/routers/billing/createCheckoutSession.ts rename to server/private/routers/billing/createCheckoutSessionSAAS.ts index a2d8080f..0f9b783e 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSessionSAAS.ts @@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() }); -export async function createCheckoutSession( +export async function createCheckoutSessionSAAS( req: Request, res: Response, next: NextFunction @@ -87,7 +87,7 @@ export async function createCheckoutSession( data: session.url, success: true, error: false, - message: "Organization created successfully", + message: "Checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index 59fce8d6..aef867af 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -11,8 +11,9 @@ * This file is not licensed under the AGPLv3. */ -export * from "./createCheckoutSession"; +export * from "./createCheckoutSessionSAAS"; export * from "./createPortalSession"; export * from "./getOrgSubscription"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; +export * from "./createCheckoutSessionLicense"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index cf6e58bc..9ad0609f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -159,11 +159,19 @@ if (build === "saas") { ); authenticated.post( - "/org/:orgId/billing/create-checkout-session", + "/org/:orgId/billing/create-checkout-session-saas", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), - billing.createCheckoutSession + billing.createCheckoutSessionSAAS + ); + + authenticated.post( + "/org/:orgId/billing/create-checkout-session-license", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + billing.createCheckoutSessionoLicense ); authenticated.post( diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 1ed5c094..e63eebcc 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -121,7 +121,7 @@ export default function GeneralPage() { setIsLoading(true); try { const response = await api.post>( - `/org/${org.org.orgId}/billing/create-checkout-session`, + `/org/${org.org.orgId}/billing/create-checkout-session-saas`, {} ); console.log("Checkout session response:", response.data); diff --git a/src/components/GenerateLicenseKeyForm.tsx b/src/components/GenerateLicenseKeyForm.tsx index 6a380082..6a5aaf54 100644 --- a/src/components/GenerateLicenseKeyForm.tsx +++ b/src/components/GenerateLicenseKeyForm.tsx @@ -345,6 +345,37 @@ export default function GenerateLicenseKeyForm({ resetForm(); }; + const handleTestCheckout = async () => { + setLoading(true); + try { + const response = await api.post>( + `/org/${orgId}/billing/create-checkout-session-license`, + { + tier: "big_license" + } + ); + console.log("Checkout session response:", response.data); + const checkoutUrl = response.data.data; + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + toast({ + title: "Failed to get checkout URL", + description: "Please try again later", + variant: "destructive" + }); + setLoading(false); + } + } catch (error) { + toast({ + title: "Checkout error", + description: formatAxiosError(error), + variant: "destructive" + }); + setLoading(false); + } + }; + return ( @@ -1066,16 +1097,26 @@ export default function GenerateLicenseKeyForm({ )} {!generatedKey && useCaseType === "business" && ( - + <> + + + )} From 5a3d75ca12d46853e67de7775631ae304dc33130 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 4 Feb 2026 15:19:53 -0800 Subject: [PATCH 02/15] add quantity check --- server/license/license.ts | 8 +++- server/private/license/license.ts | 51 +++++++++++++++++++++--- server/routers/generatedLicense/types.ts | 1 + 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/server/license/license.ts b/server/license/license.ts index cfa45d7c..7c960984 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -12,6 +12,10 @@ export type LicenseStatus = { isLicenseValid: boolean; // Is the license key valid? hostId: string; // Host ID tier?: LicenseKeyTier; + maxSites?: number; + usedSites?: number; + maxUsers?: number; + usedUsers?: number; }; export type LicenseKeyCache = { @@ -22,12 +26,14 @@ export type LicenseKeyCache = { type?: LicenseKeyType; tier?: LicenseKeyTier; terminateAt?: Date; + quantity?: number; + quantity_2?: number; }; export class License { private serverSecret!: string; - constructor(private hostMeta: HostMeta) {} + constructor(private hostMeta: HostMeta) { } public async check(): Promise { return { diff --git a/server/private/license/license.ts b/server/private/license/license.ts index f8f774c6..972dbc82 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -11,12 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, HostMeta } from "@server/db"; +import { db, HostMeta, sites, users } from "@server/db"; import { hostMeta, licenseKey } from "@server/db"; import logger from "@server/logger"; import NodeCache from "node-cache"; import { validateJWT } from "./licenseJwt"; -import { eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import moment from "moment"; import { encrypt, decrypt } from "@server/lib/crypto"; import { @@ -54,6 +54,7 @@ type TokenPayload = { type: LicenseKeyType; tier: LicenseKeyTier; quantity: number; + quantity_2: number; terminateAt: string; // ISO iat: number; // Issued at }; @@ -140,10 +141,20 @@ LQIDAQAB }; } + // Count used sites and users for license comparison + const [siteCountRes] = await db + .select({ value: count() }) + .from(sites); + const [userCountRes] = await db + .select({ value: count() }) + .from(users); + const status: LicenseStatus = { hostId: this.hostMeta.hostMetaId, isHostLicensed: true, - isLicenseValid: false + isLicenseValid: false, + usedSites: siteCountRes?.value ?? 0, + usedUsers: userCountRes?.value ?? 0 }; this.checkInProgress = true; @@ -151,6 +162,8 @@ LQIDAQAB try { if (!this.doRecheck && this.statusCache.has(this.statusKey)) { const res = this.statusCache.get("status") as LicenseStatus; + res.usedSites = status.usedSites; + res.usedUsers = status.usedUsers; return res; } logger.debug("Checking license status..."); @@ -193,7 +206,9 @@ LQIDAQAB type: payload.type, tier: payload.tier, iat: new Date(payload.iat * 1000), - terminateAt: new Date(payload.terminateAt) + terminateAt: new Date(payload.terminateAt), + quantity: payload.quantity, + quantity_2: payload.quantity_2 }); if (payload.type === "host") { @@ -292,6 +307,8 @@ LQIDAQAB cached.tier = payload.tier; cached.iat = new Date(payload.iat * 1000); cached.terminateAt = new Date(payload.terminateAt); + cached.quantity = payload.quantity; + cached.quantity_2 = payload.quantity_2; // Encrypt the updated token before storing const encryptedKey = encrypt( @@ -317,7 +334,7 @@ LQIDAQAB } } - // Compute host status + // Compute host status: quantity = users, quantity_2 = sites for (const key of keys) { const cached = newCache.get(key.licenseKey)!; @@ -329,6 +346,28 @@ LQIDAQAB if (!cached.valid) { continue; } + + // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) + if ( + cached.quantity_2 !== undefined && + cached.quantity_2 >= 0 + ) { + status.maxSites = + (status.maxSites ?? 0) + cached.quantity_2; + } + if (cached.quantity !== undefined && cached.quantity >= 0) { + status.maxUsers = (status.maxUsers ?? 0) + cached.quantity; + } + } + + // Invalidate license if over user or site limits + if ( + (status.maxSites !== undefined && + (status.usedSites ?? 0) > status.maxSites) || + (status.maxUsers !== undefined && + (status.usedUsers ?? 0) > status.maxUsers) + ) { + status.isLicenseValid = false; } // Invalidate old cache and set new cache @@ -502,7 +541,7 @@ LQIDAQAB // Calculate exponential backoff delay const retryDelay = Math.floor( initialRetryDelay * - Math.pow(exponentialFactor, attempt - 1) + Math.pow(exponentialFactor, attempt - 1) ); logger.debug( diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index 76e86265..d05da2de 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -19,6 +19,7 @@ export type NewLicenseKey = { tier: string; type: string; quantity: number; + quantity_2: number; isValid: boolean; updatedAt: string; createdAt: string; From 34b914f509262be2059dcddabfcb550b92acb884 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 4 Feb 2026 15:38:02 -0800 Subject: [PATCH 03/15] add license email --- .../EnterpriseEditionKeyGenerated.tsx | 118 ++++++++++++++++++ .../templates/components/CopyCodeBox.tsx | 14 ++- 2 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 server/emails/templates/EnterpriseEditionKeyGenerated.tsx diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx new file mode 100644 index 00000000..44472c8a --- /dev/null +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; +import CopyCodeBox from "./components/CopyCodeBox"; +import ButtonLink from "./components/ButtonLink"; + +type EnterpriseEditionKeyGeneratedProps = { + keyValue: string; + personalUseOnly: boolean; + users: number; + sites: number; + modifySubscriptionLink?: string; +}; + +export const EnterpriseEditionKeyGenerated = ({ + keyValue, + personalUseOnly, + users, + sites, + modifySubscriptionLink +}: EnterpriseEditionKeyGeneratedProps) => { + const previewText = personalUseOnly + ? "Your Enterprise Edition key for personal use is ready" + : "Thank you for your purchase — your Enterprise Edition key is ready"; + + return ( + + + {previewText} + + + + + + Hi there, + + {personalUseOnly ? ( + + Your Enterprise Edition license key has been + generated. Qualifying users can use the + Enterprise Edition for free for{" "} + personal use only. + + ) : ( + <> + + Thank you for your purchase. Your Enterprise + Edition license key is ready. Below are the + terms of your license. + + + {modifySubscriptionLink && ( + + + Modify subscription + + + )} + + )} + + + Your license key: + + + + + If you need to purchase additional license keys or + modify your existing license, please reach out to + our support team at{" "} + + support@pangolin.net + + . + + + + + + + + + + ); +}; + +export default EnterpriseEditionKeyGenerated; diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx index 3e4d1d08..497fe7a9 100644 --- a/server/emails/templates/components/CopyCodeBox.tsx +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -1,6 +1,14 @@ import React from "react"; -export default function CopyCodeBox({ text }: { text: string }) { +const DEFAULT_HINT = "Copy and paste this code when prompted"; + +export default function CopyCodeBox({ + text, + hint +}: { + text: string; + hint?: string; +}) { return (
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) { {text}
-

- Copy and paste this code when prompted -

+

{hint ?? DEFAULT_HINT}

); } From a5c7913e771e91a38ba68c984bdd22dc4f1b5d31 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 15:49:40 -0800 Subject: [PATCH 04/15] Checkout flow works --- server/private/lib/readConfigFile.ts | 9 +- .../routers/billing/hooks/getSubType.ts | 35 +++++++ .../hooks/handleSubscriptionCreated.ts | 92 ++++++++++++++++--- server/private/routers/billing/index.ts | 1 - server/private/routers/external.ts | 16 ++-- .../generateNewEnterpriseLicense.ts} | 80 +++++++++++----- .../generatedLicense/generateNewLicense.ts | 37 +++++++- .../private/routers/generatedLicense/index.ts | 1 + .../generatedLicense/listGeneratedLicenses.ts | 2 +- src/components/GenerateLicenseKeyForm.tsx | 88 +++++++----------- 10 files changed, 256 insertions(+), 105 deletions(-) create mode 100644 server/private/routers/billing/hooks/getSubType.ts rename server/private/routers/{billing/createCheckoutSessionLicense.ts => generatedLicense/generateNewEnterpriseLicense.ts} (57%) diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 3fa7f060..0211a330 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -45,6 +45,10 @@ export const privateConfigSchema = z.object({ .string() .optional() .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), fossorial_api_key: z .string() .optional() @@ -164,7 +168,10 @@ export const privateConfigSchema = z.object({ .optional(), stripe: z .object({ - secret_key: z.string().optional().transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), webhook_secret: z .string() .optional() diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts new file mode 100644 index 00000000..8cd07713 --- /dev/null +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -0,0 +1,35 @@ +import { + getLicensePriceSet, +} from "@server/lib/billing/licenses"; +import { + getTierPriceSet, +} from "@server/lib/billing/tiers"; +import Stripe from "stripe"; + +export function getSubType(fullSubscription: Stripe.Response): "saas" | "license" { + // Determine subscription type by checking subscription items + let type: "saas" | "license" = "saas"; + if (Array.isArray(fullSubscription.items?.data)) { + for (const item of fullSubscription.items.data) { + const priceId = item.price.id; + + // Check if price ID matches any license price + const licensePrices = Object.values(getLicensePriceSet()); + + if (licensePrices.includes(priceId)) { + type = "license"; + break; + } + + // Check if price ID matches any tier price (saas) + const tierPrices = Object.values(getTierPriceSet()); + + if (tierPrices.includes(priceId)) { + type = "saas"; + break; + } + } + } + + return type; +} diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 223a2545..431798dd 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -25,6 +25,8 @@ import logger from "@server/logger"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import privateConfig from "#private/lib/config"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription @@ -123,24 +125,86 @@ export async function handleSubscriptionCreated( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + const type = getSubType(fullSubscription); + if (type === "saas") { + 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 + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status + ); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Subscribed); + if (email) { + moveEmailToAudience(email, AudienceIds.Subscribed); + } + } + } else if (type === "license") { + logger.debug( + `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` + ); + + // Retrieve the client_reference_id from the checkout session + let licenseId: string | null = null; + + try { + const sessions = await stripe!.checkout.sessions.list({ + subscription: subscription.id, + limit: 1 + }); + if (sessions.data.length > 0) { + licenseId = sessions.data[0].client_reference_id || null; + } + + if (!licenseId) { + logger.error( + `No client_reference_id found for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}` + ); + + const response = await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, // this says enterprise but it does both + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseId: parseInt(licenseId) + }) + } + ); + + const data = await response.json(); + + logger.debug("Fossorial API response:", { data }); + return data; + } catch (error) { + console.error("Error creating new license:", error); + throw error; } } } catch (error) { diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index aef867af..106f3e43 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -16,4 +16,3 @@ export * from "./createPortalSession"; export * from "./getOrgSubscription"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; -export * from "./createCheckoutSessionLicense"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 9ad0609f..3377db46 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -166,14 +166,6 @@ if (build === "saas") { billing.createCheckoutSessionSAAS ); - authenticated.post( - "/org/:orgId/billing/create-checkout-session-license", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.billing), - logActionAudit(ActionsEnum.billing), - billing.createCheckoutSessionoLicense - ); - authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, @@ -208,6 +200,14 @@ if (build === "saas") { generateLicense.generateNewLicense ); + authenticated.put( + "/org/:orgId/license/enterprise", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + generateLicense.generateNewEnterpriseLicense + ); + authenticated.post( "/send-support-request", rateLimit({ diff --git a/server/private/routers/billing/createCheckoutSessionLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts similarity index 57% rename from server/private/routers/billing/createCheckoutSessionLicense.ts rename to server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 045f1797..7cffb9d7 100644 --- a/server/private/routers/billing/createCheckoutSessionLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -12,33 +12,33 @@ */ import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { customers, db } from "@server/db"; -import { eq } from "drizzle-orm"; -import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import privateConfig from "#private/lib/config"; +import { createNewLicense } from "./generateNewLicense"; import config from "@server/lib/config"; -import { fromError } from "zod-validation-error"; -import stripe from "#private/lib/stripe"; import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; +import stripe from "#private/lib/stripe"; +import { customers, db } from "@server/db"; +import { fromError } from "zod-validation-error"; +import z from "zod"; +import { eq } from "drizzle-orm"; +import { log } from "winston"; -const createCheckoutSessionParamsSchema = z.strictObject({ - orgId: z.string(), +const generateNewEnterpriseLicenseParamsSchema = z.strictObject({ + orgId: z.string() }); -const createCheckoutSessionBodySchema = z.strictObject({ - tier: z.enum([LicenseId.BIG_LICENSE, LicenseId.SMALL_LICENSE]), -}); - -export async function createCheckoutSessionoLicense( +export async function generateNewEnterpriseLicense( req: Request, res: Response, next: NextFunction ): Promise { try { - const parsedParams = createCheckoutSessionParamsSchema.safeParse(req.params); + + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( @@ -50,17 +50,49 @@ export async function createCheckoutSessionoLicense( const { orgId } = parsedParams.data; - const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); - if (!parsedBody.success) { + if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() + "Organization ID is required" ) ); } - const { tier } = parsedBody.data; + logger.debug(`Generating new license for orgId: ${orgId}`); + + const licenseData = req.body; + + if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid tier specified. Must be either 'big_license' or 'small_license'." + ) + ); + } + + const apiResponse = await createNewLicense(orgId, licenseData); + + // Check if the API call was successful + if (!apiResponse.success || apiResponse.error) { + return next( + createHttpError( + apiResponse.status || HttpCode.BAD_REQUEST, + apiResponse.message || "Failed to create license from Fossorial API" + ) + ); + } + + const keyId = apiResponse?.data?.licenseKey?.id; + if (!keyId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Fossorial API did not return a valid license key ID" + ) + ); + } // check if we already have a customer for this org const [customer] = await db @@ -80,10 +112,11 @@ export async function createCheckoutSessionoLicense( ); } + const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; const tierPrice = getLicensePriceSet()[tier] const session = await stripe!.checkout.sessions.create({ - client_reference_id: orgId, // So we can look it up the org later on the webhook + client_reference_id: keyId.toString(), billing_address_collection: "required", line_items: [ { @@ -97,17 +130,20 @@ export async function createCheckoutSessionoLicense( cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` }); - return response(res, { + return sendResponse(res, { data: session.url, success: true, error: false, - message: "Checkout session created successfully", + message: "License and checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while generating new license." + ) ); } } diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 2c0c4420..9835f40a 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; -async function createNewLicense(orgId: string, licenseData: any): Promise { +export interface CreateNewLicenseResponse { + data: Data + success: boolean + error: boolean + message: string + status: number +} + +export interface Data { + licenseKey: LicenseKey +} + +export interface LicenseKey { + id: number + instanceName: any + instanceId: string + licenseKey: string + tier: string + type: string + quantity: number + quantity_2: number + isValid: boolean + updatedAt: string + createdAt: string + expiresAt: string + paidFor: boolean + orgId: string + metadata: string +} + +export async function createNewLicense(orgId: string, licenseData: any): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both { method: "PUT", headers: { @@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise { } ); - const data = await response.json(); + const data: CreateNewLicenseResponse = await response.json(); - logger.debug("Fossorial API response:", { data }); return data; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts index 83d88634..70b9b001 100644 --- a/server/private/routers/generatedLicense/index.ts +++ b/server/private/routers/generatedLicense/index.ts @@ -13,3 +13,4 @@ export * from "./listGeneratedLicenses"; export * from "./generateNewLicense"; +export * from "./generateNewEnterpriseLicense"; diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index fb54c763..cb930882 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -25,7 +25,7 @@ import { async function fetchLicenseKeys(orgId: string): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { diff --git a/src/components/GenerateLicenseKeyForm.tsx b/src/components/GenerateLicenseKeyForm.tsx index 6a5aaf54..7274004c 100644 --- a/src/components/GenerateLicenseKeyForm.tsx +++ b/src/components/GenerateLicenseKeyForm.tsx @@ -250,20 +250,41 @@ export default function GenerateLicenseKeyForm({ const submitLicenseRequest = async (payload: any) => { setLoading(true); try { - const response = await api.put< - AxiosResponse - >(`/org/${orgId}/license`, payload); + // Check if this is a business/enterprise license request + if (payload.useCaseType === "business") { + const response = await api.put< + AxiosResponse + >(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } ); - 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" - }); + console.log("Checkout session response:", response.data); + const checkoutUrl = response.data.data; + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + toast({ + title: "Failed to get checkout URL", + description: "Please try again later", + variant: "destructive" + }); + setLoading(false); + } + } else { + // Personal license flow + const response = await api.put< + AxiosResponse + >(`/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) { console.error(e); @@ -345,37 +366,6 @@ export default function GenerateLicenseKeyForm({ resetForm(); }; - const handleTestCheckout = async () => { - setLoading(true); - try { - const response = await api.post>( - `/org/${orgId}/billing/create-checkout-session-license`, - { - tier: "big_license" - } - ); - console.log("Checkout session response:", response.data); - const checkoutUrl = response.data.data; - if (checkoutUrl) { - window.location.href = checkoutUrl; - } else { - toast({ - title: "Failed to get checkout URL", - description: "Please try again later", - variant: "destructive" - }); - setLoading(false); - } - } catch (error) { - toast({ - title: "Checkout error", - description: formatAxiosError(error), - variant: "destructive" - }); - setLoading(false); - } - }; - return ( @@ -1097,15 +1087,6 @@ export default function GenerateLicenseKeyForm({ )} {!generatedKey && useCaseType === "business" && ( - <> - + + + {!generatedKey && personalUseOnly && ( + + )} + + {!generatedKey && !personalUseOnly && ( + + )} + + + + ); +} From 1bc4480d849b138f075b62f6c37249a0da5efaf6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 16:32:39 -0800 Subject: [PATCH 06/15] Working on complete auth flow --- .../hooks/handleSubscriptionCreated.ts | 33 ++++++++++++- .../hooks/handleSubscriptionDeleted.ts | 46 +++++++++++++------ .../hooks/handleSubscriptionUpdated.ts | 24 +++++++--- server/routers/generatedLicense/types.ts | 2 + src/components/GenerateLicenseKeysTable.tsx | 42 +++++++++++++++++ 5 files changed, 125 insertions(+), 22 deletions(-) diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 431798dd..4370d088 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -27,6 +27,7 @@ import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { getSubType } from "./getSubType"; import privateConfig from "#private/lib/config"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription @@ -182,6 +183,33 @@ export async function handleSubscriptionCreated( `Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}` ); + // Determine users and sites based on license type + const priceSet = getLicensePriceSet(); + const subscriptionPriceId = + fullSubscription.items.data[0]?.price.id; + + let numUsers: number; + let numSites: number; + + if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) { + numUsers = 25; + numSites = 25; + } else if ( + subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] + ) { + numUsers = 50; + numSites = 50; + } else { + logger.error( + `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` + ); + const response = await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, // this says enterprise but it does both { @@ -193,7 +221,10 @@ export async function handleSubscriptionCreated( "Content-Type": "application/json" }, body: JSON.stringify({ - licenseId: parseInt(licenseId) + licenseId: parseInt(licenseId), + paidFor: true, + users: numUsers, + sites: numSites }) } ); diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 7a7d9149..56fca02b 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -24,11 +24,21 @@ import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import stripe from "#private/lib/stripe"; export async function handleSubscriptionDeleted( subscription: Stripe.Subscription ): Promise { try { + // Fetch the subscription from Stripe with expanded price.tiers + const fullSubscription = await stripe!.subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price.tiers"] + } + ); + const [existingSubscription] = await db .select() .from(subscriptions) @@ -64,25 +74,33 @@ export async function handleSubscriptionDeleted( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + const type = getSubType(fullSubscription); + if (type === "saas") { + logger.debug(`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + await handleSubscriptionLifesycle(customer.orgId, subscription.status); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Churned); + if (email) { + moveEmailToAudience(email, AudienceIds.Churned); + } } + } else if (type === "license") { + logger.debug(`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`); + } } catch (error) { logger.error( diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 01086054..8e6f901e 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -26,6 +26,7 @@ import logger from "@server/logger"; import { getFeatureIdByMetricId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; +import { getSubType } from "./getSubType"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, @@ -74,11 +75,6 @@ export async function handleSubscriptionUpdated( }) .where(eq(subscriptions.subscriptionId, subscription.id)); - await handleSubscriptionLifesycle( - existingCustomer.orgId, - subscription.status - ); - // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { const itemsToUpsert = fullSubscription.items.data.map((item) => ({ @@ -141,14 +137,14 @@ export async function handleSubscriptionUpdated( // This item has cycled const meterId = item.plan.meter; if (!meterId) { - logger.warn( + logger.debug( `No meterId found for subscription item ${item.id}. Skipping usage reset.` ); continue; } const featureId = getFeatureIdByMetricId(meterId); if (!featureId) { - logger.warn( + logger.debug( `No featureId found for meterId ${meterId}. Skipping usage reset.` ); continue; @@ -236,6 +232,20 @@ export async function handleSubscriptionUpdated( } } // --- end usage update --- + + const type = getSubType(fullSubscription); + if (type === "saas") { + logger.debug(`Handling SAAS subscription lifecycle for org ${existingCustomer.orgId}`); + // we only need to handle the limit lifecycle for saas subscriptions not for the licenses + await handleSubscriptionLifesycle( + existingCustomer.orgId, + subscription.status + ); + } else { + logger.debug( + `Subscription ${subscription.id} is of type ${type}. No lifecycle handling needed.` + ); + } } } catch (error) { logger.error( diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index d05da2de..d78f2332 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -6,6 +6,8 @@ export type GeneratedLicenseKey = { createdAt: string; tier: string; type: string; + users: number; + sites: number; }; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index c6db4e1d..036b2fb5 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -158,6 +158,48 @@ export default function GenerateLicenseKeysTable({ : t("licenseTierPersonal"); } }, + { + accessorKey: "users", + friendlyName: t("users"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const users = row.original.users; + return users === -1 ? "∞" : users; + } + }, + { + accessorKey: "sites", + friendlyName: t("sites"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const sites = row.original.sites; + return sites === -1 ? "∞" : sites; + } + }, { accessorKey: "terminateAt", friendlyName: t("licenseTableValidUntil"), From 4613aae47dca71ceae77fb19cd0d3d4729e8e9d8 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 17:37:31 -0800 Subject: [PATCH 07/15] Handle license lifecycle --- .../hooks/handleSubscriptionCreated.ts | 32 ++++- .../hooks/handleSubscriptionDeleted.ts | 39 +++++- .../hooks/handleSubscriptionUpdated.ts | 40 +++++- src/components/NewPricingLicenseForm.tsx | 122 ++++++++++-------- 4 files changed, 169 insertions(+), 64 deletions(-) diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 4370d088..9f2ee2ad 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -28,6 +28,9 @@ import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { getSubType } from "./getSubType"; import privateConfig from "#private/lib/config"; 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( subscription: Stripe.Subscription @@ -211,7 +214,7 @@ export async function handleSubscriptionCreated( ); 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", headers: { @@ -231,7 +234,32 @@ export async function handleSubscriptionCreated( 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; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 56fca02b..003110aa 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -26,6 +26,7 @@ import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { getSubType } from "./getSubType"; import stripe from "#private/lib/stripe"; +import privateConfig from "#private/lib/config"; export async function handleSubscriptionDeleted( subscription: Stripe.Subscription @@ -76,9 +77,14 @@ export async function handleSubscriptionDeleted( const type = getSubType(fullSubscription); 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 .select() @@ -99,8 +105,33 @@ export async function handleSubscriptionDeleted( } } } 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) { logger.error( diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 8e6f901e..21943354 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -27,6 +27,7 @@ import { getFeatureIdByMetricId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { getSubType } from "./getSubType"; +import privateConfig from "#private/lib/config"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, @@ -57,7 +58,7 @@ export async function handleSubscriptionUpdated( } // get the customer - const [existingCustomer] = await db + const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) @@ -150,7 +151,7 @@ export async function handleSubscriptionUpdated( continue; } - const orgId = existingCustomer.orgId; + const orgId = customer.orgId; if (!orgId) { logger.warn( @@ -235,16 +236,41 @@ export async function handleSubscriptionUpdated( const type = getSubType(fullSubscription); 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 await handleSubscriptionLifesycle( - existingCustomer.orgId, + customer.orgId, subscription.status ); } else { - logger.debug( - `Subscription ${subscription.id} is of type ${type}. No lifecycle handling needed.` - ); + if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "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`, + { + 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) { diff --git a/src/components/NewPricingLicenseForm.tsx b/src/components/NewPricingLicenseForm.tsx index cc2c9286..d4f76bb9 100644 --- a/src/components/NewPricingLicenseForm.tsx +++ b/src/components/NewPricingLicenseForm.tsx @@ -178,20 +178,42 @@ export default function NewPricingLicenseForm({ ): Promise => { setLoading(true); try { - const response = await api.put< - AxiosResponse - >(`/org/${orgId}/license`, payload); + // Check if this is a business/enterprise license request + if (!personalUseOnly) { + const response = await api.put>( + `/org/${orgId}/license/enterprise`, + { ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] } + ); - 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" - }); + console.log("Checkout session response:", response.data); + const checkoutUrl = response.data.data; + if (checkoutUrl) { + window.location.href = checkoutUrl; + } else { + toast({ + title: "Failed to get checkout URL", + description: "Please try again later", + variant: "destructive" + }); + setLoading(false); + } + } else { + // Personal license flow + const response = await api.put< + AxiosResponse + >(`/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) { console.error(e); @@ -229,44 +251,38 @@ export default function NewPricingLicenseForm({ }); }; - const handleContinueToCheckout = async () => { - const valid = await businessForm.trigger(); - if (!valid) return; - - const values = businessForm.getValues(); - setLoading(true); - try { - const tier = TIER_TO_LICENSE_ID[selectedTier]; - const response = await api.post>( - `/org/${orgId}/billing/create-checkout-session-license`, - { tier } - ); - const checkoutUrl = response.data.data; - if (checkoutUrl) { - window.location.href = checkoutUrl; - } else { - toast({ - title: t( - "newPricingLicenseForm.toasts.checkoutError.title" - ), - description: t( - "newPricingLicenseForm.toasts.checkoutError.description" - ), - variant: "destructive" - }); - setLoading(false); + const onSubmitBusiness = async (values: BusinessFormData) => { + const payload = { + email: values.email, + useCaseType: "business", + personal: undefined, + business: { + firstName: values.firstName, + lastName: values.lastName, + jobTitle: "N/A", + aboutYou: { + primaryUse: values.primaryUse, + industry: values.industry, + prospectiveUsers: 100, + prospectiveSites: 100 + }, + companyInfo: { + companyName: values.companyName, + countryOfResidence: "N/A", + stateProvinceRegion: "N/A", + postalZipCode: "N/A", + companyWebsite: values.companyWebsite || "", + companyPhoneNumber: values.companyPhoneNumber || "" + } + }, + consent: { + agreedToTerms: values.agreedToTerms, + acknowledgedPrivacyPolicy: values.agreedToTerms, + complianceConfirmed: values.complianceConfirmed } - } catch (error) { - toast({ - title: t("newPricingLicenseForm.toasts.checkoutError.title"), - description: formatAxiosError( - error, - t("newPricingLicenseForm.toasts.checkoutError.description") - ), - variant: "destructive" - }); - setLoading(false); - } + }; + + await submitLicenseRequest(payload); }; const handleClose = () => { @@ -608,6 +624,9 @@ export default function NewPricingLicenseForm({ {!personalUseOnly && (
@@ -877,7 +896,8 @@ export default function NewPricingLicenseForm({ {!generatedKey && !personalUseOnly && ( + + + + )} ); } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index 85802cfa..eecafce8 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -33,8 +33,11 @@ export function SubscriptionStatusProvider({ }; const isActive = () => { - if (subscriptionStatus?.subscription?.status === "active") { - return true; + if (subscriptionStatus?.subscriptions) { + // Check if any subscription is active + return subscriptionStatus.subscriptions.some( + (sub) => sub.subscription?.status === "active" + ); } return false; }; @@ -42,15 +45,20 @@ export function SubscriptionStatusProvider({ const getTier = () => { const tierPriceSet = getTierPriceSet(env, sandbox_mode); - if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) { - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = subscriptionStatus.items.find( - (item) => item.priceId === priceId - ); - if (matchingItem) { - return tierId; + if (subscriptionStatus?.subscriptions) { + // Iterate through all subscriptions + for (const { subscription, items } of subscriptionStatus.subscriptions) { + if (items && items.length > 0) { + // Iterate through tiers in order (earlier keys are higher tiers) + for (const [tierId, priceId] of Object.entries(tierPriceSet)) { + // Check if any subscription item matches this tier's price ID + const matchingItem = items.find( + (item) => item.priceId === priceId + ); + if (matchingItem) { + return tierId; + } + } } } } @@ -83,4 +91,4 @@ export function SubscriptionStatusProvider({ ); } -export default SubscriptionStatusProvider; +export default SubscriptionStatusProvider; \ No newline at end of file From 26a91cd5e149bc63a65dcdcc8dc823cdcff1612c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 18:29:35 -0800 Subject: [PATCH 10/15] Add link --- .../private/routers/billing/hooks/handleSubscriptionCreated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 9f2ee2ad..a51f825f 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -246,6 +246,7 @@ export async function handleSubscriptionCreated( personalUseOnly: false, users: numUsers, sites: numSites, + modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing` }), { to: customer.email, From 508369a59d0fae97a2319880049ed1b674e14dca Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 4 Feb 2026 20:25:20 -0800 Subject: [PATCH 11/15] adjust language in form --- messages/en-US.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 81e6ae71..e9d8cc37 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2130,16 +2130,16 @@ "tiers": { "starter": { "title": "Starter", - "description": "Ideal for small teams and getting started. Includes core features and support." + "description": "Enterprise features, 25 users, 25 sites, and community support." }, "scale": { "title": "Scale", - "description": "For growing teams and production use. Higher limits and priority support." + "description": "Enterprise features, 50 users, 50 sites, and priority support." } }, "personalUseOnly": "Personal use only (free license — no checkout)", "buttons": { - "continueToCheckout": "Continue to checkout" + "continueToCheckout": "Continue to Checkout" }, "toasts": { "checkoutError": { From 7d4aed88190811578fa4c19b90acbc33d54d496d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 20:37:05 -0800 Subject: [PATCH 12/15] Add prod price ids --- server/lib/billing/licenses.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts index a481527e..3fecb32b 100644 --- a/server/lib/billing/licenses.ts +++ b/server/lib/billing/licenses.ts @@ -9,8 +9,8 @@ export type LicensePriceSet = { export const licensePriceSet: LicensePriceSet = { // Free license matches the freeLimitSet - [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", - [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" + [LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", + [LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" }; export const licensePriceSetSandbox: LicensePriceSet = { From 11408c26560dcaf2e7967d8ffc18d6a428595ff3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 4 Feb 2026 21:16:53 -0800 Subject: [PATCH 13/15] add internal redirect --- src/app/[orgId]/layout.tsx | 3 ++ src/app/layout.tsx | 2 + src/app/page.tsx | 10 ++++- src/components/ApplyInternalRedirect.tsx | 24 ++++++++++++ src/components/RedirectToOrg.tsx | 24 ++++++++++++ src/components/StoreInternalRedirect.tsx | 27 +++++++++++++ src/lib/internalRedirect.ts | 48 ++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/components/ApplyInternalRedirect.tsx create mode 100644 src/components/RedirectToOrg.tsx create mode 100644 src/components/StoreInternalRedirect.tsx create mode 100644 src/lib/internalRedirect.ts diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 7d99fc0d..3d4b6054 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -18,6 +18,7 @@ import { build } from "@server/build"; import OrgPolicyResult from "@app/components/OrgPolicyResult"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; +import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -70,6 +71,7 @@ export default async function OrgLayout(props: { } catch (e) {} return ( + + {props.children} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 203dd778..ed7635e3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,7 @@ import Script from "next/script"; import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; +import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -79,6 +80,7 @@ export default async function RootLayout({ return ( + {build === "saas" && (