mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-09 06:26:40 +00:00
Rename tiers and get working
This commit is contained in:
@@ -7,8 +7,8 @@ services:
|
||||
POSTGRES_DB: postgres # Default database name
|
||||
POSTGRES_USER: postgres # Default user
|
||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||
volumes:
|
||||
- ./config/postgres:/var/lib/postgresql/data
|
||||
# volumes:
|
||||
# - ./config/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432" # Map host port 5432 to container port 5432
|
||||
restart: no
|
||||
|
||||
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
@@ -82,12 +82,14 @@ export const subscriptions = pgTable("subscriptions", {
|
||||
canceledAt: bigint("canceledAt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||
version: integer("version"),
|
||||
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", {
|
||||
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
||||
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
|
||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
|
||||
@@ -70,8 +70,9 @@ export const subscriptions = sqliteTable("subscriptions", {
|
||||
canceledAt: integer("canceledAt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt"),
|
||||
version: integer("version"),
|
||||
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", {
|
||||
|
||||
@@ -7,9 +7,29 @@ export enum FeatureId {
|
||||
EGRESS_DATA_MB = "egressDataMb",
|
||||
DOMAINS = "domains",
|
||||
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
|
||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||
};
|
||||
@@ -40,11 +60,11 @@ export function getFeatureIdByMetricId(
|
||||
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||
|
||||
export const homeLabFeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||
};
|
||||
|
||||
export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||
};
|
||||
|
||||
export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
|
||||
@@ -58,11 +78,11 @@ export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
|
||||
}
|
||||
}
|
||||
|
||||
export const starterFeaturePriceSet: FeaturePriceSet = {
|
||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||
};
|
||||
|
||||
export const starterFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||
};
|
||||
|
||||
@@ -71,17 +91,17 @@ export function getStarterFeaturePriceSet(): FeaturePriceSet {
|
||||
process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true"
|
||||
) {
|
||||
return starterFeaturePriceSet;
|
||||
return tier2FeaturePriceSet;
|
||||
} else {
|
||||
return starterFeaturePriceSetSandbox;
|
||||
return tier2FeaturePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
|
||||
export const scaleFeaturePriceSet: FeaturePriceSet = {
|
||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||
};
|
||||
|
||||
export const scaleFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||
};
|
||||
|
||||
@@ -90,9 +110,9 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet {
|
||||
process.env.ENVIRONMENT == "prod" &&
|
||||
process.env.SANDBOX_MODE !== "true"
|
||||
) {
|
||||
return scaleFeaturePriceSet;
|
||||
return tier3FeaturePriceSet;
|
||||
} else {
|
||||
return scaleFeaturePriceSetSandbox;
|
||||
return tier3FeaturePriceSetSandbox;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +120,14 @@ export async function getLineItems(
|
||||
featurePriceSet: FeaturePriceSet,
|
||||
orgId: string,
|
||||
): 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]) => {
|
||||
let quantity: number | undefined;
|
||||
|
||||
if (featureId === FeatureId.USERS) {
|
||||
quantity = users?.instantaneousValue || 1;
|
||||
} else if (featureId === FeatureId.HOME_LAB) {
|
||||
} else if (featureId === FeatureId.TIER1) {
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const homeLabLimitSet: LimitSet = {
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" }
|
||||
};
|
||||
|
||||
export const starterLimitSet: LimitSet = {
|
||||
export const tier2LimitSet: LimitSet = {
|
||||
[FeatureId.SITES]: {
|
||||
value: 10,
|
||||
description: "Starter limit"
|
||||
@@ -60,7 +60,7 @@ export const starterLimitSet: LimitSet = {
|
||||
}
|
||||
};
|
||||
|
||||
export const scaleLimitSet: LimitSet = {
|
||||
export const tier3LimitSet: LimitSet = {
|
||||
[FeatureId.SITES]: {
|
||||
value: 10,
|
||||
description: "Scale limit"
|
||||
|
||||
@@ -50,7 +50,8 @@ export class UsageService {
|
||||
|
||||
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 &&
|
||||
this.bucketName
|
||||
) {
|
||||
@@ -220,7 +221,7 @@ export class UsageService {
|
||||
return new Date(date * 1000).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
async updateDaily(
|
||||
async updateCount(
|
||||
orgId: string,
|
||||
featureId: FeatureId,
|
||||
value?: number,
|
||||
@@ -246,8 +247,6 @@ export class UsageService {
|
||||
value = this.truncateValue(value);
|
||||
}
|
||||
|
||||
const today = this.getTodayDateString();
|
||||
|
||||
let currentUsage: Usage | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -261,57 +260,23 @@ export class UsageService {
|
||||
.limit(1);
|
||||
|
||||
if (currentUsage) {
|
||||
const lastUpdateDate = this.getDateString(
|
||||
currentUsage.updatedAt
|
||||
);
|
||||
const currentRunningTotal = currentUsage.latestValue;
|
||||
const lastDailyValue = currentUsage.instantaneousValue || 0;
|
||||
|
||||
if (value == undefined || value === null) {
|
||||
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));
|
||||
}
|
||||
await trx
|
||||
.update(usage)
|
||||
.set({
|
||||
instantaneousValue: value,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(usage.usageId, usageId));
|
||||
} else {
|
||||
// First record for this meter
|
||||
const meterId = getFeatureMeterId(featureId);
|
||||
const truncatedValue = this.truncateValue(value || 0);
|
||||
await trx.insert(usage).values({
|
||||
usageId,
|
||||
featureId,
|
||||
orgId,
|
||||
meterId,
|
||||
instantaneousValue: truncatedValue,
|
||||
latestValue: truncatedValue,
|
||||
instantaneousValue: value || 0,
|
||||
latestValue: value || 0,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
@@ -322,7 +287,7 @@ export class UsageService {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to update daily usage for ${orgId}/${featureId}:`,
|
||||
`Failed to update count usage for ${orgId}/${featureId}:`,
|
||||
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> {
|
||||
if (this.events.length > 0) {
|
||||
// Force upload regardless of time
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function createUserAccountOrg(
|
||||
const customerId = await createCustomer(orgId, userEmail);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -17,8 +17,8 @@ import { eq, and, ne } from "drizzle-orm";
|
||||
|
||||
export async function getOrgTierData(
|
||||
orgId: string
|
||||
): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> {
|
||||
let tier: "home_lab" | "starter" | "scale" | null = null;
|
||||
): Promise<{ tier: "tier1" | "tier2" | "tier3" | null; active: boolean }> {
|
||||
let tier: "tier1" | "tier2" | "tier3" | null = null;
|
||||
let active = false;
|
||||
|
||||
if (build !== "saas") {
|
||||
@@ -50,9 +50,9 @@ export async function getOrgTierData(
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "home_lab" ||
|
||||
subscription.type === "starter" ||
|
||||
subscription.type === "scale"
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
||||
|
||||
if (build === "saas") {
|
||||
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;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getOrgTierData } from "#private/lib/billing";
|
||||
export async function isSubscribed(orgId: string): Promise<boolean> {
|
||||
if (build === "saas") {
|
||||
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;
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function verifyValidSubscription(
|
||||
}
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -35,7 +35,7 @@ const changeTierSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const changeTierBodySchema = z.strictObject({
|
||||
tier: z.enum(["home_lab", "starter", "scale"])
|
||||
tier: z.enum(["tier1", "tier2", "tier3"])
|
||||
});
|
||||
|
||||
export async function changeTier(
|
||||
@@ -93,9 +93,9 @@ export async function changeTier(
|
||||
eq(subscriptions.customerId, customer.customerId),
|
||||
eq(subscriptions.status, "active"),
|
||||
or(
|
||||
eq(subscriptions.type, "home_lab"),
|
||||
eq(subscriptions.type, "starter"),
|
||||
eq(subscriptions.type, "scale")
|
||||
eq(subscriptions.type, "tier1"),
|
||||
eq(subscriptions.type, "tier2"),
|
||||
eq(subscriptions.type, "tier3")
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -112,11 +112,11 @@ export async function changeTier(
|
||||
|
||||
// Get the target tier's price set
|
||||
let targetPriceSet: FeaturePriceSet;
|
||||
if (tier === "home_lab") {
|
||||
if (tier === "tier1") {
|
||||
targetPriceSet = getHomeLabFeaturePriceSet();
|
||||
} else if (tier === "starter") {
|
||||
} else if (tier === "tier2") {
|
||||
targetPriceSet = getStarterFeaturePriceSet();
|
||||
} else if (tier === "scale") {
|
||||
} else if (tier === "tier3") {
|
||||
targetPriceSet = getScaleFeaturePriceSet();
|
||||
} else {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
|
||||
@@ -148,11 +148,11 @@ export async function changeTier(
|
||||
);
|
||||
|
||||
// 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 switchingProducts =
|
||||
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
|
||||
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
|
||||
(currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) ||
|
||||
((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1");
|
||||
|
||||
let updatedSubscription;
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function changeTier(
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Same product, different price tier (starter <-> scale)
|
||||
// Same product, different price tier (tier2 <-> tier3)
|
||||
// We can simply update the price
|
||||
logger.info(
|
||||
`Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
|
||||
|
||||
@@ -31,7 +31,7 @@ const createCheckoutSessionSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const createCheckoutSessionBodySchema = z.strictObject({
|
||||
tier: z.enum(["home_lab", "starter", "scale"]),
|
||||
tier: z.enum(["tier1", "tier2", "tier3"]),
|
||||
});
|
||||
|
||||
export async function createCheckoutSession(
|
||||
@@ -83,11 +83,11 @@ export async function createCheckoutSession(
|
||||
}
|
||||
|
||||
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||
if (tier === "home_lab") {
|
||||
if (tier === "tier1") {
|
||||
lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId);
|
||||
} else if (tier === "starter") {
|
||||
} else if (tier === "tier2") {
|
||||
lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId);
|
||||
} else if (tier === "scale") {
|
||||
} else if (tier === "tier3") {
|
||||
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
|
||||
} else {
|
||||
return next(
|
||||
|
||||
@@ -78,16 +78,10 @@ export async function getOrgUsage(
|
||||
// Get usage for org
|
||||
const usageData = [];
|
||||
|
||||
const sites = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.SITES
|
||||
);
|
||||
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
|
||||
const domains = await usageService.getUsageDaily(
|
||||
orgId,
|
||||
FeatureId.DOMAINS
|
||||
);
|
||||
const remoteExitNodes = await usageService.getUsageDaily(
|
||||
const sites = await usageService.getUsage(orgId, FeatureId.SITES);
|
||||
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS);
|
||||
const remoteExitNodes = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@server/lib/billing/features";
|
||||
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 {
|
||||
// Determine subscription type by checking subscription items
|
||||
@@ -41,21 +41,21 @@ export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription
|
||||
// Check if price ID matches home lab tier
|
||||
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
|
||||
if (homeLabPrices.includes(priceId)) {
|
||||
return "home_lab";
|
||||
return "tier1";
|
||||
}
|
||||
|
||||
// Check if price ID matches starter tier
|
||||
const starterPrices = Object.values(getStarterFeaturePriceSet());
|
||||
if (starterPrices.includes(priceId)) {
|
||||
return "starter";
|
||||
// Check if price ID matches tier2 tier
|
||||
const tier2Prices = Object.values(getStarterFeaturePriceSet());
|
||||
if (tier2Prices.includes(priceId)) {
|
||||
return "tier2";
|
||||
}
|
||||
|
||||
// Check if price ID matches scale tier
|
||||
const scalePrices = Object.values(getScaleFeaturePriceSet());
|
||||
if (scalePrices.includes(priceId)) {
|
||||
return "scale";
|
||||
// Check if price ID matches tier3 tier
|
||||
const tier3Prices = Object.values(getScaleFeaturePriceSet());
|
||||
if (tier3Prices.includes(priceId)) {
|
||||
return "tier3";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function handleSubscriptionCreated(
|
||||
}
|
||||
|
||||
return {
|
||||
stripeSubscriptionItemId: item.id,
|
||||
subscriptionId: subscription.id,
|
||||
planId: item.plan.id,
|
||||
priceId: item.price.id,
|
||||
@@ -132,7 +133,7 @@ export async function handleSubscriptionCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "home_lab" || type === "starter" || type === "scale") {
|
||||
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||
logger.debug(
|
||||
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function handleSubscriptionDeleted(
|
||||
}
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type == "home_lab" || type == "starter" || type == "scale") {
|
||||
if (type == "tier1" || type == "tier2" || type == "tier3") {
|
||||
logger.debug(
|
||||
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function handleSubscriptionUpdated(
|
||||
// Upsert subscription items
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
|
||||
stripeSubscriptionItemId: item.id,
|
||||
subscriptionId: subscription.id,
|
||||
planId: item.plan.id,
|
||||
priceId: item.price.id,
|
||||
@@ -237,7 +238,7 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
// --- end usage update ---
|
||||
|
||||
if (type === "home_lab" || type === "starter" || type === "scale") {
|
||||
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||
logger.debug(
|
||||
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
|
||||
);
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
import {
|
||||
freeLimitSet,
|
||||
homeLabLimitSet,
|
||||
starterLimitSet,
|
||||
scaleLimitSet,
|
||||
tier2LimitSet,
|
||||
tier3LimitSet,
|
||||
limitsService,
|
||||
LimitSet
|
||||
} from "@server/lib/billing";
|
||||
@@ -24,16 +24,16 @@ import { SubscriptionType } from "./hooks/getSubType";
|
||||
|
||||
function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet {
|
||||
switch (subType) {
|
||||
case "home_lab":
|
||||
case "tier1":
|
||||
return homeLabLimitSet;
|
||||
case "starter":
|
||||
return starterLimitSet;
|
||||
case "scale":
|
||||
return scaleLimitSet;
|
||||
case "tier2":
|
||||
return tier2LimitSet;
|
||||
case "tier3":
|
||||
return tier3LimitSet;
|
||||
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
|
||||
return starterLimitSet;
|
||||
return tier2LimitSet;
|
||||
default:
|
||||
return freeLimitSet;
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function createRemoteExitNode(
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
|
||||
@@ -354,7 +354,7 @@ export async function createOrgDomain(
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
|
||||
@@ -587,7 +587,7 @@ export async function validateOidcCallback(
|
||||
});
|
||||
|
||||
for (const orgCount of orgUserCounts) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgCount.orgId,
|
||||
FeatureId.USERS,
|
||||
orgCount.userCount
|
||||
|
||||
@@ -107,7 +107,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
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 rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS);
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ export async function createOrg(
|
||||
// make sure we have the stripe customer
|
||||
const customerId = await createCustomer(orgId, req.user?.email);
|
||||
if (customerId) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
1,
|
||||
|
||||
@@ -440,7 +440,7 @@ export async function createSite(
|
||||
});
|
||||
|
||||
if (numSites) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.SITES,
|
||||
numSites.length
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function deleteSite(
|
||||
});
|
||||
|
||||
if (numSites) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
site.orgId,
|
||||
FeatureId.SITES,
|
||||
numSites.length
|
||||
|
||||
@@ -155,17 +155,19 @@ export async function acceptInvite(
|
||||
.delete(userInvites)
|
||||
.where(eq(userInvites.inviteId, inviteId));
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
||||
|
||||
// Get the total number of users in the org now
|
||||
totalUsers = await db
|
||||
totalUsers = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.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) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
existingInvite.orgId,
|
||||
FeatureId.USERS,
|
||||
totalUsers.length
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function createOrgUser(
|
||||
});
|
||||
|
||||
if (orgUsers) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
orgUsers.length
|
||||
|
||||
@@ -140,7 +140,7 @@ export async function removeUserOrg(
|
||||
});
|
||||
|
||||
if (userCount) {
|
||||
await usageService.updateDaily(
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
userCount.length
|
||||
|
||||
162
server/setup/migrations.ts
Normal file
162
server/setup/migrations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ interface PlanOption {
|
||||
name: string;
|
||||
price: 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
|
||||
@@ -69,20 +69,20 @@ interface TierLimits {
|
||||
remoteNodes: number;
|
||||
}
|
||||
|
||||
const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = {
|
||||
home_lab: {
|
||||
const tierLimits: Record<"tier1" | "tier2" | "tier3", TierLimits> = {
|
||||
tier1: {
|
||||
sites: 3,
|
||||
users: 3,
|
||||
domains: 3,
|
||||
remoteNodes: 1
|
||||
},
|
||||
starter: {
|
||||
tier2: {
|
||||
sites: 10,
|
||||
users: 150,
|
||||
domains: 250,
|
||||
remoteNodes: 5
|
||||
},
|
||||
scale: {
|
||||
tier3: {
|
||||
sites: 10,
|
||||
users: 150,
|
||||
domains: 250,
|
||||
@@ -102,21 +102,21 @@ const planOptions: PlanOption[] = [
|
||||
name: "Homelab",
|
||||
price: "$15",
|
||||
priceDetail: "/ month",
|
||||
tierType: "home_lab"
|
||||
tierType: "tier1"
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: "Team",
|
||||
price: "$5",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "starter"
|
||||
tierType: "tier2"
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price: "$10",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "scale"
|
||||
tierType: "tier3"
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
@@ -155,7 +155,7 @@ export default function BillingPage() {
|
||||
const [hasSubscription, setHasSubscription] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentTier, setCurrentTier] = useState<
|
||||
"home_lab" | "starter" | "scale" | null
|
||||
"tier1" | "tier2" | "tier3" | null
|
||||
>(null);
|
||||
|
||||
// Usage IDs
|
||||
@@ -167,7 +167,7 @@ export default function BillingPage() {
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingTier, setPendingTier] = useState<{
|
||||
tier: "home_lab" | "starter" | "scale";
|
||||
tier: "tier1" | "tier2" | "tier3";
|
||||
action: "upgrade" | "downgrade";
|
||||
planName: string;
|
||||
price: string;
|
||||
@@ -186,18 +186,18 @@ export default function BillingPage() {
|
||||
// Find tier subscription
|
||||
const tierSub = subscriptions.find(
|
||||
({ subscription }) =>
|
||||
subscription?.type === "home_lab" ||
|
||||
subscription?.type === "starter" ||
|
||||
subscription?.type === "scale"
|
||||
subscription?.type === "tier1" ||
|
||||
subscription?.type === "tier2" ||
|
||||
subscription?.type === "tier3"
|
||||
);
|
||||
setTierSubscription(tierSub || null);
|
||||
|
||||
if (tierSub?.subscription) {
|
||||
setCurrentTier(
|
||||
tierSub.subscription.type as
|
||||
| "home_lab"
|
||||
| "starter"
|
||||
| "scale"
|
||||
| "tier1"
|
||||
| "tier2"
|
||||
| "tier3"
|
||||
);
|
||||
setHasSubscription(
|
||||
tierSub.subscription.status === "active"
|
||||
@@ -243,7 +243,7 @@ export default function BillingPage() {
|
||||
}, [org.org.orgId]);
|
||||
|
||||
const handleStartSubscription = async (
|
||||
tier: "home_lab" | "starter" | "scale"
|
||||
tier: "tier1" | "tier2" | "tier3"
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
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 no subscription, start a new one
|
||||
handleStartSubscription(tier);
|
||||
@@ -343,7 +343,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const showTierConfirmation = (
|
||||
tier: "home_lab" | "starter" | "scale",
|
||||
tier: "tier1" | "tier2" | "tier3",
|
||||
action: "upgrade" | "downgrade",
|
||||
planName: string,
|
||||
price: string
|
||||
@@ -453,8 +453,8 @@ export default function BillingPage() {
|
||||
// Calculate current usage cost for display
|
||||
const getUserCount = () => getUsageValue(USERS);
|
||||
const getPricePerUser = () => {
|
||||
if (currentTier === "starter") return 5;
|
||||
if (currentTier === "scale") return 10;
|
||||
if (currentTier === "tier2") return 5;
|
||||
if (currentTier === "tier3") return 10;
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||
try {
|
||||
const subRes = await getCachedSubscription(orgId);
|
||||
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;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ export function SubscriptionStatusProvider({
|
||||
// Iterate through all subscriptions
|
||||
for (const { subscription } of subscriptionStatus.subscriptions) {
|
||||
if (
|
||||
subscription.type == "home_lab" ||
|
||||
subscription.type == "starter" ||
|
||||
subscription.type == "scale"
|
||||
subscription.type == "tier1" ||
|
||||
subscription.type == "tier2" ||
|
||||
subscription.type == "tier3"
|
||||
) {
|
||||
return {
|
||||
tier: subscription.type,
|
||||
@@ -70,7 +70,7 @@ export function SubscriptionStatusProvider({
|
||||
}
|
||||
const { tier, active } = getTier();
|
||||
return (
|
||||
(tier == "home_lab" || tier == "starter" || tier == "scale") &&
|
||||
(tier == "tier1" || tier == "tier2" || tier == "tier3") &&
|
||||
active
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user