mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-25 06:16:40 +00:00
Chungus 2.0
This commit is contained in:
101
server/private/routers/billing/createCheckoutSession.ts
Normal file
101
server/private/routers/billing/createCheckoutSession.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
89
server/private/routers/billing/createPortalSession.ts
Normal file
89
server/private/routers/billing/createPortalSession.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
157
server/private/routers/billing/getOrgSubscription.ts
Normal file
157
server/private/routers/billing/getOrgSubscription.ts
Normal 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 };
|
||||
}
|
||||
129
server/private/routers/billing/getOrgUsage.ts
Normal file
129
server/private/routers/billing/getOrgUsage.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
18
server/private/routers/billing/index.ts
Normal file
18
server/private/routers/billing/index.ts
Normal 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";
|
||||
87
server/private/routers/billing/internalGetOrgTier.ts
Normal file
87
server/private/routers/billing/internalGetOrgTier.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
45
server/private/routers/billing/subscriptionLifecycle.ts
Normal file
45
server/private/routers/billing/subscriptionLifecycle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
136
server/private/routers/billing/webhooks.ts
Normal file
136
server/private/routers/billing/webhooks.ts
Normal 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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user