/** * Tax Calculator — Shared tax calculation logic * * Used by: * - UI (Angular): for real-time tax calculation on transaction screens * - API (Node.js): for server-side validation and future recalculation * * This module has NO dependencies on Angular, Mongoose, Express, or any * framework. It only depends on: * - Big.js (for precise decimal arithmetic) * - Types from ./tax.types * - Utility functions from ../shared/math-operations and ../shared/util * * IMPORTANT: All monetary calculations use Big.js to avoid floating-point * precision issues. Never use plain JS arithmetic (* / + -) for money. */ import Big from "big.js"; import { GetNumber } from "../shared/util"; import { Add, Subtract, Multiply, Divide } from "../shared/math-operations"; import { ITaxCode, ITaxCodeComponent, ITaxComponent, ITaxSummary, ITaxSummaryLine, ILineAmountFields, ITaxCalcLineInput, ITaxSummaryInput, IRoundingConfig, IDocumentTotalsInput, IDocumentTotals, IComponentOverride, CalcMethod, ResolvedSupplyType, IWithholding, IWithholdingCalcInput, ITaxIdLabel, ITaxIdEntry, } from "./tax.types"; // ============================================================ // Net Amount Calculation // ============================================================ /** * Computes the gross line amount before discounts and tax. * * Formula: GrossAmt = Qty × UnitPrice * * If `UnAmt` / `Amt` is already present on the line, that value is treated as * the line's gross amount source of truth. * * This is the amount used for document `SubTotal` under the standard: * SubTotal = sum of line values before line/document discounts and before tax. */ export function CalculateGrossAmt(input: ILineAmountFields): number { const line = input as ILineAmountFields & { UnAmt?: number; Amt?: number; UnPr?: number; Pr?: number; UnCo?: number; UnitPrice?: number; }; if (line.UnAmt != null || line.Amt != null) { return GetNumber(line.UnAmt ?? line.Amt); } const unitPrice = line.UnitPrice ?? line.UnPr ?? line.Pr ?? line.UnCo; if (unitPrice != null) { const qty = (input.Qty == null || input.Qty == 0) ? 1 : GetNumber(input.Qty); return Multiply(qty, GetNumber(unitPrice)); } return 0; } /** * Computes the net taxable amount from line item fields. * * Formula: NetAmt = (Qty × UnitPrice) - Disc - RecDisc * * Generic across both goods and services: * For Items (goods): pass UnitPrice = item.UnPr * For Ops (services): pass UnitPrice = op.Pr * * @param input - Line amount fields: Qty, UnitPrice, Disc, RecDisc * @returns The net taxable amount * * @example * // Item: 10 units × Rs 500, line discount Rs 200, record discount Rs 100 * CalculateNetAmt({ Qty: 10, UnitPrice: 500, Disc: 200, RecDisc: 100 }); * // Returns: 4700 (5000 - 200 - 100) * * @example * // Op: 2 hours × Rs 1500, no discounts * CalculateNetAmt({ Qty: 2, UnitPrice: 1500 }); * // Returns: 3000 */ export function CalculateNetAmt(input: ILineAmountFields): number { const disc = GetNumber(input.Disc); const recDisc = GetNumber(input.RecDisc); return Subtract(CalculateGrossAmt(input), disc, recDisc); } // ============================================================ // Core Tax Calculation // ============================================================ /** * Calculates tax for a single line item based on the provided TaxCode. * * This is the MAIN function used by the UI when a user selects a tax code * and the system needs to compute CGST, SGST, IGST, Cess, etc. * * @param input - Line item context (Qty, UnitPrice, Disc, RecDisc, TaxCode, RCM flag) * @param rounding - Rounding configuration (from TaxRegime.Rounding) * @returns Array of ITaxComponent — one entry per tax component * * @example * // India GST 18% Intra-State: 10 units × Rs 500 * const result = CalculateLineTax({ * Qty: 10, UnitPrice: 500, * TaxCode: gst18IntraTaxCode, * }); * // NetAmt = 5000, Returns: [ * // { Code: "CGST", Rate: 9, Amt: 450, TaxCodeId: 106 }, * // { Code: "SGST", Rate: 9, Amt: 450, TaxCodeId: 106 }, * // ] * * @example * // India GST 28% + Specific Cess (Rs 12/liter) on 100 liters × Rs 100 * const result = CalculateLineTax({ * Qty: 100, UnitPrice: 100, * TaxCode: gst28CessDrinkTaxCode, * }); * // NetAmt = 10000, Returns: [ * // { Code: "CGST", Rate: 14, Amt: 1400, TaxCodeId: 201 }, * // { Code: "SGST", Rate: 14, Amt: 1400, TaxCodeId: 201 }, * // { Code: "CESS", Rate: 0, Amt: 1200, TaxCodeId: 201 }, * // ] */ export function CalculateLineTax( input: ITaxCalcLineInput, rounding: IRoundingConfig = { Method: "Round", Precision: 2 } ): ITaxComponent[] { const { TaxCode, RCM, ComponentOverrides } = input; const netAmt = CalculateNetAmt(input); const qty = GetNumber(input.Qty, 1); // Exempt / Nil / NonTaxable — no components if (!TaxCode.Components || TaxCode.Components.length === 0) { return []; } // Determine line-level rounding precision: // If LineTax is active, apply the configured rounding (Method + Precision) // Otherwise, use standard currency precision (2 decimals, half-up) // This distinction matters when Precision is 0 (India/Japan) — we don't want // to round each line's tax to whole rupees unless LineTax is explicitly ON. const lineRounding: IRoundingConfig = rounding.LineTax ? { Method: rounding.Method, Precision: rounding.Precision } : { Method: "Round", Precision: 2 }; const result: ITaxComponent[] = []; // First pass: calculate all "Tax" type components (primary taxes) // These are needed because some components (Cess with AppliedOn: "PostTax") // may need the sum of primary taxes as their base let primaryTaxTotal = 0; for (const comp of TaxCode.Components) { if (comp.AppliedOn !== "PostTax") { // Apply ComponentOverrides if provided (e.g., CESS Rate/PerUnitAmt from item master) const effectiveComp = ApplyComponentOverride(comp, ComponentOverrides); const calculated = CalculateSingleComponent(effectiveComp, netAmt, qty, lineRounding); if (comp.Type === "Tax") { primaryTaxTotal = Add(primaryTaxTotal, calculated.Amt); } result.push(BuildTaxComponent(effectiveComp, calculated.Amt, TaxCode._id)); } } // Second pass: calculate components that apply on PostTax (tax-on-tax) for (const comp of TaxCode.Components) { if (comp.AppliedOn === "PostTax") { const effectiveComp = ApplyComponentOverride(comp, ComponentOverrides); const postTaxBase = Add(netAmt, primaryTaxTotal); const calculated = CalculateSingleComponent(effectiveComp, postTaxBase, qty, lineRounding); result.push(BuildTaxComponent(effectiveComp, calculated.Amt, TaxCode._id)); } } return result; } /** * Merges ComponentOverrides into a TaxCode component. * * Only Rate and PerUnitAmt can be overridden — structural fields (Code, Name, * Type, CalcMethod, AppliedOn) always come from the TaxCode definition. * * Primary use case: CESS components where the TaxCode has placeholder values * (Rate: 0, PerUnitAmt: 0) and the actual values come from the item master. */ function ApplyComponentOverride( comp: ITaxCodeComponent, overrides?: Record ): ITaxCodeComponent { if (!overrides) return comp; const override = overrides[comp.Code]; if (!override) return comp; return { ...comp, Rate: override.Rate ?? comp.Rate, PerUnitAmt: override.PerUnitAmt ?? comp.PerUnitAmt, Unit: override.Unit ?? comp.Unit, }; } /** * Calculates tax amount for a single component. * * Handles four calculation methods: * - Percent: (netAmt * rate) / 100 * - PerUnit: perUnitAmt * qty * - PerUnitPlusPercent: (perUnitAmt * qty) + (netAmt * rate / 100) * - MaxOfPercentOrPerUnit: max(netAmt * rate / 100, perUnitAmt * qty) * * MaxOfPercentOrPerUnit is used for India CESS on tobacco categories where * the cess is "X% or ₹Y per unit, whichever is higher" (CBIC notification). */ function CalculateSingleComponent( comp: ITaxCodeComponent, baseAmt: number, qty: number, rounding: IRoundingConfig ): { Amt: number; taxableAmt: number } { const method: CalcMethod = comp.CalcMethod || "Percent"; let amt: number = 0; let taxableAmt = baseAmt; switch (method) { case "Percent": { const rate = GetNumber(comp.Rate); if (rate === 0) { amt = 0; } else { amt = Multiply(baseAmt, Divide(rate, 100)); } break; } case "PerUnit": { const perUnitAmt = GetNumber(comp.PerUnitAmt); amt = Multiply(perUnitAmt, qty); taxableAmt = baseAmt; // taxable base is still the net amount for reporting break; } case "PerUnitPlusPercent": { const perUnitAmt = GetNumber(comp.PerUnitAmt); const rate = GetNumber(comp.Rate); const fixedPart = Multiply(perUnitAmt, qty); const percentPart = Multiply(baseAmt, Divide(rate, 100)); amt = Add(fixedPart, percentPart); break; } case "MaxOfPercentOrPerUnit": { const perUnitAmt = GetNumber(comp.PerUnitAmt); const rate = GetNumber(comp.Rate); const perUnitResult = Multiply(perUnitAmt, qty); const percentResult = Multiply(baseAmt, Divide(rate, 100)); amt = Math.max(perUnitResult, percentResult); break; } } // Apply rounding amt = RoundAmount(amt, rounding); return { Amt: amt, taxableAmt }; } /** * Builds a lean ITaxComponent snapshot from a component definition and calculated amount. * * Core fields (always stored): Code, Rate, Amt, TaxCodeId * Snapshot fields (only for non-percent): CalcMethod, PerUnitAmt * * For standard percent-based components (CGST, SGST, IGST), CalcMethod and PerUnitAmt * are omitted to keep the snapshot lean. They're only included when the calculation * method is PerUnit, PerUnitPlusPercent, or MaxOfPercentOrPerUnit — because these * values may come from ComponentOverrides (item CessConfig) and must be preserved * for invoice reprinting, credit notes, and audit. * * TaxableAmt is NOT stored — it's derivable from line item fields (Qty × UnitPrice - Discount). * TaxAmt (sum of Taxes[].Amt) is NOT stored — it's a simple addition, computed on the fly. */ function BuildTaxComponent( comp: ITaxCodeComponent, amt: number, taxCodeId: number, ): ITaxComponent { const result: ITaxComponent = { Code: comp.Code, Rate: comp.Rate, Amt: amt, TaxCodeId: taxCodeId, }; // Snapshot CalcMethod, PerUnitAmt, and Unit for non-percent components // so the invoice is self-contained even if item cess config changes later const method: CalcMethod = comp.CalcMethod || "Percent"; if (method !== "Percent") { result.CalcMethod = method; if (comp.PerUnitAmt != null && comp.PerUnitAmt !== 0) { result.PerUnitAmt = comp.PerUnitAmt; } if (comp.Unit) { result.Unit = comp.Unit; } } return result; } // ============================================================ // Tax-Inclusive Price Back-Calculation // ============================================================ /** * Extracts the pre-tax (net) amount from a tax-inclusive price. * * Used when the entity settings have TaxInc: "I" (tax inclusive pricing). * Given a price that already includes tax, this function back-calculates * what the net amount should be. * * Formula: NetAmt = InclusivePrice / (1 + CombinedRate/100) * * NOTE: This only works for Percent-based components. PerUnit components * are subtracted directly: NetAmt = InclusivePrice - (PerUnitAmt * Qty) * before applying the percentage back-calculation. * * @param inclusivePrice - The price including tax * @param taxCode - The TaxCode to use for back-calculation * @param qty - Quantity (needed for PerUnit cess components) * @param rounding - Rounding configuration * @returns The pre-tax net amount * * @example * // Price Rs 5,900 inclusive of GST 18% * const netAmt = ExtractNetFromInclusive(5900, gst18IntraTaxCode); * // Returns: 5000 (because 5000 + 18% = 5900) */ /** * Result of tax-inclusive back-calculation. * * When the calculation is exact, `warning` is undefined. * When the TaxCode contains unsupported component types (PerUnitPlusPercent, PostTax), * an approximate result is returned with a `warning` flag so the caller can decide * whether to show a warning or reject the result. */ export interface IInclusiveResult { NetAmt: number; /** If set, the back-calculation is approximate — component structure doesn't support exact inversion */ Warning?: string; } export function ExtractNetFromInclusive( inclusivePrice: number, taxCode: ITaxCode, qty: number = 1, rounding: IRoundingConfig = { Method: "Round", Precision: 2 } ): IInclusiveResult { if (!taxCode.Components || taxCode.Components.length === 0) { return { NetAmt: inclusivePrice }; } // Guard: check for unsupported inversion cases let warning: string | undefined; for (const comp of taxCode.Components) { if (comp.CalcMethod === "PerUnitPlusPercent") { warning = "Inclusive back-calc is approximate: PerUnitPlusPercent component present"; break; } if (comp.CalcMethod === "MaxOfPercentOrPerUnit") { warning = "Inclusive back-calc is approximate: MaxOfPercentOrPerUnit component present"; break; } if (comp.AppliedOn === "PostTax") { warning = "Inclusive back-calc is approximate: PostTax (tax-on-tax) component present"; break; } } let priceAfterFixedDeduction = GetNumber(inclusivePrice); // Step 1: Subtract any PerUnit (fixed) components first for (const comp of taxCode.Components) { if (comp.CalcMethod === "PerUnit") { const fixedTax = Multiply(GetNumber(comp.PerUnitAmt), GetNumber(qty)); priceAfterFixedDeduction = new Big(priceAfterFixedDeduction).minus(new Big(fixedTax)).toNumber(); } } // Step 2: Back-calculate percentage-based components // Sum all percentage rates (only Percent type, applied on NetAmt) let totalPercentRate = 0; for (const comp of taxCode.Components) { if (comp.CalcMethod === "Percent" && comp.AppliedOn === "NetAmt") { totalPercentRate = Add(totalPercentRate, GetNumber(comp.Rate)); } } // NetAmt = InclusivePrice / (1 + totalRate/100) let netAmt: number; if (totalPercentRate > 0) { const divisor = new Big(1).plus(new Big(totalPercentRate).div(100)); netAmt = RoundAmount(new Big(priceAfterFixedDeduction).div(divisor).toNumber(), rounding); } else { netAmt = RoundAmount(priceAfterFixedDeduction, rounding); } return { NetAmt: netAmt, Warning: warning }; } // ============================================================ // Total Tax Amount (convenience) // ============================================================ /** * Sums the Amt of all tax components on a line item. * * @param taxes - Array of ITaxComponent from a line item's Taxes[] * @returns Total tax amount * * @example * const totalTax = SumTaxComponents(lineItem.Taxes); * // CGST 450 + SGST 450 = 900 */ export function SumTaxComponents(taxes: ITaxComponent[] | undefined | null): number { if (!taxes || taxes.length === 0) return 0; let total = 0; for (const tax of taxes) { total = Add(total, GetNumber(tax.Amt)); } return total; } // ============================================================ // TaxSummary — Document-Level Aggregation // ============================================================ /** * Computes the document-level TaxSummary by aggregating all line items' Taxes[]. * * This is a PURE FUNCTION — it does not read from database. * Computes NetAmt per line internally from Qty/UnitPrice/Disc/RecDisc. * * Groups tax amounts by Code+Rate (e.g., CGST@9 and CGST@14 are separate buckets). * TaxableAmt per bucket = sum of line NetAmt for lines that have that Code+Rate. * TotalTaxable = sum of unique line NetAmts (not double-counted across components). * * Rounding behavior (via input.Rounding): * - TaxComponentTotal ON: each bucket's Amt is rounded to Precision * - Otherwise: Amts are at currency precision (from CalculateLineTax) * * @param input - All line items with Qty/UnitPrice/Disc/RecDisc and Taxes[], plus regime code and optional rounding * @returns ITaxSummary — the aggregated summary (stored on the document) * * @example * const summary = ComputeTaxSummary({ * Lines: invoice.Items.map(item => ({ * Qty: item.Qty, UnitPrice: item.UnPr, Disc: item.Disc, RecDisc: item.RecDisc, * Taxes: item.Taxes, * })), * RegimeCode: "IN_GST", * Rounding: { Method: "Round", Precision: 0, TaxComponentTotal: true }, * }); * // Returns: { * // Lines: [ * // { Code: "CGST", Rate: 9, TaxableAmt: 50000, Amt: 4500 }, * // { Code: "SGST", Rate: 9, TaxableAmt: 50000, Amt: 4500 } * // ], * // TotalTaxable: 50000, * // TotalTax: 9000, * // RegimeCode: "IN_GST" * // } */ export function ComputeTaxSummary(input: ITaxSummaryInput): ITaxSummary { const { Lines, RegimeCode, Rounding } = input; // Group by Code+Rate (more granular than Code-only for rate-wise reporting) const groupMap = new Map(); let totalTaxable = 0; for (const line of Lines) { if (!line.Taxes || line.Taxes.length === 0) continue; const lineNetAmt = CalculateNetAmt(line); // Track this line's taxable amount at document level (once per line, not per component) totalTaxable = Add(totalTaxable, lineNetAmt); // Track which Code+Rate buckets we've already added this line's NetAmt to const bucketsTrackedForLine = new Set(); for (const tax of line.Taxes) { const rate = GetNumber(tax.Rate); const key = `${tax.Code}|${rate}`; if (!groupMap.has(key)) { groupMap.set(key, { Code: tax.Code, Rate: rate, TaxableAmt: 0, Amt: 0, }); } const group = groupMap.get(key)!; // Add line's NetAmt to this bucket's TaxableAmt (once per line per bucket) if (!bucketsTrackedForLine.has(key)) { group.TaxableAmt = Add(group.TaxableAmt, lineNetAmt); bucketsTrackedForLine.add(key); } group.Amt = Add(group.Amt, GetNumber(tax.Amt)); } } // Build summary lines, applying TaxComponentTotal rounding if configured const summaryLines: ITaxSummaryLine[] = []; let totalTax = 0; for (const [, group] of groupMap) { let amt = group.Amt; // If TaxComponentTotal rounding is active, round each bucket's Amt if (Rounding?.TaxComponentTotal) { amt = RoundAmount(amt, { Method: Rounding.Method, Precision: Rounding.Precision }); } summaryLines.push({ Code: group.Code, Rate: group.Rate, TaxableAmt: group.TaxableAmt, Amt: amt, }); totalTax = Add(totalTax, amt); } return { Lines: summaryLines, TotalTaxable: totalTaxable, TotalTax: totalTax, RegimeCode: RegimeCode, }; } // ============================================================ // Document Totals — Full document computation // ============================================================ /** * Computes all document-level totals in one call: SubTotal, Discount, * TaxableAmount, TaxTotal, TaxSummary, Round, and Total. * * This is the primary function UI/API should call at save time to produce * the stored financial fields. All rounding is applied per the RoundingConfig. * * Flow: * 1. SubTotal = sum of gross line values before discounts/tax * 2. Discount = sum of line discount + prorated record discount allocations * 3. TaxableAmount = sum of line NetAmts * 4. TaxSummary = grouped by Code+Rate (rounded per TaxComponentTotal) * 5. TaxTotal = sum of TaxSummary Amts * 6. GrandTotal = TaxableAmount + TaxTotal * 7. If DocTotal ON: round GrandTotal, compute Round adjustment * 8. Total = GrandTotal + Round * * NOTE: Document-level Discount, Adjust, and Withholding are NOT handled here. * If a document-level discount exists (e.g. India GST invoice discount), the * caller must prorate it into line `RecDisc` values before calling this * function so each line's taxable base is correct. * Additional final settlement adjustments are applied after this function: * FinalPayable = Total + Adjust - Withholding.Amt * * @param input - Line items with Qty/UnitPrice/Disc/RecDisc and Taxes[], plus RoundingConfig * @returns IDocumentTotals — all stored financial fields * * @example * // India GST invoice with 2 lines * const totals = ComputeDocumentTotals({ * Lines: [ * { Qty: 10, UnitPrice: 500, * Taxes: [{ Code: "CGST", Rate: 9, Amt: 450, TaxCodeId: 1 }, * { Code: "SGST", Rate: 9, Amt: 450, TaxCodeId: 1 }] }, * { Qty: 6, UnitPrice: 500, * Taxes: [{ Code: "CGST", Rate: 9, Amt: 270, TaxCodeId: 1 }, * { Code: "SGST", Rate: 9, Amt: 270, TaxCodeId: 1 }] }, * ], * Rounding: { Method: "Round", Precision: 0, TaxComponentTotal: true, DocTotal: true }, * RegimeCode: "IN_GST", * }); * // Returns: { * // SubTotal: 8000, Discount: 0, TaxableAmount: 8000, * // TaxTotal: 1440, Round: 0, Total: 9440, GrandTotal: 9440, * // TaxSummary: [ * // { Code: "CGST", Rate: 9, TaxableAmt: 8000, Amt: 720 }, * // { Code: "SGST", Rate: 9, TaxableAmt: 8000, Amt: 720 }, * // ] * // } */ export function ComputeDocumentTotals(input: IDocumentTotalsInput): IDocumentTotals { const { Lines, Rounding, RegimeCode, Adjust } = input; // 1. SubTotal = sum of gross line values before discounts/tax let subTotal = 0; let discount = 0; let taxableAmount = 0; for (const line of Lines) { subTotal = Add(subTotal, CalculateGrossAmt(line)); discount = Add(discount, GetNumber(line.Disc), GetNumber(line.RecDisc)); taxableAmount = Add(taxableAmount, CalculateNetAmt(line)); } // 2. TaxSummary (handles TaxComponentTotal rounding internally) const taxSummary = ComputeTaxSummary({ Lines, RegimeCode, Rounding, }); // 3. TaxTotal from the (potentially rounded) summary const taxTotal = taxSummary.TotalTax; // 4. GrandTotal before doc-level rounding const grandTotal = Add(taxableAmount, taxTotal, Adjust ?? 0); // 5. DocTotal rounding let roundedTotal = grandTotal; let roundAdj = 0; if (Rounding.DocTotal !== false) { // default true roundedTotal = RoundAmount(grandTotal, { Method: Rounding.Method, Precision: Rounding.Precision }); roundAdj = Subtract(roundedTotal, grandTotal); } return { SubTotal: subTotal, Discount: discount, TaxableAmount: taxableAmount, TaxTotal: taxTotal, TaxSummary: taxSummary.Lines, GrandTotal: grandTotal, Round: roundAdj, Total: roundedTotal, }; } // ============================================================ // Supply Type Detection // ============================================================ /** * Determines Intra-State or Inter-State based on seller and buyer state codes. * * Used in India GST where Place of Supply determines which tax components apply. * * Returns "Unknown" if either state code is missing — callers MUST handle this case. * UI should show a warning/prompt; API should reject or use a safe default. * * @param sellerStateCode - Seller's GST state code (e.g., "29" for Karnataka) * @param buyerStateCode - Buyer's GST state code (e.g., "33" for Tamil Nadu) * @returns "Intra" if same state, "Inter" if different states, "Unknown" if data missing * * @example * DetermineSupplyType("29", "29"); // "Intra" — both Karnataka * DetermineSupplyType("29", "33"); // "Inter" — Karnataka to Tamil Nadu * DetermineSupplyType("", "33"); // "Unknown" — seller state missing */ export function DetermineSupplyType(sellerStateCode: string, buyerStateCode: string): ResolvedSupplyType { if (!sellerStateCode || !buyerStateCode) { return "Unknown"; } return sellerStateCode.trim() === buyerStateCode.trim() ? "Intra" : "Inter"; } /** * Finds a TaxCode by combined rate and supply type. * * CombinedRate is a DISPLAY HINT only. It represents the sum of percentage-based * rates and does NOT account for PerUnit or PostTax components. Use this function * only for simple percentage-only tax codes (like standard India GST without cess). * * @param taxCodes - List of available TaxCodes * @param combinedRate - The desired combined rate (e.g., 18) * @param supplyType - "Intra" or "Inter" * @returns The matching TaxCode, or undefined if not found * * @example * // Find GST 18% Inter-State (simple percentage-only tax code) * const interCode = FindTaxCodeByRateAndSupplyType(allTaxCodes, 18, "Inter"); */ export function FindTaxCodeByRateAndSupplyType( taxCodes: ITaxCode[], combinedRate: number, supplyType: "Intra" | "Inter" ): ITaxCode | undefined { return taxCodes.find(tc => tc.IsActive && tc.CombinedRate === combinedRate && (tc.SupplyType === supplyType || tc.SupplyType === "All") ); } // ============================================================ // Rounding Utilities // ============================================================ /** * Rounds a number according to the specified rounding configuration. * * Different countries use different rounding rules: * - India: standard rounding to 2 decimals * - Japan: truncate (floor) to whole numbers * - Some EU countries: banker's rounding */ export function RoundAmount(value: number, config: IRoundingConfig): number { const precision = config.Precision ?? 2; const big = new Big(GetNumber(value)); switch (config.Method) { case "Round": return Number(big.toFixed(precision, Big.roundHalfUp)); case "Floor": return Number(big.toFixed(precision, Big.roundDown)); case "Ceil": return Number(big.toFixed(precision, Big.roundUp)); case "BankersRound": return Number(big.toFixed(precision, Big.roundHalfEven)); default: return Number(big.toFixed(precision, Big.roundHalfUp)); } } // ============================================================ // Validation Utilities // ============================================================ /** * Validates a tax identification number against the regime's format regex. * * @param taxId - The tax ID to validate (e.g., "29ABCDE1234F1Z5") * @param formatRegex - Regex pattern from TaxRegime.Features.TaxIdFormat * @returns true if valid, false if invalid * * @example * // Validate Indian GSTIN * ValidateTaxId("29ABCDE1234F1Z5", "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$"); * // Returns: true */ export function ValidateTaxId(taxId: string, formatRegex: string): boolean { if (!taxId || !formatRegex) return false; try { const regex = new RegExp(formatRegex); return regex.test(taxId.trim()); } catch { return false; } } /** * Validates HSN/SAC code length against entity settings. * * @param code - The HSN or SAC code * @param requiredLength - Required length from entity settings ("4", "6", "8") * @returns true if valid, false if invalid * * @example * ValidateHSNSACLength("8471", "4"); // true * ValidateHSNSACLength("8471", "6"); // false — needs 6 digits */ export function ValidateHSNSACLength(code: string, requiredLength: string): boolean { if (!code) return false; const trimmed = code.trim(); const len = parseInt(requiredLength, 10); if (isNaN(len)) return false; return trimmed.length >= len; } /** * Returns default TaxIdLabels for a given country. * * Used as seed data when creating TaxRegime records. The returned array is * stored on TaxRegime.Features.TaxIdLabels[], which becomes the source of truth. * At runtime, UI reads TaxIdLabels from TaxRegime — NOT from this function. * * @param country - ISO 3166-1 alpha-2 country code ("IN", "AU", "US", etc.) * @returns Array of ITaxIdLabel — default tax ID definitions for the country * * @example * // When seeding India GST regime: * const labels = GetDefaultTaxIdLabels("IN"); * // Returns: [ * // { Label: "GSTIN", Primary: true, Required: false, RegexValidate: "..." }, * // { Label: "PAN", Primary: false, Required: true, RegexValidate: "..." }, * // { Label: "CIN", Primary: false, Required: false }, * // ] */ export function GetDefaultTaxIdLabels(country: string): ITaxIdLabel[] { switch (country) { case "IN": return [ { Label: "GSTIN", Required: false, Primary: true, RegexValidate: "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$" }, { Label: "PAN", Required: true, Primary: false, RegexValidate: "^[A-Z]{5}[0-9]{4}[A-Z]{1}$" }, { Label: "CIN", Required: false, Primary: false }, ]; case "AU": return [ { Label: "ABN", Required: true, Primary: true, RegexValidate: "^[0-9]{11}$" }, ]; case "US": return [ { Label: "EIN", Required: false, Primary: true, RegexValidate: "^[0-9]{2}-[0-9]{7}$" }, ]; case "GB": return [ { Label: "VAT No", Required: true, Primary: true, RegexValidate: "^GB[0-9]{9}$" }, ]; case "AE": return [ { Label: "TRN", Required: true, Primary: true, RegexValidate: "^[0-9]{15}$" }, ]; case "CA": return [ { Label: "BN", Required: true, Primary: true }, { Label: "GST/HST No", Required: false, Primary: false }, ]; default: return [ { Label: "Tax ID", Required: false, Primary: true }, ]; } } /** * Filters TaxIds[] entries to only those marked for printing. * * Print logic: include if `Print` is `true` or `undefined` (default = print). * Exclude only when `Print` is explicitly `false`. * * Used by invoice/PDF templates to determine which entity and * customer/vendor tax IDs to render. * * @param taxIds - The TaxIds[] array from Entity Settings, Customer, or Vendor * @returns Filtered array of printable entries * * @example * const entityTaxIds = [ * { Label: "GSTIN", Value: "29ABCDE1234F1Z5", Print: true }, * { Label: "PAN", Value: "ABCDE1234F", Print: false }, * { Label: "CIN", Value: "U12345MH2020PTC123456" }, // Print undefined = include * ]; * GetPrintableTaxIds(entityTaxIds); * // Returns: [{ Label: "GSTIN", Value: "29ABCDE1234F1Z5" }, { Label: "CIN", Value: "U12345MH2020PTC123456" }] */ export function GetPrintableTaxIds(taxIds: ITaxIdEntry[] | undefined): ITaxIdEntry[] { if (!taxIds || taxIds.length === 0) return []; return taxIds.filter(t => t.Print !== false); } // ============================================================ // Withholding Tax — Generic TDS/TCS for all countries // ============================================================ /** * Calculates a withholding tax entry. * * This is a generic function that handles both: * - TDS (Type: "Deducted") — buyer deducts from payment * - TCS (Type: "Collected") — seller collects from buyer * * The function is simple: Rate * BaseAmt = Amt. * The CGST/SGST split for India is NOT done here — that's a presentation * concern handled by the UI/reporting layer. The Withholding[] array stores * the TOTAL withholding amount per section. * * @param input - Withholding calculation input * @param rounding - Rounding configuration * @returns IWithholding — the calculated withholding entry * * @example * // India GST TDS — 2% on Rs 3,00,000 taxable value * const tds = CalculateWithholding({ * Type: "Deducted", * Section: "51", * Rate: 2, * BaseAmt: 300000, * PartyLiable: "Buyer", * }); * // Returns: { Type: "Deducted", Section: "51", Rate: 2, BaseAmt: 300000, Amt: 6000, PartyLiable: "Buyer" } * * @example * // India GST TCS — 1% on Rs 5,00,000 net value * const tcs = CalculateWithholding({ * Type: "Collected", * Section: "52", * Rate: 1, * BaseAmt: 500000, * PartyLiable: "Seller", * }); * // Returns: { Type: "Collected", Section: "52", Rate: 1, BaseAmt: 500000, Amt: 5000, PartyLiable: "Seller" } * * @example * // US Federal Withholding — 24% backup withholding * const wht = CalculateWithholding({ * Type: "Deducted", * Section: "FITW", * Rate: 24, * BaseAmt: 10000, * PartyLiable: "Buyer", * }); * // Returns: { Type: "Deducted", Section: "FITW", Rate: 24, BaseAmt: 10000, Amt: 2400, PartyLiable: "Buyer" } */ export function CalculateWithholding( input: IWithholdingCalcInput, rounding: IRoundingConfig = { Method: "Round", Precision: 2 } ): IWithholding { const baseAmt = GetNumber(input.BaseAmt); const rate = GetNumber(input.Rate); const amt = RoundAmount( Multiply(baseAmt, Divide(rate, 100)), rounding ); return { Type: input.Type, Section: input.Section, Rate: rate, BaseAmt: baseAmt, Amt: amt, PartyLiable: input.PartyLiable, }; } // ============================================================ // Migration Helpers — Flat Fields to Taxes[] // ============================================================ /** * Converts legacy flat CGST/SGST/IGST fields to a Taxes[] array. * * Used during migration (Phase 3) and during the dual-write transition * to generate Taxes[] from existing data. * * @param lineItem - Any line item object with flat CGST, SGST, IGST fields * @param taxCode - The TaxCode that was used (for rate and metadata lookup) * @returns Array of ITaxComponent * * @example * const taxes = ConvertFlatToTaxes( * { CGST: 450, SGST: 450, IGST: 0, TCode: 106, NetAmt: 5000 }, * gst18IntraTaxCode * ); */ export function ConvertFlatToTaxes( lineItem: { CGST?: number; SGST?: number; IGST?: number; TCode?: number; NetAmt?: number }, taxCode?: ITaxCode | null, ): ITaxComponent[] { const taxes: ITaxComponent[] = []; const netAmt = GetNumber(lineItem.NetAmt); const taxCodeId = GetNumber(lineItem.TCode); const cgst = GetNumber(lineItem.CGST); const sgst = GetNumber(lineItem.SGST); const igst = GetNumber(lineItem.IGST); if (cgst > 0) { const rate = taxCode?.Components?.find(c => c.Code === "CGST")?.Rate ?? (netAmt > 0 ? Multiply(Divide(cgst, netAmt), 100) : 0); taxes.push({ Code: "CGST", Rate: RoundAmount(rate, { Method: "Round", Precision: 4 }), Amt: cgst, TaxCodeId: taxCodeId, }); } if (sgst > 0) { const rate = taxCode?.Components?.find(c => c.Code === "SGST")?.Rate ?? (netAmt > 0 ? Multiply(Divide(sgst, netAmt), 100) : 0); taxes.push({ Code: "SGST", Rate: RoundAmount(rate, { Method: "Round", Precision: 4 }), Amt: sgst, TaxCodeId: taxCodeId, }); } if (igst > 0) { const rate = taxCode?.Components?.find(c => c.Code === "IGST")?.Rate ?? (netAmt > 0 ? Multiply(Divide(igst, netAmt), 100) : 0); taxes.push({ Code: "IGST", Rate: RoundAmount(rate, { Method: "Round", Precision: 4 }), Amt: igst, TaxCodeId: taxCodeId, }); } return taxes; }