@mudraforge/statutory 2.10.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTRIBUTING.md +3 -2
  3. package/README.md +42 -39
  4. package/SECURITY.md +2 -2
  5. package/SKILL.md +1 -1
  6. package/bin/cli.js +288 -29
  7. package/docs/ACCOUNTING_ASSURANCE_AUDIT_DOSSIER.md +276 -0
  8. package/docs/ARCHITECTURE.md +14 -14
  9. package/docs/ARCHITECTURE_DECISIONS.md +3 -3
  10. package/docs/CASE_01_FORM_TEXT.txt +48 -0
  11. package/docs/CATALOG.md +877 -111
  12. package/docs/CA_PILOT_BENCHMARK_CASES.md +282 -0
  13. package/docs/CORPUS_QUALITY.md +33 -16
  14. package/docs/DATA_LICENSING.md +1 -1
  15. package/docs/DOMAIN_BREADTH_AND_DEPTH_COMPARISON.md +36 -34
  16. package/docs/OKF_MAINTENANCE_SOP.md +12 -12
  17. package/docs/PROVENANCE.md +2 -2
  18. package/docs/VERIFICATION.md +6 -6
  19. package/okf/corporate_ma_restructuring/business_combinations_indas_103/circulars/mca_notification_indas_103_business_combinations.md +41 -0
  20. package/okf/corporate_ma_restructuring/business_combinations_indas_103/forms/purchase_price_allocation_schedule.json +110 -0
  21. package/okf/corporate_ma_restructuring/business_combinations_indas_103/section.json +65 -0
  22. package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/circulars/mca_notification_indas_36_impairment.md +45 -0
  23. package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/forms/cgu_impairment_testing_register.json +53 -0
  24. package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/section.json +61 -0
  25. package/okf/corporate_ma_restructuring/share_based_payments_indas_102/circulars/icai_guidance_share_based_payments.md +37 -0
  26. package/okf/corporate_ma_restructuring/share_based_payments_indas_102/forms/esop_graded_vesting_amortization_schedule.json +49 -0
  27. package/okf/corporate_ma_restructuring/share_based_payments_indas_102/section.json +60 -0
  28. package/okf/corporate_ma_restructuring/tax_clearance_sec281/circulars/cbdt_circular_04_2011_sec281_clearance.md +57 -0
  29. package/okf/corporate_ma_restructuring/tax_clearance_sec281/forms/form_281_tax_clearance_application.json +42 -0
  30. package/okf/corporate_ma_restructuring/tax_clearance_sec281/section.json +70 -0
  31. package/okf/direct_tax/income_taxes_deferred_tax_indas_12/circulars/mca_notification_indas_12_income_taxes.md +40 -0
  32. package/okf/direct_tax/income_taxes_deferred_tax_indas_12/forms/deferred_tax_asset_liability_reconciliation_schedule.json +46 -0
  33. package/okf/direct_tax/income_taxes_deferred_tax_indas_12/section.json +61 -0
  34. package/okf/direct_tax/statutory_disallowance_43b/circulars/supreme_court_checkmate_services_2022.md +64 -0
  35. package/okf/direct_tax/statutory_disallowance_43b/forms/tax_audit_form_3cd_clause_26_schedule.json +71 -0
  36. package/okf/direct_tax/statutory_disallowance_43b/section.json +77 -0
  37. package/okf/direct_tax/tds_commission_194h/section.json +5 -4
  38. package/okf/direct_tax/tds_contractor_194c/section.json +3 -3
  39. package/okf/direct_tax/tds_ecommerce_194o/circulars/cbdt_circular_20_2020_guidelines_194o.md +36 -0
  40. package/okf/direct_tax/tds_ecommerce_194o/forms/form_26q_ecommerce_schedule.json +75 -0
  41. package/okf/direct_tax/tds_ecommerce_194o/section.json +65 -0
  42. package/okf/direct_tax/tds_goods_194q/circulars/cbdt_circular_13_2021_guidelines.md +2 -2
  43. package/okf/direct_tax/tds_goods_194q/section.json +9 -3
  44. package/okf/direct_tax/tds_goods_393/section.json +6 -4
  45. package/okf/direct_tax/tds_individual_contract_194m/circulars/finance_act_2024_194m_rate_cut_memo.md +24 -0
  46. package/okf/direct_tax/tds_individual_contract_194m/forms/form_26qd_contractor_statement.json +64 -0
  47. package/okf/direct_tax/tds_individual_contract_194m/section.json +58 -0
  48. package/okf/direct_tax/tds_interest_194a/circulars/cbdt_circular_03_2010_interest_tds.md +28 -0
  49. package/okf/direct_tax/tds_interest_194a/forms/form_15g_15h_interest_declaration.json +55 -0
  50. package/okf/direct_tax/tds_interest_194a/forms/form_26q_interest_schedule.json +72 -0
  51. package/okf/direct_tax/tds_interest_194a/section.json +67 -0
  52. package/okf/direct_tax/tds_perquisites_194r/forms/form_26q_perquisites_schedule.json +83 -0
  53. package/okf/direct_tax/tds_perquisites_194r/section.json +2 -2
  54. package/okf/direct_tax/tds_property_194ia/circulars/cbdt_notification_67_2022_property_tds.md +57 -0
  55. package/okf/direct_tax/tds_property_194ia/forms/form_26qb_property_tds_challan.json +43 -0
  56. package/okf/direct_tax/tds_property_194ia/section.json +73 -0
  57. package/okf/direct_tax/tds_rent_194i/section.json +5 -4
  58. package/okf/direct_tax/tds_rent_individual_194ib/circulars/finance_act_2024_rent_rate_cut_memo.md +24 -0
  59. package/okf/direct_tax/tds_rent_individual_194ib/forms/form_26qc_challan_cum_statement.json +74 -0
  60. package/okf/direct_tax/tds_rent_individual_194ib/section.json +57 -0
  61. package/okf/direct_tax/tds_services_194j/circulars/cbdt_circular_03_2020_fts_rate_cut.md +1 -1
  62. package/okf/direct_tax/tds_services_194j/section.json +10 -3
  63. package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/circulars/cbic_circular_170_2022_itc_reporting.md +30 -0
  64. package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/circulars/cbic_notification_18_2022_cutoff_amendment.md +26 -0
  65. package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/forms/gstr_3b_table_4b1_reversal_schedule.json +101 -0
  66. package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/forms/rule_42_43_annual_reconciliation_worksheet.json +87 -0
  67. package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/section.json +65 -0
  68. package/okf/indirect_tax_gst/itc_reversal_rule37_37a/circulars/cbic_circular_192_2023_ecl_interest_calculation.md +36 -0
  69. package/okf/indirect_tax_gst/itc_reversal_rule37_37a/circulars/cbic_notification_19_2022_rule37.md +31 -0
  70. package/okf/indirect_tax_gst/itc_reversal_rule37_37a/forms/gstr_3b_table_4b_reversal.json +81 -0
  71. package/okf/indirect_tax_gst/itc_reversal_rule37_37a/section.json +71 -0
  72. package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_notification_07_2025_composition_rent_exclusion.md +41 -0
  73. package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_notification_09_2024_renting_rcm.md +37 -0
  74. package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/gst_council_55th_meeting_renting_rcm_regularization.md +31 -0
  75. package/okf/indirect_tax_gst/rcm_liability_sec9_3/section.json +24 -2
  76. package/okf/international_tax/financial_instruments_ecl_indas_109/circulars/mca_notification_indas_109_financial_instruments.md +43 -0
  77. package/okf/international_tax/financial_instruments_ecl_indas_109/forms/indas_109_ecl_provision_matrix_schedule.json +43 -0
  78. package/okf/international_tax/financial_instruments_ecl_indas_109/section.json +62 -0
  79. package/okf/manifest.json +108 -15
  80. package/okf/msme_commercial/lease_accounting_indas_116/circulars/mca_notification_indas_116_leases.md +48 -0
  81. package/okf/msme_commercial/lease_accounting_indas_116/forms/lease_rou_asset_and_liability_schedule.json +55 -0
  82. package/okf/msme_commercial/lease_accounting_indas_116/section.json +67 -0
  83. package/okf/msme_commercial/provisions_contingencies_indas_37/circulars/mca_notification_indas_37_provisions.md +36 -0
  84. package/okf/msme_commercial/provisions_contingencies_indas_37/forms/provisions_and_contingent_liabilities_register.json +38 -0
  85. package/okf/msme_commercial/provisions_contingencies_indas_37/section.json +61 -0
  86. package/okf/msme_commercial/revenue_recognition_indas_115/circulars/mca_notification_indas_115_revenue_contracts.md +56 -0
  87. package/okf/msme_commercial/revenue_recognition_indas_115/forms/revenue_contract_5step_allocation_schedule.json +54 -0
  88. package/okf/msme_commercial/revenue_recognition_indas_115/section.json +69 -0
  89. package/package.json +7 -1
  90. package/src/calculators.js +7898 -638
  91. package/src/constants.js +1 -1
  92. package/src/index.js +604 -23
  93. package/src/tally.js +812 -0
  94. package/src/zoho_gstr2b.js +2964 -0
  95. package/types/index.d.ts +516 -0
package/src/tally.js ADDED
@@ -0,0 +1,812 @@
1
+ /**
2
+ * @mudraforge/statutory - Tally & Daybook Statutory Reconciliation Engine
3
+ *
4
+ * Provides zero-dependency parsing of Tally XML and CSV daybooks,
5
+ * multi-branch vendor PAN aggregation, cumulative Section 194Q threshold
6
+ * evaluation, Section 40(a)(ia) 30% expenditure disallowance risk quantification,
7
+ * and automated Tally XML compensating journal voucher generation.
8
+ *
9
+ * Complies with:
10
+ * - Section 194Q of Income-tax Act, 1961 (and Section 393 of Income-tax Act, 2025)
11
+ * - Section 40(a)(ia) 30% expenditure disallowance for un-deducted/under-deducted TDS
12
+ * - Section 201(1A) delayed deduction interest
13
+ * - CBDT Circular No. 13 of 2021 (advance payment GST inclusion & excess-over-50L math)
14
+ *
15
+ * Zero external npm dependencies: Native ES module and pure string/math parsing only.
16
+ */
17
+
18
+ import { evaluateSection194Q, roundToPaisa, cleanNumericAmount } from './calculators.js';
19
+ import { decodeTaxIdentifier } from './index.js';
20
+
21
+ const STATUTORY_THRESHOLD_INR = 5000000; // ₹50 Lakhs (Section 194Q(1))
22
+
23
+ /**
24
+ * Parses a standard CSV string representing a Tally or ERP purchase daybook.
25
+ * Tolerates variations in header casing and common column aliases.
26
+ * Resilient against UTF-8 BOM, diverse delimiters (comma, semicolon, tab),
27
+ * and accounting formatted currency strings.
28
+ *
29
+ * Supported columns (case-insensitive):
30
+ * - Date: date, vch_date, voucher_date, txn_date
31
+ * - Voucher No: voucher_no, vch_no, invoice_no, ref_no, voucher_number
32
+ * - Voucher Type: voucher_type, vch_type, type (default: 'Purchase')
33
+ * - Party Name: party_name, vendor_name, ledger_name, supplier_name
34
+ * - PAN: pan, vendor_pan, party_pan
35
+ * - GSTIN: gstin, vendor_gstin, party_gstin
36
+ * - Amount: amount, gross_amount, invoice_amount, debit, credit
37
+ * - GST Amount: gst_amount, tax_amount, igst, cgst, sgst (optional)
38
+ * - TDS Deducted: tds_deducted, tds_amount, tds (optional, default 0)
39
+ *
40
+ * @param {string} csvText - Raw CSV text
41
+ * @returns {Array<Object>} Normalized voucher objects
42
+ */
43
+ export function parseTallyDaybookCsv(csvText) {
44
+ if (!csvText || typeof csvText !== 'string') {
45
+ throw new Error('[@mudraforge/statutory] Daybook CSV content must be a non-empty string.');
46
+ }
47
+
48
+ // Strip UTF-8 Byte Order Mark (BOM) inserted by Windows/Excel
49
+ const cleanCsv = csvText.replace(/^\uFEFF/, '');
50
+ const lines = cleanCsv.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
51
+ if (lines.length < 2) {
52
+ return [];
53
+ }
54
+
55
+ // Auto-detect delimiter: comma vs semicolon vs tab
56
+ const firstLine = lines[0];
57
+ const delimiter = !firstLine.includes(',') && firstLine.includes(';') ? ';' : (!firstLine.includes(',') && firstLine.includes('\t') ? '\t' : ',');
58
+
59
+ // Parse header row
60
+ const rawHeaders = parseCsvRow(lines[0], delimiter);
61
+ const headers = rawHeaders.map(h => h.toLowerCase().replace(/[^a-z0-9_]/g, '_'));
62
+
63
+ // Map column indices
64
+ const colIndex = {
65
+ date: findColIndex(headers, ['date', 'vch_date', 'voucher_date', 'txn_date']),
66
+ voucherNo: findColIndex(headers, ['voucher_no', 'vch_no', 'invoice_no', 'ref_no', 'voucher_number']),
67
+ voucherType: findColIndex(headers, ['voucher_type', 'vch_type', 'type']),
68
+ partyName: findColIndex(headers, ['party_name', 'vendor_name', 'ledger_name', 'supplier_name', 'particulars', 'account', 'party']),
69
+ pan: findColIndex(headers, ['pan', 'vendor_pan', 'party_pan']),
70
+ gstin: findColIndex(headers, ['gstin', 'vendor_gstin', 'party_gstin']),
71
+ amount: findColIndex(headers, ['amount', 'gross_amount', 'invoice_amount', 'purchase_amount', 'value']),
72
+ debit: findColIndex(headers, ['debit', 'dr', 'debit_amount']),
73
+ credit: findColIndex(headers, ['credit', 'cr', 'credit_amount']),
74
+ gstAmount: findColIndex(headers, ['gst_amount', 'tax_amount', 'gst', 'tax']),
75
+ tdsDeducted: findColIndex(headers, ['tds_deducted', 'tds_amount', 'tds', 'tds_cut'])
76
+ };
77
+
78
+ if (colIndex.amount === -1 && colIndex.debit === -1 && colIndex.credit === -1) {
79
+ throw new Error('[@mudraforge/statutory] CSV header must include an "Amount", "Gross_Amount", or "Debit"/"Credit" column.');
80
+ }
81
+
82
+ const vouchers = [];
83
+
84
+ for (let i = 1; i < lines.length; i++) {
85
+ const row = parseCsvRow(lines[i], delimiter);
86
+ if (row.length === 0 || row.every(cell => !cell || !cell.trim())) continue;
87
+
88
+ const rawDate = colIndex.date !== -1 ? row[colIndex.date] : null;
89
+ const normalizedDate = normalizeIsoDate(rawDate);
90
+
91
+ let amount = 0;
92
+ if (colIndex.debit !== -1 || colIndex.credit !== -1) {
93
+ const rawDebit = colIndex.debit !== -1 ? row[colIndex.debit] : '0';
94
+ const rawCredit = colIndex.credit !== -1 ? row[colIndex.credit] : '0';
95
+ const debitVal = cleanNumericAmount(rawDebit);
96
+ const creditVal = cleanNumericAmount(rawCredit);
97
+ if (debitVal !== 0) {
98
+ amount = debitVal;
99
+ } else if (creditVal !== 0) {
100
+ // In purchase register / daybook context, Credit column indicates purchase return / debit note
101
+ amount = -Math.abs(creditVal);
102
+ } else if (colIndex.amount !== -1) {
103
+ amount = cleanNumericAmount(row[colIndex.amount], { interpretCreditAsNegative: true });
104
+ }
105
+ } else if (colIndex.amount !== -1) {
106
+ const rawAmount = row[colIndex.amount];
107
+ // In daybook purchase vouchers, interpret CR suffix as negative return
108
+ amount = cleanNumericAmount(rawAmount, { interpretCreditAsNegative: true });
109
+ }
110
+
111
+ const rawGst = colIndex.gstAmount !== -1 ? row[colIndex.gstAmount] : '0';
112
+ const gstAmount = Math.abs(cleanNumericAmount(rawGst));
113
+
114
+ const rawTds = colIndex.tdsDeducted !== -1 ? row[colIndex.tdsDeducted] : '0';
115
+ const tdsDeducted = Math.abs(cleanNumericAmount(rawTds));
116
+
117
+ const partyName = colIndex.partyName !== -1 ? (row[colIndex.partyName] || 'Unknown Vendor').trim() : 'Unknown Vendor';
118
+ const rawPan = colIndex.pan !== -1 ? (row[colIndex.pan] || '').trim().toUpperCase() : '';
119
+ const rawGstin = colIndex.gstin !== -1 ? (row[colIndex.gstin] || '').trim().toUpperCase() : '';
120
+ const voucherNo = colIndex.voucherNo !== -1 ? (row[colIndex.voucherNo] || `ROW-${i}`).trim() : `ROW-${i}`;
121
+ const voucherType = colIndex.voucherType !== -1 ? (row[colIndex.voucherType] || 'Purchase').trim() : 'Purchase';
122
+
123
+ vouchers.push({
124
+ rowNumber: i + 1,
125
+ date: normalizedDate,
126
+ voucherNo,
127
+ voucherType,
128
+ partyName,
129
+ partyPan: rawPan || null,
130
+ partyGstin: rawGstin || null,
131
+ grossAmount: roundToPaisa(amount),
132
+ gstAmount: roundToPaisa(gstAmount),
133
+ tdsDeducted: roundToPaisa(tdsDeducted)
134
+ });
135
+ }
136
+
137
+ return vouchers;
138
+ }
139
+
140
+ /**
141
+ * Parses native Tally XML export containing <VOUCHER> tags.
142
+ * Zero external XML parser required: Uses safe native tokenization.
143
+ *
144
+ * @param {string} xmlText - Raw Tally XML text
145
+ * @returns {Array<Object>} Normalized voucher objects
146
+ */
147
+ export function parseTallyDaybookXml(xmlText) {
148
+ if (!xmlText || typeof xmlText !== 'string') {
149
+ throw new Error('[@mudraforge/statutory] Tally XML content must be a non-empty string.');
150
+ }
151
+
152
+ const vouchers = [];
153
+ // Extract every <VOUCHER ...> ... </VOUCHER> block
154
+ const voucherRegex = /<VOUCHER\b[^>]*>([\s\S]*?)<\/VOUCHER>/gi;
155
+ let match;
156
+ let index = 1;
157
+
158
+ while ((match = voucherRegex.exec(xmlText)) !== null) {
159
+ const block = match[1];
160
+
161
+ // Extract basic voucher properties
162
+ const rawDate = extractXmlTag(block, 'DATE');
163
+ const voucherNo = extractXmlTag(block, 'VOUCHERNUMBER') || `TALLY-VCH-${index}`;
164
+ const voucherType = extractXmlTag(block, 'VOUCHERTYPENAME') || 'Purchase';
165
+ const partyName = extractXmlTag(block, 'PARTYLEDGERNAME') || extractXmlTag(block, 'PARTYNAME') || 'Unknown Vendor';
166
+ const gstin = extractXmlTag(block, 'PARTYGSTIN') || extractXmlTag(block, 'GSTIN');
167
+
168
+ // Extract amounts from ledger entries
169
+ let grossAmount = 0;
170
+ let tdsDeducted = 0;
171
+
172
+ // Match all <ALLLEDGERENTRIES.LIST> or <LEDGERENTRIES.LIST>
173
+ const ledgerEntryRegex = /<(?:ALL)?LEDGERENTRIES\.LIST>([\s\S]*?)<\/(?:ALL)?LEDGERENTRIES\.LIST>/gi;
174
+ let ledgerMatch;
175
+
176
+ while ((ledgerMatch = ledgerEntryRegex.exec(block)) !== null) {
177
+ const entryBlock = ledgerMatch[1];
178
+ const ledger = extractXmlTag(entryBlock, 'LEDGERNAME') || '';
179
+ const rawAmt = extractXmlTag(entryBlock, 'AMOUNT') || '0';
180
+ const amtVal = parseFloat(rawAmt) || 0;
181
+
182
+ // In Tally XML:
183
+ // For Purchase vouchers: Credit to Vendor is represented as negative number.
184
+ // Debit to Expense is positive.
185
+ if (ledger.toLowerCase().includes('tds') || ledger.toLowerCase().includes('194q')) {
186
+ tdsDeducted += Math.abs(amtVal);
187
+ } else if (ledger === partyName || Math.abs(amtVal) > grossAmount) {
188
+ // Main party entry
189
+ grossAmount = Math.max(grossAmount, Math.abs(amtVal));
190
+ }
191
+ }
192
+
193
+ // Fallback for amount if not extracted from ledger entries
194
+ if (grossAmount === 0) {
195
+ const topAmount = extractXmlTag(block, 'AMOUNT');
196
+ if (topAmount) {
197
+ grossAmount = Math.abs(parseFloat(topAmount) || 0);
198
+ }
199
+ }
200
+
201
+ vouchers.push({
202
+ rowNumber: index,
203
+ date: formatTallyDate(rawDate),
204
+ voucherNo,
205
+ voucherType,
206
+ partyName,
207
+ partyPan: null, // Will be resolved from GSTIN or name in reconciliation
208
+ partyGstin: gstin ? gstin.toUpperCase() : null,
209
+ grossAmount: roundToPaisa(grossAmount),
210
+ gstAmount: 0,
211
+ tdsDeducted: roundToPaisa(tdsDeducted)
212
+ });
213
+
214
+ index++;
215
+ }
216
+
217
+ return vouchers;
218
+ }
219
+
220
+ /**
221
+ * Reconciles a list of daybook vouchers against Section 194Q statutory rules,
222
+ * performing multi-branch vendor PAN consolidation, excess-over-₹50L tracking,
223
+ * and Section 40(a)(ia) 30% expenditure disallowance risk analysis.
224
+ *
225
+ * @param {Array<Object>} vouchers - Array of normalized vouchers
226
+ * @param {Object} [options={}] - Configuration options
227
+ * @param {number|null} [options.buyerTurnoverCr=null] - Preceding FY buyer turnover in ₹ Crores (null = assume > 10Cr)
228
+ * @param {number} [options.disallowancePercent=30] - Expenditure disallowance under Section 40(a)(ia) (default 30%)
229
+ * @param {number} [options.corporateTaxRatePercent=30] - Applicable corporate tax rate (default 30%)
230
+ * @param {number} [options.delayedInterestRateMonthlyPercent=1.0] - Monthly interest u/s 201(1A) (default 1%)
231
+ * @param {string|null} [options.auditDate=null] - Cutoff date for Section 201(1A) interest (YYYY-MM-DD)
232
+ * @returns {Object} Comprehensive audit reconciliation report
233
+ */
234
+ export function reconcile194QDaybook(vouchers, options = {}) {
235
+ if (!Array.isArray(vouchers)) {
236
+ throw new Error('[@mudraforge/statutory] Daybook vouchers must be an array.');
237
+ }
238
+
239
+ const {
240
+ buyerTurnoverCr = null,
241
+ disallowancePercent = 30,
242
+ corporateTaxRatePercent = 30,
243
+ delayedInterestRateMonthlyPercent = 1.0,
244
+ auditDate = new Date().toISOString().slice(0, 10)
245
+ } = options;
246
+
247
+ // 1. Resolve and validate Tax Identifier (PAN) for every voucher
248
+ const validatedVouchers = vouchers.map(v => {
249
+ let resolvedPan = v.partyPan ? String(v.partyPan).trim().toUpperCase() : null;
250
+ let isPanValid = false;
251
+ let panDetails = null;
252
+
253
+ // Try to decode existing PAN
254
+ if (resolvedPan) {
255
+ const dec = decodeTaxIdentifier(resolvedPan);
256
+ if (dec.isValid && dec.type === 'PAN') {
257
+ isPanValid = true;
258
+ panDetails = dec;
259
+ }
260
+ }
261
+
262
+ // If PAN is missing or invalid, attempt extraction from partyGstin
263
+ if (!isPanValid && v.partyGstin) {
264
+ const rawGstin = String(v.partyGstin).trim().toUpperCase();
265
+ const gstinDec = decodeTaxIdentifier(rawGstin);
266
+ const candidatePan = gstinDec.pan || (rawGstin.match(/[A-Z]{5}[0-9]{4}[A-Z]/)?.[0]);
267
+ if (candidatePan) {
268
+ const panDec = decodeTaxIdentifier(candidatePan);
269
+ if (panDec.isValid && panDec.type === 'PAN') {
270
+ resolvedPan = candidatePan;
271
+ isPanValid = true;
272
+ panDetails = panDec;
273
+ }
274
+ }
275
+ }
276
+
277
+ // If still no valid PAN, generate a synthetic grouping key based on normalized party name
278
+ const groupingKey = resolvedPan || `UNRESOLVED_PAN_${v.partyName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
279
+
280
+ return {
281
+ ...v,
282
+ resolvedPan,
283
+ groupingKey,
284
+ isPanValid,
285
+ panDetails
286
+ };
287
+ });
288
+
289
+ // 2. Group vouchers by Vendor PAN / Grouping Key
290
+ const vendorGroups = new Map();
291
+
292
+ for (const v of validatedVouchers) {
293
+ if (!vendorGroups.has(v.groupingKey)) {
294
+ vendorGroups.set(v.groupingKey, {
295
+ groupingKey: v.groupingKey,
296
+ pan: v.resolvedPan,
297
+ isPanValid: v.isPanValid,
298
+ partyNames: new Set(),
299
+ gstins: new Set(),
300
+ vouchers: []
301
+ });
302
+ }
303
+
304
+ const group = vendorGroups.get(v.groupingKey);
305
+ group.partyNames.add(v.partyName);
306
+ if (v.partyGstin) group.gstins.add(v.partyGstin);
307
+ group.vouchers.push(v);
308
+ }
309
+
310
+ // 3. Process each vendor group chronologically
311
+ const vendorSummaries = [];
312
+ const auditedVouchers = [];
313
+
314
+ let totalDaybookPurchasesInr = 0;
315
+ let totalExcessPurchasesOver50LInr = 0;
316
+ let totalTdsPayableInr = 0;
317
+ let totalTdsDeductedInr = 0;
318
+ let totalTdsShortfallInr = 0;
319
+ let totalSection40aDisallowanceInr = 0;
320
+ let totalEstimatedTaxImpactInr = 0;
321
+ let totalSection201InterestInr = 0;
322
+
323
+ for (const [key, group] of vendorGroups.entries()) {
324
+ // Sort chronologically by date, then row number
325
+ group.vouchers.sort((a, b) => {
326
+ if (a.date && b.date && a.date !== b.date) {
327
+ return a.date.localeCompare(b.date);
328
+ }
329
+ return (a.rowNumber || 0) - (b.rowNumber || 0);
330
+ });
331
+
332
+ let activeFy = null;
333
+ let rollingPriorPurchases = 0;
334
+ let vendorTotalPurchases = 0;
335
+ let vendorExcessPurchases = 0;
336
+ let vendorTdsPayable = 0;
337
+ let vendorTdsDeducted = 0;
338
+ let vendorTdsShortfall = 0;
339
+ let vendorSection40aDisallowance = 0;
340
+ let vendorEstimatedTaxHit = 0;
341
+ let vendorSection201Interest = 0;
342
+ let thresholdBreachedDate = null;
343
+ let hasExceptions = false;
344
+
345
+ const processedGroupVouchers = [];
346
+
347
+ for (const v of group.vouchers) {
348
+ const voucherFy = getIndianFinancialYear(v.date);
349
+ if (activeFy && voucherFy !== activeFy) {
350
+ // Section 194Q(1) statutory threshold resets at each FY boundary (1 April)
351
+ rollingPriorPurchases = 0;
352
+ }
353
+ activeFy = voucherFy;
354
+
355
+ const isDebitNote = Boolean(
356
+ (v.voucherType && (v.voucherType.toLowerCase().includes('debit') || v.voucherType.toLowerCase().includes('return'))) ||
357
+ (Number(v.grossAmount) < 0)
358
+ );
359
+ const rawAmount = Math.abs(Number(v.grossAmount) || 0);
360
+
361
+ if (isDebitNote) {
362
+ // Circular 13/2021 Q4: Debit Note / Purchase Return reduces rolling purchase base in same FY
363
+ rollingPriorPurchases = Math.max(0, roundToPaisa(rollingPriorPurchases - rawAmount));
364
+ totalDaybookPurchasesInr = roundToPaisa(totalDaybookPurchasesInr - rawAmount);
365
+ vendorTotalPurchases = roundToPaisa(vendorTotalPurchases - rawAmount);
366
+
367
+ const auditedVoucher = {
368
+ ...v,
369
+ financialYear: voucherFy,
370
+ vendorPan: group.pan,
371
+ isDebitNote: true,
372
+ priorPurchasesInFy: roundToPaisa(rollingPriorPurchases + rawAmount),
373
+ cumulativePurchasesInFy: rollingPriorPurchases,
374
+ isOver50LThreshold: rollingPriorPurchases > STATUTORY_THRESHOLD_INR,
375
+ taxableBase194Q: 0,
376
+ statutoryRatePercent: group.isPanValid ? 0.1 : 5.0,
377
+ requiredTdsInr: 0,
378
+ actualTdsInr: v.tdsDeducted || 0,
379
+ shortfallInr: 0,
380
+ auditStatus: 'DEBIT_NOTE_PURCHASE_RETURN_ADJUSTED',
381
+ section40aDisallowanceInr: 0,
382
+ estimatedTaxHitInr: 0,
383
+ section201InterestInr: 0,
384
+ statutoryCitation: 'CBDT Circular No. 13 of 2021 Question 4 (Purchase Return Adjustment)'
385
+ };
386
+ processedGroupVouchers.push(auditedVoucher);
387
+ auditedVouchers.push(auditedVoucher);
388
+ continue;
389
+ }
390
+
391
+ const amount = rawAmount;
392
+ totalDaybookPurchasesInr = roundToPaisa(totalDaybookPurchasesInr + amount);
393
+ vendorTotalPurchases = roundToPaisa(vendorTotalPurchases + amount);
394
+
395
+ // Run deterministic Section 194Q calculator
396
+ const calc = evaluateSection194Q({
397
+ invoiceAmount: amount,
398
+ priorFyPurchases: rollingPriorPurchases,
399
+ hasPan: group.isPanValid,
400
+ isPanInoperative: false,
401
+ transactionDate: v.date,
402
+ buyerTurnoverCr,
403
+ gstAmountInr: v.gstAmount || 0,
404
+ buyerName: 'Buyer',
405
+ sellerName: v.partyName
406
+ });
407
+
408
+ const taxableBase = calc.taxableBase || 0;
409
+ const requiredTds = calc.calculatedTdsInr || 0;
410
+ const actualTds = v.tdsDeducted || 0;
411
+ const shortfall = Math.max(0, roundToPaisa(requiredTds - actualTds));
412
+
413
+ if (calc.thresholdBreached && !thresholdBreachedDate) {
414
+ thresholdBreachedDate = v.date;
415
+ }
416
+
417
+ // If there is an under-deduction on an amount crossing the threshold
418
+ let auditStatus = 'COMPLIANT';
419
+ let section40aDisallowance = 0;
420
+ let taxHitExposure = 0;
421
+ let section201Interest = 0;
422
+
423
+ if (taxableBase > 0) {
424
+ vendorExcessPurchases = roundToPaisa(vendorExcessPurchases + taxableBase);
425
+ vendorTdsPayable = roundToPaisa(vendorTdsPayable + requiredTds);
426
+ vendorTdsDeducted = roundToPaisa(vendorTdsDeducted + actualTds);
427
+
428
+ if (shortfall > 0) {
429
+ hasExceptions = true;
430
+ if (!group.isPanValid) {
431
+ auditStatus = 'EXCEPTION_TDS_UNDER_DEDUCTED_PAN_MISSING_PENALTY_5PCT';
432
+ } else {
433
+ auditStatus = actualTds === 0 ? 'EXCEPTION_TDS_NOT_DEDUCTED' : 'EXCEPTION_TDS_UNDER_DEDUCTED';
434
+ }
435
+
436
+ // Section 40(a)(ia) disallowance: 30% of the entire unwithheld purchase base
437
+ // If shortfall is partial, proportionate base is disallowed
438
+ const unwithheldRatio = requiredTds > 0 ? shortfall / requiredTds : 1;
439
+ const unwithheldBase = roundToPaisa(taxableBase * unwithheldRatio);
440
+
441
+ section40aDisallowance = roundToPaisa(unwithheldBase * (disallowancePercent / 100));
442
+ taxHitExposure = roundToPaisa(section40aDisallowance * (corporateTaxRatePercent / 100));
443
+
444
+ // Section 201(1A) delayed deduction interest (1% per month or part of month)
445
+ const overdueMonths = calculateElapsedMonths(v.date, auditDate);
446
+ section201Interest = roundToPaisa(shortfall * (delayedInterestRateMonthlyPercent / 100) * overdueMonths);
447
+
448
+ vendorTdsShortfall = roundToPaisa(vendorTdsShortfall + shortfall);
449
+ vendorSection40aDisallowance = roundToPaisa(vendorSection40aDisallowance + section40aDisallowance);
450
+ vendorEstimatedTaxHit = roundToPaisa(vendorEstimatedTaxHit + taxHitExposure);
451
+ vendorSection201Interest = roundToPaisa(vendorSection201Interest + section201Interest);
452
+ }
453
+ } else {
454
+ auditStatus = 'BELOW_STATUTORY_50L_THRESHOLD';
455
+ }
456
+
457
+ const auditedVoucher = {
458
+ ...v,
459
+ financialYear: voucherFy,
460
+ vendorPan: group.pan,
461
+ priorPurchasesInFy: rollingPriorPurchases,
462
+ cumulativePurchasesInFy: roundToPaisa(rollingPriorPurchases + amount),
463
+ isOver50LThreshold: calc.thresholdBreached,
464
+ taxableBase194Q: taxableBase,
465
+ statutoryRatePercent: calc.effectiveRatePercent,
466
+ requiredTdsInr: requiredTds,
467
+ actualTdsInr: actualTds,
468
+ shortfallInr: shortfall,
469
+ auditStatus,
470
+ section40aDisallowanceInr: section40aDisallowance,
471
+ estimatedTaxHitInr: taxHitExposure,
472
+ section201InterestInr: section201Interest,
473
+ statutoryCitation: calc.governingWithholdingSection
474
+ };
475
+
476
+ processedGroupVouchers.push(auditedVoucher);
477
+ auditedVouchers.push(auditedVoucher);
478
+
479
+ // Advance rolling prior purchases
480
+ rollingPriorPurchases = roundToPaisa(rollingPriorPurchases + amount);
481
+ }
482
+
483
+ totalExcessPurchasesOver50LInr = roundToPaisa(totalExcessPurchasesOver50LInr + vendorExcessPurchases);
484
+ totalTdsPayableInr = roundToPaisa(totalTdsPayableInr + vendorTdsPayable);
485
+ totalTdsDeductedInr = roundToPaisa(totalTdsDeductedInr + vendorTdsDeducted);
486
+ totalTdsShortfallInr = roundToPaisa(totalTdsShortfallInr + vendorTdsShortfall);
487
+ totalSection40aDisallowanceInr = roundToPaisa(totalSection40aDisallowanceInr + vendorSection40aDisallowance);
488
+ totalEstimatedTaxImpactInr = roundToPaisa(totalEstimatedTaxImpactInr + vendorEstimatedTaxHit);
489
+ totalSection201InterestInr = roundToPaisa(totalSection201InterestInr + vendorSection201Interest);
490
+
491
+ vendorSummaries.push({
492
+ groupingKey: key,
493
+ pan: group.pan,
494
+ isPanValid: group.isPanValid,
495
+ vendorNames: Array.from(group.partyNames),
496
+ primaryVendorName: Array.from(group.partyNames)[0] || 'Unknown Vendor',
497
+ gstins: Array.from(group.gstins),
498
+ voucherCount: group.vouchers.length,
499
+ totalPurchasesInr: vendorTotalPurchases,
500
+ thresholdBreached: vendorTotalPurchases > STATUTORY_THRESHOLD_INR,
501
+ thresholdBreachedDate,
502
+ excessPurchasesOver50LInr: vendorExcessPurchases,
503
+ tdsPayableInr: vendorTdsPayable,
504
+ tdsDeductedInr: vendorTdsDeducted,
505
+ tdsShortfallInr: vendorTdsShortfall,
506
+ section40aDisallowanceInr: vendorSection40aDisallowance,
507
+ estimatedTaxHitInr: vendorEstimatedTaxHit,
508
+ section201InterestInr: vendorSection201Interest,
509
+ hasExceptions,
510
+ vouchers: processedGroupVouchers
511
+ });
512
+ }
513
+
514
+ // Sort vendor summaries: High shortfall first
515
+ vendorSummaries.sort((a, b) => b.tdsShortfallInr - a.tdsShortfallInr);
516
+
517
+ const exceptionVendors = vendorSummaries.filter(v => v.hasExceptions);
518
+ const compliantVendors = vendorSummaries.filter(v => !v.hasExceptions);
519
+
520
+ return {
521
+ auditSummary: {
522
+ totalVouchersScanned: vouchers.length,
523
+ uniqueVendorsCount: vendorGroups.size,
524
+ vendorsExceeding50LCount: vendorSummaries.filter(v => v.thresholdBreached).length,
525
+ vendorsWithExceptionsCount: exceptionVendors.length,
526
+ totalPurchasesInr: totalDaybookPurchasesInr,
527
+ totalExcessPurchasesOver50LInr: totalExcessPurchasesOver50LInr,
528
+ totalTdsPayableInr: totalTdsPayableInr,
529
+ totalTdsDeductedInr: totalTdsDeductedInr,
530
+ totalTdsShortfallInr: totalTdsShortfallInr,
531
+ totalSection40aDisallowanceInr: totalSection40aDisallowanceInr,
532
+ totalEstimatedTaxImpactInr: totalEstimatedTaxImpactInr,
533
+ totalSection201InterestInr: totalSection201InterestInr,
534
+ totalAuditExposureInr: roundToPaisa(totalTdsShortfallInr + totalEstimatedTaxImpactInr + totalSection201InterestInr),
535
+ statutoryAuthority: 'CBDT Section 194Q & Section 40(a)(ia) of the Income-tax Act, 1961',
536
+ isAuthorizedToDisburse: false
537
+ },
538
+ exceptionVendors,
539
+ compliantVendors,
540
+ allVendors: vendorSummaries,
541
+ auditedVouchers
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Generates Tally-compliant XML for compensating journal vouchers to record
547
+ * un-withheld Section 194Q TDS directly into Tally Prime.
548
+ *
549
+ * Creates double-entry vouchers:
550
+ * Debit: Vendor Ledger
551
+ * Credit: TDS on Purchase of Goods u/s 194Q
552
+ *
553
+ * @param {Object} reconciliationResult - Result from reconcile194QDaybook()
554
+ * @param {Object} [options={}] - Customization options
555
+ * @param {string} [options.voucherDate] - Voucher date YYYYMMDD (defaults to latest exception date or today)
556
+ * @param {string} [options.creditLedgerName='TDS on Purchase of Goods u/s 194Q'] - Credit ledger
557
+ * @param {string} [options.companyName=''] - Tally company name
558
+ * @returns {string} XML string ready for Tally Prime HTTP import or file import
559
+ */
560
+ export function generateTally194QJournalXml(reconciliationResult, options = {}) {
561
+ if (!reconciliationResult || !Array.isArray(reconciliationResult.exceptionVendors)) {
562
+ throw new Error('[@mudraforge/statutory] Valid reconciliation result with exceptionVendors is required.');
563
+ }
564
+
565
+ const {
566
+ voucherDate = new Date().toISOString().slice(0, 10).replace(/-/g, ''),
567
+ creditLedgerName = 'TDS on Purchase of Goods u/s 194Q',
568
+ companyName = ''
569
+ } = options;
570
+
571
+ const tallyDate = voucherDate.replace(/[^0-9]/g, '');
572
+
573
+ const voucherXmlList = [];
574
+
575
+ for (const vendor of reconciliationResult.exceptionVendors) {
576
+ if (vendor.tdsShortfallInr <= 0) continue;
577
+
578
+ const partyName = escapeXml(vendor.primaryVendorName);
579
+ const panStr = vendor.pan ? `PAN: ${vendor.pan}` : 'PAN: NOT FURNISHED';
580
+ const amount = vendor.tdsShortfallInr.toFixed(2);
581
+ const narration = escapeXml(
582
+ `Compensating TDS entry under Section 194Q on cumulative purchases of ₹${vendor.totalPurchasesInr.toLocaleString('en-IN')} (${panStr}) exceeding statutory ₹50,00,000 threshold. Ref: CBDT Circular 13/2021.`
583
+ );
584
+
585
+ voucherXmlList.push(` <TALLYMESSAGE xmlns:UDF="TallyUDF">
586
+ <VOUCHER VCHTYPE="Journal" ACTION="Create" OBJVIEW="Accounting Voucher View">
587
+ <DATE>${tallyDate}</DATE>
588
+ <VOUCHERTYPENAME>Journal</VOUCHERTYPENAME>
589
+ <NARRATION>${narration}</NARRATION>
590
+ <ALLLEDGERENTRIES.LIST>
591
+ <LEDGERNAME>${partyName}</LEDGERNAME>
592
+ <ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
593
+ <AMOUNT>-${amount}</AMOUNT>
594
+ </ALLLEDGERENTRIES.LIST>
595
+ <ALLLEDGERENTRIES.LIST>
596
+ <LEDGERNAME>${escapeXml(creditLedgerName)}</LEDGERNAME>
597
+ <ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
598
+ <AMOUNT>${amount}</AMOUNT>
599
+ </ALLLEDGERENTRIES.LIST>
600
+ </VOUCHER>
601
+ </TALLYMESSAGE>`);
602
+ }
603
+
604
+ const companyTag = companyName
605
+ ? `<STATICVARIABLES><SVCURRENTCOMPANY>${escapeXml(companyName)}</SVCURRENTCOMPANY></STATICVARIABLES>`
606
+ : '<STATICVARIABLES/>';
607
+
608
+ return `<ENVELOPE>
609
+ <HEADER>
610
+ <TALLYREQUEST>Import Data</TALLYREQUEST>
611
+ </HEADER>
612
+ <BODY>
613
+ <IMPORTDATA>
614
+ <REQUESTDESC>
615
+ <REPORTNAME>Vouchers</REPORTNAME>
616
+ ${companyTag}
617
+ </REQUESTDESC>
618
+ <REQUESTDATA>
619
+ ${voucherXmlList.join('\n')}
620
+ </REQUESTDATA>
621
+ </IMPORTDATA>
622
+ </BODY>
623
+ </ENVELOPE>`;
624
+ }
625
+
626
+ /**
627
+ * Formats a clean, executive-ready Markdown audit exception report.
628
+ *
629
+ * @param {Object} reconciliationResult - Output from reconcile194QDaybook()
630
+ * @returns {string} Formatted Markdown audit memorandum
631
+ */
632
+ export function generateAuditMarkdownReport(reconciliationResult) {
633
+ const { auditSummary, exceptionVendors } = reconciliationResult;
634
+
635
+ const fmtInr = (n) => '₹' + Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
636
+
637
+ let md = `# SECTION 194Q STATUTORY AUDIT & 40(a)(ia) EXPOSURE REPORT\n\n`;
638
+ md += `**Statutory Authority**: Income-tax Act, 1961 Section 194Q read with Section 40(a)(ia) and CBDT Circular 13/2021\n`;
639
+ md += `**Audit Scope**: Daybook Multi-Branch PAN Aggregation & Rolling Threshold Scrutiny\n\n`;
640
+
641
+ md += `## 1. Executive Summary\n\n`;
642
+ md += `| Metric | Statutory Value |\n`;
643
+ md += `| :--- | :--- |\n`;
644
+ md += `| **Total Vouchers Audited** | \`${auditSummary.totalVouchersScanned}\` |\n`;
645
+ md += `| **Unique Vendor Entities** | \`${auditSummary.uniqueVendorsCount}\` |\n`;
646
+ md += `| **Vendors Exceeding ₹50L Threshold** | \`${auditSummary.vendorsExceeding50LCount}\` |\n`;
647
+ md += `| **Vendors with Unwithheld Shortfall** | **\`${auditSummary.vendorsWithExceptionsCount}\`** |\n`;
648
+ md += `| **Total Daybook Purchases** | ${fmtInr(auditSummary.totalPurchasesInr)} |\n`;
649
+ md += `| **Taxable Base (Excess > ₹50L)** | ${fmtInr(auditSummary.totalExcessPurchasesOver50LInr)} |\n`;
650
+ md += `| **Required TDS Liability** | ${fmtInr(auditSummary.totalTdsPayableInr)} |\n`;
651
+ md += `| **Actual TDS Deducted in Books** | ${fmtInr(auditSummary.totalTdsDeductedInr)} |\n`;
652
+ md += `| **Statutory TDS Shortfall** | **${fmtInr(auditSummary.totalTdsShortfallInr)}** |\n`;
653
+ md += `| **Section 40(a)(ia) 30% Expenditure Disallowance** | **${fmtInr(auditSummary.totalSection40aDisallowanceInr)}** |\n`;
654
+ md += `| **Estimated Income Tax Exposure (@ 30%)** | **${fmtInr(auditSummary.totalEstimatedTaxImpactInr)}** |\n`;
655
+ md += `| **Section 201(1A) Delayed Deduction Interest** | ${fmtInr(auditSummary.totalSection201InterestInr)} |\n`;
656
+ md += `| **Total Cumulative Audit Exposure** | **${fmtInr(auditSummary.totalAuditExposureInr)}** |\n\n`;
657
+
658
+ if (exceptionVendors.length === 0) {
659
+ md += `> [!NOTE]\n> **100% Statutory Compliance**: Zero unwithheld Section 194Q TDS detected across all daybook records. No Section 40(a)(ia) disallowance risk.\n`;
660
+ return md;
661
+ }
662
+
663
+ md += `## 2. Vendor Exception Schedule (Form 3CD Clause 21(b) Working Papers)\n\n`;
664
+ md += `| Vendor Name | PAN | Total Purchases | Excess > ₹50L | TDS Shortfall | 40(a)(ia) Disallowance | Tax Hit Exposure |\n`;
665
+ md += `| :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n`;
666
+
667
+ for (const v of exceptionVendors) {
668
+ md += `| **${v.primaryVendorName}** | \`${v.pan || 'MISSING'}\` | ${fmtInr(v.totalPurchasesInr)} | ${fmtInr(v.excessPurchasesOver50LInr)} | **${fmtInr(v.tdsShortfallInr)}** | **${fmtInr(v.section40aDisallowanceInr)}** | **${fmtInr(v.estimatedTaxHitInr)}** |\n`;
669
+ }
670
+
671
+ md += `\n> [!CAUTION]\n`;
672
+ md += `> **Audit Defense Directive**: The Income-tax Department enforces strict 30% expenditure disallowance under Section 40(a)(ia) for failure to deduct TDS. A compensating journal entry must be posted in Tally before closing annual financial statements.\n`;
673
+
674
+ return md;
675
+ }
676
+
677
+ // -----------------------------------------------------------------------------
678
+ // INTERNAL HELPER UTILITIES
679
+ // -----------------------------------------------------------------------------
680
+
681
+ function parseCsvRow(rowText, delimiter = ',') {
682
+ const result = [];
683
+ let insideQuotes = false;
684
+ let current = '';
685
+
686
+ for (let i = 0; i < rowText.length; i++) {
687
+ const char = rowText[i];
688
+ if (char === '"') {
689
+ if (insideQuotes && rowText[i + 1] === '"') {
690
+ current += '"';
691
+ i++;
692
+ } else {
693
+ insideQuotes = !insideQuotes;
694
+ }
695
+ } else if (char === delimiter && !insideQuotes) {
696
+ result.push(current.trim());
697
+ current = '';
698
+ } else {
699
+ current += char;
700
+ }
701
+ }
702
+ result.push(current.trim());
703
+ return result;
704
+ }
705
+
706
+ function findColIndex(headers, candidateNames) {
707
+ // 1. Exact match
708
+ for (const name of candidateNames) {
709
+ const idx = headers.indexOf(name);
710
+ if (idx !== -1) return idx;
711
+ }
712
+ // 2. Substring match, but ignore short names (length <= 3 like 'gst' or 'tax') to prevent false matches (e.g. 'gst' in 'gstin')
713
+ for (const name of candidateNames) {
714
+ if (name.length <= 3) continue;
715
+ const idx = headers.findIndex(h => h.includes(name));
716
+ if (idx !== -1) return idx;
717
+ }
718
+ return -1;
719
+ }
720
+
721
+ function normalizeIsoDate(raw) {
722
+ if (!raw) return null;
723
+ // Excel serial date number support (e.g. 45422 -> 2024-05-10)
724
+ if (typeof raw === 'number' && raw > 20000 && raw < 70000) {
725
+ const ms = Math.round((raw - 25569) * 86400 * 1000);
726
+ return new Date(ms).toISOString().slice(0, 10);
727
+ }
728
+ const str = String(raw).trim();
729
+ // Excel serial as string e.g. "45422"
730
+ if (/^\d{5}$/.test(str)) {
731
+ const num = parseInt(str, 10);
732
+ if (num > 20000 && num < 70000) {
733
+ const ms = Math.round((num - 25569) * 86400 * 1000);
734
+ return new Date(ms).toISOString().slice(0, 10);
735
+ }
736
+ }
737
+ // YYYY-MM-DD
738
+ if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
739
+ // YYYY/MM/DD
740
+ if (/^\d{4}\/\d{2}\/\d{2}$/.test(str)) return str.replace(/\//g, '-');
741
+ // DD-MM-YYYY or DD/MM/YYYY
742
+ const dmy = str.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
743
+ if (dmy) {
744
+ const d = dmy[1].padStart(2, '0');
745
+ const m = dmy[2].padStart(2, '0');
746
+ const y = dmy[3];
747
+ return `${y}-${m}-${d}`;
748
+ }
749
+ // YYYYMMDD
750
+ if (/^\d{8}$/.test(str)) {
751
+ return `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}`;
752
+ }
753
+ return str;
754
+ }
755
+
756
+ function formatTallyDate(raw) {
757
+ if (!raw) return null;
758
+ const clean = String(raw).trim();
759
+ if (/^\d{8}$/.test(clean)) {
760
+ return `${clean.slice(0, 4)}-${clean.slice(4, 6)}-${clean.slice(6, 8)}`;
761
+ }
762
+ return normalizeIsoDate(clean);
763
+ }
764
+
765
+ function extractXmlTag(xmlBlock, tagName) {
766
+ const regex = new RegExp(`<${tagName}\\b[^>]*>([^<]*)<\\/${tagName}>`, 'i');
767
+ const m = xmlBlock.match(regex);
768
+ return m ? m[1].trim() : null;
769
+ }
770
+
771
+ function escapeXml(str) {
772
+ if (!str) return '';
773
+ return String(str)
774
+ .replace(/&/g, '&amp;')
775
+ .replace(/</g, '&lt;')
776
+ .replace(/>/g, '&gt;')
777
+ .replace(/"/g, '&quot;')
778
+ .replace(/'/g, '&apos;');
779
+ }
780
+
781
+ function calculateElapsedMonths(startDateStr, endDateStr) {
782
+ if (!startDateStr || !endDateStr) return 1;
783
+ const start = new Date(startDateStr);
784
+ const end = new Date(endDateStr);
785
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) return 1;
786
+
787
+ let months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth());
788
+ if (end.getDate() > start.getDate()) {
789
+ months += 1;
790
+ }
791
+ return Math.max(1, months);
792
+ }
793
+
794
+ /**
795
+ * Derives the Indian Financial Year string (e.g. '2024-25') for a given ISO date.
796
+ * Financial Year runs from 1 April to 31 March.
797
+ *
798
+ * @param {string} dateStr - ISO date string (YYYY-MM-DD)
799
+ * @returns {string} Financial Year identifier (e.g. '2024-25')
800
+ */
801
+ export function getIndianFinancialYear(dateStr) {
802
+ if (!dateStr) return 'DEFAULT_FY';
803
+ const clean = String(dateStr).trim().slice(0, 10);
804
+ const m = clean.match(/^(\d{4})-(\d{2})/);
805
+ if (!m) return 'DEFAULT_FY';
806
+ const year = parseInt(m[1], 10);
807
+ const month = parseInt(m[2], 10);
808
+ if (month >= 1 && month <= 3) {
809
+ return `${year - 1}-${String(year).slice(2)}`;
810
+ }
811
+ return `${year}-${String(year + 1).slice(2)}`;
812
+ }