File

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

Description

Shared billing helpers used by StripePaymentService and StripePaymentInvoiceWebhookService (invoice webhooks, emails, fee math).

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No

Methods

addDaysSafe
addDaysSafe(date: Date, days: number)
Parameters :
Name Type Optional
date Date No
days number No
Returns : Date
calculateRenewalChargeCents
calculateRenewalChargeCents(params: literal type)
Parameters :
Name Type Optional
params literal type No
Returns : literal type
Async clearCompanySubscriptionExpiryWhenRenewalIsAhead
clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId: number)

After a successful paid flow, clear the admin/portal expiry flag when the company has a current ACTIVE subscription with next_billing_date set and still in the future.

Parameters :
Name Type Optional
companyId number No
Returns : Promise<void>
computeNextBillingAfterOnePaidPeriod
computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined)

Next period end after paying one full billing cycle from {@param previousAnchor}. Used by expired recovery preview and one-off invoice flows.

Parameters :
Name Type Optional
previousAnchor Date No
billingCycle string | null | undefined No
Returns : Date
derivePeriodsFromStripeInvoice
derivePeriodsFromStripeInvoice(inv: Stripe.Invoice)

Prefer line-item period (subscription invoices); fall back to invoice-level period_*.

Parameters :
Name Type Optional
inv Stripe.Invoice No
Returns : literal type
extractPaymentIntentAndChargeIds
extractPaymentIntentAndChargeIds(inv: Stripe.Invoice)
Parameters :
Name Type Optional
inv Stripe.Invoice No
Returns : literal type
extractStripeCustomerId
extractStripeCustomerId(ref: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined)
Parameters :
Name Type Optional
ref string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined No
Returns : string | null
formatUsd
formatUsd(amount: number)
Parameters :
Name Type Optional
amount number No
Returns : string
getBillingCycleFixedDays
getBillingCycleFixedDays(billingCycle: string | null | undefined)
Parameters :
Name Type Optional
billingCycle string | null | undefined No
Returns : number
Private getPortalSubscriptionBillingUrl
getPortalSubscriptionBillingUrl()
Returns : string
isStripeFirstSubscriptionInvoiceBillingReason
isStripeFirstSubscriptionInvoiceBillingReason(billingReason: string | null | undefined)

Stripe: first invoice for the subscription (not a recurring period renewal).

Parameters :
Name Type Optional
billingReason string | null | undefined No
Returns : boolean
Private isUaeCountry
isUaeCountry(country: string | null | undefined)
Parameters :
Name Type Optional
country string | null | undefined No
Returns : boolean
Async normalizeLocalSubscriptionTypeAfterFirstStripeInvoice
normalizeLocalSubscriptionTypeAfterFirstStripeInvoice(params: literal type)

Recovery paths used to create paid placeholder rows as RENEWAL; first automatic Stripe charge is not a renewal. Corrects DB when isStripeFirstSubscriptionInvoiceBillingReason matches.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<SubscriptionType>
renewalFeePercentagesForDb
renewalFeePercentagesForDb(country: string | null | undefined)

Percent values for DB (e.g. 3.00 = 3%).

Parameters :
Name Type Optional
country string | null | undefined No
Returns : literal type
Async resolveCompanyIdFromStripeCustomer
resolveCompanyIdFromStripeCustomer(customerId: string)
Parameters :
Name Type Optional
customerId string No
Returns : Promise<number | null>
resolveInvoiceTypeForStripeSubscriptionInvoice
resolveInvoiceTypeForStripeSubscriptionInvoice(params: literal type)

Stripe Stripe.Invoice.billing_reason distinguishes subscription-cycle renewals from first/setup invoices. Only {@code subscription_cycle} is treated as a renewal invoice; other reasons follow the local subscription row type.

Parameters :
Name Type Optional
params literal type No
Returns : InvoiceType
resolveRecoveryAnchorDate
resolveRecoveryAnchorDate(input?: string)
Parameters :
Name Type Optional
input string Yes
Returns : Date
Async sendCompanyTemplatedEmail
sendCompanyTemplatedEmail(params: literal type)
Parameters :
Name Type Optional
params literal type No
Returns : Promise<void>
Private subscriptionTypeToInvoiceType
subscriptionTypeToInvoiceType(st: SubscriptionType)
Parameters :
Name Type Optional
st SubscriptionType No
Returns : InvoiceType

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripePaymentBillingHelpersService.name)
Private Readonly renewalProcessingFeeRate
Type : number
Default value : 0.03
Private Readonly uaeVatRate
Type : number
Default value : 0.05
import { BNestEmailSenderService, BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import { InvoiceType, SubscriptionStatus, SubscriptionType } from "@prisma/client";
import Stripe from "stripe";
import { isUaeCompanyCountry } from "../../../../../config/billing.config";

/** API responses include fields not always present on generated Stripe typings. */
export type StripeInvoiceApi = Stripe.Invoice & {
  payment_intent?: string | Stripe.PaymentIntent | null;
};

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

/**
 * Shared billing helpers used by {@link StripePaymentService} and
 * {@link StripePaymentInvoiceWebhookService} (invoice webhooks, emails, fee math).
 */
@Injectable()
export class StripePaymentBillingHelpersService {
  private readonly logger = new Logger(StripePaymentBillingHelpersService.name);
  private readonly renewalProcessingFeeRate = 0.03;
  private readonly uaeVatRate = 0.05;

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly emailSender: BNestEmailSenderService,
  ) {}

  private getPortalSubscriptionBillingUrl(): string {
    const base = (process.env["FRONTEND_URL"] ||
      process.env["PWA_URL"] ||
      "https://recallassess.localdev:8443") as string;
    return `${base}/portal/settings/subscription?tab=subscription`;
  }

  formatUsd(amount: number): string {
    try {
      return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
    } catch {
      return `$${amount.toFixed(2)}`;
    }
  }

  async sendCompanyTemplatedEmail(params: {
    companyId: number;
    templateKey: string;
    variables: Record<string, string>;
    dedupeKey: string;
    metadata?: Record<string, unknown>;
    /** Default `stripe_webhook`; use e.g. `portal_schedule_cancel` when invoked from API after Stripe update */
    triggeredBy?: string;
  }): Promise<void> {
    this.logger.log(
      `[billing-email] prepare templateKey=${params.templateKey} companyId=${params.companyId} dedupeKey=${params.dedupeKey}`,
    );

    const existing = await this.prisma.client.emailLog.findFirst({
      where: {
        metadata: { path: ["dedupe_key"], equals: params.dedupeKey },
      },
      select: { id: true },
    });
    if (existing) {
      this.logger.log(
        `[billing-email] skip (dedupe hit) templateKey=${params.templateKey} emailLogId=${existing.id} dedupeKey=${params.dedupeKey}`,
      );
      return;
    }

    const company = await this.prisma.client.company.findUnique({
      where: { id: params.companyId },
      select: { id: true, name: true, email: true },
    });
    if (!company?.email) {
      this.logger.warn(
        `[billing-email] skip (missing company email) templateKey=${params.templateKey} companyId=${params.companyId}`,
      );
      return;
    }

    const template = await this.prisma.client.emailTemplate.findFirst({
      where: { template_key: params.templateKey, is_active: true },
      select: { id: true },
    });
    if (!template) {
      this.logger.error(
        `[billing-email] skip (template missing or inactive) templateKey=${params.templateKey}. Did you run the email template migration?`,
      );
      return;
    }

    try {
      await this.emailSender.sendTemplatedEmail({
        to: company.email,
        templateKey: params.templateKey,
        variables: {
          "company.name": company.name,
          "system.portalBillingUrl": this.getPortalSubscriptionBillingUrl(),
          ...params.variables,
        },
        metadata: {
          ...(params.metadata ?? {}),
          company_id: company.id,
          dedupe_key: params.dedupeKey,
          triggeredBy: params.triggeredBy ?? "stripe_webhook",
        },
      });
      this.logger.log(
        `[billing-email] queued templateKey=${params.templateKey} to=${company.email} (email log is created inside sendEmail)`,
      );
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      this.logger.error(
        `[billing-email] FAILED templateKey=${params.templateKey} to=${company.email} companyId=${params.companyId}: ${msg}`,
      );
    }
  }

  extractStripeCustomerId(
    ref: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined,
  ): string | null {
    if (!ref) return null;
    if (typeof ref === "string") return ref;
    if (typeof ref === "object" && "id" in ref && typeof (ref as { id: unknown }).id === "string") {
      return (ref as { id: string }).id;
    }
    return null;
  }

  async resolveCompanyIdFromStripeCustomer(customerId: string): Promise<number | null> {
    const co = await this.prisma.client.company.findFirst({
      where: { stripe_customer_id: customerId },
      select: { id: true },
    });
    return co?.id ?? null;
  }

  private isUaeCountry(country: string | null | undefined): boolean {
    return isUaeCompanyCountry(country);
  }

  calculateRenewalChargeCents(params: { baseAmountCents: number; country: string | null | undefined }): {
    totalCents: number;
    processingFeeCents: number;
    vatCents: number;
  } {
    const base = Math.max(0, Math.round(params.baseAmountCents));
    const processingFeeCents = Math.round(base * this.renewalProcessingFeeRate);
    const vatBaseCents = base + processingFeeCents;
    const vatCents = this.isUaeCountry(params.country) ? Math.round(vatBaseCents * this.uaeVatRate) : 0;
    return {
      totalCents: base + processingFeeCents + vatCents,
      processingFeeCents,
      vatCents,
    };
  }

  /** Percent values for DB (e.g. 3.00 = 3%). */
  renewalFeePercentagesForDb(country: string | null | undefined): {
    processingFeePct: number;
    vatPct: number;
  } {
    return {
      processingFeePct: this.renewalProcessingFeeRate * 100,
      vatPct: this.isUaeCountry(country) ? this.uaeVatRate * 100 : 0,
    };
  }

  extractPaymentIntentAndChargeIds(inv: Stripe.Invoice): {
    paymentIntentId: string | null;
    chargeId: string | null;
    checkoutSessionId: string | null;
  } {
    const pi = (inv as StripeInvoiceApi).payment_intent;
    if (!pi) {
      return { paymentIntentId: null, chargeId: null, checkoutSessionId: null };
    }
    if (typeof pi === "string") {
      return { paymentIntentId: pi, chargeId: null, checkoutSessionId: null };
    }
    if (pi.object === "payment_intent") {
      const lc = pi.latest_charge;
      const chargeId =
        typeof lc === "string"
          ? lc
          : lc && typeof lc === "object" && "id" in lc
            ? String((lc as Stripe.Charge).id)
            : null;
      const cs = (
        pi as Stripe.PaymentIntent & {
          checkout_session?: string | Stripe.Checkout.Session | null;
        }
      ).checkout_session;
      const checkoutSessionId =
        typeof cs === "string" ? cs : cs && typeof cs === "object" && "id" in cs ? cs.id : null;
      return { paymentIntentId: pi.id, chargeId, checkoutSessionId };
    }
    return { paymentIntentId: null, chargeId: null, checkoutSessionId: null };
  }

  /**
   * Prefer line-item period (subscription invoices); fall back to invoice-level period_*.
   */
  derivePeriodsFromStripeInvoice(inv: Stripe.Invoice): {
    periodStart: Date | null;
    periodEnd: Date | null;
  } {
    const lines = inv.lines;
    if (lines && typeof lines === "object" && "data" in lines && Array.isArray(lines.data)) {
      let bestStart: number | null = null;
      let bestEnd: number | null = null;
      let bestDuration = -1;
      for (const line of lines.data) {
        const period = (line as { period?: { start?: number; end?: number } }).period;
        if (
          typeof period?.start === "number" &&
          typeof period?.end === "number" &&
          period.end > period.start
        ) {
          const duration = period.end - period.start;
          if (
            duration > bestDuration ||
            (duration === bestDuration && (bestStart === null || period.start < bestStart))
          ) {
            bestStart = period.start;
            bestEnd = period.end;
            bestDuration = duration;
          }
        }
      }
      if (bestStart !== null && bestEnd !== null) {
        return {
          periodStart: new Date(bestStart * 1000),
          periodEnd: new Date(bestEnd * 1000),
        };
      }
    }
    if (typeof inv.period_start === "number" && typeof inv.period_end === "number") {
      return {
        periodStart: new Date(inv.period_start * 1000),
        periodEnd: new Date(inv.period_end * 1000),
      };
    }
    return { periodStart: null, periodEnd: null };
  }

  private subscriptionTypeToInvoiceType(st: SubscriptionType): InvoiceType {
    switch (st) {
      case SubscriptionType.RENEWAL:
        return InvoiceType.RENEWAL;
      case SubscriptionType.UPGRADE:
        return "UPGRADE_PRORATION" as InvoiceType;
      case SubscriptionType.DOWNGRADE:
        return "DOWNGRADE_CREDIT" as InvoiceType;
      case SubscriptionType.INITIAL:
      default:
        return InvoiceType.INITIAL_SUBSCRIPTION;
    }
  }

  /** Stripe: first invoice for the subscription (not a recurring period renewal). */
  isStripeFirstSubscriptionInvoiceBillingReason(billingReason: string | null | undefined): boolean {
    const br = billingReason ?? "";
    return br === "subscription_create" || br === "subscription";
  }

  /**
   * Stripe {@link Stripe.Invoice.billing_reason} distinguishes subscription-cycle renewals from first/setup invoices.
   * Only {@code subscription_cycle} is treated as a renewal invoice; other reasons follow the local subscription row type.
   */
  resolveInvoiceTypeForStripeSubscriptionInvoice(params: {
    billingReason: string | null | undefined;
    localSubscriptionType: SubscriptionType;
  }): InvoiceType {
    if (params.billingReason === "subscription_cycle") {
      return InvoiceType.RENEWAL;
    }
    return this.subscriptionTypeToInvoiceType(params.localSubscriptionType);
  }

  /**
   * Recovery paths used to create paid placeholder rows as RENEWAL; first automatic Stripe charge is not a renewal.
   * Corrects DB when {@link isStripeFirstSubscriptionInvoiceBillingReason} matches.
   */
  async normalizeLocalSubscriptionTypeAfterFirstStripeInvoice(params: {
    subscriptionRowId: number;
    currentType: SubscriptionType;
    previousSubscriptionId: number | null;
    billingReason: string | null | undefined;
  }): Promise<SubscriptionType> {
    if (!this.isStripeFirstSubscriptionInvoiceBillingReason(params.billingReason)) {
      return params.currentType;
    }
    if (params.currentType !== SubscriptionType.RENEWAL) {
      return params.currentType;
    }
    const next = params.previousSubscriptionId != null ? SubscriptionType.UPGRADE : SubscriptionType.INITIAL;
    await this.prisma.client.subscription.update({
      where: { id: params.subscriptionRowId },
      data: { subscription_type: next },
    });
    this.logger.log(
      `Normalized subscription ${params.subscriptionRowId} subscription_type RENEWAL -> ${next} (first Stripe invoice, billing_reason=${params.billingReason ?? "null"})`,
    );
    return next;
  }

  getBillingCycleFixedDays(billingCycle: string | null | undefined): number {
    const normalized = (billingCycle ?? "QUARTERLY").toUpperCase();
    if (normalized === "ANNUAL") return 360;
    if (normalized === "HALF_YEARLY") return 180;
    return 90;
  }

  addDaysSafe(date: Date, days: number): Date {
    const d = new Date(date.getTime());
    d.setDate(d.getDate() + days);
    return d;
  }

  /**
   * Next period end after paying one full billing cycle from {@param previousAnchor}.
   * Used by expired recovery preview and one-off invoice flows.
   */
  computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined): Date {
    return this.addDaysSafe(previousAnchor, this.getBillingCycleFixedDays(billingCycle));
  }

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

  /**
   * After a successful paid flow, clear the admin/portal expiry flag when the company has a current
   * ACTIVE subscription with `next_billing_date` set and still in the future.
   */
  async clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId: number): Promise<void> {
    const now = new Date();
    const viable = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
        next_billing_date: { gte: now },
      },
      select: { id: true },
    });
    if (!viable) {
      return;
    }
    await this.prisma.client.company.update({
      where: { id: companyId },
      data: { is_subscription_expiry: false },
    });
  }
}

results matching ""

    No results matching ""