import { BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import {
InvoiceStatus,
InvoiceType,
PackageType,
SubscriptionStatus,
SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import {
addBillingDays,
getBillingCycleMultiplier,
getBillingCyclePeriodDays,
} from "../../../../../config/billing-cycle";
import {
invoiceBillingAmountsFromRenewalCents,
invoiceBillingAmountsToDbFields,
} from "../../../../../config/billing.config";
import { localStripeCancelAtPeriodEndFromSubscription, StripeService } from "../stripe.service";
import {
StripePaymentBillingHelpersService,
StripeSubscriptionApi,
} from "./stripe-payment-billing-helpers.service";
@Injectable()
export class StripePaymentInvoiceWebhookService {
private readonly logger = new Logger(StripePaymentInvoiceWebhookService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly stripeService: StripeService,
private readonly billing: StripePaymentBillingHelpersService,
) {}
private shouldSendPaymentSucceededEmail(amountUsd: number): boolean {
return Number.isFinite(amountUsd) && amountUsd > 0;
}
private async sendPaymentFailedEmailFromStripeSubscriptionInvoice(
stripeInvoice: Stripe.Invoice,
failureMessage: string,
): Promise<void> {
const sid = String(stripeInvoice.id);
if (!sid || sid === "undefined") {
return;
}
const cents =
typeof stripeInvoice.amount_due === "number" && stripeInvoice.amount_due >= 0
? stripeInvoice.amount_due
: typeof stripeInvoice.total === "number" && stripeInvoice.total >= 0
? stripeInvoice.total
: 0;
const invNo = stripeInvoice.number ? String(stripeInvoice.number) : sid;
const sendFailed = async (companyId: number): Promise<void> => {
await this.billing.sendCompanyTemplatedEmail({
companyId,
templateKey: "billing.payment.failed",
dedupeKey: `billing.payment.failed:${sid}`,
triggeredBy: "stripe_webhook_invoice_payment_failed",
variables: {
"invoice.number": invNo,
"invoice.amount": this.billing.formatUsd(cents / 100),
"invoice.failure_message": failureMessage,
},
metadata: { stripe_invoice_id: sid },
});
};
const inv = stripeInvoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
};
const subRef = inv.subscription;
let subscriptionId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
// Some Stripe webhook payloads may omit `invoice.subscription`.
// If that happens, fetch expanded invoice from Stripe and retry resolution.
if (!subscriptionId && this.stripeService.isConfigured()) {
try {
const expanded = await this.stripeService.getInvoiceExpanded(stripeInvoice.id);
const expandedSub = (expanded as Stripe.Invoice & { subscription?: string | Stripe.Subscription | null })
.subscription;
subscriptionId = typeof expandedSub === "string" ? expandedSub : expandedSub?.id ?? null;
} catch (e) {
this.logger.warn(
`[stripe-renewal-debug] could not resolve invoice subscription via expanded invoice. stripe_invoice_id=${sid}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
let companyId: number | null = null;
if (subscriptionId && this.stripeService.isConfigured()) {
try {
const stripeSub = await this.stripeService.retrieveSubscription(subscriptionId);
const companyIdStr = stripeSub.metadata?.["company_id"];
if (companyIdStr) {
const parsed = parseInt(companyIdStr, 10);
if (!Number.isNaN(parsed)) companyId = parsed;
}
if (companyId === null) {
const cust =
this.billing.extractStripeCustomerId(stripeSub.customer) ?? this.billing.extractStripeCustomerId(inv.customer);
if (cust) {
companyId = await this.billing.resolveCompanyIdFromStripeCustomer(cust);
}
}
} catch (e) {
this.logger.warn(
`invoice.payment_failed: retrieveSubscription ${subscriptionId} failed: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
if (companyId === null) {
const custOnly = this.billing.extractStripeCustomerId(inv.customer);
if (custOnly) {
companyId = await this.billing.resolveCompanyIdFromStripeCustomer(custOnly);
}
}
if (companyId === null) {
this.logger.warn(
`invoice.payment_failed: could not resolve company for stripe_invoice=${sid} (subscription=${subscriptionId ?? "none"})`,
);
return;
}
await sendFailed(companyId);
}
async handleInvoicePaymentFailed(stripeInvoice: Stripe.Invoice): Promise<void> {
const stripeSid = String(stripeInvoice.id);
this.logger.warn(
`invoice.payment_failed: stripe_invoice=${stripeSid}, subscription=${String(
(stripeInvoice as Stripe.Invoice & { subscription?: string | Stripe.Subscription | null }).subscription ??
"",
)}`,
);
const { paymentIntentId } = this.billing.extractPaymentIntentAndChargeIds(stripeInvoice);
const msg =
typeof (stripeInvoice as { last_finalization_error?: { message?: string } }).last_finalization_error
?.message === "string"
? String(
(stripeInvoice as { last_finalization_error?: { message?: string } }).last_finalization_error?.message,
)
: "Payment failed — update your payment method in Subscription & Billing or contact support.";
let emailed = false;
if (paymentIntentId) {
const invoice = await this.prisma.client.invoice.findFirst({
where: { stripe_payment_intent_id: paymentIntentId },
select: { id: true, company_id: true, invoice_number: true, total_amount: true, stripe_invoice_id: true },
});
if (invoice) {
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: {
status: InvoiceStatus.FAILED,
failure_code: "invoice_payment_failed",
failure_message: msg.slice(0, 2000),
},
});
this.logger.log(`Marked local invoice ${invoice.id} FAILED (payment_intent match)`);
// Notification email (deduped)
const total =
typeof invoice.total_amount === "number" ? invoice.total_amount : Number(invoice.total_amount);
const stripeInvoiceIdForDedupe = String(invoice.stripe_invoice_id ?? stripeSid);
await this.billing.sendCompanyTemplatedEmail({
companyId: invoice.company_id,
templateKey: "billing.payment.failed",
dedupeKey: `billing.payment.failed:${stripeInvoiceIdForDedupe}`,
triggeredBy: "stripe_webhook_invoice_payment_failed",
variables: {
"invoice.number": invoice.invoice_number,
"invoice.amount": this.billing.formatUsd(Number.isFinite(total) ? total : 0),
"invoice.failure_message": msg,
},
metadata: {
stripe_invoice_id: stripeInvoiceIdForDedupe,
invoice_id: invoice.id,
},
});
emailed = true;
}
}
const sid = stripeSid && stripeSid !== "undefined" ? stripeSid : null;
if (!emailed && sid) {
const invoice = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: sid },
select: { id: true, company_id: true, invoice_number: true, total_amount: true, stripe_invoice_id: true },
});
if (invoice) {
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: {
status: InvoiceStatus.FAILED,
failure_code: "invoice_payment_failed",
failure_message: msg.slice(0, 2000),
},
});
this.logger.log(`Marked local invoice ${invoice.id} FAILED (stripe_invoice_id match)`);
const total =
typeof invoice.total_amount === "number" ? invoice.total_amount : Number(invoice.total_amount);
await this.billing.sendCompanyTemplatedEmail({
companyId: invoice.company_id,
templateKey: "billing.payment.failed",
dedupeKey: `billing.payment.failed:${sid}`,
triggeredBy: "stripe_webhook_invoice_payment_failed",
variables: {
"invoice.number": invoice.invoice_number,
"invoice.amount": this.billing.formatUsd(Number.isFinite(total) ? total : 0),
"invoice.failure_message": msg,
},
metadata: {
stripe_invoice_id: sid,
invoice_id: invoice.id,
},
});
emailed = true;
}
}
if (!emailed) {
await this.sendPaymentFailedEmailFromStripeSubscriptionInvoice(stripeInvoice, msg);
}
}
async handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
const inv = invoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
};
const stripeInvoiceId = String(invoice.id);
this.logger.log(
`[stripe-renewal-debug] handleInvoicePaid start stripe_invoice_id=${stripeInvoiceId} amount_paid=${invoice.amount_paid ?? "null"} billing_reason=${inv.billing_reason ?? "null"}`,
);
const paidCents = typeof invoice.amount_paid === "number" && invoice.amount_paid >= 0 ? invoice.amount_paid : 0;
if (paidCents <= 0) {
const billingReason = inv.billing_reason ?? null;
if (!this.billing.isStripeFirstSubscriptionInvoiceBillingReason(billingReason)) {
// Renewal invoices can legitimately be $0 (discounts/credits) but we still must
// update subscription rotation + next billing date.
this.logger.log(
`handleInvoicePaid: allow zero-amount non-initial invoice ${stripeInvoiceId} (amount_paid=${invoice.amount_paid ?? "null"}, billing_reason=${billingReason ?? "null"})`,
);
}
this.logger.log(
`handleInvoicePaid: allow zero-amount initial invoice ${stripeInvoiceId} (billing_reason=${billingReason ?? "null"})`,
);
}
const subRef = inv.subscription;
let subscriptionId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
if (!subscriptionId) {
// Some webhook payloads (and occasionally Stripe invoice objects) omit `invoice.subscription`.
// Fallback to line items where Stripe often includes `line.subscription`.
const invAny = invoice as unknown as Record<string, unknown>;
const linesObj = invAny["lines"];
const linesData =
linesObj && typeof linesObj === "object" ? (linesObj as Record<string, unknown>)["data"] : null;
const lines = Array.isArray(linesData) ? (linesData as unknown[]) : [];
const lineSub = lines
.map((l) => {
if (!l || typeof l !== "object") return null;
const rec = l as Record<string, unknown>;
const sub = rec["subscription"];
if (typeof sub === "string") return sub;
if (sub && typeof sub === "object" && typeof (sub as Record<string, unknown>)["id"] === "string") {
return String((sub as Record<string, unknown>)["id"]);
}
return null;
})
.find((v): v is string => typeof v === "string");
subscriptionId = lineSub ?? null;
if (subscriptionId) {
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid derived subscription from invoice.lines stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId}`,
);
}
}
if (!subscriptionId && this.stripeService.isConfigured()) {
const customerId = this.billing.extractStripeCustomerId(inv.customer);
if (customerId) {
try {
const subs = await this.stripeService.listSubscriptionsForCustomer(customerId, [
"active",
"trialing",
"past_due",
"unpaid",
]);
const matchByLatestInvoice = subs.find((s) => {
const li = (s as any).latest_invoice;
const liId = typeof li === "string" ? li : li && typeof li === "object" && "id" in li ? String(li.id) : null;
return liId === stripeInvoiceId;
});
if (matchByLatestInvoice) {
subscriptionId = matchByLatestInvoice.id;
} else if (subs.length === 1) {
subscriptionId = subs[0].id;
}
if (subscriptionId) {
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid derived subscription from customer subscriptions stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId} customer=${customerId} subs=${subs.length}`,
);
}
} catch (e) {
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid failed deriving subscription via customer subscriptions stripe_invoice_id=${stripeInvoiceId} customer=${customerId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
}
if (!subscriptionId) {
if (!this.stripeService.isConfigured()) {
return;
}
const customerId = this.billing.extractStripeCustomerId(inv.customer);
const companyIdNoSub = customerId ? await this.billing.resolveCompanyIdFromStripeCustomer(customerId) : null;
if (companyIdNoSub === null) {
this.logger.debug(
`handleInvoicePaid: invoice ${stripeInvoiceId} has no subscription and no resolvable company (customer=${customerId ?? "none"})`,
);
return;
}
const invNo = invoice.number ? String(invoice.number) : stripeInvoiceId;
const amountUsd = paidCents / 100;
if (this.shouldSendPaymentSucceededEmail(amountUsd)) {
await this.billing.sendCompanyTemplatedEmail({
companyId: companyIdNoSub,
templateKey: "billing.payment.succeeded",
dedupeKey: `billing.payment.succeeded:${stripeInvoiceId}`,
triggeredBy: "stripe_webhook_invoice_paid",
variables: {
"invoice.number": invNo,
"invoice.amount": this.billing.formatUsd(amountUsd),
"subscription.next_billing_date": "",
},
metadata: { stripe_invoice_id: stripeInvoiceId },
});
} else {
this.logger.log(
`handleInvoicePaid: skip billing.payment.succeeded email for zero-amount invoice stripe_invoice_id=${stripeInvoiceId} company=${companyIdNoSub}`,
);
}
return;
}
if (!this.stripeService.isConfigured()) {
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid skip: Stripe not configured stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId}`,
);
return;
}
let stripeSub: Stripe.Subscription;
try {
stripeSub = await this.stripeService.retrieveSubscription(subscriptionId);
} catch {
this.logger.warn(`handleInvoicePaid: could not retrieve subscription ${subscriptionId}`);
return;
}
let companyId: number | null = null;
const companyIdStr = stripeSub.metadata?.["company_id"];
if (companyIdStr) {
const parsed = parseInt(companyIdStr, 10);
if (!Number.isNaN(parsed)) companyId = parsed;
}
if (companyId === null) {
const customerId =
this.billing.extractStripeCustomerId(stripeSub.customer) ?? this.billing.extractStripeCustomerId(inv.customer);
if (customerId) {
companyId = await this.billing.resolveCompanyIdFromStripeCustomer(customerId);
}
}
if (companyId === null) {
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaid cannot resolve company for stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId}`,
);
this.logger.warn(
`handleInvoicePaid: subscription ${subscriptionId} — could not resolve company (metadata + stripe_customer_id lookup)`,
);
return;
}
let localSub = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
stripe_subscription_id: subscriptionId,
is_current: true,
},
});
if (!localSub) {
localSub = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId, stripe_subscription_id: subscriptionId },
orderBy: { id: "desc" },
});
}
if (!localSub) {
const metaLocalIdStr = stripeSub.metadata?.["local_subscription_id"];
if (metaLocalIdStr) {
const metaLocalId = parseInt(metaLocalIdStr, 10);
if (!Number.isNaN(metaLocalId)) {
const byMeta = await this.prisma.client.subscription.findFirst({
where: { id: metaLocalId, company_id: companyId },
});
if (byMeta) {
await this.prisma.client.subscription.update({
where: { id: byMeta.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
localSub = await this.prisma.client.subscription.findUnique({ where: { id: byMeta.id } });
}
}
}
}
if (!localSub) {
const activeUnlinked = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: SubscriptionStatus.ACTIVE,
OR: [{ stripe_subscription_id: null }, { stripe_subscription_id: subscriptionId }],
},
orderBy: { id: "desc" },
});
if (activeUnlinked) {
await this.prisma.client.subscription.update({
where: { id: activeUnlinked.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
localSub = await this.prisma.client.subscription.findUnique({ where: { id: activeUnlinked.id } });
}
}
if (!localSub) {
const expiredUnlinked = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
status: SubscriptionStatus.EXPIRED,
OR: [{ stripe_subscription_id: null }, { stripe_subscription_id: subscriptionId }],
},
orderBy: { id: "desc" },
});
if (expiredUnlinked) {
await this.prisma.client.subscription.update({
where: { id: expiredUnlinked.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
localSub = await this.prisma.client.subscription.findUnique({ where: { id: expiredUnlinked.id } });
}
}
if (!localSub) {
// Rescue fallback: local row may exist but still not match the Stripe sub id.
const mostRecentExpired = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId, status: SubscriptionStatus.EXPIRED },
orderBy: { id: "desc" },
});
if (mostRecentExpired) {
await this.prisma.client.subscription.update({
where: { id: mostRecentExpired.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
localSub = await this.prisma.client.subscription.findUnique({ where: { id: mostRecentExpired.id } });
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaid rescue relinked local expired subscription id=${mostRecentExpired.id} to stripe_subscription_id=${subscriptionId} for company=${companyId}`,
);
}
}
if (!localSub) {
// Hard fallback: the DB row might exist but not be linked/matching `stripe_subscription_id`.
// We still want to create the local invoice + next renewal subscription record.
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaid cannot resolve local subscription match; attempting fallback (stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId} company_id=${companyId})`,
);
const fallbackCurrent = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId, is_current: true },
orderBy: { id: "desc" },
include: { package: true },
});
const fallbackAny = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId },
orderBy: { id: "desc" },
include: { package: true },
});
const picked = fallbackCurrent?.package ? fallbackCurrent : fallbackAny?.package ? fallbackAny : null;
if (!picked) {
this.logger.warn(
`handleInvoicePaid: fallback also failed to load any local subscription/package for company ${companyId}; sending billing.payment.succeeded only`,
);
const centsPaid =
typeof invoice.amount_paid === "number" && invoice.amount_paid >= 0 ? invoice.amount_paid : 0;
const invNo = invoice.number ? String(invoice.number) : stripeInvoiceId;
const periodEndUnix = (stripeSub as StripeSubscriptionApi).current_period_end;
const nextBill = typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : null;
const amountUsd = centsPaid / 100;
if (this.shouldSendPaymentSucceededEmail(amountUsd)) {
await this.billing.sendCompanyTemplatedEmail({
companyId,
templateKey: "billing.payment.succeeded",
dedupeKey: `billing.payment.succeeded:${stripeInvoiceId}`,
triggeredBy: "stripe_webhook_invoice_paid",
variables: {
"invoice.number": invNo,
"invoice.amount": this.billing.formatUsd(amountUsd),
"subscription.next_billing_date": nextBill ? nextBill.toISOString().split("T")[0] : "",
},
metadata: {
stripe_invoice_id: stripeInvoiceId,
stripe_subscription_id: subscriptionId,
},
});
} else {
this.logger.log(
`handleInvoicePaid: skip billing.payment.succeeded email for zero-amount fallback invoice stripe_invoice_id=${stripeInvoiceId} company=${companyId}`,
);
}
return;
}
// Relink chosen local row so subsequent calculations + invoice rotation have everything needed.
await this.prisma.client.subscription.update({
where: { id: picked.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
localSub = picked;
}
// Ensure package is loaded for invoice creation calculations
let localSubWithPackage = await this.prisma.client.subscription.findUnique({
where: { id: localSub.id },
include: { package: true },
});
if (!localSubWithPackage?.package) {
// Sometimes the Prisma relation is null while `package_id` is still populated.
// Try loading by ID so renewals can still rotate subscriptions.
const packageId = (localSub as any)?.package_id as number | null | undefined;
if (packageId) {
const pkg = await this.prisma.client.package.findUnique({ where: { id: packageId } });
if (pkg) {
localSubWithPackage = { ...localSubWithPackage, package: pkg } as any;
}
}
if (!localSubWithPackage?.package) {
// Hard fallback: use the company's current subscription row which should
// have a valid package relation.
const current = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId, is_current: true },
orderBy: { id: "desc" },
include: { package: true },
});
if (current?.package) {
localSubWithPackage = current as any;
}
// If relation is still null but package_id exists, fetch package by ID.
if (!localSubWithPackage?.package) {
const currentPackageId = (current as any)?.package_id as number | null | undefined;
if (currentPackageId) {
const pkg = await this.prisma.client.package.findUnique({ where: { id: currentPackageId } });
if (pkg) localSubWithPackage = { ...current, package: pkg } as any;
}
}
}
if (!localSubWithPackage?.package) {
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaid missing local subscription package for stripe_invoice_id=${stripeInvoiceId} company_id=${companyId} local_subscription_id=${localSub.id}`,
);
this.logger.warn(`handleInvoicePaid: missing local subscription package for company ${companyId}`);
return;
}
}
// Prefer the "old" subscription row that is marked current=true.
// This ensures renewal rotation uses the expected old record even when stripe_subscription_id linking is off.
const companyCurrent = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId, is_current: true },
orderBy: { id: "desc" },
include: { package: true },
});
if (companyCurrent?.package) {
localSub = companyCurrent;
localSubWithPackage = companyCurrent as any;
}
// Create local Invoice row for Stripe subscription invoice payments.
// This is required because the renewal sync job bootstraps Stripe directly without creating local invoices.
// If the row already exists but is incomplete (e.g. after earlier code versions), we update it.
const existingInvoice = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: invoice.id },
select: {
id: true,
status: true,
invoice_number: true,
total_amount: true,
pre_vat_total_amount: true,
processing_fee_percentage: true,
vat_fee_percentage: true,
stripe_payment_intent_id: true,
stripe_charge_id: true,
stripe_checkout_session_id: true,
period_start: true,
period_end: true,
invoice_type: true,
},
});
const couldCreateLocalInvoiceRow = !existingInvoice;
const wasAlreadyPaid =
existingInvoice?.status === InvoiceStatus.PAID ||
// backward compat if prisma returns raw string
(typeof (existingInvoice as any)?.status === "string" && (existingInvoice as any).status === "PAID");
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid invoiceRow stripe_invoice_id=${stripeInvoiceId} local_invoice_id=${
existingInvoice?.id ?? "(none)"
} local_status=${existingInvoice?.status ?? "(none)"} couldCreateLocalInvoiceRow=${couldCreateLocalInvoiceRow} wasAlreadyPaid=${wasAlreadyPaid}`,
);
const decimalToNumber = (v: unknown): number | null => {
if (v === null || v === undefined) return null;
if (typeof v === "number") return v;
if (typeof v === "string") return Number(v);
if (typeof v === "bigint") return Number(v);
if (typeof (v as any)?.toNumber === "function") return (v as any).toNumber();
return Number(v);
};
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { country: true },
});
let stripeInvoiceForDb: Stripe.Invoice = invoice;
try {
stripeInvoiceForDb = await this.stripeService.getInvoiceExpanded(invoice.id);
} catch (e) {
this.logger.warn(
`handleInvoicePaid: getInvoiceExpanded failed for ${invoice.id}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const signupPaidSubscriptionCreate =
stripeInvoiceForDb.billing_reason === "subscription_create" &&
stripeSub.metadata?.["source"] === "signup_account_sync";
// Trial-like packages can have zero catalog price. For the first paid cycle
// after trial, use STARTUP price as the billing baseline for local fee/tax
// snapshot so invoice VAT/processing fields are not zeroed incorrectly.
let pricePerLicense = Number(localSubWithPackage!.package.price_per_licence ?? 0);
const isTrialLikePackageForBilling =
localSubWithPackage!.package.is_trial_package ||
localSubWithPackage!.package.package_type === PackageType.FREE_TRIAL ||
localSubWithPackage!.package.package_type === PackageType.PRIVATE_VIP_TRIAL;
if (isTrialLikePackageForBilling && (!Number.isFinite(pricePerLicense) || pricePerLicense <= 0)) {
const startupPackage = await this.prisma.client.package.findFirst({
where: { package_type: PackageType.STARTUP, is_active: true },
select: { price_per_licence: true },
});
const startupPricePerLicense = Number(startupPackage?.price_per_licence ?? 0);
if (Number.isFinite(startupPricePerLicense) && startupPricePerLicense > 0) {
pricePerLicense = startupPricePerLicense;
}
}
const cycleMultiplier = getBillingCycleMultiplier(localSubWithPackage!.billing_cycle || "QUARTERLY");
const baseAmountCentsPerCycle = Math.round(
pricePerLicense * localSubWithPackage!.license_count * cycleMultiplier * 100,
);
const charge = this.billing.calculateRenewalChargeCents({
baseAmountCents: baseAmountCentsPerCycle,
country: company?.country,
});
const feePcts = this.billing.renewalFeePercentagesForDb(company?.country);
const stripePaidAmountCents =
typeof stripeInvoiceForDb.amount_paid === "number" && stripeInvoiceForDb.amount_paid >= 0
? stripeInvoiceForDb.amount_paid
: null;
const finalTotalCents = stripePaidAmountCents !== null ? stripePaidAmountCents : charge.totalCents;
const skipLocalInvoiceForZeroFirstSubscription =
this.billing.isStripeFirstSubscriptionInvoiceBillingReason(stripeInvoiceForDb.billing_reason) &&
finalTotalCents === 0;
// Paid signup already has a local row + Stripe manual invoice (-0001); the subscription_create invoice (-0002)
// is only for Billing state — duplicating it inflates admin totals (and confused amounts before Stripe price fix).
const skipDuplicateSignupSubscriptionMirrorInvoice =
this.billing.isStripeFirstSubscriptionInvoiceBillingReason(stripeInvoiceForDb.billing_reason) &&
stripeSub.metadata?.["source"] === "signup_account_sync";
const skipLocalInvoiceRow =
skipLocalInvoiceForZeroFirstSubscription || skipDuplicateSignupSubscriptionMirrorInvoice;
const createdInvoiceRow = couldCreateLocalInvoiceRow && !skipLocalInvoiceRow;
const invoiceNumber = stripeInvoiceForDb.number ? String(stripeInvoiceForDb.number) : stripeInvoiceForDb.id;
const { paymentIntentId, chargeId, checkoutSessionId } =
this.billing.extractPaymentIntentAndChargeIds(stripeInvoiceForDb);
const stripePaidAtUnix =
stripeInvoiceForDb.status_transitions && typeof stripeInvoiceForDb.status_transitions.paid_at === "number"
? stripeInvoiceForDb.status_transitions.paid_at
: null;
const paidDate = stripePaidAtUnix ? new Date(stripePaidAtUnix * 1000) : new Date();
const derived = this.billing.derivePeriodsFromStripeInvoice(stripeInvoiceForDb);
// Prefer Stripe/invoice period boundaries over local DB values.
// Local values can reflect an earlier cycle and would skew renewal rotation dates.
let periodStart = derived.periodStart ?? localSubWithPackage!.start_date;
let periodEndLocal = derived.periodEnd ?? localSubWithPackage!.end_date;
const subPeriod = stripeSub as StripeSubscriptionApi;
const cps = subPeriod.current_period_start;
const cpe = subPeriod.current_period_end;
if (typeof cps === "number") {
periodStart = new Date(cps * 1000);
}
if (typeof cpe === "number") {
periodEndLocal = new Date(cpe * 1000);
}
// Stripe `interval=year` uses calendar anchors; portal access uses fixed 30-day months (annual = 360d access).
if (signupPaidSubscriptionCreate && periodStart && !isTrialLikePackageForBilling) {
periodEndLocal = addBillingDays(
periodStart,
getBillingCyclePeriodDays(localSubWithPackage!.billing_cycle ?? "QUARTERLY"),
);
}
// Portal billing uses fixed 30-day "months" (quarterly = 90×86400s days). Do not use calendar setDate(+90),
// which follows month lengths and matches Stripe's calendar quarter, not product rules.
if (stripeInvoiceForDb.billing_reason === "subscription_cycle" && periodStart) {
periodEndLocal = addBillingDays(
periodStart,
getBillingCyclePeriodDays(localSubWithPackage!.billing_cycle ?? "QUARTERLY"),
);
}
const effectiveSubscriptionType = await this.billing.normalizeLocalSubscriptionTypeAfterFirstStripeInvoice({
subscriptionRowId: localSubWithPackage!.id,
currentType: localSubWithPackage!.subscription_type,
previousSubscriptionId: localSubWithPackage!.previous_subscription_id,
billingReason: stripeInvoiceForDb.billing_reason,
});
const invoiceTypeResolved = this.billing.resolveInvoiceTypeForStripeSubscriptionInvoice({
billingReason: stripeInvoiceForDb.billing_reason,
localSubscriptionType: effectiveSubscriptionType,
});
let localInvoiceId: number | null = null;
let localInvoiceNumber: string | null = null;
let localInvoiceTotalAmount: number | null = null;
if (couldCreateLocalInvoiceRow && !skipLocalInvoiceRow) {
await this.prisma.client.invoice.create({
data: {
company_id: companyId,
subscription_id: localSub.id,
invoice_number: invoiceNumber,
unit_price_per_license: Number(localSubWithPackage!.package.price_per_licence ?? 0),
license_quantity: localSubWithPackage!.license_count,
package_type: localSubWithPackage!.package.package_type,
// Snapshot the live subscription cycle onto the invoice row — this
// is the value the shared PDF builder prints, so later plan or
// cycle changes do not silently rewrite history on this invoice.
billing_cycle: localSubWithPackage!.billing_cycle ?? undefined,
status: InvoiceStatus.PAID,
invoice_type: invoiceTypeResolved,
stripe_invoice_id: invoice.id,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
paid_date: paidDate,
period_start: periodStart ?? undefined,
period_end: periodEndLocal ?? undefined,
...invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: baseAmountCentsPerCycle,
processingFeeCents: charge.processingFeeCents,
vatCents: charge.vatCents,
totalCents: finalTotalCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
),
} as any,
});
if (stripePaidAmountCents !== null && stripePaidAmountCents !== charge.totalCents) {
this.logger.warn(
`handleInvoicePaid: Stripe paid amount mismatch for invoice ${invoice.id}. local_calc=${charge.totalCents}c stripe_paid=${stripePaidAmountCents}c`,
);
}
this.logger.log(
`handleInvoicePaid: created local Invoice row for stripe_invoice_id=${invoice.id} (company=${companyId}, subscription=${localSub.id}).`,
);
const createdInvoice = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: invoice.id },
select: { id: true, invoice_number: true, total_amount: true },
});
localInvoiceId = createdInvoice?.id ?? null;
localInvoiceNumber = createdInvoice?.invoice_number ?? null;
localInvoiceTotalAmount =
createdInvoice?.total_amount !== null && createdInvoice?.total_amount !== undefined
? Number(createdInvoice.total_amount)
: null;
} else if (existingInvoice) {
const existingTotal = decimalToNumber(existingInvoice["total_amount"]);
const updateData: Record<string, unknown> = {};
// If the invoice row already exists (often created earlier as PENDING from `invoice.created`),
// ensure we mark it as PAID on the `invoice.paid` webhook.
updateData["status"] = InvoiceStatus.PAID;
updateData["paid_date"] = paidDate;
if (!existingInvoice["invoice_number"] && invoiceNumber) {
updateData["invoice_number"] = invoiceNumber;
}
const renewalBillingFields = invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: baseAmountCentsPerCycle,
processingFeeCents: charge.processingFeeCents,
vatCents: charge.vatCents,
totalCents: finalTotalCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
);
const breakdownTotal = renewalBillingFields.total_amount;
const existingPreVat = decimalToNumber(existingInvoice.pre_vat_total_amount);
const needsBillingFieldSync =
existingTotal === null ||
Math.abs(existingTotal - breakdownTotal) > 0.01 ||
existingPreVat === null ||
Math.abs(existingPreVat - renewalBillingFields.pre_vat_total_amount) > 0.01;
// Trial signup creates a $0 INITIAL row, then links Stripe's $0 subscription_create invoice.
// Do not replace that row with STARTUP catalog pricing (used only for post-trial billing estimates).
const preserveTrialSignupZeroAmounts =
skipLocalInvoiceRow ||
(finalTotalCents === 0 && (existingTotal === null || existingTotal === 0));
if (needsBillingFieldSync && !preserveTrialSignupZeroAmounts) {
Object.assign(updateData, renewalBillingFields);
} else if (needsBillingFieldSync && preserveTrialSignupZeroAmounts) {
this.logger.log(
`handleInvoicePaid: kept $0 billing amounts on local invoice ${existingInvoice.id} (stripe_invoice_id=${invoice.id}; trial/signup mirror).`,
);
}
if (stripePaidAmountCents !== null && Math.abs(stripePaidAmountCents / 100 - breakdownTotal) > 0.01) {
this.logger.warn(
`handleInvoicePaid: Stripe paid amount differs from stored breakdown total for invoice ${invoice.id}. breakdown_total=${breakdownTotal} stripe_paid=${(stripePaidAmountCents / 100).toFixed(2)}`,
);
}
if (paymentIntentId && existingInvoice["stripe_payment_intent_id"] !== paymentIntentId) {
updateData["stripe_payment_intent_id"] = paymentIntentId;
}
if (chargeId && existingInvoice["stripe_charge_id"] !== chargeId) {
updateData["stripe_charge_id"] = chargeId;
}
if (checkoutSessionId && existingInvoice["stripe_checkout_session_id"] !== checkoutSessionId) {
updateData["stripe_checkout_session_id"] = checkoutSessionId;
}
if (periodStart && !existingInvoice["period_start"]) {
updateData["period_start"] = periodStart;
}
if (periodEndLocal && !existingInvoice["period_end"]) {
updateData["period_end"] = periodEndLocal;
}
if (existingInvoice["invoice_type"] !== invoiceTypeResolved) {
updateData["invoice_type"] = invoiceTypeResolved;
}
if (Object.keys(updateData).length > 0) {
await this.prisma.client.invoice.update({
where: { id: existingInvoice.id },
data: updateData as any,
});
this.logger.log(
`handleInvoicePaid: updated local Invoice row for stripe_invoice_id=${invoice.id} (company=${companyId}, subscription=${localSub.id}).`,
);
}
localInvoiceId = existingInvoice.id;
localInvoiceNumber = existingInvoice.invoice_number ?? null;
localInvoiceTotalAmount = needsBillingFieldSync
? breakdownTotal
: (existingTotal ?? breakdownTotal);
} else if (skipLocalInvoiceRow) {
this.logger.log(
`handleInvoicePaid: no local invoice row for stripe_invoice_id=${invoice.id} (${
skipDuplicateSignupSubscriptionMirrorInvoice
? "skipped signup subscription mirror invoice"
: "skipped zero first-subscription invoice"
}); subscription dates still sync below.`,
);
}
const periodEndUnix = (stripeSub as Stripe.Subscription & { current_period_end?: number }).current_period_end;
const stripeInvoiceBr = stripeInvoiceForDb.billing_reason ?? "";
const isSubscriptionCycleInvoice = stripeInvoiceBr === "subscription_cycle";
const periodEnd =
isSubscriptionCycleInvoice && periodEndLocal
? periodEndLocal
: signupPaidSubscriptionCreate && periodEndLocal
? periodEndLocal
: typeof periodEndUnix === "number"
? new Date(periodEndUnix * 1000)
: periodEndLocal ?? null;
// ── TRIAL-AWARE NEXT BILLING DATE ────────────────────────────────────────
// For trialing Stripe subscriptions, current_period_end represents the END
// of the FIRST POST-TRIAL CYCLE (= trial_end + interval), NOT the trial
// cutoff. Using it as next_billing_date is wrong because the customer's
// next CHARGE happens at trial_end, not at trial_end + interval.
//
// Bug this fixes: FREE_TRIAL/VIP_TRIAL accounts had next_billing_date
// overwritten to "signup + 90 days" (Stripe's QUARTERLY current_period_end)
// when the $0 trial invoice fired the invoice.paid webhook, even though
// the initial create logic correctly set it to "signup + trial_duration_days".
//
// Fix: when the sub is trialing AND trial_end is in the future, prefer
// trial_end as the next_billing_date — that's the actual next charge date.
const isStripeTrialing = stripeSub.status === "trialing";
const stripeTrialEndUnix = (stripeSub as Stripe.Subscription & { trial_end?: number | null }).trial_end;
const trialEndDate =
typeof stripeTrialEndUnix === "number" && stripeTrialEndUnix > 0
? new Date(stripeTrialEndUnix * 1000)
: null;
const trialEndIsInFuture = !!(trialEndDate && trialEndDate.getTime() > Date.now());
// Expired recovery writes the intended next renewal here (already = one cycle from charge day). It is not
// a period-start anchor — do not add another cycle (previously done only for batch_expired_recovery).
const localNextBillingIso = stripeSub.metadata?.["local_next_billing_anchor"];
let computedNextBillingDate: Date | null = null;
if (isStripeTrialing && trialEndDate && trialEndIsInFuture) {
// Highest priority: trial_end is the actual next charge date for trialing subs.
computedNextBillingDate = trialEndDate;
this.logger.log(
`[stripe-renewal-debug] handleInvoicePaid: trialing sub ${stripeSub.id} — using trial_end=${trialEndDate.toISOString()} as next_billing_date (NOT current_period_end=${periodEnd?.toISOString() ?? "null"})`,
);
} else if (localNextBillingIso) {
const parsed = new Date(localNextBillingIso);
if (!Number.isNaN(parsed.getTime())) {
computedNextBillingDate = parsed;
}
}
if (isSubscriptionCycleInvoice) {
// Product rules use fixed billing-cycle days for renewals.
// Never let metadata/Stripe calendar anchors shrink this to 30-day-style periods.
computedNextBillingDate =
periodEndLocal ??
(periodStart
? addBillingDays(periodStart, getBillingCyclePeriodDays(localSubWithPackage?.billing_cycle ?? "QUARTERLY"))
: null);
}
if (!computedNextBillingDate) {
computedNextBillingDate = periodEnd ?? localSub.next_billing_date ?? null;
}
if (!localSub) {
return;
}
const resolvedLocalSub = localSub;
const stripeCancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(stripeSub);
// Stripe is the source of truth for the next billing date.
// Prefer Stripe subscription current_period_end; fall back to invoice-derived periodEnd/local DB.
let nextBillingForRenewal = computedNextBillingDate ?? periodEnd ?? resolvedLocalSub.next_billing_date ?? null;
// Ensure we always have a usable date for renewal rotation.
if (!nextBillingForRenewal || Number.isNaN(nextBillingForRenewal.getTime())) {
const anchor = periodEndLocal ?? resolvedLocalSub.next_billing_date ?? periodEnd ?? null;
if (anchor && anchor instanceof Date && !Number.isNaN(anchor.getTime())) {
nextBillingForRenewal = anchor;
} else {
// Final fallback: add one billing cycle from "now" based on local subscription billing_cycle.
nextBillingForRenewal = addBillingDays(
new Date(),
getBillingCyclePeriodDays(resolvedLocalSub.billing_cycle ?? undefined),
);
}
}
// Keep these aligned since downstream uses computedNextBillingDate for emails and reactivation.
computedNextBillingDate = nextBillingForRenewal;
this.logger.log(
`[stripe-renewal-debug] handleInvoicePaid timing stripe_invoice_id=${stripeInvoiceId} periodEnd=${
periodEnd ? periodEnd.toISOString().slice(0, 10) : "null"
} computedNextBillingDate=${computedNextBillingDate ? computedNextBillingDate.toISOString().slice(0, 10) : "null"} nextBillingForRenewal=${
nextBillingForRenewal ? nextBillingForRenewal.toISOString().slice(0, 10) : "null"
} localSub=${resolvedLocalSub.id} localStatus=${resolvedLocalSub.status} is_current=${resolvedLocalSub.is_current}`,
);
// Never "reactivate" CANCELLED rows (e.g. VIP superseded by renewal): that was incorrectly rewriting
// next_billing_date on the archived subscription. EXPIRED recovery + stray non-current ACTIVE only.
if (
resolvedLocalSub.status === SubscriptionStatus.EXPIRED ||
(resolvedLocalSub.status === SubscriptionStatus.ACTIVE && !resolvedLocalSub.is_current)
) {
await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: companyId, id: { not: resolvedLocalSub.id } },
data: { is_current: false },
});
await tx.subscription.updateMany({
where: {
company_id: companyId,
id: { not: resolvedLocalSub.id },
status: SubscriptionStatus.ACTIVE,
},
data: { status: SubscriptionStatus.CANCELLED },
});
await tx.subscription.update({
where: { id: resolvedLocalSub.id },
data: {
status: SubscriptionStatus.ACTIVE,
is_current: true,
next_billing_date: computedNextBillingDate ?? periodEnd ?? resolvedLocalSub.next_billing_date,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
},
});
await tx.company.update({
where: { id: companyId },
data: { is_subscription_expiry: false },
});
});
this.logger.log(
`handleInvoicePaid: reactivated local subscription ${resolvedLocalSub.id} for company ${companyId} (Stripe ${subscriptionId})`,
);
// Do not return — the success email below must run for expired recovery and other reactivation flows.
}
// Used for the success email + webhook metadata even if we skip rotation.
// Also prevents nullability issues when concurrent webhook deliveries reuse an existing renewal row.
let subscriptionIdForEmail = localSub.id;
if (nextBillingForRenewal) {
const oldSubId = localSub.id;
const oldSubNextBillingDate = localSub.next_billing_date;
if (!nextBillingForRenewal || Number.isNaN(nextBillingForRenewal.getTime())) {
this.logger.warn(
`handleInvoicePaid: renewal subscription rotation skipped for ${companyId} (invalid next_billing_date)`,
);
// Fallback: keep existing behavior so next renewal date isn't left stale.
await this.prisma.client.subscription.update({
where: { id: localSub.id },
data: {
next_billing_date: computedNextBillingDate ?? periodEnd ?? localSub.next_billing_date,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
},
});
} else if (stripeInvoiceForDb.billing_reason !== "subscription_cycle") {
await this.prisma.client.subscription.update({
where: { id: resolvedLocalSub.id },
data: {
next_billing_date: nextBillingForRenewal,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
...(signupPaidSubscriptionCreate && periodStart && periodEndLocal
? { start_date: periodStart, end_date: periodEndLocal }
: {}),
},
});
} else {
const currentNext = resolvedLocalSub.next_billing_date;
const alreadyAligned =
currentNext &&
nextBillingForRenewal &&
!Number.isNaN(currentNext.getTime()) &&
!Number.isNaN(nextBillingForRenewal.getTime()) &&
currentNext.toISOString().slice(0, 10) === nextBillingForRenewal.toISOString().slice(0, 10);
const shouldSkipRotation =
!createdInvoiceRow &&
wasAlreadyPaid &&
alreadyAligned &&
resolvedLocalSub.status === SubscriptionStatus.ACTIVE &&
resolvedLocalSub.is_current;
if (shouldSkipRotation) {
await this.prisma.client.subscription.update({
where: { id: resolvedLocalSub.id },
data: {
next_billing_date: nextBillingForRenewal,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
},
});
this.logger.warn(
`[renewal-debug] subscription-rotation skipped (already aligned) stripe_invoice_id=${stripeInvoiceId} company_id=${companyId} local_subscription_id=${resolvedLocalSub.id}`,
);
this.logger.warn(
`[stripe-link-debug] handleInvoicePaid skip-rotation reason=already-paid-and-aligned nextBilling=${nextBillingForRenewal.toISOString().slice(0, 10)}`,
);
} else {
if (!localSubWithPackage?.package) {
this.logger.warn(
`[stripe-renewal-debug] rotation aborted: missing localSubWithPackage.package (stripe_invoice_id=${stripeInvoiceId} company_id=${companyId} local_subscription_id=${resolvedLocalSub.id})`,
);
await this.prisma.client.subscription.update({
where: { id: localSub.id },
data: {
next_billing_date: nextBillingForRenewal,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
},
});
return;
}
const rotationSource = localSubWithPackage;
const pkgType = rotationSource.package?.package_type;
const shouldGraduateTrialToStartup =
pkgType === PackageType.FREE_TRIAL || pkgType === PackageType.PRIVATE_VIP_TRIAL;
const startupPackage = shouldGraduateTrialToStartup
? await this.prisma.client.package.findFirst({
where: { package_type: PackageType.STARTUP, is_active: true },
select: { id: true, minimum_license_required: true, price_per_licence: true },
})
: null;
if (shouldGraduateTrialToStartup && !startupPackage) {
this.logger.warn(
`handleInvoicePaid: trial graduation (FREE_TRIAL/VIP) requires an active STARTUP package, but none found (company=${companyId}). Keeping package_id=${rotationSource.package_id}.`,
);
}
const renewalPackageId =
shouldGraduateTrialToStartup && startupPackage ? startupPackage.id : rotationSource.package_id;
const renewalLicenseCount =
shouldGraduateTrialToStartup &&
startupPackage &&
typeof startupPackage.minimum_license_required === "number"
? Math.max(rotationSource.license_count, startupPackage.minimum_license_required)
: rotationSource.license_count;
const shouldRewriteInvoiceToStartup = shouldGraduateTrialToStartup && !!startupPackage;
const startupInvoiceSnapshot =
shouldRewriteInvoiceToStartup && startupPackage
? (() => {
const pricePerLicense = Number(startupPackage.price_per_licence ?? 0);
const cycleMultiplier = getBillingCycleMultiplier(rotationSource.billing_cycle || "QUARTERLY");
const baseAmountCentsPerCycle = Math.round(pricePerLicense * renewalLicenseCount * cycleMultiplier * 100);
const charge = this.billing.calculateRenewalChargeCents({
baseAmountCents: baseAmountCentsPerCycle,
country: company?.country,
});
const feePcts = this.billing.renewalFeePercentagesForDb(company?.country);
return {
package_type: PackageType.STARTUP,
unit_price_per_license: pricePerLicense,
license_quantity: renewalLicenseCount,
...invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: baseAmountCentsPerCycle,
processingFeeCents: charge.processingFeeCents,
vatCents: charge.vatCents,
totalCents: charge.totalCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
),
};
})()
: null;
// Concurrency/idempotency guard:
// Stripe may deliver `invoice.paid` and its aliases nearly simultaneously, causing two rotations.
// If a renewal row for this exact oldSubId + nextBilling already exists, reuse it and relink invoice.
const existingRenewal = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
previous_subscription_id: oldSubId,
stripe_subscription_id: subscriptionId,
subscription_type: SubscriptionType.RENEWAL,
is_current: true,
next_billing_date: nextBillingForRenewal,
},
orderBy: { id: "desc" },
select: { id: true },
});
if (existingRenewal) {
if (localInvoiceId) {
await this.prisma.client.invoice.update({
where: { id: localInvoiceId },
data: {
subscription_id: existingRenewal.id,
...(startupInvoiceSnapshot ? startupInvoiceSnapshot : {}),
} as any,
});
}
this.logger.warn(
`[renewal-debug] subscription-rotation skipped (existing renewal) stripe_invoice_id=${stripeInvoiceId} company_id=${companyId} old_subscription_id=${oldSubId} existing_subscription_id=${existingRenewal.id}`,
);
subscriptionIdForEmail = existingRenewal.id;
// Continue with email + expiry clear below.
} else {
const newSub = await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: companyId, id: { not: oldSubId } },
data: { is_current: false },
});
await tx.subscription.updateMany({
where: {
company_id: companyId,
id: { not: oldSubId },
status: SubscriptionStatus.ACTIVE,
},
data: { status: SubscriptionStatus.CANCELLED },
});
// 1) Archive previous current row (historical next_billing_date unchanged; clear Stripe link so lookups by sub id prefer the renewal row)
await tx.subscription.update({
where: { id: oldSubId },
data: {
status: SubscriptionStatus.CANCELLED,
is_current: false,
stripe_subscription_id: null,
// Preserve historical next_billing_date on archived rows.
// Only the newly created renewal row should get the new next billing date.
next_billing_date: oldSubNextBillingDate,
end_date: periodStart ?? paidDate ?? new Date(),
} as any,
});
// 2) Create the renewal "current" row
const created = await tx.subscription.create({
data: {
company_id: companyId,
package_id: renewalPackageId,
previous_subscription_id: oldSubId,
license_count: renewalLicenseCount,
status: SubscriptionStatus.ACTIVE,
billing_cycle: rotationSource.billing_cycle ?? undefined,
// Use Stripe period start, not webhook timestamp
start_date: periodStart ?? paidDate ?? new Date(),
end_date: nextBillingForRenewal,
next_billing_date: nextBillingForRenewal,
stripe_subscription_id: subscriptionId,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
is_current: true,
subscription_type: SubscriptionType.RENEWAL,
licenses_available: rotationSource.licenses_available,
licenses_consumed: rotationSource.licenses_consumed,
licenses_expired: rotationSource.licenses_expired,
last_license_assignment: rotationSource.last_license_assignment ?? undefined,
last_license_release: rotationSource.last_license_release ?? undefined,
user_id_created_by: rotationSource.user_id_created_by ?? undefined,
user_id_updated_by: rotationSource.user_id_updated_by ?? undefined,
} as any,
});
return created;
});
localSub = newSub;
subscriptionIdForEmail = newSub.id;
if (localInvoiceId) {
await this.prisma.client.invoice.update({
where: { id: localInvoiceId },
data: {
subscription_id: newSub.id,
...(startupInvoiceSnapshot ? startupInvoiceSnapshot : {}),
} as any,
});
this.logger.warn(
`[renewal-debug] invoice-relinked-to-rotated-subscription stripe_invoice_id=${stripeInvoiceId} local_invoice_id=${localInvoiceId} old_subscription_id=${oldSubId} new_subscription_id=${newSub.id}`,
);
}
this.logger.log(
`handleInvoicePaid: rotated local subscription on renewal (company=${companyId}, old=${oldSubId}, new=${newSub.id})`,
);
this.logger.warn(
`[renewal-debug] subscription-rotated stripe_invoice_id=${stripeInvoiceId} company_id=${companyId} old_subscription_id=${oldSubId} new_subscription_id=${newSub.id}`,
);
}
}
}
} else {
this.logger.warn(
`[renewal-debug] subscription-update-skipped-invalid-next-billing stripe_invoice_id=${stripeInvoiceId} local_subscription_id=${localSub.id} company_id=${companyId} (nextBillingForRenewal=null)`,
);
}
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
// Paid signup: email from manual OOB invoice; skip duplicate on mirror subscription_create (-0002) / $0 first invoice.
if (!skipLocalInvoiceRow) {
const nextBill = computedNextBillingDate ?? null;
const amountUsd = localInvoiceTotalAmount ?? finalTotalCents / 100;
if (this.shouldSendPaymentSucceededEmail(amountUsd)) {
await this.billing.sendCompanyTemplatedEmail({
companyId,
templateKey: "billing.payment.succeeded",
dedupeKey: `billing.payment.succeeded:${stripeInvoiceId}`,
triggeredBy: "stripe_webhook_invoice_paid",
variables: {
"invoice.number": localInvoiceNumber ?? stripeInvoiceId,
"invoice.amount": this.billing.formatUsd(amountUsd),
"subscription.next_billing_date": nextBill ? nextBill.toISOString().split("T")[0] : "",
},
metadata: {
stripe_invoice_id: stripeInvoiceId,
invoice_id: localInvoiceId ?? undefined,
subscription_id: subscriptionIdForEmail,
},
});
} else {
this.logger.log(
`handleInvoicePaid: skip billing.payment.succeeded email for zero-amount invoice stripe_invoice_id=${stripeInvoiceId} company=${companyId}`,
);
}
} else {
this.logger.log(
`handleInvoicePaid: skip billing.payment.succeeded email stripe_invoice_id=${stripeInvoiceId} company=${companyId} (${
skipDuplicateSignupSubscriptionMirrorInvoice
? "signup subscription mirror"
: skipLocalInvoiceForZeroFirstSubscription
? "zero first-subscription invoice"
: "skipped"
})`,
);
}
}
async handleInvoicePaymentPaid(eventObject: unknown): Promise<void> {
if (!this.stripeService.isConfigured()) return;
try {
const obj = eventObject as any;
let invoiceId: string | null = null;
// Common shapes:
// - { invoice: "in_...", ... }
// - { invoice: { id: "in_..." }, ... }
// - { invoice_id: "in_..." }
// - invoice itself ({ id: "...", object: "invoice" })
if (obj && typeof obj === "object") {
if (typeof obj.id === "string" && obj.object === "invoice") {
invoiceId = obj.id;
}
if (!invoiceId && typeof obj.invoice === "string") {
invoiceId = obj.invoice;
}
if (!invoiceId && obj.invoice && typeof obj.invoice === "object" && typeof obj.invoice.id === "string") {
invoiceId = obj.invoice.id;
}
if (!invoiceId && typeof obj.invoice_id === "string") {
invoiceId = obj.invoice_id;
}
}
if (!invoiceId) {
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaymentPaid: could not resolve invoice id from payload`,
);
return;
}
const invoice = await this.stripeService.getInvoiceExpanded(invoiceId);
await this.handleInvoicePaid(invoice);
} catch (e) {
this.logger.warn(
`[stripe-renewal-debug] handleInvoicePaymentPaid failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
async handlePaymentMethodAttached(paymentMethod: Stripe.PaymentMethod): Promise<void> {
if (!this.stripeService.isConfigured()) return;
const customerId = this.billing.extractStripeCustomerId(paymentMethod.customer as any);
if (!customerId) return;
const companyId = await this.billing.resolveCompanyIdFromStripeCustomer(customerId);
if (!companyId) return;
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { stripe_subscription_id: true },
});
const stripeSubId = company?.stripe_subscription_id ?? null;
if (!stripeSubId) return;
try {
const payNow = await this.stripeService.attemptPayLatestSubscriptionInvoice(stripeSubId);
if (payNow.paid && payNow.invoiceId) {
const inv = await this.stripeService.getInvoiceExpanded(payNow.invoiceId);
await this.handleInvoicePaid(inv);
}
} catch (e) {
this.logger.warn(
`handlePaymentMethodAttached: attemptPayLatestSubscriptionInvoice failed for ${stripeSubId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
async handleSetupIntentSucceeded(setupIntent: Stripe.SetupIntent): Promise<void> {
if (!this.stripeService.isConfigured()) return;
const customerId = this.billing.extractStripeCustomerId(setupIntent.customer as any);
if (!customerId) return;
const companyId = await this.billing.resolveCompanyIdFromStripeCustomer(customerId);
if (!companyId) return;
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { stripe_subscription_id: true },
});
const stripeSubId = company?.stripe_subscription_id ?? null;
if (!stripeSubId) return;
try {
// Guard: only attempt pay if there's an open invoice with amount_due > 0.
// Free signups/trials can produce setup_intent events but no payable invoice.
const subWithInvoice = await this.stripeService.retrieveSubscriptionWithLatestInvoice(stripeSubId);
const latest = (subWithInvoice as any).latest_invoice as unknown;
const latestInvoiceId =
typeof latest === "string"
? latest
: latest && typeof latest === "object" && "id" in (latest as any)
? String((latest as any).id)
: null;
const latestInvoiceStatus =
latest && typeof latest === "object" && "status" in (latest as any) ? String((latest as any).status) : null;
const latestAmountDue =
latest && typeof latest === "object" && "amount_due" in (latest as any) ? Number((latest as any).amount_due) : null;
if (!latestInvoiceId) {
this.logger.warn(`handleSetupIntentSucceeded: no latest_invoice for sub ${stripeSubId}; skip auto-pay`);
return;
}
if (latestInvoiceStatus && latestInvoiceStatus !== "open" && latestInvoiceStatus !== "draft") {
this.logger.warn(
`handleSetupIntentSucceeded: latest invoice ${latestInvoiceId} status=${latestInvoiceStatus}; skip auto-pay`,
);
return;
}
if (typeof latestAmountDue === "number" && latestAmountDue <= 0) {
this.logger.warn(
`handleSetupIntentSucceeded: latest invoice ${latestInvoiceId} amount_due=${latestAmountDue}; skip auto-pay`,
);
return;
}
const payNow = await this.stripeService.attemptPayLatestSubscriptionInvoice(stripeSubId);
if (payNow.paid && payNow.invoiceId) {
const inv = await this.stripeService.getInvoiceExpanded(payNow.invoiceId);
await this.handleInvoicePaid(inv);
}
} catch (e) {
this.logger.warn(
`handleSetupIntentSucceeded: attemptPayLatestSubscriptionInvoice failed for ${stripeSubId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
async handleInvoiceCreated(invoice: Stripe.Invoice): Promise<void> {
const inv = invoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
};
const stripeInvoiceId = String(invoice.id);
const paidStatus =
invoice.status === "paid" ||
(typeof invoice.amount_paid === "number" && invoice.amount_paid > 0) ||
(invoice.status_transitions && typeof invoice.status_transitions.paid_at === "number");
// Stripe sends both `invoice.created` and `invoice.paid` for the same invoice (often back-to-back).
// Calling `handleInvoicePaid` from both races soft email dedupe and sends duplicate billing.payment.succeeded.
// `invoice.paid` is the canonical handler (same idea as ignoring `invoice.payment_succeeded`).
if (paidStatus) {
this.logger.log(
`handleInvoiceCreated: stripe_invoice_id=${stripeInvoiceId} already paid; deferring to invoice.paid (no-op here)`,
);
return;
}
const subRef = inv.subscription;
let subscriptionId = typeof subRef === "string" ? subRef : subRef?.id ?? null;
if (!subscriptionId) {
const invAny = invoice as unknown as Record<string, unknown>;
const linesObj = invAny["lines"];
const linesData =
linesObj && typeof linesObj === "object" ? (linesObj as Record<string, unknown>)["data"] : null;
const lines = Array.isArray(linesData) ? (linesData as unknown[]) : [];
const lineSub = lines
.map((l) => {
if (!l || typeof l !== "object") return null;
const rec = l as Record<string, unknown>;
const sub = rec["subscription"];
if (typeof sub === "string") return sub;
if (sub && typeof sub === "object" && typeof (sub as Record<string, unknown>)["id"] === "string") {
return String((sub as Record<string, unknown>)["id"]);
}
return null;
})
.find((v): v is string => typeof v === "string");
subscriptionId = lineSub ?? null;
if (subscriptionId) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated derived subscription from invoice.lines stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId}`,
);
}
}
if (!subscriptionId && this.stripeService.isConfigured()) {
const customerId = this.billing.extractStripeCustomerId(inv.customer);
if (customerId) {
try {
const subs = await this.stripeService.listSubscriptionsForCustomer(customerId, [
"active",
"trialing",
"past_due",
"unpaid",
]);
const matchByLatestInvoice = subs.find((s) => {
const li = (s as any).latest_invoice;
const liId = typeof li === "string" ? li : li && typeof li === "object" && "id" in li ? String(li.id) : null;
return liId === stripeInvoiceId;
});
if (matchByLatestInvoice) {
subscriptionId = matchByLatestInvoice.id;
} else if (subs.length === 1) {
subscriptionId = subs[0].id;
}
if (subscriptionId) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated derived subscription from customer subscriptions stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId} customer=${customerId} subs=${subs.length}`,
);
}
} catch (e) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated failed deriving subscription via customer subscriptions stripe_invoice_id=${stripeInvoiceId} customer=${customerId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
}
if (!subscriptionId) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated skip: missing subscription id stripe_invoice_id=${stripeInvoiceId}`,
);
return;
}
if (!this.stripeService.isConfigured()) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated skip: Stripe not configured stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId}`,
);
return;
}
// If Stripe keeps the invoice in `draft`, it will not be charged.
// Auto-finalize + attempt payment using the customer's default payment method.
// This addresses the "invoice is draft even though default PM is set" scenario.
if (invoice.status === "draft") {
const amountDue = typeof invoice.amount_due === "number" ? invoice.amount_due : null;
if (amountDue === null || amountDue > 0) {
this.logger.warn(
`[stripe-link-debug] handleInvoiceCreated: invoice draft -> attemptPayLatestSubscriptionInvoice stripe_invoice_id=${stripeInvoiceId} stripe_subscription_id=${subscriptionId} amount_due=${amountDue ?? "n/a"}`,
);
const payNow = await this.stripeService.attemptPayLatestSubscriptionInvoice(subscriptionId);
if (payNow.paid && payNow.invoiceId) {
const paidInv = await this.stripeService.getInvoiceExpanded(payNow.invoiceId);
await this.handleInvoicePaid(paidInv);
return;
}
}
}
// Prefer the current row so post-rotation we do not attach pending invoices to an archived trial row.
let resolvedLocalSub = await this.prisma.client.subscription.findFirst({
where: { stripe_subscription_id: subscriptionId, is_current: true },
orderBy: { id: "desc" },
include: { package: true },
});
if (!resolvedLocalSub) {
resolvedLocalSub = await this.prisma.client.subscription.findFirst({
where: { stripe_subscription_id: subscriptionId },
orderBy: { id: "desc" },
include: { package: true },
});
}
if (!resolvedLocalSub) {
// Fallback: Stripe may carry our mapping in metadata even if the local row hasn't
// been linked with `stripe_subscription_id` yet.
try {
const stripeSub = await this.stripeService.retrieveSubscription(subscriptionId);
const companyIdStr = stripeSub.metadata?.["company_id"];
const localSubIdStr = stripeSub.metadata?.["local_subscription_id"];
if (companyIdStr && localSubIdStr) {
const companyId = parseInt(companyIdStr, 10);
const localSubId = parseInt(localSubIdStr, 10);
if (!Number.isNaN(companyId) && !Number.isNaN(localSubId)) {
const byMeta = await this.prisma.client.subscription.findFirst({
where: { id: localSubId, company_id: companyId },
include: { package: true },
});
if (byMeta) {
await this.prisma.client.subscription.update({
where: { id: byMeta.id },
data: { stripe_subscription_id: subscriptionId },
});
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subscriptionId },
});
resolvedLocalSub = await this.prisma.client.subscription.findUnique({
where: { id: byMeta.id },
include: { package: true },
});
}
}
}
} catch {
// ignore and handle unresolved below
}
}
if (!resolvedLocalSub?.package) {
const packageId = (resolvedLocalSub as any)?.package_id as number | null | undefined;
if (packageId) {
const pkg = await this.prisma.client.package.findUnique({ where: { id: packageId } });
if (pkg) {
resolvedLocalSub = { ...resolvedLocalSub, package: pkg } as any;
}
}
}
if (!resolvedLocalSub?.package) {
this.logger.warn(
`handleInvoiceCreated: could not resolve local subscription package for Stripe sub ${subscriptionId}; skipping pending invoice (stripe_invoice_id=${stripeInvoiceId})`,
);
return;
}
const companyId = resolvedLocalSub.company_id;
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { country: true },
});
if (!company) return;
// Idempotency: do not create duplicate rows for the same Stripe invoice.
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: invoice.id },
select: { id: true },
});
if (existing) return;
const amountDueCents =
typeof invoice.amount_due === "number" && invoice.amount_due >= 0 ? invoice.amount_due : null;
const isTrialLikePackageForBilling = resolvedLocalSub.package.is_trial_package
? true
: resolvedLocalSub.package.package_type === PackageType.FREE_TRIAL ||
resolvedLocalSub.package.package_type === PackageType.PRIVATE_VIP_TRIAL;
let pricePerLicense = Number(resolvedLocalSub.package.price_per_licence ?? 0);
if (isTrialLikePackageForBilling && (!Number.isFinite(pricePerLicense) || pricePerLicense <= 0)) {
const startupPackage = await this.prisma.client.package.findFirst({
where: { package_type: PackageType.STARTUP, is_active: true },
select: { price_per_licence: true },
});
const startupPricePerLicense = Number(startupPackage?.price_per_licence ?? 0);
if (Number.isFinite(startupPricePerLicense) && startupPricePerLicense > 0) {
pricePerLicense = startupPricePerLicense;
}
}
const cycleMultiplier = getBillingCycleMultiplier(resolvedLocalSub.billing_cycle || "QUARTERLY");
const baseAmountCentsPerCycle = Math.round(
pricePerLicense * resolvedLocalSub.license_count * cycleMultiplier * 100,
);
const charge = this.billing.calculateRenewalChargeCents({
baseAmountCents: baseAmountCentsPerCycle,
country: company.country,
});
const feePcts = this.billing.renewalFeePercentagesForDb(company.country);
const derived = this.billing.derivePeriodsFromStripeInvoice(invoice);
const periodStart = derived.periodStart ?? resolvedLocalSub.start_date ?? null;
let periodEndLocal = derived.periodEnd ?? resolvedLocalSub.end_date ?? null;
if (invoice.billing_reason === "subscription_cycle" && periodStart) {
periodEndLocal = addBillingDays(
periodStart,
getBillingCyclePeriodDays(resolvedLocalSub.billing_cycle ?? "QUARTERLY"),
);
}
const invoiceNumber = invoice.number ? String(invoice.number) : stripeInvoiceId;
const { paymentIntentId, chargeId, checkoutSessionId } = this.billing.extractPaymentIntentAndChargeIds(invoice);
const finalTotalCents = amountDueCents !== null ? amountDueCents : charge.totalCents;
const invoiceTypeResolved = this.billing.resolveInvoiceTypeForStripeSubscriptionInvoice({
billingReason: invoice.billing_reason,
localSubscriptionType: resolvedLocalSub.subscription_type,
});
// Paid signup already creates/links its own local initial invoice row.
// The Stripe subscription_create mirror invoice is only a Stripe Billing state artifact
// and should not create a second local invoice row for the same initial signup.
let isSignupAccountSyncSource = false;
if (this.billing.isStripeFirstSubscriptionInvoiceBillingReason(invoice.billing_reason)) {
try {
const stripeSub = await this.stripeService.retrieveSubscription(subscriptionId);
isSignupAccountSyncSource = stripeSub.metadata?.["source"] === "signup_account_sync";
} catch {
isSignupAccountSyncSource = false;
}
}
const skipDuplicateSignupSubscriptionMirrorInvoice =
this.billing.isStripeFirstSubscriptionInvoiceBillingReason(invoice.billing_reason) &&
isSignupAccountSyncSource;
if (skipDuplicateSignupSubscriptionMirrorInvoice) {
this.logger.log(
`handleInvoiceCreated: skipped signup subscription mirror invoice stripe_invoice_id=${stripeInvoiceId} (company=${companyId}, subscription=${resolvedLocalSub.id}).`,
);
return;
}
await this.prisma.client.invoice.create({
data: {
company_id: companyId,
subscription_id: resolvedLocalSub.id,
invoice_number: invoiceNumber,
unit_price_per_license: Number(resolvedLocalSub.package.price_per_licence ?? 0),
license_quantity: resolvedLocalSub.license_count,
package_type: resolvedLocalSub.package.package_type,
billing_cycle: resolvedLocalSub.billing_cycle ?? undefined,
status: InvoiceStatus.PENDING,
invoice_type: invoiceTypeResolved,
stripe_invoice_id: invoice.id,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
period_start: periodStart ?? undefined,
period_end: periodEndLocal ?? undefined,
...invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: baseAmountCentsPerCycle,
processingFeeCents: charge.processingFeeCents,
vatCents: charge.vatCents,
totalCents: finalTotalCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
),
} as any,
});
this.logger.log(
`handleInvoiceCreated: created local pending Invoice row for stripe_invoice_id=${stripeInvoiceId} (company=${companyId}, subscription=${resolvedLocalSub.id}).`,
);
}
}