File

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

Index

Properties
Methods

Constructor

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

Methods

Private Async buildImmediateChargeSnapshot
buildImmediateChargeSnapshot(companyId: number, recoveredLocalSubscriptionId: number)

Builds DB + Stripe status snapshot for the admin immediate-charge response.

Parameters :
Name Type Optional
companyId number No
recoveredLocalSubscriptionId number No
Private computeNextBillingAfterOnePaidPeriod
computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined)

Next period end after paying one full billing cycle from {@param previousAnchor}. Ensures the result is strictly in the future so Stripe trial_end is valid.

Parameters :
Name Type Optional
previousAnchor Date No
billingCycle string | null | undefined No
Returns : Date
Private Async mirrorPaidLatestInvoiceIfMissing
mirrorPaidLatestInvoiceIfMissing(stripeSubscriptionId: string)

When the first invoice is paid automatically (default PM), Stripe may return the subscription as active so syncStripeSubscriptionForLocalSubscription never enters the incomplete + pay path. Align local Invoice + subscription state as invoice.paid would. Same for trialing: sync skips the incomplete/pay branch; mirror here so handleInvoicePaid runs (and {@code billing.payment.succeeded} sends) when webhooks are delayed.

Parameters :
Name Type Optional
stripeSubscriptionId string No
Returns : Promise<void>
Async previewImmediateExpiredRecoveryCharge
previewImmediateExpiredRecoveryCharge(companyId: number, fromDate?: string)
Parameters :
Name Type Optional
companyId number No
fromDate string Yes
Async refreshPaidSubscriptionLinkedPackageFromCatalog
refreshPaidSubscriptionLinkedPackageFromCatalog(companyId: number)

Before portal recovery for expired companies: point the eligible paid subscription row at the active catalog Package for the same PackageType so Stripe sync uses current prices/Stripe price IDs. License count on the subscription row is unchanged.

Parameters :
Name Type Optional
companyId number No
Returns : Promise<void>
Private resolveRecoveryAnchorDate
resolveRecoveryAnchorDate(input?: string)
Parameters :
Name Type Optional
input string Yes
Returns : Date
Async runAdminCreateStripeSubscription
runAdminCreateStripeSubscription(companyId: number)

Admin: one-shot create/recreate Stripe subscription from local subscription rows.

Behavior:

  • Prefers local is_current=true + status=ACTIVE paid subscription.
  • If none, uses the latest local status=EXPIRED paid subscription.
  • Even if Stripe already has an active/trialing subscription for the customer, this will cancel billable Stripe subscriptions first and create a new one.
Parameters :
Name Type Optional
companyId number No
Returns : Promise<literal type>
Async runBatchStripeSubscriptionSync
runBatchStripeSubscriptionSync(options?: literal type)
Parameters :
Name Type Optional
options literal type Yes
Returns : Promise<literal type>
Async runImmediateExpiredRecoveryCharge
runImmediateExpiredRecoveryCharge(companyId: number, options?: literal type)

Admin: one-shot expired recovery — same path as batch STRIPE_SYNC_RECOVER_EXPIRED. Charges one billing period via a standalone Stripe invoice (card / PaymentIntent), then creates a trialing subscription for future renewals.

Parameters :
Name Type Optional
companyId number No
options literal type Yes
Private Async shouldSkipStripeSubscriptionSync
shouldSkipStripeSubscriptionSync(params: literal type)

Skip batch Stripe creation when the customer already has an active or trialing subscription that matches our DB (company or subscription-row Stripe id, or metadata.local_subscription_id). If Stripe has no such subscription (or only unrelated/canceled/past_due-only state), returns false so syncStripeSubscriptionForLocalSubscription can create a new one.

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

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripePaymentSubscriptionAdminService.name)
import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
  BillingCycle,
  InvoiceStatus,
  InvoiceType,
  PackageType,
  SubscriptionStatus,
  SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import { getBillingCycleMultiplier } from "../../../../../config/billing-cycle";
import { ImmediateChargeResult } from "../../dto/immediate-charge-result.dto";
import { StripeService } from "../stripe.service";
import { ImmediateRecoveryQuote } from "./stripe-payment.types";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripePaymentInvoiceWebhookService } from "./stripe-payment-invoice-webhook.service";
import { StripePaymentSubscriptionWriterService } from "./stripe-payment-subscription-writer.service";

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

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

  async runBatchStripeSubscriptionSync(options?: {
    dryRun?: boolean;
    recoverExpiredCompanies?: boolean;
  }): Promise<{
    activeSynced: number;
    activeSkipped: number;
    expiredAttempts: number;
    errors: Array<{ companyId: number; message: string }>;
  }> {
    const dryRun = options?.dryRun ?? process.env["STRIPE_SYNC_DRY_RUN"] === "true";
    const recoverExpired =
      options?.recoverExpiredCompanies ?? process.env["STRIPE_SYNC_RECOVER_EXPIRED"] === "true";
    const forceRecreate = process.env["STRIPE_SYNC_FORCE_RECREATE"] === "true";

    const errors: Array<{ companyId: number; message: string }> = [];
    let activeSynced = 0;
    let activeSkipped = 0;
    let expiredAttempts = 0;

    if (!this.stripeService.isConfigured()) {
      this.logger.warn(
        "runBatchStripeSubscriptionSync: STRIPE_SECRET_KEY is missing — set it in recallassess-api docker/.env (or your shell).",
      );
      return {
        activeSynced: 0,
        activeSkipped: 0,
        expiredAttempts: 0,
        errors: [{ companyId: 0, message: "Stripe not configured" }],
      };
    }

    const syncCompanyIdRaw = process.env["STRIPE_SYNC_COMPANY_ID"];
    const syncCompanyId = syncCompanyIdRaw && syncCompanyIdRaw.length > 0 ? parseInt(syncCompanyIdRaw, 10) : NaN;
    const companyScope = Number.isFinite(syncCompanyId) && syncCompanyId > 0 ? { company_id: syncCompanyId } : {};

    // Eligible packages for creating/linking a Stripe subscription:
    // - Exclude FREE_TRIAL (never billable)
    // - Allow PRIVATE_VIP_TRIAL because it becomes billable after trial_end (VIP offer applied on first invoice)
    const eligiblePackageFilter = {
      package_type: { not: PackageType.FREE_TRIAL },
    };

    // "Paid" package filter for batch selection of already-billable subscriptions.
    // (PRIVATE_VIP_TRIAL is excluded here because trial→paid is handled via trial_end logic.)
    const paidPackageFilter = {
      is_trial_package: false,
      package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
    };

    const activeLocals = await this.prisma.client.subscription.findMany({
      where: {
        ...companyScope,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
        package: eligiblePackageFilter,
        company: {
          stripe_customer_id: { not: null },
        },
      },
      include: {
        company: { select: { id: true, stripe_customer_id: true, stripe_subscription_id: true } },
        package: true,
      },
    });

    this.logger.log(
      `runBatchStripeSubscriptionSync: ${activeLocals.length} row(s) match (ACTIVE current, paid package, stripe_customer_id). ` +
        `STRIPE_SYNC_COMPANY_ID=${Number.isFinite(syncCompanyId) ? String(syncCompanyId) : "unset"}`,
    );
    if (activeLocals.length === 0) {
      this.logger.warn(
        "No eligible local subscriptions. Need: is_current=true, status=ACTIVE, non-trial package (e.g. STARTUP), " +
          "company.stripe_customer_id set. EXPIRED-only companies need STRIPE_SYNC_RECOVER_EXPIRED=true.",
      );
    }

    for (const row of activeLocals) {
      const companyId = row.company_id;
      try {
        const pricePerLicense = Number(row.package.price_per_licence ?? 0);
        const cycleMultiplier = getBillingCycleMultiplier(row.billing_cycle || "QUARTERLY");
        const cents = Math.round(pricePerLicense * row.license_count * cycleMultiplier * 100);
        if (cents <= 0) {
          activeSkipped += 1;
          continue;
        }

        const stripeCustomerId = row.company.stripe_customer_id;
        if (!stripeCustomerId) {
          activeSkipped += 1;
          continue;
        }

        if (
          !forceRecreate &&
          (await this.shouldSkipStripeSubscriptionSync({
            stripeCustomerId,
            companyStripeSubscriptionId: row.company.stripe_subscription_id,
            rowStripeSubscriptionId: row.stripe_subscription_id,
            localSubscriptionId: row.id,
          }))
        ) {
          activeSkipped += 1;
          continue;
        }

        if (dryRun) {
          this.logger.log(
            `[dry-run] Would sync Stripe subscription for company ${companyId} (local sub ${row.id}, next_billing=${row.next_billing_date?.toISOString() ?? "null"})`,
          );
          activeSynced += 1;
          continue;
        }

        const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, row.id, {
          metadataSource: "batch_active_renewal",
        });
        if (created) {
          activeSynced += 1;
        } else {
          activeSkipped += 1;
        }
      } catch (e) {
        const message = e instanceof Error ? e.message : String(e);
        errors.push({ companyId, message });
        this.logger.error(`Batch Stripe sync failed for company ${companyId}: ${message}`);
      }
    }

    if (recoverExpired) {
      const expiredCompanies = await this.prisma.client.company.findMany({
        where: {
          ...(Number.isFinite(syncCompanyId) && syncCompanyId > 0 ? { id: syncCompanyId } : {}),
          is_subscription_expiry: true,
          stripe_customer_id: { not: null },
        },
        select: { id: true, stripe_customer_id: true, stripe_subscription_id: true },
      });

      let startupPackageCache:
        | {
            id: number;
            minimum_license_required: number;
          }
        | null
        | undefined;

      for (const c of expiredCompanies) {
        const hasCurrentActive = await this.prisma.client.subscription.findFirst({
          where: {
            company_id: c.id,
            is_current: true,
            status: SubscriptionStatus.ACTIVE,
          },
          select: { id: true },
        });
        if (hasCurrentActive) {
          continue;
        }

        let lastPaid = await this.prisma.client.subscription.findFirst({
          where: {
            company_id: c.id,
            status: SubscriptionStatus.EXPIRED,
            package: paidPackageFilter,
          },
          orderBy: { id: "desc" },
          include: { package: true },
        });

        if (!lastPaid?.package) {
          const lastExpiredAny = await this.prisma.client.subscription.findFirst({
            where: {
              company_id: c.id,
              status: SubscriptionStatus.EXPIRED,
            },
            orderBy: { id: "desc" },
            include: { package: true },
          });

          const isTrialExpired =
            !!lastExpiredAny?.package &&
            (lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
              lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);

          if (!isTrialExpired) {
            continue;
          }

          if (startupPackageCache === undefined) {
            startupPackageCache = await this.prisma.client.package.findFirst({
              where: {
                package_type: PackageType.STARTUP,
                is_active: true,
              },
              select: {
                id: true,
                minimum_license_required: true,
              },
            });
          }

          if (!startupPackageCache) {
            this.logger.warn(
              `Expired recovery skipped for company ${c.id}: STARTUP package not found/active for trial-to-paid fallback`,
            );
            continue;
          }

          const migratedLicenseCount = Math.max(
            lastExpiredAny?.license_count ?? 0,
            startupPackageCache.minimum_license_required ?? 1,
            1,
          );

          const migrated = await this.prisma.client.subscription.create({
            data: {
              company_id: c.id,
              package_id: startupPackageCache.id,
              previous_subscription_id: lastExpiredAny?.id ?? null,
              license_count: migratedLicenseCount,
              status: SubscriptionStatus.EXPIRED,
              is_current: false,
              billing_cycle: BillingCycle.QUARTERLY,
              start_date: new Date(),
              end_date: new Date(),
              next_billing_date: new Date(),
              // First paid term after trial: upgrade from trial, not a billing-cycle renewal.
              subscription_type: SubscriptionType.UPGRADE,
            },
            include: { package: true },
          });

          this.logger.log(
            `Expired trial fallback: created STARTUP quarterly row ${migrated.id} for company ${c.id} (period start anchor=now)`,
          );
          lastPaid = migrated;
        }

        const pricePerLicense = Number(lastPaid.package.price_per_licence ?? 0);
        const cycleMultiplier = getBillingCycleMultiplier(lastPaid.billing_cycle || "QUARTERLY");
        if (Math.round(pricePerLicense * lastPaid.license_count * cycleMultiplier * 100) <= 0) {
          continue;
        }

        const expiredStripeCustomerId = c.stripe_customer_id;
        if (!expiredStripeCustomerId) {
          continue;
        }

        try {
          if (
            !forceRecreate &&
            (await this.shouldSkipStripeSubscriptionSync({
              stripeCustomerId: expiredStripeCustomerId,
              companyStripeSubscriptionId: c.stripe_subscription_id,
              rowStripeSubscriptionId: lastPaid.stripe_subscription_id,
              localSubscriptionId: lastPaid.id,
            }))
          ) {
            continue;
          }

          if (dryRun) {
            this.logger.log(
              `[dry-run] Would recover expired company ${c.id} via one-off invoice + trialing subscription (local sub ${lastPaid.id})`,
            );
            expiredAttempts += 1;
            continue;
          }

          if (!lastPaid) {
            continue;
          }

          const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(c.id, lastPaid.id, {
            metadataSource: "batch_expired_recovery",
            forceImmediateFirstInvoice: true,
          });
          if (created) {
            expiredAttempts += 1;
          }
        } catch (e) {
          const message = e instanceof Error ? e.message : String(e);
          errors.push({ companyId: c.id, message });
          this.logger.error(`Expired recovery Stripe sync failed for company ${c.id}: ${message}`);
        }
      }
    }

    this.logger.log(
      `runBatchStripeSubscriptionSync complete: activeSynced=${activeSynced}, activeSkipped=${activeSkipped}, expiredAttempts=${expiredAttempts}, errors=${errors.length}, dryRun=${dryRun}, forceRecreate=${forceRecreate}`,
    );

    return { activeSynced, activeSkipped, expiredAttempts, errors };
  }

  /**
   * Before portal recovery for expired companies: point the eligible paid subscription row at the
   * active catalog {@link Package} for the same {@link PackageType} so Stripe sync uses current
   * prices/Stripe price IDs. License count on the subscription row is unchanged.
   */
  async refreshPaidSubscriptionLinkedPackageFromCatalog(companyId: number): Promise<void> {
    const paidPackageFilter = {
      is_trial_package: false,
      package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
    };

    const currentActive = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
        package: paidPackageFilter,
      },
      orderBy: { id: "desc" },
      include: { package: true },
    });

    const latestExpired = !currentActive
      ? await this.prisma.client.subscription.findFirst({
          where: {
            company_id: companyId,
            status: SubscriptionStatus.EXPIRED,
            package: paidPackageFilter,
          },
          orderBy: { id: "desc" },
          include: { package: true },
        })
      : null;

    const localSub = currentActive ?? latestExpired;
    if (!localSub?.package) {
      return;
    }

    const canonicalPackage = await this.prisma.client.package.findFirst({
      where: {
        package_type: localSub.package.package_type,
        is_active: true,
        OR: [{ special_slug: null }, { special_slug: "" }],
      },
    });

    if (!canonicalPackage || canonicalPackage.id === localSub.package_id) {
      return;
    }

    await this.prisma.client.subscription.update({
      where: { id: localSub.id },
      data: { package_id: canonicalPackage.id },
    });

    this.logger.log(
      `refreshPaidSubscriptionLinkedPackageFromCatalog: company ${companyId} subscription ${localSub.id} package_id ${localSub.package_id} -> ${canonicalPackage.id} (${localSub.package.package_type})`,
    );
  }

  /**
   * Admin: one-shot create/recreate Stripe subscription from local subscription rows.
   *
   * Behavior:
   * - Prefers local `is_current=true` + `status=ACTIVE` paid subscription.
   * - If none, uses the latest local `status=EXPIRED` paid subscription.
   * - Even if Stripe already has an active/trialing subscription for the customer,
   *   this will cancel billable Stripe subscriptions first and create a new one.
   */
  async runAdminCreateStripeSubscription(companyId: number): Promise<{
    success: boolean;
    message: string;
    next_period_end_iso?: string | null;
  }> {
    if (!this.stripeService.isConfigured()) {
      throw new BadRequestException("Stripe is not configured");
    }

    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { id: true, stripe_customer_id: true },
    });

    if (!company) {
      throw new NotFoundException(`Company ${companyId} not found`);
    }

    if (!company.stripe_customer_id) {
      throw new BadRequestException("Company has no Stripe customer ID.");
    }

    // Eligible packages for creating/linking a Stripe subscription:
    // - Exclude FREE_TRIAL (never billable)
    // - Allow PRIVATE_VIP_TRIAL because it becomes billable after trial_end (VIP offer applied on first invoice)
    const eligiblePackageFilter = {
      package_type: { not: PackageType.FREE_TRIAL },
    };

    // Prefer current ACTIVE eligible subscription (mirrors batch "active renewals" selection).
    const currentActive = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
        package: eligiblePackageFilter,
      },
      orderBy: { id: "desc" },
      include: { package: true },
    });

    // Some older data may not keep `is_current` perfectly in sync. If no current row is found,
    // fall back to the latest ACTIVE eligible subscription for the company.
    const latestActiveAny = !currentActive
      ? await this.prisma.client.subscription.findFirst({
          where: {
            company_id: companyId,
            status: SubscriptionStatus.ACTIVE,
            package: eligiblePackageFilter,
          },
          orderBy: { id: "desc" },
          include: { package: true },
        })
      : null;

    // Fallback for ended companies: latest eligible expired subscription.
    const latestExpired = !(currentActive ?? latestActiveAny)
      ? await this.prisma.client.subscription.findFirst({
          where: {
            company_id: companyId,
            status: SubscriptionStatus.EXPIRED,
            package: eligiblePackageFilter,
          },
          orderBy: { id: "desc" },
          include: { package: true },
        })
      : null;

    const localSub = currentActive ?? latestActiveAny ?? latestExpired;
    if (!localSub?.package) {
      throw new BadRequestException(
        "No eligible local paid subscription row found. Subscribe in the app first (Settings → Subscription billing).",
      );
    }

    let created: boolean;
    try {
      created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, localSub.id, {
        metadataSource: "admin_manual_create_subscription",
        // For expired recovery we prefer immediate first invoice alignment.
        forceImmediateFirstInvoice: localSub.status === SubscriptionStatus.EXPIRED,
      });
    } catch (e) {
      const stripeErr = e as Stripe.errors.StripeError | undefined;
      if (
        stripeErr?.code === "resource_missing" &&
        typeof stripeErr.message === "string" &&
        /customer/i.test(stripeErr.message)
      ) {
        const mode = this.stripeService.getDashboardAccountMode();
        throw new BadRequestException(
          `Stripe could not find customer ${company.stripe_customer_id} with this server's ${mode} API key (no such customer). ` +
            `This usually means the Stripe Dashboard is in a different mode than STRIPE_SECRET_KEY (test vs live), ` +
            `or the customer belongs to another Stripe account. ` +
            `Open the customer in Dashboard with the same test/live toggle and account as UAT, or fix company.stripe_customer_id if the DB was copied from another environment.`,
        );
      }
      throw e;
    }

    if (!created) {
      return {
        success: false,
        message: "Stripe sync did not create a subscription (see server logs).",
        next_period_end_iso: null,
      };
    }

    let nextPeriodEndIso: string | null = null;
    const companyAfter = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { stripe_subscription_id: true },
    });
    if (companyAfter?.stripe_subscription_id) {
      try {
        const stripeSub = await this.stripeService.retrieveSubscription(companyAfter.stripe_subscription_id);
        const end = (stripeSub as Stripe.Subscription & { current_period_end?: number }).current_period_end;
        if (typeof end === "number") {
          nextPeriodEndIso = new Date(end * 1000).toISOString();
        }
      } catch (e) {
        this.logger.warn(
          `runAdminCreateStripeSubscription: could not read current_period_end for company ${companyId}: ${
            e instanceof Error ? e.message : String(e)
          }`,
        );
      }
    }

    const endLabel = nextPeriodEndIso
      ? new Date(nextPeriodEndIso).toLocaleDateString("en-US", {
          month: "short",
          day: "numeric",
          year: "numeric",
          timeZone: "UTC",
        })
      : null;

    const message =
      localSub.status === SubscriptionStatus.EXPIRED
        ? endLabel
          ? `Stripe subscription recovery is set up. Confirm payment in Stripe if needed. Next billing period ends ${endLabel} (UTC).`
          : "Stripe subscription recovery created/replaced for this company. Confirm payment in Stripe if needed."
        : endLabel
          ? `Stripe billing is linked. Your current period ends ${endLabel} (UTC); renewals follow this billing cycle.`
          : "Stripe subscription created for this company. Confirm payment in Stripe if needed.";

    return {
      success: true,
      message,
      next_period_end_iso: nextPeriodEndIso,
    };
  }

  /**
   * Builds DB + Stripe status snapshot for the admin immediate-charge response.
   */
  private async buildImmediateChargeSnapshot(
    companyId: number,
    recoveredLocalSubscriptionId: number,
  ): Promise<Pick<ImmediateChargeResult, "stripe_subscription_status" | "company" | "subscription" | "invoice">> {
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { is_subscription_expiry: true, stripe_subscription_id: true },
    });
    if (!company) {
      return {
        stripe_subscription_status: null,
        company: undefined,
        subscription: null,
        invoice: null,
      };
    }

    const subscription = await this.prisma.client.subscription.findFirst({
      where: { id: recoveredLocalSubscriptionId, company_id: companyId },
      select: { id: true, status: true, is_current: true, stripe_subscription_id: true },
    });

    const invoiceRow = await this.prisma.client.invoice.findFirst({
      where: { company_id: companyId, subscription_id: recoveredLocalSubscriptionId },
      orderBy: { id: "desc" },
      select: {
        id: true,
        stripe_invoice_id: true,
        stripe_payment_intent_id: true,
        status: true,
        total_amount: true,
        paid_date: true,
      },
    });

    let stripe_subscription_status: string | null = null;
    if (company.stripe_subscription_id) {
      try {
        const sub = await this.stripeService.retrieveSubscription(company.stripe_subscription_id);
        stripe_subscription_status = sub.status;
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        this.logger.warn(
          `Immediate charge snapshot: could not retrieve Stripe subscription ${company.stripe_subscription_id}: ${msg}`,
        );
      }
    }

    return {
      stripe_subscription_status,
      company: {
        is_subscription_expiry: company.is_subscription_expiry,
        stripe_subscription_id: company.stripe_subscription_id,
      },
      subscription: subscription
        ? {
            id: subscription.id,
            status: String(subscription.status),
            is_current: subscription.is_current,
            stripe_subscription_id: subscription.stripe_subscription_id,
          }
        : null,
      invoice: invoiceRow
        ? {
            id: invoiceRow.id,
            stripe_invoice_id: invoiceRow.stripe_invoice_id,
            stripe_payment_intent_id: invoiceRow.stripe_payment_intent_id,
            status: String(invoiceRow.status),
            total_amount: Number(invoiceRow.total_amount),
            paid_date: invoiceRow.paid_date?.toISOString() ?? null,
          }
        : null,
    };
  }

  /**
   * Admin: one-shot expired recovery — same path as batch `STRIPE_SYNC_RECOVER_EXPIRED`.
   * Charges one billing period via a standalone Stripe invoice (card / PaymentIntent), then creates a trialing subscription for future renewals.
   */
  async runImmediateExpiredRecoveryCharge(
    companyId: number,
    options?: { forceRecreateStripeSubscription?: boolean; recoveryFromDate?: string },
  ): Promise<ImmediateChargeResult> {
    if (!this.stripeService.isConfigured()) {
      throw new BadRequestException("Stripe is not configured");
    }

    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: {
        id: true,
        is_subscription_expiry: true,
        stripe_customer_id: true,
        stripe_subscription_id: true,
      },
    });

    if (!company) {
      throw new NotFoundException(`Company ${companyId} not found`);
    }

    if (!company.is_subscription_expiry) {
      throw new BadRequestException(
        "Subscription expiry is not set for this company. This action is only for expired subscription recovery.",
      );
    }

    if (!company.stripe_customer_id) {
      throw new BadRequestException("Company has no Stripe customer ID.");
    }

    const paidPackageFilter = {
      is_trial_package: false,
      package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
    };

    const c = company;

    const hasCurrentActive = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: c.id,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
      },
      select: { id: true },
    });
    if (hasCurrentActive) {
      throw new BadRequestException(
        "This company still has a current ACTIVE subscription. Resolve that before running expired recovery.",
      );
    }

    let lastPaid = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: c.id,
        status: SubscriptionStatus.EXPIRED,
        package: paidPackageFilter,
      },
      orderBy: { id: "desc" },
      include: { package: true },
    });
    // VIP-only offer: if the last expired row was PRIVATE_VIP_TRIAL, apply discount on the immediate charge.
    let vipOfferAppliesToImmediateRecovery = false;

    let startupPackageCache:
      | {
          id: number;
          minimum_license_required: number;
        }
      | null
      | undefined;

    if (!lastPaid?.package) {
      const lastExpiredAny = await this.prisma.client.subscription.findFirst({
        where: {
          company_id: c.id,
          status: SubscriptionStatus.EXPIRED,
        },
        orderBy: { id: "desc" },
        include: { package: true },
      });

      const isTrialExpired =
        !!lastExpiredAny?.package &&
        (lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
          lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);

      if (!isTrialExpired) {
        throw new BadRequestException(
          "No paid expired subscription row found to recover. Check package, or use the customer app to subscribe.",
        );
      }
      vipOfferAppliesToImmediateRecovery = lastExpiredAny?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;

      if (startupPackageCache === undefined) {
        startupPackageCache = await this.prisma.client.package.findFirst({
          where: {
            package_type: PackageType.STARTUP,
            is_active: true,
          },
          select: {
            id: true,
            minimum_license_required: true,
          },
        });
      }

      if (!startupPackageCache) {
        throw new BadRequestException(
          "STARTUP package is not available for trial-to-paid fallback. Configure an active STARTUP package.",
        );
      }

      const migratedLicenseCount = Math.max(
        lastExpiredAny?.license_count ?? 0,
        startupPackageCache.minimum_license_required ?? 1,
        1,
      );

      const migrated = await this.prisma.client.subscription.create({
        data: {
          company_id: c.id,
          package_id: startupPackageCache.id,
          previous_subscription_id: lastExpiredAny?.id ?? null,
          license_count: migratedLicenseCount,
          status: SubscriptionStatus.EXPIRED,
          is_current: false,
          billing_cycle: BillingCycle.QUARTERLY,
          start_date: new Date(),
          end_date: new Date(),
          next_billing_date: new Date(),
          subscription_type: SubscriptionType.UPGRADE,
        } as any,
        include: { package: true },
      });

      this.logger.log(
        `Immediate expired recovery: created STARTUP quarterly row ${migrated.id} for company ${c.id}`,
      );
      lastPaid = migrated;
    }

    if (!lastPaid?.package) {
      throw new BadRequestException("Could not resolve an expired subscription row to recover.");
    }

    const pricePerLicense = Number(lastPaid.package.price_per_licence ?? 0);
    const cycleMultiplier = getBillingCycleMultiplier(lastPaid.billing_cycle || "QUARTERLY");
    if (Math.round(pricePerLicense * lastPaid.license_count * cycleMultiplier * 100) <= 0) {
      throw new BadRequestException(
        "Renewal amount is zero (check package price and license count). Cannot charge.",
      );
    }

    const hasPmForImmediate = await this.stripeService.ensureDefaultPaymentMethodFromSavedCards(
      c.stripe_customer_id!,
    );
    if (!hasPmForImmediate) {
      throw new BadRequestException(
        "Immediate charge requires a saved card on the Stripe customer with invoice default set. " +
          "In Stripe Dashboard open the customer, add a card payment method, and set it as the default for invoices.",
      );
    }
    const pmIdForImmediate = await this.stripeService.getInvoiceDefaultPaymentMethodId(c.stripe_customer_id!);
    if (!pmIdForImmediate) {
      throw new BadRequestException(
        "Could not resolve invoice default payment method id after saving card default. Retry or set default in Stripe Dashboard.",
      );
    }

    const forceRecreate =
      options?.forceRecreateStripeSubscription === true || process.env["STRIPE_SYNC_FORCE_RECREATE"] === "true";
    if (
      !forceRecreate &&
      (await this.shouldSkipStripeSubscriptionSync({
        stripeCustomerId: c.stripe_customer_id!,
        companyStripeSubscriptionId: c.stripe_subscription_id,
        rowStripeSubscriptionId: lastPaid.stripe_subscription_id,
        localSubscriptionId: lastPaid.id,
      }))
    ) {
      throw new BadRequestException(
        "Stripe subscription already linked and up to date; nothing to do. Set STRIPE_SYNC_FORCE_RECREATE=true on the server to force recreate if needed.",
      );
    }

    if (forceRecreate) {
      await this.stripeService.cancelCustomerBillableSubscriptions(c.stripe_customer_id!);
    }

    const recoveredSubId = lastPaid.id;
    const immediateDiscountMultiplier =
      lastPaid.billing_cycle === BillingCycle.ANNUAL
        ? vipOfferAppliesToImmediateRecovery
          ? 2 / 3
          : 5 / 6
        : vipOfferAppliesToImmediateRecovery
          ? 2 / 3
          : undefined;

    const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(c.id, recoveredSubId, {
      metadataSource: "admin_immediate_expired_recovery",
      forceImmediateFirstInvoice: true,
      recoveryAnchorDate: options?.recoveryFromDate,
      // Annual offer: charge 300/360 days (2 months free). VIP annual uses 2/3 (2+1 free).
      immediateFirstInvoiceDiscountMultiplier: immediateDiscountMultiplier,
    });

    const snapshot = await this.buildImmediateChargeSnapshot(c.id, recoveredSubId);

    if (!created) {
      return {
        success: false,
        message: "Stripe sync did not create a subscription (see server logs).",
        ...snapshot,
      };
    }

    return {
      success: true,
      message: vipOfferAppliesToImmediateRecovery
        ? "Recovery complete: VIP offer applied on the immediate charge; a trialing subscription is set for the next renewal. See snapshot below."
        : "Recovery complete: one billing period was charged on a Stripe invoice; a trialing subscription is set for the next renewal. See snapshot below.",
      ...snapshot,
    };
  }

  /**
   * Stripe subscription invoices: reactivate expired local rows after payment, and keep next_billing_date aligned.
   */

  /**
   * Alias handler for some Stripe/local test flows:
   * Stripe may emit `invoice_payment.paid` instead of/alongside `invoice.paid`.
   *
   * We resolve the actual Stripe invoice from the payload (best-effort) and then
   * reuse {@link handleInvoicePaid} so local invoice/subscription rotation stays consistent.
   */

  /**
   * When a customer adds a payment method, try paying their latest subscription invoice now.
   * This fixes the "no payment method -> invoice pending" case without manual admin action.
   */

  /**
   * When a SetupIntent succeeds (payment method saved), try paying latest subscription invoice.
   */

  /**
   * Skip batch Stripe creation when the customer already has an active or trialing subscription
   * that matches our DB (company or subscription-row Stripe id, or metadata.local_subscription_id).
   * If Stripe has no such subscription (or only unrelated/canceled/past_due-only state), returns false
   * so {@link syncStripeSubscriptionForLocalSubscription} can create a new one.
   */
  private async shouldSkipStripeSubscriptionSync(params: {
    stripeCustomerId: string;
    companyStripeSubscriptionId: string | null;
    rowStripeSubscriptionId: string | null;
    localSubscriptionId: number;
  }): Promise<boolean> {
    const activeLike = await this.stripeService.listSubscriptionsForCustomer(params.stripeCustomerId, [
      "active",
      "trialing",
    ]);
    if (activeLike.length === 0) {
      return false;
    }

    const companyId = params.companyStripeSubscriptionId?.trim() || null;
    const rowId = params.rowStripeSubscriptionId?.trim() || null;
    const localSubStr = String(params.localSubscriptionId);

    for (const sub of activeLike) {
      if (companyId && sub.id === companyId) {
        return true;
      }
      if (rowId && sub.id === rowId) {
        return true;
      }
      const metaLocal = sub.metadata?.["local_subscription_id"];
      if (metaLocal === localSubStr) {
        return true;
      }
    }

    return false;
  }

  /**
   * When the first invoice is paid automatically (default PM), Stripe may return the subscription as
   * {@link Stripe.Subscription.Status active} so {@link syncStripeSubscriptionForLocalSubscription} never
   * enters the incomplete + pay path. Align local Invoice + subscription state as invoice.paid would.
   * Same for {@link Stripe.Subscription.Status trialing}: sync skips the incomplete/pay branch; mirror here
   * so {@link handleInvoicePaid} runs (and {@code billing.payment.succeeded} sends) when webhooks are delayed.
   */
  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;
    }
    // Zero-amount Stripe invoices can still be status=paid (e.g. discounts/trials).
    // We still need the normal invoice.paid side effects, including billing email.
    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}).`,
    );
  }

  /**
   * Next period end after paying one full billing cycle from {@param previousAnchor}.
   * Ensures the result is strictly in the future so Stripe `trial_end` is valid.
   */
  private computeNextBillingAfterOnePaidPeriod(
    previousAnchor: Date,
    billingCycle: string | null | undefined,
  ): Date {
    return this.billing.addDaysSafe(previousAnchor, this.billing.getBillingCycleFixedDays(billingCycle));
  }


  private resolveRecoveryAnchorDate(input?: string): Date {
    if (!input || input.trim() === "") {
      return new Date();
    }
    const parsed = new Date(input);
    if (Number.isNaN(parsed.getTime())) {
      return new Date();
    }
    return parsed;
  }

  async previewImmediateExpiredRecoveryCharge(
    companyId: number,
    fromDate?: string,
  ): Promise<ImmediateRecoveryQuote> {
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: {
        id: true,
        is_subscription_expiry: true,
        country: true,
      },
    });
    if (!company) {
      throw new NotFoundException(`Company ${companyId} not found`);
    }
    if (!company.is_subscription_expiry) {
      throw new BadRequestException("Immediate re-activation is only available for expired subscriptions.");
    }

    const paidPackageFilter = {
      is_trial_package: false,
      package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
    };

    const hasCurrentActive = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: company.id,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
      },
      select: { id: true },
    });
    if (hasCurrentActive) {
      throw new BadRequestException("Company already has an active subscription.");
    }

    let target = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: company.id,
        status: SubscriptionStatus.EXPIRED,
        package: paidPackageFilter,
      },
      orderBy: { id: "desc" },
      include: { package: true },
    });
    let vipOfferAppliesToImmediateRecovery = false;

    if (!target?.package) {
      const lastExpiredAny = await this.prisma.client.subscription.findFirst({
        where: {
          company_id: company.id,
          status: SubscriptionStatus.EXPIRED,
        },
        orderBy: { id: "desc" },
        include: { package: true },
      });
      const isTrialExpired =
        !!lastExpiredAny?.package &&
        (lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
          lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);
      if (!isTrialExpired) {
        throw new BadRequestException("No expired paid subscription available for re-activation.");
      }
      vipOfferAppliesToImmediateRecovery = lastExpiredAny?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;

      const startupPackage = await this.prisma.client.package.findFirst({
        where: { package_type: PackageType.STARTUP, is_active: true },
        select: { id: true, price_per_licence: true, minimum_license_required: true },
      });
      if (!startupPackage) {
        throw new BadRequestException("STARTUP package is required for trial-to-paid re-activation preview.");
      }

      const licenseCount = Math.max(
        lastExpiredAny?.license_count ?? 0,
        startupPackage.minimum_license_required ?? 1,
        1,
      );
      if (!lastExpiredAny || !lastExpiredAny.package) {
        throw new BadRequestException("Could not resolve expired trial subscription for preview.");
      }
      target = {
        ...lastExpiredAny,
        license_count: licenseCount,
        billing_cycle: BillingCycle.QUARTERLY,
        package: {
          ...lastExpiredAny.package,
          price_per_licence: startupPackage.price_per_licence,
          package_type: PackageType.STARTUP,
        },
      } as typeof target;
    }

    if (!target?.package) {
      throw new BadRequestException("Could not calculate re-activation preview.");
    }

    const cycle = (target.billing_cycle || "QUARTERLY") as "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
    const cycleMultiplier = getBillingCycleMultiplier(cycle);
    const pricePerLicense = Number(target.package.price_per_licence ?? 0);
    const fullBaseAmountCents = Math.round(pricePerLicense * target.license_count * cycleMultiplier * 100);
    let baseAmountCents = fullBaseAmountCents;
    let immediateDiscountMultiplier: number | null = null;
    if (cycle === "ANNUAL") {
      immediateDiscountMultiplier = vipOfferAppliesToImmediateRecovery ? 2 / 3 : 5 / 6;
    } else if (vipOfferAppliesToImmediateRecovery) {
      immediateDiscountMultiplier = 2 / 3;
    }
    if (typeof immediateDiscountMultiplier === "number") {
      baseAmountCents = Math.max(1, Math.round(baseAmountCents * immediateDiscountMultiplier));
    }
    if (baseAmountCents <= 0) {
      throw new BadRequestException("Re-activation amount is zero. Check package price and license count.");
    }

    const charge = this.billing.calculateRenewalChargeCents({
      baseAmountCents,
      country: company.country,
    });
    const nextRecurringCharge = this.billing.calculateRenewalChargeCents({
      baseAmountCents: fullBaseAmountCents,
      country: company.country,
    });
    const anchor = this.billing.resolveRecoveryAnchorDate(fromDate);
    const endDate = this.billing.computeNextBillingAfterOnePaidPeriod(anchor, cycle);
    const toDateOnly = (d: Date): string => d.toISOString().slice(0, 10);

    return {
      currency: "USD",
      base_amount: baseAmountCents / 100,
      processing_fee: charge.processingFeeCents / 100,
      vat_fee: charge.vatCents / 100,
      immediate_charge_amount: charge.totalCents / 100,
      next_recurring_charge_amount: nextRecurringCharge.totalCents / 100,
      vip_offer_applied: vipOfferAppliesToImmediateRecovery,
      vip_offer_summary: vipOfferAppliesToImmediateRecovery
        ? cycle === "ANNUAL"
          ? "VIP offer applied: annual immediate charge uses 2/3 of one full cycle (2+1 free)."
          : "VIP offer applied: immediate charge uses 2/3 of one full cycle."
        : cycle === "ANNUAL"
          ? "Annual offer applied: immediate charge uses 300/360 days (2 months free)."
        : null,
      billing_cycle: cycle,
      from_date: toDateOnly(anchor),
      end_date: toDateOnly(endDate),
      next_stripe_billing_date: toDateOnly(endDate),
    };
  }
}

results matching ""

    No results matching ""