From a5c7913e771e91a38ba68c984bdd22dc4f1b5d31 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 4 Feb 2026 15:49:40 -0800 Subject: [PATCH] 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" && ( - <> -