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,
};
}
/**
* Heal inconsistent invoice money columns for admin detail + PDF.
*
* Common prod bug: VIP/recovery wrote the **already-discounted** net into both
* `gross_license_amount` and `subtotal_amount` (no discount row), or left gross
* null while discount was present — so detail columns and qty×months×unit disagree.
*
* Returns fields to patch, or `null` when nothing needs changing.
*/
export function repairInvoiceBillingBreakdownFields(row: {
unit_price_per_license?: number | null;
license_quantity?: number | null;
billing_cycle?: string | null;
billing_cycle_months?: number | null;
gross_license_amount?: number | null;
subtotal_amount?: number | null;
discount_amount?: number | null;
proration_amount?: number | null;
processing_fee?: number | null;
vat_fee?: number | null;
pre_vat_total_amount?: number | null;
total_amount?: number | null;
}): Partial<InvoiceBillingPrismaAmountFields> | null {
const net = roundMoney(Math.max(0, row.subtotal_amount ?? 0));
const discount = roundMoney(Math.max(0, row.discount_amount ?? 0));
const proration = roundMoney(row.proration_amount ?? 0);
const processingFee = roundMoney(Math.max(0, row.processing_fee ?? 0));
const vatFee = roundMoney(Math.max(0, row.vat_fee ?? 0));
let gross = roundMoney(Math.max(0, row.gross_license_amount ?? 0));
const unit = Number(row.unit_price_per_license ?? 0);
const qty = Number(row.license_quantity ?? 0);
const monthsFromCycle =
typeof row.billing_cycle_months === "number" && row.billing_cycle_months > 0
? row.billing_cycle_months
: null;
// Prefer pricing multiplier (annual = 10 billed months), not calendar display months.
let catalogMonths = monthsFromCycle;
if (catalogMonths == null && row.billing_cycle) {
const c = String(row.billing_cycle).toUpperCase();
catalogMonths = c === "ANNUAL" ? 10 : c === "HALF_YEARLY" ? 6 : c === "QUARTERLY" ? 3 : 1;
}
const catalogGross =
unit > 0 && qty > 0 && (catalogMonths ?? 0) > 0
? roundMoney(unit * qty * (catalogMonths as number))
: 0;
const patch: Partial<InvoiceBillingPrismaAmountFields> = {};
if (discount > 0.001) {
const expectedGross = roundMoney(net + discount);
if (gross <= 0.001 || Math.abs(gross - net) <= 0.02) {
gross = expectedGross;
patch.gross_license_amount = gross;
} else if (Math.abs(gross - expectedGross) > 0.02 && catalogGross > 0 && Math.abs(catalogGross - expectedGross) <= 0.02) {
gross = catalogGross;
patch.gross_license_amount = gross;
}
} else if (gross <= 0.001) {
if (catalogGross > 0.001 && Math.abs(net - catalogGross) <= 0.02) {
patch.gross_license_amount = catalogGross;
gross = catalogGross;
} else if (net > 0.001) {
patch.gross_license_amount = net;
gross = net;
}
} else if (
catalogGross > 0.001 &&
Math.abs(gross - net) <= 0.02 &&
Math.abs(net - roundMoney(catalogGross * (2 / 3))) <= 0.05
) {
// VIP ~2/3 charge stored without a discount column.
const inferredDiscount = roundMoney(catalogGross - net);
if (inferredDiscount > 0.001) {
patch.gross_license_amount = catalogGross;
patch.discount_amount = inferredDiscount;
gross = catalogGross;
}
}
const expectedPreVat = roundMoney(net + proration + processingFee);
const preVatStored = roundMoney(Math.max(0, row.pre_vat_total_amount ?? 0));
if (preVatStored <= 0.001 || Math.abs(preVatStored - expectedPreVat) > 0.02) {
// Only rewrite pre_vat when it looks broken (missing or double-subtracted discount).
const looksDoubleDiscounted =
discount > 0.001 && Math.abs(preVatStored - roundMoney(net + proration - discount + processingFee)) <= 0.02;
if (preVatStored <= 0.001 || looksDoubleDiscounted) {
patch.pre_vat_total_amount = expectedPreVat;
}
}
const preVat = patch.pre_vat_total_amount ?? (preVatStored > 0.001 ? preVatStored : expectedPreVat);
const expectedTotal = roundMoney(preVat + vatFee);
const totalStored = roundMoney(Math.max(0, row.total_amount ?? 0));
if (totalStored <= 0.001 && expectedTotal > 0.001) {
patch.total_amount = expectedTotal;
}
return Object.keys(patch).length > 0 ? patch : null;
}
/** 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),
};
}