mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-24 17:52:33 +00:00
Compare commits
4 Commits
6cfc7b7c69
...
5f26b9eeea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f26b9eeea | ||
|
|
1cca69ad23 | ||
|
|
e101ac341b | ||
|
|
f2ba4b270f |
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -105,11 +105,13 @@ function getOpenApiDocumentation() {
|
|||||||
servers: [{ url: "/v1" }]
|
servers: [{ url: "/v1" }]
|
||||||
});
|
});
|
||||||
|
|
||||||
// convert to yaml and save to file
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
// convert to yaml and save to file
|
||||||
const yamlOutput = yaml.dump(generated);
|
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
||||||
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
const yamlOutput = yaml.dump(generated);
|
||||||
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
||||||
|
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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> {
|
private async checkAndUploadEvents(): Promise<void> {
|
||||||
if (!this.currentEventFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
server/lib/isSubscribed.ts
Normal file
3
server/lib/isSubscribed.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export async function isSubscribed(orgId: string): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -149,7 +150,7 @@ export async function changeTier(
|
|||||||
// Determine if we're switching between different products
|
// Determine if we're switching between different products
|
||||||
// home_lab uses HOME_LAB product, starter/scale use USERS product
|
// home_lab uses HOME_LAB product, starter/scale use USERS product
|
||||||
const currentTier = subscription.type;
|
const currentTier = subscription.type;
|
||||||
const switchingProducts =
|
const switchingProducts =
|
||||||
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
|
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
|
||||||
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
|
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user