File

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

Index

Properties
Methods

Constructor

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

Methods

Async handleCustomerSubscriptionCreated
handleCustomerSubscriptionCreated(subscription: Stripe.Subscription)
Parameters :
Name Type Optional
subscription Stripe.Subscription No
Returns : Promise<void>
Async handleCustomerSubscriptionUpdated
handleCustomerSubscriptionUpdated(subscription: Stripe.Subscription)

Keeps Subscription.stripe_cancel_at_period_end in sync when the customer changes cancellation settings in Stripe (Billing Portal, Dashboard, API). Stripe does not send invoice.paid for that.

Parameters :
Name Type Optional
subscription Stripe.Subscription No
Returns : Promise<void>
Async sendBillingSubscriptionResumeEmail
sendBillingSubscriptionResumeEmail(companyId: number, subscription: Stripe.Subscription, options?: literal type)

Send “subscription resumed” email when cancel_at_period_end is cleared (portal, Billing Portal, or webhook). Dedupe per Stripe billing period (current_period_start) so duplicate events do not spam.

Parameters :
Name Type Optional
companyId number No
subscription Stripe.Subscription No
options literal type Yes
Returns : Promise<void>
Async sendBillingSubscriptionScheduledCancelEmail
sendBillingSubscriptionScheduledCancelEmail(companyId: number, subscription: Stripe.Subscription, options?: literal type)
Parameters :
Name Type Optional
companyId number No
subscription Stripe.Subscription No
options literal type Yes
Returns : Promise<void>

Properties

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

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

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

  async sendBillingSubscriptionScheduledCancelEmail(
    companyId: number,
    subscription: Stripe.Subscription,
    options?: { triggeredBy?: string; subscriptionRowId?: number },
  ): Promise<void> {
    if (subscription.cancel_at_period_end !== true) {
      return;
    }

    const subId = subscription.id;
    const cancelAtUnix = subscription.cancel_at;
    const periodEndUnix =
      (subscription as StripeSubscriptionApi).current_period_end ??
      // Some Stripe responses/events may omit current_period_end; cancel_at often equals period end when scheduled.
      (typeof cancelAtUnix === "number" ? cancelAtUnix : undefined);
    const endsOn = typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : null;
    const dedupeSegment =
      typeof cancelAtUnix === "number"
        ? String(cancelAtUnix)
        : typeof periodEndUnix === "number"
          ? String(periodEndUnix)
          : "unknown";
    const dedupeKey = `billing.subscription.cancelled:${subId}:${dedupeSegment}`;

    await this.billing.sendCompanyTemplatedEmail({
      companyId,
      templateKey: "billing.subscription.cancelled",
      dedupeKey,
      triggeredBy: options?.triggeredBy ?? "stripe_webhook",
      variables: {
        "subscription.ends_on": endsOn ? endsOn.toISOString().split("T")[0] : "",
        "subscription.cancel_type": "scheduled",
      },
      metadata: {
        stripe_subscription_id: subId,
        ...(options?.subscriptionRowId != null ? { subscription_id: options.subscriptionRowId } : {}),
      },
    });
  }

  /**
   * Send “subscription resumed” email when `cancel_at_period_end` is cleared (portal, Billing Portal, or webhook).
   * Dedupe per Stripe billing period (`current_period_start`) so duplicate events do not spam.
   */
  async sendBillingSubscriptionResumeEmail(
    companyId: number,
    subscription: Stripe.Subscription,
    options?: { triggeredBy?: string; subscriptionRowId?: number },
  ): Promise<void> {
    if (subscription.cancel_at_period_end === true) {
      return;
    }
    const status = subscription.status;
    if (status === "canceled" || status === "incomplete_expired") {
      return;
    }

    const subId = subscription.id;
    const periodEndUnix = (subscription as StripeSubscriptionApi).current_period_end;
    const periodStartUnix = (subscription as StripeSubscriptionApi).current_period_start;
    let endsOn = typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : null;
    if (!endsOn && options?.subscriptionRowId != null) {
      // Fallback to local DB row when Stripe event omitted current_period_end.
      const local = await this.prisma.client.subscription.findUnique({
        where: { id: options.subscriptionRowId },
        select: { next_billing_date: true },
      });
      if (local?.next_billing_date instanceof Date) {
        endsOn = local.next_billing_date;
      }
    }
    const dedupeSegment = typeof periodStartUnix === "number" ? String(periodStartUnix) : "unknown";
    const dedupeKey = `billing.subscription.reactivated:${subId}:${dedupeSegment}`;

    await this.billing.sendCompanyTemplatedEmail({
      companyId,
      templateKey: "billing.subscription.reactivated",
      dedupeKey,
      triggeredBy: options?.triggeredBy ?? "stripe_webhook",
      variables: {
        "subscription.next_renewal_date": endsOn ? endsOn.toISOString().split("T")[0] : "",
      },
      metadata: {
        stripe_subscription_id: subId,
        ...(options?.subscriptionRowId != null ? { subscription_id: options.subscriptionRowId } : {}),
      },
    });
  }
  async handleCustomerSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
    const subId = subscription.id;
    const companyIdStr = subscription.metadata?.["company_id"];
    const localSubIdStr = subscription.metadata?.["local_subscription_id"];
    if (!companyIdStr || !localSubIdStr) {
      this.logger.debug(
        `customer.subscription.created ${subId}: skip (missing company_id or local_subscription_id in metadata)`,
      );
      return;
    }

    const companyId = parseInt(companyIdStr, 10);
    const localSubId = parseInt(localSubIdStr, 10);
    if (Number.isNaN(companyId) || Number.isNaN(localSubId)) {
      return;
    }

    const row = await this.prisma.client.subscription.findFirst({
      where: { id: localSubId, company_id: companyId },
      select: { id: true },
    });
    if (!row) {
      this.logger.warn(
        `customer.subscription.created: no local subscription id=${localSubId} for company ${companyId}`,
      );
      return;
    }

    await this.prisma.client.company.update({
      where: { id: companyId },
      data: { stripe_subscription_id: subId },
    });
    await this.prisma.client.subscription.update({
      where: { id: localSubId },
      data: {
        stripe_subscription_id: subId,
        stripe_cancel_at_period_end: localStripeCancelAtPeriodEndFromSubscription(subscription),
      },
    });
    this.logger.log(
      `customer.subscription.created: linked Stripe ${subId} to local subscription ${localSubId} (company ${companyId})`,
    );
  }

  /**
   * Keeps {@link Subscription.stripe_cancel_at_period_end} in sync when the customer changes cancellation
   * settings in Stripe (Billing Portal, Dashboard, API). Stripe does not send `invoice.paid` for that.
   */
  async handleCustomerSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
    const subId = subscription.id;
    const cancelAtEnd = localStripeCancelAtPeriodEndFromSubscription(subscription);
    const customerId =
      typeof subscription.customer === "string"
        ? subscription.customer
        : subscription.customer && typeof subscription.customer === "object" && "id" in subscription.customer
          ? (subscription.customer.id ?? null)
          : null;
    this.logger.warn(
      `customer.subscription.updated/deleted: received stripe_sub=${subId} customer=${customerId ?? "unknown"} status=${
        subscription.status
      } cancel_at_period_end=${subscription.cancel_at_period_end === true} cancel_at=${
        typeof subscription.cancel_at === "number" ? subscription.cancel_at : "null"
      } mapped_cancel_at_period_end=${cancelAtEnd}`,
    );

    const subSelect = {
      id: true,
      company_id: true,
      is_current: true,
      status: true,
      stripe_cancel_at_period_end: true,
      stripe_subscription_id: true,
    } as const;

    let localSub = await this.prisma.client.subscription.findFirst({
      where: { stripe_subscription_id: subId, is_current: true },
      select: subSelect,
    });
    if (!localSub) {
      localSub = await this.prisma.client.subscription.findFirst({
        where: { stripe_subscription_id: subId },
        orderBy: { id: "desc" },
        select: subSelect,
      });
    }

    if (!localSub) {
      const companyIdStr = subscription.metadata?.["company_id"];
      const localSubIdStr = subscription.metadata?.["local_subscription_id"];
      if (companyIdStr && localSubIdStr) {
        const companyId = parseInt(companyIdStr, 10);
        const localSubId = parseInt(localSubIdStr, 10);
        if (!Number.isNaN(companyId) && !Number.isNaN(localSubId)) {
          const row = await this.prisma.client.subscription.findFirst({
            where: { id: localSubId, company_id: companyId },
            select: subSelect,
          });
          if (row) {
            localSub = row;
          }
        }
      }
    }

    /** Billing Portal sometimes leaves subscription rows without `stripe_subscription_id` while company has it. */
    let resolveStripeSubIdViaCompany = false;
    if (!localSub) {
      const company = await this.prisma.client.company.findFirst({
        where: { stripe_subscription_id: subId },
        select: { id: true, stripe_customer_id: true },
      });
      if (company) {
        let row = await this.prisma.client.subscription.findFirst({
          where: { company_id: company.id, is_current: true },
          select: subSelect,
        });
        if (!row) {
          row = await this.prisma.client.subscription.findFirst({
            where: { company_id: company.id },
            orderBy: { id: "desc" },
            select: subSelect,
          });
        }
        if (row) {
          localSub = row;
          resolveStripeSubIdViaCompany = true;
          this.logger.log(
            `customer.subscription.updated/deleted ${subId}: resolved subscription row ${row.id} via company.stripe_subscription_id (company ${company.id})`,
          );
        }
      }
    }

    // Final fallback: resolve company via Stripe customer id, then map to current/latest subscription row.
    if (!localSub && customerId) {
      const company = await this.prisma.client.company.findFirst({
        where: { stripe_customer_id: customerId },
        select: { id: true, stripe_subscription_id: true },
      });
      if (company) {
        let row = await this.prisma.client.subscription.findFirst({
          where: { company_id: company.id, is_current: true },
          select: subSelect,
        });
        if (!row) {
          row = await this.prisma.client.subscription.findFirst({
            where: { company_id: company.id },
            orderBy: { id: "desc" },
            select: subSelect,
          });
        }
        if (row) {
          localSub = row;
          resolveStripeSubIdViaCompany = true;
          this.logger.log(
            `customer.subscription.updated/deleted ${subId}: resolved subscription row ${row.id} via company.stripe_customer_id=${customerId} (company ${company.id})`,
          );
        }
      }
    }

    if (!localSub) {
      this.logger.warn(
        `customer.subscription.updated/deleted ${subId}: no local subscription (stripe_subscription_id, metadata company_id/local_subscription_id, company.stripe_subscription_id, or company.stripe_customer_id=${customerId ?? "unknown"})`,
      );
      return;
    }

    const wasScheduledCancel = localSub.stripe_cancel_at_period_end === true;
    const stripeStatus = subscription.status;
    const isTerminalCancellation = stripeStatus === "canceled" || stripeStatus === "incomplete_expired";
    const nextLocalStatus = isTerminalCancellation ? SubscriptionStatus.CANCELLED : localSub.status;

    const shouldBackfillSubscriptionStripeId =
      resolveStripeSubIdViaCompany || localSub.stripe_subscription_id !== subId;
    await this.prisma.client.$transaction([
      this.prisma.client.subscription.update({
        where: { id: localSub.id },
        data: {
          stripe_cancel_at_period_end: cancelAtEnd,
          ...(nextLocalStatus !== localSub.status ? { status: nextLocalStatus } : {}),
          ...(shouldBackfillSubscriptionStripeId ? { stripe_subscription_id: subId } : {}),
        },
      }),
      this.prisma.client.company.update({
        where: { id: localSub.company_id },
        data: {
          stripe_subscription_id: subId,
          ...(isTerminalCancellation && localSub.is_current ? { is_subscription_expiry: true } : {}),
        },
      }),
    ]);
    this.logger.log(
      `customer.subscription.updated/deleted: subscription row ${localSub.id} stripe_cancel_at_period_end=${cancelAtEnd} local_status=${nextLocalStatus} (Stripe ${subId}, status=${subscription.status}, backfilled_subscription_stripe_id=${shouldBackfillSubscriptionStripeId}, set_company_subscription_expiry=${isTerminalCancellation && localSub.is_current})`,
    );

    // Scheduled cancellation email: do not require a local DB transition (portal may set the flag before this webhook).
    if (subscription.cancel_at_period_end === true) {
      await this.sendBillingSubscriptionScheduledCancelEmail(localSub.company_id, subscription, {
        triggeredBy: "stripe_webhook_customer_subscription",
        subscriptionRowId: localSub.id,
      });
    } else if (
      wasScheduledCancel &&
      subscription.cancel_at_period_end === false &&
      subscription.status !== "canceled" &&
      subscription.status !== "incomplete_expired"
    ) {
      await this.sendBillingSubscriptionResumeEmail(localSub.company_id, subscription, {
        triggeredBy: "stripe_webhook_customer_subscription",
        subscriptionRowId: localSub.id,
      });
    }
  }
}

results matching ""

    No results matching ""