diff --git a/Common/Models/DatabaseModels/Project.ts b/Common/Models/DatabaseModels/Project.ts index fabe223f81..9cfac89645 100644 --- a/Common/Models/DatabaseModels/Project.ts +++ b/Common/Models/DatabaseModels/Project.ts @@ -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: [], diff --git a/Common/Server/API/BillingAPI.ts b/Common/Server/API/BillingAPI.ts index a2b9d00014..d91e4d36f7 100644 --- a/Common/Server/API/BillingAPI.ts +++ b/Common/Server/API/BillingAPI.ts @@ -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, diff --git a/Common/Server/BillingConfig.ts b/Common/Server/BillingConfig.ts index 77e51fe61d..1208fcafac 100644 --- a/Common/Server/BillingConfig.ts +++ b/Common/Server/BillingConfig.ts @@ -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, }; diff --git a/Common/Server/EnvironmentConfig.ts b/Common/Server/EnvironmentConfig.ts index df310cac33..17c2218e52 100644 --- a/Common/Server/EnvironmentConfig.ts +++ b/Common/Server/EnvironmentConfig.ts @@ -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", diff --git a/Common/Server/Services/AIBillingService.ts b/Common/Server/Services/AIBillingService.ts index 8470544197..70ba63c1ff 100644 --- a/Common/Server/Services/AIBillingService.ts +++ b/Common/Server/Services/AIBillingService.ts @@ -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({ diff --git a/Common/Server/Services/BillingService.ts b/Common/Server/Services/BillingService.ts index 40b2cde218..bd4010c9c7 100644 --- a/Common/Server/Services/BillingService.ts +++ b/Common/Server/Services/BillingService.ts @@ -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 { 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 { + 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 { + if (!this.isBillingEnabled()) { + return false; + } + + try { + const customer: Stripe.Response = + 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 { 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 { + 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(); diff --git a/Common/Server/Services/NotificationService.ts b/Common/Server/Services/NotificationService.ts index 15de5c74ab..b689fd97bf 100644 --- a/Common/Server/Services/NotificationService.ts +++ b/Common/Server/Services/NotificationService.ts @@ -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({ diff --git a/Common/Server/Services/ProjectService.ts b/Common/Server/Services/ProjectService.ts index f0adb347ae..4f0361dad1 100755 --- a/Common/Server/Services/ProjectService.ts +++ b/Common/Server/Services/ProjectService.ts @@ -280,7 +280,8 @@ export class ProjectService extends DatabaseService { 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 { select: { paymentProviderCustomerId: true, financeAccountingEmail: true, + sendInvoicesByEmail: true, }, props: { isRoot: true }, }); @@ -301,6 +303,9 @@ export class ProjectService extends DatabaseService { (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( diff --git a/Dashboard/src/Pages/Settings/Billing.tsx b/Dashboard/src/Pages/Settings/Billing.tsx index b39e843bd6..b2600ab16a 100644 --- a/Dashboard/src/Pages/Settings/Billing.tsx +++ b/Dashboard/src/Pages/Settings/Billing.tsx @@ -604,6 +604,16 @@ const Settings: FunctionComponent = ( 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 = ( 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()!, }} diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index f01da64d14..c0c2ae136f 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -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 }} diff --git a/HelmChart/Public/oneuptime/values.schema.json b/HelmChart/Public/oneuptime/values.schema.json index c8d76520f7..631b9694b0 100644 --- a/HelmChart/Public/oneuptime/values.schema.json +++ b/HelmChart/Public/oneuptime/values.schema.json @@ -620,6 +620,10 @@ "privateKey": { "type": ["string", "null"] }, + "webhookSecret": { + "type": ["string", "null"], + "description": "Stripe webhook secret for automatic invoice emails" + }, "smsDefaultValueInCents": { "type": ["integer", "null"] }, diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index d71c458c91..94e7f841a7 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -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: diff --git a/config.example.env b/config.example.env index e391f37050..ebb51b08c1 100644 --- a/config.example.env +++ b/config.example.env @@ -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 diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 837f5e442b..7b48781298 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -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}