Checkout flow works

This commit is contained in:
Owen
2026-02-04 15:49:40 -08:00
parent 34b914f509
commit a5c7913e77
10 changed files with 256 additions and 105 deletions

View File

@@ -45,6 +45,10 @@ export const privateConfigSchema = z.object({
.string() .string()
.optional() .optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")), .transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z fossorial_api_key: z
.string() .string()
.optional() .optional()
@@ -164,7 +168,10 @@ export const privateConfigSchema = z.object({
.optional(), .optional(),
stripe: z stripe: z
.object({ .object({
secret_key: z.string().optional().transform(getEnvOrYaml("STRIPE_SECRET_KEY")), secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z webhook_secret: z
.string() .string()
.optional() .optional()

View File

@@ -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<Stripe.Subscription>): "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;
}

View File

@@ -25,6 +25,8 @@ import logger from "@server/logger";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionCreated( export async function handleSubscriptionCreated(
subscription: Stripe.Subscription subscription: Stripe.Subscription
@@ -123,7 +125,16 @@ export async function handleSubscriptionCreated(
return; 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 const [orgUserRes] = await db
.select() .select()
@@ -143,6 +154,59 @@ export async function handleSubscriptionCreated(
moveEmailToAudience(email, AudienceIds.Subscribed); 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) { } catch (error) {
logger.error( logger.error(
`Error handling subscription created event for ID ${subscription.id}:`, `Error handling subscription created event for ID ${subscription.id}:`,

View File

@@ -16,4 +16,3 @@ export * from "./createPortalSession";
export * from "./getOrgSubscription"; export * from "./getOrgSubscription";
export * from "./getOrgUsage"; export * from "./getOrgUsage";
export * from "./internalGetOrgTier"; export * from "./internalGetOrgTier";
export * from "./createCheckoutSessionLicense";

View File

@@ -166,14 +166,6 @@ if (build === "saas") {
billing.createCheckoutSessionSAAS billing.createCheckoutSessionSAAS
); );
authenticated.post(
"/org/:orgId/billing/create-checkout-session-license",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
billing.createCheckoutSessionoLicense
);
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-portal-session", "/org/:orgId/billing/create-portal-session",
verifyOrgAccess, verifyOrgAccess,
@@ -208,6 +200,14 @@ if (build === "saas") {
generateLicense.generateNewLicense generateLicense.generateNewLicense
); );
authenticated.put(
"/org/:orgId/license/enterprise",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
generateLicense.generateNewEnterpriseLicense
);
authenticated.post( authenticated.post(
"/send-support-request", "/send-support-request",
rateLimit({ rateLimit({

View File

@@ -12,33 +12,33 @@
*/ */
import { Request, Response, NextFunction } from "express"; 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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; 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 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 { 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({ const generateNewEnterpriseLicenseParamsSchema = z.strictObject({
orgId: z.string(), orgId: z.string()
}); });
const createCheckoutSessionBodySchema = z.strictObject({ export async function generateNewEnterpriseLicense(
tier: z.enum([LicenseId.BIG_LICENSE, LicenseId.SMALL_LICENSE]),
});
export async function createCheckoutSessionoLicense(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = createCheckoutSessionParamsSchema.safeParse(req.params);
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
@@ -50,17 +50,49 @@ export async function createCheckoutSessionoLicense(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); if (!orgId) {
if (!parsedBody.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, 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 // check if we already have a customer for this org
const [customer] = await db 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 tierPrice = getLicensePriceSet()[tier]
const session = await stripe!.checkout.sessions.create({ 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", billing_address_collection: "required",
line_items: [ line_items: [
{ {
@@ -97,17 +130,20 @@ export async function createCheckoutSessionoLicense(
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
}); });
return response<string>(res, { return sendResponse<string>(res, {
data: session.url, data: session.url,
success: true, success: true,
error: false, error: false,
message: "Checkout session created successfully", message: "License and checkout session created successfully",
status: HttpCode.CREATED status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while generating new license."
)
); );
} }
} }

View File

@@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
async function createNewLicense(orgId: string, licenseData: any): Promise<any> { 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<CreateNewLicenseResponse> {
try { try {
const response = await fetch( 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", method: "PUT",
headers: { headers: {
@@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
} }
); );
const data = await response.json(); const data: CreateNewLicenseResponse = await response.json();
logger.debug("Fossorial API response:", { data });
return data; return data;
} catch (error) { } catch (error) {
console.error("Error creating new license:", error); console.error("Error creating new license:", error);

View File

@@ -13,3 +13,4 @@
export * from "./listGeneratedLicenses"; export * from "./listGeneratedLicenses";
export * from "./generateNewLicense"; export * from "./generateNewLicense";
export * from "./generateNewEnterpriseLicense";

View File

@@ -25,7 +25,7 @@ import {
async function fetchLicenseKeys(orgId: string): Promise<any> { async function fetchLicenseKeys(orgId: string): Promise<any> {
try { try {
const response = await fetch( 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", method: "GET",
headers: { headers: {

View File

@@ -250,6 +250,26 @@ export default function GenerateLicenseKeyForm({
const submitLicenseRequest = async (payload: any) => { const submitLicenseRequest = async (payload: any) => {
setLoading(true); setLoading(true);
try { try {
// Check if this is a business/enterprise license request
if (payload.useCaseType === "business") {
const response = await api.put<
AxiosResponse<string>
>(`/org/${orgId}/license/enterprise`, { ...payload, 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);
}
} else {
// Personal license flow
const response = await api.put< const response = await api.put<
AxiosResponse<GenerateNewLicenseResponse> AxiosResponse<GenerateNewLicenseResponse>
>(`/org/${orgId}/license`, payload); >(`/org/${orgId}/license`, payload);
@@ -265,6 +285,7 @@ export default function GenerateLicenseKeyForm({
variant: "default" variant: "default"
}); });
} }
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
toast({ toast({
@@ -345,37 +366,6 @@ export default function GenerateLicenseKeyForm({
resetForm(); resetForm();
}; };
const handleTestCheckout = async () => {
setLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
`/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 ( return (
<Credenza open={open} onOpenChange={handleClose}> <Credenza open={open} onOpenChange={handleClose}>
<CredenzaContent className="max-w-4xl"> <CredenzaContent className="max-w-4xl">
@@ -1097,15 +1087,6 @@ export default function GenerateLicenseKeyForm({
)} )}
{!generatedKey && useCaseType === "business" && ( {!generatedKey && useCaseType === "business" && (
<>
<Button
variant="secondary"
onClick={handleTestCheckout}
disabled={loading}
loading={loading}
>
TEST: Go to Checkout
</Button>
<Button <Button
type="submit" type="submit"
form="generate-license-business-form" form="generate-license-business-form"
@@ -1116,7 +1097,6 @@ export default function GenerateLicenseKeyForm({
"generateLicenseKeyForm.buttons.generateLicenseKey" "generateLicenseKeyForm.buttons.generateLicenseKey"
)} )}
</Button> </Button>
</>
)} )}
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>