feat: implement Stripe webhook for automatic invoice email sending and add configuration for webhook secret

This commit is contained in:
Nawaz Dhandala
2026-01-28 11:26:22 +00:00
parent 6ef5e409da
commit 9640732e29
14 changed files with 218 additions and 6 deletions

View File

@@ -1105,6 +1105,35 @@ export default class Project extends TenantModel {
})
public enableAutoRechargeAiBalance?: boolean = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
required: true,
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Send Invoices by Email",
description:
"When enabled, invoices will be automatically sent to the finance/accounting email when they are generated.",
defaultValue: false,
example: true,
})
@Column({
nullable: false,
default: false,
type: ColumnType.Boolean,
})
public sendInvoicesByEmail?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [],

View File

@@ -1,14 +1,15 @@
import { IsBillingEnabled } from "../EnvironmentConfig";
import { BillingWebhookSecret, IsBillingEnabled } from "../EnvironmentConfig";
import UserMiddleware from "../Middleware/UserAuthorization";
import BillingService from "../Services/BillingService";
import ProjectService from "../Services/ProjectService";
import Express, {
import {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Express from "../Utils/Express";
import Response from "../Utils/Response";
import BadDataException from "../../Types/Exception/BadDataException";
import Permission, { UserPermission } from "../../Types/Permission";
@@ -16,6 +17,7 @@ import Project from "../../Models/DatabaseModels/Project";
import CommonAPI from "./CommonAPI";
import ObjectID from "../../Types/ObjectID";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
import logger from "../Utils/Logger";
export default class BillingAPI {
public router: ExpressRouter;
@@ -23,6 +25,50 @@ export default class BillingAPI {
public constructor() {
this.router = Express.getRouter();
// Stripe webhook endpoint - uses raw body captured by JSON parser for signature verification
this.router.post(
`/billing/webhook`,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
if (!IsBillingEnabled) {
return Response.sendJsonObjectResponse(req, res, {
message: "Billing is not enabled",
});
}
if (!BillingWebhookSecret) {
throw new BadDataException(
"Billing webhook secret is not configured",
);
}
const signature = req.headers["stripe-signature"] as string;
if (!signature) {
throw new BadDataException("Missing Stripe signature header");
}
const rawBody = (req as OneUptimeRequest).rawBody;
if (!rawBody) {
throw new BadDataException("Missing raw body for webhook verification");
}
const event = BillingService.verifyWebhookSignature(rawBody, signature);
// Handle the event asynchronously
await BillingService.handleWebhookEvent(event);
return Response.sendJsonObjectResponse(req, res, {
received: true,
});
} catch (err) {
logger.error("Stripe webhook error: " + err);
next(err);
}
},
);
this.router.get(
`/billing/customer-balance`,
UserMiddleware.getUserMiddleware,

View File

@@ -1,9 +1,11 @@
const IsBillingEnabled: boolean = process.env["BILLING_ENABLED"] === "true";
const BillingPublicKey: string = process.env["BILLING_PUBLIC_KEY"] || "";
const BillingPrivateKey: string = process.env["BILLING_PRIVATE_KEY"] || "";
const BillingWebhookSecret: string = process.env["BILLING_WEBHOOK_SECRET"] || "";
export default {
IsBillingEnabled,
BillingPublicKey,
BillingPrivateKey,
BillingWebhookSecret,
};

View File

@@ -102,6 +102,7 @@ const parsePositiveNumberFromEnv: (
export const IsBillingEnabled: boolean = BillingConfig.IsBillingEnabled;
export const BillingPublicKey: string = BillingConfig.BillingPublicKey;
export const BillingPrivateKey: string = BillingConfig.BillingPrivateKey;
export const BillingWebhookSecret: string = BillingConfig.BillingWebhookSecret;
export const DatabaseHost: Hostname = Hostname.fromString(
process.env["DATABASE_HOST"] || "postgres",

View File

@@ -39,6 +39,7 @@ export class AIBillingService extends BaseService {
paymentProviderCustomerId: true,
name: true,
failedAiBalanceChargeNotificationSentToOwners: true,
sendInvoicesByEmail: true,
},
props: {
isRoot: true,
@@ -89,6 +90,7 @@ export class AIBillingService extends BaseService {
project.paymentProviderCustomerId!,
"AI Balance Recharge",
amountInUSD,
project.sendInvoicesByEmail || false,
);
await ProjectService.updateOneById({

View File

@@ -1,4 +1,4 @@
import { BillingPrivateKey, IsBillingEnabled } from "../EnvironmentConfig";
import { BillingPrivateKey, BillingWebhookSecret, IsBillingEnabled } from "../EnvironmentConfig";
import ServerMeteredPlan from "../Types/Billing/MeteredPlan/ServerMeteredPlan";
import Errors from "../Utils/Errors";
import logger from "../Utils/Logger";
@@ -86,6 +86,7 @@ export class BillingService extends BaseService {
businessDetails: string,
countryCode?: string | null,
financeAccountingEmail?: string | null,
sendInvoicesByEmail?: boolean | null,
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
@@ -133,6 +134,9 @@ export class BillingService extends BaseService {
// Remove if cleared
metadata["finance_accounting_email"] = "";
}
if (sendInvoicesByEmail !== undefined && sendInvoicesByEmail !== null) {
metadata["send_invoices_by_email"] = sendInvoicesByEmail ? "true" : "false";
}
const updateParams: Stripe.CustomerUpdateParams = {
metadata,
@@ -924,11 +928,48 @@ export class BillingService extends BaseService {
return billingInvoices;
}
@CaptureSpan()
public async sendInvoiceByEmail(invoiceId: string): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
}
try {
await this.stripe.invoices.sendInvoice(invoiceId);
} catch (err) {
logger.error("Failed to send invoice by email: " + err);
// Don't throw - sending email is not critical
}
}
@CaptureSpan()
public async shouldSendInvoicesByEmail(customerId: string): Promise<boolean> {
if (!this.isBillingEnabled()) {
return false;
}
try {
const customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer> =
await this.stripe.customers.retrieve(customerId);
if (!customer || customer.deleted) {
return false;
}
const metadata = (customer as Stripe.Customer).metadata;
return metadata?.["send_invoices_by_email"] === "true";
} catch (err) {
logger.error("Failed to check invoice email preference: " + err);
return false;
}
}
@CaptureSpan()
public async generateInvoiceAndChargeCustomer(
customerId: string,
itemText: string,
amountInUsd: number,
sendInvoiceByEmail?: boolean,
): Promise<void> {
const invoice: Stripe.Invoice = await this.stripe.invoices.create({
customer: customerId,
@@ -951,6 +992,11 @@ export class BillingService extends BaseService {
try {
await this.payInvoice(customerId, invoice.id!);
// Send invoice by email if requested
if (sendInvoiceByEmail) {
await this.sendInvoiceByEmail(invoice.id!);
}
} catch (err) {
// mark invoice as failed and do not collect payment.
await this.voidInvoice(invoice.id!);
@@ -1035,6 +1081,54 @@ export class BillingService extends BaseService {
"Plan with productType " + productType + " not found",
);
}
@CaptureSpan()
public verifyWebhookSignature(
payload: string | Buffer,
signature: string,
): Stripe.Event {
if (!BillingWebhookSecret) {
throw new BadDataException("Billing webhook secret is not configured");
}
return this.stripe.webhooks.constructEvent(
payload,
signature,
BillingWebhookSecret,
);
}
@CaptureSpan()
public async handleWebhookEvent(event: Stripe.Event): Promise<void> {
if (!this.isBillingEnabled()) {
return;
}
// Handle invoice.finalized event to send invoice by email if customer has opted in
if (event.type === "invoice.finalized") {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.customer) {
return;
}
const customerId = typeof invoice.customer === "string"
? invoice.customer
: invoice.customer.id;
try {
const shouldSend = await this.shouldSendInvoicesByEmail(customerId);
if (shouldSend && invoice.id) {
await this.sendInvoiceByEmail(invoice.id);
logger.debug(`Sent invoice ${invoice.id} by email to customer ${customerId}`);
}
} catch (err) {
logger.error(`Failed to send invoice by email for invoice ${invoice.id}: ${err}`);
// Don't throw - webhook should still return success
}
}
}
}
export default new BillingService();

View File

@@ -35,6 +35,7 @@ export class NotificationService extends BaseService {
paymentProviderCustomerId: true,
name: true,
failedCallAndSMSBalanceChargeNotificationSentToOwners: true,
sendInvoicesByEmail: true,
},
props: {
isRoot: true,
@@ -85,6 +86,7 @@ export class NotificationService extends BaseService {
project.paymentProviderCustomerId!,
"SMS or Call Balance Recharge",
amountInUSD,
project.sendInvoicesByEmail || false,
);
await ProjectService.updateOneById({

View File

@@ -280,7 +280,8 @@ export class ProjectService extends DatabaseService<Model> {
if (
updateBy.data.businessDetails ||
updateBy.data.businessDetailsCountry ||
updateBy.data.financeAccountingEmail
updateBy.data.financeAccountingEmail ||
updateBy.data.sendInvoicesByEmail !== undefined
) {
// Sync to Stripe.
const project: Model | null = await this.findOneById({
@@ -288,6 +289,7 @@ export class ProjectService extends DatabaseService<Model> {
select: {
paymentProviderCustomerId: true,
financeAccountingEmail: true,
sendInvoicesByEmail: true,
},
props: { isRoot: true },
});
@@ -301,6 +303,9 @@ export class ProjectService extends DatabaseService<Model> {
(updateBy.data.financeAccountingEmail as string) ||
(project as any).financeAccountingEmail ||
null,
updateBy.data.sendInvoicesByEmail !== undefined
? (updateBy.data.sendInvoicesByEmail as boolean)
: (project as any).sendInvoicesByEmail || null,
);
} catch (err) {
logger.error(

View File

@@ -604,6 +604,16 @@ const Settings: FunctionComponent<ComponentProps> = (
maxLength: 200,
},
},
{
field: {
sendInvoicesByEmail: true,
},
title: "Send Invoices by Email",
description:
"When enabled, invoices will be automatically sent to the finance/accounting email when they are generated.",
required: false,
fieldType: FormFieldSchemaType.Toggle,
},
]}
modelDetailProps={{
modelType: Project,
@@ -633,6 +643,14 @@ const Settings: FunctionComponent<ComponentProps> = (
placeholder: "No finance / accounting email added yet.",
fieldType: FieldType.Email,
},
{
field: {
sendInvoicesByEmail: true,
},
title: "Send Invoices by Email",
placeholder: "Disabled",
fieldType: FieldType.Boolean,
},
],
modelId: ProjectUtil.getCurrentProjectId()!,
}}

View File

@@ -518,6 +518,9 @@ Usage:
- name: BILLING_PRIVATE_KEY
value: {{ $.Values.billing.privateKey }}
- name: BILLING_WEBHOOK_SECRET
value: {{ $.Values.billing.webhookSecret }}
- name: DISABLE_AUTOMATIC_INCIDENT_CREATION
value: {{ $.Values.incidents.disableAutomaticCreation | squote }}

View File

@@ -620,6 +620,10 @@
"privateKey": {
"type": ["string", "null"]
},
"webhookSecret": {
"type": ["string", "null"],
"description": "Stripe webhook secret for automatic invoice emails"
},
"smsDefaultValueInCents": {
"type": ["integer", "null"]
},

View File

@@ -245,6 +245,8 @@ billing:
enabled: false
publicKey:
privateKey:
# Stripe webhook secret for automatic invoice emails (get from Stripe Dashboard > Developers > Webhooks)
webhookSecret:
smsDefaultValueInCents:
whatsAppTextDefaultValueInCents:
callDefaultValueInCentsPerMinute:

View File

@@ -196,11 +196,14 @@ SMS_HIGH_RISK_COST_IN_CENTS=
WHATSAPP_TEXT_DEFAULT_COST_IN_CENTS=
CALL_HIGH_RISK_COST_IN_CENTS_PER_MINUTE=
# IS BILLING ENABLED for this installer.
# IS BILLING ENABLED for this installer.
BILLING_ENABLED=false
# Public and private key for billing provider, usually stripe.
# Public and private key for billing provider, usually stripe.
BILLING_PUBLIC_KEY=
BILLING_PRIVATE_KEY=
# Webhook secret for verifying Stripe webhook events (for automatic invoice emails)
# Get this from Stripe Dashboard > Developers > Webhooks > Your endpoint > Signing secret
BILLING_WEBHOOK_SECRET=
# Average telemetry row sizes in bytes used to estimate usage when reporting to the billing provider.
AVERAGE_SPAN_ROW_SIZE_IN_BYTES=1024

View File

@@ -107,6 +107,7 @@ x-common-runtime-variables: &common-runtime-variables
BILLING_PRIVATE_KEY: ${BILLING_PRIVATE_KEY}
BILLING_PUBLIC_KEY: ${BILLING_PUBLIC_KEY}
BILLING_ENABLED: ${BILLING_ENABLED}
BILLING_WEBHOOK_SECRET: ${BILLING_WEBHOOK_SECRET}
CLICKHOUSE_USER: ${CLICKHOUSE_USER}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}