mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
1397 lines
42 KiB
TypeScript
1397 lines
42 KiB
TypeScript
import {
|
|
BillingPrivateKey,
|
|
BillingWebhookSecret,
|
|
IsBillingEnabled,
|
|
DashboardClientUrl,
|
|
} from "../EnvironmentConfig";
|
|
import Project from "../../Models/DatabaseModels/Project";
|
|
import ServerMeteredPlan from "../Types/Billing/MeteredPlan/ServerMeteredPlan";
|
|
import Errors from "../Utils/Errors";
|
|
import logger from "../Utils/Logger";
|
|
import BaseService from "./BaseService";
|
|
import MailService from "./MailService";
|
|
import ProjectService from "./ProjectService";
|
|
import SubscriptionPlan from "../../Types/Billing/SubscriptionPlan";
|
|
import SubscriptionStatus, {
|
|
SubscriptionStatusUtil,
|
|
} from "../../Types/Billing/SubscriptionStatus";
|
|
import OneUptimeDate from "../../Types/Date";
|
|
import Dictionary from "../../Types/Dictionary";
|
|
import Email from "../../Types/Email";
|
|
import EmailTemplateType from "../../Types/Email/EmailTemplateType";
|
|
import APIException from "../../Types/Exception/ApiException";
|
|
import BadDataException from "../../Types/Exception/BadDataException";
|
|
import ProductType from "../../Types/MeteredPlan/ProductType";
|
|
import ObjectID from "../../Types/ObjectID";
|
|
import Stripe from "stripe";
|
|
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
|
|
export type SubscriptionItem = Stripe.SubscriptionItem;
|
|
|
|
export type Coupon = Stripe.Coupon;
|
|
|
|
export interface PaymentMethod {
|
|
id: string;
|
|
type: string;
|
|
last4Digits: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
export interface Invoice {
|
|
id: string;
|
|
amount: number;
|
|
currencyCode: string;
|
|
subscriptionId?: string | undefined;
|
|
status: string;
|
|
downloadableLink: string;
|
|
customerId: string | undefined;
|
|
invoiceDate: Date;
|
|
invoiceNumber: string | undefined;
|
|
paymentIntentId?: string | undefined;
|
|
}
|
|
|
|
export class BillingService extends BaseService {
|
|
public constructor() {
|
|
super();
|
|
}
|
|
|
|
private stripe: Stripe = new Stripe(BillingPrivateKey, {
|
|
apiVersion: "2022-08-01",
|
|
});
|
|
|
|
// returns billing id of the customer.
|
|
@CaptureSpan()
|
|
public async createCustomer(data: {
|
|
name: string;
|
|
id: ObjectID;
|
|
email: Email;
|
|
}): Promise<string> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
const customer: Stripe.Response<Stripe.Customer> =
|
|
await this.stripe.customers.create({
|
|
name: data.name,
|
|
email: data.email.toString(),
|
|
metadata: {
|
|
id: data.id.toString(),
|
|
},
|
|
});
|
|
return customer.id;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async updateCustomerName(id: string, newName: string): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
await this.stripe.customers.update(id, { name: newName });
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async updateCustomerBusinessDetails(
|
|
id: string,
|
|
businessDetails: string,
|
|
countryCode?: string | null,
|
|
financeAccountingEmail?: string | null,
|
|
sendInvoicesByEmail?: boolean | null,
|
|
): Promise<void> {
|
|
logger.debug(
|
|
`[Invoice Email] updateCustomerBusinessDetails called - customerId: ${id}, sendInvoicesByEmail: ${sendInvoicesByEmail}`,
|
|
);
|
|
|
|
if (!this.isBillingEnabled()) {
|
|
logger.debug(
|
|
`[Invoice Email] Billing not enabled, skipping updateCustomerBusinessDetails for customer ${id}`,
|
|
);
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
/*
|
|
* Goal: Update Stripe Customer "Billing details" (address fields) rather than invoice footer.
|
|
* We only have a single free-form textarea. We'll map:
|
|
* First non-empty line -> address.line1
|
|
* Second non-empty line (if any) and remaining (joined, truncated) -> address.line2
|
|
* We also persist full text in metadata so we can reconstruct or improve parsing later.
|
|
* NOTE: Because Stripe requires structured address, any city/state/postal/country detection
|
|
* would be heuristic; we keep it simple unless we later add structured fields.
|
|
*/
|
|
|
|
const lines: Array<string> = businessDetails
|
|
.split(/\r?\n/)
|
|
.map((l: string) => {
|
|
return l.trim();
|
|
})
|
|
.filter((l: string) => {
|
|
return l.length > 0;
|
|
});
|
|
|
|
let line1: string | undefined = undefined;
|
|
let line2: string | undefined = undefined;
|
|
|
|
if (lines && lines.length > 0) {
|
|
const first: string = lines[0]!; // non-null
|
|
line1 = first.substring(0, 200); // Stripe typical limit safeguard.
|
|
}
|
|
if (lines && lines.length > 1) {
|
|
const rest: string = lines.slice(1).join(", ");
|
|
line2 = rest.substring(0, 200);
|
|
}
|
|
|
|
const metadata: Record<string, string> = {
|
|
business_details_full: businessDetails.substring(0, 5000),
|
|
};
|
|
if (financeAccountingEmail) {
|
|
metadata["finance_accounting_email"] = financeAccountingEmail.substring(
|
|
0,
|
|
200,
|
|
);
|
|
} else {
|
|
// Remove if cleared
|
|
metadata["finance_accounting_email"] = "";
|
|
}
|
|
if (sendInvoicesByEmail !== undefined && sendInvoicesByEmail !== null) {
|
|
metadata["send_invoices_by_email"] = sendInvoicesByEmail
|
|
? "true"
|
|
: "false";
|
|
logger.debug(
|
|
`[Invoice Email] Setting send_invoices_by_email metadata to "${metadata["send_invoices_by_email"]}" for customer ${id}`,
|
|
);
|
|
}
|
|
|
|
const updateParams: Stripe.CustomerUpdateParams = {
|
|
metadata,
|
|
address: {},
|
|
};
|
|
|
|
/*
|
|
* If finance / accounting email provided, set it as the customer email so Stripe sends
|
|
* invoices / receipts there. (Stripe only supports a single email via API currently.)
|
|
*/
|
|
if (financeAccountingEmail && financeAccountingEmail.trim().length > 0) {
|
|
updateParams.email = financeAccountingEmail.trim();
|
|
}
|
|
|
|
if (line1) {
|
|
updateParams.address = updateParams.address || {};
|
|
updateParams.address.line1 = line1;
|
|
}
|
|
if (line2) {
|
|
updateParams.address = updateParams.address || {};
|
|
updateParams.address.line2 = line2;
|
|
}
|
|
if (countryCode) {
|
|
updateParams.address = updateParams.address || {};
|
|
// Stripe expects uppercase 2-letter ISO code
|
|
updateParams.address.country = countryCode.toUpperCase();
|
|
}
|
|
|
|
if (!line1 && !line2 && !countryCode) {
|
|
// Clear address if empty details submitted.
|
|
updateParams.address = {
|
|
line1: "",
|
|
line2: "",
|
|
} as any;
|
|
}
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Updating Stripe customer ${id} with metadata: ${JSON.stringify(metadata)}`,
|
|
);
|
|
await this.stripe.customers.update(id, updateParams);
|
|
logger.debug(`[Invoice Email] Successfully updated Stripe customer ${id}`);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async deleteCustomer(id: string): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
await this.stripe.customers.del(id);
|
|
}
|
|
|
|
public isBillingEnabled(): boolean {
|
|
return IsBillingEnabled;
|
|
}
|
|
|
|
public isSubscriptionActive(status: SubscriptionStatus): boolean {
|
|
return SubscriptionStatusUtil.isSubscriptionActive(status);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async subscribeToMeteredPlan(data: {
|
|
projectId: ObjectID;
|
|
customerId: string;
|
|
serverMeteredPlans: Array<ServerMeteredPlan>;
|
|
trialDate: Date | null;
|
|
defaultPaymentMethodId?: string | undefined;
|
|
promoCode?: string | undefined;
|
|
}): Promise<{
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt: Date | null;
|
|
}> {
|
|
const meteredPlanSubscriptionParams: Stripe.SubscriptionCreateParams = {
|
|
customer: data.customerId,
|
|
|
|
proration_behavior: "always_invoice",
|
|
|
|
items: data.serverMeteredPlans.map((item: ServerMeteredPlan) => {
|
|
return {
|
|
price: item.getPriceId(),
|
|
};
|
|
}),
|
|
|
|
trial_end:
|
|
data.trialDate && OneUptimeDate.isInTheFuture(data.trialDate)
|
|
? OneUptimeDate.toUnixTimestamp(data.trialDate)
|
|
: "now",
|
|
};
|
|
|
|
if (data.promoCode) {
|
|
meteredPlanSubscriptionParams.coupon = data.promoCode;
|
|
}
|
|
|
|
if (data.defaultPaymentMethodId) {
|
|
meteredPlanSubscriptionParams.default_payment_method =
|
|
data.defaultPaymentMethodId;
|
|
}
|
|
|
|
// Create metered subscriptions
|
|
const meteredSubscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.create(meteredPlanSubscriptionParams);
|
|
|
|
for (const serverMeteredPlan of data.serverMeteredPlans) {
|
|
await serverMeteredPlan.reportQuantityToBillingProvider(data.projectId, {
|
|
meteredPlanSubscriptionId: meteredSubscription.id,
|
|
});
|
|
}
|
|
|
|
return {
|
|
meteredSubscriptionId: meteredSubscription.id,
|
|
trialEndsAt: data.trialDate,
|
|
};
|
|
}
|
|
|
|
public isTestEnvironment(): boolean {
|
|
return BillingPrivateKey.startsWith("sk_test");
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async generateCouponCode(data: {
|
|
name: string;
|
|
metadata?: Dictionary<string> | undefined;
|
|
percentOff: number;
|
|
durationInMonths: number;
|
|
maxRedemptions: number;
|
|
}): Promise<string> {
|
|
const coupon: Coupon = await this.stripe.coupons.create({
|
|
name: data.name,
|
|
percent_off: data.percentOff,
|
|
duration: "repeating",
|
|
duration_in_months: data.durationInMonths,
|
|
max_redemptions: data.maxRedemptions,
|
|
metadata: data.metadata || null,
|
|
});
|
|
|
|
return coupon.id;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async subscribeToPlan(data: {
|
|
projectId: ObjectID;
|
|
customerId: string;
|
|
serverMeteredPlans: Array<ServerMeteredPlan>;
|
|
plan: SubscriptionPlan;
|
|
quantity: number;
|
|
isYearly: boolean;
|
|
trial: boolean | Date | undefined;
|
|
defaultPaymentMethodId?: string | undefined;
|
|
promoCode?: string | undefined;
|
|
}): Promise<{
|
|
subscriptionId: string;
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt: Date | null;
|
|
}> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
let trialDate: Date | null = null;
|
|
|
|
if (typeof data.trial === "boolean") {
|
|
if (data.trial) {
|
|
trialDate = OneUptimeDate.getSomeDaysAfter(data.plan.getTrialPeriod());
|
|
} else {
|
|
trialDate = null;
|
|
}
|
|
} else if (data.trial instanceof Date) {
|
|
trialDate = data.trial;
|
|
}
|
|
|
|
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
|
customer: data.customerId,
|
|
|
|
items: [
|
|
{
|
|
price: data.isYearly
|
|
? data.plan.getYearlyPlanId()
|
|
: data.plan.getMonthlyPlanId(),
|
|
quantity: data.quantity,
|
|
},
|
|
],
|
|
|
|
proration_behavior: "always_invoice",
|
|
|
|
trial_end:
|
|
trialDate && data.plan.getTrialPeriod() > 0
|
|
? OneUptimeDate.toUnixTimestamp(trialDate)
|
|
: "now",
|
|
};
|
|
|
|
if (data.promoCode) {
|
|
subscriptionParams.coupon = data.promoCode;
|
|
}
|
|
|
|
if (data.defaultPaymentMethodId) {
|
|
subscriptionParams.default_payment_method = data.defaultPaymentMethodId;
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.create(subscriptionParams);
|
|
|
|
// Create metered subscriptions
|
|
const meteredSubscription: {
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt: Date | null;
|
|
} = await this.subscribeToMeteredPlan({
|
|
...data,
|
|
trialDate,
|
|
});
|
|
|
|
return {
|
|
subscriptionId: subscription.id,
|
|
meteredSubscriptionId: meteredSubscription.meteredSubscriptionId,
|
|
trialEndsAt:
|
|
trialDate && data.plan.getTrialPeriod() > 0 ? trialDate : null,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async changeQuantity(
|
|
subscriptionId: string,
|
|
quantity: number,
|
|
): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
if (!subscription) {
|
|
throw new BadDataException(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
}
|
|
if (subscription.status === "canceled") {
|
|
// subscription is canceled.
|
|
return;
|
|
}
|
|
|
|
const subscriptionItemId: string | undefined =
|
|
subscription.items.data[0]?.id;
|
|
|
|
if (!subscriptionItemId) {
|
|
throw new BadDataException(
|
|
Errors.BillingService.SUBSCRIPTION_ITEM_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
await this.stripe.subscriptionItems.update(subscriptionItemId, {
|
|
quantity: quantity,
|
|
});
|
|
|
|
// add billing anchor, so that the billing cycle starts now. New quantity will be charged from now. https://stackoverflow.com/questions/44417047/immediately-charge-for-subscription-changes
|
|
await this.stripe.subscriptions.update(subscriptionId, {
|
|
proration_behavior: "always_invoice",
|
|
});
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async addOrUpdateMeteredPricingOnSubscription(
|
|
subscriptionId: string,
|
|
serverMeteredPlan: ServerMeteredPlan,
|
|
quantity: number,
|
|
): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
// get subscription.
|
|
const subscription: Stripe.Subscription = await this.getSubscription(
|
|
subscriptionId.toString(),
|
|
);
|
|
|
|
if (!subscription) {
|
|
throw new BadDataException(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
}
|
|
|
|
// check if this pricing exists
|
|
|
|
const pricingExists: boolean = subscription.items.data.some(
|
|
(item: SubscriptionItem) => {
|
|
return item.price?.id === serverMeteredPlan.getPriceId();
|
|
},
|
|
);
|
|
|
|
if (pricingExists) {
|
|
// update the quantity.
|
|
const subscriptionItemId: string | undefined =
|
|
subscription.items.data.find((item: SubscriptionItem) => {
|
|
return item.price?.id === serverMeteredPlan.getPriceId();
|
|
})?.id;
|
|
|
|
if (!subscriptionItemId) {
|
|
throw new BadDataException(
|
|
Errors.BillingService.SUBSCRIPTION_ITEM_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
// use stripe usage based api to update the quantity.
|
|
await this.stripe.subscriptionItems.createUsageRecord(
|
|
subscriptionItemId,
|
|
{
|
|
quantity: quantity,
|
|
},
|
|
);
|
|
} else {
|
|
// add the pricing.
|
|
const subscriptionItem: SubscriptionItem =
|
|
await this.stripe.subscriptionItems.create({
|
|
subscription: subscriptionId,
|
|
price: serverMeteredPlan.getPriceId(),
|
|
});
|
|
|
|
// use stripe usage based api to update the quantity.
|
|
await this.stripe.subscriptionItems.createUsageRecord(
|
|
subscriptionItem.id,
|
|
{
|
|
quantity: quantity,
|
|
},
|
|
);
|
|
}
|
|
|
|
// complete.
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async isPromoCodeValid(promoCode: string): Promise<boolean> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
try {
|
|
const promoCodeResponse: Stripe.Response<Stripe.Coupon> =
|
|
await this.stripe.coupons.retrieve(promoCode);
|
|
|
|
if (!promoCodeResponse) {
|
|
throw new BadDataException(Errors.BillingService.PROMO_CODE_NOT_FOUND);
|
|
}
|
|
|
|
return promoCodeResponse.valid;
|
|
} catch (err) {
|
|
throw new BadDataException(
|
|
(err as Error).message || Errors.BillingService.PROMO_CODE_INVALID,
|
|
);
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async removeSubscriptionItem(
|
|
subscriptionId: string,
|
|
subscriptionItemId: string,
|
|
isMeteredSubscriptionItem: boolean,
|
|
): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
if (!subscription) {
|
|
throw new BadDataException(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
}
|
|
|
|
if (subscription.status === "canceled") {
|
|
// subscription is canceled.
|
|
return;
|
|
}
|
|
|
|
const subscriptionItemOptions: Stripe.SubscriptionItemDeleteParams =
|
|
isMeteredSubscriptionItem
|
|
? {
|
|
proration_behavior: "create_prorations",
|
|
clear_usage: true,
|
|
}
|
|
: {};
|
|
|
|
await this.stripe.subscriptionItems.del(
|
|
subscriptionItemId,
|
|
subscriptionItemOptions,
|
|
);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getSubscriptionItems(
|
|
subscriptionId: string,
|
|
): Promise<Array<SubscriptionItem>> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
if (!subscription) {
|
|
throw new BadDataException(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
}
|
|
|
|
return subscription.items.data;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async changePlan(data: {
|
|
projectId: ObjectID;
|
|
subscriptionId: string;
|
|
meteredSubscriptionId: string;
|
|
serverMeteredPlans: Array<ServerMeteredPlan>;
|
|
newPlan: SubscriptionPlan;
|
|
quantity: number;
|
|
isYearly: boolean;
|
|
endTrialAt?: Date | undefined;
|
|
}): Promise<{
|
|
subscriptionId: string;
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt?: Date | undefined;
|
|
}> {
|
|
logger.debug("Changing plan");
|
|
logger.debug(data);
|
|
|
|
if (!this.isBillingEnabled()) {
|
|
logger.debug(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.retrieve(data.subscriptionId);
|
|
|
|
logger.debug("Subscription");
|
|
logger.debug(subscription);
|
|
|
|
if (!subscription) {
|
|
logger.debug(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
throw new BadDataException(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
|
|
}
|
|
|
|
logger.debug("Subscription status");
|
|
logger.debug(subscription.status);
|
|
|
|
const paymentMethods: Array<PaymentMethod> = await this.getPaymentMethods(
|
|
subscription.customer.toString(),
|
|
);
|
|
|
|
logger.debug("Payment methods");
|
|
logger.debug(paymentMethods);
|
|
|
|
if (paymentMethods.length === 0) {
|
|
logger.debug("No payment methods");
|
|
|
|
throw new BadDataException(Errors.BillingService.NO_PAYMENTS_METHODS);
|
|
}
|
|
|
|
logger.debug("Cancelling subscriptions");
|
|
logger.debug(data.subscriptionId);
|
|
await this.cancelSubscription(data.subscriptionId);
|
|
|
|
logger.debug("Cancelling metered subscriptions");
|
|
logger.debug(data.meteredSubscriptionId);
|
|
await this.cancelSubscription(data.meteredSubscriptionId);
|
|
|
|
if (data.endTrialAt && !OneUptimeDate.isInTheFuture(data.endTrialAt)) {
|
|
data.endTrialAt = undefined;
|
|
}
|
|
|
|
logger.debug("Subscribing to plan");
|
|
|
|
const subscribeToPlan: {
|
|
subscriptionId: string;
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt: Date | null;
|
|
} = await this.subscribeToPlan({
|
|
projectId: data.projectId,
|
|
customerId: subscription.customer.toString(),
|
|
serverMeteredPlans: data.serverMeteredPlans,
|
|
plan: data.newPlan,
|
|
quantity: data.quantity,
|
|
isYearly: data.isYearly,
|
|
trial: data.endTrialAt,
|
|
defaultPaymentMethodId: paymentMethods[0]?.id,
|
|
promoCode: undefined,
|
|
});
|
|
|
|
logger.debug("Subscribed to plan");
|
|
|
|
const value: {
|
|
subscriptionId: string;
|
|
meteredSubscriptionId: string;
|
|
trialEndsAt?: Date | undefined;
|
|
} = {
|
|
subscriptionId: subscribeToPlan.subscriptionId,
|
|
meteredSubscriptionId: subscribeToPlan.meteredSubscriptionId,
|
|
trialEndsAt: subscribeToPlan.trialEndsAt || undefined,
|
|
};
|
|
|
|
return value;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async deletePaymentMethod(
|
|
customerId: string,
|
|
paymentMethodId: string,
|
|
): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const paymentMethods: Array<PaymentMethod> =
|
|
await this.getPaymentMethods(customerId);
|
|
|
|
if (paymentMethods.length === 1) {
|
|
throw new BadDataException(
|
|
Errors.BillingService.MIN_REQUIRED_PAYMENT_METHOD_NOT_MET,
|
|
);
|
|
}
|
|
|
|
await this.stripe.paymentMethods.detach(paymentMethodId);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async hasPaymentMethods(customerId: string): Promise<boolean> {
|
|
if ((await this.getPaymentMethods(customerId)).length > 0) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async setDefaultPaymentMethod(
|
|
customerId: string,
|
|
paymentMethodId: string,
|
|
): Promise<void> {
|
|
await this.stripe.customers.update(customerId, {
|
|
invoice_settings: {
|
|
default_payment_method: paymentMethodId,
|
|
},
|
|
});
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getPaymentMethods(
|
|
customerId: string,
|
|
): Promise<Array<PaymentMethod>> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
const paymentMethods: Array<PaymentMethod> = [];
|
|
|
|
const cardPaymentMethods: Stripe.ApiList<Stripe.PaymentMethod> =
|
|
await this.stripe.paymentMethods.list({
|
|
customer: customerId,
|
|
type: "card",
|
|
});
|
|
|
|
const sepaPaymentMethods: Stripe.ApiList<Stripe.PaymentMethod> =
|
|
await this.stripe.paymentMethods.list({
|
|
customer: customerId,
|
|
type: "sepa_debit",
|
|
});
|
|
|
|
const usBankPaymentMethods: Stripe.ApiList<Stripe.PaymentMethod> =
|
|
await this.stripe.paymentMethods.list({
|
|
customer: customerId,
|
|
type: "us_bank_account",
|
|
});
|
|
|
|
const bacsPaymentMethods: Stripe.ApiList<Stripe.PaymentMethod> =
|
|
await this.stripe.paymentMethods.list({
|
|
customer: customerId,
|
|
type: "bacs_debit",
|
|
});
|
|
|
|
cardPaymentMethods.data.forEach((item: Stripe.PaymentMethod) => {
|
|
paymentMethods.push({
|
|
type: item.card?.brand || "Card",
|
|
last4Digits: item.card?.last4 || "xxxx",
|
|
isDefault: false,
|
|
id: item.id,
|
|
});
|
|
});
|
|
|
|
bacsPaymentMethods.data.forEach((item: Stripe.PaymentMethod) => {
|
|
paymentMethods.push({
|
|
type: "UK Bank Account",
|
|
last4Digits: item.bacs_debit?.last4 || "xxxx",
|
|
isDefault: false,
|
|
id: item.id,
|
|
});
|
|
});
|
|
|
|
usBankPaymentMethods.data.forEach((item: Stripe.PaymentMethod) => {
|
|
paymentMethods.push({
|
|
type: "US Bank Account",
|
|
last4Digits: item.us_bank_account?.last4 || "xxxx",
|
|
isDefault: false,
|
|
id: item.id,
|
|
});
|
|
});
|
|
|
|
sepaPaymentMethods.data.forEach((item: Stripe.PaymentMethod) => {
|
|
paymentMethods.push({
|
|
type: "EU Bank Account",
|
|
last4Digits: item.sepa_debit?.last4 || "xxxx",
|
|
isDefault: false,
|
|
id: item.id,
|
|
});
|
|
});
|
|
|
|
// check if there's a default payment method.
|
|
|
|
const customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer> =
|
|
await this.stripe.customers.retrieve(customerId);
|
|
|
|
if (
|
|
(customer as Stripe.Customer).invoice_settings &&
|
|
!(customer as Stripe.Customer).invoice_settings?.default_payment_method
|
|
) {
|
|
// set the first payment method as default.
|
|
if (paymentMethods.length > 0 && paymentMethods[0]?.id) {
|
|
await this.setDefaultPaymentMethod(customerId, paymentMethods[0]?.id);
|
|
}
|
|
}
|
|
|
|
return paymentMethods;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getSetupIntentSecret(customerId: string): Promise<string> {
|
|
const setupIntent: Stripe.Response<Stripe.SetupIntent> =
|
|
await this.stripe.setupIntents.create({
|
|
customer: customerId,
|
|
});
|
|
|
|
if (!setupIntent.client_secret) {
|
|
throw new APIException(Errors.BillingService.CLIENT_SECRET_MISSING);
|
|
}
|
|
|
|
return setupIntent.client_secret;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getCustomerBalance(customerId: string): Promise<number> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer> =
|
|
await this.stripe.customers.retrieve(customerId);
|
|
|
|
if (!customer || customer.deleted) {
|
|
throw new BadDataException(Errors.BillingService.CUSTOMER_NOT_FOUND);
|
|
}
|
|
|
|
// Balance is in cents, convert to dollars
|
|
return ((customer as Stripe.Customer).balance || 0) / 100;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async cancelSubscription(subscriptionId: string): Promise<void> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
try {
|
|
await this.stripe.subscriptions.del(subscriptionId);
|
|
} catch (err) {
|
|
logger.error(err);
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getSubscriptionStatus(
|
|
subscriptionId: string,
|
|
): Promise<SubscriptionStatus> {
|
|
const subscription: Stripe.Subscription =
|
|
await this.getSubscription(subscriptionId);
|
|
return subscription.status as SubscriptionStatus;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getSubscription(
|
|
subscriptionId: string,
|
|
): Promise<Stripe.Subscription> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
const subscription: Stripe.Response<Stripe.Subscription> =
|
|
await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
return subscription;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getInvoice(
|
|
customerId: string,
|
|
invoiceId: string,
|
|
): Promise<Invoice> {
|
|
if (!this.isBillingEnabled()) {
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
logger.debug("Getting invoice");
|
|
logger.debug(invoiceId);
|
|
logger.debug(customerId);
|
|
|
|
const invoice: Stripe.Response<Stripe.Invoice> =
|
|
await this.stripe.invoices.retrieve(invoiceId);
|
|
|
|
if (!invoice) {
|
|
throw new BadDataException(Errors.BillingService.INVOICE_NOT_FOUND);
|
|
}
|
|
|
|
return {
|
|
id: invoice.id!,
|
|
amount: invoice.amount_due,
|
|
currencyCode: invoice.currency,
|
|
subscriptionId: invoice.subscription?.toString() || undefined,
|
|
status: invoice.status?.toString() || "Unknown",
|
|
downloadableLink: invoice.invoice_pdf?.toString() || "",
|
|
customerId: invoice.customer?.toString() || "",
|
|
invoiceDate: invoice.created
|
|
? new Date(invoice.created * 1000)
|
|
: OneUptimeDate.getCurrentDate(),
|
|
invoiceNumber: invoice.number || undefined,
|
|
paymentIntentId: invoice.payment_intent?.toString() || undefined,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getPaymentIntentClientSecret(
|
|
paymentIntentId: string,
|
|
): Promise<string> {
|
|
const paymentIntent: Stripe.Response<Stripe.PaymentIntent> =
|
|
await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
|
|
|
if (!paymentIntent.client_secret) {
|
|
throw new APIException(Errors.BillingService.CLIENT_SECRET_MISSING);
|
|
}
|
|
|
|
return paymentIntent.client_secret;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getPaymentIntentIdFromInvoice(
|
|
invoiceId: string,
|
|
): Promise<string> {
|
|
const invoice: Stripe.Response<Stripe.Invoice> =
|
|
await this.stripe.invoices.retrieve(invoiceId);
|
|
|
|
if (!invoice) {
|
|
throw new BadDataException(Errors.BillingService.INVOICE_NOT_FOUND);
|
|
}
|
|
|
|
if (!invoice.payment_intent) {
|
|
throw new BadDataException(
|
|
Errors.BillingService.PAYMENT_INTENT_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
return invoice.payment_intent.toString();
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getInvoices(customerId: string): Promise<Array<Invoice>> {
|
|
const invoices: Stripe.ApiList<Stripe.Invoice> =
|
|
await this.stripe.invoices.list({
|
|
customer: customerId,
|
|
limit: 100,
|
|
});
|
|
|
|
let billingInvoices: Array<Invoice> = invoices.data.map(
|
|
(invoice: Stripe.Invoice) => {
|
|
return {
|
|
id: invoice.id!,
|
|
amount: invoice.amount_due,
|
|
currencyCode: invoice.currency,
|
|
subscriptionId: invoice.subscription?.toString() || undefined,
|
|
status: invoice.status?.toString() || "Unknown",
|
|
downloadableLink: invoice.invoice_pdf?.toString() || "",
|
|
customerId: invoice.customer?.toString() || "",
|
|
invoiceDate: invoice.created
|
|
? new Date(invoice.created * 1000)
|
|
: OneUptimeDate.getCurrentDate(),
|
|
invoiceNumber: invoice.number || undefined,
|
|
paymentIntent: invoice.payment_intent?.toString() || undefined,
|
|
};
|
|
},
|
|
);
|
|
|
|
// sort by date in descending order.
|
|
billingInvoices = billingInvoices.sort((a: Invoice, b: Invoice) => {
|
|
return a.invoiceDate.getTime() - b.invoiceDate.getTime();
|
|
});
|
|
|
|
return billingInvoices;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async sendInvoiceByEmail(
|
|
invoiceId: string,
|
|
recipientEmail?: Email,
|
|
projectId?: ObjectID,
|
|
): Promise<void> {
|
|
logger.debug(
|
|
`[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipientEmail: ${recipientEmail?.toString()}`,
|
|
);
|
|
|
|
if (!this.isBillingEnabled()) {
|
|
logger.debug(
|
|
`[Invoice Email] Billing not enabled, skipping send for invoice: ${invoiceId}`,
|
|
);
|
|
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
|
}
|
|
|
|
try {
|
|
// Fetch invoice details from Stripe
|
|
logger.debug(
|
|
`[Invoice Email] Fetching invoice ${invoiceId} details from Stripe`,
|
|
);
|
|
const stripeInvoice: Stripe.Invoice =
|
|
await this.stripe.invoices.retrieve(invoiceId);
|
|
|
|
if (!stripeInvoice) {
|
|
logger.error(
|
|
`[Invoice Email] Invoice ${invoiceId} not found in Stripe`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Determine recipient email
|
|
let toEmail: Email | undefined = recipientEmail;
|
|
if (!toEmail && stripeInvoice.customer_email) {
|
|
toEmail = new Email(stripeInvoice.customer_email);
|
|
}
|
|
|
|
if (!toEmail) {
|
|
logger.error(
|
|
`[Invoice Email] No recipient email found for invoice ${invoiceId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Format invoice data for email
|
|
const invoiceNumber: string = stripeInvoice.number || invoiceId;
|
|
const invoiceDate: string = stripeInvoice.created
|
|
? OneUptimeDate.getDateAsFormattedString(
|
|
new Date(stripeInvoice.created * 1000),
|
|
)
|
|
: OneUptimeDate.getDateAsFormattedString(
|
|
OneUptimeDate.getCurrentDate(),
|
|
);
|
|
const amount: string = `${(stripeInvoice.amount_due / 100).toFixed(2)} ${stripeInvoice.currency?.toUpperCase() || "USD"}`;
|
|
const invoicePdfUrl: string | undefined =
|
|
stripeInvoice.invoice_pdf || undefined;
|
|
const description: string | undefined =
|
|
stripeInvoice.description || undefined;
|
|
|
|
// Build dashboard link
|
|
let dashboardLink: string | undefined = undefined;
|
|
if (projectId && DashboardClientUrl) {
|
|
dashboardLink = `${DashboardClientUrl.toString()}/dashboard/${projectId.toString()}/settings/billing`;
|
|
}
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
|
|
);
|
|
|
|
// Send email via OneUptime MailService
|
|
await MailService.sendMail(
|
|
{
|
|
toEmail: toEmail,
|
|
templateType: EmailTemplateType.Invoice,
|
|
vars: {
|
|
invoiceNumber: invoiceNumber,
|
|
invoiceDate: invoiceDate,
|
|
amount: amount,
|
|
description: description || "",
|
|
invoicePdfUrl: invoicePdfUrl || "",
|
|
dashboardLink: dashboardLink || "",
|
|
},
|
|
subject: `Invoice #${invoiceNumber} from OneUptime`,
|
|
},
|
|
{
|
|
projectId: projectId,
|
|
},
|
|
);
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
`[Invoice Email] Failed to send invoice ${invoiceId} by email: ${err}`,
|
|
);
|
|
// Don't throw - sending email is not critical
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async shouldSendInvoicesByEmail(customerId: string): Promise<boolean> {
|
|
logger.debug(
|
|
`[Invoice Email] shouldSendInvoicesByEmail called for customer: ${customerId}`,
|
|
);
|
|
|
|
if (!this.isBillingEnabled()) {
|
|
logger.debug(
|
|
`[Invoice Email] Billing not enabled, returning false for customer: ${customerId}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
logger.debug(
|
|
`[Invoice Email] Retrieving customer ${customerId} from Stripe to check preference`,
|
|
);
|
|
const customer: Stripe.Response<
|
|
Stripe.Customer | Stripe.DeletedCustomer
|
|
> = await this.stripe.customers.retrieve(customerId);
|
|
|
|
if (!customer || customer.deleted) {
|
|
logger.debug(
|
|
`[Invoice Email] Customer ${customerId} not found or deleted, returning false`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const metadata: Stripe.Metadata = (customer as Stripe.Customer).metadata;
|
|
const sendInvoicesByEmail: boolean =
|
|
metadata?.["send_invoices_by_email"] === "true";
|
|
logger.debug(
|
|
`[Invoice Email] Customer ${customerId} metadata.send_invoices_by_email = "${metadata?.["send_invoices_by_email"]}", result: ${sendInvoicesByEmail}`,
|
|
);
|
|
return sendInvoicesByEmail;
|
|
} catch (err) {
|
|
logger.error(
|
|
`[Invoice Email] Failed to check invoice email preference for customer ${customerId}: ${err}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async generateInvoiceAndChargeCustomer(
|
|
customerId: string,
|
|
itemText: string,
|
|
amountInUsd: number,
|
|
options?: {
|
|
sendInvoiceByEmail?: boolean | undefined;
|
|
recipientEmail?: Email | undefined;
|
|
projectId?: ObjectID | undefined;
|
|
},
|
|
): Promise<void> {
|
|
const sendInvoiceByEmail: boolean = options?.sendInvoiceByEmail || false;
|
|
const recipientEmail: Email | undefined = options?.recipientEmail;
|
|
const projectId: ObjectID | undefined = options?.projectId;
|
|
|
|
logger.debug(
|
|
`[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipientEmail: ${recipientEmail?.toString()}, projectId: ${projectId?.toString()}`,
|
|
);
|
|
|
|
const invoice: Stripe.Invoice = await this.stripe.invoices.create({
|
|
customer: customerId,
|
|
auto_advance: true, // do not automatically charge.
|
|
collection_method: "charge_automatically",
|
|
});
|
|
|
|
if (!invoice || !invoice.id) {
|
|
logger.error(
|
|
`[Invoice Email] Failed to create invoice for customer ${customerId}`,
|
|
);
|
|
throw new APIException(Errors.BillingService.INVOICE_NOT_GENERATED);
|
|
}
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Created invoice ${invoice.id} for customer ${customerId}`,
|
|
);
|
|
|
|
await this.stripe.invoiceItems.create({
|
|
invoice: invoice.id,
|
|
amount: amountInUsd * 100,
|
|
description: itemText,
|
|
customer: customerId,
|
|
});
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Added invoice item to invoice ${invoice.id}: ${itemText}, $${amountInUsd}`,
|
|
);
|
|
|
|
await this.stripe.invoices.finalizeInvoice(invoice.id!);
|
|
logger.debug(`[Invoice Email] Finalized invoice ${invoice.id}`);
|
|
|
|
try {
|
|
await this.payInvoice(customerId, invoice.id!);
|
|
logger.debug(`[Invoice Email] Paid invoice ${invoice.id}`);
|
|
|
|
// Send invoice by email if requested
|
|
if (sendInvoiceByEmail) {
|
|
logger.debug(
|
|
`[Invoice Email] sendInvoiceByEmail is true, sending invoice ${invoice.id} by email`,
|
|
);
|
|
await this.sendInvoiceByEmail(invoice.id!, recipientEmail, projectId);
|
|
} else {
|
|
logger.debug(
|
|
`[Invoice Email] sendInvoiceByEmail is false, skipping email for invoice ${invoice.id}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
`[Invoice Email] Failed to pay invoice ${invoice.id}, voiding: ${err}`,
|
|
);
|
|
// mark invoice as failed and do not collect payment.
|
|
await this.voidInvoice(invoice.id!);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async voidInvoice(invoiceId: string): Promise<Stripe.Invoice> {
|
|
const invoice: Stripe.Invoice =
|
|
await this.stripe.invoices.voidInvoice(invoiceId);
|
|
|
|
return invoice;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async payInvoice(
|
|
customerId: string,
|
|
invoiceId: string,
|
|
): Promise<Invoice> {
|
|
// after the invoice is paid, // please fetch subscription and check the status.
|
|
const paymentMethods: Array<PaymentMethod> =
|
|
await this.getPaymentMethods(customerId);
|
|
|
|
if (paymentMethods.length === 0) {
|
|
throw new BadDataException(Errors.BillingService.NO_PAYMENTS_METHODS);
|
|
}
|
|
|
|
const invoice: Stripe.Invoice = await this.stripe.invoices.pay(invoiceId, {
|
|
payment_method: paymentMethods[0]?.id || "",
|
|
});
|
|
|
|
return {
|
|
id: invoice.id!,
|
|
amount: invoice.amount_due,
|
|
currencyCode: invoice.currency,
|
|
subscriptionId: invoice.subscription?.toString() || undefined,
|
|
status: invoice.status?.toString() || "Unknown",
|
|
downloadableLink: invoice.invoice_pdf?.toString() || "",
|
|
customerId: invoice.customer?.toString() || "",
|
|
invoiceDate: invoice.created
|
|
? new Date(invoice.created * 1000)
|
|
: OneUptimeDate.getCurrentDate(),
|
|
invoiceNumber: invoice.number || undefined,
|
|
};
|
|
}
|
|
|
|
public getMeteredPlanPriceId(productType: ProductType): string {
|
|
if (productType === ProductType.ActiveMonitoring) {
|
|
if (this.isTestEnvironment()) {
|
|
return "price_1N6CHFANuQdJ93r7qDaLmb7S";
|
|
}
|
|
|
|
return "price_1N6B9EANuQdJ93r7fj3bhcWP";
|
|
}
|
|
|
|
if (productType === ProductType.Logs) {
|
|
if (this.isTestEnvironment()) {
|
|
return "price_1OPnB5ANuQdJ93r7jG4NLCJG";
|
|
}
|
|
|
|
return "price_1OQ8gwANuQdJ93r74Pi85UQq";
|
|
}
|
|
|
|
if (productType === ProductType.Traces) {
|
|
if (this.isTestEnvironment()) {
|
|
return "price_1OQ8i9ANuQdJ93r75J3wr0PX";
|
|
}
|
|
|
|
return "price_1OQ8ivANuQdJ93r7NAR8KbH3";
|
|
}
|
|
|
|
if (productType === ProductType.Metrics) {
|
|
if (this.isTestEnvironment()) {
|
|
return "price_1OQ8iqANuQdJ93r7wZ7gJ7Gb";
|
|
}
|
|
|
|
return "price_1OQ8j0ANuQdJ93r7WGzR0p6j";
|
|
}
|
|
|
|
if (productType === ProductType.Profiles) {
|
|
if (this.isTestEnvironment()) {
|
|
return "price_1TGwUpANuQdJ93r7b9qxa5Se";
|
|
}
|
|
|
|
return "price_1TGwTDANuQdJ93r7s0jKRxaT";
|
|
}
|
|
|
|
throw new BadDataException(
|
|
"Plan with productType " + productType + " not found",
|
|
);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public verifyWebhookSignature(
|
|
payload: string | Buffer,
|
|
signature: string,
|
|
): Stripe.Event {
|
|
logger.debug(`[Invoice Email] verifyWebhookSignature called`);
|
|
|
|
if (!BillingWebhookSecret) {
|
|
logger.error(`[Invoice Email] Billing webhook secret is not configured`);
|
|
throw new BadDataException("Billing webhook secret is not configured");
|
|
}
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Verifying webhook signature with secret (length: ${BillingWebhookSecret.length})`,
|
|
);
|
|
const event: Stripe.Event = this.stripe.webhooks.constructEvent(
|
|
payload,
|
|
signature,
|
|
BillingWebhookSecret,
|
|
);
|
|
logger.debug(
|
|
`[Invoice Email] Webhook signature verified, event type: ${event.type}, event id: ${event.id}`,
|
|
);
|
|
return event;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async handleWebhookEvent(event: Stripe.Event): Promise<void> {
|
|
logger.debug(
|
|
`[Invoice Email] handleWebhookEvent called - event type: ${event.type}, event id: ${event.id}`,
|
|
);
|
|
|
|
if (!this.isBillingEnabled()) {
|
|
logger.debug(
|
|
`[Invoice Email] Billing not enabled, ignoring webhook event ${event.id}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Handle invoice.finalized event to send invoice by email if customer has opted in
|
|
if (event.type === "invoice.finalized") {
|
|
logger.debug(
|
|
`[Invoice Email] Processing invoice.finalized event ${event.id}`,
|
|
);
|
|
const invoice: Stripe.Invoice = event.data.object as Stripe.Invoice;
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Invoice details - id: ${invoice.id}, number: ${invoice.number}, customer: ${invoice.customer}, status: ${invoice.status}`,
|
|
);
|
|
|
|
if (!invoice.customer) {
|
|
logger.debug(
|
|
`[Invoice Email] No customer on invoice ${invoice.id}, skipping`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const customerId: string =
|
|
typeof invoice.customer === "string"
|
|
? invoice.customer
|
|
: invoice.customer.id;
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Extracted customer ID: ${customerId} from invoice ${invoice.id}`,
|
|
);
|
|
|
|
try {
|
|
logger.debug(
|
|
`[Invoice Email] Checking if customer ${customerId} has invoice emails enabled`,
|
|
);
|
|
const shouldSend: boolean =
|
|
await this.shouldSendInvoicesByEmail(customerId);
|
|
|
|
if (shouldSend && invoice.id) {
|
|
logger.debug(
|
|
`[Invoice Email] Customer ${customerId} has invoice emails enabled, looking up project`,
|
|
);
|
|
|
|
// Find the project by Stripe customer ID
|
|
const project: Project | null = await ProjectService.findOneBy({
|
|
query: {
|
|
paymentProviderCustomerId: customerId,
|
|
},
|
|
select: {
|
|
_id: true,
|
|
financeAccountingEmail: true,
|
|
},
|
|
props: {
|
|
isRoot: true,
|
|
},
|
|
});
|
|
|
|
let recipientEmail: Email | undefined = undefined;
|
|
let projectId: ObjectID | undefined = undefined;
|
|
|
|
if (project) {
|
|
projectId = project.id || undefined;
|
|
if (project.financeAccountingEmail) {
|
|
recipientEmail = new Email(project.financeAccountingEmail);
|
|
}
|
|
logger.debug(
|
|
`[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail: ${recipientEmail?.toString()}`,
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
`[Invoice Email] No project found for customer ${customerId}, will use Stripe customer email`,
|
|
);
|
|
}
|
|
|
|
logger.debug(
|
|
`[Invoice Email] Sending invoice ${invoice.id} by email`,
|
|
);
|
|
await this.sendInvoiceByEmail(invoice.id, recipientEmail, projectId);
|
|
logger.debug(
|
|
`[Invoice Email] Successfully processed invoice.finalized - sent invoice ${invoice.id} by email`,
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
`[Invoice Email] Customer ${customerId} has invoice emails disabled (shouldSend: ${shouldSend}), skipping email for invoice ${invoice.id}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
`[Invoice Email] Failed to send invoice by email for invoice ${invoice.id}: ${err}`,
|
|
);
|
|
// Don't throw - webhook should still return success
|
|
}
|
|
} else {
|
|
logger.debug(
|
|
`[Invoice Email] Ignoring event type ${event.type}, not invoice.finalized`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new BillingService();
|