mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: implement Stripe webhook for automatic invoice email sending and add configuration for webhook secret
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()!,
|
||||
}}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -620,6 +620,10 @@
|
||||
"privateKey": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"webhookSecret": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Stripe webhook secret for automatic invoice emails"
|
||||
},
|
||||
"smsDefaultValueInCents": {
|
||||
"type": ["integer", "null"]
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user