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),
};
}