Chungus 2.0

This commit is contained in:
Owen
2025-10-10 11:27:15 -07:00
parent f64a477c3d
commit d92b87b7c8
224 changed files with 1507 additions and 1586 deletions

View File

@@ -0,0 +1,101 @@
/*
* 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 } from "@server/db";
import { eq } 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 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";
const createCheckoutSessionSchema = z
.object({
orgId: z.string()
})
.strict();
export async function createCheckoutSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createCheckoutSessionSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
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
customer: customer.customerId,
mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true`
});
return response<string>(res, {
data: session.url,
success: true,
error: false,
message: "Organization created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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 { account, customers, db } from "@server/db";
import { eq } 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 config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
const createPortalSessionSchema = z
.object({
orgId: z.string()
})
.strict();
export async function createPortalSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createPortalSessionSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
let customerId: string;
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
} else {
// If we have a customer, use the existing customer ID
customerId = customer.customerId;
}
const portalSession = await stripe!.billingPortal.sessions.create({
customer: customerId,
return_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`
});
return response<string>(res, {
data: portalSession.url,
success: true,
error: false,
message: "Organization created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,157 @@
/*
* 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 { db } from "@server/db";
import { Org, orgs } from "@server/db";
import { eq } 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 { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
// Import tables for billing
import {
customers,
subscriptions,
subscriptionItems,
Subscription,
SubscriptionItem
} from "@server/db";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgSubscriptionResponse = {
subscription: Subscription | null;
items: SubscriptionItem[];
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/subscription",
description: "Get an organization",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgSubscription(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
let subscriptionData = null;
let itemsData: SubscriptionItem[] = [];
try {
const { subscription, items } = await getOrgSubscriptionData(orgId);
subscriptionData = subscription;
itemsData = items;
} catch (err) {
if ((err as Error).message === "Not found") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
throw err;
}
return response<GetOrgSubscriptionResponse>(res, {
data: {
subscription: subscriptionData,
items: itemsData
},
success: true,
error: false,
message: "Organization and subscription retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
export async function getOrgSubscriptionData(
orgId: string
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
throw new Error(`Not found`);
}
// Get customer for org
const customer = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
let subscription = null;
let items: SubscriptionItem[] = [];
if (customer.length > 0) {
// Get subscription for customer
const subs = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.customerId, customer[0].customerId))
.limit(1);
if (subs.length > 0) {
subscription = subs[0];
// Get subscription items
items = await db
.select()
.from(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.subscriptionId
)
);
}
}
return { subscription, items };
}

View File

@@ -0,0 +1,129 @@
/*
* 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 { db } from "@server/db";
import { orgs } from "@server/db";
import { eq } 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 { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { Limit, limits, Usage, usage } from "@server/db";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgUsageResponse = {
usage: Usage[];
limits: Limit[];
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/usage",
description: "Get an organization's billing usage",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgUsage(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
// Get usage for org
const usageData = [];
const siteUptime = await usageService.getUsage(orgId, FeatureId.SITE_UPTIME);
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily(orgId, FeatureId.DOMAINS);
const remoteExitNodes = await usageService.getUsageDaily(orgId, FeatureId.REMOTE_EXIT_NODES);
const egressData = await usageService.getUsage(orgId, FeatureId.EGRESS_DATA_MB);
if (siteUptime) {
usageData.push(siteUptime);
}
if (users) {
usageData.push(users);
}
if (egressData) {
usageData.push(egressData);
}
if (domains) {
usageData.push(domains);
}
if (remoteExitNodes) {
usageData.push(remoteExitNodes);
}
const orgLimits = await db.select()
.from(limits)
.where(eq(limits.orgId, orgId));
return response<GetOrgUsageResponse>(res, {
data: {
usage: usageData,
limits: orgLimits
},
success: true,
error: false,
message: "Organization usage retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerCreated(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (existingCustomer) {
logger.info(`Customer with ID ${customer.id} already exists.`);
return;
}
if (!customer.metadata.orgId) {
logger.error(
`Customer with ID ${customer.id} does not have an orgId in metadata.`
);
return;
}
await db.insert(customers).values({
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
createdAt: customer.created,
updatedAt: customer.created
});
logger.info(`Customer with ID ${customer.id} created successfully.`);
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -0,0 +1,44 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerDeleted(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (!existingCustomer) {
logger.info(`Customer with ID ${customer.id} does not exist.`);
return;
}
await db
.delete(customers)
.where(eq(customers.customerId, customer.id));
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -0,0 +1,54 @@
/*
* 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 Stripe from "stripe";
import { customers, db } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function handleCustomerUpdated(
customer: Stripe.Customer
): Promise<void> {
try {
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, customer.id))
.limit(1);
if (!existingCustomer) {
logger.info(`Customer with ID ${customer.id} does not exist.`);
return;
}
const newCustomer = {
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
updatedAt: Math.floor(Date.now() / 1000)
};
// Update the existing customer record
await db
.update(customers)
.set(newCustomer)
.where(eq(customers.customerId, customer.id));
} catch (error) {
logger.error(
`Error handling customer created event for ID ${customer.id}:`,
error
);
}
return;
}

View File

@@ -0,0 +1,153 @@
/*
* 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 Stripe from "stripe";
import {
customers,
subscriptions,
db,
subscriptionItems,
userOrgs,
users
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
): Promise<void> {
try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
logger.info(JSON.stringify(fullSubscription, null, 2));
// Check if subscription already exists
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} already exists.`
);
return;
}
const newSubscription = {
subscriptionId: subscription.id,
customerId: subscription.customer as string,
status: subscription.status,
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
createdAt: subscription.created
};
await db.insert(subscriptions).values(newSubscription);
logger.info(
`Subscription with ID ${subscription.id} created successfully.`
);
// Insert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToInsertPromises = fullSubscription.items.data.map(
async (item) => {
// try to get the product name from stripe and add it to the item
let name = null;
if (item.price.product) {
const product = await stripe!.products.retrieve(
item.price.product as string
);
name = product.name || null;
}
return {
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start,
currentPeriodEnd: item.current_period_end,
tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
: null,
interval: item.plan.interval,
name
};
}
);
// wait for all items to be processed
const itemsToInsert = await Promise.all(itemsToInsertPromises);
if (itemsToInsert.length > 0) {
await db.insert(subscriptionItems).values(itemsToInsert);
logger.info(
`Inserted ${itemsToInsert.length} subscription items for subscription ${subscription.id}.`
);
}
}
// Lookup customer to get orgId
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
if (!customer) {
logger.error(
`Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.`
);
return;
}
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Subscribed);
}
}
} catch (error) {
logger.error(
`Error handling subscription created event for ID ${subscription.id}:`,
error
);
}
return;
}

View File

@@ -0,0 +1,91 @@
/*
* 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 Stripe from "stripe";
import { subscriptions, db, subscriptionItems, customers, userOrgs, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
): Promise<void> {
try {
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (!existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} does not exist.`
);
return;
}
await db
.delete(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id));
await db
.delete(subscriptionItems)
.where(eq(subscriptionItems.subscriptionId, subscription.id));
// Lookup customer to get orgId
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
if (!customer) {
logger.error(
`Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.`
);
return;
}
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Churned);
}
}
} catch (error) {
logger.error(
`Error handling subscription updated event for ID ${subscription.id}:`,
error
);
}
return;
}

View File

@@ -0,0 +1,296 @@
/*
* 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 Stripe from "stripe";
import {
subscriptions,
db,
subscriptionItems,
usage,
sites,
customers,
orgs
} from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { getFeatureIdByMetricId } from "@server/lib/billing/features";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
previousAttributes: Partial<Stripe.Subscription> | undefined
): Promise<void> {
try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
logger.info(JSON.stringify(fullSubscription, null, 2));
const [existingSubscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id))
.limit(1);
if (!existingSubscription) {
logger.info(
`Subscription with ID ${subscription.id} does not exist.`
);
return;
}
// get the customer
const [existingCustomer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
.limit(1);
await db
.update(subscriptions)
.set({
status: subscription.status,
canceledAt: subscription.canceled_at
? subscription.canceled_at
: null,
updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor
})
.where(eq(subscriptions.subscriptionId, subscription.id));
await handleSubscriptionLifesycle(
existingCustomer.orgId,
subscription.status
);
// Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start,
currentPeriodEnd: item.current_period_end,
tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
: null,
interval: item.plan.interval
}));
if (itemsToUpsert.length > 0) {
await db.transaction(async (trx) => {
await trx
.delete(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.id
)
);
await trx.insert(subscriptionItems).values(itemsToUpsert);
});
logger.info(
`Updated ${itemsToUpsert.length} subscription items for subscription ${subscription.id}.`
);
}
// --- Detect cycled items and update usage ---
if (previousAttributes) {
// Only proceed if latest_invoice changed (per Stripe docs)
if ("latest_invoice" in previousAttributes) {
// If items array present in previous_attributes, check each item
if (Array.isArray(previousAttributes.items?.data)) {
for (const item of subscription.items.data) {
const prevItem = previousAttributes.items.data.find(
(pi: any) => pi.id === item.id
);
if (
prevItem &&
prevItem.current_period_end &&
item.current_period_start &&
prevItem.current_period_end ===
item.current_period_start &&
item.current_period_start >
prevItem.current_period_start
) {
logger.info(
`Subscription item ${item.id} has cycled. Resetting usage.`
);
} else {
continue;
}
// This item has cycled
const meterId = item.plan.meter;
if (!meterId) {
logger.warn(
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
);
continue;
}
const featureId = getFeatureIdByMetricId(meterId);
if (!featureId) {
logger.warn(
`No featureId found for meterId ${meterId}. Skipping usage reset.`
);
continue;
}
const orgId = existingCustomer.orgId;
if (!orgId) {
logger.warn(
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
);
continue;
}
await db.transaction(async (trx) => {
const [usageRow] = await trx
.select()
.from(usage)
.where(
eq(
usage.usageId,
`${orgId}-${featureId}`
)
)
.limit(1);
if (usageRow) {
// get the next rollover date
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
const lastRollover = usageRow.rolledOverAt
? new Date(usageRow.rolledOverAt * 1000)
: new Date();
const anchorDate = org.createdAt
? new Date(org.createdAt)
: new Date();
const nextRollover =
calculateNextRollOverDate(
lastRollover,
anchorDate
);
await trx
.update(usage)
.set({
previousValue: usageRow.latestValue,
latestValue:
usageRow.instantaneousValue ||
0,
updatedAt: Math.floor(
Date.now() / 1000
),
rolledOverAt: Math.floor(
Date.now() / 1000
),
nextRolloverAt: Math.floor(
nextRollover.getTime() / 1000
)
})
.where(
eq(usage.usageId, usageRow.usageId)
);
logger.info(
`Usage reset for org ${orgId}, meter ${featureId} on subscription item cycle.`
);
}
// Also reset the sites to 0
await trx
.update(sites)
.set({
megabytesIn: 0,
megabytesOut: 0
})
.where(eq(sites.orgId, orgId));
});
}
}
}
}
// --- end usage update ---
}
} catch (error) {
logger.error(
`Error handling subscription updated event for ID ${subscription.id}:`,
error
);
}
return;
}
/**
* Calculate the next billing date based on monthly billing cycle
* Handles end-of-month scenarios as described in the requirements
* Made public for testing
*/
function calculateNextRollOverDate(lastRollover: Date, anchorDate: Date): Date {
const rolloverDate = new Date(lastRollover);
const anchor = new Date(anchorDate);
// Get components from rollover date
const rolloverYear = rolloverDate.getUTCFullYear();
const rolloverMonth = rolloverDate.getUTCMonth();
// Calculate target month and year (next month)
let targetMonth = rolloverMonth + 1;
let targetYear = rolloverYear;
if (targetMonth > 11) {
targetMonth = 0;
targetYear++;
}
// Get anchor day for billing
const anchorDay = anchor.getUTCDate();
// Get the last day of the target month
const lastDayOfMonth = new Date(
Date.UTC(targetYear, targetMonth + 1, 0)
).getUTCDate();
// Use the anchor day or the last day of the month, whichever is smaller
const targetDay = Math.min(anchorDay, lastDayOfMonth);
// Create the next billing date using UTC
const nextBilling = new Date(
Date.UTC(
targetYear,
targetMonth,
targetDay,
anchor.getUTCHours(),
anchor.getUTCMinutes(),
anchor.getUTCSeconds(),
anchor.getUTCMilliseconds()
)
);
return nextBilling;
}

View File

@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from "./createCheckoutSession";
export * from "./createPortalSession";
export * from "./getOrgSubscription";
export * from "./getOrgUsage";
export * from "./internalGetOrgTier";

View File

@@ -0,0 +1,87 @@
/*
* 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { getOrgTierData } from "#private/lib/billing";
const getOrgSchema = z
.object({
orgId: z.string()
})
.strict();
export type GetOrgTierResponse = {
tier: string | null;
active: boolean;
};
export async function getOrgTier(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
let tierData = null;
let activeData = false;
try {
const { tier, active } = await getOrgTierData(orgId);
tierData = tier;
activeData = active;
} catch (err) {
if ((err as Error).message === "Not found") {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
throw err;
}
return response<GetOrgTierResponse>(res, {
data: {
tier: tierData,
active: activeData
},
success: true,
error: false,
message: "Organization and subscription retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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 { freeLimitSet, limitsService, subscribedLimitSet } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import logger from "@server/logger";
export async function handleSubscriptionLifesycle(orgId: string, status: string) {
switch (status) {
case "active":
await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "canceled":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "past_due":
// Optionally handle past due status, e.g., notify customer
break;
case "unpaid":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
case "incomplete":
// Optionally handle incomplete status, e.g., notify customer
break;
case "incomplete_expired":
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId, true);
break;
default:
break;
}
}

View File

@@ -0,0 +1,136 @@
/*
* 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 stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
import logger from "@server/logger";
import createHttpError from "http-errors";
import { response } from "@server/lib/response";
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import Stripe from "stripe";
import { handleCustomerCreated } from "./hooks/handleCustomerCreated";
import { handleSubscriptionCreated } from "./hooks/handleSubscriptionCreated";
import { handleSubscriptionUpdated } from "./hooks/handleSubscriptionUpdated";
import { handleCustomerUpdated } from "./hooks/handleCustomerUpdated";
import { handleSubscriptionDeleted } from "./hooks/handleSubscriptionDeleted";
import { handleCustomerDeleted } from "./hooks/handleCustomerDeleted";
export async function billingWebhookHandler(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
let event: Stripe.Event = req.body;
const endpointSecret = privateConfig.getRawPrivateConfig().stripe?.webhook_secret;
if (!endpointSecret) {
logger.warn("Stripe webhook secret is not configured. Webhook events will not be priocessed.");
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "")
);
}
// Only verify the event if you have an endpoint secret defined.
// Otherwise use the basic event deserialized with JSON.parse
if (endpointSecret) {
// Get the signature sent by Stripe
const signature = req.headers["stripe-signature"];
if (!signature) {
logger.info("No stripe signature found in headers.");
return next(
createHttpError(HttpCode.BAD_REQUEST, "No stripe signature found in headers")
);
}
try {
event = stripe!.webhooks.constructEvent(
req.body,
signature,
endpointSecret
);
} catch (err) {
logger.error(`Webhook signature verification failed.`, err);
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Webhook signature verification failed")
);
}
}
let subscription;
let previousAttributes;
// Handle the event
switch (event.type) {
case "customer.created":
const customer = event.data.object;
logger.info("Customer created: ", customer);
handleCustomerCreated(customer);
break;
case "customer.updated":
const customerUpdated = event.data.object;
logger.info("Customer updated: ", customerUpdated);
handleCustomerUpdated(customerUpdated);
break;
case "customer.deleted":
const customerDeleted = event.data.object;
logger.info("Customer deleted: ", customerDeleted);
handleCustomerDeleted(customerDeleted);
break;
case "customer.subscription.paused":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.resumed":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.deleted":
subscription = event.data.object;
handleSubscriptionDeleted(subscription);
break;
case "customer.subscription.created":
subscription = event.data.object;
handleSubscriptionCreated(subscription);
break;
case "customer.subscription.updated":
subscription = event.data.object;
previousAttributes = event.data.previous_attributes;
handleSubscriptionUpdated(subscription, previousAttributes);
break;
case "customer.subscription.trial_will_end":
subscription = event.data.object;
// Then define and call a method to handle the subscription trial ending.
// handleSubscriptionTrialEnding(subscription);
break;
case "entitlements.active_entitlement_summary.updated":
subscription = event.data.object;
logger.info(
`Active entitlement summary updated for ${subscription}.`
);
// Then define and call a method to handle active entitlement summary updated
// handleEntitlementUpdated(subscription);
break;
default:
// Unexpected event type
logger.info(`Unhandled event type ${event.type}.`);
}
// Return a 200 response to acknowledge receipt of the event
return response(res, {
data: null,
success: true,
error: false,
message: "Webhook event processed successfully",
status: HttpCode.CREATED
});
}