Compare commits

..

4 Commits

Author SHA1 Message Date
Owen
5f26b9eeea Merge branch 'k8s' into new-pricing 2026-02-08 11:08:51 -08:00
Owen
1cca69ad23 Further billing 2026-02-08 11:08:23 -08:00
Owen
e101ac341b Basic billing page is working 2026-02-06 17:41:20 -08:00
Owen
f2ba4b270f Dont write stripe to files anymore 2026-01-29 20:56:46 -08:00
19 changed files with 745 additions and 983 deletions

View File

@@ -1405,9 +1405,9 @@
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
"billingDataUsage": "Data Usage", "billingDataUsage": "Data Usage",
"billingSites": "Sites", "billingSites": "Sites",
"billingUsers": "Active Users", "billingUsers": "Users",
"billingDomains": "Active Domains", "billingDomains": "Domains",
"billingRemoteExitNodes": "Active Self-hosted Nodes", "billingRemoteExitNodes": "Remote Nodes",
"billingNoLimitConfigured": "No limit configured", "billingNoLimitConfigured": "No limit configured",
"billingEstimatedPeriod": "Estimated Billing Period", "billingEstimatedPeriod": "Estimated Billing Period",
"billingIncludedUsage": "Included Usage", "billingIncludedUsage": "Included Usage",
@@ -1520,6 +1520,27 @@
"resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortRequired": "Port number is required for non-HTTP resources",
"resourcePortNotAllowed": "Port number should not be set for HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources",
"billingPricingCalculatorLink": "Pricing Calculator", "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",
"billingConfirmUpgrade": "Confirm Upgrade",
"billingConfirmDowngrade": "Confirm Downgrade",
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
"billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.",
"billingPlanIncludes": "Plan Includes",
"billingProcessing": "Processing...",
"billingConfirmUpgradeButton": "Confirm Upgrade",
"billingConfirmDowngradeButton": "Confirm Downgrade",
"signUpTerms": { "signUpTerms": {
"IAgreeToThe": "I agree to the", "IAgreeToThe": "I agree to the",
"termsOfService": "terms of service", "termsOfService": "terms of service",

View File

@@ -14,7 +14,8 @@
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check", "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", "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:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts", "db:studio": "drizzle-kit studio --config=./drizzle.config.ts",

View File

@@ -105,11 +105,13 @@ function getOpenApiDocumentation() {
servers: [{ url: "/v1" }] servers: [{ url: "/v1" }]
}); });
if (!process.env.DISABLE_GEN_OPENAPI) {
// convert to yaml and save to file // convert to yaml and save to file
const outputPath = path.join(APP_PATH, "openapi.yaml"); const outputPath = path.join(APP_PATH, "openapi.yaml");
const yamlOutput = yaml.dump(generated); const yamlOutput = yaml.dump(generated);
fs.writeFileSync(outputPath, yamlOutput, "utf8"); fs.writeFileSync(outputPath, yamlOutput, "utf8");
logger.info(`OpenAPI documentation saved to ${outputPath}`); logger.info(`OpenAPI documentation saved to ${outputPath}`);
}
return generated; return generated;
} }

View File

@@ -1,4 +1,5 @@
import Stripe from "stripe"; import Stripe from "stripe";
import { usageService } from "./usageService";
export enum FeatureId { export enum FeatureId {
USERS = "users", USERS = "users",
@@ -95,10 +96,24 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet {
} }
} }
export function getLineItems( export async function getLineItems(
featurePriceSet: FeaturePriceSet featurePriceSet: FeaturePriceSet,
): Stripe.Checkout.SessionCreateParams.LineItem[] { orgId: string,
return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({ ): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
price: priceId 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

@@ -23,7 +23,7 @@ export const freeLimitSet: LimitSet = {
description: "Free tier limit" description: "Free tier limit"
}, // 25 GB }, // 25 GB
[FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Free tier limit" }
}; };
export const homeLabLimitSet: LimitSet = { export const homeLabLimitSet: LimitSet = {

View File

@@ -1,8 +1,6 @@
import { eq, sql, and } from "drizzle-orm"; import { eq, sql, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand } from "@aws-sdk/client-s3";
import * as fs from "fs/promises";
import * as path from "path";
import { import {
db, db,
usage, usage,
@@ -33,9 +31,7 @@ interface StripeEvent {
export function noop() { export function noop() {
if ( if (
build !== "saas" || build !== "saas"
!process.env.S3_BUCKET ||
!process.env.LOCAL_FILE_PATH
) { ) {
return true; return true;
} }
@@ -44,31 +40,37 @@ export function noop() {
export class UsageService { export class UsageService {
private bucketName: string | undefined; private bucketName: string | undefined;
private currentEventFile: string | null = null; private events: StripeEvent[] = [];
private currentFileStartTime: number = 0; private lastUploadTime: number = Date.now();
private eventsDir: string | undefined; private isUploading: boolean = false;
private uploadingFiles: Set<string> = new Set();
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
} }
// this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket;
// this.eventsDir = privateConfig.getRawPrivateConfig().stripe?.localFilePath;
this.bucketName = process.env.S3_BUCKET || undefined; this.bucketName = process.env.S3_BUCKET || undefined;
this.eventsDir = process.env.LOCAL_FILE_PATH || undefined;
// Ensure events directory exists // Periodically check and upload events
this.initializeEventsDirectory().then(() => {
this.uploadPendingEventFilesOnStartup();
});
// Periodically check for old event files to upload
setInterval(() => { setInterval(() => {
this.uploadOldEventFiles().catch((err) => { this.checkAndUploadEvents().catch((err) => {
logger.error("Error in periodic event file upload:", err); logger.error("Error in periodic event upload:", err);
}); });
}, 30000); // every 30 seconds }, 30000); // every 30 seconds
// Handle graceful shutdown on SIGTERM
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, uploading events before shutdown...");
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
});
// Handle SIGINT as well (Ctrl+C)
process.on("SIGINT", async () => {
logger.info("SIGINT received, uploading events before shutdown...");
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
process.exit(0);
});
} }
/** /**
@@ -78,85 +80,6 @@ export class UsageService {
return Math.round(value * 100000000000) / 100000000000; // 11 decimal places return Math.round(value * 100000000000) / 100000000000; // 11 decimal places
} }
private async initializeEventsDirectory(): Promise<void> {
if (!this.eventsDir) {
logger.warn(
"Stripe local file path is not configured, skipping events directory initialization."
);
return;
}
try {
await fs.mkdir(this.eventsDir, { recursive: true });
} catch (error) {
logger.error("Failed to create events directory:", error);
}
}
private async uploadPendingEventFilesOnStartup(): Promise<void> {
if (!this.eventsDir || !this.bucketName) {
logger.warn(
"Stripe local file path or bucket name is not configured, skipping leftover event file upload."
);
return;
}
try {
const files = await fs.readdir(this.eventsDir);
for (const file of files) {
if (file.endsWith(".json")) {
const filePath = path.join(this.eventsDir, file);
try {
const fileContent = await fs.readFile(
filePath,
"utf-8"
);
const events = JSON.parse(fileContent);
if (Array.isArray(events) && events.length > 0) {
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: file,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
// Check if file still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Startup file ${file} was already deleted`
);
}
logger.info(
`Uploaded leftover event file ${file} to S3 with ${events.length} events`
);
} else {
// Remove empty file
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Empty startup file ${file} was already deleted`
);
}
}
} catch (err) {
logger.error(
`Error processing leftover event file ${file}:`,
err
);
}
}
}
} catch (error) {
logger.error("Failed to scan for leftover event files");
}
}
public async add( public async add(
orgId: string, orgId: string,
featureId: FeatureId, featureId: FeatureId,
@@ -450,121 +373,58 @@ export class UsageService {
} }
}; };
await this.writeEventToFile(event); this.addEventToMemory(event);
await this.checkAndUploadFile(); await this.checkAndUploadEvents();
} }
private async writeEventToFile(event: StripeEvent): Promise<void> { private addEventToMemory(event: StripeEvent): void {
if (!this.eventsDir || !this.bucketName) { if (!this.bucketName) {
logger.warn( logger.warn(
"Stripe local file path or bucket name is not configured, skipping event file write." "S3 bucket name is not configured, skipping event storage."
); );
return; return;
} }
if (!this.currentEventFile) { this.events.push(event);
this.currentEventFile = this.generateEventFileName();
this.currentFileStartTime = Date.now();
}
const filePath = path.join(this.eventsDir, this.currentEventFile);
try {
let events: StripeEvent[] = [];
// Try to read existing file
try {
const fileContent = await fs.readFile(filePath, "utf-8");
events = JSON.parse(fileContent);
} catch (error) {
// File doesn't exist or is empty, start with empty array
events = [];
}
// Add new event
events.push(event);
// Write back to file
await fs.writeFile(filePath, JSON.stringify(events, null, 2));
} catch (error) {
logger.error("Failed to write event to file:", error);
}
}
private async checkAndUploadFile(): Promise<void> {
if (!this.currentEventFile) {
return;
} }
private async checkAndUploadEvents(): Promise<void> {
const now = Date.now(); const now = Date.now();
const fileAge = now - this.currentFileStartTime; const timeSinceLastUpload = now - this.lastUploadTime;
// Check if file is at least 1 minute old // Check if at least 1 minute has passed since last upload
if (fileAge >= 60000) { if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
// 60 seconds await this.uploadEventsToS3();
await this.uploadFileToS3();
} }
} }
private async uploadFileToS3(): Promise<void> { private async uploadEventsToS3(): Promise<void> {
if (!this.bucketName || !this.eventsDir) { if (!this.bucketName) {
logger.warn( logger.warn(
"Stripe local file path or bucket name is not configured, skipping S3 upload." "S3 bucket name is not configured, skipping S3 upload."
);
return;
}
if (!this.currentEventFile) {
return;
}
const fileName = this.currentEventFile;
const filePath = path.join(this.eventsDir, fileName);
// Check if this file is already being uploaded
if (this.uploadingFiles.has(fileName)) {
logger.debug(
`File ${fileName} is already being uploaded, skipping`
); );
return; return;
} }
// Mark file as being uploaded if (this.events.length === 0) {
this.uploadingFiles.add(fileName); return;
}
// Check if already uploading
if (this.isUploading) {
logger.debug("Already uploading events, skipping");
return;
}
this.isUploading = true;
try { try {
// Check if file exists before trying to read it // Take a snapshot of current events and clear the array
try { const eventsToUpload = [...this.events];
await fs.access(filePath); this.events = [];
} catch (error) { this.lastUploadTime = Date.now();
logger.debug(
`File ${fileName} does not exist, may have been already processed`
);
this.uploadingFiles.delete(fileName);
// Reset current file if it was this file
if (this.currentEventFile === fileName) {
this.currentEventFile = null;
this.currentFileStartTime = 0;
}
return;
}
// Check if file exists and has content const fileName = this.generateEventFileName();
const fileContent = await fs.readFile(filePath, "utf-8"); const fileContent = JSON.stringify(eventsToUpload, null, 2);
const events = JSON.parse(fileContent);
if (events.length === 0) {
// No events to upload, just clean up
try {
await fs.unlink(filePath);
} catch (unlinkError) {
// File may have been already deleted
logger.debug(
`File ${fileName} was already deleted during cleanup`
);
}
this.currentEventFile = null;
this.uploadingFiles.delete(fileName);
return;
}
// Upload to S3 // Upload to S3
const uploadCommand = new PutObjectCommand({ const uploadCommand = new PutObjectCommand({
@@ -576,29 +436,15 @@ export class UsageService {
await s3Client.send(uploadCommand); await s3Client.send(uploadCommand);
// Clean up local file - check if it still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
// File may have been already deleted by another process
logger.debug(
`File ${fileName} was already deleted during upload`
);
}
logger.info( logger.info(
`Uploaded ${fileName} to S3 with ${events.length} events` `Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
); );
// Reset for next file
this.currentEventFile = null;
this.currentFileStartTime = 0;
} catch (error) { } catch (error) {
logger.error(`Failed to upload ${fileName} to S3:`, error); logger.error("Failed to upload events to S3:", error);
// Note: Events are lost if upload fails. In a production system,
// you might want to add the events back to the array or implement retry logic
} finally { } finally {
// Always remove from uploading set this.isUploading = false;
this.uploadingFiles.delete(fileName);
} }
} }
@@ -695,111 +541,10 @@ export class UsageService {
} }
public async forceUpload(): Promise<void> { public async forceUpload(): Promise<void> {
await this.uploadFileToS3(); if (this.events.length > 0) {
} // Force upload regardless of time
this.lastUploadTime = 0; // Reset to force upload
/** await this.uploadEventsToS3();
* Scan the events directory for files older than 1 minute and upload them if not empty.
*/
private async uploadOldEventFiles(): Promise<void> {
if (!this.eventsDir || !this.bucketName) {
logger.warn(
"Stripe local file path or bucket name is not configured, skipping old event file upload."
);
return;
}
try {
const files = await fs.readdir(this.eventsDir);
const now = Date.now();
for (const file of files) {
if (!file.endsWith(".json")) continue;
// Skip files that are already being uploaded
if (this.uploadingFiles.has(file)) {
logger.debug(
`Skipping file ${file} as it's already being uploaded`
);
continue;
}
const filePath = path.join(this.eventsDir, file);
try {
// Check if file still exists before processing
try {
await fs.access(filePath);
} catch (accessError) {
logger.debug(`File ${file} does not exist, skipping`);
continue;
}
const stat = await fs.stat(filePath);
const age = now - stat.mtimeMs;
if (age >= 90000) {
// 1.5 minutes - Mark as being uploaded
this.uploadingFiles.add(file);
try {
const fileContent = await fs.readFile(
filePath,
"utf-8"
);
const events = JSON.parse(fileContent);
if (Array.isArray(events) && events.length > 0) {
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: file,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
// Check if file still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`File ${file} was already deleted during interval upload`
);
}
logger.info(
`Interval: Uploaded event file ${file} to S3 with ${events.length} events`
);
// If this was the current event file, reset it
if (this.currentEventFile === file) {
this.currentEventFile = null;
this.currentFileStartTime = 0;
}
} else {
// Remove empty file
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Empty file ${file} was already deleted`
);
}
}
} finally {
// Always remove from uploading set
this.uploadingFiles.delete(file);
}
}
} catch (err) {
logger.error(
`Interval: Error processing event file ${file}:`,
err
);
// Remove from uploading set on error
this.uploadingFiles.delete(file);
}
}
} catch (err) {
logger.error("Interval: Failed to scan for event files:", err);
} }
} }

View File

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

View File

@@ -128,10 +128,7 @@ export class PrivateConfig {
if (this.rawPrivateConfig.stripe?.s3Bucket) { if (this.rawPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket; process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
} }
if (this.rawPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
this.rawPrivateConfig.stripe.localFilePath;
}
if (this.rawPrivateConfig.stripe?.s3Region) { if (this.rawPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region; process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
} }

View File

@@ -178,7 +178,7 @@ export const privateConfigSchema = z.object({
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
s3Bucket: z.string(), s3Bucket: z.string(),
s3Region: z.string().default("us-east-1"), s3Region: z.string().default("us-east-1"),
localFilePath: z.string() localFilePath: z.string().optional()
}) })
.optional() .optional()
}); });

View File

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

View File

@@ -25,6 +25,7 @@ import {
getHomeLabFeaturePriceSet, getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet, getScaleFeaturePriceSet,
getStarterFeaturePriceSet, getStarterFeaturePriceSet,
getLineItems,
FeatureId, FeatureId,
type FeaturePriceSet type FeaturePriceSet
} from "@server/lib/billing"; } from "@server/lib/billing";
@@ -175,10 +176,9 @@ export async function changeTier(
} }
// Add new items for the target tier // Add new items for the target tier
for (const [featureId, priceId] of Object.entries(targetPriceSet)) { const newLineItems = await getLineItems(targetPriceSet, orgId);
itemsToUpdate.push({ for (const lineItem of newLineItems) {
price: priceId itemsToUpdate.push(lineItem);
});
} }
updatedSubscription = await stripe!.subscriptions.update( updatedSubscription = await stripe!.subscriptions.update(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff