Basic billing page is working

This commit is contained in:
Owen
2026-02-06 17:41:20 -08:00
parent 6cfc7b7c69
commit e101ac341b
14 changed files with 451 additions and 644 deletions

View File

@@ -1520,6 +1520,19 @@
"resourcePortRequired": "Port number is required for non-HTTP resources",
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
"billingPricingCalculatorLink": "Pricing Calculator",
"billingYourPlan": "Your Plan",
"billingViewOrModifyPlan": "View or modify your current plan",
"billingViewPlanDetails": "View Plan Details",
"billingUsageAndLimits": "Usage and Limits",
"billingViewUsageAndLimits": "View your plan's limits and current usage",
"billingCurrentUsage": "Current Usage",
"billingMaximumLimits": "Maximum Limits",
"billingRemoteNodes": "Remote Nodes",
"billingUnlimited": "Unlimited",
"billingPaidLicenseKeys": "Paid License Keys",
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
"billingCurrentKeys": "Current Keys",
"billingModifyCurrentPlan": "Modify Current Plan",
"signUpTerms": {
"IAgreeToThe": "I agree to the",
"termsOfService": "terms of service",

View File

@@ -14,7 +14,8 @@
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",

View File

@@ -1,4 +1,5 @@
import Stripe from "stripe";
import { usageService } from "./usageService";
export enum FeatureId {
USERS = "users",
@@ -95,10 +96,24 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet {
}
}
export function getLineItems(
featurePriceSet: FeaturePriceSet
): Stripe.Checkout.SessionCreateParams.LineItem[] {
return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({
price: priceId
}));
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.HOME_LAB) {
quantity = 1;
}
return {
price: priceId,
quantity: quantity
};
});
}

View File

@@ -0,0 +1,3 @@
export async function isSubscribed(orgId: string): Promise<boolean> {
return false;
}

View File

@@ -31,7 +31,6 @@ import {
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
import response from "@server/lib/response";
import { getUserDeviceName } from "@server/db/names";
import { isLicensedOrSubscribed } from "@server/private/lib/isLicencedOrSubscribed";
const paramsSchema = z.strictObject({
orgId: z.string()

View File

@@ -25,6 +25,7 @@ import {
getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet,
getStarterFeaturePriceSet,
getLineItems,
FeatureId,
type FeaturePriceSet
} from "@server/lib/billing";
@@ -149,7 +150,7 @@ export async function changeTier(
// Determine if we're switching between different products
// home_lab uses HOME_LAB product, starter/scale use USERS product
const currentTier = subscription.type;
const switchingProducts =
const switchingProducts =
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
@@ -175,10 +176,9 @@ export async function changeTier(
}
// Add new items for the target tier
for (const [featureId, priceId] of Object.entries(targetPriceSet)) {
itemsToUpdate.push({
price: priceId
});
const newLineItems = await getLineItems(targetPriceSet, orgId);
for (const lineItem of newLineItems) {
itemsToUpdate.push(lineItem);
}
updatedSubscription = await stripe!.subscriptions.update(

View File

@@ -23,6 +23,8 @@ import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import Stripe from "stripe";
const createCheckoutSessionSchema = z.strictObject({
orgId: z.string()
@@ -80,19 +82,21 @@ export async function createCheckoutSession(
);
}
let lineItems;
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
if (tier === "home_lab") {
lineItems = getLineItems(getHomeLabFeaturePriceSet());
lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId);
} else if (tier === "starter") {
lineItems = getLineItems(getStarterFeaturePriceSet());
lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId);
} else if (tier === "scale") {
lineItems = getLineItems(getScaleFeaturePriceSet());
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")
);
}
logger.debug(`Line items: ${JSON.stringify(lineItems)}`)
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",

View File

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

View File

@@ -151,6 +151,14 @@ if (build === "saas") {
billing.createCheckoutSession
);
authenticated.post(
"/org/:orgId/billing/change-tier",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
billing.changeTier
);
authenticated.post(
"/org/:orgId/billing/create-portal-session",
verifyOrgAccess,

View File

@@ -26,7 +26,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm";
import { build } from "@server/build";
import config from "@server/private/lib/config";
import config from "#private/lib/config";
const paramsSchema = z.strictObject({
orgId: z.string()

View File

@@ -14,7 +14,7 @@ import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { build } from "@server/build";
import { isSubscribed } from "@server/private/lib/isSubscribed";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
const paramsSchema = z
.object({

View File

@@ -12,8 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { cache } from "@server/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { subscribe } from "node:diagnostics_channel";
import { isSubscribed } from "@server/private/lib/isSubscribed";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
const updateOrgParamsSchema = z.strictObject({
orgId: z.string()

View File

@@ -14,7 +14,7 @@ import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "@server/private/lib/isSubscribed";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()

File diff suppressed because it is too large Load Diff