@mudraforge/statutory 2.11.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.
- package/CHANGELOG.md +155 -1
- package/CONTRIBUTING.md +2 -2
- package/README.md +1 -1
- package/SECURITY.md +2 -2
- package/SKILL.md +1 -1
- package/bin/cli.js +288 -27
- package/docs/ACCOUNTING_ASSURANCE_AUDIT_DOSSIER.md +276 -0
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/CASE_01_FORM_TEXT.txt +48 -0
- package/docs/CATALOG.md +405 -39
- package/docs/CA_PILOT_BENCHMARK_CASES.md +282 -0
- package/docs/CORPUS_QUALITY.md +31 -22
- package/docs/DOMAIN_BREADTH_AND_DEPTH_COMPARISON.md +1 -1
- package/okf/corporate_ma_restructuring/business_combinations_indas_103/circulars/mca_notification_indas_103_business_combinations.md +41 -0
- package/okf/corporate_ma_restructuring/business_combinations_indas_103/forms/purchase_price_allocation_schedule.json +110 -0
- package/okf/corporate_ma_restructuring/business_combinations_indas_103/section.json +65 -0
- package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/circulars/mca_notification_indas_36_impairment.md +45 -0
- package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/forms/cgu_impairment_testing_register.json +53 -0
- package/okf/corporate_ma_restructuring/impairment_of_assets_indas_36/section.json +61 -0
- package/okf/corporate_ma_restructuring/share_based_payments_indas_102/circulars/icai_guidance_share_based_payments.md +37 -0
- package/okf/corporate_ma_restructuring/share_based_payments_indas_102/forms/esop_graded_vesting_amortization_schedule.json +49 -0
- package/okf/corporate_ma_restructuring/share_based_payments_indas_102/section.json +60 -0
- package/okf/direct_tax/income_taxes_deferred_tax_indas_12/circulars/mca_notification_indas_12_income_taxes.md +40 -0
- package/okf/direct_tax/income_taxes_deferred_tax_indas_12/forms/deferred_tax_asset_liability_reconciliation_schedule.json +46 -0
- package/okf/direct_tax/income_taxes_deferred_tax_indas_12/section.json +61 -0
- package/okf/direct_tax/tds_commission_194h/section.json +5 -4
- package/okf/direct_tax/tds_contractor_194c/section.json +3 -3
- package/okf/direct_tax/tds_ecommerce_194o/section.json +3 -3
- package/okf/direct_tax/tds_goods_194q/circulars/cbdt_circular_13_2021_guidelines.md +2 -2
- package/okf/direct_tax/tds_goods_194q/section.json +9 -3
- package/okf/direct_tax/tds_goods_393/section.json +6 -4
- package/okf/direct_tax/tds_individual_contract_194m/section.json +3 -3
- package/okf/direct_tax/tds_interest_194a/section.json +7 -4
- package/okf/direct_tax/tds_perquisites_194r/section.json +1 -1
- package/okf/direct_tax/tds_property_194ia/section.json +3 -3
- package/okf/direct_tax/tds_rent_194i/section.json +5 -4
- package/okf/direct_tax/tds_rent_individual_194ib/section.json +2 -2
- package/okf/direct_tax/tds_services_194j/circulars/cbdt_circular_03_2020_fts_rate_cut.md +1 -1
- package/okf/direct_tax/tds_services_194j/section.json +10 -3
- package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/circulars/cbic_circular_170_2022_itc_reporting.md +30 -0
- package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/circulars/cbic_notification_18_2022_cutoff_amendment.md +26 -0
- package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/forms/gstr_3b_table_4b1_reversal_schedule.json +101 -0
- package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/forms/rule_42_43_annual_reconciliation_worksheet.json +87 -0
- package/okf/indirect_tax_gst/common_itc_reversal_rule42_43/section.json +65 -0
- package/okf/indirect_tax_gst/itc_reversal_rule37_37a/circulars/cbic_circular_192_2023_ecl_interest_calculation.md +36 -0
- package/okf/indirect_tax_gst/itc_reversal_rule37_37a/section.json +8 -1
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_notification_07_2025_composition_rent_exclusion.md +41 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_notification_09_2024_renting_rcm.md +37 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/gst_council_55th_meeting_renting_rcm_regularization.md +31 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/section.json +24 -2
- package/okf/international_tax/financial_instruments_ecl_indas_109/circulars/mca_notification_indas_109_financial_instruments.md +43 -0
- package/okf/international_tax/financial_instruments_ecl_indas_109/forms/indas_109_ecl_provision_matrix_schedule.json +43 -0
- package/okf/international_tax/financial_instruments_ecl_indas_109/section.json +62 -0
- package/okf/manifest.json +71 -20
- package/okf/msme_commercial/lease_accounting_indas_116/circulars/mca_notification_indas_116_leases.md +48 -0
- package/okf/msme_commercial/lease_accounting_indas_116/forms/lease_rou_asset_and_liability_schedule.json +55 -0
- package/okf/msme_commercial/lease_accounting_indas_116/section.json +67 -0
- package/okf/msme_commercial/provisions_contingencies_indas_37/circulars/mca_notification_indas_37_provisions.md +36 -0
- package/okf/msme_commercial/provisions_contingencies_indas_37/forms/provisions_and_contingent_liabilities_register.json +38 -0
- package/okf/msme_commercial/provisions_contingencies_indas_37/section.json +61 -0
- package/okf/msme_commercial/revenue_recognition_indas_115/circulars/mca_notification_indas_115_revenue_contracts.md +56 -0
- package/okf/msme_commercial/revenue_recognition_indas_115/forms/revenue_contract_5step_allocation_schedule.json +54 -0
- package/okf/msme_commercial/revenue_recognition_indas_115/section.json +69 -0
- package/package.json +7 -1
- package/src/calculators.js +2195 -260
- package/src/constants.js +1 -1
- package/src/index.js +407 -23
- package/src/tally.js +812 -0
- package/src/zoho_gstr2b.js +2964 -0
- package/types/index.d.ts +476 -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, '&')
|
|
775
|
+
.replace(/</g, '<')
|
|
776
|
+
.replace(/>/g, '>')
|
|
777
|
+
.replace(/"/g, '"')
|
|
778
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|