import { Add, Divide, Multiply, Subtract } from '../shared/math-operations'; export interface ReverseCalculationInput { total: number; qty: number; discount: number; overallDiscount: number; totalTaxRate: number; isIndependentTax: boolean; } export interface TaxTotalOptions { includeTaxInTotal: boolean; } /** * TransactionCalculationEngine * * Reusable pure-calculation module for ERP-style transactions: * Invoice | Bill | Sales Order | Delivery Challan | Purchase Order * * All methods are static and side-effect-free. * Callers are responsible for writing results back to Reactive Form controls * using { emitEvent: false } to prevent infinite loops. * * Monetary values are not rounded by this engine. * * Item fields used: * qty, unpr, unAmt, disc, perc, recDisc, tax, total, NoDisc * * Ops fields used: * amount, recDisc * * Transaction-level discount fields: * PDisc / PPerc → items only * LDisc / LPerc → ops only * Disc / Prec → items + ops proportionally */ // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function toNum(value: any): number { const n = Number(value); return Number.isFinite(n) ? n : 0; } function isNullOrEmpty(value: any): boolean { return ( value === null || value === undefined || value === '' || value === 'null' ); } function getTaxesArray(value: any): any[] { const taxes = value?.Taxes ?? value?.taxes ?? value; return Array.isArray(taxes) ? taxes : []; } function sumTaxAmountFromTaxes(taxes: any): number { const rows = getTaxesArray(taxes); return rows.reduce( (sum, row) => Add(sum, toNum(row?.Amt ?? row?.amt)), 0, ); } function sumTaxRateFromTaxes(taxes: any): number { const rows = getTaxesArray(taxes); return rows.reduce( (sum, row) => Add(sum, toNum(row?.Rate ?? row?.rate)), 0, ); } /** * Distribute a flat discount amount across a set of rows proportionally * by each row's `baseKey` value. Returns an array of share amounts aligned * with the input rows. * * The last row absorbs any remainder so the sum always equals discAmt. */ function distributeProportionally( rows: any[], discAmt: number, baseKey: string, ): number[] { const total = rows.reduce((sum, row) => Add(sum, toNum(row[baseKey])), 0); if (total === 0) { return rows.map(() => 0); } const shares: number[] = []; let allocated = 0; rows.forEach((row, index) => { if (index === rows.length - 1) { // Last row: give the remainder to avoid floating-point drift shares.push(Subtract(discAmt, allocated)); } else { const share = Multiply(Divide(toNum(row[baseKey]), total), discAmt); shares.push(share); allocated = Add(allocated, share); } }); return shares; } // --------------------------------------------------------------------------- // Public engine // --------------------------------------------------------------------------- export class TransactionCalculationEngine { // ------------------------------------------------------------------------- // Taxes helpers (Amt-based + Rate-based) // ------------------------------------------------------------------------- /** * Summarizes a taxes array. * * Supported row shapes (case-tolerant): * - `{ Amt: number }` (preferred for totals) * - `{ Rate: number }` (preferred for reverse calc if Amt not present) */ static getTaxesSummary(taxes: any): { taxAmount: number; taxRate: number } { return { taxAmount: sumTaxAmountFromTaxes(taxes), taxRate: sumTaxRateFromTaxes(taxes), }; } static sumTaxAmountFromRows(rows: unknown): number { if (!Array.isArray(rows)) { return 0; } return rows.reduce((sum: number, row: any) => { return Add(sum, toNum(row?.Amt)); }, 0); } static calculateTotalFromTaxRows(input: { baseAmount: number; taxRows: unknown; options: TaxTotalOptions; }): number { const baseAmount = toNum(input.baseAmount); const taxAmount = TransactionCalculationEngine.sumTaxAmountFromRows( input.taxRows, ); return input.options.includeTaxInTotal ? Add(baseAmount, taxAmount) : baseAmount; } static calculateSubtotalFromTotalUsingTaxes(input: { total: number; taxRows: unknown; includeTaxInTotal: boolean; }): number { const total = toNum(input.total); if (!input.includeTaxInTotal) { return total; } const taxSummary = TransactionCalculationEngine.getTaxesSummary( input.taxRows, ); if (taxSummary.taxRate) { const divisor = Add(1, Divide(taxSummary.taxRate, 100)); return divisor === 0 ? total : Divide(total, divisor); } return Subtract(total, taxSummary.taxAmount); } static reverseCalculateAmountAndUnitPriceFromTotal(input: { qty: number; total: number; disc: number; recDisc: number; noDisc: boolean; taxRows: unknown; includeTaxInTotal: boolean; }): { amount: number; unitPrice: number } { const qty = toNum(input.qty) || 1; const subtotal = TransactionCalculationEngine.calculateSubtotalFromTotalUsingTaxes({ total: input.total, taxRows: input.taxRows, includeTaxInTotal: input.includeTaxInTotal, }); const disc = toNum(input.disc); const recDisc = toNum(input.recDisc); const amount = input.noDisc ? subtotal : Add(subtotal, Add(disc, recDisc)); const unitPrice = Divide(amount, qty); return { amount, unitPrice }; } // Shared reverse-calculation formula. Components only need to supply their own field mapping. static calculatePriceFromTotal(input: ReverseCalculationInput): number { const qty = toNum(input.qty) || 1; const total = toNum(input.total); const discount = toNum(input.discount); const overallDiscount = toNum(input.overallDiscount); const totalTaxRate = toNum(input.totalTaxRate); const taxableBase = input.isIndependentTax ? Divide(total, Add(1, Divide(totalTaxRate, 100))) : total; return Divide(Add(Add(taxableBase, discount), overallDiscount), qty); } /** * Returns the sum of all component rates for a tax code id. * * Safe for missing inputs: * - empty TaxCodes * - TCode not found * - empty Components */ static getGSTRateSumByTaxCode(TCode: any, TaxCodes: any[]): number { if (isNullOrEmpty(TCode) || !Array.isArray(TaxCodes)) { return 0; } const taxCode = TaxCodes.find((code: any) => code?._id === TCode); const components = Array.isArray(taxCode?.Components) ? taxCode.Components : []; return components.reduce((sum:number, comp: any) => Add(sum, toNum(comp?.Rate)), 0); } // ------------------------------------------------------------------------- // 1. calculateItemAmount // ------------------------------------------------------------------------- /** * Calculates the raw base amount for an item: * unAmt = qty * unpr * * Returns a shallow copy of the item with `unAmt` set. */ static calculateItemAmount(item: any): any { const qty = toNum(item.qty); const unpr = toNum(item.unpr); return { ...item, unAmt: Multiply(qty, unpr) }; } // ------------------------------------------------------------------------- // 2. calculateItemDiscount // ------------------------------------------------------------------------- /** * Calculates the item-level (record-level) discount applied to a single item. * * Rules: * - If `NoDisc` is true → recDisc = 0 * - Else if `perc` exists → recDisc = qty * unpr * (perc / 100) * - Else → recDisc = disc (flat amount) * * Returns a shallow copy of the item with `recDisc` set. */ static calculateItemDiscount(item: any): any { if (item.NoDisc) { return { ...item, recDisc: 0 }; } const qty = toNum(item.qty); const unpr = toNum(item.unpr); const perc = toNum(item.perc); const disc = toNum(item.disc); const recDisc = !isNullOrEmpty(item.perc) && perc !== 0 ? Multiply(Multiply(qty, unpr), Divide(perc, 100)) : disc; return { ...item, recDisc }; } // ------------------------------------------------------------------------- // 3. calculateItemForwardTotal // ------------------------------------------------------------------------- /** * Calculates subtotal, tax amount, and total for an item using forward logic * (price/qty → total). * * subtotal = (qty * unpr) − recDisc * taxAmount = subtotal * (tax / 100) * total = subtotal + taxAmount * * Returns a shallow copy of the item with `subtotal`, `taxAmount`, and * `total` set. */ static calculateItemForwardTotal(item: any): any { const qty = toNum(item.qty); const unpr = toNum(item.unpr); const recDisc = toNum(item.recDisc); const tax = toNum(item.tax); const subtotal = Subtract(Multiply(qty, unpr), recDisc); const taxAmount = Divide(Multiply(subtotal, tax), 100); const total = Add(subtotal, taxAmount); return { ...item, subtotal, taxAmount, total }; } // ------------------------------------------------------------------------- // 3b. calculateItemForwardTotalUsingTaxes // ------------------------------------------------------------------------- /** * Calculates subtotal, taxAmount, and total for an item using a `Taxes` array. * * Rules: * - subtotal = (qty * unpr) − recDisc * - taxAmount = Σ Taxes[].Amt (if missing/0, falls back to Σ Taxes[].Rate %) * - total = subtotal + taxAmount (when includeTaxInTotal = true) * * Notes: * - If you want a "pre-tax total" line, pass includeTaxInTotal = false. */ static calculateItemForwardTotalUsingTaxes( item: any, options?: { includeTaxInTotal?: boolean }, ): any { const includeTaxInTotal = options?.includeTaxInTotal !== false; const qty = toNum(item.qty); const unpr = toNum(item.unpr); const recDisc = toNum(item.recDisc); const subtotal = Subtract(Multiply(qty, unpr), recDisc); const taxes = item?.Taxes ?? item?.taxes; const taxSummary = TransactionCalculationEngine.getTaxesSummary(taxes); const taxAmount = taxSummary.taxAmount !== 0 ? taxSummary.taxAmount : Divide(Multiply(subtotal, taxSummary.taxRate), 100); const total = Add(subtotal, includeTaxInTotal ? taxAmount : 0); return { ...item, subtotal, taxAmount, total }; } // ------------------------------------------------------------------------- // 4. calculateReverseFromTotal // ------------------------------------------------------------------------- /** * Reverse-calculates `unpr` from a manually entered `total`. * * Steps: * subtotal = total / (1 + tax / 100) [strips tax] * baseAmt = subtotal + recDisc [adds back discount] * unpr = baseAmt / qty [per-unit price] * * Guards: * - If `qty` is 0 the calculation is skipped and the item is returned unchanged. * - If `tax` is 0 the divisor reduces to 1 (safe). * * Returns a shallow copy of the item with `unpr` (and derived `unAmt`) updated. */ static calculateReverseFromTotal(item: any): any { const qty = toNum(item.qty); const total = toNum(item.total); const recDisc = toNum(item.recDisc); const tax = toNum(item.tax); if (qty === 0) { // Cannot reverse without a quantity – return item unchanged return { ...item }; } const divisor = Add(1, Divide(tax, 100)); const subtotal = Divide(total, divisor === 0 ? 1 : divisor); const baseAmt = Add(subtotal, recDisc); const unpr = Divide(baseAmt, qty); const unAmt = Multiply(qty, unpr); return { ...item, unpr, unAmt }; } // ------------------------------------------------------------------------- // 4b. calculateReverseFromTotalUsingDiscPerc // ------------------------------------------------------------------------- /** * Reverse-calculates `unpr` from a manually entered `total`, supporting the * same `disc`/`perc` semantics as `calculateItemDiscount`. * * This is intentionally tolerant of existing calling conventions: * - `item.extraDisc` can be provided to represent an additional discount * amount that should be added on top of the row discount (e.g. overall / * record-level discount allocation). It is ignored when `NoDisc` is true. * - When `tax` is 0, tax stripping becomes a no-op. * * Returns a shallow copy of the item with `unpr`, `unAmt`, and `recDisc` set. */ static calculateReverseFromTotalUsingDiscPerc(item: any): any { const qty = toNum(item.qty); const total = toNum(item.total); const tax = toNum(item.tax); const disc = toNum(item.disc); const perc = toNum(item.perc); const extraDisc = toNum(item.extraDisc); if (qty === 0) { return { ...item }; } const divisor = Add(1, Divide(tax, 100)); const subtotal = Divide(total, divisor === 0 ? 1 : divisor); if (item.NoDisc) { const unpr = Divide(subtotal, qty); const unAmt = Multiply(qty, unpr); return { ...item, unpr, unAmt, recDisc: 0 }; } // Percentage path: solve for unpr algebraically because recDisc depends on unpr if (!isNullOrEmpty(item.perc) && perc !== 0) { const discountRate = Divide(perc, 100); const priceFactor = Subtract(1, discountRate); if (priceFactor === 0) { return { ...item }; } const unpr = Divide(Add(subtotal, extraDisc), Multiply(qty, priceFactor)); const unAmt = Multiply(qty, unpr); const rowDisc = Multiply(unAmt, discountRate); const recDisc = Add(rowDisc, extraDisc); return { ...item, unpr, unAmt, recDisc }; } // Flat amount path const recDisc = Add(disc, extraDisc); const unpr = Divide(Add(subtotal, recDisc), qty); const unAmt = Multiply(qty, unpr); return { ...item, unpr, unAmt, recDisc }; } // ------------------------------------------------------------------------- // 4c. calculateReverseFromTotalUsingDiscPercAndTaxes // ------------------------------------------------------------------------- /** * Reverse-calculates `unpr` from a manually entered `total` using a `Taxes` array. * * When taxes contain `Amt` rows, the reverse "strips tax" by subtracting the * summed tax amount. If tax `Amt` is 0/missing, it falls back to stripping by * summed tax `Rate` (same as `calculateReverseFromTotalUsingDiscPerc`). * * Inputs: * - qty, total, disc, perc, extraDisc, NoDisc * - Taxes: array of `{ Amt?, Rate? }` */ static calculateReverseFromTotalUsingDiscPercAndTaxes( item: any, options?: { includeTaxInTotal?: boolean }, ): any { const includeTaxInTotal = options?.includeTaxInTotal !== false; const qty = toNum(item.qty); const total = toNum(item.total); const disc = toNum(item.disc); const perc = toNum(item.perc); const extraDisc = toNum(item.extraDisc); if (qty === 0) { return { ...item }; } const taxSummary = TransactionCalculationEngine.getTaxesSummary( item?.Taxes ?? item?.taxes, ); // If total doesn't include tax, treat it as "already stripped". const subtotal = includeTaxInTotal ? taxSummary.taxAmount !== 0 ? Subtract(total, taxSummary.taxAmount) : (() => { const divisor = Add(1, Divide(taxSummary.taxRate, 100)); return Divide(total, divisor === 0 ? 1 : divisor); })() : total; if (item.NoDisc) { const unpr = Divide(subtotal, qty); const unAmt = Multiply(qty, unpr); return { ...item, unpr, unAmt, recDisc: 0 }; } // Percentage path: solve for unpr algebraically because row discount depends on unpr. if (!isNullOrEmpty(item.perc) && perc !== 0) { const discountRate = Divide(perc, 100); const priceFactor = Subtract(1, discountRate); if (priceFactor === 0) { return { ...item }; } const unpr = Divide(Add(subtotal, extraDisc), Multiply(qty, priceFactor)); const unAmt = Multiply(qty, unpr); const rowDisc = Multiply(unAmt, discountRate); const recDisc = Add(rowDisc, extraDisc); return { ...item, unpr, unAmt, recDisc }; } const recDisc = Add(disc, extraDisc); const unpr = Divide(Add(subtotal, recDisc), qty); const unAmt = Multiply(qty, unpr); return { ...item, unpr, unAmt, recDisc }; } // ------------------------------------------------------------------------- // 5. applyPDiscountToItems // ------------------------------------------------------------------------- /** * Applies an item-only discount (`PDisc` / `PPerc`) to an array of items. * * Rules: * - Items with `NoDisc = true` are skipped. * - If `PPerc` is provided: each item's share = its netAmt * (PPerc / 100) * - Otherwise: `PDisc` is distributed proportionally by each item's `unAmt`. * * The `recDisc` on each item is **added to** any existing item-level discount * so that both record-level and overall PDisc coexist correctly. * * Returns a new array of updated item objects. */ static applyPDiscountToItems(items: any[], PDisc: any, PPerc: any): any[] { if (!Array.isArray(items) || items.length === 0) { return items ?? []; } const discountable = items.filter((i) => !i.NoDisc); // Percentage path if (!isNullOrEmpty(PPerc) && toNum(PPerc) !== 0) { const pperc = toNum(PPerc); return items.map((item) => { if (item.NoDisc) return { ...item }; const netAmt = Subtract(toNum(item.unAmt), toNum(item.recDisc)); const share = Divide(Multiply(netAmt, pperc), 100); return { ...item, recDisc: Add(toNum(item.recDisc), share) }; }); } // Flat-amount proportional path const pdisc = toNum(PDisc); if (pdisc === 0 || discountable.length === 0) { return items.map((i) => ({ ...i })); } const shares = distributeProportionally(discountable, pdisc, 'unAmt'); let shareIdx = 0; return items.map((item) => { if (item.NoDisc) return { ...item }; const share = shares[shareIdx++] ?? 0; return { ...item, recDisc: Add(toNum(item.recDisc), share) }; }); } // ------------------------------------------------------------------------- // 6. applyLDiscountToOps // ------------------------------------------------------------------------- /** * Applies an ops-only discount (`LDisc` / `LPerc`) to an array of ops. * * Rules: * - If `LPerc` is provided: each op's share = its `amount` * (LPerc / 100) * - Otherwise: `LDisc` is distributed proportionally by each op's `amount`. * * Returns a new array of updated ops objects. */ static applyLDiscountToOps(ops: any[], LDisc: any, LPerc: any): any[] { if (!Array.isArray(ops) || ops.length === 0) { return ops ?? []; } // Percentage path if (!isNullOrEmpty(LPerc) && toNum(LPerc) !== 0) { const lperc = toNum(LPerc); return ops.map((op) => { const share = Divide(Multiply(toNum(op.amount), lperc), 100); return { ...op, recDisc: Add(toNum(op.recDisc), share) }; }); } // Flat-amount proportional path const ldisc = toNum(LDisc); if (ldisc === 0) { return ops.map((o) => ({ ...o })); } const shares = distributeProportionally(ops, ldisc, 'amount'); return ops.map((op, i) => ({ ...op, recDisc: Add(toNum(op.recDisc), shares[i] ?? 0), })); } // ------------------------------------------------------------------------- // 7. applyCommonDiscount // ------------------------------------------------------------------------- /** * Applies a cross-line discount (`Disc` / `Prec`) proportionally across * both items and ops. * * Rules: * - Items with `NoDisc = true` are excluded from the base but returned unchanged. * - If `Prec` is provided: share = row's base * (Prec / 100) * - Otherwise: `Disc` is distributed proportionally across the combined base * (items by `unAmt`, ops by `amount`). * * Returns `{ items, ops }` with updated `recDisc` values. */ static applyCommonDiscount( items: any[], ops: any[], Disc: any, Prec: any, ): { items: any[]; ops: any[] } { const safeItems = Array.isArray(items) ? items : []; const safeOps = Array.isArray(ops) ? ops : []; const discountableItems = safeItems.filter((i) => !i.NoDisc); // Percentage path if (!isNullOrEmpty(Prec) && toNum(Prec) !== 0) { const prec = toNum(Prec); const updatedItems = safeItems.map((item) => { if (item.NoDisc) return { ...item }; // Apply common % discount on the net base after any prior discounts // (item-level discount + PDisc), so discounts stack sequentially. const base = Math.max( 0, Subtract(toNum(item.unAmt), toNum(item.recDisc)), ); const share = Divide(Multiply(base, prec), 100); console.log('Disc share', { id: item.id, base, share }); return { ...item, recDisc: Add(toNum(item.recDisc), share) }; }); const updatedOps = safeOps.map((op) => { // Apply common % discount on the net base after LDisc. const base = Math.max( 0, Subtract(toNum(op.amount), toNum(op.recDisc)), ); const share = Divide(Multiply(base, prec), 100); return { ...op, recDisc: Add(toNum(op.recDisc), share) }; }); return { items: updatedItems, ops: updatedOps }; } // Flat-amount proportional path const disc = toNum(Disc); if (disc === 0) { return { items: safeItems.map((i) => ({ ...i })), ops: safeOps.map((o) => ({ ...o })), }; } // Build a unified list for proportional distribution (no-disc items excluded) const rows: any[] = [ ...discountableItems.map((i) => ({ _key: 'item', _ref: i, base: toNum(i.unAmt), })), ...safeOps.map((o) => ({ _key: 'op', _ref: o, base: toNum(o.amount) })), ]; const totalBase = rows.reduce((sum, r) => Add(sum, r.base), 0); if (totalBase === 0) { return { items: safeItems.map((i) => ({ ...i })), ops: safeOps.map((o) => ({ ...o })), }; } // Assign shares let allocated = 0; rows.forEach((row, index) => { let share: number; if (index === rows.length - 1) { share = Subtract(disc, allocated); } else { share = Multiply(Divide(row.base, totalBase), disc); allocated = Add(allocated, share); } row._share = share; }); // Build result maps const itemShareMap = new Map(); const opShareMap = new Map(); rows.forEach((row) => { if (row._key === 'item') itemShareMap.set(row._ref, row._share); else opShareMap.set(row._ref, row._share); }); const updatedItems = safeItems.map((item) => { if (item.NoDisc) return { ...item }; const share = itemShareMap.get(item) ?? 0; return { ...item, recDisc: Add(toNum(item.recDisc), share) }; }); const updatedOps = safeOps.map((op) => { const share = opShareMap.get(op) ?? 0; return { ...op, recDisc: Add(toNum(op.recDisc), share) }; }); return { items: updatedItems, ops: updatedOps }; } // ------------------------------------------------------------------------- // 8. distributeRecordDiscount // ------------------------------------------------------------------------- /** * Orchestration method that applies all transaction-level discounts in the * correct order: * * 1. applyPDiscountToItems (PDisc / PPerc) * 2. applyLDiscountToOps (LDisc / LPerc) * 3. applyCommonDiscount (Disc / Prec) * * The `recDisc` on each line starts fresh from its item-level discount * (already computed by `calculateItemDiscount`) before overall discounts * are stacked on top. * * Returns `{ items, ops }`. */ static distributeRecordDiscount( items: any[], ops: any[], transaction: any, ): { items: any[]; ops: any[] } { const { PDisc, PPerc, LDisc, LPerc, Disc, Prec } = transaction ?? {}; let updatedItems: any[] = Array.isArray(items) ? items.map((i) => ({ ...i })) : []; let updatedOps: any[] = Array.isArray(ops) ? ops.map((o) => ({ ...o })) : []; // Step 1 – items-only discount updatedItems = TransactionCalculationEngine.applyPDiscountToItems( updatedItems, PDisc, PPerc, ); // Step 2 – ops-only discount updatedOps = TransactionCalculationEngine.applyLDiscountToOps( updatedOps, LDisc, LPerc, ); // Step 3 – common discount across both const result = TransactionCalculationEngine.applyCommonDiscount( updatedItems, updatedOps, Disc, Prec, ); return { items: result.items, ops: result.ops }; } // ------------------------------------------------------------------------- // 9. recalculateTransaction // ------------------------------------------------------------------------- /** * Master recalculation method. * * Call whenever any value in the transaction changes: * qty | unpr | disc | perc | tax | PDisc | PPerc | LDisc | LPerc | Disc | Prec * or when items / ops are added or removed. * * Processing pipeline: * For each item: * a. calculateItemAmount → unAmt * b. calculateItemDiscount → item-level recDisc * → distributeRecordDiscount → stacks overall discounts onto recDisc * For each item: * c. calculateItemForwardTotal → subtotal, taxAmount, total * For each op: * d. recalculate op total → amount − recDisc * → computeTransactionTotals → subTotal, taxTotal, grandTotal * * Returns a new transaction object (shallow-copied) with updated * `Items`, `Ops`, and summary totals (`SubTotal`, `TaxTotal`, `Total`). * * The original transaction object is never mutated. * * ⚠️ When writing results back to Reactive Form controls always use * `{ emitEvent: false }` to prevent triggering another recalculation. */ static recalculateTransaction(transaction: any): any { if (!transaction) return transaction; const rawItems: any[] = Array.isArray(transaction.Items) ? transaction.Items : []; const rawOps: any[] = Array.isArray(transaction.Ops) ? transaction.Ops : []; // ── Step A & B: item-level amounts and item-level discounts ────────────── let items = rawItems .map((item) => TransactionCalculationEngine.calculateItemAmount(item)) .map((item) => TransactionCalculationEngine.calculateItemDiscount(item)); // Ops: ensure `amount` exists before distribution (carry existing value) let ops = rawOps.map((op) => ({ ...op })); // ── Step C: distribute transaction-level discounts ──────────────────────── const distributed = TransactionCalculationEngine.distributeRecordDiscount( items, ops, transaction, ); items = distributed.items; ops = distributed.ops; // ── Step D: forward totals per item ────────────────────────────────────── items = items.map((item) => TransactionCalculationEngine.calculateItemForwardTotal(item), ); // ── Step E: op totals (amount already known; subtract recDisc) ─────────── ops = ops.map((op) => ({ ...op, total: Subtract(toNum(op.amount), toNum(op.recDisc)), })); // ── Step F: transaction-level summary ──────────────────────────────────── const totals = TransactionCalculationEngine.computeTransactionTotals( items, ops, ); return { ...transaction, Items: items, Ops: ops, SubTotal: totals.subTotal, TaxTotal: totals.taxTotal, Total: totals.grandTotal, }; } // ------------------------------------------------------------------------- // computeTransactionTotals (utility – exposed for standalone use) // ------------------------------------------------------------------------- /** * Computes summary totals from already-calculated items and ops. * * subTotal = Σ item.subtotal + Σ op.total * taxTotal = Σ item.taxAmount * grandTotal = subTotal + taxTotal * * Returns `{ subTotal, taxTotal, grandTotal }`. */ static computeTransactionTotals( items: any[], ops: any[], ): { subTotal: number; taxTotal: number; grandTotal: number } { const safeItems = Array.isArray(items) ? items : []; const safeOps = Array.isArray(ops) ? ops : []; const itemSubTotal = safeItems.reduce( (sum, i) => Add(sum, toNum(i.subtotal)), 0, ); const taxTotal = safeItems.reduce( (sum, i) => Add(sum, toNum(i.taxAmount)), 0, ); const opsTotal = safeOps.reduce((sum, o) => Add(sum, toNum(o.total)), 0); const subTotal = Add(itemSubTotal, opsTotal); const grandTotal = Add(subTotal, taxTotal); return { subTotal, taxTotal, grandTotal, }; } }