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()
.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()

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 { 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,7 +125,16 @@ 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()
@@ -143,6 +154,59 @@ export async function handleSubscriptionCreated(
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) {
logger.error(
`Error handling subscription created event for ID ${subscription.id}:`,

View File

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

View File

@@ -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({

View File

@@ -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<any> {
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<string>(res, {
return sendResponse<string>(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."
)
);
}
}

View File

@@ -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<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 {
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<any> {
}
);
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);

View File

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

View File

@@ -25,7 +25,7 @@ import {
async function fetchLicenseKeys(orgId: string): Promise<any> {
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: {

View File

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