File

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

Index

Properties
Methods

Constructor

constructor(stripeConfig: StripeConfig)
Parameters :
Name Type Optional
stripeConfig StripeConfig No

Methods

Async attachPaymentMethodToCustomer
attachPaymentMethodToCustomer(paymentMethodId: string, customerId: string)

Attach a payment method to a customer (safe to call if already attached). Needed when a PaymentIntent succeeds but does not persist the card as a saved PM.

Parameters :
Name Type Optional
paymentMethodId string No
customerId string No
Returns : Promise<void>
Async attemptPayLatestSubscriptionInvoice
attemptPayLatestSubscriptionInvoice(subscriptionId: string)

Attempt to pay the latest invoice of a subscription now. Finalizes draft invoices, passes the customer's invoice default payment method, and uses off_session.

Parameters :
Name Type Optional
subscriptionId string No
Returns : Promise<literal type>
Async cancelCustomerBillableSubscriptions
cancelCustomerBillableSubscriptions(customerId: string)
Parameters :
Name Type Optional
customerId string No
Returns : Promise<string[]>
Async createBillingPortalSession
createBillingPortalSession(params: literal type)

Create a Stripe billing portal session so customers can manage payment methods

Parameters :
Name Type Optional
params literal type No
Returns : Promise<literal type>
Async createCheckoutSession
createCheckoutSession(params: literal type)

Create a Checkout Session for participant payment All payments go to RecallAssess's single Stripe account

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Checkout.Session>
Async createCustomer
createCustomer(params: literal type)

Create a Stripe customer so we can attach subscriptions and payment methods later.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Customer>
Async createCustomerSubscription
createCustomerSubscription(params: literal type)

Create a Stripe subscription for a customer using inline price_data. This is used to bootstrap renewal in Stripe when local subscription exists but no Stripe subscription record is present.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Subscription>
Async createFinalizeAndMarkInvoicePaidOutOfBand
createFinalizeAndMarkInvoicePaidOutOfBand(params: literal type)

Create a Stripe invoice for reporting/records and mark it paid out-of-band (no additional charge). Use when a separate PaymentIntent already charged the customer but you still want a Stripe Invoice with the correct amount in Billing → Invoices.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Invoice>
Async createFinalizeAndPayOneOffInvoice
createFinalizeAndPayOneOffInvoice(params: literal type)

One-off invoice for a fixed USD amount, finalized and paid off-session with a specific payment method. Used for expired recovery “immediate charge” without creating a subscription invoice first (avoids SetupIntent-only flows).

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Invoice>
Async createPaymentIntent
createPaymentIntent(params: literal type)

Create a Payment Intent (for custom payment flows)

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.PaymentIntent>
Private Async createRecallAssessRecurringPrice
createRecallAssessRecurringPrice(billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL", unitAmountCents: number)
Parameters :
Name Type Optional
billingCycle "QUARTERLY" | "HALF_YEARLY" | "ANNUAL" No
unitAmountCents number No
Returns : Promise<Stripe.Price>
Async createSetupIntent
createSetupIntent(params: literal type)

Create a Setup Intent (for saving payment methods without charging) Used for free trials where we want to collect card details but charge later

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.SetupIntent>
Async createTrialingSubscription
createTrialingSubscription(params: literal type)

Recurring subscription in trialing until {@param trialEndUnix} — no first invoice until trial ends. Use after a separate one-off invoice has collected the lapsed period.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Subscription>
Async customerExists
customerExists(customerId: string)

Checks whether a Stripe customer id exists in the currently configured Stripe account/mode.

Parameters :
Name Type Optional
customerId string No
Returns : Promise<boolean>
Async ensureDefaultPaymentMethodFromSavedCards
ensureDefaultPaymentMethodFromSavedCards(customerId: string)

If the customer has saved card payment methods but no invoice default, set the newest card as Stripe.Customer.invoice_settings.default_payment_method so subscriptions can charge.

Parameters :
Name Type Optional
customerId string No
Returns : Promise<boolean>

true when a default was already set or was just assigned

Async getCustomerDefaultPaymentMethod
getCustomerDefaultPaymentMethod(customerId: string, companyContext?: literal type)

Retrieve default payment method details for a Stripe customer.

Lookup order (each branch logged so missing-card issues are debuggable):

  1. customer.invoice_settings.default_payment_method (the canonical default)
  2. Default PM on any active/trialing subscription for this customer
  3. Most recently attached card on the customer (catches Portal-added cards that haven't propagated to invoice_settings yet)
  4. ORPHAN-CUSTOMER FALLBACK: if companyEmail and/or companyId provided, search Stripe customers by email — pick the one whose metadata.company_id matches AND has a card. Catches the case where a previous Stripe customer record was orphaned (e.g. duplicate created) and the card lives there.
  5. Self-heal: if a card is found but not set as customer default, set it now so subsequent retrievals are fast and the next charge cycle works.

NEVER throws. Top-level failures (network/auth) are logged and return nulls so the overview endpoint stays up — frontend treats nulls as "no card on file" which renders the "Add payment method" CTA.

Parameters :
Name Type Optional
customerId string No
companyContext literal type Yes
Returns : Promise<literal type>
Async getCustomerInvoiceSettings
getCustomerInvoiceSettings(customerId: string)

Get customer invoice settings (collection method for auto payment) Note: collection_method is subscription-scoped, so we check active subscriptions

Parameters :
Name Type Optional
customerId string No
Returns : Promise<literal type>
getDashboardAccountMode
getDashboardAccountMode()

Matches StripeConfig.getDashboardAccountMode — for admin dashboard links.

Returns : "test" | "live"
Async getInvoice
getInvoice(invoiceId: string)

Retrieve invoice by ID

Parameters :
Name Type Optional
invoiceId string No
Returns : Promise<Stripe.Invoice>
Async getInvoiceDefaultPaymentMethodId
getInvoiceDefaultPaymentMethodId(customerId: string)

Customer's invoice default payment method id (use after ensureDefaultPaymentMethodFromSavedCards).

Parameters :
Name Type Optional
customerId string No
Returns : Promise<string | null>
Async getInvoiceExpanded
getInvoiceExpanded(invoiceId: string)

Same as Stripe CLI: stripe invoices retrieve <id> --expand 'payment_intent.latest_charge' plus lines.data (useful for line items / periods).

Parameters :
Name Type Optional
invoiceId string No
Returns : Promise<Stripe.Invoice>
Async getPaymentIntent
getPaymentIntent(paymentIntentId: string)

Get payment intent by ID

Parameters :
Name Type Optional
paymentIntentId string No
Returns : Promise<Stripe.PaymentIntent>
getPublishableKey
getPublishableKey()

Get publishable key (safe to expose to frontend)

Returns : string
Private getStripe
getStripe()
Returns : Stripe
Private isBillableSubscriptionStatus
isBillableSubscriptionStatus(status: Stripe.Subscription.Status)
Parameters :
Name Type Optional
status Stripe.Subscription.Status No
Returns : boolean
isConfigured
isConfigured()

Check if Stripe is properly configured

Returns : boolean
Private isRetryableStripeError
isRetryableStripeError(error: unknown)

True for transient Stripe/network issues worth retrying after SDK-level retries exhaust.

Parameters :
Name Type Optional
error unknown No
Returns : boolean
Async listChargesForCustomer
listChargesForCustomer(params: literal type)

List charges for a Stripe customer (paginates up to max, newest first).

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Charge[]>
Async listInvoicesForCustomer
listInvoicesForCustomer(params: literal type)

List invoices for a Stripe customer (paginates up to max, newest first).

Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Invoice[]>
Async listRefundsForCharge
listRefundsForCharge(params: literal type)
Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Refund[]>
Async listRefundsForPaymentIntent
listRefundsForPaymentIntent(params: literal type)
Parameters :
Name Type Optional
params literal type No
Returns : Promise<Stripe.Refund[]>
Async listSubscriptionsForCustomer
listSubscriptionsForCustomer(customerId: string, statuses: Stripe.Subscription.Status[])

List subscriptions for a customer (one Stripe API call per status).

Parameters :
Name Type Optional
customerId string No
statuses Stripe.Subscription.Status[] No
Returns : Promise<Stripe.Subscription[]>
Async markSubscriptionLatestInvoicePaidOutOfBand
markSubscriptionLatestInvoicePaidOutOfBand(subscriptionId: string)

Paid signup funds are captured on a standalone PaymentIntent; createCustomerSubscription still creates a first-cycle invoice under {@code payment_behavior: default_incomplete}. Without this step that invoice stays “requires payment” even though the customer already paid. Close it via {@code paid_out_of_band} (no second charge — aligns Stripe Billing with the signup PI).

Parameters :
Name Type Optional
subscriptionId string No
Returns : Promise<void>
Private normalizePlanBillingCycle
normalizePlanBillingCycle(billingCycle: string)
Parameters :
Name Type Optional
billingCycle string No
Returns : "QUARTERLY" | "HALF_YEARLY" | "ANNUAL"
Async retrieveCharge
retrieveCharge(chargeId: string)

Retrieve Charge (e.g. for receipt_url when PaymentIntent.latest_charge is only an id string)

Parameters :
Name Type Optional
chargeId string No
Returns : Promise<Stripe.Charge>
Async retrieveCheckoutSession
retrieveCheckoutSession(sessionId: string, expand?: string[])

Retrieve Checkout Session

Parameters :
Name Type Optional
sessionId string No
expand string[] Yes
Returns : Promise<Stripe.Checkout.Session>
Async retrievePaymentIntent
retrievePaymentIntent(paymentIntentId: string, expand?: string[])

Retrieve Payment Intent

Parameters :
Name Type Optional
paymentIntentId string No
expand string[] Yes
Returns : Promise<Stripe.PaymentIntent>
Async retrieveRefund
retrieveRefund(refundId: string)

Retrieve Refund (useful for debugging/manual sync)

Parameters :
Name Type Optional
refundId string No
Returns : Promise<Stripe.Refund>
Async retrieveSetupIntent
retrieveSetupIntent(setupIntentId: string)

Retrieve Setup Intent

Parameters :
Name Type Optional
setupIntentId string No
Returns : Promise<Stripe.SetupIntent>
Async retrieveSubscription
retrieveSubscription(subscriptionId: string)
Parameters :
Name Type Optional
subscriptionId string No
Returns : Promise<Stripe.Subscription>
Async retrieveSubscriptionWithLatestInvoice
retrieveSubscriptionWithLatestInvoice(subscriptionId: string)

Subscription with latest_invoice expanded (used after create when status may already be active).

Parameters :
Name Type Optional
subscriptionId string No
Returns : Promise<Stripe.Subscription>
Async retrieveUpcomingInvoiceAmountForSubscription
retrieveUpcomingInvoiceAmountForSubscription(subscriptionId: string)

Next invoice preview for a subscription (invoices.createPreview): amount Stripe will charge (amount_due), in major currency units. Null when preview is unavailable.

Parameters :
Name Type Optional
subscriptionId string No
Returns : Promise<literal type>
Private Async runWithStripeRetry
runWithStripeRetry(label: string, fn: () => void)
Type parameters :
  • T

Extra retries for flaky networks (SDK already retries a few times).

Parameters :
Name Type Optional
label string No
fn function No
Returns : Promise<T>
Async setCustomerDefaultPaymentMethod
setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string)

Set default payment method for a customer

Parameters :
Name Type Optional
customerId string No
paymentMethodId string No
Returns : Promise<Stripe.Customer>
Async setSubscriptionCancelAtPeriodEnd
setSubscriptionCancelAtPeriodEnd(subscriptionId: string, cancelAtPeriodEnd: boolean)

Schedule or clear end-of-period cancellation (Stripe Billing).

Parameters :
Name Type Optional
subscriptionId string No
cancelAtPeriodEnd boolean No
Returns : Promise<Stripe.Subscription>
Private stripeMinorToMajor
stripeMinorToMajor(amountMinor: number, currencyLower: string)

Convert Stripe smallest-currency-unit amounts to major units (handles zero-decimal currencies).

Parameters :
Name Type Optional
amountMinor number No
currencyLower string No
Returns : number
Async updateCustomerSubscriptionsCollectionMethod
updateCustomerSubscriptionsCollectionMethod(customerId: string, autoPaymentEnabled: boolean)

Update all active subscriptions for a customer's collection method

Parameters :
Name Type Optional
customerId string No
autoPaymentEnabled boolean No
Returns : Promise<Stripe.Subscription[]>
Async updateSubscriptionBillingCycleAnchor
updateSubscriptionBillingCycleAnchor(subscriptionId: string, billingCycleAnchorUnix: number)

Move Stripe subscription next invoice anchor to a target future date without proration. Useful after immediate recovery charge so next renewal aligns to local schedule.

Parameters :
Name Type Optional
subscriptionId string No
billingCycleAnchorUnix number No
Returns : Promise<Stripe.Subscription>
Async updateSubscriptionCollectionMethod
updateSubscriptionCollectionMethod(subscriptionId: string, autoPaymentEnabled: boolean)

Update subscription collection method (enable/disable auto payment) Note: collection_method is set on subscriptions, not directly on customers

Parameters :
Name Type Optional
subscriptionId string No
autoPaymentEnabled boolean No
Returns : Promise<Stripe.Subscription>
verifyWebhookSignature
verifyWebhookSignature(payload: string | Buffer, signature: string)

Verify webhook signature

Parameters :
Name Type Optional
payload string | Buffer No
signature string No
Returns : Stripe.Event

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripeService.name)
Private stripe
Type : Stripe | undefined
import { Injectable, Logger } from "@nestjs/common";
import Stripe from "stripe";
import { StripeConfig } from "../../../../config/stripe.config";

@Injectable()
export class StripeService {
  private readonly logger = new Logger(StripeService.name);
  private stripe: Stripe | undefined;

  constructor(private readonly stripeConfig: StripeConfig) {
    if (!this.stripeConfig.isConfigured()) {
      this.logger.warn("Stripe is not configured. Payment features will be disabled.");
    } else {
      // Note: apiVersion must match the types installed with `stripe` package
      // If you update the Stripe API version, also update @stripe/stripe-node
      // Using type assertion to support newer API version before types are updated
      this.stripe = new Stripe(this.stripeConfig.secretKey, {
        apiVersion: "2025-12-15.clover" as any,
        typescript: true,
        maxNetworkRetries: 5,
        timeout: 120_000,
      });
      this.logger.log("Stripe service initialized successfully");
    }
  }

  /**
   * Create a Checkout Session for participant payment
   * All payments go to RecallAssess's single Stripe account
   */
  async createCheckoutSession(params: {
    amount: number; // in cents
    currency: string;
    successUrl: string;
    cancelUrl: string;
    customerEmail?: string;
    customerId?: string; // Stripe customer ID - if provided, payment method will be saved
    metadata?: Record<string, string>;
    lineItems?: Stripe.Checkout.SessionCreateParams.LineItem[];
  }): Promise<Stripe.Checkout.Session> {
    try {
      const sessionParams: Stripe.Checkout.SessionCreateParams = {
        mode: "payment",
        payment_method_types: ["card"],
        line_items: params.lineItems || [
          {
            price_data: {
              currency: params.currency,
              product_data: {
                name: "Recall Course License",
                description: "Access to learning course",
              },
              unit_amount: params.amount,
            },
            quantity: 1,
          },
        ],
        success_url: params.successUrl,
        cancel_url: params.cancelUrl,
        metadata: params.metadata,
        // Note: payment_method_collection is only valid for subscription mode
        // For one-time payments (mode: "payment"), payment method is automatically saved when customer ID is provided
      };

      // If customer ID is provided, attach to customer (saves payment method automatically)
      if (params.customerId) {
        sessionParams.customer = params.customerId;
        // When customer is set, payment method is automatically saved for one-time payments
        this.logger.log(`Checkout session will save payment method to customer: ${params.customerId}`);
      } else if (params.customerEmail) {
        // If no customer ID but email provided, Stripe will create/use customer by email
        sessionParams.customer_email = params.customerEmail;
        // Payment method will still be saved if customer exists
        this.logger.log(`Checkout session will use customer email: ${params.customerEmail}`);
      }

      const session = await this.getStripe().checkout.sessions.create(sessionParams);

      return session;
    } catch (error) {
      this.logger.error("Failed to create checkout session", error);
      throw new Error("Failed to create payment session");
    }
  }

  /**
   * Create a Payment Intent (for custom payment flows)
   */
  async createPaymentIntent(params: {
    amount: number;
    currency: string;
    metadata?: Record<string, string>;
    customerId?: string;
    automaticPaymentMethods?: Stripe.PaymentIntentCreateParams.AutomaticPaymentMethods;
  }): Promise<Stripe.PaymentIntent> {
    try {
      // Payment Intent should only be used for amounts > 0
      // For $0 amounts, use Setup Intent instead (handled in stripe-payment.service.ts)
      return await this.getStripe().paymentIntents.create({
        amount: params.amount,
        currency: params.currency,
        metadata: params.metadata,
        customer: params.customerId,
        ...(params.customerId
          ? {
              // Required so the card can be reused as default for invoices/subscriptions after signup charge.
              setup_future_usage: "off_session" as const,
            }
          : {}),
        automatic_payment_methods: params.automaticPaymentMethods,
      });
    } catch (error) {
      this.logger.error("Failed to create payment intent", error);
      throw new Error("Failed to create payment intent");
    }
  }

  /**
   * Create a Setup Intent (for saving payment methods without charging)
   * Used for free trials where we want to collect card details but charge later
   */
  async createSetupIntent(params: {
    customerId: string;
    metadata?: Record<string, string>;
  }): Promise<Stripe.SetupIntent> {
    try {
      return await this.getStripe().setupIntents.create({
        customer: params.customerId,
        payment_method_types: ["card"],
        usage: "off_session", // Save for future use
        metadata: params.metadata,
      });
    } catch (error) {
      this.logger.error("Failed to create setup intent", error);
      throw new Error("Failed to create setup intent");
    }
  }

  /**
   * Retrieve Setup Intent
   */
  async retrieveSetupIntent(setupIntentId: string): Promise<Stripe.SetupIntent> {
    try {
      return await this.getStripe().setupIntents.retrieve(setupIntentId);
    } catch (error) {
      this.logger.error("Failed to retrieve setup intent", error);
      throw new Error("Failed to retrieve setup intent");
    }
  }

  /**
   * Retrieve Payment Intent
   */
  async retrievePaymentIntent(paymentIntentId: string, expand?: string[]): Promise<Stripe.PaymentIntent> {
    try {
      return await this.getStripe().paymentIntents.retrieve(
        paymentIntentId,
        expand?.length ? { expand } : undefined,
      );
    } catch (error) {
      this.logger.error(`Failed to retrieve payment intent ${paymentIntentId}`, error);
      throw new Error("Failed to retrieve payment");
    }
  }

  /**
   * Retrieve Charge (e.g. for receipt_url when PaymentIntent.latest_charge is only an id string)
   */
  async retrieveCharge(chargeId: string): Promise<Stripe.Charge> {
    try {
      return await this.getStripe().charges.retrieve(chargeId);
    } catch (error) {
      this.logger.error(`Failed to retrieve charge ${chargeId}`, error);
      throw new Error("Failed to retrieve charge");
    }
  }

  /**
   * Retrieve Refund (useful for debugging/manual sync)
   */
  async retrieveRefund(refundId: string): Promise<Stripe.Refund> {
    try {
      return await this.getStripe().refunds.retrieve(refundId);
    } catch (error) {
      this.logger.error(`Failed to retrieve refund ${refundId}`, error);
      throw new Error("Failed to retrieve refund");
    }
  }

  /**
   * List invoices for a Stripe customer (paginates up to `max`, newest first).
   */
  async listInvoicesForCustomer(params: {
    customerId: string;
    max?: number;
    status?: Stripe.Invoice.Status | "all";
  }): Promise<Stripe.Invoice[]> {
    const max = Math.max(1, Math.min(params.max ?? 200, 500));
    return this.runWithStripeRetry(`listInvoicesForCustomer(${params.customerId})`, async () => {
      const out: Stripe.Invoice[] = [];
      let startingAfter: string | undefined;
      while (out.length < max) {
        const status = params.status;
        const res = await this.getStripe().invoices.list({
          customer: params.customerId,
          ...(status && status !== "all" ? { status } : {}),
          limit: Math.min(100, max - out.length),
          ...(startingAfter ? { starting_after: startingAfter } : {}),
        });
        out.push(...res.data);
        if (!res.has_more || res.data.length === 0) break;
        startingAfter = res.data[res.data.length - 1]?.id;
        if (!startingAfter) break;
      }
      return out;
    });
  }

  /**
   * List charges for a Stripe customer (paginates up to `max`, newest first).
   */
  async listChargesForCustomer(params: { customerId: string; max?: number }): Promise<Stripe.Charge[]> {
    const max = Math.max(1, Math.min(params.max ?? 200, 500));
    return this.runWithStripeRetry(`listChargesForCustomer(${params.customerId})`, async () => {
      const out: Stripe.Charge[] = [];
      let startingAfter: string | undefined;
      while (out.length < max) {
        const res = await this.getStripe().charges.list({
          customer: params.customerId,
          limit: Math.min(100, max - out.length),
          ...(startingAfter ? { starting_after: startingAfter } : {}),
        });
        out.push(...res.data);
        if (!res.has_more || res.data.length === 0) break;
        startingAfter = res.data[res.data.length - 1]?.id;
        if (!startingAfter) break;
      }
      return out;
    });
  }

  async listRefundsForCharge(params: { chargeId: string; max?: number }): Promise<Stripe.Refund[]> {
    const max = Math.max(1, Math.min(params.max ?? 100, 500));
    return this.runWithStripeRetry(`listRefundsForCharge(${params.chargeId})`, async () => {
      const out: Stripe.Refund[] = [];
      let startingAfter: string | undefined;
      while (out.length < max) {
        const res = await this.getStripe().refunds.list({
          charge: params.chargeId,
          limit: Math.min(100, max - out.length),
          ...(startingAfter ? { starting_after: startingAfter } : {}),
        });
        out.push(...res.data);
        if (!res.has_more || res.data.length === 0) break;
        startingAfter = res.data[res.data.length - 1]?.id;
        if (!startingAfter) break;
      }
      return out;
    });
  }

  async listRefundsForPaymentIntent(params: { paymentIntentId: string; max?: number }): Promise<Stripe.Refund[]> {
    const max = Math.max(1, Math.min(params.max ?? 100, 500));
    return this.runWithStripeRetry(`listRefundsForPaymentIntent(${params.paymentIntentId})`, async () => {
      const out: Stripe.Refund[] = [];
      let startingAfter: string | undefined;
      while (out.length < max) {
        const res = await this.getStripe().refunds.list({
          payment_intent: params.paymentIntentId,
          limit: Math.min(100, max - out.length),
          ...(startingAfter ? { starting_after: startingAfter } : {}),
        });
        out.push(...res.data);
        if (!res.has_more || res.data.length === 0) break;
        startingAfter = res.data[res.data.length - 1]?.id;
        if (!startingAfter) break;
      }
      return out;
    });
  }

  /**
   * Set default payment method for a customer
   */
  async setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string): Promise<Stripe.Customer> {
    try {
      return await this.getStripe().customers.update(customerId, {
        invoice_settings: {
          default_payment_method: paymentMethodId,
        } as any,
      });
    } catch (error) {
      this.logger.error(`Failed to set default payment method for customer ${customerId}`, error);
      throw new Error("Failed to set default payment method");
    }
  }

  /**
   * Attach a payment method to a customer (safe to call if already attached).
   * Needed when a PaymentIntent succeeds but does not persist the card as a saved PM.
   */
  async attachPaymentMethodToCustomer(paymentMethodId: string, customerId: string): Promise<void> {
    try {
      await this.getStripe().paymentMethods.attach(paymentMethodId, { customer: customerId });
    } catch (error) {
      // Stripe throws when already attached to the same customer; treat that as OK.
      const msg = error instanceof Error ? error.message : String(error);
      if (/already attached/i.test(msg)) {
        return;
      }
      this.logger.error(
        `Failed to attach payment method ${paymentMethodId} to customer ${customerId}`,
        error,
      );
      throw new Error("Failed to attach payment method");
    }
  }

  /**
   * If the customer has saved card payment methods but no invoice default, set the newest card
   * as {@link Stripe.Customer.invoice_settings.default_payment_method} so subscriptions can charge.
   * @returns true when a default was already set or was just assigned
   */
  async ensureDefaultPaymentMethodFromSavedCards(customerId: string): Promise<boolean> {
    return this.runWithStripeRetry(`ensureDefaultPaymentMethodFromSavedCards(${customerId})`, async () => {
      const customer = await this.getStripe().customers.retrieve(customerId, {
        expand: ["invoice_settings.default_payment_method"],
      });
      if ("deleted" in customer && customer.deleted) {
        return false;
      }
      const inv = customer.invoice_settings?.default_payment_method;
      if (inv && (typeof inv === "string" || inv.object === "payment_method")) {
        return true;
      }

      const pms = await this.getStripe().paymentMethods.list({
        customer: customerId,
        type: "card",
        limit: 100,
      });
      if (pms.data.length === 0) {
        return false;
      }
      const newest = [...pms.data].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))[0];
      if (!newest) {
        return false;
      }

      await this.getStripe().customers.update(customerId, {
        invoice_settings: {
          default_payment_method: newest.id,
        },
      });
      this.logger.log(
        `Set invoice default payment method for customer ${customerId} to saved card ${newest.id} (newest by created time)`,
      );
      return true;
    });
  }

  /**
   * Customer's invoice default payment method id (use after {@link ensureDefaultPaymentMethodFromSavedCards}).
   */
  async getInvoiceDefaultPaymentMethodId(customerId: string): Promise<string | null> {
    return this.runWithStripeRetry(`getInvoiceDefaultPaymentMethodId(${customerId})`, async () => {
      const customer = await this.getStripe().customers.retrieve(customerId, {
        expand: ["invoice_settings.default_payment_method"],
      });
      if ("deleted" in customer && customer.deleted) {
        return null;
      }
      const inv = customer.invoice_settings?.default_payment_method;
      if (typeof inv === "string") {
        return inv;
      }
      if (inv && inv.object === "payment_method") {
        return inv.id;
      }
      return null;
    });
  }

  /**
   * Retrieve Checkout Session
   */
  async retrieveCheckoutSession(sessionId: string, expand?: string[]): Promise<Stripe.Checkout.Session> {
    try {
      return await this.getStripe().checkout.sessions.retrieve(sessionId, expand?.length ? { expand } : undefined);
    } catch (error) {
      this.logger.error(`Failed to retrieve checkout session ${sessionId}`, error);
      throw new Error("Failed to retrieve checkout session");
    }
  }

  /**
   * Verify webhook signature
   */
  verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
    const secrets = this.stripeConfig.getWebhookSecrets();
    if (secrets.length === 0) {
      throw new Error("No STRIPE_WEBHOOK_SECRET configured");
    }
    let lastError: unknown;
    for (const secret of secrets) {
      try {
        return this.getStripe().webhooks.constructEvent(payload, signature, secret);
      } catch (error) {
        lastError = error;
      }
    }
    this.logger.error(
      "Webhook signature verification failed for all configured secrets. If using Stripe CLI, append the current `whsec_` from `stripe listen` to STRIPE_WEBHOOK_SECRET (comma-separated).",
      lastError,
    );
    throw new Error("Invalid webhook signature");
  }

  /**
   * Check if Stripe is properly configured
   */
  isConfigured(): boolean {
    return this.stripeConfig.isConfigured();
  }

  /** Matches {@link StripeConfig.getDashboardAccountMode} — for admin dashboard links. */
  getDashboardAccountMode(): "test" | "live" {
    return this.stripeConfig.getDashboardAccountMode();
  }

  /**
   * Get publishable key (safe to expose to frontend)
   */
  getPublishableKey(): string {
    return this.stripeConfig.publishableKey;
  }

  /**
   * Get payment intent by ID
   */
  async getPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
    try {
      return await this.getStripe().paymentIntents.retrieve(paymentIntentId);
    } catch (error) {
      this.logger.error(`Failed to retrieve payment intent ${paymentIntentId}`, error);
      throw new Error("Failed to retrieve payment intent");
    }
  }

  /**
   * Retrieve default payment method details for a Stripe customer.
   *
   * Lookup order (each branch logged so missing-card issues are debuggable):
   *   1. customer.invoice_settings.default_payment_method (the canonical default)
   *   2. Default PM on any active/trialing subscription for this customer
   *   3. Most recently attached card on the customer (catches Portal-added cards
   *      that haven't propagated to invoice_settings yet)
   *   4. ORPHAN-CUSTOMER FALLBACK: if `companyEmail` and/or `companyId` provided,
   *      search Stripe customers by email — pick the one whose metadata.company_id
   *      matches AND has a card. Catches the case where a previous Stripe customer
   *      record was orphaned (e.g. duplicate created) and the card lives there.
   *   5. Self-heal: if a card is found but not set as customer default, set it now
   *      so subsequent retrievals are fast and the next charge cycle works.
   *
   * NEVER throws. Top-level failures (network/auth) are logged and return nulls
   * so the overview endpoint stays up — frontend treats nulls as "no card on file"
   * which renders the "Add payment method" CTA.
   */
  async getCustomerDefaultPaymentMethod(
    customerId: string,
    companyContext?: { companyId?: number | null; email?: string | null },
  ): Promise<{
    last4: string | null;
    brand: string | null;
    exp_month: number | null;
    exp_year: number | null;
    /** Effective customer ID where the card was actually found — may differ from input customerId for orphan-recovery cases. Caller can persist this to fix company.stripe_customer_id. */
    effective_customer_id?: string | null;
  }> {
    const empty = { last4: null, brand: null, exp_month: null, exp_year: null };
    let defaultPaymentMethod: Stripe.PaymentMethod | null = null;
    let foundVia: "invoice_settings" | "subscription" | "attached_pm_list" | "email_fallback" | null = null;
    let effectiveCustomerId: string = customerId;

    try {
      // ── Branch 1: customer.invoice_settings.default_payment_method ───────────
      let customer: Stripe.Customer | Stripe.DeletedCustomer;
      try {
        customer = await this.getStripe().customers.retrieve(customerId, {
          expand: ["invoice_settings.default_payment_method"],
        });
      } catch (retrieveError) {
        this.logger.warn(
          `getCustomerDefaultPaymentMethod: customers.retrieve failed for ${customerId}: ` +
            (retrieveError instanceof Error ? retrieveError.message : String(retrieveError)),
        );
        // Don't return early — drop into email fallback below using a fake customer.
        // We still want orphan-recovery if the configured customer is invalid.
        customer = { id: customerId, deleted: true } as unknown as Stripe.DeletedCustomer;
      }

      if ("deleted" in customer && customer.deleted) {
        this.logger.warn(
          `getCustomerDefaultPaymentMethod: customer ${customerId} is deleted/invalid in Stripe — ` +
            `attempting email fallback if email provided`,
        );
        // Skip branches 1-3 (need a valid customer), go straight to email fallback
      }

      const customerIsValid = !("deleted" in customer && customer.deleted);

      const invSettingsPm = customerIsValid
        ? (customer as Stripe.Customer).invoice_settings?.default_payment_method
        : null;
      if (invSettingsPm && typeof invSettingsPm === "object" && invSettingsPm.object === "payment_method") {
        defaultPaymentMethod = invSettingsPm as Stripe.PaymentMethod;
        foundVia = "invoice_settings";
      } else if (typeof invSettingsPm === "string") {
        // Defensive: expansion didn't resolve — retrieve manually
        try {
          defaultPaymentMethod = await this.getStripe().paymentMethods.retrieve(invSettingsPm);
          foundVia = "invoice_settings";
        } catch (pmRetrieveError) {
          this.logger.warn(
            `getCustomerDefaultPaymentMethod: invoice_settings PM ${invSettingsPm} retrieve failed: ` +
              (pmRetrieveError instanceof Error ? pmRetrieveError.message : String(pmRetrieveError)),
          );
        }
      }

      // ── Branch 2: subscription.default_payment_method (active/trialing) ──────
      if (!defaultPaymentMethod && customerIsValid) {
        for (const status of ["active", "trialing"] as const) {
          try {
            const subscriptions = await this.getStripe().subscriptions.list({
              customer: customerId,
              status,
              limit: 1,
              expand: ["data.default_payment_method"],
            });
            const sub = subscriptions.data[0];
            if (sub?.default_payment_method) {
              defaultPaymentMethod =
                typeof sub.default_payment_method === "string"
                  ? await this.getStripe().paymentMethods.retrieve(sub.default_payment_method)
                  : (sub.default_payment_method as Stripe.PaymentMethod);
              foundVia = "subscription";
              break;
            }
          } catch (subError) {
            this.logger.warn(
              `getCustomerDefaultPaymentMethod: ${status}-subscription lookup failed for ${customerId}: ` +
                (subError instanceof Error ? subError.message : String(subError)),
            );
          }
        }
      }

      // ── Branch 3: most recent attached card (Portal-added cards land here) ───
      if (!defaultPaymentMethod && customerIsValid) {
        try {
          const list = await this.getStripe().paymentMethods.list({
            customer: customerId,
            type: "card",
            limit: 10,
          });
          if (list.data.length > 0) {
            const newest = [...list.data].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))[0];
            defaultPaymentMethod = newest;
            foundVia = "attached_pm_list";
            this.logger.debug(
              `getCustomerDefaultPaymentMethod: using most-recent attached PM ${newest.id} for ${customerId} ` +
                `(${list.data.length} card(s) attached, none set as default)`,
            );
          } else {
            this.logger.debug(
              `getCustomerDefaultPaymentMethod: paymentMethods.list returned 0 cards for ${customerId}`,
            );
          }
        } catch (listError) {
          this.logger.warn(
            `getCustomerDefaultPaymentMethod: paymentMethods.list failed for ${customerId}: ` +
              (listError instanceof Error ? listError.message : String(listError)),
          );
        }
      }

      // ── Branch 4: ORPHAN-CUSTOMER FALLBACK via email + metadata.company_id ───
      // Catches the rare-but-real case where the company's stripe_customer_id
      // points to a customer that has no card, but an older/duplicate Stripe
      // customer for the same company DOES have one (typically because a
      // previous integration created a fresh customer without migrating PMs).
      if (!defaultPaymentMethod && companyContext?.email) {
        try {
          const candidates = await this.getStripe().customers.list({
            email: companyContext.email,
            limit: 20,
          });
          const expectedCompanyIdStr =
            companyContext.companyId != null ? String(companyContext.companyId) : null;

          // Score candidates: matching company_id metadata > any candidate with a card
          const otherCandidates = candidates.data.filter((c) => c.id !== customerId);
          this.logger.debug(
            `getCustomerDefaultPaymentMethod: email fallback for ${customerId} found ` +
              `${otherCandidates.length} other Stripe customer(s) sharing email ${companyContext.email}`,
          );

          for (const candidate of otherCandidates) {
            const metadataCompanyId = candidate.metadata?.["company_id"] ?? null;
            const matchesCompany =
              !expectedCompanyIdStr || metadataCompanyId === expectedCompanyIdStr;
            if (!matchesCompany) continue;

            try {
              const candidatePMs = await this.getStripe().paymentMethods.list({
                customer: candidate.id,
                type: "card",
                limit: 10,
              });
              if (candidatePMs.data.length === 0) continue;

              const newest = [...candidatePMs.data].sort(
                (a, b) => (b.created ?? 0) - (a.created ?? 0),
              )[0];
              defaultPaymentMethod = newest;
              foundVia = "email_fallback";
              effectiveCustomerId = candidate.id;
              this.logger.warn(
                `getCustomerDefaultPaymentMethod: ORPHAN customer detected — ` +
                  `configured customer=${customerId} has no card, but candidate ` +
                  `${candidate.id} (metadata.company_id=${metadataCompanyId ?? "<none>"}) ` +
                  `has card ${newest.id}. Caller should reconcile company.stripe_customer_id.`,
              );
              break;
            } catch (candidatePMError) {
              this.logger.warn(
                `getCustomerDefaultPaymentMethod: candidate ${candidate.id} PM list failed: ` +
                  (candidatePMError instanceof Error ? candidatePMError.message : String(candidatePMError)),
              );
            }
          }
        } catch (emailError) {
          this.logger.warn(
            `getCustomerDefaultPaymentMethod: email-fallback customers.list failed for ${companyContext.email}: ` +
              (emailError instanceof Error ? emailError.message : String(emailError)),
          );
        }
      }

      if (!defaultPaymentMethod || defaultPaymentMethod.object !== "payment_method") {
        this.logger.log(
          `getCustomerDefaultPaymentMethod: no card found for customer ${customerId} ` +
            `(all branches empty${companyContext?.email ? ", including email fallback" : ", email fallback skipped — no email"})`,
        );
        return { ...empty, effective_customer_id: null };
      }

      const card = defaultPaymentMethod.card;
      if (!card) {
        this.logger.warn(
          `getCustomerDefaultPaymentMethod: PM ${defaultPaymentMethod.id} has no .card object (type=${defaultPaymentMethod.type})`,
        );
        return { ...empty, effective_customer_id: null };
      }

      // ── Branch 5 (self-heal): persist the discovered PM as customer default ──
      // For invoice_settings/subscription/attached_pm_list cases, update the
      // SAME customer. For email_fallback, update the candidate customer (the
      // one that actually owns the card) — caller is responsible for moving
      // company.stripe_customer_id to effectiveCustomerId.
      if (foundVia !== "invoice_settings") {
        this.getStripe()
          .customers.update(effectiveCustomerId, {
            invoice_settings: { default_payment_method: defaultPaymentMethod.id },
          })
          .then(() => {
            this.logger.debug(
              `getCustomerDefaultPaymentMethod: self-healed invoice_settings.default_payment_method=` +
                `${defaultPaymentMethod!.id} on ${effectiveCustomerId}`,
            );
          })
          .catch((healError: unknown) => {
            this.logger.warn(
              `getCustomerDefaultPaymentMethod: self-heal update failed for ${effectiveCustomerId}: ` +
                (healError instanceof Error ? healError.message : String(healError)),
            );
          });
      }

      this.logger.debug(
        `getCustomerDefaultPaymentMethod: found ${card.brand} •••• ${card.last4} via ${foundVia} ` +
          `(input=${customerId}, effective=${effectiveCustomerId})`,
      );

      return {
        last4: card.last4 ?? null,
        brand: card.brand ?? null,
        exp_month: card.exp_month ?? null,
        exp_year: card.exp_year ?? null,
        effective_customer_id: effectiveCustomerId,
      };
    } catch (error) {
      // Final safety net — never propagate to the overview endpoint.
      this.logger.error(
        `getCustomerDefaultPaymentMethod: unexpected top-level error for ${customerId}`,
        error,
      );
      return { ...empty, effective_customer_id: null };
    }
  }

  /**
   * Get customer invoice settings (collection method for auto payment)
   * Note: collection_method is subscription-scoped, so we check active subscriptions
   */
  async getCustomerInvoiceSettings(customerId: string): Promise<{
    collection_method: "charge_automatically" | "send_invoice" | null;
  }> {
    try {
      // Get subscriptions for the customer and pick the first billable one.
      // Using `all` avoids false negatives for trialing/past_due/unpaid subscriptions.
      const subscriptions = await this.getStripe().subscriptions.list({
        customer: customerId,
        status: "all",
        limit: 100,
      });

      const billableSubscription = subscriptions.data.find((subscription) =>
        this.isBillableSubscriptionStatus(subscription.status),
      );

      if (!billableSubscription) {
        // No active subscriptions, return null
        return {
          collection_method: null,
        };
      }

      // Return the collection_method from the first billable subscription.
      return {
        collection_method:
          (billableSubscription.collection_method as "charge_automatically" | "send_invoice") ?? null,
      };
    } catch (error) {
      this.logger.error(`Failed to retrieve invoice settings for customer ${customerId}`, error);
      throw new Error("Failed to retrieve invoice settings");
    }
  }

  /**
   * Update subscription collection method (enable/disable auto payment)
   * Note: collection_method is set on subscriptions, not directly on customers
   */
  async updateSubscriptionCollectionMethod(
    subscriptionId: string,
    autoPaymentEnabled: boolean,
  ): Promise<Stripe.Subscription> {
    try {
      const collectionMethod = autoPaymentEnabled ? "charge_automatically" : "send_invoice";
      const data: Stripe.SubscriptionUpdateParams = {
        collection_method: collectionMethod,
        cancel_at_period_end: !autoPaymentEnabled,
      };
      if (!autoPaymentEnabled) {
        // Stripe requires days_until_due when using send_invoice.
        data.days_until_due = 30;
      }

      return await this.getStripe().subscriptions.update(subscriptionId, data);
    } catch (error) {
      this.logger.error(`Failed to update subscription collection method for ${subscriptionId}`, error);
      throw new Error("Failed to update subscription collection method");
    }
  }

  /**
   * Update all active subscriptions for a customer's collection method
   */
  async updateCustomerSubscriptionsCollectionMethod(
    customerId: string,
    autoPaymentEnabled: boolean,
  ): Promise<Stripe.Subscription[]> {
    // List all subscriptions and update billable non-canceled ones.
    // This prevents "success with no-op" when subscription is trialing/past_due/unpaid.
    const subscriptions = await this.getStripe().subscriptions.list({
      customer: customerId,
      status: "all",
      limit: 100,
    });

    const collectionMethod = autoPaymentEnabled ? "charge_automatically" : "send_invoice";
    const updatedSubscriptions: Stripe.Subscription[] = [];
    const targetSubscriptions = subscriptions.data.filter((subscription) =>
      this.isBillableSubscriptionStatus(subscription.status),
    );

    this.logger.log(
      `Updating ${targetSubscriptions.length} subscription(s) for customer ${customerId} to collection_method: ${collectionMethod}`,
    );

    // Update each billable subscription. Do not fail the full operation for one bad row.
    for (const subscription of targetSubscriptions) {
      try {
        const data: Stripe.SubscriptionUpdateParams = {
          collection_method: collectionMethod,
          cancel_at_period_end: !autoPaymentEnabled,
        };
        if (!autoPaymentEnabled) {
          // Stripe requires days_until_due when using send_invoice.
          data.days_until_due = 30;
        }
        const updated = await this.getStripe().subscriptions.update(subscription.id, data);
        updatedSubscriptions.push(updated);
        this.logger.debug(
          `Updated subscription ${subscription.id}: collection_method = ${updated.collection_method}`,
        );
      } catch (error) {
        this.logger.warn(
          `Failed updating subscription ${subscription.id} collection_method: ${
            error instanceof Error ? error.message : String(error)
          }`,
        );
      }
    }

    this.logger.log(
      `Successfully updated ${updatedSubscriptions.length} subscription(s) collection method to ${collectionMethod}`,
    );

    return updatedSubscriptions;
  }

  private isBillableSubscriptionStatus(status: Stripe.Subscription.Status): boolean {
    return ["active", "trialing", "past_due", "unpaid", "incomplete"].includes(status);
  }

  /** True for transient Stripe/network issues worth retrying after SDK-level retries exhaust. */
  private isRetryableStripeError(error: unknown): boolean {
    if (error && typeof error === "object" && "type" in error) {
      const t = (error as { type?: string }).type;
      if (t === "StripeConnectionError") {
        return true;
      }
    }
    const msg = error instanceof Error ? error.message : String(error);
    if (
      /connection to Stripe|ECONNRESET|ETIMEDOUT|socket hang up|ENETUNREACH|EAI_AGAIN|network/i.test(msg)
    ) {
      return true;
    }
    const code = (error as { statusCode?: number }).statusCode;
    if (code === 429) {
      return true;
    }
    return false;
  }

  /** Extra retries for flaky networks (SDK already retries a few times). */
  private async runWithStripeRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
    const maxAttempts = 4;
    const baseMs = 1000;
    let lastError: unknown;
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        return await fn();
      } catch (e) {
        lastError = e;
        const canRetry = this.isRetryableStripeError(e) && attempt < maxAttempts - 1;
        if (!canRetry) {
          throw e;
        }
        const wait = baseMs * 2 ** attempt;
        this.logger.warn(
          `${label} failed (attempt ${attempt + 1}/${maxAttempts}): ${
            e instanceof Error ? e.message : String(e)
          } — retrying in ${wait}ms`,
        );
        await new Promise((r) => setTimeout(r, wait));
      }
    }
    throw lastError;
  }

  async retrieveSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
    try {
      return await this.getStripe().subscriptions.retrieve(subscriptionId);
    } catch (error) {
      this.logger.error(`Failed to retrieve subscription ${subscriptionId}`, error);
      throw new Error("Failed to retrieve subscription");
    }
  }

  /**
   * Next invoice preview for a subscription (`invoices.createPreview`): amount Stripe will charge
   * (`amount_due`), in major currency units. Null when preview is unavailable.
   */
  async retrieveUpcomingInvoiceAmountForSubscription(subscriptionId: string): Promise<{
    amount: number | null;
    currency: string | null;
  }> {
    if (!this.isConfigured()) {
      return { amount: null, currency: null };
    }
    try {
      return await this.runWithStripeRetry(`createPreviewInvoice(${subscriptionId})`, async () => {
        const inv = await this.getStripe().invoices.createPreview({
          subscription: subscriptionId,
        });
        const currency = (inv.currency ?? "usd").toLowerCase();
        const cents = inv.amount_due ?? inv.total ?? 0;
        const amount = this.stripeMinorToMajor(cents, currency);
        return { amount, currency: currency.toUpperCase() };
      });
    } catch (error) {
      this.logger.debug(
        `Upcoming invoice not available for subscription ${subscriptionId}: ${
          error instanceof Error ? error.message : String(error)
        }`,
      );
      return { amount: null, currency: null };
    }
  }

  /** Convert Stripe smallest-currency-unit amounts to major units (handles zero-decimal currencies). */
  private stripeMinorToMajor(amountMinor: number, currencyLower: string): number {
    const zeroDecimal = new Set([
      "bif",
      "clp",
      "djf",
      "gnf",
      "jpy",
      "kmf",
      "krw",
      "mga",
      "pyg",
      "rwf",
      "ugx",
      "vnd",
      "vuv",
      "xaf",
      "xof",
      "xpf",
    ]);
    if (zeroDecimal.has(currencyLower)) {
      return amountMinor;
    }
    return Math.round(amountMinor) / 100;
  }

  /** Schedule or clear end-of-period cancellation (Stripe Billing). */
  async setSubscriptionCancelAtPeriodEnd(
    subscriptionId: string,
    cancelAtPeriodEnd: boolean,
  ): Promise<Stripe.Subscription> {
    return this.runWithStripeRetry(`setSubscriptionCancelAtPeriodEnd(${subscriptionId})`, async () => {
      return await this.getStripe().subscriptions.update(subscriptionId, {
        cancel_at_period_end: cancelAtPeriodEnd,
      });
    });
  }

  /**
   * Subscription with latest_invoice expanded (used after create when status may already be active).
   */
  async retrieveSubscriptionWithLatestInvoice(subscriptionId: string): Promise<Stripe.Subscription> {
    return this.runWithStripeRetry(`retrieveSubscriptionWithLatestInvoice(${subscriptionId})`, async () => {
      return await this.getStripe().subscriptions.retrieve(subscriptionId, {
        expand: ["latest_invoice"],
      });
    });
  }

  /**
   * List subscriptions for a customer (one Stripe API call per status).
   */
  async listSubscriptionsForCustomer(
    customerId: string,
    statuses: Stripe.Subscription.Status[],
  ): Promise<Stripe.Subscription[]> {
    return this.runWithStripeRetry(`listSubscriptionsForCustomer(${customerId})`, async () => {
      const out: Stripe.Subscription[] = [];
      for (const status of statuses) {
        const res = await this.getStripe().subscriptions.list({
          customer: customerId,
          status,
          limit: 100,
        });
        out.push(...res.data);
      }
      return out;
    });
  }

  async cancelCustomerBillableSubscriptions(customerId: string): Promise<string[]> {
    const subscriptions = await this.runWithStripeRetry(
      `cancelCustomerBillableSubscriptions.list(${customerId})`,
      () =>
        this.getStripe().subscriptions.list({
          customer: customerId,
          status: "all",
          limit: 100,
        }),
    );

    const targetSubscriptions = subscriptions.data.filter((subscription) =>
      this.isBillableSubscriptionStatus(subscription.status),
    );
    const cancelledIds: string[] = [];

    for (const subscription of targetSubscriptions) {
      try {
        await this.getStripe().subscriptions.cancel(subscription.id);
        cancelledIds.push(subscription.id);
      } catch (error) {
        this.logger.warn(
          `Failed to cancel subscription ${subscription.id}: ${
            error instanceof Error ? error.message : String(error)
          }`,
        );
      }
    }

    return cancelledIds;
  }

  /**
   * Retrieve invoice by ID
   */
  async getInvoice(invoiceId: string): Promise<Stripe.Invoice> {
    try {
      return await this.getStripe().invoices.retrieve(invoiceId);
    } catch (error) {
      this.logger.error(`Failed to retrieve invoice ${invoiceId}`, error);
      throw new Error("Failed to retrieve invoice");
    }
  }

  /**
   * Same as Stripe CLI:
   * `stripe invoices retrieve <id> --expand 'payment_intent.latest_charge'`
   * plus `lines.data` (useful for line items / periods).
   */
  async getInvoiceExpanded(invoiceId: string): Promise<Stripe.Invoice> {
    return this.runWithStripeRetry(`getInvoiceExpanded(${invoiceId})`, async () => {
      return await this.getStripe().invoices.retrieve(invoiceId, {
        expand: ["payment_intent.latest_charge", "lines.data"],
      });
    });
  }

  /**
   * Attempt to pay the latest invoice of a subscription now.
   * Finalizes draft invoices, passes the customer's invoice default payment method, and uses off_session.
   */
  async attemptPayLatestSubscriptionInvoice(subscriptionId: string): Promise<{
    invoiceId: string | null;
    status: string | null;
    paid: boolean;
  }> {
    return this.runWithStripeRetry(`attemptPayLatestSubscriptionInvoice(${subscriptionId})`, async () => {
      const sub = await this.getStripe().subscriptions.retrieve(subscriptionId, {
        expand: ["latest_invoice"],
      });

      const customerId =
        typeof sub.customer === "string" ? sub.customer : sub.customer && "id" in sub.customer ? sub.customer.id : null;
      if (!customerId) {
        return { invoiceId: null, status: null, paid: false };
      }

      const latest = sub.latest_invoice;
      let invoiceId =
        typeof latest === "string" ? latest : latest && latest.object === "invoice" ? latest.id : null;
      let invoice: Stripe.Invoice | null =
        typeof latest === "object" && latest?.object === "invoice" ? latest : null;

      if (!invoiceId) {
        return { invoiceId: null, status: null, paid: false };
      }
      if (!invoice) {
        invoice = await this.getStripe().invoices.retrieve(invoiceId);
      }

      if (invoice.status === "paid") {
        return { invoiceId: invoice.id, status: "paid", paid: true };
      }

      if (invoice.status === "draft") {
        invoice = await this.getStripe().invoices.finalizeInvoice(invoiceId);
        invoiceId = invoice.id;
      }

      if (invoice.status === "paid") {
        return { invoiceId: invoice.id, status: "paid", paid: true };
      }

      const customer = await this.getStripe().customers.retrieve(customerId, {
        expand: ["invoice_settings.default_payment_method"],
      });
      if ("deleted" in customer && customer.deleted) {
        this.logger.warn(`attemptPayLatestSubscriptionInvoice: customer ${customerId} is deleted`);
        return { invoiceId: invoice.id, status: invoice.status ?? null, paid: false };
      }

      const invPm = customer.invoice_settings?.default_payment_method;
      const pmId =
        typeof invPm === "string" ? invPm : invPm && invPm.object === "payment_method" ? invPm.id : null;

      const payParams: Stripe.InvoicePayParams = {};
      if (pmId) {
        payParams.payment_method = pmId;
        payParams.off_session = true;
      }

      try {
        const paidInv = await this.getStripe().invoices.pay(invoice.id, payParams);
        return {
          invoiceId: paidInv.id,
          status: paidInv.status ?? null,
          paid: paidInv.status === "paid",
        };
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        this.logger.warn(
          `invoices.pay failed for invoice ${invoice.id} (subscription ${subscriptionId}): ${msg}`,
        );
        return { invoiceId: invoice.id, status: invoice.status ?? null, paid: false };
      }
    });
  }

  /**
   * Paid signup funds are captured on a standalone PaymentIntent; {@link createCustomerSubscription}
   * still creates a first-cycle invoice under {@code payment_behavior: default_incomplete}.
   * Without this step that invoice stays “requires payment” even though the customer already paid.
   * Close it via {@code paid_out_of_band} (no second charge — aligns Stripe Billing with the signup PI).
   */
  async markSubscriptionLatestInvoicePaidOutOfBand(subscriptionId: string): Promise<void> {
    await this.runWithStripeRetry(`markSubscriptionLatestInvoicePaidOutOfBand(${subscriptionId})`, async () => {
      const sub = await this.getStripe().subscriptions.retrieve(subscriptionId, {
        expand: ["latest_invoice"],
      });
      const latest = sub.latest_invoice;
      let invoice: Stripe.Invoice | null =
        typeof latest === "object" && latest?.object === "invoice" ? latest : null;
      let invoiceId =
        typeof latest === "string" ? latest : invoice?.id ?? null;
      if (!invoiceId) {
        return;
      }
      if (!invoice) {
        invoice = await this.getStripe().invoices.retrieve(invoiceId);
      }

      if (invoice.status === "paid") {
        return;
      }

      if (invoice.status === "draft") {
        invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
        invoiceId = invoice.id;
      }

      if (invoice.status === "paid") {
        return;
      }
      if (invoice.status === "void" || invoice.status === "uncollectible") {
        return;
      }

      if (typeof invoice.amount_due === "number" && invoice.amount_due <= 0) {
        return;
      }

      await this.getStripe().invoices.pay(invoice.id, { paid_out_of_band: true });
    });
  }

  /**
   * Move Stripe subscription next invoice anchor to a target future date without proration.
   * Useful after immediate recovery charge so next renewal aligns to local schedule.
   */
  async updateSubscriptionBillingCycleAnchor(
    subscriptionId: string,
    billingCycleAnchorUnix: number,
  ): Promise<Stripe.Subscription> {
    return this.runWithStripeRetry(`updateSubscriptionBillingCycleAnchor(${subscriptionId})`, async () => {
      try {
        // Attempt direct anchor move first (older API behavior).
        const updateParams = {
          billing_cycle_anchor: billingCycleAnchorUnix,
          proration_behavior: "none",
        } as unknown as Stripe.SubscriptionUpdateParams;
        return await this.getStripe().subscriptions.update(subscriptionId, updateParams);
      } catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        const anchorRestriction =
          /billing_cycle_anchor must be either unset, 'now', or 'unchanged'/i.test(msg);
        if (!anchorRestriction) {
          throw error;
        }

        // Newer Stripe API behavior: timestamp anchor updates on existing subscriptions are blocked.
        // Fallback: set trial_end to the desired timestamp (no proration), which moves next invoice date.
        this.logger.warn(
          `Stripe rejected direct billing_cycle_anchor timestamp update for ${subscriptionId}; falling back to trial_end anchor.`,
        );
        return await this.getStripe().subscriptions.update(subscriptionId, {
          trial_end: billingCycleAnchorUnix,
          proration_behavior: "none",
        });
      }
    });
  }

  /**
   * Create a Stripe billing portal session so customers can manage payment methods
   */
  async createBillingPortalSession(params: { customerId: string; returnUrl: string }): Promise<{ url: string }> {
    try {
      const session = await this.getStripe().billingPortal.sessions.create({
        customer: params.customerId,
        return_url: params.returnUrl,
      });

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

      return {
        url: session.url,
      };
    } catch (error) {
      this.logger.error(`Failed to create billing portal session for customer ${params.customerId}`, error);
      throw new Error("Failed to create billing portal session");
    }
  }

  /**
   * One-off invoice for a fixed USD amount, finalized and paid off-session with a specific payment method.
   * Used for expired recovery “immediate charge” without creating a subscription invoice first (avoids SetupIntent-only flows).
   */
  async createFinalizeAndPayOneOffInvoice(params: {
    customerId: string;
    amountCents: number;
    currency: string;
    description: string;
    paymentMethodId: string;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Invoice> {
    return this.runWithStripeRetry(`createFinalizeAndPayOneOffInvoice(${params.customerId})`, async () => {
      // Create an empty draft first, then attach the line to this invoice explicitly.
      // Relying on "pending" invoice items + invoices.create() can yield $0 invoices with no lines
      // (items attach to another invoice or are skipped depending on timing / account behavior).
      let invoice = await this.getStripe().invoices.create({
        customer: params.customerId,
        currency: params.currency,
        collection_method: "charge_automatically",
        auto_advance: false,
        metadata: params.metadata,
      });

      await this.getStripe().invoiceItems.create({
        customer: params.customerId,
        invoice: invoice.id,
        currency: params.currency,
        amount: params.amountCents,
        description: params.description,
        metadata: params.metadata,
      });

      invoice = await this.getStripe().invoices.retrieve(invoice.id);

      if (invoice.status === "draft") {
        invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
      }
      if (invoice.status === "paid") {
        return invoice;
      }
      return await this.getStripe().invoices.pay(invoice.id, {
        payment_method: params.paymentMethodId,
        off_session: true,
      });
    });
  }

  /**
   * Create a Stripe invoice for reporting/records and mark it paid out-of-band (no additional charge).
   * Use when a separate PaymentIntent already charged the customer but you still want a Stripe Invoice
   * with the correct amount in Billing → Invoices.
   */
  async createFinalizeAndMarkInvoicePaidOutOfBand(params: {
    customerId: string;
    amountCents: number;
    currency: string;
    description: string;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Invoice> {
    return this.runWithStripeRetry(`createFinalizeAndMarkInvoicePaidOutOfBand(${params.customerId})`, async () => {
      // `charge_automatically` + finalize always spun up an invoice PI canceled by immediate `paid_out_of_band`.
      // `send_invoice` reduces that; Stripe may still attach a PI to the open invoice until we pay OOB (signup PI already captured funds).
      let invoice = await this.getStripe().invoices.create({
        customer: params.customerId,
        currency: params.currency,
        collection_method: "send_invoice",
        days_until_due: 30,
        auto_advance: false,
        metadata: params.metadata,
      });

      await this.getStripe().invoiceItems.create({
        customer: params.customerId,
        invoice: invoice.id,
        currency: params.currency,
        amount: params.amountCents,
        description: params.description,
        metadata: params.metadata,
      });

      invoice = await this.getStripe().invoices.retrieve(invoice.id);
      if (invoice.status === "draft") {
        invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
      }
      if (invoice.status === "paid") {
        return invoice;
      }

      // Mark paid without charging (PaymentIntent already captured funds).
      return await this.getStripe().invoices.pay(invoice.id, {
        paid_out_of_band: true,
      });
    });
  }

  /**
   * Recurring subscription in `trialing` until {@param trialEndUnix} — no first invoice until trial ends.
   * Use after a separate one-off invoice has collected the lapsed period.
   */
  async createTrialingSubscription(params: {
    customerId: string;
    defaultPaymentMethodId: string;
    billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
    totalAmountCentsPerCycle: number;
    trialEndUnix: number;
    metadata: Record<string, string>;
  }): Promise<Stripe.Subscription> {
    return this.runWithStripeRetry(`createTrialingSubscription(${params.customerId})`, async () => {
      const price = await this.createRecallAssessRecurringPrice(
        params.billingCycle,
        params.totalAmountCentsPerCycle,
      );
      return await this.getStripe().subscriptions.create({
        customer: params.customerId,
        items: [{ price: price.id }],
        collection_method: "charge_automatically",
        default_payment_method: params.defaultPaymentMethodId,
        // Do not enable Stripe automatic_tax: totals already include app-level processing + UAE VAT
        // (see calculateRenewalChargeCents), and automatic_tax requires a valid customer tax address.
        trial_end: params.trialEndUnix,
        metadata: params.metadata,
      });
    });
  }

  private normalizePlanBillingCycle(billingCycle: string): "QUARTERLY" | "HALF_YEARLY" | "ANNUAL" {
    const u = (billingCycle ?? "QUARTERLY").trim().toUpperCase();
    if (u === "HALF_YEARLY") return "HALF_YEARLY";
    if (u === "ANNUAL") return "ANNUAL";
    return "QUARTERLY";
  }

  private async createRecallAssessRecurringPrice(
    billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL",
    unitAmountCents: number,
  ): Promise<Stripe.Price> {
    const bc = this.normalizePlanBillingCycle(billingCycle);
    const recurring =
      bc === "ANNUAL"
        ? { interval: "year" as const, interval_count: 1 }
        : {
            interval: "month" as const,
            interval_count: bc === "HALF_YEARLY" ? 6 : 3,
          };
    const product = await this.getStripe().products.create({
      name: "RecallAssess Subscription",
    });
    return await this.getStripe().prices.create({
      currency: "usd",
      unit_amount: unitAmountCents,
      recurring,
      product: product.id,
    });
  }

  /**
   * Create a Stripe subscription for a customer using inline price_data.
   * This is used to bootstrap renewal in Stripe when local subscription exists
   * but no Stripe subscription record is present.
   */
  async createCustomerSubscription(params: {
    customerId: string;
    billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
    totalAmountCentsPerCycle: number;
    autoPaymentEnabled: boolean;
    trialEndUnix?: number;
    /** Used for expired recovery to align next invoice date to a past renewal anchor. */
    backdateStartDateUnix?: number;
    /** Optional one-time discount applied to the first invoice only. */
    firstInvoiceAmountOffCents?: number;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Subscription> {
    try {
      return await this.runWithStripeRetry(
        `createCustomerSubscription(${params.customerId})`,
        async () => {
          const price = await this.createRecallAssessRecurringPrice(
            params.billingCycle,
            params.totalAmountCentsPerCycle,
          );

          const subscriptionParams: Stripe.SubscriptionCreateParams = {
            customer: params.customerId,
            collection_method: params.autoPaymentEnabled ? "charge_automatically" : "send_invoice",
            cancel_at_period_end: !params.autoPaymentEnabled,
            items: [
              {
                price: price.id,
              },
            ],
            metadata: params.metadata,
          };
          if (!params.autoPaymentEnabled) {
            subscriptionParams.days_until_due = 30;
          } else {
            subscriptionParams.payment_behavior = "default_incomplete";
            subscriptionParams.payment_settings = {
              save_default_payment_method: "on_subscription",
            };
            // Do not enable Stripe automatic_tax here: unit_amount already includes app fees/VAT;
            // automatic_tax requires customer_tax location and can fail signup (customer_tax_location_invalid).
          }

          if (params.trialEndUnix && params.trialEndUnix > Math.floor(Date.now() / 1000)) {
            subscriptionParams.trial_end = params.trialEndUnix;
          }

          if (typeof params.backdateStartDateUnix === "number" && params.backdateStartDateUnix > 0) {
            subscriptionParams.backdate_start_date = params.backdateStartDateUnix;
            subscriptionParams.proration_behavior = "none";
          }

          if (
            typeof params.firstInvoiceAmountOffCents === "number" &&
            Number.isFinite(params.firstInvoiceAmountOffCents) &&
            params.firstInvoiceAmountOffCents > 0 &&
            params.firstInvoiceAmountOffCents < params.totalAmountCentsPerCycle
          ) {
            // Create a one-time coupon for the first invoice only.
            const coupon = await this.getStripe().coupons.create({
              duration: "once",
              amount_off: Math.round(params.firstInvoiceAmountOffCents),
              currency: "usd",
              name: "VIP offer (one-time)",
            });
            subscriptionParams.discounts = [{ coupon: coupon.id }];
          }

          return await this.getStripe().subscriptions.create(subscriptionParams);
        },
      );
    } catch (error) {
      const stripeMsg =
        error && typeof error === "object" && "message" in error
          ? String((error as { message?: string }).message)
          : String(error);
      this.logger.error(`Failed to create subscription for customer ${params.customerId}: ${stripeMsg}`, error);
      throw new Error(`Failed to create Stripe subscription: ${stripeMsg}`);
    }
  }

  private getStripe(): Stripe {
    if (!this.stripe) {
      throw new Error("Stripe is not configured");
    }
    return this.stripe;
  }

  /**
   * Create a Stripe customer so we can attach subscriptions and payment methods later.
   */
  async createCustomer(params: {
    email?: string;
    name?: string;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Customer> {
    try {
      return await this.getStripe().customers.create({
        email: params.email,
        name: params.name,
        metadata: params.metadata,
      });
    } catch (error) {
      this.logger.error(`Failed to create customer for ${params.email ?? "unknown email"}`, error);
      throw new Error("Failed to create customer");
    }
  }

  /**
   * Checks whether a Stripe customer id exists in the currently configured Stripe account/mode.
   */
  async customerExists(customerId: string): Promise<boolean> {
    try {
      const customer = await this.getStripe().customers.retrieve(customerId);
      if ("deleted" in customer && customer.deleted) {
        return false;
      }
      return true;
    } catch (error) {
      const msg = error instanceof Error ? error.message : String(error);
      this.logger.warn(`customerExists: could not retrieve customer ${customerId}: ${msg}`);
      return false;
    }
  }
}

/**
 * Maps a Stripe subscription to our DB `stripe_cancel_at_period_end` flag.
 * Stripe clears `cancel_at_period_end` once `status` is `canceled`, but we still want `true`
 * so admin/history rows (e.g. superseded upgrades) reflect that the sub no longer renews.
 */
/** Minimal subscription fields; `status` is `string` so callers can use API-shaped objects without Stripe enum friction. */
export function localStripeCancelAtPeriodEndFromSubscription(sub: {
  status: string;
  cancel_at_period_end?: boolean | null;
  cancel_at?: number | null;
}): boolean {
  // Stripe may set `cancel_at` (scheduled cancellation timestamp) while `cancel_at_period_end` is false/null.
  return sub.cancel_at_period_end === true || typeof sub.cancel_at === "number" || sub.status === "canceled";
}

results matching ""

    No results matching ""