Switching to new pricing - remove old feature tracking

This commit is contained in:
Owen
2026-02-06 10:47:43 -08:00
parent ac09e3aaf9
commit 34cced872f
16 changed files with 409 additions and 723 deletions

View File

@@ -0,0 +1,263 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { customers, db, subscriptions, subscriptionItems } from "@server/db";
import { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
import {
getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet,
getStarterFeaturePriceSet,
FeatureId,
type FeaturePriceSet
} from "@server/lib/billing";
const changeTierSchema = z.strictObject({
orgId: z.string()
});
const changeTierBodySchema = z.strictObject({
tier: z.enum(["home_lab", "starter", "scale"])
});
export async function changeTier(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = changeTierSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = changeTierBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { tier } = parsedBody.data;
// Get the customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
if (!customer) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
// Get the active subscription for this customer
const [subscription] = await db
.select()
.from(subscriptions)
.where(
and(
eq(subscriptions.customerId, customer.customerId),
eq(subscriptions.status, "active"),
or(
eq(subscriptions.type, "home_lab"),
eq(subscriptions.type, "starter"),
eq(subscriptions.type, "scale")
)
)
)
.limit(1);
if (!subscription) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No active subscription found for this organization"
)
);
}
// Get the target tier's price set
let targetPriceSet: FeaturePriceSet;
if (tier === "home_lab") {
targetPriceSet = getHomeLabFeaturePriceSet();
} else if (tier === "starter") {
targetPriceSet = getStarterFeaturePriceSet();
} else if (tier === "scale") {
targetPriceSet = getScaleFeaturePriceSet();
} else {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
}
// Get current subscription items from our database
const currentItems = await db
.select()
.from(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.subscriptionId
)
);
if (currentItems.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No subscription items found"
)
);
}
// Retrieve the full subscription from Stripe to get item IDs
const stripeSubscription = await stripe!.subscriptions.retrieve(
subscription.subscriptionId
);
// Determine if we're switching between different products
// home_lab uses HOME_LAB product, starter/scale use USERS product
const currentTier = subscription.type;
const switchingProducts =
(currentTier === "home_lab" && (tier === "starter" || tier === "scale")) ||
((currentTier === "starter" || currentTier === "scale") && tier === "home_lab");
let updatedSubscription;
if (switchingProducts) {
// When switching between different products, we need to:
// 1. Delete old subscription items
// 2. Add new subscription items
logger.info(
`Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
);
// Build array to delete all existing items and add new ones
const itemsToUpdate: any[] = [];
// Mark all existing items for deletion
for (const stripeItem of stripeSubscription.items.data) {
itemsToUpdate.push({
id: stripeItem.id,
deleted: true
});
}
// Add new items for the target tier
for (const [featureId, priceId] of Object.entries(targetPriceSet)) {
itemsToUpdate.push({
price: priceId
});
}
updatedSubscription = await stripe!.subscriptions.update(
subscription.subscriptionId,
{
items: itemsToUpdate,
proration_behavior: "create_prorations"
}
);
} else {
// Same product, different price tier (starter <-> scale)
// We can simply update the price
logger.info(
`Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
);
const itemsToUpdate = stripeSubscription.items.data.map(
(stripeItem) => {
// Find the corresponding item in our database
const dbItem = currentItems.find(
(item) => item.priceId === stripeItem.price.id
);
if (!dbItem) {
// Keep the existing item unchanged if we can't find it
return {
id: stripeItem.id,
price: stripeItem.price.id
};
}
// Map to the corresponding feature in the new tier
const newPriceId = targetPriceSet[FeatureId.USERS];
if (newPriceId) {
return {
id: stripeItem.id,
price: newPriceId
};
}
// If no mapping found, keep existing
return {
id: stripeItem.id,
price: stripeItem.price.id
};
}
);
updatedSubscription = await stripe!.subscriptions.update(
subscription.subscriptionId,
{
items: itemsToUpdate,
proration_behavior: "create_prorations"
}
);
}
logger.info(
`Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}`
);
return response<{ subscriptionId: string; newTier: string }>(res, {
data: {
subscriptionId: updatedSubscription.id,
newTier: tier
},
success: true,
error: false,
message: "Tier change successful",
status: HttpCode.OK
});
} catch (error) {
logger.error("Error changing tier:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while changing tier"
)
);
}
}

View File

@@ -22,13 +22,16 @@ import logger from "@server/logger";
import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing";
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing";
const createCheckoutSessionSchema = z.strictObject({
orgId: z.string()
});
const createCheckoutSessionBodySchema = z.strictObject({
tier: z.enum(["home_lab", "starter", "scale"]),
});
export async function createCheckoutSessionSAAS(
req: Request,
res: Response,
@@ -47,6 +50,18 @@ export async function createCheckoutSessionSAAS(
const { orgId } = parsedParams.data;
const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { tier } = parsedBody.data;
// check if we already have a customer for this org
const [customer] = await db
.select()
@@ -65,18 +80,23 @@ export async function createCheckoutSessionSAAS(
);
}
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
let lineItems;
if (tier === "home_lab") {
lineItems = getLineItems(getHomeLabFeaturePriceSet());
} else if (tier === "starter") {
lineItems = getLineItems(getStarterFeaturePriceSet());
} else if (tier === "scale") {
lineItems = getLineItems(getScaleFeaturePriceSet());
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")
);
}
const session = await stripe!.checkout.sessions.create({
client_reference_id: orgId, // So we can look it up the org later on the webhook
billing_address_collection: "required",
line_items: [
{
price: standardTierPrice, // Use the standard tier
quantity: 1
},
...getLineItems(getStandardFeaturePriceSet())
], // Start with the standard feature set that matches the free limits
line_items: lineItems,
customer: customer.customerId,
mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,

View File

@@ -1,35 +1,61 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
getLicensePriceSet,
} from "@server/lib/billing/licenses";
import {
getTierPriceSet,
} from "@server/lib/billing/tiers";
getHomeLabFeaturePriceSet,
getStarterFeaturePriceSet,
getScaleFeaturePriceSet,
} from "@server/lib/billing/features";
import Stripe from "stripe";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
export type SubscriptionType = "home_lab" | "starter" | "scale" | "license";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
// Determine subscription type by checking subscription items
let type: "saas" | "license" = "saas";
if (Array.isArray(fullSubscription.items?.data)) {
for (const item of fullSubscription.items.data) {
const priceId = item.price.id;
if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) {
return null;
}
// Check if price ID matches any license price
const licensePrices = Object.values(getLicensePriceSet());
for (const item of fullSubscription.items.data) {
const priceId = item.price.id;
if (licensePrices.includes(priceId)) {
type = "license";
break;
}
// Check if price ID matches any license price
const licensePrices = Object.values(getLicensePriceSet());
if (licensePrices.includes(priceId)) {
return "license";
}
// Check if price ID matches any tier price (saas)
const tierPrices = Object.values(getTierPriceSet());
// Check if price ID matches home lab tier
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
if (homeLabPrices.includes(priceId)) {
return "home_lab";
}
if (tierPrices.includes(priceId)) {
type = "saas";
break;
}
// Check if price ID matches starter tier
const starterPrices = Object.values(getStarterFeaturePriceSet());
if (starterPrices.includes(priceId)) {
return "starter";
}
// Check if price ID matches scale tier
const scalePrices = Object.values(getScaleFeaturePriceSet());
if (scalePrices.includes(priceId)) {
return "scale";
}
}
return type;
}
return null;
}

View File

@@ -59,6 +59,8 @@ export async function handleSubscriptionCreated(
return;
}
const type = getSubType(fullSubscription);
const newSubscription = {
subscriptionId: subscription.id,
customerId: subscription.customer as string,
@@ -66,7 +68,8 @@ export async function handleSubscriptionCreated(
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
createdAt: subscription.created
createdAt: subscription.created,
type: type
};
await db.insert(subscriptions).values(newSubscription);
@@ -129,10 +132,9 @@ export async function handleSubscriptionCreated(
return;
}
const type = getSubType(fullSubscription);
if (type === "saas") {
if (type === "home_lab" || type === "starter" || type === "scale") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(

View File

@@ -64,6 +64,8 @@ export async function handleSubscriptionUpdated(
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
const type = getSubType(fullSubscription);
await db
.update(subscriptions)
.set({
@@ -72,7 +74,8 @@ export async function handleSubscriptionUpdated(
? subscription.canceled_at
: null,
updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor
billingCycleAnchor: subscription.billing_cycle_anchor,
type: type
})
.where(eq(subscriptions.subscriptionId, subscription.id));
@@ -234,17 +237,16 @@ export async function handleSubscriptionUpdated(
}
// --- end usage update ---
const type = getSubType(fullSubscription);
if (type === "saas") {
if (type === "home_lab" || type === "starter" || type === "scale") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
} else {
} else if (type === "license") {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
try {
// WARNING:

View File

@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./createCheckoutSessionSAAS";
export * from "./createCheckoutSession";
export * from "./createPortalSession";
export * from "./getOrgSubscriptions";
export * from "./getOrgUsage";