apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-subscription-writer.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, stripeService: StripeService, billing: StripePaymentBillingHelpersService, invoiceWebhooks: StripePaymentInvoiceWebhookService)
|
|||||||||||||||
|
Parameters :
|
| Private Async ensureNormalRenewalInvoiceRecord | ||||||
ensureNormalRenewalInvoiceRecord(params: literal type)
|
||||||
|
Parameters :
Returns :
Promise<void>
|
| Private Async mirrorPaidLatestInvoiceIfMissing | ||||||
mirrorPaidLatestInvoiceIfMissing(stripeSubscriptionId: string)
|
||||||
|
When the first invoice is paid automatically (default PM), Stripe may return the subscription as active/trialing so the incomplete/pay branch is skipped. Mirror {@code invoice.paid} when webhooks lag.
Parameters :
Returns :
Promise<void>
|
| Private Async recoverExpiredSubscriptionViaOneOffInvoiceThenTrialingSub | ||||||||||||||||||||||||
recoverExpiredSubscriptionViaOneOffInvoiceThenTrialingSub(company: literal type, localSubscription: literal type, metadataSource: string, baseAmountCentsPerCycle: number, charge: literal type, recurringTotalAmountCentsPerCycle: number, recoveryAnchorDate?: string)
|
||||||||||||||||||||||||
|
Parameters :
Returns :
Promise<boolean>
|
| Async syncStripeSubscriptionForLocalSubscription | ||||||||||||
syncStripeSubscriptionForLocalSubscription(companyId: number, localSubscriptionId: number, opts?: literal type)
|
||||||||||||
|
Parameters :
Returns :
Promise<boolean>
true if a Stripe subscription was created and local rows were updated. |
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripePaymentSubscriptionWriterService.name)
|
import { BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import {
BillingCycle,
InvoiceStatus,
InvoiceType,
PackageType,
SubscriptionStatus,
SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import { calculateTrialNextBillingDate, getBillingCycleMultiplier } from "../../../../../config/billing-cycle";
import {
invoiceBillingAmountsFromRenewalCents,
invoiceBillingAmountsToDbFields,
} from "../../../../../config/billing.config";
import { localStripeCancelAtPeriodEndFromSubscription, StripeService } from "../stripe.service";
import { StripeSubscriptionApi } from "./stripe-payment.types";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripePaymentInvoiceWebhookService } from "./stripe-payment-invoice-webhook.service";
@Injectable()
export class StripePaymentSubscriptionWriterService {
private readonly logger = new Logger(StripePaymentSubscriptionWriterService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly stripeService: StripeService,
private readonly billing: StripePaymentBillingHelpersService,
private readonly invoiceWebhooks: StripePaymentInvoiceWebhookService,
) {}
private async recoverExpiredSubscriptionViaOneOffInvoiceThenTrialingSub(
company: { id: number; stripe_customer_id: string; country: string | null },
localSubscription: {
id: number;
license_count: number;
subscription_type: SubscriptionType;
next_billing_date: Date | null;
billing_cycle: BillingCycle | null;
package: { price_per_licence: unknown; package_type: PackageType };
},
metadataSource: string,
baseAmountCentsPerCycle: number,
charge: { totalCents: number; processingFeeCents: number; vatCents: number },
recurringTotalAmountCentsPerCycle: number,
recoveryAnchorDate?: string,
): Promise<boolean> {
try {
let hasPm = false;
try {
hasPm = await this.stripeService.ensureDefaultPaymentMethodFromSavedCards(company.stripe_customer_id);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(`Expired recovery: ensure PM failed company ${company.id}: ${msg}`);
}
if (!hasPm) {
this.logger.warn(`Expired recovery skipped company ${company.id}: no invoice default payment method`);
return false;
}
const pmId = await this.stripeService.getInvoiceDefaultPaymentMethodId(company.stripe_customer_id);
if (!pmId) {
this.logger.warn(`Expired recovery skipped company ${company.id}: could not read default PM id`);
return false;
}
const cancelled = await this.stripeService.cancelCustomerBillableSubscriptions(company.stripe_customer_id);
if (cancelled.length > 0) {
this.logger.log(`Expired recovery: cancelled Stripe subs ${cancelled.join(", ")}`);
}
const metaLines: Record<string, string> = {
company_id: String(company.id),
local_subscription_id: String(localSubscription.id),
source: metadataSource,
};
const paidInv = await this.stripeService.createFinalizeAndPayOneOffInvoice({
customerId: company.stripe_customer_id,
amountCents: charge.totalCents,
currency: "usd",
description: `RecallAssess renewal (1 billing period) — company ${company.id}`,
paymentMethodId: pmId,
metadata: metaLines,
});
if (paidInv.status !== "paid") {
this.logger.warn(
`Expired recovery: one-off invoice ${paidInv.id} ended with status ${paidInv.status ?? "unknown"}`,
);
return false;
}
// Expired recovery is an immediate "start a new renewal cycle now":
// - We charge 1 full period today
// - Next renewal is one full billing cycle from today (not from any stale past `next_billing_date`)
const anchor = this.billing.resolveRecoveryAnchorDate(recoveryAnchorDate);
const nextBillingDate = this.billing.computeNextBillingAfterOnePaidPeriod(anchor, localSubscription.billing_cycle);
let invFull: Stripe.Invoice;
try {
invFull = await this.stripeService.getInvoiceExpanded(paidInv.id);
} catch (e) {
this.logger.warn(
`Expired recovery: getInvoiceExpanded failed for ${paidInv.id}: ${
e instanceof Error ? e.message : String(e)
}`,
);
invFull = await this.stripeService.getInvoice(paidInv.id);
}
const feePcts = this.billing.renewalFeePercentagesForDb(company.country);
const { paymentIntentId, chargeId, checkoutSessionId } = this.billing.extractPaymentIntentAndChargeIds(invFull);
const stripePaidAtUnix =
invFull.status_transitions && typeof invFull.status_transitions.paid_at === "number"
? invFull.status_transitions.paid_at
: null;
const paidDate = stripePaidAtUnix ? new Date(stripePaidAtUnix * 1000) : new Date();
const derived = this.billing.derivePeriodsFromStripeInvoice(invFull);
let periodStart: Date | undefined = derived.periodStart ?? undefined;
let periodEndLocal: Date | undefined = derived.periodEnd ?? undefined;
if (!periodStart || !periodEndLocal) {
periodStart = anchor;
periodEndLocal = nextBillingDate;
}
const stripePaidAmountCents =
typeof invFull.amount_paid === "number" && invFull.amount_paid >= 0
? invFull.amount_paid
: charge.totalCents;
const existingLocalPaidInvoice = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: paidInv.id },
select: {
id: true,
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,
},
});
let stripeSubId: string | null = null;
let stripeCancelAtPeriodEnd = false;
try {
const stripeSub = await this.stripeService.createTrialingSubscription({
customerId: company.stripe_customer_id,
defaultPaymentMethodId: pmId,
billingCycle: (localSubscription.billing_cycle || "QUARTERLY") as "QUARTERLY" | "HALF_YEARLY" | "ANNUAL",
totalAmountCentsPerCycle: recurringTotalAmountCentsPerCycle,
trialEndUnix: Math.floor(nextBillingDate.getTime() / 1000),
metadata: {
...metaLines,
local_billing_cycle: localSubscription.billing_cycle || "QUARTERLY",
local_next_billing_anchor: nextBillingDate.toISOString(),
},
});
stripeSubId = stripeSub.id;
stripeCancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(stripeSub);
this.logger.log(
`Expired recovery: trialing Stripe subscription ${stripeSubId} until ${nextBillingDate.toISOString()}`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(
`Expired recovery: invoice paid but trialing subscription failed for company ${company.id}: ${msg}`,
);
}
await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: company.id, id: { not: localSubscription.id } },
data: { is_current: false },
});
await tx.subscription.updateMany({
where: {
company_id: company.id,
id: { not: localSubscription.id },
status: SubscriptionStatus.ACTIVE,
},
data: { status: SubscriptionStatus.CANCELLED },
});
await tx.subscription.update({
where: { id: localSubscription.id },
data: {
status: SubscriptionStatus.ACTIVE,
is_current: true,
start_date: anchor,
end_date: nextBillingDate,
next_billing_date: nextBillingDate,
stripe_subscription_id: stripeSubId,
...(stripeSubId ? { stripe_cancel_at_period_end: stripeCancelAtPeriodEnd } : {}),
},
});
if (!existingLocalPaidInvoice) {
await tx.invoice.create({
data: {
company_id: company.id,
subscription_id: localSubscription.id,
invoice_number: invFull.number ? String(invFull.number) : paidInv.id,
unit_price_per_license: Number(localSubscription.package.price_per_licence ?? 0),
license_quantity: localSubscription.license_count,
package_type: localSubscription.package.package_type,
billing_cycle: localSubscription.billing_cycle ?? undefined,
status: InvoiceStatus.PAID,
invoice_type: InvoiceType.RENEWAL,
stripe_invoice_id: paidInv.id,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
paid_date: paidDate,
period_start: periodStart,
period_end: periodEndLocal,
...invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: baseAmountCentsPerCycle,
processingFeeCents: charge.processingFeeCents,
vatCents: charge.vatCents,
totalCents: stripePaidAmountCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
),
} as any,
});
} else {
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 computedTotal = stripePaidAmountCents / 100;
const existingTotal = decimalToNumber(existingLocalPaidInvoice["total_amount"]);
const updateData: Record<string, unknown> = {};
if (existingTotal === null || Math.abs(existingTotal - computedTotal) > 0.01) {
updateData["total_amount"] = computedTotal;
}
// Bring fee percentages/amounts in sync with the current renewal rules.
const existingProcessingPct = decimalToNumber(existingLocalPaidInvoice["processing_fee_percentage"]);
if (
existingProcessingPct === null ||
Math.abs(existingProcessingPct - feePcts.processingFeePct) > 0.01
) {
updateData["processing_fee_percentage"] = feePcts.processingFeePct;
updateData["processing_fee"] = charge.processingFeeCents / 100;
}
const existingVatPct = decimalToNumber(existingLocalPaidInvoice["vat_fee_percentage"]);
if (existingVatPct === null || Math.abs(existingVatPct - feePcts.vatPct) > 0.01) {
updateData["vat_fee_percentage"] = feePcts.vatPct;
updateData["vat_fee"] = charge.vatCents / 100;
}
if (paymentIntentId && existingLocalPaidInvoice["stripe_payment_intent_id"] !== paymentIntentId) {
updateData["stripe_payment_intent_id"] = paymentIntentId;
}
if (chargeId && existingLocalPaidInvoice["stripe_charge_id"] !== chargeId) {
updateData["stripe_charge_id"] = chargeId;
}
if (checkoutSessionId && existingLocalPaidInvoice["stripe_checkout_session_id"] !== checkoutSessionId) {
updateData["stripe_checkout_session_id"] = checkoutSessionId;
}
updateData["subscription_id"] = localSubscription.id;
// Fix periods when previously created without line periods / or with wrong invoice-level periods.
if (periodStart && !existingLocalPaidInvoice["period_start"]) {
updateData["period_start"] = periodStart;
}
if (periodEndLocal && !existingLocalPaidInvoice["period_end"]) {
updateData["period_end"] = periodEndLocal;
}
// Immediate recovery should be classified as renewal.
if (existingLocalPaidInvoice["invoice_type"] !== InvoiceType.RENEWAL) {
updateData["invoice_type"] = InvoiceType.RENEWAL;
}
if (Object.keys(updateData).length > 0) {
await tx.invoice.update({
where: { id: existingLocalPaidInvoice.id },
data: updateData as any,
});
}
}
await tx.company.update({
where: { id: company.id },
data: {
is_subscription_expiry: false,
stripe_subscription_id: stripeSubId,
},
});
});
// One-off invoice is not the trialing subscription’s latest_invoice; invoice.paid webhooks are often enough
// but local/dev may miss them. Reuse handleInvoicePaid so billing.payment.succeeded sends (deduped by Stripe id).
try {
await this.invoiceWebhooks.handleInvoicePaid(invFull);
} catch (e) {
this.logger.warn(
`Expired recovery: post-transaction handleInvoicePaid failed for stripe invoice ${invFull.id}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(`Expired recovery one-off flow failed company ${company.id}: ${msg}`);
return false;
}
}
/**
* @returns true if a Stripe subscription was created and local rows were updated.
*/
async syncStripeSubscriptionForLocalSubscription(
companyId: number,
localSubscriptionId: number,
opts?: {
metadataSource?: string;
/** Expired recovery: charge one period via one-off invoice, then trialing subscription (not subscription-first invoice). */
forceImmediateFirstInvoice?: boolean;
/** Optional billing anchor date (YYYY-MM-DD or ISO). Used only by immediate-recovery flow. */
recoveryAnchorDate?: string;
/**
* Expired recovery pricing adjustment (VIP offer).
* Applied only when {@link forceImmediateFirstInvoice} is true.
* Example: 2/3 means "charge 2 months worth for a quarterly period" (Month 1 & 2 at 50% + Month 3 full).
*/
immediateFirstInvoiceDiscountMultiplier?: number;
},
): Promise<boolean> {
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: {
id: true,
stripe_customer_id: true,
country: true,
},
});
if (!company) {
this.logger.warn(`Stripe sync skipped for company ${companyId}: company not found`);
return false;
}
const stripeCustomerId = company.stripe_customer_id;
if (!stripeCustomerId) {
this.logger.warn(`Stripe sync skipped for company ${companyId}: missing stripe_customer_id`);
return false;
}
const localSubscription = await this.prisma.client.subscription.findUnique({
where: { id: localSubscriptionId },
include: {
package: true,
previousSubscription: { include: { package: true } },
},
});
if (!localSubscription?.package) {
this.logger.warn(`Stripe sync skipped for company ${companyId}: local subscription/package not found`);
return false;
}
// VIP trial rows can have $0 pricing in the catalog; billing after trial end should use STARTUP pricing.
// (VIP offer discount is applied separately on the first invoice only.)
let effectivePricePerLicense = Number(localSubscription.package.price_per_licence ?? 0);
if (
localSubscription.package.package_type === PackageType.PRIVATE_VIP_TRIAL &&
(!Number.isFinite(effectivePricePerLicense) || effectivePricePerLicense <= 0)
) {
const startup = await this.prisma.client.package.findFirst({
where: { package_type: PackageType.STARTUP, is_active: true },
select: { price_per_licence: true },
});
const startupPpl = Number(startup?.price_per_licence ?? 0);
if (Number.isFinite(startupPpl) && startupPpl > 0) {
effectivePricePerLicense = startupPpl;
}
}
const cycleMultiplier = getBillingCycleMultiplier(localSubscription.billing_cycle || "QUARTERLY");
const fullBaseAmountCentsPerCycle = Math.round(
effectivePricePerLicense * localSubscription.license_count * cycleMultiplier * 100,
);
let baseAmountCentsPerCycle = fullBaseAmountCentsPerCycle;
const isAnnualCycle = (localSubscription.billing_cycle || "QUARTERLY") === BillingCycle.ANNUAL;
const vipImmediateOfferApplies =
localSubscription.package?.package_type === PackageType.PRIVATE_VIP_TRIAL ||
localSubscription.previousSubscription?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;
const effectiveImmediateDiscountMultiplier =
typeof opts?.immediateFirstInvoiceDiscountMultiplier === "number"
? opts.immediateFirstInvoiceDiscountMultiplier
: opts?.forceImmediateFirstInvoice
? isAnnualCycle
? vipImmediateOfferApplies
? 2 / 3
: 5 / 6
: undefined
: undefined;
if (
opts?.forceImmediateFirstInvoice &&
typeof effectiveImmediateDiscountMultiplier === "number" &&
Number.isFinite(effectiveImmediateDiscountMultiplier) &&
effectiveImmediateDiscountMultiplier > 0 &&
effectiveImmediateDiscountMultiplier < 1
) {
baseAmountCentsPerCycle = Math.max(
1,
Math.round(baseAmountCentsPerCycle * effectiveImmediateDiscountMultiplier),
);
}
if (baseAmountCentsPerCycle <= 0) {
this.logger.warn(
`Stripe sync skipped for company ${companyId}: non-positive cycle amount (${baseAmountCentsPerCycle} cents; check package price and license_count)`,
);
return false;
}
const charge = this.billing.calculateRenewalChargeCents({
baseAmountCents: baseAmountCentsPerCycle,
country: company.country,
});
const recurringCharge = this.billing.calculateRenewalChargeCents({
baseAmountCents: fullBaseAmountCentsPerCycle,
country: company.country,
});
if (opts?.forceImmediateFirstInvoice) {
return this.recoverExpiredSubscriptionViaOneOffInvoiceThenTrialingSub(
{
id: company.id,
stripe_customer_id: stripeCustomerId,
country: company.country,
},
{
id: localSubscription.id,
license_count: localSubscription.license_count,
subscription_type: localSubscription.subscription_type,
next_billing_date: localSubscription.next_billing_date,
billing_cycle: localSubscription.billing_cycle,
package: localSubscription.package,
},
opts.metadataSource ?? "webhook_subscription_sync",
baseAmountCentsPerCycle,
charge,
recurringCharge.totalCents,
opts.recoveryAnchorDate,
);
}
try {
await this.stripeService.ensureDefaultPaymentMethodFromSavedCards(stripeCustomerId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(
`Could not ensure default payment method from saved cards for company ${companyId}: ${msg}`,
);
}
const cancelled = await this.stripeService.cancelCustomerBillableSubscriptions(stripeCustomerId);
if (cancelled.length > 0) {
this.logger.log(`Cancelled Stripe subscriptions during sync: ${cancelled.join(", ")}`);
}
const isTrialLikeLocalPackage =
!!localSubscription.package.is_trial_package ||
localSubscription.package.package_type === PackageType.FREE_TRIAL ||
localSubscription.package.package_type === PackageType.PRIVATE_VIP_TRIAL;
let trialEndUnix: number | undefined;
if (isTrialLikeLocalPackage && localSubscription.next_billing_date) {
trialEndUnix = Math.floor(localSubscription.next_billing_date.getTime() / 1000);
}
const isVipTrialContext =
localSubscription.package?.package_type === PackageType.PRIVATE_VIP_TRIAL ||
localSubscription.previousSubscription?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;
if (!trialEndUnix && isVipTrialContext && localSubscription.package?.trial_duration_days) {
const trialEnd = calculateTrialNextBillingDate(localSubscription.package.trial_duration_days);
trialEndUnix = Math.floor(trialEnd.getTime() / 1000);
this.logger.log(
`VIP offer fallback: derived trial_end from package trial_duration_days=${localSubscription.package.trial_duration_days} for company ${company.id}.`,
);
}
const metaSource = opts?.metadataSource ?? "webhook_subscription_sync";
const stripeSub = await this.stripeService.createCustomerSubscription({
customerId: stripeCustomerId,
billingCycle: (localSubscription.billing_cycle || "QUARTERLY") as "QUARTERLY" | "HALF_YEARLY" | "ANNUAL",
totalAmountCentsPerCycle: charge.totalCents,
autoPaymentEnabled: true,
trialEndUnix,
// VIP offer (trial → paid): first invoice after trial end gets 1 month off (equivalent to Month 1 & 2 at 50%).
firstInvoiceAmountOffCents:
isVipTrialContext && trialEndUnix
? Math.max(1, Math.round(charge.totalCents / cycleMultiplier))
: undefined,
metadata: {
company_id: String(company.id),
local_subscription_id: String(localSubscription.id),
source: metaSource,
local_billing_cycle: localSubscription.billing_cycle || "QUARTERLY",
},
});
if (isVipTrialContext) {
this.logger.log(
`VIP offer context for company ${company.id}: trialEndUnix=${trialEndUnix ?? "none"}, discountCents=${
trialEndUnix ? Math.max(1, Math.round(charge.totalCents / cycleMultiplier)) : 0
}`,
);
}
const cancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(stripeSub);
// Portal checkout already created the current local row (UPGRADE/DOWNGRADE/INITIAL) and marked the
// checkout invoice PAID. Do not supersede with a RENEWAL row or create a second invoice from Stripe Billing.
const isCheckoutEstablishedCurrentRow =
metaSource === "webhook_checkout" && localSubscription.is_current;
if (isCheckoutEstablishedCurrentRow) {
await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.update({
where: { id: localSubscription.id },
data: {
stripe_subscription_id: stripeSub.id,
stripe_cancel_at_period_end: cancelAtPeriodEnd,
},
});
await tx.company.update({
where: { id: company.id },
data: {
stripe_subscription_id: stripeSub.id,
is_subscription_expiry: false,
},
});
});
this.logger.log(
`Checkout sync: linked Stripe subscription ${stripeSub.id} to local subscription ${localSubscription.id} ` +
`(skipped RENEWAL row and renewal invoice; portal checkout invoice is authoritative).`,
);
return true;
}
const activeLocalSubscription = await this.createNormalRenewalLocalSubscriptionRecord({
companyId: company.id,
previousSubscription: localSubscription,
stripeSubscriptionId: stripeSub.id,
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
anchorDate: opts?.recoveryAnchorDate,
});
await this.ensureNormalRenewalInvoiceRecord({
companyId: company.id,
subscriptionId: activeLocalSubscription.id,
localSubscription,
stripeSubscriptionId: stripeSub.id,
baseAmountCentsPerCycle,
charge,
country: company.country,
});
const needsPayment =
stripeSub.status === "incomplete" || stripeSub.status === "past_due" || stripeSub.status === "unpaid";
if (needsPayment) {
try {
const payNow = await this.stripeService.attemptPayLatestSubscriptionInvoice(stripeSub.id);
if (payNow.paid && payNow.invoiceId) {
let paidInvoice: Stripe.Invoice;
try {
paidInvoice = await this.stripeService.getInvoiceExpanded(payNow.invoiceId);
} catch (e) {
this.logger.warn(
`Auto-pay fallback: getInvoiceExpanded failed for ${payNow.invoiceId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
paidInvoice = await this.stripeService.getInvoice(payNow.invoiceId);
}
const existingLocalPaidInvoice = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: payNow.invoiceId },
select: {
id: true,
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 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);
};
// Ensure local Invoice row fields are correct even if the row already exists.
if (!existingLocalPaidInvoice) {
const feePcts = this.billing.renewalFeePercentagesForDb(company.country);
const { paymentIntentId, chargeId, checkoutSessionId } =
this.billing.extractPaymentIntentAndChargeIds(paidInvoice);
const stripePaidAtUnix =
paidInvoice.status_transitions && typeof paidInvoice.status_transitions.paid_at === "number"
? paidInvoice.status_transitions.paid_at
: null;
const paidDate = stripePaidAtUnix ? new Date(stripePaidAtUnix * 1000) : new Date();
const derived = this.billing.derivePeriodsFromStripeInvoice(paidInvoice);
let periodStart = derived.periodStart;
let periodEndLocal = derived.periodEnd;
const subPeriod = stripeSub as StripeSubscriptionApi;
const cps = subPeriod.current_period_start;
const cpe = subPeriod.current_period_end;
if (!periodStart && typeof cps === "number") {
periodStart = new Date(cps * 1000);
}
if (!periodEndLocal && typeof cpe === "number") {
periodEndLocal = new Date(cpe * 1000);
}
const stripePaidAmountCents =
typeof paidInvoice.amount_paid === "number" && paidInvoice.amount_paid >= 0
? paidInvoice.amount_paid
: null;
const finalTotalCents = stripePaidAmountCents !== null ? stripePaidAmountCents : charge.totalCents;
const invoiceTypeResolved = this.billing.resolveInvoiceTypeForStripeSubscriptionInvoice({
billingReason: paidInvoice.billing_reason,
localSubscriptionType: localSubscription.subscription_type,
});
await this.prisma.client.invoice.create({
data: {
company_id: company.id,
subscription_id: activeLocalSubscription.id,
invoice_number: paidInvoice.number ? String(paidInvoice.number) : payNow.invoiceId,
unit_price_per_license: Number(localSubscription.package.price_per_licence ?? 0),
license_quantity: localSubscription.license_count,
package_type: localSubscription.package.package_type,
billing_cycle: localSubscription.billing_cycle ?? undefined,
status: InvoiceStatus.PAID,
invoice_type: invoiceTypeResolved,
stripe_invoice_id: payNow.invoiceId,
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(
`Auto-pay fallback: Stripe paid amount mismatch for invoice ${payNow.invoiceId}. local_calc=${charge.totalCents}c stripe_paid=${stripePaidAmountCents}c`,
);
}
this.logger.log(
`Auto-pay fallback: created local Invoice row for stripe_invoice_id=${payNow.invoiceId} (company=${company.id}, subscription=${activeLocalSubscription.id}).`,
);
} else {
const feePcts = this.billing.renewalFeePercentagesForDb(company.country);
const { paymentIntentId, chargeId, checkoutSessionId } =
this.billing.extractPaymentIntentAndChargeIds(paidInvoice);
const stripePaidAtUnix =
paidInvoice.status_transitions && typeof paidInvoice.status_transitions.paid_at === "number"
? paidInvoice.status_transitions.paid_at
: null;
const paidDate = stripePaidAtUnix ? new Date(stripePaidAtUnix * 1000) : new Date();
const derived = this.billing.derivePeriodsFromStripeInvoice(paidInvoice);
let periodStart = derived.periodStart;
let periodEndLocal = derived.periodEnd;
const subPeriod = stripeSub as StripeSubscriptionApi;
const cps = subPeriod.current_period_start;
const cpe = subPeriod.current_period_end;
if (!periodStart && typeof cps === "number") {
periodStart = new Date(cps * 1000);
}
if (!periodEndLocal && typeof cpe === "number") {
periodEndLocal = new Date(cpe * 1000);
}
const stripePaidAmountCents =
typeof paidInvoice.amount_paid === "number" && paidInvoice.amount_paid >= 0
? paidInvoice.amount_paid
: null;
const finalTotalCents = stripePaidAmountCents !== null ? stripePaidAmountCents : charge.totalCents;
const invoiceTypeResolved = this.billing.resolveInvoiceTypeForStripeSubscriptionInvoice({
billingReason: paidInvoice.billing_reason,
localSubscriptionType: localSubscription.subscription_type,
});
const existingTotal = decimalToNumber(existingLocalPaidInvoice["total_amount"]);
const computedTotal = finalTotalCents / 100;
const updateData: Record<string, unknown> = {};
if (existingTotal === null || Math.abs(existingTotal - computedTotal) > 0.01) {
updateData["total_amount"] = computedTotal;
}
const existingProcessingPct = decimalToNumber(existingLocalPaidInvoice["processing_fee_percentage"]);
if (
existingProcessingPct === null ||
Math.abs(existingProcessingPct - feePcts.processingFeePct) > 0.01
) {
updateData["processing_fee_percentage"] = feePcts.processingFeePct;
updateData["processing_fee"] = charge.processingFeeCents / 100;
}
const existingVatPct = decimalToNumber(existingLocalPaidInvoice["vat_fee_percentage"]);
if (existingVatPct === null || Math.abs(existingVatPct - feePcts.vatPct) > 0.01) {
updateData["vat_fee_percentage"] = feePcts.vatPct;
updateData["vat_fee"] = charge.vatCents / 100;
}
if (paymentIntentId && existingLocalPaidInvoice["stripe_payment_intent_id"] !== paymentIntentId) {
updateData["stripe_payment_intent_id"] = paymentIntentId;
}
if (chargeId && existingLocalPaidInvoice["stripe_charge_id"] !== chargeId) {
updateData["stripe_charge_id"] = chargeId;
}
if (
checkoutSessionId &&
existingLocalPaidInvoice["stripe_checkout_session_id"] !== checkoutSessionId
) {
updateData["stripe_checkout_session_id"] = checkoutSessionId;
}
if (periodStart && !existingLocalPaidInvoice["period_start"]) {
updateData["period_start"] = periodStart;
}
if (periodEndLocal && !existingLocalPaidInvoice["period_end"]) {
updateData["period_end"] = periodEndLocal;
}
if (existingLocalPaidInvoice["invoice_type"] !== invoiceTypeResolved) {
updateData["invoice_type"] = invoiceTypeResolved;
}
if (Object.keys(updateData).length > 0) {
await this.prisma.client.invoice.update({
where: { id: existingLocalPaidInvoice.id },
data: updateData as any,
});
this.logger.log(
`Auto-pay fallback: updated local Invoice row for stripe_invoice_id=${payNow.invoiceId} (company=${company.id}, subscription=${activeLocalSubscription.id}).`,
);
}
}
await this.invoiceWebhooks.handleInvoicePaid(paidInvoice);
let localUpdated = false;
const refreshed = await this.prisma.client.subscription.findUnique({
where: { id: activeLocalSubscription.id },
select: { is_current: true, status: true, next_billing_date: true },
});
if (refreshed?.is_current && refreshed.status === SubscriptionStatus.ACTIVE) {
localUpdated = true;
}
// Fallback: if webhook-style resolution could not map invoice -> local row, enforce local state update
// using the row we just synced.
if (
!localUpdated &&
(localSubscription.status === SubscriptionStatus.EXPIRED ||
localSubscription.status === SubscriptionStatus.CANCELLED ||
!localSubscription.is_current)
) {
const fallbackNextBilling = localSubscription.next_billing_date;
await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: company.id, id: { not: activeLocalSubscription.id } },
data: { is_current: false },
});
await tx.subscription.updateMany({
where: {
company_id: company.id,
id: { not: activeLocalSubscription.id },
status: SubscriptionStatus.ACTIVE,
},
data: { status: SubscriptionStatus.CANCELLED },
});
await tx.subscription.update({
where: { id: activeLocalSubscription.id },
data: {
status: SubscriptionStatus.ACTIVE,
is_current: true,
next_billing_date: fallbackNextBilling,
},
});
await tx.company.update({
where: { id: company.id },
data: { is_subscription_expiry: false },
});
});
localUpdated = true;
}
this.logger.log(
`Auto-collected latest invoice ${payNow.invoiceId} for subscription ${stripeSub.id}; ` +
(localUpdated
? "local renewal/state updated."
: "payment succeeded (local update pending webhook)."),
);
} else {
this.logger.warn(
`Subscription ${stripeSub.id} still requires payment (invoice=${payNow.invoiceId ?? "n/a"}, status=${payNow.status ?? "unknown"}).`,
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(
`Auto-pay attempt failed for subscription ${stripeSub.id}; waiting for customer payment/webhook. ${msg}`,
);
}
} else if (stripeSub.status === "active" || stripeSub.status === "trialing") {
// Default payment method can settle the first invoice during create, so Stripe returns
// status active (never incomplete) and the incomplete/pay branch above is skipped.
// Trialing subs skip the incomplete branch too; latest_invoice may still be a paid charge (e.g. recovery).
// Local invoice rows normally come from invoice.paid webhooks; mirror here when webhooks lag or are off.
try {
await this.mirrorPaidLatestInvoiceIfMissing(stripeSub.id);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(
`Could not mirror already-paid latest invoice for subscription ${stripeSub.id} (status=${stripeSub.status}): ${msg}`,
);
}
}
this.logger.log(
`Synced Stripe subscription ${stripeSub.id} for company ${company.id} (Stripe status=${stripeSub.status})` +
(needsPayment
? ". Customer must add a default payment method or pay the open invoice before auto-charge."
: "") +
` Amount breakdown cents: base=${baseAmountCentsPerCycle}, processing_fee=${charge.processingFeeCents}, vat=${charge.vatCents}, total=${charge.totalCents}.`,
);
return true;
}
/**
* When the first invoice is paid automatically (default PM), Stripe may return the subscription as
* active/trialing so the incomplete/pay branch is skipped. Mirror {@code invoice.paid} when webhooks lag.
*/
private async mirrorPaidLatestInvoiceIfMissing(stripeSubscriptionId: string): Promise<void> {
const sub = await this.stripeService.retrieveSubscriptionWithLatestInvoice(stripeSubscriptionId);
if (sub.status !== "active" && sub.status !== "trialing") {
return;
}
const latest = sub.latest_invoice;
const inv =
typeof latest === "object" && latest && latest.object === "invoice" ? (latest as Stripe.Invoice) : null;
if (!inv || inv.status !== "paid") {
return;
}
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: inv.id },
select: { id: true },
});
if (existing) {
return;
}
await this.invoiceWebhooks.handleInvoicePaid(inv);
this.logger.log(
`mirrorPaidLatestInvoiceIfMissing: applied handleInvoicePaid for stripe_invoice_id=${inv.id} (subscription=${stripeSubscriptionId}).`,
);
}
private async createNormalRenewalLocalSubscriptionRecord(params: {
companyId: number;
previousSubscription: {
id: number;
package_id: number;
license_count: number;
billing_cycle: BillingCycle | null;
licenses_available: number;
licenses_consumed: number;
licenses_expired: number;
last_license_assignment: Date | null;
last_license_release: Date | null;
start_date: Date | null;
end_date: Date | null;
next_billing_date: Date | null;
status: SubscriptionStatus;
};
stripeSubscriptionId: string;
stripeCancelAtPeriodEnd: boolean;
anchorDate?: string;
}): Promise<{ id: number }> {
const billingCycle = params.previousSubscription.billing_cycle || BillingCycle.QUARTERLY;
const parsedAnchor =
params.anchorDate && params.anchorDate.trim().length > 0 ? new Date(params.anchorDate) : null;
const startDate =
(parsedAnchor && !Number.isNaN(parsedAnchor.getTime()) ? parsedAnchor : null) ??
params.previousSubscription.next_billing_date ??
params.previousSubscription.end_date ??
new Date();
if (
!params.previousSubscription.next_billing_date &&
!params.previousSubscription.end_date &&
!(parsedAnchor && !Number.isNaN(parsedAnchor.getTime()))
) {
this.logger.warn(
`createNormalRenewalLocalSubscriptionRecord: company ${params.companyId} previous sub ${params.previousSubscription.id} has no next_billing_date/end_date; falling back to current date anchor.`,
);
}
const endDate = this.billing.addDaysSafe(startDate, this.billing.getBillingCycleFixedDays(billingCycle));
return this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: params.companyId, id: { not: params.previousSubscription.id } },
data: { is_current: false },
});
await tx.subscription.update({
where: { id: params.previousSubscription.id },
data: {
is_current: false,
status:
params.previousSubscription.status === SubscriptionStatus.ACTIVE
? SubscriptionStatus.CANCELLED
: params.previousSubscription.status,
end_date: startDate,
},
});
const created = await tx.subscription.create({
data: {
company_id: params.companyId,
package_id: params.previousSubscription.package_id,
previous_subscription_id: params.previousSubscription.id,
license_count: params.previousSubscription.license_count,
status: SubscriptionStatus.ACTIVE,
billing_cycle: billingCycle,
start_date: startDate,
end_date: endDate,
next_billing_date: endDate,
stripe_subscription_id: params.stripeSubscriptionId,
stripe_cancel_at_period_end: params.stripeCancelAtPeriodEnd,
is_current: true,
subscription_type: SubscriptionType.RENEWAL,
licenses_available: params.previousSubscription.licenses_available,
licenses_consumed: params.previousSubscription.licenses_consumed,
licenses_expired: params.previousSubscription.licenses_expired,
last_license_assignment: params.previousSubscription.last_license_assignment,
last_license_release: params.previousSubscription.last_license_release,
},
select: { id: true },
});
await tx.company.update({
where: { id: params.companyId },
data: {
stripe_subscription_id: params.stripeSubscriptionId,
is_subscription_expiry: false,
},
});
return created;
});
}
private async ensureNormalRenewalInvoiceRecord(params: {
companyId: number;
subscriptionId: number;
localSubscription: {
package: { package_type: PackageType; price_per_licence: unknown };
license_count: number;
billing_cycle: BillingCycle | null;
start_date: Date | null;
end_date: Date | null;
subscription_type: SubscriptionType;
};
stripeSubscriptionId: string;
baseAmountCentsPerCycle: number;
charge: { totalCents: number; processingFeeCents: number; vatCents: number };
country: string | null;
}): Promise<void> {
let latestInvoice: Stripe.Invoice | null = null;
try {
const subWithInvoice = await this.stripeService.retrieveSubscriptionWithLatestInvoice(
params.stripeSubscriptionId,
);
const inv = subWithInvoice.latest_invoice;
latestInvoice = typeof inv === "object" && inv && inv.object === "invoice" ? inv : null;
} catch {
// keep null; we still create a local pending invoice row
}
if (latestInvoice?.id) {
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: latestInvoice.id },
select: { id: true },
});
if (existing) return;
}
const feePcts = this.billing.renewalFeePercentagesForDb(params.country);
const { paymentIntentId, chargeId, checkoutSessionId } = latestInvoice
? this.billing.extractPaymentIntentAndChargeIds(latestInvoice)
: { paymentIntentId: null, chargeId: null, checkoutSessionId: null };
const derived = latestInvoice ? this.billing.derivePeriodsFromStripeInvoice(latestInvoice) : { periodStart: null, periodEnd: null };
const periodStart = params.localSubscription.start_date ?? derived.periodStart;
const periodEnd = params.localSubscription.end_date ?? derived.periodEnd;
const amountDueCents =
latestInvoice && typeof latestInvoice.amount_due === "number" && latestInvoice.amount_due >= 0
? latestInvoice.amount_due
: params.charge.totalCents;
const status: InvoiceStatus =
latestInvoice?.status === "paid" ? InvoiceStatus.PAID : InvoiceStatus.PENDING;
await this.prisma.client.invoice.create({
data: {
company_id: params.companyId,
subscription_id: params.subscriptionId,
invoice_number:
(latestInvoice?.number && String(latestInvoice.number).trim().length > 0
? String(latestInvoice.number)
: latestInvoice?.id) ?? `RNW-PENDING-${params.subscriptionId}-${Date.now()}`,
unit_price_per_license: Number(params.localSubscription.package.price_per_licence ?? 0),
license_quantity: params.localSubscription.license_count,
package_type: params.localSubscription.package.package_type,
billing_cycle: params.localSubscription.billing_cycle ?? undefined,
status,
invoice_type: InvoiceType.RENEWAL,
stripe_invoice_id: latestInvoice?.id ?? undefined,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
paid_date: status === InvoiceStatus.PAID ? new Date() : undefined,
period_start: periodStart ?? undefined,
period_end: periodEnd ?? undefined,
...invoiceBillingAmountsToDbFields(
invoiceBillingAmountsFromRenewalCents({
baseAmountCents: params.baseAmountCentsPerCycle,
processingFeeCents: params.charge.processingFeeCents,
vatCents: params.charge.vatCents,
totalCents: amountDueCents,
processingFeePct: feePcts.processingFeePct,
vatPct: feePcts.vatPct,
}),
),
},
});
}
}