@oneuptime/common 9.4.11 → 9.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/Models/DatabaseModels/Project.ts +29 -0
  2. package/Server/API/BillingAPI.ts +78 -1
  3. package/Server/BillingConfig.ts +3 -0
  4. package/Server/EnvironmentConfig.ts +1 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.ts +29 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/AIBillingService.ts +10 -0
  8. package/Server/Services/BillingService.ts +351 -1
  9. package/Server/Services/NotificationService.ts +10 -0
  10. package/Server/Services/ProjectService.ts +33 -2
  11. package/Server/Services/UserService.ts +45 -1
  12. package/Server/Types/Database/Permissions/TenantPermission.ts +20 -0
  13. package/Types/Email/EmailTemplateType.ts +1 -0
  14. package/build/dist/Models/DatabaseModels/Project.js +30 -0
  15. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  16. package/build/dist/Server/API/BillingAPI.js +44 -1
  17. package/build/dist/Server/API/BillingAPI.js.map +1 -1
  18. package/build/dist/Server/BillingConfig.js +2 -0
  19. package/build/dist/Server/BillingConfig.js.map +1 -1
  20. package/build/dist/Server/EnvironmentConfig.js +1 -0
  21. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  22. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.js +16 -0
  23. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.js.map +1 -0
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  26. package/build/dist/Server/Services/AIBillingService.js +10 -1
  27. package/build/dist/Server/Services/AIBillingService.js.map +1 -1
  28. package/build/dist/Server/Services/BillingService.js +225 -5
  29. package/build/dist/Server/Services/BillingService.js.map +1 -1
  30. package/build/dist/Server/Services/NotificationService.js +10 -1
  31. package/build/dist/Server/Services/NotificationService.js.map +1 -1
  32. package/build/dist/Server/Services/ProjectService.js +16 -3
  33. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  34. package/build/dist/Server/Services/UserService.js +40 -0
  35. package/build/dist/Server/Services/UserService.js.map +1 -1
  36. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +17 -0
  37. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  38. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  39. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  40. package/package.json +1 -1
@@ -1105,6 +1105,35 @@ export default class Project extends TenantModel {
1105
1105
  })
1106
1106
  public enableAutoRechargeAiBalance?: boolean = undefined;
1107
1107
 
1108
+ @ColumnAccessControl({
1109
+ create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
1110
+ read: [
1111
+ Permission.ProjectOwner,
1112
+ Permission.ProjectAdmin,
1113
+ Permission.ProjectMember,
1114
+ Permission.ReadProject,
1115
+ Permission.UnAuthorizedSsoUser,
1116
+ Permission.ProjectUser,
1117
+ ],
1118
+ update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
1119
+ })
1120
+ @TableColumn({
1121
+ required: true,
1122
+ isDefaultValueColumn: true,
1123
+ type: TableColumnType.Boolean,
1124
+ title: "Send Invoices by Email",
1125
+ description:
1126
+ "When enabled, invoices will be automatically sent to the finance/accounting email when they are generated.",
1127
+ defaultValue: false,
1128
+ example: true,
1129
+ })
1130
+ @Column({
1131
+ nullable: false,
1132
+ default: false,
1133
+ type: ColumnType.Boolean,
1134
+ })
1135
+ public sendInvoicesByEmail?: boolean = undefined;
1136
+
1108
1137
  @ColumnAccessControl({
1109
1138
  create: [],
1110
1139
  read: [],
@@ -1,4 +1,5 @@
1
- import { IsBillingEnabled } from "../EnvironmentConfig";
1
+ import { BillingWebhookSecret, IsBillingEnabled } from "../EnvironmentConfig";
2
+ import Stripe from "stripe";
2
3
  import UserMiddleware from "../Middleware/UserAuthorization";
3
4
  import BillingService from "../Services/BillingService";
4
5
  import ProjectService from "../Services/ProjectService";
@@ -16,6 +17,7 @@ import Project from "../../Models/DatabaseModels/Project";
16
17
  import CommonAPI from "./CommonAPI";
17
18
  import ObjectID from "../../Types/ObjectID";
18
19
  import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
20
+ import logger from "../Utils/Logger";
19
21
 
20
22
  export default class BillingAPI {
21
23
  public router: ExpressRouter;
@@ -23,6 +25,81 @@ export default class BillingAPI {
23
25
  public constructor() {
24
26
  this.router = Express.getRouter();
25
27
 
28
+ // Stripe webhook endpoint - uses raw body captured by JSON parser for signature verification
29
+ this.router.post(
30
+ `/billing/webhook`,
31
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
32
+ try {
33
+ logger.debug(
34
+ `[Invoice Email] Webhook endpoint hit - /billing/webhook`,
35
+ );
36
+
37
+ if (!IsBillingEnabled) {
38
+ logger.debug(
39
+ `[Invoice Email] Billing not enabled, returning early`,
40
+ );
41
+ return Response.sendJsonObjectResponse(req, res, {
42
+ message: "Billing is not enabled",
43
+ });
44
+ }
45
+
46
+ if (!BillingWebhookSecret) {
47
+ logger.error(
48
+ `[Invoice Email] Billing webhook secret is not configured`,
49
+ );
50
+ throw new BadDataException(
51
+ "Billing webhook secret is not configured",
52
+ );
53
+ }
54
+
55
+ const signature: string = req.headers["stripe-signature"] as string;
56
+ logger.debug(
57
+ `[Invoice Email] Stripe signature header present: ${Boolean(signature)}`,
58
+ );
59
+
60
+ if (!signature) {
61
+ logger.error(`[Invoice Email] Missing Stripe signature header`);
62
+ throw new BadDataException("Missing Stripe signature header");
63
+ }
64
+
65
+ const rawBody: string | undefined = (req as OneUptimeRequest).rawBody;
66
+ logger.debug(
67
+ `[Invoice Email] Raw body present: ${Boolean(rawBody)}, length: ${rawBody?.length || 0}`,
68
+ );
69
+
70
+ if (!rawBody) {
71
+ logger.error(
72
+ `[Invoice Email] Missing raw body for webhook verification`,
73
+ );
74
+ throw new BadDataException(
75
+ "Missing raw body for webhook verification",
76
+ );
77
+ }
78
+
79
+ logger.debug(`[Invoice Email] Verifying webhook signature...`);
80
+ const event: Stripe.Event = BillingService.verifyWebhookSignature(
81
+ rawBody,
82
+ signature,
83
+ );
84
+ logger.debug(
85
+ `[Invoice Email] Webhook signature verified successfully, event type: ${event.type}`,
86
+ );
87
+
88
+ // Handle the event asynchronously
89
+ logger.debug(`[Invoice Email] Handling webhook event...`);
90
+ await BillingService.handleWebhookEvent(event);
91
+ logger.debug(`[Invoice Email] Webhook event handled successfully`);
92
+
93
+ return Response.sendJsonObjectResponse(req, res, {
94
+ received: true,
95
+ });
96
+ } catch (err) {
97
+ logger.error(`[Invoice Email] Stripe webhook error: ${err}`);
98
+ next(err);
99
+ }
100
+ },
101
+ );
102
+
26
103
  this.router.get(
27
104
  `/billing/customer-balance`,
28
105
  UserMiddleware.getUserMiddleware,
@@ -1,9 +1,12 @@
1
1
  const IsBillingEnabled: boolean = process.env["BILLING_ENABLED"] === "true";
2
2
  const BillingPublicKey: string = process.env["BILLING_PUBLIC_KEY"] || "";
3
3
  const BillingPrivateKey: string = process.env["BILLING_PRIVATE_KEY"] || "";
4
+ const BillingWebhookSecret: string =
5
+ process.env["BILLING_WEBHOOK_SECRET"] || "";
4
6
 
5
7
  export default {
6
8
  IsBillingEnabled,
7
9
  BillingPublicKey,
8
10
  BillingPrivateKey,
11
+ BillingWebhookSecret,
9
12
  };
@@ -102,6 +102,7 @@ const parsePositiveNumberFromEnv: (
102
102
  export const IsBillingEnabled: boolean = BillingConfig.IsBillingEnabled;
103
103
  export const BillingPublicKey: string = BillingConfig.BillingPublicKey;
104
104
  export const BillingPrivateKey: string = BillingConfig.BillingPrivateKey;
105
+ export const BillingWebhookSecret: string = BillingConfig.BillingWebhookSecret;
105
106
 
106
107
  export const DatabaseHost: Hostname = Hostname.fromString(
107
108
  process.env["DATABASE_HOST"] || "postgres",
@@ -0,0 +1,29 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1769599843642 implements MigrationInterface {
4
+ public name = "MigrationName1769599843642";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "Project" ADD "sendInvoicesByEmail" boolean NOT NULL DEFAULT false`,
9
+ );
10
+ await queryRunner.query(
11
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
12
+ );
13
+ await queryRunner.query(
14
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
15
+ );
16
+ }
17
+
18
+ public async down(queryRunner: QueryRunner): Promise<void> {
19
+ await queryRunner.query(
20
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
21
+ );
22
+ await queryRunner.query(
23
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
24
+ );
25
+ await queryRunner.query(
26
+ `ALTER TABLE "Project" DROP COLUMN "sendInvoicesByEmail"`,
27
+ );
28
+ }
29
+ }
@@ -236,6 +236,7 @@ import { MigrationName1769428619414 } from "./1769428619414-MigrationName";
236
236
  import { MigrationName1769428821686 } from "./1769428821686-MigrationName";
237
237
  import { MigrationName1769469813786 } from "./1769469813786-MigrationName";
238
238
  import { RenameNotificationRuleTypes1769517677937 } from "./1769517677937-RenameNotificationRuleTypes";
239
+ import { MigrationName1769599843642 } from "./1769599843642-MigrationName";
239
240
 
240
241
  export default [
241
242
  InitialMigration,
@@ -476,4 +477,5 @@ export default [
476
477
  MigrationName1769428821686,
477
478
  MigrationName1769469813786,
478
479
  RenameNotificationRuleTypes1769517677937,
480
+ MigrationName1769599843642,
479
481
  ];
@@ -7,6 +7,7 @@ import BaseService from "./BaseService";
7
7
  import BillingService from "./BillingService";
8
8
  import ProjectService from "./ProjectService";
9
9
  import BadDataException from "../../Types/Exception/BadDataException";
10
+ import Email from "../../Types/Email";
10
11
  import ObjectID from "../../Types/ObjectID";
11
12
  import Project from "../../Models/DatabaseModels/Project";
12
13
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
@@ -39,6 +40,8 @@ export class AIBillingService extends BaseService {
39
40
  paymentProviderCustomerId: true,
40
41
  name: true,
41
42
  failedAiBalanceChargeNotificationSentToOwners: true,
43
+ sendInvoicesByEmail: true,
44
+ financeAccountingEmail: true,
42
45
  },
43
46
  props: {
44
47
  isRoot: true,
@@ -89,6 +92,13 @@ export class AIBillingService extends BaseService {
89
92
  project.paymentProviderCustomerId!,
90
93
  "AI Balance Recharge",
91
94
  amountInUSD,
95
+ {
96
+ sendInvoiceByEmail: project.sendInvoicesByEmail || false,
97
+ recipientEmail: project.financeAccountingEmail
98
+ ? new Email(project.financeAccountingEmail)
99
+ : undefined,
100
+ projectId: project.id || undefined,
101
+ },
92
102
  );
93
103
 
94
104
  await ProjectService.updateOneById({
@@ -1,8 +1,15 @@
1
- import { BillingPrivateKey, IsBillingEnabled } from "../EnvironmentConfig";
1
+ import {
2
+ BillingPrivateKey,
3
+ BillingWebhookSecret,
4
+ IsBillingEnabled,
5
+ DashboardClientUrl,
6
+ } from "../EnvironmentConfig";
7
+ import Project from "../../Models/DatabaseModels/Project";
2
8
  import ServerMeteredPlan from "../Types/Billing/MeteredPlan/ServerMeteredPlan";
3
9
  import Errors from "../Utils/Errors";
4
10
  import logger from "../Utils/Logger";
5
11
  import BaseService from "./BaseService";
12
+ import MailService from "./MailService";
6
13
  import SubscriptionPlan from "../../Types/Billing/SubscriptionPlan";
7
14
  import SubscriptionStatus, {
8
15
  SubscriptionStatusUtil,
@@ -10,6 +17,7 @@ import SubscriptionStatus, {
10
17
  import OneUptimeDate from "../../Types/Date";
11
18
  import Dictionary from "../../Types/Dictionary";
12
19
  import Email from "../../Types/Email";
20
+ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
13
21
  import APIException from "../../Types/Exception/ApiException";
14
22
  import BadDataException from "../../Types/Exception/BadDataException";
15
23
  import ProductType from "../../Types/MeteredPlan/ProductType";
@@ -86,8 +94,16 @@ export class BillingService extends BaseService {
86
94
  businessDetails: string,
87
95
  countryCode?: string | null,
88
96
  financeAccountingEmail?: string | null,
97
+ sendInvoicesByEmail?: boolean | null,
89
98
  ): Promise<void> {
99
+ logger.debug(
100
+ `[Invoice Email] updateCustomerBusinessDetails called - customerId: ${id}, sendInvoicesByEmail: ${sendInvoicesByEmail}`,
101
+ );
102
+
90
103
  if (!this.isBillingEnabled()) {
104
+ logger.debug(
105
+ `[Invoice Email] Billing not enabled, skipping updateCustomerBusinessDetails for customer ${id}`,
106
+ );
91
107
  throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
92
108
  }
93
109
  /*
@@ -133,6 +149,14 @@ export class BillingService extends BaseService {
133
149
  // Remove if cleared
134
150
  metadata["finance_accounting_email"] = "";
135
151
  }
152
+ if (sendInvoicesByEmail !== undefined && sendInvoicesByEmail !== null) {
153
+ metadata["send_invoices_by_email"] = sendInvoicesByEmail
154
+ ? "true"
155
+ : "false";
156
+ logger.debug(
157
+ `[Invoice Email] Setting send_invoices_by_email metadata to "${metadata["send_invoices_by_email"]}" for customer ${id}`,
158
+ );
159
+ }
136
160
 
137
161
  const updateParams: Stripe.CustomerUpdateParams = {
138
162
  metadata,
@@ -169,7 +193,11 @@ export class BillingService extends BaseService {
169
193
  } as any;
170
194
  }
171
195
 
196
+ logger.debug(
197
+ `[Invoice Email] Updating Stripe customer ${id} with metadata: ${JSON.stringify(metadata)}`,
198
+ );
172
199
  await this.stripe.customers.update(id, updateParams);
200
+ logger.debug(`[Invoice Email] Successfully updated Stripe customer ${id}`);
173
201
  }
174
202
 
175
203
  @CaptureSpan()
@@ -924,12 +952,169 @@ export class BillingService extends BaseService {
924
952
  return billingInvoices;
925
953
  }
926
954
 
955
+ @CaptureSpan()
956
+ public async sendInvoiceByEmail(
957
+ invoiceId: string,
958
+ recipientEmail?: Email,
959
+ projectId?: ObjectID,
960
+ ): Promise<void> {
961
+ logger.debug(
962
+ `[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipientEmail: ${recipientEmail?.toString()}`,
963
+ );
964
+
965
+ if (!this.isBillingEnabled()) {
966
+ logger.debug(
967
+ `[Invoice Email] Billing not enabled, skipping send for invoice: ${invoiceId}`,
968
+ );
969
+ throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
970
+ }
971
+
972
+ try {
973
+ // Fetch invoice details from Stripe
974
+ logger.debug(
975
+ `[Invoice Email] Fetching invoice ${invoiceId} details from Stripe`,
976
+ );
977
+ const stripeInvoice: Stripe.Invoice =
978
+ await this.stripe.invoices.retrieve(invoiceId);
979
+
980
+ if (!stripeInvoice) {
981
+ logger.error(
982
+ `[Invoice Email] Invoice ${invoiceId} not found in Stripe`,
983
+ );
984
+ return;
985
+ }
986
+
987
+ // Determine recipient email
988
+ let toEmail: Email | undefined = recipientEmail;
989
+ if (!toEmail && stripeInvoice.customer_email) {
990
+ toEmail = new Email(stripeInvoice.customer_email);
991
+ }
992
+
993
+ if (!toEmail) {
994
+ logger.error(
995
+ `[Invoice Email] No recipient email found for invoice ${invoiceId}`,
996
+ );
997
+ return;
998
+ }
999
+
1000
+ // Format invoice data for email
1001
+ const invoiceNumber: string = stripeInvoice.number || invoiceId;
1002
+ const invoiceDate: string = stripeInvoice.created
1003
+ ? OneUptimeDate.getDateAsFormattedString(
1004
+ new Date(stripeInvoice.created * 1000),
1005
+ )
1006
+ : OneUptimeDate.getDateAsFormattedString(
1007
+ OneUptimeDate.getCurrentDate(),
1008
+ );
1009
+ const amount: string = `${(stripeInvoice.amount_due / 100).toFixed(2)} ${stripeInvoice.currency?.toUpperCase() || "USD"}`;
1010
+ const invoicePdfUrl: string | undefined =
1011
+ stripeInvoice.invoice_pdf || undefined;
1012
+ const description: string | undefined =
1013
+ stripeInvoice.description || undefined;
1014
+
1015
+ // Build dashboard link
1016
+ let dashboardLink: string | undefined = undefined;
1017
+ if (projectId && DashboardClientUrl) {
1018
+ dashboardLink = `${DashboardClientUrl.toString()}/dashboard/${projectId.toString()}/settings/billing`;
1019
+ }
1020
+
1021
+ logger.debug(
1022
+ `[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
1023
+ );
1024
+
1025
+ // Send email via OneUptime MailService
1026
+ await MailService.sendMail(
1027
+ {
1028
+ toEmail: toEmail,
1029
+ templateType: EmailTemplateType.Invoice,
1030
+ vars: {
1031
+ invoiceNumber: invoiceNumber,
1032
+ invoiceDate: invoiceDate,
1033
+ amount: amount,
1034
+ description: description || "",
1035
+ invoicePdfUrl: invoicePdfUrl || "",
1036
+ dashboardLink: dashboardLink || "",
1037
+ },
1038
+ subject: `Invoice #${invoiceNumber} from OneUptime`,
1039
+ },
1040
+ {
1041
+ projectId: projectId,
1042
+ },
1043
+ );
1044
+
1045
+ logger.debug(
1046
+ `[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
1047
+ );
1048
+ } catch (err) {
1049
+ logger.error(
1050
+ `[Invoice Email] Failed to send invoice ${invoiceId} by email: ${err}`,
1051
+ );
1052
+ // Don't throw - sending email is not critical
1053
+ }
1054
+ }
1055
+
1056
+ @CaptureSpan()
1057
+ public async shouldSendInvoicesByEmail(customerId: string): Promise<boolean> {
1058
+ logger.debug(
1059
+ `[Invoice Email] shouldSendInvoicesByEmail called for customer: ${customerId}`,
1060
+ );
1061
+
1062
+ if (!this.isBillingEnabled()) {
1063
+ logger.debug(
1064
+ `[Invoice Email] Billing not enabled, returning false for customer: ${customerId}`,
1065
+ );
1066
+ return false;
1067
+ }
1068
+
1069
+ try {
1070
+ logger.debug(
1071
+ `[Invoice Email] Retrieving customer ${customerId} from Stripe to check preference`,
1072
+ );
1073
+ const customer: Stripe.Response<
1074
+ Stripe.Customer | Stripe.DeletedCustomer
1075
+ > = await this.stripe.customers.retrieve(customerId);
1076
+
1077
+ if (!customer || customer.deleted) {
1078
+ logger.debug(
1079
+ `[Invoice Email] Customer ${customerId} not found or deleted, returning false`,
1080
+ );
1081
+ return false;
1082
+ }
1083
+
1084
+ const metadata: Stripe.Metadata = (customer as Stripe.Customer).metadata;
1085
+ const sendInvoicesByEmail: boolean =
1086
+ metadata?.["send_invoices_by_email"] === "true";
1087
+ logger.debug(
1088
+ `[Invoice Email] Customer ${customerId} metadata.send_invoices_by_email = "${metadata?.["send_invoices_by_email"]}", result: ${sendInvoicesByEmail}`,
1089
+ );
1090
+ return sendInvoicesByEmail;
1091
+ } catch (err) {
1092
+ logger.error(
1093
+ `[Invoice Email] Failed to check invoice email preference for customer ${customerId}: ${err}`,
1094
+ );
1095
+ return false;
1096
+ }
1097
+ }
1098
+
927
1099
  @CaptureSpan()
928
1100
  public async generateInvoiceAndChargeCustomer(
929
1101
  customerId: string,
930
1102
  itemText: string,
931
1103
  amountInUsd: number,
1104
+ options?: {
1105
+ sendInvoiceByEmail?: boolean | undefined;
1106
+ recipientEmail?: Email | undefined;
1107
+ projectId?: ObjectID | undefined;
1108
+ },
932
1109
  ): Promise<void> {
1110
+ const sendInvoiceByEmail: boolean = options?.sendInvoiceByEmail || false;
1111
+ const recipientEmail: Email | undefined = options?.recipientEmail;
1112
+ const projectId: ObjectID | undefined = options?.projectId;
1113
+
1114
+ logger.debug(
1115
+ `[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipientEmail: ${recipientEmail?.toString()}, projectId: ${projectId?.toString()}`,
1116
+ );
1117
+
933
1118
  const invoice: Stripe.Invoice = await this.stripe.invoices.create({
934
1119
  customer: customerId,
935
1120
  auto_advance: true, // do not automatically charge.
@@ -937,9 +1122,16 @@ export class BillingService extends BaseService {
937
1122
  });
938
1123
 
939
1124
  if (!invoice || !invoice.id) {
1125
+ logger.error(
1126
+ `[Invoice Email] Failed to create invoice for customer ${customerId}`,
1127
+ );
940
1128
  throw new APIException(Errors.BillingService.INVOICE_NOT_GENERATED);
941
1129
  }
942
1130
 
1131
+ logger.debug(
1132
+ `[Invoice Email] Created invoice ${invoice.id} for customer ${customerId}`,
1133
+ );
1134
+
943
1135
  await this.stripe.invoiceItems.create({
944
1136
  invoice: invoice.id,
945
1137
  amount: amountInUsd * 100,
@@ -947,11 +1139,32 @@ export class BillingService extends BaseService {
947
1139
  customer: customerId,
948
1140
  });
949
1141
 
1142
+ logger.debug(
1143
+ `[Invoice Email] Added invoice item to invoice ${invoice.id}: ${itemText}, $${amountInUsd}`,
1144
+ );
1145
+
950
1146
  await this.stripe.invoices.finalizeInvoice(invoice.id!);
1147
+ logger.debug(`[Invoice Email] Finalized invoice ${invoice.id}`);
951
1148
 
952
1149
  try {
953
1150
  await this.payInvoice(customerId, invoice.id!);
1151
+ logger.debug(`[Invoice Email] Paid invoice ${invoice.id}`);
1152
+
1153
+ // Send invoice by email if requested
1154
+ if (sendInvoiceByEmail) {
1155
+ logger.debug(
1156
+ `[Invoice Email] sendInvoiceByEmail is true, sending invoice ${invoice.id} by email`,
1157
+ );
1158
+ await this.sendInvoiceByEmail(invoice.id!, recipientEmail, projectId);
1159
+ } else {
1160
+ logger.debug(
1161
+ `[Invoice Email] sendInvoiceByEmail is false, skipping email for invoice ${invoice.id}`,
1162
+ );
1163
+ }
954
1164
  } catch (err) {
1165
+ logger.error(
1166
+ `[Invoice Email] Failed to pay invoice ${invoice.id}, voiding: ${err}`,
1167
+ );
955
1168
  // mark invoice as failed and do not collect payment.
956
1169
  await this.voidInvoice(invoice.id!);
957
1170
  throw err;
@@ -1035,6 +1248,143 @@ export class BillingService extends BaseService {
1035
1248
  "Plan with productType " + productType + " not found",
1036
1249
  );
1037
1250
  }
1251
+
1252
+ @CaptureSpan()
1253
+ public verifyWebhookSignature(
1254
+ payload: string | Buffer,
1255
+ signature: string,
1256
+ ): Stripe.Event {
1257
+ logger.debug(`[Invoice Email] verifyWebhookSignature called`);
1258
+
1259
+ if (!BillingWebhookSecret) {
1260
+ logger.error(`[Invoice Email] Billing webhook secret is not configured`);
1261
+ throw new BadDataException("Billing webhook secret is not configured");
1262
+ }
1263
+
1264
+ logger.debug(
1265
+ `[Invoice Email] Verifying webhook signature with secret (length: ${BillingWebhookSecret.length})`,
1266
+ );
1267
+ const event: Stripe.Event = this.stripe.webhooks.constructEvent(
1268
+ payload,
1269
+ signature,
1270
+ BillingWebhookSecret,
1271
+ );
1272
+ logger.debug(
1273
+ `[Invoice Email] Webhook signature verified, event type: ${event.type}, event id: ${event.id}`,
1274
+ );
1275
+ return event;
1276
+ }
1277
+
1278
+ @CaptureSpan()
1279
+ public async handleWebhookEvent(event: Stripe.Event): Promise<void> {
1280
+ logger.debug(
1281
+ `[Invoice Email] handleWebhookEvent called - event type: ${event.type}, event id: ${event.id}`,
1282
+ );
1283
+
1284
+ if (!this.isBillingEnabled()) {
1285
+ logger.debug(
1286
+ `[Invoice Email] Billing not enabled, ignoring webhook event ${event.id}`,
1287
+ );
1288
+ return;
1289
+ }
1290
+
1291
+ // Handle invoice.finalized event to send invoice by email if customer has opted in
1292
+ if (event.type === "invoice.finalized") {
1293
+ logger.debug(
1294
+ `[Invoice Email] Processing invoice.finalized event ${event.id}`,
1295
+ );
1296
+ const invoice: Stripe.Invoice = event.data.object as Stripe.Invoice;
1297
+
1298
+ logger.debug(
1299
+ `[Invoice Email] Invoice details - id: ${invoice.id}, number: ${invoice.number}, customer: ${invoice.customer}, status: ${invoice.status}`,
1300
+ );
1301
+
1302
+ if (!invoice.customer) {
1303
+ logger.debug(
1304
+ `[Invoice Email] No customer on invoice ${invoice.id}, skipping`,
1305
+ );
1306
+ return;
1307
+ }
1308
+
1309
+ const customerId: string =
1310
+ typeof invoice.customer === "string"
1311
+ ? invoice.customer
1312
+ : invoice.customer.id;
1313
+
1314
+ logger.debug(
1315
+ `[Invoice Email] Extracted customer ID: ${customerId} from invoice ${invoice.id}`,
1316
+ );
1317
+
1318
+ try {
1319
+ logger.debug(
1320
+ `[Invoice Email] Checking if customer ${customerId} has invoice emails enabled`,
1321
+ );
1322
+ const shouldSend: boolean =
1323
+ await this.shouldSendInvoicesByEmail(customerId);
1324
+
1325
+ if (shouldSend && invoice.id) {
1326
+ logger.debug(
1327
+ `[Invoice Email] Customer ${customerId} has invoice emails enabled, looking up project`,
1328
+ );
1329
+
1330
+ // Lazy import to avoid circular dependency
1331
+ const { default: ProjectService } = await import("./ProjectService");
1332
+
1333
+ // Find the project by Stripe customer ID
1334
+ const project: Project | null = await ProjectService.findOneBy({
1335
+ query: {
1336
+ paymentProviderCustomerId: customerId,
1337
+ },
1338
+ select: {
1339
+ _id: true,
1340
+ financeAccountingEmail: true,
1341
+ },
1342
+ props: {
1343
+ isRoot: true,
1344
+ },
1345
+ });
1346
+
1347
+ let recipientEmail: Email | undefined = undefined;
1348
+ let projectId: ObjectID | undefined = undefined;
1349
+
1350
+ if (project) {
1351
+ projectId = project.id || undefined;
1352
+ if (project.financeAccountingEmail) {
1353
+ recipientEmail = new Email(project.financeAccountingEmail);
1354
+ }
1355
+ logger.debug(
1356
+ `[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail: ${recipientEmail?.toString()}`,
1357
+ );
1358
+ } else {
1359
+ logger.debug(
1360
+ `[Invoice Email] No project found for customer ${customerId}, will use Stripe customer email`,
1361
+ );
1362
+ }
1363
+
1364
+ logger.debug(
1365
+ `[Invoice Email] Sending invoice ${invoice.id} by email`,
1366
+ );
1367
+ await this.sendInvoiceByEmail(invoice.id, recipientEmail, projectId);
1368
+ logger.debug(
1369
+ `[Invoice Email] Successfully processed invoice.finalized - sent invoice ${invoice.id} by email`,
1370
+ );
1371
+ } else {
1372
+ logger.debug(
1373
+ `[Invoice Email] Customer ${customerId} has invoice emails disabled (shouldSend: ${shouldSend}), skipping email for invoice ${invoice.id}`,
1374
+ );
1375
+ }
1376
+ } catch (err) {
1377
+ logger.error(
1378
+ `[Invoice Email] Failed to send invoice by email for invoice ${invoice.id}: ${err}`,
1379
+ );
1380
+ // Don't throw - webhook should still return success
1381
+ }
1382
+ } else {
1383
+ logger.debug(
1384
+ `[Invoice Email] Ignoring event type ${event.type}, not invoice.finalized`,
1385
+ );
1386
+ }
1387
+ }
1038
1388
  }
1039
1389
 
1040
1390
  export default new BillingService();
@@ -7,6 +7,7 @@ import BaseService from "./BaseService";
7
7
  import BillingService from "./BillingService";
8
8
  import ProjectService from "./ProjectService";
9
9
  import BadDataException from "../../Types/Exception/BadDataException";
10
+ import Email from "../../Types/Email";
10
11
  import ObjectID from "../../Types/ObjectID";
11
12
  import Project from "../../Models/DatabaseModels/Project";
12
13
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
@@ -35,6 +36,8 @@ export class NotificationService extends BaseService {
35
36
  paymentProviderCustomerId: true,
36
37
  name: true,
37
38
  failedCallAndSMSBalanceChargeNotificationSentToOwners: true,
39
+ sendInvoicesByEmail: true,
40
+ financeAccountingEmail: true,
38
41
  },
39
42
  props: {
40
43
  isRoot: true,
@@ -85,6 +88,13 @@ export class NotificationService extends BaseService {
85
88
  project.paymentProviderCustomerId!,
86
89
  "SMS or Call Balance Recharge",
87
90
  amountInUSD,
91
+ {
92
+ sendInvoiceByEmail: project.sendInvoicesByEmail || false,
93
+ recipientEmail: project.financeAccountingEmail
94
+ ? new Email(project.financeAccountingEmail)
95
+ : undefined,
96
+ projectId: project.id || undefined,
97
+ },
88
98
  );
89
99
 
90
100
  await ProjectService.updateOneById({