File

apps/recallassess/recallassess-api/src/config/billing.config.ts

Description

Money columns on invoice for Prisma create/update (numeric values; Prisma accepts number or Decimal).

Index

Properties

Properties

discount_amount
discount_amount: number | null
Type : number | null
gross_license_amount
gross_license_amount: number
Type : number
pre_vat_total_amount
pre_vat_total_amount: number
Type : number
processing_fee
processing_fee: number | null
Type : number | null
processing_fee_percentage
processing_fee_percentage: number | null
Type : number | null
proration_amount
proration_amount: number | null
Type : number | null
subtotal_amount
subtotal_amount: number
Type : number
total_amount
total_amount: number
Type : number
vat_fee
vat_fee: number | null
Type : number | null
vat_fee_percentage
vat_fee_percentage: number | null
Type : number | null
import { Decimal } from "@prisma/client/runtime/library";

/**
 * Central configuration for subscription billing fees (processing fee, VAT, etc.)
 */

// Processing fee: 3% of net payable license amount (after discounts, before fees)
export const PROCESSING_FEE_PERCENT = 0.03; // 3%

// UAE VAT: 5% VAT for admin_country AE
export const UAE_COUNTRY_CODE = "AE";
export const UAE_VAT_PERCENT = 0.05; // 5%

export interface ProcessingFeeResult {
  /** Percentage stored in DB (e.g. 3.00 for 3%) */
  percentage: number;
  /** Calculated processing fee amount, rounded to 2 decimals */
  amount: number;
}

export interface VatFeeResult {
  /** VAT percentage stored in DB (e.g. 5.00 for 5%), or 0 when not applicable */
  vatFeePercentage: number;
  /** VAT amount, rounded to 2 decimals, or 0 when not applicable */
  vatFee: number;
}

/**
 * Calculate processing fee on the net payable license amount (after discount, plus proration when applicable).
 */
export function calculateProcessingFee(payableSubtotal: number): ProcessingFeeResult {
  const amount = Math.round(payableSubtotal * PROCESSING_FEE_PERCENT * 100) / 100;
  return {
    percentage: PROCESSING_FEE_PERCENT * 100, // store as 3.00
    amount,
  };
}

/**
 * Calculate VAT for the given admin country.
 * Applies 5% VAT only for UAE (AE) on **subtotal after discount + processing fee**.
 */
/** True when company country is UAE (ISO AE or common aliases). Matches Stripe renewal fee logic. */
export function isUaeCompanyCountry(country: string | null | undefined): boolean {
  if (!country) {
    return false;
  }
  const v = country.trim().toUpperCase();
  return v === "UAE" || v === UAE_COUNTRY_CODE || v === "UNITED ARAB EMIRATES";
}

export function calculateVatForAdminCountry(options: {
  subtotalAfterDiscount: number;
  processingFee: number;
  adminCountry?: string | null;
}): VatFeeResult {
  if (!isUaeCompanyCountry(options.adminCountry)) {
    return { vatFeePercentage: 0, vatFee: 0 };
  }

  const vatBase = options.subtotalAfterDiscount + options.processingFee;
  const vatFee = Math.round(vatBase * UAE_VAT_PERCENT * 100) / 100;

  return {
    vatFeePercentage: UAE_VAT_PERCENT * 100, // store as 5.00
    vatFee,
  };
}

export function roundMoney(amount: number): number {
  return Math.round(amount * 100) / 100;
}

/** Final charge: pre-VAT subtotal plus VAT (all fee columns already included in pre-VAT). */
export function computeInvoiceTotalFromBreakdown(parts: {
  preVatTotalAmount: number;
  vatFee?: number | null;
}): number {
  return roundMoney(parts.preVatTotalAmount + (parts.vatFee ?? 0));
}

/** All monetary columns persisted on `invoice` (amounts in dollars, percentages as 3.00 / 5.00). */
export interface InvoiceBillingAmounts {
  /** License charge before discount (licenses × unit price × billing period). */
  gross_license_amount: number;
  /** License charge after discount; same as gross when no discount. */
  subtotal_amount: number;
  discount_amount: number | null;
  proration_amount: number | null;
  processing_fee_percentage: number | null;
  processing_fee: number | null;
  vat_fee_percentage: number | null;
  vat_fee: number | null;
  /** Amount before VAT: net license + proration + processing fee. */
  pre_vat_total_amount: number;
  /** Final amount charged (pre_vat + VAT). */
  total_amount: number;
}

function optionalFeeAmount(amount: number): number | null {
  return amount > 0 ? roundMoney(amount) : null;
}

function optionalFeePercentage(percentage: number): number | null {
  return percentage > 0 ? roundMoney(percentage) : null;
}

/**
 * Build every invoice money field for portal checkout, signup, and admin flows.
 * Processing fee is always 3% of net license (after discount) plus any proration.
 */
export function buildInvoiceBillingAmounts(input: {
  grossLicenseAmount: number;
  discountAmount?: number;
  prorationAmount?: number;
  adminCountry?: string | null;
}): InvoiceBillingAmounts {
  const gross = roundMoney(Math.max(0, input.grossLicenseAmount));
  const discount = roundMoney(Math.max(0, input.discountAmount ?? 0));
  const proration = roundMoney(input.prorationAmount ?? 0);
  const netLicense = roundMoney(Math.max(0, gross - discount));

  const processingBase = netLicense + proration;
  const { amount: processingFee, percentage: processingFeePercentage } = calculateProcessingFee(processingBase);
  const { vatFee, vatFeePercentage } = calculateVatForAdminCountry({
    subtotalAfterDiscount: netLicense + proration,
    processingFee,
    adminCountry: input.adminCountry,
  });

  const preVatTotal = roundMoney(netLicense + proration + processingFee);
  const total = computeInvoiceTotalFromBreakdown({ preVatTotalAmount: preVatTotal, vatFee });

  return {
    gross_license_amount: gross,
    subtotal_amount: netLicense,
    discount_amount: optionalFeeAmount(discount),
    proration_amount: proration !== 0 ? proration : null,
    processing_fee_percentage: optionalFeePercentage(processingFeePercentage),
    processing_fee: optionalFeeAmount(processingFee),
    vat_fee_percentage: optionalFeePercentage(vatFeePercentage),
    vat_fee: optionalFeeAmount(vatFee),
    pre_vat_total_amount: preVatTotal,
    total_amount: total,
  };
}

/**
 * VIP trial → first paid cycle after trial ends (matches portal preview and Stripe `amount_off` coupon).
 * Months 1 & 2 at 50% license, remainder at full monthly rate — discount equals one month at full rate.
 */
export function buildVipTrialFirstPaidInvoiceBillingAmounts(input: {
  grossLicenseAmount: number;
  billingCycleMonths: number;
  adminCountry?: string | null;
}): InvoiceBillingAmounts {
  const gross = roundMoney(Math.max(0, input.grossLicenseAmount));
  const months = Math.max(1, Math.round(input.billingCycleMonths));
  const discount = roundMoney(gross / months);
  return buildInvoiceBillingAmounts({
    grossLicenseAmount: gross,
    discountAmount: discount,
    adminCountry: input.adminCountry,
  });
}

export function isVipTrialPackageType(packageType: string | null | undefined): boolean {
  return packageType === "PRIVATE_VIP_TRIAL";
}

/**
 * First paid invoice after a VIP trial (Stripe coupon or portal preview pricing).
 * Includes `subscription_create` when Stripe bills immediately after sync, not only `subscription_cycle`.
 */
export function shouldApplyVipFirstPaidInvoiceDiscount(input: {
  packageType?: string | null;
  previousPackageType?: string | null;
  billingReason?: string | null;
  /** When true, ignore subscription_create (paid signup mirror). */
  skipSignupAccountSync?: boolean;
}): boolean {
  const isVipContext =
    isVipTrialPackageType(input.packageType ?? null) ||
    isVipTrialPackageType(input.previousPackageType ?? null);
  if (!isVipContext) {
    return false;
  }
  const reason = (input.billingReason ?? "").trim();
  if (reason === "subscription_cycle") {
    return true;
  }
  if (reason === "subscription_create" && !input.skipSignupAccountSync) {
    return true;
  }
  return false;
}

/** Stripe charged ~2/3 of full cycle (VIP one-time offer on quarterly / similar cycles). */
export function stripePaidReflectsVipFirstPaidDiscount(
  fullChargeTotalCents: number,
  stripePaidCents: number,
): boolean {
  if (fullChargeTotalCents <= 0 || stripePaidCents <= 0) {
    return false;
  }
  const ratio = stripePaidCents / fullChargeTotalCents;
  return ratio >= 0.62 && ratio <= 0.72;
}

/**
 * Map renewal / Stripe-sync charge cents to persisted invoice columns.
 * `baseAmountCents` is the net license total for the billing period (before fees).
 */
export function invoiceBillingAmountsFromRenewalCents(params: {
  baseAmountCents: number;
  processingFeeCents: number;
  vatCents: number;
  totalCents: number;
  processingFeePct: number;
  vatPct: number;
  discountAmountCents?: number;
  prorationAmountCents?: number;
}): InvoiceBillingAmounts {
  const discount = roundMoney((params.discountAmountCents ?? 0) / 100);
  const proration = roundMoney((params.prorationAmountCents ?? 0) / 100);
  const netLicense = roundMoney(params.baseAmountCents / 100);
  const gross = roundMoney(netLicense + discount);
  const processingFee = roundMoney(params.processingFeeCents / 100);
  const vatFee = roundMoney(params.vatCents / 100);
  const preVat = roundMoney(netLicense + proration + processingFee);
  const computedTotal = computeInvoiceTotalFromBreakdown({ preVatTotalAmount: preVat, vatFee });
  const explicitTotal = roundMoney(params.totalCents / 100);
  const total =
    Math.abs(explicitTotal - computedTotal) > 0.02 ? explicitTotal : computedTotal;

  return {
    gross_license_amount: gross,
    subtotal_amount: netLicense,
    discount_amount: optionalFeeAmount(discount),
    proration_amount: proration !== 0 ? proration : null,
    processing_fee_percentage: optionalFeePercentage(params.processingFeePct),
    processing_fee: optionalFeeAmount(processingFee),
    vat_fee_percentage: optionalFeePercentage(params.vatPct),
    vat_fee: optionalFeeAmount(vatFee),
    pre_vat_total_amount: preVat,
    total_amount: total,
  };
}

/** Money columns on `invoice` for Prisma create/update (numeric values; Prisma accepts number or Decimal). */
export interface InvoiceBillingPrismaAmountFields {
  gross_license_amount: number;
  subtotal_amount: number;
  discount_amount: number | null;
  proration_amount: number | null;
  processing_fee_percentage: number | null;
  processing_fee: number | null;
  vat_fee_percentage: number | null;
  vat_fee: number | null;
  pre_vat_total_amount: number;
  total_amount: number;
}

/** Prisma-friendly invoice money fields (nulls for zero optional fees). */
export function invoiceBillingAmountsToDbFields(amounts: InvoiceBillingAmounts): InvoiceBillingPrismaAmountFields {
  return {
    gross_license_amount: amounts.gross_license_amount,
    subtotal_amount: amounts.subtotal_amount,
    discount_amount: amounts.discount_amount,
    proration_amount: amounts.proration_amount,
    processing_fee_percentage: amounts.processing_fee_percentage,
    processing_fee: amounts.processing_fee,
    vat_fee_percentage: amounts.vat_fee_percentage,
    vat_fee: amounts.vat_fee,
    pre_vat_total_amount: amounts.pre_vat_total_amount,
    total_amount: amounts.total_amount,
  };
}

/** Same as {@link invoiceBillingAmountsToDbFields} with Prisma `Decimal` values. */
export function invoiceBillingAmountsToPrismaDecimals(
  amounts: InvoiceBillingAmounts,
): {
  gross_license_amount: Decimal;
  subtotal_amount: Decimal;
  discount_amount: Decimal | null;
  proration_amount: Decimal | null;
  processing_fee_percentage: Decimal | null;
  processing_fee: Decimal | null;
  vat_fee_percentage: Decimal | null;
  vat_fee: Decimal | null;
  pre_vat_total_amount: Decimal;
  total_amount: Decimal;
} {
  const f = invoiceBillingAmountsToDbFields(amounts);
  const dec = (n: number): Decimal => new Decimal(n);
  const decOrNull = (n: number | null): Decimal | null => (n === null ? null : new Decimal(n));
  return {
    gross_license_amount: dec(f.gross_license_amount),
    subtotal_amount: dec(f.subtotal_amount),
    discount_amount: decOrNull(f.discount_amount),
    proration_amount: decOrNull(f.proration_amount),
    processing_fee_percentage: decOrNull(f.processing_fee_percentage),
    processing_fee: decOrNull(f.processing_fee),
    vat_fee_percentage: decOrNull(f.vat_fee_percentage),
    vat_fee: decOrNull(f.vat_fee),
    pre_vat_total_amount: dec(f.pre_vat_total_amount),
    total_amount: dec(f.total_amount),
  };
}

results matching ""

    No results matching ""