@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.
- package/Models/DatabaseModels/Project.ts +29 -0
- package/Server/API/BillingAPI.ts +78 -1
- package/Server/BillingConfig.ts +3 -0
- package/Server/EnvironmentConfig.ts +1 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.ts +29 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AIBillingService.ts +10 -0
- package/Server/Services/BillingService.ts +351 -1
- package/Server/Services/NotificationService.ts +10 -0
- package/Server/Services/ProjectService.ts +33 -2
- package/Server/Services/UserService.ts +45 -1
- package/Server/Types/Database/Permissions/TenantPermission.ts +20 -0
- package/Types/Email/EmailTemplateType.ts +1 -0
- package/build/dist/Models/DatabaseModels/Project.js +30 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Server/API/BillingAPI.js +44 -1
- package/build/dist/Server/API/BillingAPI.js.map +1 -1
- package/build/dist/Server/BillingConfig.js +2 -0
- package/build/dist/Server/BillingConfig.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +1 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.js +16 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1769599843642-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AIBillingService.js +10 -1
- package/build/dist/Server/Services/AIBillingService.js.map +1 -1
- package/build/dist/Server/Services/BillingService.js +225 -5
- package/build/dist/Server/Services/BillingService.js.map +1 -1
- package/build/dist/Server/Services/NotificationService.js +10 -1
- package/build/dist/Server/Services/NotificationService.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +16 -3
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +40 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +17 -0
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- 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: [],
|
package/Server/API/BillingAPI.ts
CHANGED
|
@@ -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,
|
package/Server/BillingConfig.ts
CHANGED
|
@@ -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 {
|
|
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({
|