File

apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-subscription-writer.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, stripeService: StripeService, billing: StripePaymentBillingHelpersService, invoiceWebhooks: StripePaymentInvoiceWebhookService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
stripeService StripeService No
billing StripePaymentBillingHelpersService No
invoiceWebhooks StripePaymentInvoiceWebhookService No

Methods

Private Async createNormalRenewalLocalSubscriptionRecord
createNormalRenewalLocalSubscriptionRecord(params: literal type)
Parameters :
Name Type Optional
params literal type No
Returns : Promise<literal type>
Private Async ensureNormalRenewalInvoiceRecord
ensureNormalRenewalInvoiceRecord(params: literal type)
Parameters :
Name Type Optional
params literal type No
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 :
Name Type Optional
stripeSubscriptionId string No
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 :
Name Type Optional
company literal type No
localSubscription literal type No
metadataSource string No
baseAmountCentsPerCycle number No
charge literal type No
recurringTotalAmountCentsPerCycle number No
recoveryAnchorDate string Yes
Returns : Promise<boolean>
Async syncStripeSubscriptionForLocalSubscription
syncStripeSubscriptionForLocalSubscription(companyId: number, localSubscriptionId: number, opts?: literal type)
Parameters :
Name Type Optional
companyId number No
localSubscriptionId number No
opts literal type Yes
Returns : Promise<boolean>

true if a Stripe subscription was created and local rows were updated.

Properties

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,
          }),
        ),
      },
    });
  }
}

results matching ""

    No results matching ""