Rename tiers and get working

This commit is contained in:
Owen
2026-02-08 17:55:26 -08:00
parent c41e8be3e8
commit 81ef2db7f8
36 changed files with 326 additions and 175 deletions

View File

@@ -7,8 +7,8 @@ services:
POSTGRES_DB: postgres # Default database name POSTGRES_DB: postgres # Default database name
POSTGRES_USER: postgres # Default user POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: password # Default password (change for production!) POSTGRES_PASSWORD: password # Default password (change for production!)
volumes: # volumes:
- ./config/postgres:/var/lib/postgresql/data # - ./config/postgres:/var/lib/postgresql/data
ports: ports:
- "5432:5432" # Map host port 5432 to container port 5432 - "5432:5432" # Map host port 5432 to container port 5432
restart: no restart: no

14
drizzle.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
import path from "path";
const schema = [path.join("server", "db", "pg", "schema")];
export default defineConfig({
dialect: "postgresql",
schema: schema,
out: path.join("server", "migrations"),
verbose: true,
dbCredentials: {
url: process.env.DATABASE_URL as string
}
});

View File

@@ -82,12 +82,14 @@ export const subscriptions = pgTable("subscriptions", {
canceledAt: bigint("canceledAt", { mode: "number" }), canceledAt: bigint("canceledAt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }), updatedAt: bigint("updatedAt", { mode: "number" }),
version: integer("version"),
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
type: varchar("type", { length: 50 }) // home_lab, starter, scale, or license type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
}); });
export const subscriptionItems = pgTable("subscriptionItems", { export const subscriptionItems = pgTable("subscriptionItems", {
subscriptionItemId: serial("subscriptionItemId").primaryKey(), subscriptionItemId: serial("subscriptionItemId").primaryKey(),
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
subscriptionId: varchar("subscriptionId", { length: 255 }) subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull() .notNull()
.references(() => subscriptions.subscriptionId, { .references(() => subscriptions.subscriptionId, {

View File

@@ -70,8 +70,9 @@ export const subscriptions = sqliteTable("subscriptions", {
canceledAt: integer("canceledAt"), canceledAt: integer("canceledAt"),
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt"), updatedAt: integer("updatedAt"),
version: integer("version"),
billingCycleAnchor: integer("billingCycleAnchor"), billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // home_lab, starter, scale, or license type: text("type") // tier1, tier2, tier3, or license
}); });
export const subscriptionItems = sqliteTable("subscriptionItems", { export const subscriptionItems = sqliteTable("subscriptionItems", {

View File

@@ -7,9 +7,29 @@ export enum FeatureId {
EGRESS_DATA_MB = "egressDataMb", EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains", DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes", REMOTE_EXIT_NODES = "remoteExitNodes",
HOME_LAB = "home_lab" TIER1 = "tier1"
} }
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
switch (featureId) {
case FeatureId.USERS:
return "Users";
case FeatureId.SITES:
return "Sites";
case FeatureId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.TIER1:
return "Home Lab";
default:
return featureId;
}
}
// this is from the old system
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
}; };
@@ -40,11 +60,11 @@ export function getFeatureIdByMetricId(
export type FeaturePriceSet = Partial<Record<FeatureId, string>>; export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export const homeLabFeaturePriceSet: FeaturePriceSet = { export const homeLabFeaturePriceSet: FeaturePriceSet = {
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
}; };
export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = { export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
}; };
export function getHomeLabFeaturePriceSet(): FeaturePriceSet { export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
@@ -58,11 +78,11 @@ export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
} }
} }
export const starterFeaturePriceSet: FeaturePriceSet = { export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
}; };
export const starterFeaturePriceSetSandbox: FeaturePriceSet = { export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
}; };
@@ -71,17 +91,17 @@ export function getStarterFeaturePriceSet(): FeaturePriceSet {
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
) { ) {
return starterFeaturePriceSet; return tier2FeaturePriceSet;
} else { } else {
return starterFeaturePriceSetSandbox; return tier2FeaturePriceSetSandbox;
} }
} }
export const scaleFeaturePriceSet: FeaturePriceSet = { export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
}; };
export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
}; };
@@ -90,9 +110,9 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet {
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
) { ) {
return scaleFeaturePriceSet; return tier3FeaturePriceSet;
} else { } else {
return scaleFeaturePriceSetSandbox; return tier3FeaturePriceSetSandbox;
} }
} }
@@ -100,14 +120,14 @@ export async function getLineItems(
featurePriceSet: FeaturePriceSet, featurePriceSet: FeaturePriceSet,
orgId: string, orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> { ): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); const users = await usageService.getUsage(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => { return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined; let quantity: number | undefined;
if (featureId === FeatureId.USERS) { if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1; quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.HOME_LAB) { } else if (featureId === FeatureId.TIER1) {
quantity = 1; quantity = 1;
} }

View File

@@ -37,7 +37,7 @@ export const homeLabLimitSet: LimitSet = {
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" } [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" }
}; };
export const starterLimitSet: LimitSet = { export const tier2LimitSet: LimitSet = {
[FeatureId.SITES]: { [FeatureId.SITES]: {
value: 10, value: 10,
description: "Starter limit" description: "Starter limit"
@@ -60,7 +60,7 @@ export const starterLimitSet: LimitSet = {
} }
}; };
export const scaleLimitSet: LimitSet = { export const tier3LimitSet: LimitSet = {
[FeatureId.SITES]: { [FeatureId.SITES]: {
value: 10, value: 10,
description: "Scale limit" description: "Scale limit"

View File

@@ -50,7 +50,8 @@ export class UsageService {
this.bucketName = process.env.S3_BUCKET || undefined; this.bucketName = process.env.S3_BUCKET || undefined;
if ( // Only set up event uploading if usage reporting is enabled and bucket name is configured if (
// Only set up event uploading if usage reporting is enabled and bucket name is configured
privateConfig.getRawPrivateConfig().flags.usage_reporting && privateConfig.getRawPrivateConfig().flags.usage_reporting &&
this.bucketName this.bucketName
) { ) {
@@ -220,7 +221,7 @@ export class UsageService {
return new Date(date * 1000).toISOString().split("T")[0]; return new Date(date * 1000).toISOString().split("T")[0];
} }
async updateDaily( async updateCount(
orgId: string, orgId: string,
featureId: FeatureId, featureId: FeatureId,
value?: number, value?: number,
@@ -246,8 +247,6 @@ export class UsageService {
value = this.truncateValue(value); value = this.truncateValue(value);
} }
const today = this.getTodayDateString();
let currentUsage: Usage | null = null; let currentUsage: Usage | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@@ -261,57 +260,23 @@ export class UsageService {
.limit(1); .limit(1);
if (currentUsage) { if (currentUsage) {
const lastUpdateDate = this.getDateString( await trx
currentUsage.updatedAt .update(usage)
); .set({
const currentRunningTotal = currentUsage.latestValue; instantaneousValue: value,
const lastDailyValue = currentUsage.instantaneousValue || 0; updatedAt: Math.floor(Date.now() / 1000)
})
if (value == undefined || value === null) { .where(eq(usage.usageId, usageId));
value = currentUsage.instantaneousValue || 0;
}
if (lastUpdateDate === today) {
// Same day update: replace the daily value
// Remove old daily value from running total, add new value
const newRunningTotal = this.truncateValue(
currentRunningTotal - lastDailyValue + value
);
await trx
.update(usage)
.set({
latestValue: newRunningTotal,
instantaneousValue: value,
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(usage.usageId, usageId));
} else {
// New day: add to running total
const newRunningTotal = this.truncateValue(
currentRunningTotal + value
);
await trx
.update(usage)
.set({
latestValue: newRunningTotal,
instantaneousValue: value,
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(usage.usageId, usageId));
}
} else { } else {
// First record for this meter // First record for this meter
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
const truncatedValue = this.truncateValue(value || 0);
await trx.insert(usage).values({ await trx.insert(usage).values({
usageId, usageId,
featureId, featureId,
orgId, orgId,
meterId, meterId,
instantaneousValue: truncatedValue, instantaneousValue: value || 0,
latestValue: truncatedValue, latestValue: value || 0,
updatedAt: Math.floor(Date.now() / 1000) updatedAt: Math.floor(Date.now() / 1000)
}); });
} }
@@ -322,7 +287,7 @@ export class UsageService {
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to update daily usage for ${orgId}/${featureId}:`, `Failed to update count usage for ${orgId}/${featureId}:`,
error error
); );
} }
@@ -542,17 +507,6 @@ export class UsageService {
} }
} }
public async getUsageDaily(
orgId: string,
featureId: FeatureId
): Promise<Usage | null> {
if (noop()) {
return null;
}
await this.updateDaily(orgId, featureId); // Ensure daily usage is updated
return this.getUsage(orgId, featureId);
}
public async forceUpload(): Promise<void> { public async forceUpload(): Promise<void> {
if (this.events.length > 0) { if (this.events.length > 0) {
// Force upload regardless of time // Force upload regardless of time

View File

@@ -182,7 +182,7 @@ export async function createUserAccountOrg(
const customerId = await createCustomer(orgId, userEmail); const customerId = await createCustomer(orgId, userEmail);
if (customerId) { if (customerId) {
await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
} }
return { return {

View File

@@ -17,8 +17,8 @@ import { eq, and, ne } from "drizzle-orm";
export async function getOrgTierData( export async function getOrgTierData(
orgId: string orgId: string
): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> { ): Promise<{ tier: "tier1" | "tier2" | "tier3" | null; active: boolean }> {
let tier: "home_lab" | "starter" | "scale" | null = null; let tier: "tier1" | "tier2" | "tier3" | null = null;
let active = false; let active = false;
if (build !== "saas") { if (build !== "saas") {
@@ -50,9 +50,9 @@ export async function getOrgTierData(
if (subscription) { if (subscription) {
// Validate that subscription.type is one of the expected tier values // Validate that subscription.type is one of the expected tier values
if ( if (
subscription.type === "home_lab" || subscription.type === "tier1" ||
subscription.type === "starter" || subscription.type === "tier2" ||
subscription.type === "scale" subscription.type === "tier3"
) { ) {
tier = subscription.type; tier = subscription.type;
active = true; active = true;

View File

@@ -22,7 +22,7 @@ export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "saas") { if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId); const { tier, active } = await getOrgTierData(orgId);
return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active;
} }
return false; return false;

View File

@@ -17,7 +17,7 @@ import { getOrgTierData } from "#private/lib/billing";
export async function isSubscribed(orgId: string): Promise<boolean> { export async function isSubscribed(orgId: string): Promise<boolean> {
if (build === "saas") { if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId); const { tier, active } = await getOrgTierData(orgId);
return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active;
} }
return false; return false;

View File

@@ -39,7 +39,7 @@ export async function verifyValidSubscription(
} }
const { tier, active } = await getOrgTierData(orgId); const { tier, active } = await getOrgTierData(orgId);
if ((tier == "home_lab" || tier == "starter" || tier == "scale") && active) { if ((tier == "tier1" || tier == "tier2" || tier == "tier3") && active) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View File

@@ -35,7 +35,7 @@ const changeTierSchema = z.strictObject({
}); });
const changeTierBodySchema = z.strictObject({ const changeTierBodySchema = z.strictObject({
tier: z.enum(["home_lab", "starter", "scale"]) tier: z.enum(["tier1", "tier2", "tier3"])
}); });
export async function changeTier( export async function changeTier(
@@ -93,9 +93,9 @@ export async function changeTier(
eq(subscriptions.customerId, customer.customerId), eq(subscriptions.customerId, customer.customerId),
eq(subscriptions.status, "active"), eq(subscriptions.status, "active"),
or( or(
eq(subscriptions.type, "home_lab"), eq(subscriptions.type, "tier1"),
eq(subscriptions.type, "starter"), eq(subscriptions.type, "tier2"),
eq(subscriptions.type, "scale") eq(subscriptions.type, "tier3")
) )
) )
) )
@@ -112,11 +112,11 @@ export async function changeTier(
// Get the target tier's price set // Get the target tier's price set
let targetPriceSet: FeaturePriceSet; let targetPriceSet: FeaturePriceSet;
if (tier === "home_lab") { if (tier === "tier1") {
targetPriceSet = getHomeLabFeaturePriceSet(); targetPriceSet = getHomeLabFeaturePriceSet();
} else if (tier === "starter") { } else if (tier === "tier2") {
targetPriceSet = getStarterFeaturePriceSet(); targetPriceSet = getStarterFeaturePriceSet();
} else if (tier === "scale") { } else if (tier === "tier3") {
targetPriceSet = getScaleFeaturePriceSet(); targetPriceSet = getScaleFeaturePriceSet();
} else { } else {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
@@ -148,11 +148,11 @@ 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 // tier1 uses TIER1 product, tier2/tier3 use USERS product
const currentTier = subscription.type; const currentTier = subscription.type;
const switchingProducts = const switchingProducts =
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || (currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) ||
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); ((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1");
let updatedSubscription; let updatedSubscription;
@@ -189,7 +189,7 @@ export async function changeTier(
} }
); );
} else { } else {
// Same product, different price tier (starter <-> scale) // Same product, different price tier (tier2 <-> tier3)
// We can simply update the price // We can simply update the price
logger.info( logger.info(
`Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`

View File

@@ -31,7 +31,7 @@ const createCheckoutSessionSchema = z.strictObject({
}); });
const createCheckoutSessionBodySchema = z.strictObject({ const createCheckoutSessionBodySchema = z.strictObject({
tier: z.enum(["home_lab", "starter", "scale"]), tier: z.enum(["tier1", "tier2", "tier3"]),
}); });
export async function createCheckoutSession( export async function createCheckoutSession(
@@ -83,11 +83,11 @@ export async function createCheckoutSession(
} }
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
if (tier === "home_lab") { if (tier === "tier1") {
lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId); lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId);
} else if (tier === "starter") { } else if (tier === "tier2") {
lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId); lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId);
} else if (tier === "scale") { } else if (tier === "tier3") {
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId); lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
} else { } else {
return next( return next(

View File

@@ -78,16 +78,10 @@ export async function getOrgUsage(
// Get usage for org // Get usage for org
const usageData = []; const usageData = [];
const sites = await usageService.getUsage( const sites = await usageService.getUsage(orgId, FeatureId.SITES);
orgId, const users = await usageService.getUsage(orgId, FeatureId.USERS);
FeatureId.SITES const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS);
); const remoteExitNodes = await usageService.getUsage(
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily(
orgId,
FeatureId.DOMAINS
);
const remoteExitNodes = await usageService.getUsageDaily(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES FeatureId.REMOTE_EXIT_NODES
); );

View File

@@ -21,7 +21,7 @@ import {
} from "@server/lib/billing/features"; } from "@server/lib/billing/features";
import Stripe from "stripe"; import Stripe from "stripe";
export type SubscriptionType = "home_lab" | "starter" | "scale" | "license"; export type SubscriptionType = "tier1" | "tier2" | "tier3" | "license";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null { export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
// Determine subscription type by checking subscription items // Determine subscription type by checking subscription items
@@ -41,19 +41,19 @@ export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription
// Check if price ID matches home lab tier // Check if price ID matches home lab tier
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet()); const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
if (homeLabPrices.includes(priceId)) { if (homeLabPrices.includes(priceId)) {
return "home_lab"; return "tier1";
} }
// Check if price ID matches starter tier // Check if price ID matches tier2 tier
const starterPrices = Object.values(getStarterFeaturePriceSet()); const tier2Prices = Object.values(getStarterFeaturePriceSet());
if (starterPrices.includes(priceId)) { if (tier2Prices.includes(priceId)) {
return "starter"; return "tier2";
} }
// Check if price ID matches scale tier // Check if price ID matches tier3 tier
const scalePrices = Object.values(getScaleFeaturePriceSet()); const tier3Prices = Object.values(getScaleFeaturePriceSet());
if (scalePrices.includes(priceId)) { if (tier3Prices.includes(priceId)) {
return "scale"; return "tier3";
} }
} }

View File

@@ -91,6 +91,7 @@ export async function handleSubscriptionCreated(
} }
return { return {
stripeSubscriptionItemId: item.id,
subscriptionId: subscription.id, subscriptionId: subscription.id,
planId: item.plan.id, planId: item.plan.id,
priceId: item.price.id, priceId: item.price.id,
@@ -132,7 +133,7 @@ export async function handleSubscriptionCreated(
return; return;
} }
if (type === "home_lab" || type === "starter" || type === "scale") { if (type === "tier1" || type === "tier2" || type === "tier3") {
logger.debug( logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
); );

View File

@@ -76,7 +76,7 @@ export async function handleSubscriptionDeleted(
} }
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
if (type == "home_lab" || type == "starter" || type == "scale") { if (type == "tier1" || type == "tier2" || type == "tier3") {
logger.debug( logger.debug(
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` `Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
); );

View File

@@ -82,6 +82,7 @@ export async function handleSubscriptionUpdated(
// Upsert subscription items // Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) { if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({ const itemsToUpsert = fullSubscription.items.data.map((item) => ({
stripeSubscriptionItemId: item.id,
subscriptionId: subscription.id, subscriptionId: subscription.id,
planId: item.plan.id, planId: item.plan.id,
priceId: item.price.id, priceId: item.price.id,
@@ -237,7 +238,7 @@ export async function handleSubscriptionUpdated(
} }
// --- end usage update --- // --- end usage update ---
if (type === "home_lab" || type === "starter" || type === "scale") { if (type === "tier1" || type === "tier2" || type === "tier3") {
logger.debug( logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
); );

View File

@@ -14,8 +14,8 @@
import { import {
freeLimitSet, freeLimitSet,
homeLabLimitSet, homeLabLimitSet,
starterLimitSet, tier2LimitSet,
scaleLimitSet, tier3LimitSet,
limitsService, limitsService,
LimitSet LimitSet
} from "@server/lib/billing"; } from "@server/lib/billing";
@@ -24,16 +24,16 @@ import { SubscriptionType } from "./hooks/getSubType";
function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet { function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet {
switch (subType) { switch (subType) {
case "home_lab": case "tier1":
return homeLabLimitSet; return homeLabLimitSet;
case "starter": case "tier2":
return starterLimitSet; return tier2LimitSet;
case "scale": case "tier3":
return scaleLimitSet; return tier3LimitSet;
case "license": case "license":
// License subscriptions use starter limits by default // License subscriptions use tier2 limits by default
// This can be adjusted based on your business logic // This can be adjusted based on your business logic
return starterLimitSet; return tier2LimitSet;
default: default:
return freeLimitSet; return freeLimitSet;
} }

View File

@@ -224,7 +224,7 @@ export async function createRemoteExitNode(
}); });
if (numExitNodeOrgs) { if (numExitNodeOrgs) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length numExitNodeOrgs.length

View File

@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
}); });
if (numExitNodeOrgs) { if (numExitNodeOrgs) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length numExitNodeOrgs.length

View File

@@ -354,7 +354,7 @@ export async function createOrgDomain(
}); });
if (numOrgDomains) { if (numOrgDomains) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.DOMAINS, FeatureId.DOMAINS,
numOrgDomains.length numOrgDomains.length

View File

@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
}); });
if (numOrgDomains) { if (numOrgDomains) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.DOMAINS, FeatureId.DOMAINS,
numOrgDomains.length numOrgDomains.length

View File

@@ -587,7 +587,7 @@ export async function validateOidcCallback(
}); });
for (const orgCount of orgUserCounts) { for (const orgCount of orgUserCounts) {
await usageService.updateDaily( await usageService.updateCount(
orgCount.orgId, orgCount.orgId,
FeatureId.USERS, FeatureId.USERS,
orgCount.userCount orgCount.userCount

View File

@@ -107,7 +107,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
FeatureId.EGRESS_DATA_MB FeatureId.EGRESS_DATA_MB
); );
// Do we need to check the users and domains daily limits here? // Do we need to check the users and domains count limits here?
// const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS);
// const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);

View File

@@ -271,7 +271,7 @@ export async function createOrg(
// make sure we have the stripe customer // make sure we have the stripe customer
const customerId = await createCustomer(orgId, req.user?.email); const customerId = await createCustomer(orgId, req.user?.email);
if (customerId) { if (customerId) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.USERS, FeatureId.USERS,
1, 1,

View File

@@ -440,7 +440,7 @@ export async function createSite(
}); });
if (numSites) { if (numSites) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.SITES, FeatureId.SITES,
numSites.length numSites.length

View File

@@ -110,7 +110,7 @@ export async function deleteSite(
}); });
if (numSites) { if (numSites) {
await usageService.updateDaily( await usageService.updateCount(
site.orgId, site.orgId,
FeatureId.SITES, FeatureId.SITES,
numSites.length numSites.length

View File

@@ -155,17 +155,19 @@ export async function acceptInvite(
.delete(userInvites) .delete(userInvites)
.where(eq(userInvites.inviteId, inviteId)); .where(eq(userInvites.inviteId, inviteId));
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
// Get the total number of users in the org now // Get the total number of users in the org now
totalUsers = await db totalUsers = await trx
.select() .select()
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.orgId, existingInvite.orgId)); .where(eq(userOrgs.orgId, existingInvite.orgId));
await calculateUserClientsForOrgs(existingUser[0].userId, trx); logger.debug(`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`);
}); });
if (totalUsers) { if (totalUsers) {
await usageService.updateDaily( await usageService.updateCount(
existingInvite.orgId, existingInvite.orgId,
FeatureId.USERS, FeatureId.USERS,
totalUsers.length totalUsers.length

View File

@@ -254,7 +254,7 @@ export async function createOrgUser(
}); });
if (orgUsers) { if (orgUsers) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.USERS, FeatureId.USERS,
orgUsers.length orgUsers.length

View File

@@ -140,7 +140,7 @@ export async function removeUserOrg(
}); });
if (userCount) { if (userCount) {
await usageService.updateDaily( await usageService.updateCount(
orgId, orgId,
FeatureId.USERS, FeatureId.USERS,
userCount.length userCount.length

162
server/setup/migrations.ts Normal file
View File

@@ -0,0 +1,162 @@
#! /usr/bin/env node
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db } from "../db/pg";
import semver from "semver";
import { versionMigrations } from "../db/pg";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import path from "path";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0";
import m5 from "./scriptsPg/1.10.0";
import m6 from "./scriptsPg/1.10.2";
import m7 from "./scriptsPg/1.11.0";
import m8 from "./scriptsPg/1.11.1";
import m9 from "./scriptsPg/1.12.0";
import m10 from "./scriptsPg/1.13.0";
import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions
const migrations = [
{ version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 },
{ version: "1.10.0", run: m5 },
{ version: "1.10.2", run: m6 },
{ version: "1.11.0", run: m7 },
{ version: "1.11.1", run: m8 },
{ version: "1.12.0", run: m9 },
{ version: "1.13.0", run: m10 },
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 }
// Add new migrations here as they are created
] as {
version: string;
run: () => Promise<void>;
}[];
await run();
async function run() {
// run the migrations
await runMigrations();
}
export async function runMigrations() {
if (process.env.DISABLE_MIGRATIONS) {
console.log("Migrations are disabled. Skipping...");
return;
}
try {
const appVersion = APP_VERSION;
// determine if the migrations table exists
const exists = await db
.select()
.from(versionMigrations)
.limit(1)
.execute()
.then((res) => res.length > 0)
.catch(() => false);
if (exists) {
console.log("Migrations table exists, running scripts...");
await executeScripts();
} else {
console.log("Migrations table does not exist, creating it...");
console.log("Running migrations...");
try {
await migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
await migration.run();
// Update version in database
await db
.insert(versionMigrations)
.values({
version: migration.version,
executedAt: Date.now()
})
.execute();
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (e) {
if (
e instanceof Error &&
typeof (e as any).code === "string" &&
(e as any).code === "23505"
) {
console.error("Migration has already run! Skipping...");
continue; // or return, depending on context
}
console.error(
`Failed to run migration ${migration.version}:`,
e
);
throw e;
}
}
console.log("All migrations completed successfully");
} catch (error) {
console.error("Migration process failed:", error);
throw error;
}
}

View File

@@ -58,7 +58,7 @@ interface PlanOption {
name: string; name: string;
price: string; price: string;
priceDetail?: string; priceDetail?: string;
tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types tierType: "tier1" | "tier2" | "tier3" | null; // Maps to backend tier types
} }
// Tier limits for display in confirmation dialog // Tier limits for display in confirmation dialog
@@ -69,20 +69,20 @@ interface TierLimits {
remoteNodes: number; remoteNodes: number;
} }
const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = { const tierLimits: Record<"tier1" | "tier2" | "tier3", TierLimits> = {
home_lab: { tier1: {
sites: 3, sites: 3,
users: 3, users: 3,
domains: 3, domains: 3,
remoteNodes: 1 remoteNodes: 1
}, },
starter: { tier2: {
sites: 10, sites: 10,
users: 150, users: 150,
domains: 250, domains: 250,
remoteNodes: 5 remoteNodes: 5
}, },
scale: { tier3: {
sites: 10, sites: 10,
users: 150, users: 150,
domains: 250, domains: 250,
@@ -102,21 +102,21 @@ const planOptions: PlanOption[] = [
name: "Homelab", name: "Homelab",
price: "$15", price: "$15",
priceDetail: "/ month", priceDetail: "/ month",
tierType: "home_lab" tierType: "tier1"
}, },
{ {
id: "team", id: "team",
name: "Team", name: "Team",
price: "$5", price: "$5",
priceDetail: "per user / month", priceDetail: "per user / month",
tierType: "starter" tierType: "tier2"
}, },
{ {
id: "business", id: "business",
name: "Business", name: "Business",
price: "$10", price: "$10",
priceDetail: "per user / month", priceDetail: "per user / month",
tierType: "scale" tierType: "tier3"
}, },
{ {
id: "enterprise", id: "enterprise",
@@ -155,7 +155,7 @@ export default function BillingPage() {
const [hasSubscription, setHasSubscription] = useState(false); const [hasSubscription, setHasSubscription] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentTier, setCurrentTier] = useState< const [currentTier, setCurrentTier] = useState<
"home_lab" | "starter" | "scale" | null "tier1" | "tier2" | "tier3" | null
>(null); >(null);
// Usage IDs // Usage IDs
@@ -167,7 +167,7 @@ export default function BillingPage() {
// Confirmation dialog state // Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTier, setPendingTier] = useState<{ const [pendingTier, setPendingTier] = useState<{
tier: "home_lab" | "starter" | "scale"; tier: "tier1" | "tier2" | "tier3";
action: "upgrade" | "downgrade"; action: "upgrade" | "downgrade";
planName: string; planName: string;
price: string; price: string;
@@ -186,18 +186,18 @@ export default function BillingPage() {
// Find tier subscription // Find tier subscription
const tierSub = subscriptions.find( const tierSub = subscriptions.find(
({ subscription }) => ({ subscription }) =>
subscription?.type === "home_lab" || subscription?.type === "tier1" ||
subscription?.type === "starter" || subscription?.type === "tier2" ||
subscription?.type === "scale" subscription?.type === "tier3"
); );
setTierSubscription(tierSub || null); setTierSubscription(tierSub || null);
if (tierSub?.subscription) { if (tierSub?.subscription) {
setCurrentTier( setCurrentTier(
tierSub.subscription.type as tierSub.subscription.type as
| "home_lab" | "tier1"
| "starter" | "tier2"
| "scale" | "tier3"
); );
setHasSubscription( setHasSubscription(
tierSub.subscription.status === "active" tierSub.subscription.status === "active"
@@ -243,7 +243,7 @@ export default function BillingPage() {
}, [org.org.orgId]); }, [org.org.orgId]);
const handleStartSubscription = async ( const handleStartSubscription = async (
tier: "home_lab" | "starter" | "scale" tier: "tier1" | "tier2" | "tier3"
) => { ) => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -300,7 +300,7 @@ export default function BillingPage() {
} }
}; };
const handleChangeTier = async (tier: "home_lab" | "starter" | "scale") => { const handleChangeTier = async (tier: "tier1" | "tier2" | "tier3") => {
if (!hasSubscription) { if (!hasSubscription) {
// If no subscription, start a new one // If no subscription, start a new one
handleStartSubscription(tier); handleStartSubscription(tier);
@@ -343,7 +343,7 @@ export default function BillingPage() {
}; };
const showTierConfirmation = ( const showTierConfirmation = (
tier: "home_lab" | "starter" | "scale", tier: "tier1" | "tier2" | "tier3",
action: "upgrade" | "downgrade", action: "upgrade" | "downgrade",
planName: string, planName: string,
price: string price: string
@@ -453,8 +453,8 @@ export default function BillingPage() {
// Calculate current usage cost for display // Calculate current usage cost for display
const getUserCount = () => getUsageValue(USERS); const getUserCount = () => getUsageValue(USERS);
const getPricePerUser = () => { const getPricePerUser = () => {
if (currentTier === "starter") return 5; if (currentTier === "tier2") return 5;
if (currentTier === "scale") return 10; if (currentTier === "tier3") return 10;
return 0; return 0;
}; };

View File

@@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try { try {
const subRes = await getCachedSubscription(orgId); const subRes = await getCachedSubscription(orgId);
subscribed = subscribed =
(subRes.data.data.tier == "home_lab" || subRes.data.data.tier == "starter" || subRes.data.data.tier == "scale") && (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") &&
subRes.data.data.active; subRes.data.data.active;
} catch {} } catch {}
} }

View File

@@ -46,9 +46,9 @@ export function SubscriptionStatusProvider({
// Iterate through all subscriptions // Iterate through all subscriptions
for (const { subscription } of subscriptionStatus.subscriptions) { for (const { subscription } of subscriptionStatus.subscriptions) {
if ( if (
subscription.type == "home_lab" || subscription.type == "tier1" ||
subscription.type == "starter" || subscription.type == "tier2" ||
subscription.type == "scale" subscription.type == "tier3"
) { ) {
return { return {
tier: subscription.type, tier: subscription.type,
@@ -70,7 +70,7 @@ export function SubscriptionStatusProvider({
} }
const { tier, active } = getTier(); const { tier, active } = getTier();
return ( return (
(tier == "home_lab" || tier == "starter" || tier == "scale") && (tier == "tier1" || tier == "tier2" || tier == "tier3") &&
active active
); );
}; };