File

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

Index

Properties
Methods

Constructor

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

Methods

Private addMonthsSafe
addMonthsSafe(date: Date, months: number)
Parameters :
Name Type Optional
date Date No
months number No
Returns : Date
Async createCheckoutSession
createCheckoutSession(dto: CreateCheckoutSessionDto)

Create checkout session for participant payment

Parameters :
Name Type Optional
dto CreateCheckoutSessionDto No
Returns : unknown
Async createPaymentIntent
createPaymentIntent(dto: CreatePaymentIntentDto)

Create Payment Intent or Setup Intent (for embedded card form) Called BEFORE account creation during sign-up flow For $0 amounts (trial packages), uses Setup Intent to collect payment method without charging For paid amounts, uses Payment Intent to charge immediately

Parameters :
Name Type Optional
dto CreatePaymentIntentDto No
Private dataForSubscriptionRowSupersededByPaidChange
dataForSubscriptionRowSupersededByPaidChange()
Returns : { is_current: boolean; status: any; end_date: any; stripe_subscription_id: any; stripe_cancel_at_period_end: boolean; }
Private getBillingCycleMonths
getBillingCycleMonths(billingCycle: string | null | undefined)
Parameters :
Name Type Optional
billingCycle string | null | undefined No
Returns : number
Async getInvoiceStatus
getInvoiceStatus(invoiceId: number)

Get invoice payment status

Parameters :
Name Type Optional
invoiceId number No
Returns : unknown
Async handleCheckoutSessionCompleted
handleCheckoutSessionCompleted(session: Stripe.Checkout.Session)

Handle successful checkout session

Parameters :
Name Type Optional
session Stripe.Checkout.Session No
Returns : any
Async handlePaymentFailed
handlePaymentFailed(paymentIntent: Stripe.PaymentIntent)

Handle failed payment

Parameters :
Name Type Optional
paymentIntent Stripe.PaymentIntent No
Returns : any
Async handlePaymentSucceeded
handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent)

Handle successful payment

Parameters :
Name Type Optional
paymentIntent Stripe.PaymentIntent No
Returns : any
Private isTrialLikeLocalPackage
isTrialLikeLocalPackage(pkg: literal type)
Parameters :
Name Type Optional
pkg literal type No
Returns : boolean
Private Async revertProvisionalSubscriptionAfterCheckoutFailed
revertProvisionalSubscriptionAfterCheckoutFailed(invoice: literal type, logContext: string)

Portal first-time paid checkout creates a provisional local row (ACTIVE, is_current) with no Subscription.next_billing_date until payment succeeds. If checkout fails, is abandoned, or times out, the invoice is marked FAILED — cancel that placeholder so the UI does not show an “active” plan with an empty renewal date.

Parameters :
Name Type Optional
invoice literal type No
logContext string No
Returns : Promise<void>
Private Async trySupersedeTrialWithStartupSubscriptionRow
trySupersedeTrialWithStartupSubscriptionRow(params: literal type)

First paid period after Stripe trialing ({@code billing_reason=subscription_cycle}): end the trial row as non-current (history only — do not rewrite its plan/dates/type) and create a new STARTUP current row linked to the same Stripe subscription. Skips the generic renewal rotation (would add a third row).

Parameters :
Name Type Optional
params literal type No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripePaymentCheckoutService.name)
import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
  BillingCycle,
  InvoiceStatus,
  InvoiceType,
  PackageType,
  SubscriptionPlan,
  SubscriptionStatus,
  SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import {
  calculateNextBillingDateFromAnchor,
  calculateTrialNextBillingDate,
  getBillingCycleMultiplier,
} from "../../../../../config/billing-cycle";
import { InvoicePdfService } from "../../../invoice/invoice-pdf.service";
import { CreateCheckoutSessionDto, CreatePaymentIntentDto, PaymentIntentResponseDto } from "../../dto/payment.dto";
import { localStripeCancelAtPeriodEndFromSubscription, StripeService } from "../stripe.service";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripePaymentInvoiceWebhookService } from "./stripe-payment-invoice-webhook.service";
import { StripePaymentSubscriptionWriterService } from "./stripe-payment-subscription-writer.service";

type StripeSubscriptionApi = Stripe.Subscription & {
  current_period_start?: number;
  current_period_end?: number;
};

@Injectable()
export class StripePaymentCheckoutService {
  private readonly logger = new Logger(StripePaymentCheckoutService.name);

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly stripeService: StripeService,
    private readonly billing: StripePaymentBillingHelpersService,
    private readonly invoiceWebhooks: StripePaymentInvoiceWebhookService,
    private readonly subscriptionWriter: StripePaymentSubscriptionWriterService,
    private readonly invoicePdfService: InvoicePdfService,
  ) {}

  private dataForSubscriptionRowSupersededByPaidChange() {
    return {
      is_current: false,
      status: SubscriptionStatus.CANCELLED,
      end_date: new Date(),
      stripe_subscription_id: null,
      stripe_cancel_at_period_end: false,
    };
  }

  /**
   * Portal first-time paid checkout creates a provisional local row (ACTIVE, is_current) with no
   * {@link Subscription.next_billing_date} until payment succeeds. If checkout fails, is abandoned, or
   * times out, the invoice is marked FAILED — cancel that placeholder so the UI does not show an
   * “active” plan with an empty renewal date.
   */
  private async revertProvisionalSubscriptionAfterCheckoutFailed(
    invoice: { id: number; invoice_type: InvoiceType; subscription_id: number },
    logContext: string,
  ): Promise<void> {
    if (invoice.invoice_type !== InvoiceType.INITIAL_SUBSCRIPTION) {
      return;
    }
    const sub = await this.prisma.client.subscription.findUnique({
      where: { id: invoice.subscription_id },
    });
    if (!sub?.is_current || sub.status !== SubscriptionStatus.ACTIVE) {
      return;
    }
    if (sub.next_billing_date != null) {
      return;
    }
    if (sub.stripe_subscription_id) {
      return;
    }
    await this.prisma.client.subscription.update({
      where: { id: sub.id },
      data: this.dataForSubscriptionRowSupersededByPaidChange(),
    });
    this.logger.log(
      `Reverted provisional subscription ${sub.id} after failed/abandoned checkout (invoice ${invoice.id}): ${logContext}`,
    );
  }

  /**
   * Create checkout session for participant payment
   */
  async createCheckoutSession(dto: CreateCheckoutSessionDto) {
    this.logger.log(`Creating checkout session for company ${dto.company_id}, invoice ${dto.invoice_id}`);

    // Verify company exists
    const company = await this.prisma.client.company.findUnique({
      where: { id: dto.company_id },
    });

    if (!company) {
      throw new NotFoundException("Company not found");
    }

    // Verify invoice exists
    const invoice = await this.prisma.client.invoice.findUnique({
      where: { id: dto.invoice_id },
    });

    if (!invoice) {
      throw new NotFoundException("Invoice not found");
    }

    if (invoice.status === InvoiceStatus.PAID) {
      throw new BadRequestException("Invoice is already paid");
    }

    // Generate success and cancel URLs
    const FRONTEND_URL_ENV_KEY = "FRONTEND_URL" as const;
    const baseUrl = dto.success_url || process.env[FRONTEND_URL_ENV_KEY];
    const successUrl = `${baseUrl}/payment/success?session_id={CHECKOUT_SESSION_ID}`;
    const cancelUrl = `${baseUrl}/payment/cancelled`;

    // Create checkout session
    const session = await this.stripeService.createCheckoutSession({
      amount: dto.amount,
      currency: dto.currency || "usd",
      successUrl,
      cancelUrl,
      customerEmail: dto.customer_email,
      metadata: {
        companyId: dto.company_id.toString(),
        invoiceId: dto.invoice_id.toString(),
        participantId: dto.participant_id || "",
      },
    });

    if (!session.url) {
      throw new Error("Stripe did not return a checkout session URL");
    }

    // Update invoice with session ID
    await this.prisma.client.invoice.update({
      where: { id: dto.invoice_id },
      data: {
        stripe_checkout_session_id: session.id,
      },
    });

    return {
      session_id: session.id,
      session_url: session.url,
      publishable_key: this.stripeService.getPublishableKey(),
    };
  }

  /**
   * Create Payment Intent or Setup Intent (for embedded card form)
   * Called BEFORE account creation during sign-up flow
   * For $0 amounts (trial packages), uses Setup Intent to collect payment method without charging
   * For paid amounts, uses Payment Intent to charge immediately
   */
  async createPaymentIntent(dto: CreatePaymentIntentDto): Promise<PaymentIntentResponseDto> {
    // Convert amount to number and round to integer (cents)
    // Allow 0 for trial packages
    const amount = Math.round(Number(dto.amount ?? 0));

    // Log payment intent creation details
    this.logger.log(
      `Creating payment intent - amount: ${amount} cents (${(amount / 100).toFixed(2)} USD), customer: ${dto.customer_email}`,
    );

    // Only reject clearly invalid amounts (negative or NaN after conversion)
    if (amount < 0 || Number.isNaN(amount)) {
      this.logger.error(`Invalid amount received: ${dto.amount} (converted to: ${amount})`);
      throw new BadRequestException("amount must be a non-negative number (>= 0)");
    }

    // Create customer with Stripe
    // Note: company and invoice don't exist yet - they're created AFTER payment/setup succeeds
    const customer = await this.stripeService.createCustomer({
      email: dto.customer_email,
      name: dto.customer_name,
      metadata: {
        context: "signup",
        pending_company_email: dto.customer_email || "",
      },
    });

    const metadata = {
      customer_email: dto.customer_email || "",
      customer_name: dto.customer_name || "",
      signup_flow: "recallassess",
      environment: process.env["NODE_ENV"] || "development",
      timestamp: new Date().toISOString(),
    };

    // For $0 amounts (trial packages), use Setup Intent to collect payment method
    if (amount === 0) {
      const setupIntent = await this.stripeService.createSetupIntent({
        customerId: customer.id,
        metadata,
      });

      this.logger.log(`Setup intent created: ${setupIntent.id} for ${dto.customer_email} (trial package)`);

      if (!setupIntent.client_secret) {
        throw new Error("Stripe did not return a client secret for the setup intent");
      }

      return {
        client_secret: setupIntent.client_secret,
        setup_intent_id: setupIntent.id,
        publishable_key: this.stripeService.getPublishableKey(),
        stripe_customer_id: customer.id,
        is_setup_intent: true,
      };
    }

    // For paid amounts, use Payment Intent to charge immediately
    const paymentIntent = await this.stripeService.createPaymentIntent({
      amount: amount,
      currency: dto.currency || "usd",
      metadata,
      customerId: customer.id,
      automaticPaymentMethods: {
        enabled: true,
      },
    });

    this.logger.log(`Payment intent created: ${paymentIntent.id} for ${dto.customer_email}`);

    if (!paymentIntent.client_secret) {
      throw new Error("Stripe did not return a client secret for the payment intent");
    }

    return {
      client_secret: paymentIntent.client_secret,
      payment_intent_id: paymentIntent.id,
      publishable_key: this.stripeService.getPublishableKey(),
      stripe_customer_id: customer.id,
      is_setup_intent: false,
    };
  }

  /**
   * Get invoice payment status
   */
  async getInvoiceStatus(invoiceId: number) {
    const invoice = await this.prisma.client.invoice.findUnique({
      where: { id: invoiceId },
    });

    if (!invoice) {
      throw new NotFoundException("Invoice not found");
    }

    // If invoice is pending, check various Stripe statuses
    if (invoice.status === "PENDING") {
      // First, check if there's a payment intent and if it still exists in Stripe
      if (invoice.stripe_payment_intent_id) {
        try {
          this.logger.log(`Checking payment intent ${invoice.stripe_payment_intent_id} for invoice ${invoiceId}`);
          const paymentIntent = await this.stripeService.retrievePaymentIntent(invoice.stripe_payment_intent_id);
          this.logger.log(`Payment intent status: ${paymentIntent.status}`);

          // If payment intent is succeeded, it should have been processed by webhook
          // If it's failed or canceled, mark invoice as failed
          if (paymentIntent.status === "succeeded") {
            // Payment succeeded - this should have been caught by webhook
            // But if invoice is still pending, something went wrong
            this.logger.warn(`Payment intent succeeded but invoice ${invoiceId} still pending - forcing update`);
            await this.prisma.client.invoice.update({
              where: { id: invoiceId },
              data: {
                status: "PAID",
                paid_date: new Date(),
              },
            });

            const total =
              typeof invoice.total_amount === "number" ? invoice.total_amount : Number(invoice.total_amount);
            const invNo = invoice.invoice_number ?? String(invoiceId);
            await this.billing.sendCompanyTemplatedEmail({
              companyId: invoice.company_id,
              templateKey: "billing.payment.succeeded",
              dedupeKey: `billing.payment.succeeded:local_pi_repair:${invoice.stripe_payment_intent_id ?? invoiceId}`,
              triggeredBy: "getInvoiceStatus_payment_intent_repair",
              variables: {
                "invoice.number": invNo,
                "invoice.amount": this.billing.formatUsd(Number.isFinite(total) ? total : 0),
                "subscription.next_billing_date": "",
              },
              metadata: {
                invoice_id: invoice.id,
                stripe_payment_intent_id: invoice.stripe_payment_intent_id ?? undefined,
              },
            });

            return {
              invoice_id: invoice.id,
              invoice_number: invoice.invoice_number,
              status: "Paid",
              amount: invoice.total_amount,
              paid_date: new Date(),
              stripe_payment_intent_id: invoice.stripe_payment_intent_id,
              stripe_charge_id: invoice.stripe_charge_id,
              failure_code: invoice.failure_code,
              failure_message: invoice.failure_message,
              synced_from_stripe: true,
            };
          } else if (paymentIntent.status === "canceled" || paymentIntent.status === "requires_payment_method") {
            // Payment was canceled or failed - mark as failed
            this.logger.log(`Payment intent ${paymentIntent.status} - marking invoice as failed`);
            await this.prisma.client.invoice.update({
              where: { id: invoiceId },
              data: {
                status: "FAILED",
                failure_code: paymentIntent.last_payment_error?.code || "payment_cancelled",
                failure_message: paymentIntent.last_payment_error?.message || `Payment ${paymentIntent.status}`,
              },
            });
            await this.revertProvisionalSubscriptionAfterCheckoutFailed(
              {
                id: invoice.id,
                invoice_type: invoice.invoice_type,
                subscription_id: invoice.subscription_id,
              },
              `payment_intent ${paymentIntent.status}`,
            );

            return {
              invoice_id: invoice.id,
              invoice_number: invoice.invoice_number,
              status: "Failed",
              amount: invoice.total_amount,
              paid_date: invoice.paid_date,
              stripe_payment_intent_id: invoice.stripe_payment_intent_id,
              stripe_charge_id: invoice.stripe_charge_id,
              failure_code: paymentIntent.last_payment_error?.code || "payment_cancelled",
              failure_message: paymentIntent.last_payment_error?.message || `Payment ${paymentIntent.status}`,
              synced_from_stripe: true,
            };
          }
        } catch (error) {
          // Payment intent doesn't exist in Stripe - mark as failed
          this.logger.warn(
            `Payment intent ${invoice.stripe_payment_intent_id} not found in Stripe for invoice ${invoiceId}: ${error instanceof Error ? error.message : String(error)}`,
          );
          await this.prisma.client.invoice.update({
            where: { id: invoiceId },
            data: {
              status: "FAILED",
              failure_code: "payment_intent_not_found",
              failure_message: "Payment intent not found in Stripe - payment may have failed",
            },
          });
          await this.revertProvisionalSubscriptionAfterCheckoutFailed(
            {
              id: invoice.id,
              invoice_type: invoice.invoice_type,
              subscription_id: invoice.subscription_id,
            },
            "payment_intent_not_found",
          );

          return {
            invoice_id: invoice.id,
            invoice_number: invoice.invoice_number,
            status: "Failed",
            amount: invoice.total_amount,
            paid_date: invoice.paid_date,
            stripe_payment_intent_id: invoice.stripe_payment_intent_id,
            stripe_charge_id: invoice.stripe_charge_id,
            failure_code: "payment_intent_not_found",
            failure_message: "Payment intent not found in Stripe - payment may have failed",
            synced_from_stripe: true,
          };
        }
      }

      // If no payment intent ID, check checkout session
      if (invoice.stripe_checkout_session_id) {
        try {
          this.logger.log(
            `Checking Stripe payment status for pending invoice ${invoiceId}, session: ${invoice.stripe_checkout_session_id}`,
          );
          const session = await this.stripeService.retrieveCheckoutSession(invoice.stripe_checkout_session_id);

          // If payment is completed in Stripe but invoice is still pending, process it
          if (session.payment_status === "paid" && invoice.status === "PENDING") {
            this.logger.log(`Payment completed in Stripe but invoice is pending. Processing checkout session...`);
            await this.handleCheckoutSessionCompleted(session);

            // Reload invoice to get updated status
            const updatedInvoice = await this.prisma.client.invoice.findUnique({
              where: { id: invoiceId },
            });

            if (updatedInvoice) {
              return {
                invoice_id: updatedInvoice.id,
                invoice_number: updatedInvoice.invoice_number,
                status: updatedInvoice.status,
                amount: updatedInvoice.total_amount,
                paid_date: updatedInvoice.paid_date,
                stripe_payment_intent_id: updatedInvoice.stripe_payment_intent_id,
                stripe_charge_id: updatedInvoice.stripe_charge_id,
                failure_code: updatedInvoice.failure_code,
                failure_message: updatedInvoice.failure_message,
                synced_from_stripe: true,
              };
            }
          } else if (session.payment_status === "unpaid") {
            // Checkout session exists but payment not completed - mark as failed
            this.logger.log(`Checkout session unpaid - marking invoice as failed`);
            await this.prisma.client.invoice.update({
              where: { id: invoiceId },
              data: {
                status: "FAILED",
                failure_code: "checkout_unpaid",
                failure_message: "Checkout session was not completed",
              },
            });
            await this.revertProvisionalSubscriptionAfterCheckoutFailed(
              {
                id: invoice.id,
                invoice_type: invoice.invoice_type,
                subscription_id: invoice.subscription_id,
              },
              "checkout session unpaid",
            );

            return {
              invoice_id: invoice.id,
              invoice_number: invoice.invoice_number,
              status: "Failed",
              amount: invoice.total_amount,
              paid_date: invoice.paid_date,
              stripe_payment_intent_id: invoice.stripe_payment_intent_id,
              stripe_charge_id: invoice.stripe_charge_id,
              failure_code: "checkout_unpaid",
              failure_message: "Checkout session was not completed",
              synced_from_stripe: true,
            };
          }
        } catch (error) {
          this.logger.warn(
            `Failed to check Stripe payment status for invoice ${invoiceId}: ${error instanceof Error ? error.message : String(error)}`,
          );
          // Continue to return database status if Stripe check fails
        }
      }

      // If invoice has been pending for more than 24 hours, mark as failed
      const createdAt = invoice.created_at;
      const hoursPending = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60);

      if (hoursPending > 24) {
        this.logger.log(
          `Invoice ${invoiceId} has been pending for ${hoursPending.toFixed(1)} hours - marking as failed`,
        );
        await this.prisma.client.invoice.update({
          where: { id: invoiceId },
          data: {
            status: "FAILED",
            failure_code: "payment_timeout",
            failure_message: `Payment not completed within ${hoursPending.toFixed(1)} hours`,
          },
        });
        await this.revertProvisionalSubscriptionAfterCheckoutFailed(
          {
            id: invoice.id,
            invoice_type: invoice.invoice_type,
            subscription_id: invoice.subscription_id,
          },
          "payment_timeout",
        );

        return {
          invoice_id: invoice.id,
          invoice_number: invoice.invoice_number,
          status: "Failed",
          amount: invoice.total_amount,
          paid_date: invoice.paid_date,
          stripe_payment_intent_id: invoice.stripe_payment_intent_id,
          stripe_charge_id: invoice.stripe_charge_id,
          failure_code: "payment_timeout",
          failure_message: `Payment not completed within ${hoursPending.toFixed(1)} hours`,
          synced_from_stripe: true,
        };
      }
    }

    // Normalize status for frontend (PAID -> Paid, FAILED -> Failed, PENDING -> Pending)
    let normalizedStatus: string;
    switch (invoice.status) {
      case "PAID":
        normalizedStatus = "Paid";
        break;
      case "FAILED":
        normalizedStatus = "Failed";
        break;
      case "PENDING":
      case "CANCELLED":
      default:
        normalizedStatus = "Pending";
        break;
    }

    return {
      invoice_id: invoice.id,
      invoice_number: invoice.invoice_number,
      status: normalizedStatus,
      amount: invoice.total_amount,
      paid_date: invoice.paid_date,
      stripe_payment_intent_id: invoice.stripe_payment_intent_id,
      stripe_charge_id: invoice.stripe_charge_id,
      failure_code: invoice.failure_code,
      failure_message: invoice.failure_message,
    };
  }

  /**
   * Handle successful checkout session
   */
  async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
    this.logger.log(`=== CHECKOUT SESSION COMPLETED ===`);
    this.logger.log(`Session ID: ${session.id}`);
    this.logger.log(`Payment Status: ${session.payment_status}`);
    this.logger.log(
      `Payment Intent: ${typeof session.payment_intent === "string" ? session.payment_intent : (session.payment_intent?.id ?? "N/A")}`,
    );
    this.logger.log(`Session metadata: ${JSON.stringify(session.metadata)}`);
    this.logger.log(`Session mode: ${session.mode}`);
    this.logger.log(`Customer ID: ${session.customer}`);

    const metadata = (session.metadata ?? {}) as Stripe.Metadata & {
      invoiceId?: string;
      invoice_id?: string;
      company_id?: string;
      package_id?: string;
      license_count?: string;
      invoice_type?: string;
      billing_cycle?: string;
      should_update_existing?: string; // "true" = update existing subscription, "false" = create new
      is_billing_cycle_change?: string; // "true" = billing cycle changed
    };

    const invoiceIdRaw = metadata.invoiceId ?? metadata.invoice_id;
    const invoiceId = invoiceIdRaw ? parseInt(invoiceIdRaw, 10) : NaN;

    this.logger.log(`Invoice ID from metadata: ${invoiceIdRaw} -> parsed: ${invoiceId}`);

    if (!invoiceId || isNaN(invoiceId)) {
      this.logger.error(`No valid invoice ID in session metadata. Raw value: ${invoiceIdRaw}`);
      this.logger.error(`Full metadata: ${JSON.stringify(metadata)}`);
      return;
    }

    this.logger.log(`Processing invoice ${invoiceId} from checkout session`);

    const invoice = await this.prisma.client.invoice.findUnique({
      where: { id: invoiceId },
      include: {
        company: true,
        subscription: {
          include: {
            package: true,
          },
        },
      },
    });

    if (!invoice) {
      this.logger.error(`Invoice ${invoiceId} not found in database`);
      return;
    }

    this.logger.log(
      `Invoice found: ID=${invoice.id}, Status=${invoice.status}, SubscriptionID=${invoice.subscription_id}`,
    );

    const paymentOk = session.payment_status === "paid" || session.payment_status === "no_payment_required";
    if (!paymentOk) {
      this.logger.warn(
        `Checkout session ${session.id} not paid (payment_status=${session.payment_status}); skipping subscription/invoice paid updates`,
      );
      if (invoice.status === InvoiceStatus.PENDING) {
        await this.prisma.client.invoice.update({
          where: { id: invoiceId },
          data: {
            status: InvoiceStatus.FAILED,
            failure_code: `checkout_${session.payment_status ?? "unpaid"}`,
            failure_message: `Checkout completed without successful payment (payment_status=${session.payment_status ?? "unknown"})`,
          },
        });
        await this.revertProvisionalSubscriptionAfterCheckoutFailed(
          {
            id: invoice.id,
            invoice_type: invoice.invoice_type,
            subscription_id: invoice.subscription_id,
          },
          `checkout.session.completed payment_status=${session.payment_status}`,
        );
      }
      return;
    }

    if (session.customer && session.payment_intent) {
      try {
        const customerId = typeof session.customer === "string" ? session.customer : session.customer.id;
        const paymentIntentIdForPm =
          typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent.id;

        const paymentIntent = await this.stripeService.retrievePaymentIntent(paymentIntentIdForPm, [
          "payment_method",
        ]);

        if (paymentIntent.payment_method) {
          const paymentMethodId =
            typeof paymentIntent.payment_method === "string"
              ? paymentIntent.payment_method
              : paymentIntent.payment_method.id;

          await this.stripeService.setCustomerDefaultPaymentMethod(customerId, paymentMethodId);

          this.logger.log(`Payment method ${paymentMethodId} set as default for customer ${customerId}`);
        }
      } catch (pmError) {
        this.logger.warn(
          `Failed to set payment method as default: ${pmError instanceof Error ? pmError.message : String(pmError)}`,
        );
      }
    }

    // Update invoice status
    const paymentIntentId =
      typeof session.payment_intent === "string" ? session.payment_intent : (session.payment_intent?.id ?? null);

    this.logger.log(`Payment Intent ID: ${paymentIntentId}`);

    let checkoutStripeChargeId: string | null = null;
    if (paymentIntentId && this.stripeService.isConfigured()) {
      try {
        const piForCharge = await this.stripeService.retrievePaymentIntent(paymentIntentId, []);
        const lc = piForCharge.latest_charge;
        checkoutStripeChargeId = typeof lc === "string" ? lc : (lc?.id ?? null);
      } catch (e) {
        this.logger.warn(
          `Could not resolve Stripe charge id for checkout payment intent ${paymentIntentId}: ${
            e instanceof Error ? e.message : String(e)
          }`,
        );
      }
    }
    const checkoutPaidInvoiceExtras =
      checkoutStripeChargeId !== null ? { stripe_charge_id: checkoutStripeChargeId } : {};

    // Check if this is a subscription change (has package_id and license_count in metadata)
    const isSubscriptionChange = !!(metadata.package_id && metadata.license_count);

    this.logger.log(`=== SUBSCRIPTION CHANGE CHECK ===`);
    this.logger.log(`Is subscription change: ${isSubscriptionChange}`);
    this.logger.log(`Package ID from metadata: ${metadata.package_id}`);
    this.logger.log(`License count from metadata: ${metadata.license_count}`);
    this.logger.log(`Company ID from metadata: ${metadata.company_id}`);

    if (isSubscriptionChange) {
      // Process subscription change - create new subscription and update old one
      const companyId = parseInt(metadata.company_id ?? invoice.company_id.toString(), 10);
      const packageIdRaw = metadata.package_id;
      const licenseCountRaw = metadata.license_count;

      if (!packageIdRaw || !licenseCountRaw) {
        this.logger.error("Missing package_id or license_count in subscription change metadata");
        // Fall through to regular invoice update
      } else {
        const packageId = parseInt(packageIdRaw, 10);
        const licenseCount = parseInt(licenseCountRaw, 10);

        if (isNaN(packageId) || isNaN(licenseCount)) {
          this.logger.error(
            `Invalid package_id or license_count: package_id=${packageIdRaw}, license_count=${licenseCountRaw}`,
          );
          // Fall through to regular invoice update
        } else {
          // Get current subscription
          const currentSubscription = await this.prisma.client.subscription.findFirst({
            where: {
              company_id: companyId,
              is_current: true,
            },
            include: {
              package: true,
            },
          });

          const licensesConsumed = currentSubscription?.licenses_consumed ?? 0;
          const licensesAvailable = Math.max(0, licenseCount - licensesConsumed);

          // Get the invoice's current subscription
          const invoiceSubscription = invoice.subscription;

          // Check invoice_type from metadata to determine if this is an upgrade/change
          // If invoice_type is UPGRADE or DOWNGRADE, always create new subscription
          const invoiceType = metadata.invoice_type;
          const isUpgradeOrDowngrade = invoiceType === "UPGRADE" || invoiceType === "DOWNGRADE";

          // Check if this is an initial subscription
          // It's initial if: no current subscription exists AND it's not explicitly an upgrade/downgrade
          const isInitialSubscription = !currentSubscription && !isUpgradeOrDowngrade;

          this.logger.log(
            `Invoice type: ${invoiceType}, isUpgradeOrDowngrade: ${isUpgradeOrDowngrade}, isInitialSubscription: ${isInitialSubscription}`,
          );
          this.logger.log(
            `Current subscription exists: ${!!currentSubscription}, Current subscription ID: ${currentSubscription?.id}, Invoice subscription ID: ${invoiceSubscription?.id}`,
          );

          if (isInitialSubscription) {
            // Get package to check if it's a trial package
            const packageData = await this.prisma.client.package.findUnique({
              where: { id: packageId },
            });

            if (!packageData) {
              this.logger.error(`Package ${packageId} not found for initial subscription`);
              // Fall through to regular invoice update
            } else {
              // Calculate next billing date
              // Trial: trial end from package; billing_cycle = quarterly (post-trial cadence).
              let nextBillingDate: Date;
              let subscriptionBillingCycle: BillingCycle | null = null;
              const isTrialPackage =
                packageData.is_trial_package ||
                packageData.package_type === "FREE_TRIAL" ||
                packageData.package_type === "PRIVATE_VIP_TRIAL";

              if (isTrialPackage) {
                nextBillingDate = calculateTrialNextBillingDate(packageData.trial_duration_days);
                subscriptionBillingCycle = BillingCycle.QUARTERLY;
                this.logger.log(
                  `Initial trial subscription: package_type=${packageData.package_type}, trial_duration_days=${packageData.trial_duration_days}, next_billing_date=${nextBillingDate.toISOString()}, billing_cycle=${subscriptionBillingCycle}`,
                );
              } else {
                // For non-trial packages, use billing cycle
                // Handle empty string from metadata (converted from null)
                const billingCycleFromMetadata =
                  metadata.billing_cycle && metadata.billing_cycle !== "" ? metadata.billing_cycle : null;
                const billingCycle = billingCycleFromMetadata || invoiceSubscription.billing_cycle || "QUARTERLY";
                nextBillingDate = calculateNextBillingDateFromAnchor(billingCycle);
                subscriptionBillingCycle = billingCycle as any;
                this.logger.log(
                  `Initial subscription: using billing cycle ${billingCycle}, next_billing_date=${nextBillingDate.toISOString()}`,
                );
              }

              // Build update data
              const updateData: any = {
                package_id: packageId,
                license_count: licenseCount,
                licenses_available: licensesAvailable,
                licenses_consumed: licensesConsumed,
                status: "ACTIVE",
                is_current: true,
                subscription_type: "INITIAL",
                next_billing_date: nextBillingDate,
              };

              if (subscriptionBillingCycle) {
                updateData.billing_cycle = subscriptionBillingCycle;
              }

              // For initial subscriptions, update the existing subscription (created before payment)
              await this.prisma.client.subscription.update({
                where: { id: invoiceSubscription.id },
                data: updateData,
              });

              // Update invoice with payment info
              await this.prisma.client.invoice.update({
                where: { id: invoiceId },
                data: {
                  status: InvoiceStatus.PAID,
                  stripe_payment_intent_id: paymentIntentId,
                  stripe_checkout_session_id: session.id,
                  paid_date: new Date(),
                  ...checkoutPaidInvoiceExtras,
                },
              });

              this.logger.log(
                `Initial subscription completed: companyId=${companyId}, invoiceId=${invoiceId}, subscriptionId=${invoiceSubscription.id}, next_billing_date=${nextBillingDate.toISOString()}`,
              );

              await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, invoiceSubscription.id, {
                metadataSource: "webhook_checkout",
              });
              await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
              return; // Exit early after processing initial subscription
            }
          } else {
            // For subscription changes (UPGRADE/DOWNGRADE), we must have a current subscription
            if (!currentSubscription) {
              this.logger.error(
                `Cannot process upgrade/downgrade without existing subscription. Company ID: ${companyId}`,
              );
              // Fall through to regular invoice update
            } else {
              // Portal confirm flow always sends should_update_existing "false" so paid changes get a new local row
              // and syncStripeSubscriptionForLocalSubscription replaces Stripe subscription(s) at the new amount.
              const shouldUpdateExisting = metadata.should_update_existing === "true";
              const isBillingCycleChange = metadata.is_billing_cycle_change === "true";
              const billingCycle = metadata.billing_cycle || currentSubscription?.billing_cycle || "QUARTERLY";
              const isLicenseIncreaseOnly =
                currentSubscription.package_id === packageId &&
                (currentSubscription.billing_cycle || "QUARTERLY") === (billingCycle || "QUARTERLY") &&
                currentSubscription.license_count < licenseCount;

              // Calculate next billing date based on billing cycle
              // For billing cycle changes, calculate from existing next_billing_date, not payment date
              const baseDateForNextBilling =
                isBillingCycleChange && currentSubscription?.next_billing_date
                  ? currentSubscription.next_billing_date
                  : undefined; // Use now for plan changes
              const nextBillingDate = calculateNextBillingDateFromAnchor(billingCycle, baseDateForNextBilling);

              if (shouldUpdateExisting) {
                // Legacy / manual metadata only: update one local row in place (portal does not use this).
                // License increase only (same plan + same billing cycle): UPDATE existing subscription
                // This is an amendment to the current subscription period
                await this.prisma.client.subscription.update({
                  where: { id: currentSubscription.id },
                  data: {
                    license_count: licenseCount,
                    licenses_available: licensesAvailable,
                    next_billing_date: nextBillingDate,
                    // billing_cycle stays the same (not changing)
                  },
                });

                // Update invoice with payment info
                await this.prisma.client.invoice.update({
                  where: { id: invoiceId },
                  data: {
                    status: InvoiceStatus.PAID,
                    stripe_payment_intent_id: paymentIntentId,
                    stripe_checkout_session_id: session.id,
                    paid_date: new Date(),
                    subscription_id: currentSubscription.id,
                    ...checkoutPaidInvoiceExtras,
                  },
                });

                this.logger.log(`=== LICENSE INCREASE UPDATE COMPLETED ===`);
                this.logger.log(`Company ID: ${companyId}`);
                this.logger.log(`Invoice ID: ${invoiceId}`);
                this.logger.log(`Subscription ID: ${currentSubscription.id} (updated)`);
                this.logger.log(`License count updated: ${currentSubscription.license_count} → ${licenseCount}`);
                this.logger.log(`Next billing date: ${nextBillingDate.toISOString()}`);

                await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, currentSubscription.id, {
                  metadataSource: "webhook_checkout",
                });
                await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
                return; // Exit early after processing subscription update
              } else {
                // Same-plan license bump, plan change, or billing-cycle change: new local row + Stripe replace via sync.
                await this.prisma.client.subscription.update({
                  where: { id: currentSubscription.id },
                  data: this.dataForSubscriptionRowSupersededByPaidChange(),
                });

                const subscriptionType = invoiceType === "DOWNGRADE" ? "DOWNGRADE" : "UPGRADE";

                const newSubscription = await this.prisma.client.subscription.create({
                  data: {
                    company_id: companyId,
                    package_id: packageId,
                    license_count: licenseCount,
                    licenses_available: licensesAvailable,
                    licenses_consumed: licensesConsumed,
                    status: "ACTIVE",
                    is_current: true,
                    subscription_type: subscriptionType,
                    billing_cycle: billingCycle as any,
                    start_date: new Date(),
                    end_date: nextBillingDate,
                    next_billing_date: nextBillingDate,
                    previous_subscription_id: currentSubscription.id,
                  },
                });

                // Update invoice with payment info and new subscription
                await this.prisma.client.invoice.update({
                  where: { id: invoiceId },
                  data: {
                    status: InvoiceStatus.PAID,
                    stripe_payment_intent_id: paymentIntentId,
                    stripe_checkout_session_id: session.id,
                    paid_date: new Date(),
                    subscription_id: newSubscription.id,
                    ...checkoutPaidInvoiceExtras,
                  },
                });

                this.logger.log(`=== NEW SUBSCRIPTION CREATED ===`);
                this.logger.log(`Company ID: ${companyId}`);
                this.logger.log(`Invoice ID: ${invoiceId}`);
                this.logger.log(`New Subscription ID: ${newSubscription.id}`);
                this.logger.log(`Old Subscription ID: ${currentSubscription.id}`);
                const changeTypeLabel = isLicenseIncreaseOnly
                  ? "License increase (same plan)"
                  : isBillingCycleChange
                    ? "Billing cycle change"
                    : "Plan / package change";
                this.logger.log(`Change type: ${changeTypeLabel}`);
                this.logger.log(`Old subscription is_current set to: false`);
                this.logger.log(`New subscription is_current set to: true`);
                this.logger.log(`Billing cycle: ${billingCycle}`);
                this.logger.log(`Next billing date: ${nextBillingDate.toISOString()}`);

                await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, newSubscription.id, {
                  metadataSource: "webhook_checkout",
                });
                await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
                return; // Exit early after processing subscription change
              }
            }
          }
          // If subscription change processing failed, fall through to regular invoice update
        }
      }
    }

    // Regular invoice payment (not subscription change or subscription change processing failed)
    this.logger.log(`=== PROCESSING REGULAR INVOICE PAYMENT ===`);
    this.logger.log(`Invoice ID: ${invoiceId}`);
    await this.prisma.client.invoice.update({
      where: { id: invoiceId },
      data: {
        status: InvoiceStatus.PAID,
        stripe_payment_intent_id: paymentIntentId,
        stripe_checkout_session_id: session.id,
        paid_date: new Date(),
        ...checkoutPaidInvoiceExtras,
      },
    });

    this.logger.log(`=== INVOICE ${invoiceId} MARKED AS PAID ===`);
    await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(invoice.company_id);
  }

  /**
   * Handle successful payment
   */
  async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
    this.logger.log(`=== PAYMENT SUCCEEDED PROCESSING ===`);
    this.logger.log(`Payment Intent ID: ${paymentIntent.id}`);
    this.logger.log(`Amount: ${paymentIntent.amount} cents ($${(paymentIntent.amount / 100).toFixed(2)})`);
    this.logger.log(`Status: ${paymentIntent.status}`);
    this.logger.log(`Metadata: ${JSON.stringify(paymentIntent.metadata)}`);
    this.logger.log(
      `[stripe-link-debug] handlePaymentSucceeded stripeConfigured=${this.stripeService.isConfigured()} invoiceField=${
        (paymentIntent as any)?.invoice ? "present" : "missing"
      }`,
    );

    // Check if this payment intent is from a checkout session
    let checkoutSessionMetadata = null;
    if ((paymentIntent as any).checkout_session) {
      try {
        this.logger.log(`Payment intent has checkout_session: ${(paymentIntent as any).checkout_session}`);
        const checkoutSession = await this.stripeService.retrieveCheckoutSession(
          (paymentIntent as any).checkout_session,
        );
        checkoutSessionMetadata = checkoutSession.metadata;
        this.logger.log(`Retrieved checkout session metadata: ${JSON.stringify(checkoutSessionMetadata)}`);
      } catch (error) {
        this.logger.warn(
          `Failed to retrieve checkout session: ${error instanceof Error ? error.message : String(error)}`,
        );
      }
    }

    // Find invoice by payment intent ID
    let invoice = await this.prisma.client.invoice.findFirst({
      where: { stripe_payment_intent_id: paymentIntent.id },
      include: { company: true },
    });

    this.logger.log(
      `Invoice lookup by payment_intent_id: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
    );

    // If not found by payment_intent_id, try to find by metadata (for subscription changes)
    if (!invoice && paymentIntent.metadata) {
      const metadata = paymentIntent.metadata as Stripe.Metadata & { invoice_id?: string };
      this.logger.log(`Checking payment intent metadata for invoice_id: ${metadata.invoice_id}`);
      if (metadata.invoice_id) {
        const invoiceId = parseInt(metadata.invoice_id, 10);
        this.logger.log(`Parsed invoice ID from payment intent: ${invoiceId}`);
        if (!isNaN(invoiceId)) {
          invoice = await this.prisma.client.invoice.findUnique({
            where: { id: invoiceId },
            include: { company: true },
          });
          this.logger.log(
            `Invoice lookup by payment intent metadata: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
          );
        }
      }
    }

    // If still not found, try checkout session metadata
    if (!invoice && checkoutSessionMetadata) {
      const metadata = checkoutSessionMetadata as { invoice_id?: string };
      this.logger.log(`Checking checkout session metadata for invoice_id: ${metadata.invoice_id}`);
      if (metadata.invoice_id) {
        const invoiceId = parseInt(metadata.invoice_id, 10);
        this.logger.log(`Parsed invoice ID from checkout session: ${invoiceId}`);
        if (!isNaN(invoiceId)) {
          invoice = await this.prisma.client.invoice.findUnique({
            where: { id: invoiceId },
            include: { company: true },
          });
          this.logger.log(
            `Invoice lookup by checkout session metadata: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
          );
        }
      }
    }

    if (!invoice) {
      this.logger.warn(`No invoice found for payment intent ${paymentIntent.id} - checking for recent invoices`);

      // If Stripe associates the payment intent with an invoice (common for subscription renewals),
      // fetch that Stripe invoice and run the normal invoice.paid reconciliation.
      // This avoids relying on our DB linking/pending-invoice heuristics.
      const piAny = paymentIntent as any;
      const piInvoiceRef = piAny.invoice ?? piAny.invoice_id ?? null;
      const stripeInvoiceId =
        typeof piInvoiceRef === "string"
          ? piInvoiceRef
          : piInvoiceRef && typeof piInvoiceRef === "object" && typeof piInvoiceRef.id === "string"
            ? piInvoiceRef.id
            : null;

      // Some webhook payloads do not include `invoice` on the PI object.
      // If we have Stripe credentials, re-fetch the PI with `invoice` expanded so we can reconcile.
      let stripeInvoiceIdResolved: string | null = stripeInvoiceId;
      if (!stripeInvoiceIdResolved && this.stripeService.isConfigured()) {
        try {
          const piExpanded = await this.stripeService.retrievePaymentIntent(paymentIntent.id, ["invoice"]);
          const piExpAny = piExpanded as any;
          const invRef = piExpAny.invoice ?? piExpAny.invoice_id ?? null;
          stripeInvoiceIdResolved =
            typeof invRef === "string"
              ? invRef
              : invRef && typeof invRef === "object" && typeof invRef.id === "string"
                ? invRef.id
                : null;
          if (stripeInvoiceIdResolved) {
            this.logger.warn(
              `[stripe-renewal-debug] handlePaymentSucceeded: resolved invoice by re-fetching PI ${paymentIntent.id} -> ${stripeInvoiceIdResolved}`,
            );
          } else {
            this.logger.warn(
              `[stripe-link-debug] handlePaymentSucceeded: re-fetched PI ${paymentIntent.id} but PI still has no invoice reference`,
            );
          }
        } catch (e) {
          this.logger.warn(
            `[stripe-renewal-debug] handlePaymentSucceeded: failed re-fetching PI ${paymentIntent.id} for invoice expansion: ${
              e instanceof Error ? e.message : String(e)
            }`,
          );
        }
      }
      if (!stripeInvoiceIdResolved && !this.stripeService.isConfigured()) {
        this.logger.warn(
          `[stripe-link-debug] handlePaymentSucceeded: Stripe not configured, cannot resolve invoice from payment_intent ${paymentIntent.id}`,
        );
      }

      if (stripeInvoiceIdResolved && this.stripeService.isConfigured()) {
        try {
          this.logger.warn(
            `[stripe-renewal-debug] handlePaymentSucceeded: resolving invoice from payment_intent.invoice ${stripeInvoiceIdResolved}`,
          );
          const stripeInv = await this.stripeService.getInvoiceExpanded(stripeInvoiceIdResolved);
          await this.invoiceWebhooks.handleInvoicePaid(stripeInv);
          return;
        } catch (e) {
          this.logger.warn(
            `[stripe-renewal-debug] handlePaymentSucceeded: failed resolving invoice from payment_intent.invoice ${stripeInvoiceIdResolved}: ${
              e instanceof Error ? e.message : String(e)
            }`,
          );
        }
      }

      // For signup payments, the invoice might not be linked yet due to race condition
      // Try to find invoices created recently for the same customer
      const metadata = paymentIntent.metadata as Stripe.Metadata & {
        customer_email?: string;
        signup_flow?: string;
      };
      if (metadata.customer_email && metadata.signup_flow === "recallassess") {
        const recentInvoices = await this.prisma.client.invoice.findMany({
          where: {
            company: {
              email: metadata.customer_email,
            },
            status: "PENDING",
            created_at: {
              gte: new Date(Date.now() - 5 * 60 * 1000), // Last 5 minutes
            },
          },
          include: { company: true },
          orderBy: { created_at: "desc" },
          take: 5,
        });

        this.logger.log(
          `Found ${recentInvoices.length} recent pending invoices for customer ${metadata.customer_email}`,
        );

        if (recentInvoices.length > 0) {
          // Use the most recent invoice
          invoice = recentInvoices[0];
          this.logger.log(`Using recent invoice ${invoice.id} (created ${invoice.created_at})`);

          // Update the invoice to link it to this payment intent
          await this.prisma.client.invoice.update({
            where: { id: invoice.id },
            data: { stripe_payment_intent_id: paymentIntent.id },
          });
          this.logger.log(`Linked payment intent ${paymentIntent.id} to invoice ${invoice.id}`);
        }
      }

      if (!invoice) {
        // For subscription renewals, Stripe's PI payload often has no invoice reference and no metadata.
        // In that case `invoice.paid` is the authoritative event and will reconcile correctly.
        // Keep as WARN to avoid noisy false-positive errors.
        this.logger.warn(`Still no invoice found for payment intent ${paymentIntent.id} (will rely on invoice.paid)`);
        this.logger.warn(`Payment metadata: ${JSON.stringify(paymentIntent.metadata)}`);
        this.logger.warn(
          `[stripe-link-debug] handlePaymentSucceeded: mapping failed (no local invoice row found via stripe_payment_intent_id/metadata/checkout).`,
        );
        return;
      }
    }

    if (invoice) {
      this.logger.log(`Found invoice ${invoice.id} with status ${invoice.status}`);
      this.logger.log(`Company: ${invoice.company?.name} (${invoice.company_id})`);

      const latestChargeId =
        typeof paymentIntent.latest_charge === "string"
          ? paymentIntent.latest_charge
          : (paymentIntent.latest_charge?.id ?? null);

      this.logger.log(`Updating invoice ${invoice.id} to PAID status`);
      this.logger.log(`Charge ID: ${latestChargeId}`);

      try {
        await this.prisma.client.invoice.update({
          where: { id: invoice.id },
          data: {
            status: InvoiceStatus.PAID,
            stripe_payment_intent_id: paymentIntent.id,
            stripe_charge_id: latestChargeId,
            paid_date: new Date(),
          },
        });
        this.logger.log(`✅ Invoice ${invoice.id} successfully updated to PAID`);
        this.invoicePdfService.scheduleWarmInvoicePdf(invoice.id);
      } catch (updateError) {
        this.logger.error(
          `❌ Failed to update invoice ${invoice.id}: ${updateError instanceof Error ? updateError.message : String(updateError)}`,
        );
        throw updateError;
      }

      // Check if this is a subscription change that needs processing
      const paymentMetadata = paymentIntent.metadata as Stripe.Metadata & {
        invoice_id?: string;
        company_id?: string;
        package_id?: string;
        license_count?: string;
        is_new_signup?: string;
      };

      const isSubscriptionChange = !!(paymentMetadata.package_id && paymentMetadata.license_count);

      // Portal plan changes are reconciled in checkout.session.completed; avoid duplicate local rows here.
      if (isSubscriptionChange && checkoutSessionMetadata) {
        this.logger.log(
          `Skipping subscription change in payment_intent.succeeded for invoice ${invoice.id}; checkout.session.completed owns portal upgrades.`,
        );
      } else if (isSubscriptionChange && invoice.status === InvoiceStatus.PENDING) {
        // This is a subscription change - process it
        const companyId = parseInt(paymentMetadata.company_id ?? invoice.company_id.toString(), 10);
        const packageIdRaw = paymentMetadata.package_id;
        const licenseCountRaw = paymentMetadata.license_count;

        if (!packageIdRaw || !licenseCountRaw) {
          this.logger.error("Missing package_id or license_count in payment intent metadata");
        } else {
          const packageId = parseInt(packageIdRaw, 10);
          const licenseCount = parseInt(licenseCountRaw, 10);

          if (!isNaN(packageId) && !isNaN(licenseCount)) {
            // Get current subscription
            const currentSubscription = await this.prisma.client.subscription.findFirst({
              where: {
                company_id: companyId,
                is_current: true,
              },
            });

            if (currentSubscription && currentSubscription.id !== invoice.subscription_id) {
              // Mark old subscription as not current
              await this.prisma.client.subscription.update({
                where: { id: currentSubscription.id },
                data: this.dataForSubscriptionRowSupersededByPaidChange(),
              });

              // Create new subscription
              const licensesConsumed = currentSubscription.licenses_consumed ?? 0;
              const licensesAvailable = Math.max(0, licenseCount - licensesConsumed);

              const newSubscription = await this.prisma.client.subscription.create({
                data: {
                  company_id: companyId,
                  package_id: packageId,
                  license_count: licenseCount,
                  licenses_available: licensesAvailable,
                  licenses_consumed: licensesConsumed,
                  status: "ACTIVE",
                  is_current: true,
                  subscription_type: "UPGRADE",
                  start_date: new Date(),
                  end_date: currentSubscription.next_billing_date ?? undefined,
                },
              });

              // Update invoice with new subscription
              await this.prisma.client.invoice.update({
                where: { id: invoice.id },
                data: {
                  subscription_id: newSubscription.id,
                },
              });

              this.logger.log(
                `Subscription change completed via payment_intent: invoiceId=${invoice.id}, newSubscriptionId=${newSubscription.id}`,
              );
            }
          }
        }
      }

      this.logger.log(
        `Invoice ${invoice.id} marked as PAID via payment_intent.succeeded. Subscription ${invoice.subscription_id} is active.`,
      );

      // Check if this is a new signup (no company exists yet)
      // For new signups, metadata will contain is_new_signup flag
      const isNewSignup = paymentMetadata["is_new_signup"] === "true";

      if (isNewSignup && !invoice.company_id) {
        this.logger.log("New signup detected - account creation will be handled by frontend after redirect");
        // Account creation will be handled by the frontend after payment success
        // The frontend will call POST /api/account/create-from-payment endpoint
      }

      if (invoice.company_id) {
        await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(invoice.company_id);
      }
    }
  }

  private getBillingCycleMonths(billingCycle: string | null | undefined): number {
    if (!billingCycle) {
      return 3;
    }
    const normalized = billingCycle.toUpperCase();
    if (normalized === "ANNUAL") return 12;
    if (normalized === "HALF_YEARLY") return 6;
    return 3;
  }

  private addMonthsSafe(date: Date, months: number): Date {
    const d = new Date(date.getTime());
    d.setMonth(d.getMonth() + months);
    return d;
  }


  private isTrialLikeLocalPackage(pkg: { is_trial_package: boolean; package_type: PackageType }): boolean {
    return (
      pkg.is_trial_package ||
      pkg.package_type === PackageType.FREE_TRIAL ||
      pkg.package_type === PackageType.PRIVATE_VIP_TRIAL
    );
  }

  /**
   * First paid period after Stripe trialing ({@code billing_reason=subscription_cycle}): end the trial row as
   * non-current (history only — do not rewrite its plan/dates/type) and create a new **STARTUP** current row
   * linked to the same Stripe subscription. Skips the generic renewal rotation (would add a third row).
   */
  private async trySupersedeTrialWithStartupSubscriptionRow(params: {
    companyId: number;
    oldSubscription: {
      id: number;
      license_count: number;
      billing_cycle: BillingCycle | null;
      next_billing_date: Date | null;
      licenses_available: number;
      licenses_consumed: number;
      licenses_expired: number;
      last_license_assignment: Date | null;
      last_license_release: Date | null;
      user_id_created_by: number | null;
      user_id_updated_by: number | null;
      package: { id: number; is_trial_package: boolean; package_type: PackageType };
    };
    stripeInvoice: Pick<
      Stripe.Invoice,
      "billing_reason" | "amount_paid" | "lines" | "period_start" | "period_end"
    >;
    stripeSub: Stripe.Subscription;
    stripeSubscriptionId: string;
  }): Promise<{ applied: boolean; newSubscriptionId: number | null }> {
    const pkg = params.oldSubscription.package;
    if (!this.isTrialLikeLocalPackage(pkg)) {
      return { applied: false, newSubscriptionId: null };
    }
    const paid =
      typeof params.stripeInvoice.amount_paid === "number" && params.stripeInvoice.amount_paid > 0;
    if (!paid) {
      return { applied: false, newSubscriptionId: null };
    }
    if (params.stripeInvoice.billing_reason !== "subscription_cycle") {
      return { applied: false, newSubscriptionId: null };
    }

    const startup = await this.prisma.client.package.findFirst({
      where: {
        package_type: PackageType.STARTUP,
        is_active: true,
        OR: [{ special_slug: null }, { special_slug: "" }],
      },
      select: { id: true },
    });
    if (!startup) {
      this.logger.error(
        `trySupersedeTrialWithStartupSubscriptionRow: no active STARTUP package (company ${params.companyId})`,
      );
      return { applied: false, newSubscriptionId: null };
    }

    if (pkg.package_type === PackageType.STARTUP || pkg.id === startup.id) {
      return { applied: false, newSubscriptionId: null };
    }

    const derived = this.billing.derivePeriodsFromStripeInvoice(params.stripeInvoice as Stripe.Invoice);
    let nextBilling: Date | null = derived.periodEnd;
    const subPeriod = params.stripeSub as StripeSubscriptionApi;
    const cpe = subPeriod.current_period_end;
    if (!nextBilling && typeof cpe === "number") {
      nextBilling = new Date(cpe * 1000);
    }
    if (!nextBilling) {
      nextBilling = params.oldSubscription.next_billing_date;
    }
    if (!nextBilling || Number.isNaN(nextBilling.getTime())) {
      this.logger.warn(
        `trySupersedeTrialWithStartupSubscriptionRow: could not resolve next_billing_date for company ${params.companyId} (old sub ${params.oldSubscription.id})`,
      );
      return { applied: false, newSubscriptionId: null };
    }

    const stripeCancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(params.stripeSub);
    const oldId = params.oldSubscription.id;

    const newId = await this.prisma.client.$transaction(async (tx) => {
      await tx.subscription.updateMany({
        where: { company_id: params.companyId, id: { not: oldId } },
        data: { is_current: false },
      });

      await tx.subscription.update({
        where: { id: oldId },
        data: {
          is_current: false,
          status: SubscriptionStatus.CANCELLED,
          end_date: new Date(),
          stripe_subscription_id: null,
        },
      });

      const created = await tx.subscription.create({
        data: {
          company_id: params.companyId,
          package_id: startup.id,
          previous_subscription_id: oldId,
          license_count: params.oldSubscription.license_count,
          licenses_available: params.oldSubscription.licenses_available,
          licenses_consumed: params.oldSubscription.licenses_consumed,
          licenses_expired: params.oldSubscription.licenses_expired,
          last_license_assignment: params.oldSubscription.last_license_assignment ?? undefined,
          last_license_release: params.oldSubscription.last_license_release ?? undefined,
          user_id_created_by: params.oldSubscription.user_id_created_by ?? undefined,
          user_id_updated_by: params.oldSubscription.user_id_updated_by ?? undefined,
          status: SubscriptionStatus.ACTIVE,
          billing_cycle: params.oldSubscription.billing_cycle ?? BillingCycle.QUARTERLY,
          start_date: new Date(),
          end_date: nextBilling,
          next_billing_date: nextBilling,
          stripe_subscription_id: params.stripeSubscriptionId,
          stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
          is_current: true,
          subscription_type: SubscriptionType.UPGRADE,
        },
      });

      await tx.company.update({
        where: { id: params.companyId },
        data: {
          plan: SubscriptionPlan.STARTUP,
          trial_start_date: null,
          trial_end_date: null,
        },
      });

      return created.id;
    });

    this.logger.log(
      `Trial graduation: company ${params.companyId} superseded subscription ${oldId} with new STARTUP row ${newId} (stripe billing_reason=subscription_cycle)`,
    );
    return { applied: true, newSubscriptionId: newId };
  }

  /**
   * Handle failed payment
   */
  async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
    this.logger.log(`Payment failed: ${paymentIntent.id}`);

    // Find invoice by payment intent ID
    const invoice = await this.prisma.client.invoice.findUnique({
      where: { stripe_payment_intent_id: paymentIntent.id },
    });

    if (invoice) {
      await this.prisma.client.invoice.update({
        where: { id: invoice.id },
        data: {
          status: InvoiceStatus.FAILED,
          failure_code: paymentIntent.last_payment_error?.code,
          failure_message: paymentIntent.last_payment_error?.message,
        },
      });

      this.logger.log(`Invoice ${invoice.id} marked as FAILED`);
    }
  }
}

results matching ""

    No results matching ""