File

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

Index

Properties
Methods

Constructor

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

Methods

Async handleRefundCreated
handleRefundCreated(refund: Stripe.Refund)
Parameters :
Name Type Optional
refund Stripe.Refund No
Returns : Promise<boolean>
Async syncCompanyInvoicesAndRefundsFromStripe
syncCompanyInvoicesAndRefundsFromStripe(companyId: number)
Parameters :
Name Type Optional
companyId number No
Returns : Promise<literal type>
Async updateAdminStripeNextBillingDate
updateAdminStripeNextBillingDate(companyId: number, nextBillingDate: string)
Parameters :
Name Type Optional
companyId number No
nextBillingDate string No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripePaymentSyncRefundService.name)
import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InvoiceStatus, InvoiceType, PackageType, SubscriptionStatus } from "@prisma/client";
import Stripe from "stripe";
import { StripeService } from "../stripe.service";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripeSubscriptionApi } from "./stripe-payment.types";

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

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly stripeService: StripeService,
    private readonly billing: StripePaymentBillingHelpersService,
  ) {}

  async updateAdminStripeNextBillingDate(
    companyId: number,
    nextBillingDate: string,
  ): Promise<{ success: boolean; message: string; next_billing_date: string }> {
    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_subscription_id: true },
    });
    if (!company) {
      throw new NotFoundException(`Company ${companyId} not found`);
    }
    if (!company.stripe_subscription_id) {
      throw new BadRequestException("No Stripe subscription linked to this company.");
    }

    const parsed = new Date(`${nextBillingDate}T00:00:00.000Z`);
    if (Number.isNaN(parsed.getTime())) {
      throw new BadRequestException("Invalid next billing date.");
    }
    const anchorUnix = Math.floor(parsed.getTime() / 1000);
    const nowUnix = Math.floor(Date.now() / 1000);
    if (anchorUnix <= nowUnix) {
      throw new BadRequestException("next_billing_date must be in the future.");
    }

    await this.stripeService.updateSubscriptionBillingCycleAnchor(company.stripe_subscription_id, anchorUnix);
    const updatedSub = await this.stripeService.retrieveSubscription(company.stripe_subscription_id);
    const periodEndUnix = (updatedSub as StripeSubscriptionApi).current_period_end;
    const normalizedNext =
      typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : parsed;

    await this.prisma.client.subscription.updateMany({
      where: { company_id: companyId, is_current: true },
      data: {
        next_billing_date: normalizedNext,
        end_date: normalizedNext,
      },
    });

    return {
      success: true,
      message: `Next billing date updated to ${normalizedNext.toISOString().slice(0, 10)}.`,
      next_billing_date: normalizedNext.toISOString(),
    };
  }
  async syncCompanyInvoicesAndRefundsFromStripe(companyId: number): Promise<{
    success: boolean;
    message: string;
    invoices_created: number;
    refunds_created: number;
  }> {
    if (!this.stripeService.isConfigured()) {
      return {
        success: false,
        message: "Stripe is not configured on this API environment.",
        invoices_created: 0,
        refunds_created: 0,
      };
    }

    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { id: true, stripe_customer_id: true },
    });
    if (!company) {
      return {
        success: false,
        message: `Company ${companyId} not found`,
        invoices_created: 0,
        refunds_created: 0,
      };
    }
    if (!company.stripe_customer_id) {
      return {
        success: false,
        message: "Company has no stripe_customer_id.",
        invoices_created: 0,
        refunds_created: 0,
      };
    }

    const latestSub = await this.prisma.client.subscription.findFirst({
      where: { company_id: companyId },
      orderBy: { id: "desc" },
      select: { id: true, billing_cycle: true, package: { select: { package_type: true } } },
    });
    const fallbackInvoice = await this.prisma.client.invoice.findFirst({
      where: { company_id: companyId },
      orderBy: { id: "desc" },
      select: { subscription_id: true, package_type: true, billing_cycle: true },
    });

    const subscriptionId = latestSub?.id ?? fallbackInvoice?.subscription_id ?? null;
    const packageType = latestSub?.package?.package_type ?? fallbackInvoice?.package_type ?? null;
    // Snapshot the cycle for every backfilled row; the PDF builder renders
    // from this column and must not need to JOIN back to the live subscription.
    const backfillBillingCycle = latestSub?.billing_cycle ?? fallbackInvoice?.billing_cycle ?? null;
    if (!subscriptionId || !packageType) {
      return {
        success: false,
        message: "Could not resolve subscription/package for backfilled invoices.",
        invoices_created: 0,
        refunds_created: 0,
      };
    }

    let invoicesCreated = 0;
    let refundsCreated = 0;

    const stripeInvoices = await this.stripeService.listInvoicesForCustomer({
      customerId: company.stripe_customer_id,
      max: 200,
      status: "all",
    });

    for (const inv of stripeInvoices) {
      const stripeInvoiceId = String(inv.id);
      const existing = await this.prisma.client.invoice.findFirst({
        where: { stripe_invoice_id: stripeInvoiceId },
        select: { id: true },
      });
      if (existing) continue;

      if (inv.status !== "paid") continue;

      const centsPaid = typeof inv.amount_paid === "number" ? inv.amount_paid : 0;
      // Backfill only billable invoices; skip zero-amount Stripe invoices.
      if (centsPaid <= 0) continue;
      const totalStr = (centsPaid / 100).toFixed(2);
      const paidUnix = (inv.status_transitions as any)?.paid_at;
      const paidDate =
        typeof paidUnix === "number" && paidUnix > 0
          ? new Date(paidUnix * 1000)
          : typeof inv.created === "number"
            ? new Date(inv.created * 1000)
            : new Date();

      const invoiceNumber =
        inv.number && String(inv.number).trim().length > 0 ? String(inv.number) : `STRP-${stripeInvoiceId}`;

      let expandedForIds: Stripe.Invoice = inv;
      try {
        expandedForIds = await this.stripeService.getInvoiceExpanded(stripeInvoiceId);
      } catch {
        // keep list payload; charge id may be missing without expand
      }
      const { paymentIntentId, chargeId, checkoutSessionId } =
        this.billing.extractPaymentIntentAndChargeIds(expandedForIds);

      try {
        await this.prisma.client.invoice.create({
          data: {
            company_id: companyId,
            subscription_id: subscriptionId,
            invoice_number: invoiceNumber,
            unit_price_per_license: "0",
            license_quantity: 0,
            subtotal_amount: totalStr,
            total_amount: totalStr,
            package_type: packageType,
            billing_cycle: backfillBillingCycle,
            status: InvoiceStatus.PAID,
            invoice_type: InvoiceType.RENEWAL,
            stripe_invoice_id: stripeInvoiceId,
            stripe_payment_intent_id: paymentIntentId ?? undefined,
            stripe_charge_id: chargeId ?? undefined,
            stripe_checkout_session_id: checkoutSessionId ?? undefined,
            paid_date: paidDate,
            billing_reference_notes: `Backfilled from Stripe invoice ${stripeInvoiceId}`,
          },
        });
        invoicesCreated += 1;
      } catch (e) {
        // Likely invoice_number uniqueness conflict; fall back to guaranteed unique number
        const altNumber = `STRP-${stripeInvoiceId}`;
        await this.prisma.client.invoice.create({
          data: {
            company_id: companyId,
            subscription_id: subscriptionId,
            invoice_number: altNumber,
            unit_price_per_license: "0",
            license_quantity: 0,
            subtotal_amount: totalStr,
            total_amount: totalStr,
            package_type: packageType,
            billing_cycle: backfillBillingCycle,
            status: InvoiceStatus.PAID,
            invoice_type: InvoiceType.RENEWAL,
            stripe_invoice_id: stripeInvoiceId,
            stripe_payment_intent_id: paymentIntentId ?? undefined,
            stripe_charge_id: chargeId ?? undefined,
            stripe_checkout_session_id: checkoutSessionId ?? undefined,
            paid_date: paidDate,
            billing_reference_notes: `Backfilled from Stripe invoice ${stripeInvoiceId}`,
          },
        });
        invoicesCreated += 1;
      }
    }

    const invoicesMissingPi = await this.prisma.client.invoice.findMany({
      where: {
        company_id: companyId,
        stripe_invoice_id: { not: null },
        stripe_payment_intent_id: null,
        invoice_type: { not: InvoiceType.REFUND },
      },
      select: { id: true, stripe_invoice_id: true },
      take: 100,
    });
    for (const row of invoicesMissingPi) {
      const sid = row.stripe_invoice_id;
      if (!sid) continue;
      try {
        const expanded = await this.stripeService.getInvoiceExpanded(sid);
        const {
          paymentIntentId: pi,
          chargeId,
          checkoutSessionId,
        } = this.billing.extractPaymentIntentAndChargeIds(expanded);
        const patch: {
          stripe_payment_intent_id?: string;
          stripe_charge_id?: string;
          stripe_checkout_session_id?: string;
        } = {};
        if (pi) patch.stripe_payment_intent_id = pi;
        if (chargeId) patch.stripe_charge_id = chargeId;
        if (checkoutSessionId) patch.stripe_checkout_session_id = checkoutSessionId;
        if (Object.keys(patch).length > 0) {
          try {
            await this.prisma.client.invoice.update({
              where: { id: row.id },
              data: patch,
            });
          } catch {
            // e.g. unique stripe_payment_intent_id already used by another row
          }
        }
      } catch {
        // ignore per-invoice Stripe errors
      }
    }

    const invoicesMissingCharge = await this.prisma.client.invoice.findMany({
      where: {
        company_id: companyId,
        stripe_invoice_id: { not: null },
        stripe_charge_id: null,
        stripe_payment_intent_id: { not: null },
        invoice_type: { not: InvoiceType.REFUND },
      },
      select: { id: true, stripe_invoice_id: true },
      take: 100,
    });
    for (const row of invoicesMissingCharge) {
      const sid = row.stripe_invoice_id;
      if (!sid) continue;
      try {
        const expanded = await this.stripeService.getInvoiceExpanded(sid);
        const { chargeId, checkoutSessionId } = this.billing.extractPaymentIntentAndChargeIds(expanded);
        const patch: { stripe_charge_id?: string; stripe_checkout_session_id?: string } = {};
        if (chargeId) patch.stripe_charge_id = chargeId;
        if (checkoutSessionId) patch.stripe_checkout_session_id = checkoutSessionId;
        if (Object.keys(patch).length > 0) {
          try {
            await this.prisma.client.invoice.update({ where: { id: row.id }, data: patch });
          } catch {
            // ignore
          }
        }
      } catch {
        // ignore per-invoice Stripe errors
      }
    }

    const charges = await this.stripeService.listChargesForCustomer({
      customerId: company.stripe_customer_id,
      max: 200,
    });
    for (const ch of charges) {
      const amountRefunded = typeof ch.amount_refunded === "number" ? ch.amount_refunded : 0;
      if (!ch.refunded && amountRefunded <= 0) continue;
      const refunds = await this.stripeService.listRefundsForCharge({ chargeId: ch.id, max: 50 });
      for (const r of refunds) {
        const created = await this.handleRefundCreated(r);
        if (created) refundsCreated += 1;
      }
    }

    const piRows = await this.prisma.client.invoice.findMany({
      where: {
        company_id: companyId,
        stripe_payment_intent_id: { not: null },
        invoice_type: { not: InvoiceType.REFUND },
      },
      select: { stripe_payment_intent_id: true },
      distinct: ["stripe_payment_intent_id"],
    });
    const seenPi = new Set<string>();
    for (const row of piRows) {
      const pi = row.stripe_payment_intent_id;
      if (!pi || seenPi.has(pi)) continue;
      seenPi.add(pi);
      const refundsByPi = await this.stripeService.listRefundsForPaymentIntent({ paymentIntentId: pi, max: 50 });
      for (const r of refundsByPi) {
        const created = await this.handleRefundCreated(r);
        if (created) refundsCreated += 1;
      }
    }

    return {
      success: true,
      message: `Synced from Stripe for customer ${company.stripe_customer_id}.`,
      invoices_created: invoicesCreated,
      refunds_created: refundsCreated,
    };
  }
  async handleRefundCreated(refund: Stripe.Refund): Promise<boolean> {
    if (!this.stripeService.isConfigured()) {
      return false;
    }

    const stripeRefundId = String(refund.id);
    const existing = await this.prisma.client.invoice.findFirst({
      where: { stripe_refund_id: stripeRefundId },
      select: { id: true },
    });
    if (existing) {
      return false;
    }

    const chargeId =
      typeof refund.charge === "string"
        ? refund.charge
        : refund.charge && typeof refund.charge === "object"
          ? refund.charge.id
          : null;

    if (!chargeId) {
      this.logger.warn(`handleRefundCreated: refund ${stripeRefundId} has no charge id; skipping local invoice.`);
      return false;
    }

    let charge: Stripe.Charge;
    try {
      charge = await this.stripeService.retrieveCharge(chargeId);
    } catch {
      this.logger.warn(
        `handleRefundCreated: failed to retrieve charge ${chargeId} for refund ${stripeRefundId}; skipping.`,
      );
      return false;
    }

    const paymentIntentId =
      typeof charge.payment_intent === "string"
        ? charge.payment_intent
        : charge.payment_intent && typeof charge.payment_intent === "object"
          ? charge.payment_intent.id
          : null;
    const stripeInvoiceId =
      typeof (charge as any).invoice === "string"
        ? String((charge as any).invoice)
        : (charge as any).invoice && typeof (charge as any).invoice === "object"
          ? String((charge as any).invoice.id)
          : null;

    const or: Array<Record<string, string>> = [{ stripe_charge_id: chargeId }];
    if (paymentIntentId) or.push({ stripe_payment_intent_id: paymentIntentId });
    if (stripeInvoiceId) or.push({ stripe_invoice_id: stripeInvoiceId });

    let original = await this.prisma.client.invoice.findFirst({
      where: { OR: or },
      orderBy: { id: "desc" },
    });

    let matchNote = "";
    if (!original && paymentIntentId) {
      try {
        const piExpanded = await this.stripeService.retrievePaymentIntent(paymentIntentId, ["invoice"]);
        const invRef = (piExpanded as Stripe.PaymentIntent & { invoice?: string | Stripe.Invoice | null }).invoice;
        const invIdFromPi =
          typeof invRef === "string"
            ? invRef
            : invRef && typeof invRef === "object" && "id" in invRef
              ? invRef.id
              : null;
        if (invIdFromPi) {
          original = await this.prisma.client.invoice.findFirst({
            where: { stripe_invoice_id: invIdFromPi },
            orderBy: { id: "desc" },
          });
          if (original) {
            matchNote = `Matched via Stripe PaymentIntent → invoice ${invIdFromPi}.`;
            const patch: { stripe_payment_intent_id?: string; stripe_charge_id?: string } = {};
            if (!original.stripe_payment_intent_id && paymentIntentId) {
              patch.stripe_payment_intent_id = paymentIntentId;
            }
            if (!original.stripe_charge_id) {
              patch.stripe_charge_id = chargeId;
            }
            if (Object.keys(patch).length > 0) {
              try {
                await this.prisma.client.invoice.update({ where: { id: original.id }, data: patch });
              } catch {
                // unique stripe_payment_intent_id / race — ignore
              }
            }
          }
        }
      } catch {
        // ignore PI retrieve errors; fall through to company fallback
      }
    }

    if (!original) {
      const customerId = this.billing.extractStripeCustomerId(charge.customer);
      if (customerId) {
        const co = await this.prisma.client.company.findFirst({
          where: { stripe_customer_id: customerId },
          select: { id: true },
        });
        if (co) {
          original = await this.prisma.client.invoice.findFirst({
            where: {
              company_id: co.id,
              invoice_type: { not: InvoiceType.REFUND },
              status: InvoiceStatus.PAID,
            },
            orderBy: [{ paid_date: "desc" }, { id: "desc" }],
          });
          if (original) {
            matchNote = `Fallback: linked to latest paid invoice for Stripe customer ${customerId}; verify amount matches.`;
            this.logger.warn(
              `handleRefundCreated: refund ${stripeRefundId} had no exact local match; using invoice id=${original.id} for company ${co.id}.`,
            );
          }
        }
      }
    }

    if (!original) {
      this.logger.warn(
        `handleRefundCreated: could not match refund ${stripeRefundId} (charge=${chargeId}, pi=${paymentIntentId ?? "null"}, invoice=${stripeInvoiceId ?? "null"}) to a local invoice; skipping.`,
      );
      return false;
    }

    const refundAmountCents = typeof refund.amount === "number" ? refund.amount : 0;
    const refundAmount = refundAmountCents / 100;
    const totalStr = (-refundAmount).toFixed(2);

    const paidDate =
      typeof refund.created === "number" && refund.created > 0 ? new Date(refund.created * 1000) : new Date();

    // Refund rows are credit notes in our model: they link back to the paid
    // invoice being refunded via `parent_invoice_id`, and they carry the same
    // `billing_cycle` snapshot so the PDF labels the period correctly even if
    // the subscription's live cycle has since changed.
    await this.prisma.client.invoice.create({
      data: {
        company_id: original.company_id,
        subscription_id: original.subscription_id,
        invoice_number: `RFND-${stripeRefundId}`,
        unit_price_per_license: "0",
        license_quantity: 0,
        subtotal_amount: totalStr,
        total_amount: totalStr,
        package_type: original.package_type,
        billing_cycle: original.billing_cycle,
        parent_invoice_id: original.id,
        status: InvoiceStatus.PAID,
        invoice_type: InvoiceType.REFUND,
        stripe_invoice_id: stripeInvoiceId ?? undefined,
        stripe_payment_intent_id: undefined,
        stripe_charge_id: chargeId,
        stripe_refund_id: stripeRefundId,
        paid_date: paidDate,
        billing_reference_notes: [`Stripe refund ${stripeRefundId} (charge=${chargeId})`, matchNote || undefined]
          .filter(Boolean)
          .join(" "),
      },
    });
    return true;
  }
}

results matching ""

    No results matching ""