/** * Form 16 (Form 16A TDS Credit) service. * Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger. * * Credit note: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS → CN-F-16-{...}, ledger, CSV to WFM FORM_16). * Debit: process26asUploadAggregation only (when 26AS total drops for a SETTLED quarter); DN-F-16-{...}, CSV to WFM FORM_16. */ import crypto from 'crypto'; import { Op, fn, col, QueryTypes, where as sqlWhere } from 'sequelize'; import { sequelize } from '../config/database'; import { Form16CreditNote, Form16DebitNote, Form16aSubmission, WorkflowRequest, Document, Form1626asQuarterSnapshot, Form16QuarterStatus, Form16LedgerEntry, Form16SapResponse, } from '../models'; import { Tds26asEntry } from '../models/Tds26asEntry'; import { Form1626asUploadLog } from '../models/Form1626asUploadLog'; import { Form16NonSubmittedNotification } from '../models/Form16NonSubmittedNotification'; import { Dealer } from '../models/Dealer'; import { User } from '../models/User'; import { Priority, WorkflowStatus } from '../types/common.types'; import { generateRequestNumber, padDealerCode } from '../utils/helpers'; import { gcsStorageService } from './gcsStorage.service'; import { activityService } from './activity.service'; import { wfmFileService } from './wfmFile.service'; import logger from '../utils/logger'; /** * Resolve dealer_code for the current user. * Uses users.employee_number (DB column) first — dealer code saved at login; else falls back to email match with dealers.dealer_principal_email_id. * Same dealer code is used for submission, credit note, and debit note generation. * Returns null if no dealer code found. */ export async function getDealerCodeForUser(userId: string, userEmail?: string | null): Promise { const [row] = await sequelize.query<{ employee_number: string | null }>( `SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`, { replacements: { userId }, type: QueryTypes.SELECT } ); if (row?.employee_number != null && String(row.employee_number).trim()) { return String(row.employee_number).trim(); } const user = await User.findByPk(userId, { attributes: ['userId', 'email'] }); const emailToUse = (user?.email || userEmail || '').toString().trim(); if (!emailToUse) return null; const dealer = await Dealer.findOne({ where: { dealerPrincipalEmailId: { [Op.iLike]: emailToUse }, isActive: true, }, attributes: ['salesCode', 'dlrcode', 'dealerId'], }); if (!dealer) return null; const code = dealer.salesCode || dealer.dlrcode || null; return code; } /** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */ const SECTION_26AS_194Q = '194Q'; const AMOUNT_MATCH_TOLERANCE = 1; /** * Form 16 INCOMING CSV `TDS_AMT`: amount digits first, sign last — credit `123.45+`, debit `123.45-`. */ function formatForm16IncomingCsvTdsAmt(amount: number, kind: 'credit' | 'debit'): string { const n = Math.abs(Number(amount)).toFixed(2); return kind === 'credit' ? `${n}+` : `${n}-`; } type Latest26asRow = { panNumber: string | null; amountPaid: number | null; taxDeducted: number; totalTdsDeposited: number | null; transactionDate: string | null; dateOfBooking: string | null; }; function normalizeTanNumber(raw: unknown): string { return String(raw ?? '') .trim() .toUpperCase() .replace(/[^A-Z0-9]/g, ''); } // Some environments might still be on the old DB schema. // If `tds_26as_entries.pan_number` is missing, uploads/selects will fail without this guard. let _panNumberColumnPresent: boolean | null = null; async function isPanNumberColumnPresent(): Promise { if (_panNumberColumnPresent !== null) return _panNumberColumnPresent; try { const [row] = await sequelize.query<{ exists: boolean }>( `SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists FROM information_schema.columns WHERE table_name = 'tds_26as_entries' AND column_name = 'pan_number'`, { type: QueryTypes.SELECT } ); _panNumberColumnPresent = !!row?.exists; } catch (e) { logger.warn('[Form16] pan_number column presence check failed; disabling PAN persistence/enforcement.', { error: e instanceof Error ? e.message : String(e), }); _panNumberColumnPresent = false; } return _panNumberColumnPresent; } /** * Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O). * Use case: "Always match Form 16A only with the latest 26AS version." Each upload can be full cumulative; * we sum only rows from the most recent upload for that quarter to avoid double-counting across uploads. * If no rows have upload_log_id (legacy), falls back to summing all rows for that quarter. */ export async function getLatest26asAggregatedForQuarter( tanNumber: string, financialYear: string, quarter: string ): Promise { const normalizedTan = normalizeTanNumber(tanNumber); const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const [row] = await sequelize.query<{ sum: string }>( `WITH latest_upload AS ( SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND financial_year = :fy AND quarter = :qtr AND UPPER(TRIM(COALESCE(section_code, ''))) = :section AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O') AND upload_log_id IS NOT NULL ) SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum FROM tds_26as_entries e WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND e.financial_year = :fy AND e.quarter = :qtr AND UPPER(TRIM(COALESCE(e.section_code, ''))) = :section AND UPPER(TRIM(COALESCE(e.status_oltas, ''))) IN ('F', 'O') AND ( e.upload_log_id = (SELECT mid FROM latest_upload) OR (SELECT mid FROM latest_upload) IS NULL )`, { replacements: { tan: normalizedTan, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT } ); return parseFloat(row?.sum ?? '0') || 0; } async function getLatest26asRowsForQuarter( tanNumber: string, financialYear: string, quarter: string ): Promise { const normalizedTan = normalizeTanNumber(tanNumber); const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const hasPan = await isPanNumberColumnPresent(); const panSelect = hasPan ? 'e.pan_number' : 'NULL::text AS pan_number'; const rows = await sequelize.query<{ pan_number: string | null; amount_paid: string | null; tax_deducted: string; total_tds_deposited: string | null; transaction_date: string | null; date_of_booking: string | null; }>( `WITH latest_upload AS ( SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND financial_year = :fy AND quarter = :qtr AND UPPER(TRIM(COALESCE(section_code, ''))) = :section AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O') AND upload_log_id IS NOT NULL ) SELECT ${panSelect}, e.amount_paid, e.tax_deducted, e.total_tds_deposited, e.transaction_date, e.date_of_booking FROM tds_26as_entries e WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND e.financial_year = :fy AND e.quarter = :qtr AND UPPER(TRIM(COALESCE(e.section_code, ''))) = :section AND UPPER(TRIM(COALESCE(e.status_oltas, ''))) IN ('F', 'O') AND ( e.upload_log_id = (SELECT mid FROM latest_upload) OR (SELECT mid FROM latest_upload) IS NULL )`, { replacements: { tan: normalizedTan, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT } ); return rows.map((r) => ({ panNumber: r.pan_number ? String(r.pan_number).trim().toUpperCase() : null, amountPaid: r.amount_paid == null ? null : parseFloat(r.amount_paid), taxDeducted: parseFloat(r.tax_deducted || '0') || 0, totalTdsDeposited: r.total_tds_deposited == null ? null : parseFloat(r.total_tds_deposited), transactionDate: r.transaction_date || null, dateOfBooking: r.date_of_booking || null, })); } async function get26asCoverageDebug(tanNumber: string, financialYear: string, quarter: string) { const normalizedTan = normalizeTanNumber(tanNumber); const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; // Overall counts + how many rows qualify for our matching rule: const [counts] = await sequelize.query<{ total_rows: string; matching_194q_f_o_rows: string; }>( `SELECT COUNT(*)::text AS total_rows, SUM( CASE WHEN UPPER(TRIM(COALESCE(section_code, ''))) = :section AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O') THEN 1 ELSE 0 END )::text AS matching_194q_f_o_rows FROM tds_26as_entries e WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND e.financial_year = :fy AND e.quarter = :q`, { replacements: { tan: normalizedTan, fy, q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT } ); // Section/status breakdown to make it obvious why latestRows became empty. const breakdown = (await sequelize.query( `SELECT section_code, status_oltas, COUNT(*)::text AS cnt FROM tds_26as_entries e WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^a-zA-Z0-9]', '', 'g')) = :tan AND e.financial_year = :fy AND e.quarter = :q GROUP BY section_code, status_oltas ORDER BY cnt DESC LIMIT 8`, { replacements: { tan: normalizedTan, fy, q }, type: QueryTypes.SELECT } )) as Array<{ section_code: string | null; status_oltas: string | null; cnt: string }>; const totalRows = parseInt(String(counts?.total_rows ?? '0'), 10) || 0; const matchingRows = parseInt(String(counts?.matching_194q_f_o_rows ?? '0'), 10) || 0; const breakdownLines = (breakdown || []) .map((b) => { const sec = (b.section_code ?? '').toString().trim() || '(null)'; const st = (b.status_oltas ?? '').toString().trim() || '(null)'; const c = parseInt(String(b.cnt ?? '0'), 10) || 0; return `${sec}/${st}:${c}`; }) .join(', '); return { totalRows, matchingRows, breakdownLines }; } function normalizeDateOnly(value: unknown): string | null { if (!value) return null; const raw = String(value).trim(); if (!raw) return null; // Prefer Indian-style numeric dates from OCR (DD-MM-YYYY or DD/MM/YYYY). // Do NOT let JS Date parse these first, because it may interpret as MM-DD-YYYY. const m = raw.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/); if (m) { const dd = m[1].padStart(2, '0'); const mm = m[2].padStart(2, '0'); const yyyy = m[3].length === 2 ? `20${m[3]}` : m[3]; return `${yyyy}-${mm}-${dd}`; } // Handle OCR values like "13-Jan-2025" without timezone conversion. const m2 = raw.match(/^(\d{1,2})[-\/]([A-Za-z]{3,9})[-\/](\d{4})$/); if (m2) { const dd = m2[1].padStart(2, '0'); const mon = m2[2].toLowerCase(); const yyyy = m2[3]; const monthMap: Record = { jan: '01', january: '01', feb: '02', february: '02', mar: '03', march: '03', apr: '04', april: '04', may: '05', jun: '06', june: '06', jul: '07', july: '07', aug: '08', august: '08', sep: '09', sept: '09', september: '09', oct: '10', october: '10', nov: '11', november: '11', dec: '12', december: '12', }; const mm = monthMap[mon]; if (mm) return `${yyyy}-${mm}-${dd}`; } const d = new Date(raw); if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); return null; } /** * Derive Indian FY and 26AS quarter from an OCR date-only string (YYYY-MM-DD). * Uses the same quarter boundaries as 26AS dateToFyAndQuarter: * - Apr-Jun => Q1 (FY end = year+1) * - Jul-Sep => Q2 * - Oct-Dec => Q3 * - Jan-Mar => Q4 (FY end = year) */ function deriveFyAndQuarterFromDateOnly(dateOnly: string | null): { financialYear: string; quarter: string } | null { if (!dateOnly) return null; const d = new Date(`${dateOnly}T00:00:00.000Z`); if (Number.isNaN(d.getTime())) return null; const month = d.getUTCMonth() + 1; // 1-12 const year = d.getUTCFullYear(); let quarter: string; if ([4, 5, 6].includes(month)) quarter = 'Q1'; else if ([7, 8, 9].includes(month)) quarter = 'Q2'; else if ([10, 11, 12].includes(month)) quarter = 'Q3'; else quarter = 'Q4'; // Jan-Mar const fyEnd = [1, 2, 3].includes(month) ? year : year + 1; const fyStart = fyEnd - 1; const next = (fyEnd % 100).toString().padStart(2, '0'); return { financialYear: `${fyStart}-${next}`, quarter }; } function toNumberOrNull(value: unknown): number | null { if (value == null || value === '') return null; const n = typeof value === 'number' ? value : parseFloat(String(value).replace(/,/g, '')); return Number.isFinite(n) ? n : null; } /** Get latest 26AS quarter snapshot for (tan, fy, quarter). */ export async function getLatest26asSnapshot( tanNumber: string, financialYear: string, quarter: string ): Promise { const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; return Form1626asQuarterSnapshot.findOne({ where: { tanNumber: { [Op.iLike]: normalized }, financialYear: fy, quarter: q, }, order: [['createdAt', 'DESC']], }); } /** Get quarter status row for (tan, fy, quarter). */ export async function getQuarterStatus( tanNumber: string, financialYear: string, quarter: string ): Promise { const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); return Form16QuarterStatus.findOne({ where: { tanNumber: { [Op.iLike]: normalized }, financialYear: fy, quarter: q, }, }); } /** Create or update quarter status to SETTLED with given credit note. */ async function setQuarterStatusSettled( tanNumber: string, financialYear: string, quarter: string, creditNoteId: number ): Promise { const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); const [status] = await Form16QuarterStatus.findOrCreate({ where: { tanNumber: normalized, financialYear: fy, quarter: q }, defaults: { tanNumber: normalized, financialYear: fy, quarter: q, status: 'SETTLED', lastCreditNoteId: creditNoteId, lastDebitNoteId: null, updatedAt: new Date(), }, }); await status.update({ status: 'SETTLED', lastCreditNoteId: creditNoteId, lastDebitNoteId: null, updatedAt: new Date(), }); } /** Create or update quarter status to DEBIT_ISSUED_PENDING_FORM16 with given debit note. */ async function setQuarterStatusDebitIssued( tanNumber: string, financialYear: string, quarter: string, debitNoteId: number ): Promise { const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); const [status] = await Form16QuarterStatus.findOrCreate({ where: { tanNumber: normalized, financialYear: fy, quarter: q }, defaults: { tanNumber: normalized, financialYear: fy, quarter: q, status: 'DEBIT_ISSUED_PENDING_FORM16', lastDebitNoteId: debitNoteId, lastCreditNoteId: null, updatedAt: new Date(), }, }); await status.update({ status: 'DEBIT_ISSUED_PENDING_FORM16', lastDebitNoteId: debitNoteId, updatedAt: new Date(), }); } /** Add a ledger entry (CREDIT or DEBIT). No deletion; full audit trail. */ async function addLedgerEntry(params: { tanNumber: string; financialYear: string; quarter: string; entryType: 'CREDIT' | 'DEBIT'; amount: number; creditNoteId?: number | null; debitNoteId?: number | null; form16SubmissionId?: number | null; snapshotId?: number | null; }): Promise { const fy = normalizeFinancialYear(params.financialYear) || params.financialYear; const q = normalizeQuarter(params.quarter) || params.quarter; const normalized = (params.tanNumber || '').trim().replace(/\s+/g, ' '); await Form16LedgerEntry.create({ tanNumber: normalized, financialYear: fy, quarter: q, entryType: params.entryType, amount: params.amount, creditNoteId: params.creditNoteId ?? null, debitNoteId: params.debitNoteId ?? null, form16SubmissionId: params.form16SubmissionId ?? null, snapshotId: params.snapshotId ?? null, createdAt: new Date(), }); } /** * List credit notes for the dealer associated with the current user. * Used by dealer-facing Credit Notes page under Form 16. */ export async function listCreditNotesForDealer(userId: string, filters?: { financialYear?: string; quarter?: string }) { const dealerCode = await getDealerCodeForUser(userId); if (!dealerCode) { return { rows: [], total: 0 }; } // If DB migrations for TRNS uniq no are not applied yet, do not break listing. // We'll return sapResponseAvailable=false in that case. let hasTrnsUniqNoColumn = true; try { await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT }); } catch { hasTrnsUniqNoColumn = false; } const whereSubmission: any = { dealerCode }; if (filters?.financialYear) whereSubmission.financialYear = filters.financialYear; if (filters?.quarter) whereSubmission.quarter = filters.quarter; const submissions = await Form16aSubmission.findAll({ where: whereSubmission, attributes: ['id', 'requestId', 'form16aNumber', 'financialYear', 'quarter', 'status', 'submittedDate'], }); const submissionIds = submissions.map((s) => s.id); if (submissionIds.length === 0) { return { rows: [], total: 0 }; } const { rows, count } = await Form16CreditNote.findAndCountAll({ where: { submissionId: { [Op.in]: submissionIds } }, include: [ { model: Form16aSubmission, as: 'submission', attributes: ['id', 'requestId', 'form16aNumber', 'financialYear', 'quarter', 'status'], }, ], order: [['issueDate', 'DESC'], ['createdAt', 'DESC']], }); const noteIds = rows.map((r) => r.id); let sapSet = new Set(); if (hasTrnsUniqNoColumn && noteIds.length) { try { const creditNotes = await Form16CreditNote.findAll({ where: { id: { [Op.in]: noteIds } }, attributes: ['id', 'creditNoteNumber'], raw: true, }) as any[]; const creditNumbers = creditNotes.map((n) => n.creditNoteNumber).filter(Boolean); const sapRows = await (Form16SapResponse as any).findAll({ where: { tdsTransId: { [Op.in]: creditNumbers } }, attributes: ['tdsTransId'], raw: true, }); const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId))); sapSet = new Set(creditNotes.filter((n) => available.has(String(n.creditNoteNumber))).map((n) => Number(n.id))); } catch (e: any) { logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e); sapSet = new Set(); } } const dealer = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, attributes: ['dealership', 'dealerPrincipalName'], }); const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode; return { rows: rows.map((r) => ({ id: r.id, creditNoteNumber: r.creditNoteNumber, sapDocumentNumber: r.sapDocumentNumber, sapResponseAvailable: sapSet.has(r.id), amount: r.amount, issueDate: r.issueDate, financialYear: r.financialYear, quarter: r.quarter, status: r.status, remarks: r.remarks, dealerCode, dealerName, submission: r.submission ? { requestId: r.submission.requestId, form16aNumber: r.submission.form16aNumber, financialYear: r.submission.financialYear, quarter: r.submission.quarter, status: r.submission.status, } : null, })), total: count, summary: { totalCreditNotes: count, totalAmount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0), activeDealersCount: 1, }, }; } export interface CreateForm16SubmissionBody { /** * Optional override for RE/UAT users who are not mapped as a dealer by email. * If user is a dealer, this (when provided) must match the resolved dealerCode. */ dealerCode?: string; financialYear: string; quarter: string; form16aNumber: string; tdsAmount: number; totalAmount: number; tanNumber: string; deductorName: string; version?: number; /** Raw OCR extracted JSON for audit/support (optional). */ ocrExtractedData?: Record | null; } export interface CreateForm16SubmissionResult { requestId: string; requestNumber: string; submissionId: number; /** Set when 26AS matching runs synchronously: 'success' | 'failed' | 'resubmission_needed' | 'duplicate' */ validationStatus?: string; /** Credit note number when validationStatus === 'success' */ creditNoteNumber?: string | null; /** Message for dealer when validation failed / resubmission needed / duplicate */ validationNotes?: string; } /** * Get next version number for dealer + FY + quarter (for versioning Form 16 uploads per FY+quarter). */ async function getNextVersionForDealerFyQuarter(dealerCode: string, financialYear: string, quarter: string): Promise { const rows = await Form16aSubmission.findAll({ where: { dealerCode, financialYear, quarter }, attributes: ['version'], order: [['version', 'DESC']], limit: 1, }); const maxVersion = rows.length > 0 ? Math.max(0, ...(rows as any[]).map((r) => (r.version ?? 1))) : 0; return maxVersion + 1; } /** * Check if there is already an active (non-withdrawn) credit note for this dealer + FY + quarter. * Optional fyAlternates and quarterAlternates allow matching submissions stored with raw/alternate formats. */ async function hasActiveCreditNoteForDealerFyQuarter( dealerCode: string, financialYear: string, quarter: string, alternates?: { fyAlternates?: string[]; quarterAlternates?: string[] } ): Promise { const fySet = [...new Set([financialYear, ...(alternates?.fyAlternates || [])].filter(Boolean))]; const qSet = [...new Set([quarter, ...(alternates?.quarterAlternates || [])].filter(Boolean))]; const submissions = await Form16aSubmission.findAll({ where: { dealerCode, financialYear: fySet.length ? { [Op.in]: fySet } : financialYear, quarter: qSet.length ? { [Op.in]: qSet } : quarter, }, attributes: ['id'], }); if (submissions.length === 0) return false; const submissionIds = submissions.map((s) => s.id); const activeNote = await Form16CreditNote.findOne({ where: { submissionId: { [Op.in]: submissionIds }, status: { [Op.ne]: 'withdrawn' }, }, attributes: ['id'], }); return !!activeNote; } /** * Normalize financial year to 26AS format (e.g. "2024-25"). * Handles: "2024-25", "24-25", "FY 2024-25", "FY2024-25". */ function normalizeFinancialYear(raw: string): string { const s = (raw || '').trim().replace(/^FY\s*/i, ''); if (!s) return ''; const m = s.match(/^(\d{2,4})-(\d{2})$/); if (m) { const start = m[1].length === 2 ? 2000 + parseInt(m[1], 10) : parseInt(m[1], 10); return `${start}-${m[2]}`; } return s; } /** * Normalize quarter to 26AS format (Q1, Q2, Q3, Q4). * Handles: "Q1", "1", "Quarter 1", "Q 1". */ function normalizeQuarter(raw: string): string { const s = (raw || '').trim().toUpperCase().replace(/\s+/g, ''); if (/^Q?[1-4]$/.test(s)) return s.length === 1 ? `Q${s}` : s; const m = s.match(/QUARTER?([1-4])/i); if (m) return `Q${m[1]}`; return (raw || '').trim() || ''; } /** * Assessment Year from Financial Year (Indian income tax): FY 2024-25 → AY 2025-26. */ function financialYearToAssessmentYear(financialYear: string): string { const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim(); const m = /^(\d{4})-(\d{2})$/.exec(fy); if (!m) return fy.replace(/[^\w.-]/g, '_').slice(0, 24) || 'AY'; const y1 = parseInt(m[1], 10); const ayStart = y1 + 1; const ayEnd2 = (y1 + 2) % 100; return `${ayStart}-${String(ayEnd2).padStart(2, '0')}`; } function sanitizeForm16PdfDeductorSegment(text: string, maxLen: number): string { let s = String(text || '') .replace(/[\r\n]+/g, ' ') .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') .replace(/\s+/g, ' ') .trim(); if (!s) return 'Deductor'; if (s.length > maxLen) s = s.slice(0, maxLen).trim(); return s; } function sanitizeForm16PdfCertSegment(text: string): string { const t = String(text || '').trim().replace(/[^A-Za-z0-9_-]/g, ''); return t || 'CERT'; } /** * PDF file name after successful 26AS match + credit note: * [TAN]_[Assessment Year]_[Quarter]_[Name and address of deductor]_[Certificate].pdf */ function buildForm16CreditNoteSuccessPdfFileName(sub: Form16aSubmission): string { const tan = normalizeTanNumber(String(sub.tanNumber || '')) .replace(/[^A-Z0-9]/gi, '') .toUpperCase() || 'TAN'; const fy = normalizeFinancialYear(String(sub.financialYear || '').trim()) || String(sub.financialYear || '').trim(); const ay = financialYearToAssessmentYear(fy); const qRaw = String(sub.quarter || '').trim(); const q = normalizeQuarter(qRaw) || qRaw || 'QX'; const ocr = (sub.ocrExtractedData || {}) as Record; let nameAddr = String(ocr.nameAndAddressOfDeductor || '').trim(); if (!nameAddr) { const dn = String(ocr.deductorName || sub.deductorName || '').trim(); const da = String(ocr.deductorAddress || '').trim(); nameAddr = [dn, da].filter(Boolean).join(', '); } if (!nameAddr) nameAddr = String(sub.deductorName || 'Deductor').trim(); let deductorSan = sanitizeForm16PdfDeductorSegment(nameAddr, 150); const certSan = sanitizeForm16PdfCertSegment(String(sub.form16aNumber || '')); let base = `${tan}_${ay}_${q}_${deductorSan}_${certSan}`; if (base.length > 220) { const over = base.length - 220; const shorter = Math.max(20, deductorSan.length - over - 5); deductorSan = sanitizeForm16PdfDeductorSegment(nameAddr, shorter); base = `${tan}_${ay}_${q}_${deductorSan}_${certSan}`; } return `${base}.pdf`; } async function renameForm16SubmissionPdfAfterCreditNote(params: { submissionId: number; requestId: string; oldRelativePath: string; }): Promise { const { submissionId, requestId, oldRelativePath } = params; const oldPathNorm = String(oldRelativePath || '').replace(/\\/g, '/').trim(); if (!oldPathNorm || oldPathNorm.includes('..') || !oldPathNorm.startsWith('requests/')) { logger.warn('[Form16] Skip PDF rename: invalid storage path', { oldPathNorm }); return; } const sub = await Form16aSubmission.findByPk(submissionId); if (!sub) return; const newFileName = buildForm16CreditNoteSuccessPdfFileName(sub); try { const result = await gcsStorageService.renameRequestDocumentFile({ oldRelativePath: oldPathNorm, newFileName, }); await sub.update({ documentUrl: result.storageUrl }); const doc = await Document.findOne({ where: { requestId, filePath: oldPathNorm }, }); if (doc) { const fp = result.filePath.length <= 500 ? result.filePath : result.filePath.slice(0, 500); const su = result.storageUrl.length <= 500 ? result.storageUrl : undefined; await doc.update({ fileName: newFileName.slice(0, 255), originalFileName: newFileName.slice(0, 255), filePath: fp, storageUrl: su, }); } else { logger.warn('[Form16] PDF renamed; documents row not found for path', { requestId, oldPathNorm }); } logger.info('[Form16] Form 16A PDF renamed after credit note', { submissionId, newFileName }); } catch (e: any) { logger.error('[Form16] Failed to rename Form 16 PDF after credit note:', e?.message || e); } } /** Compact FY for Form 16 note numbers: "2024-25" -> "24-25" */ function form16FyCompact(financialYear: string): string { const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim(); if (!fy) return ''; const m = fy.match(/^(\d{2,4})-(\d{2})$/); if (m) { const start = m[1].length === 2 ? m[1] : m[1].slice(-2); return `${start}-${m[2]}`; } return fy; } /** FY Short for 16-char ID: "2025-26" -> "26" */ function form16FyShort(financialYear: string): string { const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim(); const m = fy.match(/-(\d{2})$/); return m ? m[1] : (fy.length >= 2 ? fy.slice(-2) : 'XX'); } /** Get next sequence for Form 16 credit note (4 digits) */ async function getNextForm16NoteSequence( dealerCode: string, financialYear: string, quarter: string ): Promise { const dc = padDealerCode(dealerCode); const fy = form16FyShort(financialYear); const q = normalizeQuarter(quarter); const prefix = `CN${dc}${fy}${q}`; const lastNote = (await Form16CreditNote.findOne({ where: { creditNoteNumber: { [Op.like]: `${prefix}%` }, }, order: [['creditNoteNumber', 'DESC']], attributes: ['creditNoteNumber'], })) as any; let seq = 1; if (lastNote?.creditNoteNumber) { const lastSeqStr = lastNote.creditNoteNumber.slice(-4); const lastSeq = parseInt(lastSeqStr, 10); if (!isNaN(lastSeq)) { seq = lastSeq + 1; } } return seq.toString().padStart(4, '0'); } /** * Sanitize certificate number for use in note numbers (alphanumeric and single hyphens only). */ function sanitizeCertificateNumber(raw: string): string { const s = (raw || '').trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9-]/g, '') || ''; return s || 'XX'; } /** * Form 16 credit note number (16 chars): CN{dc}{fy}{q}{seq} * Example: CN00628226Q20001 */ export async function formatForm16CreditNoteNumber( dealerCode: string, financialYear: string, quarter: string ): Promise { const dc = padDealerCode(dealerCode); const fy = form16FyShort(financialYear); const q = normalizeQuarter(quarter); const seq = await getNextForm16NoteSequence(dealerCode, financialYear, quarter); return `CN${dc}${fy}${q}${seq}`; } /** * Form 16 debit note number (16 chars): DN{dc}{fy}{q}{seq} * Usually matches the sequence of the credit note being reversed. */ export function formatForm16DebitNoteNumber( creditNoteNumber: string ): string { if (creditNoteNumber.startsWith('CN')) { return creditNoteNumber.replace(/^CN/, 'DN'); } return creditNoteNumber.replace(/^.{2}/, 'DN'); // fallback } /** * Match submission against latest 26AS aggregated amount (quarter-level). Only Section 194Q, Booking F/O. * Reject if no 26AS data, amount mismatch, or duplicate (already settled with same amount). * On match: create credit note, ledger entry, set quarter status SETTLED. */ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise<{ validationStatus: string; creditNoteNumber?: string | null; validationNotes?: string }> { const sub = submission as any; const tanNumberRaw = (sub.tanNumber || '').toString().trim(); const tanNumber = normalizeTanNumber(tanNumberRaw); const tdsAmount = parseFloat(sub.tdsAmount) || 0; if (!tanNumber || tanNumber.length < 10 || tdsAmount <= 0) { logger.warn( `[Form16] 26AS MATCH RESULT: RESUBMISSION_NEEDED – OCR incomplete. Form 16A: TAN=${tanNumber || '(missing)'}, TDS amount=${tdsAmount}, FY=${(sub.financialYear || '').toString().trim() || '(missing)'}, Quarter=${(sub.quarter || '').toString().trim() || '(missing)'}. No 26AS check performed.` ); await submission.update({ validationStatus: 'resubmission_needed', validationNotes: 'OCR data incomplete/invalid (TAN or TDS amount missing). Please resubmit Form 16 or contact RE for manual approval.', }); return { validationStatus: 'resubmission_needed' }; } const financialYearRaw = (sub.financialYear || '').trim(); const quarterRaw = (sub.quarter || '').trim(); let financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw; let quarter = normalizeQuarter(quarterRaw) || quarterRaw; if (!financialYear || !quarter) { logger.warn( `[Form16] 26AS MATCH RESULT: RESUBMISSION_NEEDED – FY or Quarter missing. Form 16A: FY=${financialYearRaw || '(missing)'}, Quarter=${quarterRaw || '(missing)'}, TAN=${tanNumber}, TDS amount=${tdsAmount}. No 26AS check performed.` ); await submission.update({ validationStatus: 'resubmission_needed', validationNotes: 'Financial year or quarter missing. Please resubmit Form 16.', }); return { validationStatus: 'resubmission_needed' }; } const extracted = (sub.ocrExtractedData || {}) as Record; const submittedPan = (extracted.panOfDeductee as string) || (extracted.deducteePan as string) || (extracted.panNumber as string) || null; const normalizedSubmittedPan = submittedPan ? String(submittedPan).trim().toUpperCase() : null; const submittedAmountPaid = toNumberOrNull(extracted.totalAmountPaid ?? sub.totalAmount); const submittedTaxDeducted = toNumberOrNull(extracted.totalTaxDeducted ?? sub.tdsAmount); const submittedTdsDeposited = toNumberOrNull(extracted.totalTdsDeposited ?? sub.tdsAmount); const submittedTransactionDate = normalizeDateOnly(extracted.transactionDate); const submittedLastUpdatedOn = normalizeDateOnly(extracted.certificateDate ?? extracted.lastUpdatedOn ?? extracted.lastUpdatedDate); // Mandatory for matching: Form 16A "Last updated on" must be extracted and matched to 26AS booking date. if (!submittedLastUpdatedOn) { const msg = 'OCR could not extract "Last updated on" date from Form 16A. Please resubmit a clear document.'; await submission.update({ validationStatus: 'resubmission_needed', validationNotes: msg, }); return { validationStatus: 'resubmission_needed', validationNotes: msg, }; } // Latest 26AS upload rows for the same TAN + FY + Quarter. let latestRows = await getLatest26asRowsForQuarter(tanNumber, financialYear, quarter); // If OCR extracted FY/Quarter incorrectly, derive FY/Quarter from OCR dates and retry. if (latestRows.length === 0) { const derivedFromTx = deriveFyAndQuarterFromDateOnly(submittedTransactionDate); const derivedFromBooking = deriveFyAndQuarterFromDateOnly(submittedLastUpdatedOn); const derived = derivedFromTx || derivedFromBooking; if (derived && (derived.financialYear !== financialYear || derived.quarter !== quarter)) { const altRows = await getLatest26asRowsForQuarter(tanNumber, derived.financialYear, derived.quarter); if (altRows.length > 0) { logger.warn( `[Form16] FY/Quarter retry using OCR date-derived period. TAN=${tanNumber}. OCR FY=${financialYear},Q=${quarter} → derived FY=${derived.financialYear},Q=${derived.quarter}.` ); financialYear = derived.financialYear; quarter = derived.quarter; latestRows = altRows; await submission.update({ financialYear, quarter }); } } } const aggregated26as = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0); const hasPanColumn = await isPanNumberColumnPresent(); if (normalizedSubmittedPan && hasPanColumn && latestRows.length > 0) { const hasPanMatch = latestRows.some((r) => r.panNumber && r.panNumber === normalizedSubmittedPan); if (!hasPanMatch) { logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – PAN mismatch. TAN=${tanNumber}, PAN(Form16A)=${normalizedSubmittedPan}, FY=${financialYear}, Quarter=${quarter}.` ); await submission.update({ validationStatus: 'failed', validationNotes: `PAN mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A PAN: ${normalizedSubmittedPan}.`, }); return { validationStatus: 'failed', validationNotes: 'PAN mismatch with latest 26AS.' }; } } else if (normalizedSubmittedPan && !hasPanColumn) { logger.warn( `[Form16] PAN strict check skipped because DB column tds_26as_entries.pan_number is missing. TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}.` ); } if (latestRows.length === 0) { // Provide actionable debug info so we can see why latestRows became empty // (section_code not 194Q, status not F/O, FY/Quarter mismatch, or TAN mismatch). let debugNotes = ''; try { const dbg = await get26asCoverageDebug(tanNumber, financialYear, quarter); debugNotes = ` | DEBUG 26AS coverage: total=${dbg.totalRows}, matching(194Q & F/O)=${dbg.matchingRows}. Top breakdown: ${dbg.breakdownLines || '(none)'}`; } catch (e: any) { debugNotes = ` | DEBUG 26AS coverage query failed: ${e?.message || String(e)}`; } logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – No 26AS data. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS: no records for this TAN/FY/Quarter (Section 194Q, Booking F/O).${debugNotes}` ); await submission.update({ validationStatus: 'failed', validationNotes: `No 26AS data found for TAN no - ${tanNumber}, financial year and quarter. Please ensure 26AS has been uploaded for this period.${debugNotes}`, }); return { validationStatus: 'failed', validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.${debugNotes}`, }; } // Validate against quarter-level aggregate from latest upload. // 26AS has many transaction lines; we compare submitted totals against aggregated totals. const aggregatedAmountPaid = latestRows.reduce((sum, r) => sum + (r.amountPaid || 0), 0); const aggregatedTaxDeducted = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0); const aggregatedTdsDeposited = latestRows.reduce((sum, r) => sum + (r.totalTdsDeposited ?? r.taxDeducted ?? 0), 0); if ( submittedAmountPaid != null && Math.abs(submittedAmountPaid - aggregatedAmountPaid) > AMOUNT_MATCH_TOLERANCE ) { logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – Amount paid mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A amountPaid=${submittedAmountPaid}, 26AS latest aggregate amountPaid=${aggregatedAmountPaid}.` ); await submission.update({ validationStatus: 'failed', validationNotes: `Amount paid mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A amount paid: ${submittedAmountPaid}. Latest 26AS aggregated amount paid for this quarter: ${aggregatedAmountPaid}.`, }); return { validationStatus: 'failed', validationNotes: 'Amount paid mismatch with latest 26AS.' }; } if ( submittedTaxDeducted != null && Math.abs(submittedTaxDeducted - aggregatedTaxDeducted) > AMOUNT_MATCH_TOLERANCE ) { logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – Tax deducted mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A taxDeducted=${submittedTaxDeducted}, 26AS latest aggregate taxDeducted=${aggregatedTaxDeducted}.` ); await submission.update({ validationStatus: 'failed', validationNotes: `Tax deducted mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A tax deducted: ${submittedTaxDeducted}. Latest 26AS aggregated tax deducted for this quarter: ${aggregatedTaxDeducted}.`, }); return { validationStatus: 'failed', validationNotes: 'Tax deducted mismatch with latest 26AS.' }; } if ( submittedTdsDeposited != null && Math.abs(submittedTdsDeposited - aggregatedTdsDeposited) > AMOUNT_MATCH_TOLERANCE ) { logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – TDS deposited mismatch. TAN=${tanNumber}, PAN=${submittedPan || '(not available)'}, FY=${financialYear}, Quarter=${quarter}. Form16A tdsDeposited=${submittedTdsDeposited}, 26AS latest aggregate tdsDeposited=${aggregatedTdsDeposited}.` ); await submission.update({ validationStatus: 'failed', validationNotes: `TDS deposited mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A TDS deposited: ${submittedTdsDeposited}. Latest 26AS aggregated TDS deposited for this quarter: ${aggregatedTdsDeposited}.`, }); return { validationStatus: 'failed', validationNotes: 'TDS deposited mismatch with latest 26AS.' }; } // Optional date checks: if OCR extracted transaction/booking date, at least one latest-upload row should contain that date. if (submittedTransactionDate) { const hasTxDate = latestRows.some((r) => normalizeDateOnly(r.transactionDate) === submittedTransactionDate); if (!hasTxDate) { await submission.update({ validationStatus: 'failed', validationNotes: `Transaction date mismatch with latest 26AS for TAN no - ${tanNumber}. No latest 26AS transaction found with date ${submittedTransactionDate}.`, }); return { validationStatus: 'failed', validationNotes: 'Transaction date mismatch with latest 26AS.' }; } } // Match Form 16A "Last updated on" against 26AS "Date of Booking" const hasBookingDate = latestRows.some((r) => normalizeDateOnly(r.dateOfBooking) === submittedLastUpdatedOn); if (!hasBookingDate) { await submission.update({ validationStatus: 'failed', validationNotes: `Last updated on date mismatch with latest 26AS booking date for TAN no - ${tanNumber}. Form 16A last updated on: ${submittedLastUpdatedOn}.`, }); return { validationStatus: 'failed', validationNotes: 'Last updated on date mismatch with latest 26AS booking date.' }; } if (Math.abs(tdsAmount - aggregated26as) > AMOUNT_MATCH_TOLERANCE) { logger.warn( `[Form16] 26AS MATCH RESULT: FAILED – Amount mismatch. Form 16A TDS amount=${tdsAmount} | 26AS aggregated amount (quarter)=${aggregated26as} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. Difference=${Math.abs(tdsAmount - aggregated26as).toFixed(2)}.` ); await submission.update({ validationStatus: 'failed', validationNotes: `Amount mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A TDS amount: ${tdsAmount}. Latest 26AS aggregated amount for this quarter: ${aggregated26as}. Please submit Form 16 with correct data.`, }); return { validationStatus: 'failed', validationNotes: `Amount mismatch with latest 26AS for TAN no - ${tanNumber}. Please verify the certificate and resubmit.`, }; } // Duplicate check: quarter already SETTLED with an active credit for this tan+fy+quarter const qStatus = await getQuarterStatus(tanNumber, financialYear, quarter); if (qStatus && qStatus.status === 'SETTLED' && qStatus.lastCreditNoteId) { const lastCn = await Form16CreditNote.findByPk(qStatus.lastCreditNoteId, { attributes: ['amount'] }); const lastAmount = lastCn ? parseFloat((lastCn as any).amount as string) : 0; if (Math.abs(lastAmount - tdsAmount) <= AMOUNT_MATCH_TOLERANCE) { logger.warn( `[Form16] 26AS MATCH RESULT: DUPLICATE – Quarter already settled. Form 16A TDS amount=${tdsAmount} | Existing credit note amount=${lastAmount} | TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}. No new credit note issued.` ); await submission.update({ validationStatus: 'duplicate', validationNotes: 'This quarter is already settled with the same amount. Duplicate submission rejected. No new credit note issued.', }); return { validationStatus: 'duplicate', validationNotes: 'Credit note already issued for this FY and quarter.' }; } } // Dealer code, certificate number and version from submission (for revised 26AS / Form 16 versioning) const dealerCode = (sub.dealerCode || '').toString().trim(); const certificateNumber = (sub.form16aNumber || '').toString().trim(); const version = typeof sub.version === 'number' && sub.version >= 1 ? sub.version : 1; const cnNumber = await formatForm16CreditNoteNumber(dealerCode, financialYear, quarter); const now = new Date(); const creditNote = await Form16CreditNote.create({ submissionId: submission.id, creditNoteNumber: cnNumber, amount: tdsAmount, issueDate: now, financialYear, quarter, status: 'issued', remarks: 'Auto-generated on 26AS match (quarter aggregate).', createdAt: now, updatedAt: now, }); await addLedgerEntry({ tanNumber, financialYear, quarter, entryType: 'CREDIT', amount: tdsAmount, creditNoteId: creditNote.id, form16SubmissionId: submission.id, }); await setQuarterStatusSettled(tanNumber, financialYear, quarter, creditNote.id); await submission.update({ validationStatus: 'success', validationNotes: null, }); // Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16 try { const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`; await creditNote.update({ trnsUniqNo }); const docDate = now.toISOString().slice(0, 10).replace(/-/g, ''); const fyCompact = form16FyCompact(financialYear) || ''; const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : ''; const csvRow: Record = { TRNS_UNIQ_NO: trnsUniqNo, TDS_TRNS_ID: cnNumber, DEALER_CODE: padDealerCode(dealerCode), TDS_TRNS_DOC_TYPE: 'ZTDS', DLR_TAN_NO: tanNumber, 'FIN_YEAR&QUARTER': finYearAndQuarter, DOC_DATE: docDate, TDS_AMT: formatForm16IncomingCsvTdsAmt(tdsAmount, 'credit'), TDS_CERTIFICATE_NO: certificateNumber, }; const fileName = `${cnNumber}.csv`; await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit'); logger.info(`[Form16] Credit note CSV pushed to WFM FORM16: ${cnNumber}`); } catch (csvErr: any) { logger.error('[Form16] Failed to push credit note CSV to WFM FORM16:', csvErr?.message || csvErr); // Do not fail the flow; credit note and ledger are already created } logger.info( `[Form16] 26AS MATCH RESULT: SUCCESS – Credit note issued. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS aggregated=${aggregated26as} | Credit note=${cnNumber}.` ); return { validationStatus: 'success', creditNoteNumber: cnNumber }; } export async function createSubmission( userId: string, fileBuffer: Buffer, originalName: string, body: CreateForm16SubmissionBody, userEmail?: string | null ): Promise { // Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note. const resolvedDealerCode = await getDealerCodeForUser(userId, userEmail); const overrideDealerCode = (body.dealerCode || '').trim() || null; const dealerCode = resolvedDealerCode || overrideDealerCode; // Frontend may not provide dealerCode (dealer code may be absent in Form16/26AS), // so we must not hard-fail. Use a deterministic fallback so note generation can continue. const effectiveDealerCode = dealerCode || '000000'; if (!dealerCode) { logger.warn( '[Form16] dealerCode not resolved for submission; using fallback dealerCode="000000". Matching uses TAN/FY/Quarter, but note numbering/CSV dealer code will be for fallback.' ); } const version = await getNextVersionForDealerFyQuarter(effectiveDealerCode, body.financialYear, body.quarter); const requestNumber = await generateRequestNumber(); const title = version > 1 ? `Form 16A - ${body.financialYear} ${body.quarter} (v${version})` : `Form 16A - ${body.financialYear} ${body.quarter}`; const description = version > 1 ? `Form 16A TDS certificate submission. Deductor: ${body.deductorName}. Version ${version}.` : `Form 16A TDS certificate submission. Deductor: ${body.deductorName}.`; const workflow = await WorkflowRequest.create({ requestNumber, initiatorId: userId, templateType: 'FORM_16', workflowType: 'FORM_16', title, description, priority: Priority.STANDARD, status: WorkflowStatus.PENDING, currentLevel: 1, totalLevels: 1, totalTatHours: 0, isDraft: false, isDeleted: false, isPaused: false, }); const requestId = (workflow as any).requestId; let documentUrl: string; let uploadFilePath: string; let uploadFileName: string; try { const mimeType = (originalName || '').toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'application/octet-stream'; const result = await gcsStorageService.uploadFileWithFallback({ buffer: fileBuffer, originalName: originalName || 'form16a.pdf', mimeType, requestNumber, fileType: 'documents', }); documentUrl = result.storageUrl; uploadFilePath = result.filePath || result.storageUrl || ''; uploadFileName = result.fileName || (originalName || 'form16a.pdf'); } catch (err: any) { logger.error('[Form16] Document upload failed:', err); await workflow.destroy(); throw new Error('Failed to upload Form 16A document. Please try again.'); } const now = new Date(); // Truncate strings to column max lengths to avoid Sequelize validation error const safeStr = (s: string, max: number) => (s ?? '').slice(0, max); const submission = await Form16aSubmission.create({ requestId, dealerCode: safeStr(effectiveDealerCode, 50), form16aNumber: safeStr(body.form16aNumber, 50), financialYear: safeStr(body.financialYear, 20), quarter: safeStr(body.quarter, 10), version, tdsAmount: Number(body.tdsAmount) || 0, totalAmount: Number(body.totalAmount) || 0, tanNumber: safeStr(body.tanNumber, 20), deductorName: safeStr(body.deductorName, 255), documentUrl, ocrExtractedData: body.ocrExtractedData ?? undefined, status: 'pending', submittedDate: now, createdAt: now, updatedAt: now, }); // Create a row in documents table so the Form 16 uploaded file appears in the request's Documents tab (dealer, RE, admin). const origName = (originalName || 'form16a.pdf').slice(0, 255); const ext = (origName.includes('.') ? origName.split('.').pop() : 'pdf') || 'pdf'; const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const docStorageUrl = documentUrl.length <= 500 ? documentUrl : null; try { await Document.create({ requestId, uploadedBy: userId, fileName: uploadFileName.slice(0, 255), originalFileName: origName, fileType: 'application/pdf', fileExtension: ext.slice(0, 10), fileSize: fileBuffer.length, filePath: uploadFilePath.slice(0, 500), storageUrl: docStorageUrl ?? undefined, mimeType: 'application/pdf', checksum, isGoogleDoc: false, category: 'SUPPORTING', version: 1, isDeleted: false, downloadCount: 0, uploadedAt: now, }); } catch (docErr: any) { logger.error('[Form16] Failed to create document row for Documents tab:', docErr); // Do not fail the submission; form16a_submissions.document_url is already set } try { const initiator = await User.findByPk(userId, { attributes: ['userId', 'displayName', 'email'], raw: true }) as { userId: string; displayName?: string; email?: string } | null; const initiatorName = initiator?.displayName || initiator?.email || 'Dealer'; await activityService.log({ requestId, type: 'submitted', user: { userId, name: initiatorName }, timestamp: now.toISOString(), action: 'Form 16A submitted', details: `Form 16A certificate for ${body.financialYear} ${body.quarter} submitted by ${initiatorName}.${version > 1 ? ` (Version ${version})` : ''}`, }); } catch (actErr: any) { logger.warn('[Form16] Failed to log activity:', actErr); } logger.info('[Form16] Submission created', { requestId, requestNumber, submissionId: submission.id, dealerCode, }); let validationStatus: string | undefined; let creditNoteNumber: string | null | undefined; let validationNotes: string | undefined; try { const matchResult = await run26asMatchAndCreditNote(submission); validationStatus = matchResult.validationStatus; creditNoteNumber = matchResult.creditNoteNumber ?? null; validationNotes = matchResult.validationNotes ?? undefined; logger.info( `[Form16] Submission match complete: requestId=${requestId}, status=${validationStatus}${validationNotes ? `, notes=${validationNotes}` : ''}${creditNoteNumber ? `, creditNote=${creditNoteNumber}` : ''}.` ); // When credit note is issued (completed), set workflow status to CLOSED so the request appears on Closed requests page if (validationStatus === 'success' && creditNoteNumber) { const workflow = await WorkflowRequest.findOne({ where: { requestId }, attributes: ['requestId', 'status'] }); if (workflow && (workflow as any).status !== WorkflowStatus.CLOSED) { await workflow.update({ status: WorkflowStatus.CLOSED }); logger.info(`[Form16] Request ${requestId} set to CLOSED (credit note issued).`); } await renameForm16SubmissionPdfAfterCreditNote({ submissionId: submission.id, requestId, oldRelativePath: uploadFilePath.replace(/\\/g, '/'), }); } } catch (err: any) { logger.error( `[Form16] 26AS match/credit note error for requestId=${requestId}: Form 16A TAN=${(submission as any).tanNumber}, FY=${(submission as any).financialYear}, Quarter=${(submission as any).quarter}, TDS amount=${(submission as any).tdsAmount}. Error:`, err ); validationStatus = 'failed'; const rawMessage = err?.message || 'Validation error.'; validationNotes = /notNull|Violation|Sequelize|ECONNREFUSED|database/i.test(rawMessage) ? 'Failed - data mismatch with 26AS, submit the Form 16 with correct data.' : rawMessage; await submission.update({ validationStatus: 'failed', validationNotes, }); } return { requestId, requestNumber, submissionId: submission.id, validationStatus, creditNoteNumber, validationNotes, }; } // ---------- RE-only: list all credit notes (all dealers) ---------- export async function listAllCreditNotesForRe(filters?: { financialYear?: string; quarter?: string }) { const whereNote: any = {}; const whereSubmission: any = {}; if (filters?.financialYear) whereNote.financialYear = filters.financialYear; if (filters?.quarter) whereNote.quarter = filters.quarter; const { rows, count } = await Form16CreditNote.findAndCountAll({ where: Object.keys(whereNote).length ? whereNote : undefined, include: [ { model: Form16aSubmission, as: 'submission', attributes: ['id', 'requestId', 'dealerCode', 'form16aNumber', 'financialYear', 'quarter', 'status', 'submittedDate'], where: Object.keys(whereSubmission).length ? whereSubmission : undefined, required: true, }, ], order: [['issueDate', 'DESC'], ['createdAt', 'DESC']], }); let hasTrnsUniqNoColumn = true; try { await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT }); } catch { hasTrnsUniqNoColumn = false; } const noteIds = rows.map((r) => r.id); let sapSet = new Set(); if (hasTrnsUniqNoColumn && noteIds.length) { try { const creditNotes = await Form16CreditNote.findAll({ where: { id: { [Op.in]: noteIds } }, attributes: ['id', 'creditNoteNumber'], raw: true, }) as any[]; const creditNumbers = creditNotes.map((n) => n.creditNoteNumber).filter(Boolean); const sapRows = await (Form16SapResponse as any).findAll({ where: { tdsTransId: { [Op.in]: creditNumbers } }, attributes: ['tdsTransId'], raw: true, }); const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId))); sapSet = new Set(creditNotes.filter((n) => available.has(String(n.creditNoteNumber))).map((n) => Number(n.id))); } catch (e: any) { logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e); sapSet = new Set(); } } const dealerCodes = [...new Set(rows.map((r) => (r as any).submission?.dealerCode).filter(Boolean))] as string[]; const dealers = dealerCodes.length ? await Dealer.findAll({ where: { isActive: true, [Op.or]: [ ...dealerCodes.map((c) => ({ salesCode: c })), ...dealerCodes.map((c) => ({ dlrcode: c })), ], }, attributes: ['salesCode', 'dlrcode', 'dealership', 'dealerPrincipalName'], }) : []; const codeToName = new Map(); for (const d of dealers) { const code = (d as any).salesCode || (d as any).dlrcode; if (code) codeToName.set(code, (d as any).dealership || (d as any).dealerPrincipalName || code); } const totalAmount = rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0); return { rows: rows.map((r) => { const dc = (r as any).submission?.dealerCode; return { id: r.id, creditNoteNumber: r.creditNoteNumber, sapDocumentNumber: r.sapDocumentNumber, sapResponseAvailable: sapSet.has(r.id), amount: r.amount, issueDate: r.issueDate, financialYear: r.financialYear, quarter: r.quarter, status: r.status, remarks: r.remarks, dealerCode: dc, dealerName: (dc && codeToName.get(dc)) || dc || '—', submission: (r as any).submission ? { requestId: (r as any).submission.requestId, dealerCode: (r as any).submission.dealerCode, form16aNumber: (r as any).submission.form16aNumber, financialYear: (r as any).submission.financialYear, quarter: (r as any).submission.quarter, status: (r as any).submission.status, submittedDate: (r as any).submission.submittedDate, } : null, }; }), total: count, summary: { totalCreditNotes: count, totalAmount, activeDealersCount: dealerCodes.length, }, }; } /** List credit notes: for dealer returns dealer's only; for RE returns all. */ export async function listCreditNotesDealerOrRe(userId: string, filters?: { financialYear?: string; quarter?: string }) { const dealerCode = await getDealerCodeForUser(userId); if (dealerCode) { return listCreditNotesForDealer(userId, filters); } return listAllCreditNotesForRe(filters); } // ---------- RE-only: list all debit notes (all dealers) ---------- export async function listAllDebitNotesForRe(filters?: { financialYear?: string; quarter?: string }) { const whereDebit: any = {}; if (filters?.financialYear) whereDebit.financialYear = filters.financialYear; if (filters?.quarter) whereDebit.quarter = filters.quarter; const { rows, count } = await Form16DebitNote.findAndCountAll({ include: [ { model: Form16CreditNote, as: 'creditNote', attributes: ['id', 'creditNoteNumber', 'financialYear', 'quarter', 'submissionId'], required: true, where: Object.keys(whereDebit).length ? whereDebit : undefined, include: [ { model: Form16aSubmission, as: 'submission', attributes: ['dealerCode', 'form16aNumber'], required: false, }, ], }, ], order: [['issueDate', 'DESC'], ['createdAt', 'DESC']], }); // Mark which debit notes have ingested SAP response CSV available. const noteIds = rows.map((r: any) => r.id); let sapSet = new Set(); if (noteIds.length) { try { const debitNotes = await Form16DebitNote.findAll({ where: { id: { [Op.in]: noteIds } }, attributes: ['id', 'debitNoteNumber'], raw: true, }) as any[]; const debitNumbers = debitNotes.map((n) => n.debitNoteNumber).filter(Boolean); const sapRows = await (Form16SapResponse as any).findAll({ where: { tdsTransId: { [Op.in]: debitNumbers } }, attributes: ['tdsTransId'], raw: true, }); const available = new Set((sapRows as any[]).map((r) => String(r.tdsTransId))); sapSet = new Set(debitNotes.filter((n) => available.has(String(n.debitNoteNumber))).map((n) => Number(n.id))); } catch (e: any) { logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e); sapSet = new Set(); } } const dealerCodes = [...new Set(rows.map((r: any) => r.creditNote?.submission?.dealerCode).filter(Boolean))] as string[]; const dealers = dealerCodes.length ? await Dealer.findAll({ where: { isActive: true, [Op.or]: [ ...dealerCodes.map((c) => ({ salesCode: c })), ...dealerCodes.map((c) => ({ dlrcode: c })), ], }, attributes: ['salesCode', 'dlrcode', 'dealership', 'dealerPrincipalName'], }) : []; const codeToName = new Map(); for (const d of dealers) { const code = (d as any).salesCode || (d as any).dlrcode; if (code) codeToName.set(code, (d as any).dealership || (d as any).dealerPrincipalName || code); } const totalAmount = rows.reduce((sum: number, r: any) => sum + (Number(r.amount) || 0), 0); return { rows: rows.map((r: any) => { const dc = r.creditNote?.submission?.dealerCode ?? null; return { id: r.id, debitNoteNumber: r.debitNoteNumber, sapDocumentNumber: r.sapDocumentNumber ?? null, sapResponseAvailable: sapSet.has(r.id), amount: r.amount ?? null, issueDate: r.issueDate ?? null, status: r.status ?? null, financialYear: r.creditNote?.financialYear ?? null, quarter: r.creditNote?.quarter ?? null, creditNoteNumber: r.creditNote?.creditNoteNumber ?? null, dealerCode: dc, dealerName: (dc && codeToName.get(dc)) || dc || '—', form16aNumber: r.creditNote?.submission?.form16aNumber ?? null, }; }), total: count, summary: { totalDebitNotes: count, totalAmount, impactedDealersCount: dealerCodes.length, }, }; } /** * List Form 16 submissions for the authenticated dealer (for Pending Submissions page). * Optional filter: status = pending | failed | pending,failed (default: pending,failed). */ export async function listDealerSubmissions( userId: string, filters?: { status?: string; financialYear?: string; quarter?: string } ) { const dealerCode = await getDealerCodeForUser(userId); if (!dealerCode) { return []; } const where: any = { dealerCode }; if (filters?.financialYear) where.financialYear = filters.financialYear; if (filters?.quarter) where.quarter = filters.quarter; const statusFilter = (filters?.status || 'pending,failed,completed').toLowerCase(); const wantPending = statusFilter.includes('pending'); const wantFailed = statusFilter.includes('failed'); const wantCompleted = statusFilter.includes('completed'); const rows = await Form16aSubmission.findAll({ where, attributes: ['id', 'requestId', 'form16aNumber', 'financialYear', 'quarter', 'version', 'status', 'validationStatus', 'validationNotes', 'submittedDate', 'documentUrl', 'totalAmount'], order: [['financialYear', 'DESC'], ['quarter', 'DESC'], ['submittedDate', 'DESC']], }); const submissionIds = rows.map((r) => r.id); const creditNotesList = submissionIds.length ? await Form16CreditNote.findAll({ where: { submissionId: submissionIds }, attributes: ['submissionId', 'creditNoteNumber'], }) : []; const creditNoteBySubmissionId = new Map(); for (const c of creditNotesList as any[]) { if (c.submissionId && c.creditNoteNumber) creditNoteBySubmissionId.set(c.submissionId, c.creditNoteNumber); } const hasNote = new Set(creditNoteBySubmissionId.keys()); /** Display status for Form 16: never show "Pending"; use Completed, Resubmission needed, Duplicate submission, Balance mismatch, Failed, Under review. */ const toDisplayStatus = (hasCreditNote: boolean, validationStatus: string | null, status: string, validationNotes?: string | null): string => { if (hasCreditNote) return 'Completed'; const v = (validationStatus || '').toLowerCase(); const notes = (validationNotes || '').toLowerCase(); if (v === 'resubmission_needed') return 'Resubmission needed'; if (v === 'duplicate') return 'Duplicate'; if (v === 'manually_approved') return 'Completed'; if (v === 'failed' || status === 'failed') { if (notes.includes('mismatch') || notes.includes('26as') || notes.includes('value')) return 'Balance mismatch'; if (notes.includes('partial')) return 'Partial extracted data'; return 'Failed'; } return 'Under review'; }; const list: Array<{ id: number; requestId: string; form16a_number: string; financial_year: string; quarter: string; version: number; version_number?: number; status: string; display_status: string; validation_status: string | null; submitted_date: string | null; total_amount: number | null; credit_note_number: string | null; document_url: string | null; }> = []; for (const r of rows as any[]) { const hasCreditNote = hasNote.has(r.id); const effectiveStatus = hasCreditNote ? 'completed' : (r.validationStatus === 'failed' || r.status === 'failed' ? 'failed' : 'pending'); const displayStatus = toDisplayStatus(hasCreditNote, r.validationStatus ?? null, r.status || '', r.validationNotes); const creditNoteNumber = creditNoteBySubmissionId.get(r.id) ?? null; const totalAmount = r.totalAmount != null ? Number(r.totalAmount) : null; if (wantCompleted && effectiveStatus === 'completed') { list.push({ id: r.id, requestId: r.requestId, form16a_number: r.form16aNumber, financial_year: r.financialYear, quarter: r.quarter, version: r.version ?? 1, version_number: r.version ?? 1, status: effectiveStatus, display_status: displayStatus, validation_status: r.validationStatus ?? null, submitted_date: r.submittedDate ? new Date(r.submittedDate).toISOString() : null, total_amount: totalAmount, credit_note_number: creditNoteNumber, document_url: r.documentUrl ?? null, }); } else if (wantFailed && effectiveStatus === 'failed') { list.push({ id: r.id, requestId: r.requestId, form16a_number: r.form16aNumber, financial_year: r.financialYear, quarter: r.quarter, version: r.version ?? 1, version_number: r.version ?? 1, status: effectiveStatus, display_status: displayStatus, validation_status: r.validationStatus ?? null, submitted_date: r.submittedDate ? new Date(r.submittedDate).toISOString() : null, total_amount: totalAmount, credit_note_number: creditNoteNumber, document_url: r.documentUrl ?? null, }); } else if (wantPending && effectiveStatus === 'pending') { list.push({ id: r.id, requestId: r.requestId, form16a_number: r.form16aNumber, financial_year: r.financialYear, quarter: r.quarter, version: r.version ?? 1, version_number: r.version ?? 1, status: effectiveStatus, display_status: displayStatus, validation_status: r.validationStatus ?? null, submitted_date: r.submittedDate ? new Date(r.submittedDate).toISOString() : null, total_amount: totalAmount, credit_note_number: creditNoteNumber, document_url: r.documentUrl ?? null, }); } } return list; } /** Quarter metadata for pending-quarters: audit start = first day of quarter (Indian FY). */ function getQuarterStartDate(financialYear: string, quarter: string): Date | null { const match = financialYear.match(/^(\d{4})-(\d{4})$/); if (!match) return null; const startYear = parseInt(match[1], 10); if (quarter === 'Q1') return new Date(startYear, 3, 1); // 1 Apr if (quarter === 'Q2') return new Date(startYear, 6, 1); // 1 Jul if (quarter === 'Q3') return new Date(startYear, 9, 1); // 1 Oct if (quarter === 'Q4') return new Date(startYear + 1, 0, 1); // 1 Jan next year return null; } /** Due date = 45 days after quarter end (approximate). */ function getQuarterDueDate(financialYear: string, quarter: string): Date | null { const start = getQuarterStartDate(financialYear, quarter); if (!start) return null; const end = new Date(start); end.setMonth(end.getMonth() + 3); end.setDate(0); // last day of quarter const due = new Date(end); due.setDate(due.getDate() + 45); return due; } /** * List quarters for which the dealer has not completed Form 16A (no credit note). * Only includes FY/quarters that have 26AS data (so future quarters like 2027-28 are not shown until 26AS is uploaded). * Returns dealer_name, 26AS start date (when 26AS data was first added for that FY/quarter), and days_since_26as_uploaded. */ export async function listDealerPendingQuarters(userId: string) { const dealerCode = await getDealerCodeForUser(userId); if (!dealerCode) { return []; } const dealer = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, attributes: ['dealership', 'dealerPrincipalName'], raw: true, }); const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode).trim() : dealerCode; // Only show quarters that exist in 26AS data (no future/unavailable quarters) const twentySixAsRows = await Tds26asEntry.findAll({ attributes: ['financialYear', 'quarter'], raw: true, }); const quarterSet = new Map(); for (const r of twentySixAsRows as { financialYear: string; quarter: string }[]) { const key = `${r.financialYear}|${r.quarter}`; if (!quarterSet.has(key)) quarterSet.set(key, { financialYear: r.financialYear, quarter: r.quarter }); } const quarters = Array.from(quarterSet.values()); if (quarters.length === 0) { return []; } // Earliest 26AS created_at per (financial_year, quarter) = "26AS start date" const { QueryTypes } = await import('sequelize'); const twentySixAsMinDates = (await sequelize.query<{ financial_year: string; quarter: string; min_created: Date }>( `SELECT financial_year, quarter, MIN(created_at) AS min_created FROM tds_26as_entries GROUP BY financial_year, quarter`, { type: QueryTypes.SELECT } )); const minDateByKey = new Map(); for (const row of twentySixAsMinDates) { const key = `${row.financial_year}|${row.quarter}`; if (row.min_created) minDateByKey.set(key, new Date(row.min_created)); } const dealerSubmissions = await Form16aSubmission.findAll({ where: { dealerCode }, attributes: ['id', 'financialYear', 'quarter', 'status', 'validationStatus', 'submittedDate'], }); const submissionIds = dealerSubmissions.map((s) => s.id); const withNotes = await Form16CreditNote.findAll({ where: { submissionId: submissionIds }, attributes: ['submissionId'], }); const completedSubmissionIds = new Set(withNotes.map((n) => n.submissionId)); const byKey = new Map(); for (const s of dealerSubmissions as any[]) { const key = `${s.financialYear}|${s.quarter}`; const existing = byKey.get(key); if (!existing || (s.submittedDate && (!existing.submittedDate || new Date(s.submittedDate) > (existing.submittedDate as Date)))) { byKey.set(key, { id: s.id, status: s.status || 'pending', validationStatus: s.validationStatus ?? null, submittedDate: s.submittedDate ? new Date(s.submittedDate) : null, }); } } const result: Array<{ financial_year: string; quarter: string; dealer_name: string; has_submission: boolean; latest_submission_status: string | null; latest_submission_id: number | null; audit_start_date: string | null; twenty_six_as_start_date: string | null; days_remaining: number | null; days_overdue: number | null; days_since_26as_uploaded: number | null; }> = []; const now = new Date(); const oneDayMs = 24 * 60 * 60 * 1000; for (const { financialYear, quarter } of quarters) { const key = `${financialYear}|${quarter}`; const sub = byKey.get(key); const hasSubmission = !!sub; const hasCreditNote = hasSubmission && completedSubmissionIds.has(sub.id); if (hasCreditNote) continue; const quarterStart = getQuarterStartDate(financialYear, quarter); const dueDate = getQuarterDueDate(financialYear, quarter); let daysRemaining: number | null = null; let daysOverdue: number | null = null; if (dueDate) { const diffMs = dueDate.getTime() - now.getTime(); const diffDays = Math.ceil(diffMs / oneDayMs); if (diffDays > 0) daysRemaining = diffDays; else daysOverdue = Math.abs(diffDays); } const twentySixAsMin = minDateByKey.get(key); const twentySixAsStartDate = twentySixAsMin ? twentySixAsMin.toISOString().slice(0, 10) : null; const daysSince26AsUploaded = twentySixAsMin ? Math.floor((now.getTime() - twentySixAsMin.getTime()) / oneDayMs) : null; result.push({ financial_year: financialYear, quarter, dealer_name: dealerName, has_submission: hasSubmission, latest_submission_status: hasSubmission ? (sub!.validationStatus === 'failed' ? 'failed' : sub!.status) : null, latest_submission_id: hasSubmission ? sub!.id : null, audit_start_date: quarterStart ? quarterStart.toISOString().slice(0, 10) : null, twenty_six_as_start_date: twentySixAsStartDate, days_remaining: daysRemaining, days_overdue: daysOverdue, days_since_26as_uploaded: daysSince26AsUploaded, }); } result.sort((a, b) => { const aFy = a.financial_year; const bFy = b.financial_year; if (aFy !== bFy) return bFy.localeCompare(aFy); const qOrder = { Q1: 0, Q2: 1, Q3: 2, Q4: 3 }; return (qOrder[b.quarter as keyof typeof qOrder] ?? 0) - (qOrder[a.quarter as keyof typeof qOrder] ?? 0); }); return result; } /** * RE only. Cancel a Form 16 submission: set submission status to cancelled and workflow to REJECTED. */ export async function cancelForm16Submission(requestId: string, _userId: string) { const submission = await Form16aSubmission.findOne({ where: { requestId }, attributes: ['id', 'requestId', 'status'] }); if (!submission) throw new Error('Form 16 submission not found for this request.'); if ((submission as any).status === 'cancelled') return { submission, workflow: null }; await submission.update({ status: 'cancelled' }); const workflow = await WorkflowRequest.findOne({ where: { requestId }, attributes: ['requestId', 'status', 'conclusionRemark'] }); if (workflow) { await workflow.update({ status: WorkflowStatus.REJECTED, conclusionRemark: (workflow as any).conclusionRemark ? `${(workflow as any).conclusionRemark}\n\nSubmission cancelled by RE.` : 'Submission cancelled by RE.', }); } return { submission, workflow }; } /** * RE only. Mark submission as resubmission needed (e.g. partial OCR). */ export async function setForm16ResubmissionNeeded(requestId: string, _userId: string) { const submission = await Form16aSubmission.findOne({ where: { requestId } }); if (!submission) throw new Error('Form 16 submission not found for this request.'); await submission.update({ validationStatus: 'resubmission_needed', validationNotes: 'RE user marked this submission as resubmission needed. Dealer should resubmit Form 16.', }); return { submission }; } /** Get credit note linked to a Form 16 request (by requestId). Returns null if none. */ export async function getCreditNoteByRequestId(requestId: string) { const submission = await Form16aSubmission.findOne({ where: { requestId }, attributes: ['id', 'requestId', 'form16aNumber', 'financialYear', 'quarter', 'status'], }); if (!submission) return null; const note = await Form16CreditNote.findOne({ where: { submissionId: submission.id }, include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'requestId', 'form16aNumber', 'financialYear', 'quarter'] }], }); if (!note) return null; const n = note as any; return { id: n.id, creditNoteNumber: n.creditNoteNumber, sapDocumentNumber: n.sapDocumentNumber, amount: n.amount, issueDate: n.issueDate, financialYear: n.financialYear, quarter: n.quarter, status: n.status, remarks: n.remarks, submission: n.submission ? { requestId: n.submission.requestId, form16aNumber: n.submission.form16aNumber, financialYear: n.submission.financialYear, quarter: n.submission.quarter } : null, }; } /** Get credit note by id with dealer info, debit note (if withdrawn), and same-dealer history for detail page. */ export async function getCreditNoteById(creditNoteId: number) { const note = await Form16CreditNote.findByPk(creditNoteId, { include: [ { model: Form16aSubmission, as: 'submission', attributes: ['id', 'requestId', 'dealerCode', 'form16aNumber', 'financialYear', 'quarter', 'status', 'submittedDate'] }, { model: Form16DebitNote, as: 'debitNote', required: false, attributes: ['id', 'debitNoteNumber', 'sapDocumentNumber', 'amount', 'issueDate', 'status'] }, ], }); if (!note || !(note as any).submission) return null; const dealerCode = (note as any).submission.dealerCode as string; const dealer = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'], }); const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode; const dealerEmail = (dealer as any)?.dealerPrincipalEmailId ?? ''; const dealerContact = (dealer as any)?.dpContactNumber ?? ''; const submissionIds = await Form16aSubmission.findAll({ where: { dealerCode }, attributes: ['id'], }); const sIds = submissionIds.map((s) => s.id); const dealerNotes = await Form16CreditNote.findAll({ where: { submissionId: { [Op.in]: sIds } }, include: [{ model: Form16aSubmission, as: 'submission', attributes: ['form16aNumber', 'submittedDate'] }], order: [['issueDate', 'DESC']], }); const n = note as any; const debitNote = n.debitNote ? { id: n.debitNote.id, debitNoteNumber: n.debitNote.debitNoteNumber, sapDocumentNumber: n.debitNote.sapDocumentNumber, amount: n.debitNote.amount, issueDate: n.debitNote.issueDate, status: n.debitNote.status, } : null; return { creditNote: { id: n.id, creditNoteNumber: n.creditNoteNumber, sapDocumentNumber: n.sapDocumentNumber, amount: n.amount, issueDate: n.issueDate, financialYear: n.financialYear, quarter: n.quarter, status: n.status, remarks: n.remarks, submission: { requestId: n.submission.requestId, form16aNumber: n.submission.form16aNumber, financialYear: n.submission.financialYear, quarter: n.submission.quarter, submittedDate: n.submission.submittedDate, }, }, debitNote, dealerName, dealerEmail, dealerContact, dealerCreditNotes: dealerNotes.map((cn) => { const c = cn as any; return { id: c.id, creditNoteNumber: c.creditNoteNumber, amount: c.amount, issueDate: c.issueDate, status: c.status, form16aNumber: c.submission?.form16aNumber, submittedDate: c.submission?.submittedDate, }; }), }; } export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise { // API-backed CSV generation is used now; URL is deterministic when SAP response exists. const row = await getCreditNoteSapResponse(creditNoteId); return row ? `/api/v1/form16/credit-notes/${creditNoteId}/sap-response/csv` : null; } export interface Form16SapResponseView { fileName: string | null; trnsUniqNo: string | null; tdsTransId: string | null; sapDocumentNumber: string | null; msgTyp: string | null; message: string | null; docDate: string | null; tdsAmt: string | null; storageUrl: string | null; createdAt: Date | null; updatedAt: Date | null; } function mapSapResponseView(row: any): Form16SapResponseView { return { fileName: row?.fileName ?? null, trnsUniqNo: row?.trnsUniqNo ?? null, tdsTransId: row?.tdsTransId ?? null, sapDocumentNumber: row?.docNo ?? null, msgTyp: row?.msgTyp ?? null, message: row?.message ?? null, docDate: null, tdsAmt: null, storageUrl: null, createdAt: row?.createdAt ?? null, updatedAt: row?.updatedAt ?? null, }; } export async function getCreditNoteSapResponse(creditNoteId: number): Promise { const cn = await Form16CreditNote.findByPk(creditNoteId, { attributes: ['id', 'creditNoteNumber'] }); const creditNoteNumber = (cn as any)?.creditNoteNumber; if (!creditNoteNumber) return null; const row = await (Form16SapResponse as any).findOne({ where: { tdsTransId: creditNoteNumber, }, attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'], order: [['createdAt', 'DESC']], }); return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${creditNoteNumber}.csv` }) : null; } export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise { const row = await getDebitNoteSapResponse(debitNoteId); return row ? `/api/v1/form16/debit-notes/${debitNoteId}/sap-response/csv` : null; } export async function getDebitNoteSapResponse(debitNoteId: number): Promise { const dn = await Form16DebitNote.findByPk(debitNoteId, { attributes: ['id', 'debitNoteNumber'] }); const debitNoteNumber = (dn as any)?.debitNoteNumber; if (!debitNoteNumber) return null; const row = await (Form16SapResponse as any).findOne({ where: { tdsTransId: debitNoteNumber, }, attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'], order: [['createdAt', 'DESC']], }); return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${debitNoteNumber}.csv` }) : null; } /** * Dealer-safe download: dealers can only download their own credit note. * RE/Admin (non-dealer users) can download any credit note. */ export async function getCreditNoteSapResponseUrlForUser(creditNoteId: number, userId: string): Promise { const dealerCode = await getDealerCodeForUser(userId); if (dealerCode) { const note = await Form16CreditNote.findByPk(creditNoteId, { attributes: ['id', 'submissionId'], include: [{ model: Form16aSubmission, as: 'submission', attributes: ['dealerCode'] }], }); const noteDealerCode = (note as any)?.submission?.dealerCode; if (!note || !noteDealerCode || String(noteDealerCode).trim() !== String(dealerCode).trim()) { // Hide existence for unauthorized dealer throw new Error('Credit note not found'); } } return getCreditNoteSapResponseUrl(creditNoteId); } export async function getCreditNoteSapResponseForUser(creditNoteId: number, userId: string): Promise { const dealerCode = await getDealerCodeForUser(userId); if (dealerCode) { const note = await Form16CreditNote.findByPk(creditNoteId, { attributes: ['id', 'submissionId'], include: [{ model: Form16aSubmission, as: 'submission', attributes: ['dealerCode'] }], }); const noteDealerCode = (note as any)?.submission?.dealerCode; if (!note || !noteDealerCode || String(noteDealerCode).trim() !== String(dealerCode).trim()) { throw new Error('Credit note not found'); } } return getCreditNoteSapResponse(creditNoteId); } // ---------- Non-submitted dealers (RE only) ---------- const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const; export interface NonSubmittedDealerRow { id: string; dealerName: string; dealerCode: string; email: string; phone: string; location: string; missingQuarters: string[]; lastSubmissionDate: string | null; daysSinceLastSubmission: number | null; lastNotifiedDate: string | null; lastNotifiedBy: string | null; notificationCount: number; notificationHistory: Array<{ date: string; notifiedBy: string; method: string }>; } export interface NonSubmittedDealersResult { summary: { totalDealers: number; nonSubmittedCount: number; neverSubmittedCount: number; overdue90Count: number }; dealers: NonSubmittedDealerRow[]; } /** * Non-submitted dealers: dealers who have at least one missing Form 16A submission for the selected financial year. * For each dealer we return missing quarters (e.g. "Q1 2024-25"), last submission date, days since, and placeholder notification fields. */ export async function listNonSubmittedDealers(financialYear?: string): Promise { const fy = (financialYear || '').trim() || getDefaultFinancialYear(); const allDealers = await Dealer.findAll({ where: { isActive: true }, attributes: ['dealerId', 'salesCode', 'dlrcode', 'dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber', 'state', 'city'], }); const codeToDealer = new Map(); for (const d of allDealers) { const code = (d as any).salesCode || (d as any).dlrcode; if (code && !codeToDealer.has(code)) codeToDealer.set(code, d); } const allSubmissions = await Form16aSubmission.findAll({ attributes: ['dealerCode', 'financialYear', 'quarter', 'submittedDate'], }); const submissionsByCode = new Map>(); for (const s of allSubmissions) { const list = submissionsByCode.get(s.dealerCode) || []; list.push({ financialYear: s.financialYear, quarter: s.quarter, submittedDate: s.submittedDate || null, }); submissionsByCode.set(s.dealerCode, list); } const now = new Date(); const dealers: NonSubmittedDealerRow[] = []; for (const [code, d] of codeToDealer) { const subs = submissionsByCode.get(code) || []; const submittedForFy = new Set(subs.filter((x) => x.financialYear === fy).map((x) => x.quarter)); const missingQuarters = QUARTERS.filter((q) => !submittedForFy.has(q)).map((q) => `${q} ${fy}`); if (missingQuarters.length === 0) continue; const lastSub = subs.reduce((best, x) => { if (!x.submittedDate) return best; const d = new Date(x.submittedDate); return !best || d > best ? d : best; }, null); const lastSubmissionDate = lastSub ? lastSub.toISOString().slice(0, 10) : null; const daysSinceLastSubmission = lastSub ? Math.floor((now.getTime() - lastSub.getTime()) / (24 * 60 * 60 * 1000)) : null; const dealerName = (d as any).dealership || (d as any).dealerPrincipalName || '—'; const location = [((d as any).city as string) || '', ((d as any).state as string) || ''].filter(Boolean).join(', ') || '—'; dealers.push({ id: (d as any).dealerId, dealerName, dealerCode: code, email: (d as any).dealerPrincipalEmailId || '', phone: (d as any).dpContactNumber || '', location, missingQuarters, lastSubmissionDate, daysSinceLastSubmission, lastNotifiedDate: null, lastNotifiedBy: null, notificationCount: 0, notificationHistory: [], }); } const dealerCodes = dealers.map((x) => x.dealerCode); if (dealerCodes.length > 0) { const notifications = await Form16NonSubmittedNotification.findAll({ where: { dealerCode: { [Op.in]: dealerCodes }, financialYear: fy }, order: [['notifiedAt', 'DESC']], include: [{ model: User, as: 'notifiedByUser', attributes: ['displayName', 'email'], required: false }], raw: false, }); const byDealer = new Map(); for (const n of notifications) { const code = (n as any).dealerCode; if (!byDealer.has(code)) byDealer.set(code, []); byDealer.get(code)!.push(n); } for (const row of dealers) { const list = byDealer.get(row.dealerCode) || []; if (list.length > 0) { const latest = list[0] as any; row.lastNotifiedDate = latest.notifiedAt ? new Date(latest.notifiedAt).toISOString().slice(0, 10) : null; row.lastNotifiedBy = latest.notifiedByUser?.displayName || (latest.notifiedByUser as any)?.email || null; row.notificationCount = list.length; row.notificationHistory = list.slice(0, 10).map((n: any) => ({ date: n.notifiedAt ? new Date(n.notifiedAt).toISOString().slice(0, 10) : '', notifiedBy: n.notifiedByUser?.displayName || (n.notifiedByUser as any)?.email || '—', method: 'In-app', })); } } } const neverSubmittedCount = dealers.filter((x) => x.lastSubmissionDate === null).length; const overdue90Count = dealers.filter((x) => x.daysSinceLastSubmission != null && x.daysSinceLastSubmission > 90).length; return { summary: { totalDealers: allDealers.length, nonSubmittedCount: dealers.length, neverSubmittedCount, overdue90Count, }, dealers, }; } /** * Record that an RE user sent a "submit Form 16" notification to a non-submitted dealer, and send the in-app/push alert. * Returns the updated dealer row (with lastNotifiedDate, etc.) or null if dealer not in non-submitted list for that FY. */ export async function recordNonSubmittedDealerNotification( dealerCode: string, financialYear: string, userId: string ): Promise { const fy = (financialYear || '').trim() || getDefaultFinancialYear(); const code = (dealerCode || '').trim(); if (!code) return null; const result = await listNonSubmittedDealers(fy); const dealer = result.dealers.find((d) => d.dealerCode === code); if (!dealer) return null; await Form16NonSubmittedNotification.create({ dealerCode: code, financialYear: fy, notifiedBy: userId, }); const dealerRecord = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: code }, { dlrcode: code }], isActive: true }, attributes: ['dealerPrincipalEmailId', 'dealership', 'dealerPrincipalName'], }); const email = (dealerRecord as any)?.dealerPrincipalEmailId; let targetUserId: string | null = null; if (email) { const u = await User.findOne({ where: { email: { [Op.iLike]: email } }, attributes: ['userId'], }); targetUserId = (u as any)?.userId ?? null; } if (targetUserId) { const { triggerForm16AlertSubmit } = await import('./form16Notification.service'); const dueDate = `FY ${fy} (as per policy)`; const name = (dealerRecord as any)?.dealership || (dealerRecord as any)?.dealerPrincipalName || 'Dealer'; await triggerForm16AlertSubmit([targetUserId], { name, dueDate }); } const updated = await listNonSubmittedDealers(fy); const updatedDealer = updated.dealers.find((d) => d.dealerCode === code) ?? null; return updatedDealer; } function getDefaultFinancialYear(): string { const y = new Date().getFullYear(); const end = (y + 1).toString().slice(-2); return `${y}-${end}`; } /** * Get user IDs for dealers who have not submitted Form 16 for the given financial year. * Used by the alert "submit Form 16" scheduled job. */ export async function getDealerUserIdsFromNonSubmittedDealers(financialYear?: string): Promise { const result = await listNonSubmittedDealers(financialYear); const emails = [...new Set(result.dealers.map((d) => d.email).filter(Boolean))].map((e) => e.trim().toLowerCase()); if (emails.length === 0) return []; const users = await User.findAll({ where: { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) }, attributes: ['userId'], raw: true, }); return users.map((u) => (u as any).userId); } /** * Get dealer user IDs missing a specific FY+quarter submission. * Used by quarter-based submit reminders. */ export async function getDealerUserIdsMissingQuarter(financialYear: string, quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'): Promise { const result = await listNonSubmittedDealers(financialYear); const key = `${quarter} ${financialYear}`; const emails = [...new Set(result.dealers.filter((d) => (d.missingQuarters || []).includes(key)).map((d) => d.email).filter(Boolean))] .map((e) => e.trim().toLowerCase()); if (emails.length === 0) return []; const users = await User.findAll({ where: { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) }, attributes: ['userId'], raw: true, }); return users.map((u) => (u as any).userId); } /** * Get dealers (initiator user IDs) who have at least one pending Form 16 submission (no credit note yet, request open). * Returns one entry per pending request so reminders can include a human-readable request reference. */ export async function getDealersWithPendingForm16Submissions(): Promise<{ userId: string; requestId: string; requestNumber: string }[]> { const submissions = await Form16aSubmission.findAll({ attributes: ['id', 'requestId'], raw: true, }); const submissionIds = submissions.map((s) => (s as any).id); const withCreditNote = new Set( submissionIds.length ? (await Form16CreditNote.findAll({ where: { submissionId: submissionIds }, attributes: ['submissionId'], raw: true })).map( (c) => (c as any).submissionId ) : [] ); const pendingRequestIds = submissions.filter((s) => !withCreditNote.has((s as any).id)).map((s) => (s as any).requestId); if (pendingRequestIds.length === 0) return []; const { WorkflowStatus: WS } = await import('../types/common.types'); const requests = await WorkflowRequest.findAll({ where: { requestId: pendingRequestIds, templateType: 'FORM_16', status: { [Op.ne]: WS.CLOSED } }, attributes: ['requestId', 'requestNumber', 'initiatorId'], raw: true, }); return requests.map((r) => ({ userId: (r as any).initiatorId, requestId: (r as any).requestId, requestNumber: (r as any).requestNumber || (r as any).requestId, })); } // ---------- 26AS (RE admin) ---------- const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 500; export interface List26asFilters { financialYear?: string; quarter?: string; tanNumber?: string; search?: string; status?: string; assessmentYear?: string; sectionCode?: string; limit?: number; offset?: number; } export interface List26asSummary { totalRecords: number; booked: number; notBooked: number; pending: number; totalTaxDeducted: number; } function build26asWhere(filters?: List26asFilters): Record { const where: Record = {}; const andClauses: unknown[] = []; if (filters?.financialYear) where.financialYear = normalizeFinancialYear(filters.financialYear) || filters.financialYear; if (filters?.quarter) where.quarter = normalizeQuarter(filters.quarter) || filters.quarter; if (filters?.status) where.statusOltas = filters.status; if (filters?.assessmentYear) where.assessmentYear = filters.assessmentYear; if (filters?.sectionCode) where.sectionCode = filters.sectionCode; if (filters?.tanNumber?.trim()) { const normalizedTan = normalizeTanNumber(filters.tanNumber); if (normalizedTan) { andClauses.push( sqlWhere( fn('upper', fn('regexp_replace', fn('coalesce', col('tan_number'), ''), '[^a-zA-Z0-9]', '', 'g')), { [Op.like]: `%${normalizedTan}%` } ) ); } } if (filters?.search?.trim()) { const s = filters.search.trim(); const normalizedSearchTan = normalizeTanNumber(s); const searchOr: unknown[] = [{ deductorName: { [Op.iLike]: `%${s}%` } }]; if (normalizedSearchTan) { searchOr.push( sqlWhere( fn('upper', fn('regexp_replace', fn('coalesce', col('tan_number'), ''), '[^a-zA-Z0-9]', '', 'g')), { [Op.like]: `%${normalizedSearchTan}%` } ) ); } andClauses.push({ [Op.or]: searchOr }); } if (andClauses.length > 0) { (where as any)[Op.and] = andClauses; } return where; } export async function list26asEntries(filters?: List26asFilters): Promise<{ rows: Tds26asEntry[]; total: number; summary: List26asSummary; }> { const where = build26asWhere(filters); // Use Reflect.ownKeys so symbol keys like Op.and are counted. const hasWhere = Reflect.ownKeys(where).length > 0; const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, filters?.limit ?? DEFAULT_PAGE_SIZE)); const offset = Math.max(0, filters?.offset ?? 0); const [rowsResult, summaryRows] = await Promise.all([ Tds26asEntry.findAndCountAll({ where: hasWhere ? where : undefined, order: [['financialYear', 'DESC'], ['quarter', 'ASC'], ['createdAt', 'DESC']], limit, offset, }), Tds26asEntry.findAll({ where: hasWhere ? where : undefined, attributes: [ 'statusOltas', [fn('COUNT', col('id')), 'count'], [fn('SUM', col('tax_deducted')), 'totalTax'], ], group: ['statusOltas'], raw: true, }) as unknown as Promise>, ]); const { rows, count: total } = rowsResult; const summary: List26asSummary = { totalRecords: total, booked: 0, notBooked: 0, pending: 0, totalTaxDeducted: 0, }; for (const row of summaryRows) { const status = (row.statusOltas ?? row.status_oltas ?? '').trim().toUpperCase().slice(0, 1); const cnt = parseInt(String(row.count), 10) || 0; const tax = parseFloat(String(row.totalTax || 0)) || 0; if (status === 'F') summary.booked += cnt; else if (status === 'O') summary.notBooked += cnt; else if (status === 'P') summary.pending += cnt; summary.totalTaxDeducted += tax; } return { rows, total, summary }; } export async function create26asEntry(data: { panNumber?: string; tanNumber: string; deductorName?: string; quarter: string; assessmentYear?: string; financialYear: string; sectionCode?: string; amountPaid?: number; taxDeducted: number; totalTdsDeposited?: number; natureOfPayment?: string; transactionDate?: string; dateOfBooking?: string; statusOltas?: string; remarks?: string; }) { const includePanNumber = await isPanNumberColumnPresent(); const payload: any = { tanNumber: data.tanNumber, deductorName: data.deductorName, quarter: data.quarter, assessmentYear: data.assessmentYear, financialYear: data.financialYear, sectionCode: data.sectionCode, amountPaid: data.amountPaid, taxDeducted: data.taxDeducted ?? 0, totalTdsDeposited: data.totalTdsDeposited, natureOfPayment: data.natureOfPayment, transactionDate: data.transactionDate, dateOfBooking: data.dateOfBooking, statusOltas: data.statusOltas, remarks: data.remarks, }; if (includePanNumber && data.panNumber != null) payload.panNumber = data.panNumber; const entry = await Tds26asEntry.create(payload); return entry; } export async function update26asEntry( id: number, data: Partial<{ panNumber: string; tanNumber: string; deductorName: string; quarter: string; assessmentYear: string; financialYear: string; sectionCode: string; amountPaid: number; taxDeducted: number; totalTdsDeposited: number; natureOfPayment: string; transactionDate: string; dateOfBooking: string; statusOltas: string; remarks: string; }> ) { const entry = await Tds26asEntry.findByPk(id); if (!entry) return null; if (data.panNumber != null) { const includePanNumber = await isPanNumberColumnPresent(); if (!includePanNumber) delete (data as any).panNumber; } await entry.update(data); return entry; } export async function delete26asEntry(id: number): Promise { const entry = await Tds26asEntry.findByPk(id); if (!entry) return false; await entry.destroy(); return true; } // ---------- 26AS bulk upload from TXT file ---------- /** Supports (1) Official 26AS/Annual Tax Statement format (delimiter ^, deductor blocks + transaction lines) and (2) generic delimiter (comma, tab, pipe, semicolon) with optional header. */ function parseDecimal(val: string): number | undefined { if (val == null || val === '') return undefined; const n = parseFloat(String(val).replace(/,/g, '')); return Number.isNaN(n) ? undefined : n; } const MONTH_NAMES: Record = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }; function parseDateOnly(val: string): string | undefined { if (val == null || val === '') return undefined; const s = String(val).trim(); if (!s) return undefined; const m = s.match(/^(\d{1,2})-(\w{3})-(\d{4})$/i); if (m) { const month = MONTH_NAMES[m[2].toLowerCase()]; if (month) return `${m[3]}-${String(month).padStart(2, '0')}-${m[1].padStart(2, '0')}`; } return s; } /** Derive FY and quarter from date string DD-MMM-YYYY (e.g. 30-Sep-2024) */ function dateToFyAndQuarter(dateStr: string): { financialYear: string; quarter: string } { const m = dateStr.match(/^(\d{1,2})-(\w{3})-(\d{4})$/i); if (!m) return { financialYear: getCurrentFinancialYear(), quarter: 'Q1' }; const month = m[2].toLowerCase(); const year = parseInt(m[3], 10); let quarter = 'Q1'; if (['apr', 'may', 'jun'].includes(month)) quarter = 'Q1'; else if (['jul', 'aug', 'sep'].includes(month)) quarter = 'Q2'; else if (['oct', 'nov', 'dec'].includes(month)) quarter = 'Q3'; else quarter = 'Q4'; const fyEnd = month === 'jan' || month === 'feb' || month === 'mar' ? year : year + 1; const fyStart = fyEnd - 1; const next = (fyEnd % 100).toString().padStart(2, '0'); return { financialYear: `${fyStart}-${next}`, quarter }; } function getCurrentFinancialYear(): string { const y = new Date().getFullYear(); const next = (y + 1) % 100; return `${y}-${next < 10 ? '0' + next : next}`; } /** Official 26AS format: lines with ^ delimiter. Deductor summary: "1^Name^TAN^^^^^TotalAmt^Tax^TDS". Transaction: "^1^194Q^30-Sep-2024^F^24-Oct-2024^-^Amount^Tax^TDS". */ function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string[] } { const errors: string[] = []; const rows: any[] = []; let currentPAN = ''; let currentTAN = ''; let currentDeductorName = ''; const transactionDateRe = /^\d{1,2}-\w{3}-\d{4}$/i; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const cells = line.split('^').map((c) => c.trim()); if (cells.length < 8) continue; const c0 = cells[0]; const c1 = cells[1]; const c2 = cells[2]; const c3 = cells[3]; // Header block with PAN row: // File Creation Date ^ Permanent Account Number (PAN) ^ ... // 25-10-2024 ^ AAACE3883A ^ ... if (cells.length >= 2 && /^[A-Z]{5}[0-9]{4}[A-Z]$/i.test(c1 || '')) { currentPAN = String(c1).trim().toUpperCase(); continue; } // Deductor summary: first cell is numeric (Sr No), third looks like TAN (e.g. AGRA13250G), then empties then amounts const srNoNum = /^\d+$/.test(c0); const looksLikeTan = c2 && c2.length >= 8 && /^[A-Z0-9]+$/i.test(c2); if (srNoNum && looksLikeTan && c1) { currentTAN = c2; currentDeductorName = c1; continue; } // Transaction line: first cell empty (line starts with ^), second is Sr No, third is Section (e.g. 194Q), fourth is Transaction Date const firstEmpty = !c0; const secondNumeric = /^\d+$/.test(c1); const sectionLike = c2 && /^\d{3}[A-Z]?$/i.test(c2); const hasDate = c3 && transactionDateRe.test(c3); if (firstEmpty && secondNumeric && sectionLike && hasDate && currentTAN) { const amountPaid = parseDecimal(cells[7]); const taxDeducted = parseDecimal(cells[8]); const totalTds = parseDecimal(cells[9]); const { financialYear, quarter } = dateToFyAndQuarter(cells[3]); rows.push({ panNumber: currentPAN || undefined, tanNumber: currentTAN, deductorName: currentDeductorName || undefined, quarter, financialYear, sectionCode: c2 || undefined, amountPaid: amountPaid ?? undefined, taxDeducted: taxDeducted ?? 0, totalTdsDeposited: totalTds ?? undefined, natureOfPayment: undefined, transactionDate: parseDateOnly(cells[3]), dateOfBooking: parseDateOnly(cells[5]), assessmentYear: undefined, statusOltas: cells[4] || undefined, remarks: cells[6] || undefined, }); } } return { rows, errors }; } function detectDelimiter(line: string): string { const caretCount = (line.match(/\^/g) || []).length; if (caretCount >= 5) return '^'; const candidates = [',', '\t', '|', ';']; let best = '\t'; let maxCols = 0; for (const d of candidates) { const count = line.split(d).length; if (count > maxCols && count >= 2) { maxCols = count; best = d; } } return best; } function looksLikeHeader(line: string): boolean { const lower = line.toLowerCase(); const tokens = lower.split(/[\t,|;]+/).map((s) => s.replace(/\s+/g, '')); const hasTan = tokens.some((t) => t.includes('tan') && !t.includes('amount')); const hasQuarter = tokens.some((t) => t.includes('quarter') || t === 'q'); const hasFinancial = tokens.some((t) => t.includes('financial') || t.includes('year') || t === 'fy'); const hasTax = tokens.some((t) => t.includes('tax') || t.includes('deduct')); const hasAmount = tokens.some((t) => t.includes('amount') || t.includes('paid')); return (hasTan || hasQuarter || hasFinancial || hasTax || hasAmount) && tokens.length >= 2; } function buildColumnMap(headerCells: string[]): Record { const map: Record = {}; const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, ''); headerCells.forEach((cell, idx) => { const n = norm(cell); if (n === 'pan' || n.includes('pannumber') || (n.includes('permanent') && n.includes('account'))) map['panNumber'] = idx; if (n.includes('tan') && !n.includes('amount')) map['tanNumber'] = idx; else if (n.includes('deductor') && (n.includes('name') || n.length < 20)) map['deductorName'] = idx; else if (n.includes('quarter') || n === 'q') map['quarter'] = idx; else if (n.includes('financial') || n.includes('year') || n === 'fy') map['financialYear'] = idx; else if (n.includes('assessment')) map['assessmentYear'] = idx; else if (n.includes('section')) map['sectionCode'] = idx; else if (n.includes('amount') && n.includes('paid')) map['amountPaid'] = idx; else if ((n.includes('tax') && n.includes('deduct')) || n === 'taxdeducted') map['taxDeducted'] = idx; else if (n.includes('total') && n.includes('tds')) map['totalTdsDeposited'] = idx; else if (n.includes('nature') && n.includes('payment')) map['natureOfPayment'] = idx; else if (n.includes('transaction') && n.includes('date')) map['transactionDate'] = idx; else if (n.includes('booking') || n.includes('dateofbooking')) map['dateOfBooking'] = idx; else if (n.includes('status') || n.includes('oltas')) map['statusOltas'] = idx; else if (n.includes('remark')) map['remarks'] = idx; }); return map; } export function parse26asTxtFile(buffer: Buffer): { rows: any[]; errors: string[] } { const text = buffer.toString('utf8'); const rawLines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const errors: string[] = []; if (rawLines.length === 0) return { rows: [], errors }; // Lightweight, non-blocking sanity logging – detect obviously suspicious uploads without rejecting them. try { const totalLines = rawLines.length; const sampleLines = rawLines.slice(0, Math.min(200, totalLines)); const caretLines = sampleLines.filter((l) => (l.match(/\^/g) || []).length >= 5).length; const hasDatePattern = sampleLines.some((l) => /\b\d{1,2}-[A-Za-z]{3}-\d{4}\b/.test(l)); const hasTanLike = sampleLines.some((l) => /\b[A-Z]{4}[A-Z0-9]{5}[A-Z]\b/i.test(l)); const suspicious = totalLines < 5 || (caretLines === 0 && !hasDatePattern && !hasTanLike); if (suspicious) { logger.warn( '[Form16] 26AS TXT upload appears suspicious (non-blocking): ' + `lines=${totalLines}, caretLines=${caretLines}, hasDatePattern=${hasDatePattern}, hasTanLike=${hasTanLike}` ); } } catch { // Never block parsing due to logging issues } const firstLine = rawLines[0]; let delimiter = detectDelimiter(firstLine); if (delimiter !== '^') { const withCaret = rawLines.slice(0, 30).find((l) => (l.match(/\^/g) || []).length >= 5); if (withCaret) delimiter = '^'; } if (delimiter === '^') { return parse26asOfficialFormat(rawLines); } const rows: any[] = []; const allLines = rawLines.map((l) => l.split(delimiter).map((c) => c.trim())); let dataStart = 0; let colMap: Record = {}; const numCols = allLines[0].length; if (looksLikeHeader(firstLine)) { dataStart = 1; colMap = buildColumnMap(allLines[0].map((c) => c || '')); if (Object.keys(colMap).length === 0) { for (let i = 0; i < Math.min(14, numCols); i++) colMap[['tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode', 'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment', 'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks'][i]] = i; } } else { for (let i = 0; i < Math.min(14, numCols); i++) colMap[['tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode', 'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment', 'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks'][i]] = i; } const get = (cells: string[], key: string): string => { const idx = colMap[key]; if (idx == null || idx >= cells.length) return ''; return String(cells[idx] ?? '').trim(); }; const defaultFY = getCurrentFinancialYear(); for (let i = dataStart; i < allLines.length; i++) { const cells = allLines[i]; if (cells.length < 2) continue; const tanNumber = get(cells, 'tanNumber') || (cells[0] ? String(cells[0]).trim() : '') || 'UNKNOWN'; const quarter = get(cells, 'quarter') || 'Q1'; const financialYear = get(cells, 'financialYear') || defaultFY; const taxDeductedNum = parseDecimal(get(cells, 'taxDeducted')) ?? 0; rows.push({ panNumber: get(cells, 'panNumber') || undefined, tanNumber, deductorName: get(cells, 'deductorName') || undefined, quarter, financialYear, sectionCode: get(cells, 'sectionCode') || undefined, amountPaid: parseDecimal(get(cells, 'amountPaid')), taxDeducted: taxDeductedNum, totalTdsDeposited: parseDecimal(get(cells, 'totalTdsDeposited')), natureOfPayment: get(cells, 'natureOfPayment') || undefined, transactionDate: parseDateOnly(get(cells, 'transactionDate')), dateOfBooking: parseDateOnly(get(cells, 'dateOfBooking')), assessmentYear: get(cells, 'assessmentYear') || undefined, statusOltas: get(cells, 'statusOltas') || undefined, remarks: get(cells, 'remarks') || undefined, }); } return { rows, errors }; } /** Allowed fields for 26AS create – exclude timestamps so Sequelize sets them. */ const TDS_26AS_CREATE_KEYS = [ 'panNumber', 'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode', 'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment', 'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId', ] as const; function build26asCreatePayload( row: Record, uploadLogId: number | null | undefined, includePanNumber: boolean ): Record { const payload: Record = {}; for (const k of TDS_26AS_CREATE_KEYS) { if (k === 'uploadLogId') continue; if (k === 'panNumber' && !includePanNumber) continue; const v = row[k]; if (v !== undefined && v !== null) payload[k] = v; } payload.tanNumber = normalizeTanNumber(row.tanNumber); if (includePanNumber && row.panNumber != null) payload.panNumber = String(row.panNumber).trim().toUpperCase(); const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || ''; const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1'; payload.financialYear = normalizeFinancialYear(rawFy) || rawFy; payload.quarter = normalizeQuarter(rawQ) || rawQ; payload.taxDeducted = row.taxDeducted ?? 0; if (uploadLogId != null) payload.uploadLogId = uploadLogId; return payload; } const TDS_26AS_BATCH_SIZE = 500; /** * Upload 26AS TXT file: parse and bulk-insert. Only records with Section 194Q and Booking Status F/O are used for aggregation. * All records are stored; pass uploadLogId when log was created first (e.g. by controller) for snapshot/debit processing. */ export async function upload26asFile(buffer: Buffer, uploadLogId?: number | null): Promise<{ imported: number; errors: string[] }> { const { rows, errors: parseErrors } = parse26asTxtFile(buffer); if (rows.length === 0 && parseErrors.length === 0) { return { imported: 0, errors: ['File is empty or has no data rows.'] }; } const insertErrors: string[] = []; let imported = 0; const includePanNumber = await isPanNumberColumnPresent(); for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) { const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE); const payloads = batch.map((r) => build26asCreatePayload(r as Record, uploadLogId, includePanNumber)); try { const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true }); imported += created.length; } catch (err: any) { const baseRow = i + 1; insertErrors.push(`Rows ${baseRow}-${baseRow + batch.length - 1}: ${err?.message || 'Insert failed'}`); } } return { imported, errors: [...parseErrors, ...insertErrors], }; } /** * After 26AS upload: compute quarter aggregates (Section 194Q, Booking F/O only), create snapshots, issue debits when 26AS total changed and quarter was SETTLED. * Duplicate 26AS (same total) creates no new snapshot and no debit. */ export async function process26asUploadAggregation(uploadLogId: number): Promise<{ snapshotsCreated: number; debitsCreated: number }> { const entries = await Tds26asEntry.findAll({ where: { uploadLogId }, attributes: ['tanNumber', 'financialYear', 'quarter'], raw: true, }); const keys = new Map(); for (const e of entries as any[]) { const tan = (e.tanNumber || '').trim().replace(/\s+/g, ' '); const fy = (e.financialYear || '').trim(); const q = (e.quarter || '').trim(); if (!tan || !fy || !q) continue; keys.set(`${tan}|${fy}|${q}`, { tanNumber: tan, financialYear: fy, quarter: q }); } let snapshotsCreated = 0; let debitsCreated = 0; for (const [, { tanNumber, financialYear, quarter }] of keys) { const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, q); const latest = await getLatest26asSnapshot(tanNumber, fy, q); const prevTotal = latest ? parseFloat(String((latest as any).aggregatedAmount ?? 0)) : 0; if (Math.abs(newTotal - prevTotal) < 0.01) continue; // duplicate 26AS: no change const status = await getQuarterStatus(tanNumber, fy, q); if (status?.status === 'SETTLED' && status.lastCreditNoteId) { const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, { attributes: ['id', 'amount', 'financialYear', 'quarter', 'creditNoteNumber', 'submissionId', 'issueDate'], }); if (creditNote) { const amount = parseFloat(String((creditNote as any).amount ?? 0)); const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode', 'version', 'form16aNumber'] }); // Dealer code, version and certificate number from submission (DN uses same cert as the credit note being reversed) const dealerCode = submission ? ((submission as any).dealerCode || '').toString().trim() : ''; const version = typeof (submission as any)?.version === 'number' && (submission as any).version >= 1 ? (submission as any).version : 1; const creditNoteCertNumber = submission ? ((submission as any).form16aNumber || '').toString().trim() : ''; const cnFy = (creditNote as any).financialYear || fy; const cnQuarter = (creditNote as any).quarter || q; const creditNoteNumber = (creditNote as any).creditNoteNumber || (creditNote as any).credit_note_number || ''; const debitNum = formatForm16DebitNoteNumber(creditNoteNumber); const now = new Date(); const debit = await Form16DebitNote.create({ creditNoteId: creditNote.id, debitNoteNumber: debitNum, amount, issueDate: now, status: 'issued', reason: '26AS quarter total changed; previous credit reversed.', createdAt: now, updatedAt: now, }); await addLedgerEntry({ tanNumber, financialYear: fy, quarter: q, entryType: 'DEBIT', amount, debitNoteId: debit.id, }); await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id); debitsCreated++; // Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16 try { const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`; await debit.update({ trnsUniqNo }); const docDate = now.toISOString().slice(0, 10).replace(/-/g, ''); const fyCompact = form16FyCompact(cnFy) || ''; const finYearAndQuarter = fyCompact && cnQuarter ? `FY_${fyCompact}_${cnQuarter}` : ''; const csvRow: Record = { TRNS_UNIQ_NO: trnsUniqNo, TDS_TRNS_ID: debitNum, DEALER_CODE: padDealerCode(dealerCode), TDS_TRNS_DOC_TYPE: 'ZTDS', DLR_TAN_NO: tanNumber, 'FIN_YEAR&QUARTER': finYearAndQuarter, DOC_DATE: docDate, TDS_AMT: formatForm16IncomingCsvTdsAmt(Number(amount), 'debit'), TDS_CERTIFICATE_NO: creditNoteCertNumber, }; const fileName = `${debitNum}.csv`; await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit'); logger.info(`[Form16] Debit note CSV pushed to WFM FORM16: ${debitNum}`); } catch (csvErr: any) { logger.error('[Form16] Failed to push debit note CSV to WFM FORM16:', csvErr?.message || csvErr); } } } const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); await Form1626asQuarterSnapshot.create({ tanNumber: normalized, financialYear: fy, quarter: q, aggregatedAmount: newTotal, uploadLogId, createdAt: new Date(), }); snapshotsCreated++; } return { snapshotsCreated, debitsCreated }; } /** Log a 26AS file upload for audit (who uploaded, when, how many records). */ export async function log26asUpload( userId: string, opts: { fileName?: string | null; recordsImported: number; errorsCount: number } ): Promise { const log = await Form1626asUploadLog.create({ uploadedAt: new Date(), uploadedBy: userId, fileName: opts.fileName ?? null, recordsImported: opts.recordsImported, errorsCount: opts.errorsCount, }); return log as Form1626asUploadLog; } export interface Form1626asUploadLogRow { id: number; uploadedAt: string; uploadedBy: string; uploadedByEmail?: string | null; uploadedByDisplayName?: string | null; fileName?: string | null; recordsImported: number; errorsCount: number; } /** List 26AS upload history (most recent first) for management section. */ export async function list26asUploadHistory( limit: number = 50, offset: number = 0 ): Promise<{ rows: Form1626asUploadLogRow[]; total: number }> { const { rows, count } = await Form1626asUploadLog.findAndCountAll({ limit, offset, order: [['uploadedAt', 'DESC']], include: [{ model: User, as: 'uploadedByUser', attributes: ['email', 'displayName'], required: false }], distinct: true, }); const mapped = rows.map((r) => { const u = r as any; return { id: u.id, uploadedAt: (u.uploadedAt instanceof Date ? u.uploadedAt.toISOString() : u.uploadedAt) as string, uploadedBy: u.uploadedBy, uploadedByEmail: u.uploadedByUser?.email ?? null, uploadedByDisplayName: u.uploadedByUser?.displayName ?? null, fileName: u.fileName ?? null, recordsImported: u.recordsImported ?? 0, errorsCount: u.errorsCount ?? 0, }; }); return { rows: mapped, total: count }; } export interface Form16DashboardKpi { collectionPct: number; pendingPct: number; submittedPct: number; submissionPendingPct: number; } export interface Form16DashboardOverall { totalAmount: number; submittedAmount: number; pendingAmount: number; totalDealers: number; submittedDealerCount: number; pendingDealerCount: number; } export interface Form16DashboardBreakdownRow { label: string; totalAmount: number; dealerCount: number; submittedAmount: number; submittedDealerCount: number; pendingAmount: number; pendingDealerCount: number; } export interface Form16DashboardData { kpi: Form16DashboardKpi; overall: Form16DashboardOverall; yearWise: Form16DashboardBreakdownRow[]; zoneWise: Form16DashboardBreakdownRow[]; } /** * Form16A dashboard for RE users. * Uses real DB data: * - dealer universe from active dealers * - latest submission per dealer+FY+quarter * - submitted/credited via form_16_credit_notes * Zone mapping follows dealer region code prefix: N* -> North, S* -> South, E* -> East, W* -> West, C* -> Central. */ export async function getForm16DashboardData(): Promise { const toNum = (v: unknown): number => { const n = Number(v ?? 0); return Number.isFinite(n) ? n : 0; }; const [overallRow] = await sequelize.query<{ total_amount: number | string | null; submitted_amount: number | string | null; total_dealers: number | string | null; submitted_dealer_count: number | string | null; }>( ` WITH active_dealers AS ( SELECT DISTINCT TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) AS dealer_code FROM dealers d WHERE d.is_active = true AND TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) <> '' ), latest_submissions AS ( SELECT s.id, s.dealer_code, s.financial_year, s.quarter, COALESCE(s.total_amount, 0)::numeric AS total_amount, ROW_NUMBER() OVER ( PARTITION BY s.dealer_code, s.financial_year, s.quarter ORDER BY COALESCE(s.version, 1) DESC, COALESCE(s.submitted_date, s.created_at) DESC, s.id DESC ) AS rn FROM form16a_submissions s INNER JOIN active_dealers ad ON ad.dealer_code = s.dealer_code ), latest_base AS ( SELECT id, dealer_code, financial_year, quarter, total_amount FROM latest_submissions WHERE rn = 1 ), submitted_by_dealer AS ( SELECT lb.dealer_code, SUM(COALESCE(cn.amount, 0))::numeric AS submitted_amount FROM latest_base lb LEFT JOIN form_16_credit_notes cn ON cn.submission_id = lb.id GROUP BY lb.dealer_code ) SELECT COALESCE((SELECT SUM(lb.total_amount) FROM latest_base lb), 0) AS total_amount, COALESCE((SELECT SUM(sbd.submitted_amount) FROM submitted_by_dealer sbd), 0) AS submitted_amount, COALESCE((SELECT COUNT(*) FROM active_dealers), 0) AS total_dealers, COALESCE(( SELECT COUNT(DISTINCT sbd.dealer_code) FROM submitted_by_dealer sbd WHERE sbd.submitted_amount > 0 ), 0) AS submitted_dealer_count `, { type: QueryTypes.SELECT } ); const totalAmount = toNum(overallRow?.total_amount); const submittedAmount = toNum(overallRow?.submitted_amount); const totalDealers = Math.max(0, Math.trunc(toNum(overallRow?.total_dealers))); const submittedDealerCount = Math.max(0, Math.trunc(toNum(overallRow?.submitted_dealer_count))); const pendingDealerCount = Math.max(0, totalDealers - submittedDealerCount); const pendingAmount = Math.max(0, totalAmount - submittedAmount); const toPct = (part: number, whole: number): number => { if (!whole || whole <= 0) return 0; return Math.max(0, Math.min(100, Math.round((part / whole) * 100))); }; const yearRowsRaw = await sequelize.query<{ label: string; total_amount: number | string | null; dealer_count: number | string | null; submitted_amount: number | string | null; submitted_dealer_count: number | string | null; }>( ` WITH active_dealers AS ( SELECT DISTINCT TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) AS dealer_code FROM dealers d WHERE d.is_active = true AND TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) <> '' ), latest_submissions AS ( SELECT s.id, s.dealer_code, s.financial_year, s.quarter, COALESCE(s.total_amount, 0)::numeric AS total_amount, ROW_NUMBER() OVER ( PARTITION BY s.dealer_code, s.financial_year, s.quarter ORDER BY COALESCE(s.version, 1) DESC, COALESCE(s.submitted_date, s.created_at) DESC, s.id DESC ) AS rn FROM form16a_submissions s INNER JOIN active_dealers ad ON ad.dealer_code = s.dealer_code ), latest_base AS ( SELECT id, dealer_code, financial_year, quarter, total_amount FROM latest_submissions WHERE rn = 1 ), by_year AS ( SELECT lb.financial_year AS label, SUM(lb.total_amount)::numeric AS total_amount, COUNT(DISTINCT lb.dealer_code) AS dealer_count, SUM(COALESCE(cn.amount, 0))::numeric AS submitted_amount, COUNT(DISTINCT CASE WHEN COALESCE(cn.amount, 0) > 0 THEN lb.dealer_code END) AS submitted_dealer_count FROM latest_base lb LEFT JOIN form_16_credit_notes cn ON cn.submission_id = lb.id GROUP BY lb.financial_year ) SELECT * FROM by_year ORDER BY label DESC `, { type: QueryTypes.SELECT } ); const zoneRowsRaw = await sequelize.query<{ label: string; total_amount: number | string | null; dealer_count: number | string | null; submitted_amount: number | string | null; submitted_dealer_count: number | string | null; }>( ` WITH active_dealers AS ( SELECT DISTINCT TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) AS dealer_code, CASE WHEN UPPER(COALESCE(d.region, '')) LIKE 'N%' THEN 'North' WHEN UPPER(COALESCE(d.region, '')) LIKE 'S%' THEN 'South' WHEN UPPER(COALESCE(d.region, '')) LIKE 'E%' THEN 'East' WHEN UPPER(COALESCE(d.region, '')) LIKE 'W%' THEN 'West' WHEN UPPER(COALESCE(d.region, '')) LIKE 'C%' THEN 'Central' ELSE 'Unknown' END AS zone FROM dealers d WHERE d.is_active = true AND TRIM(COALESCE(NULLIF(d.sales_code, ''), NULLIF(d.dlrcode, ''))) <> '' ), latest_submissions AS ( SELECT s.id, s.dealer_code, COALESCE(s.total_amount, 0)::numeric AS total_amount, ROW_NUMBER() OVER ( PARTITION BY s.dealer_code, s.financial_year, s.quarter ORDER BY COALESCE(s.version, 1) DESC, COALESCE(s.submitted_date, s.created_at) DESC, s.id DESC ) AS rn FROM form16a_submissions s INNER JOIN active_dealers ad ON ad.dealer_code = s.dealer_code ), latest_base AS ( SELECT id, dealer_code, total_amount FROM latest_submissions WHERE rn = 1 ), by_zone AS ( SELECT ad.zone AS label, SUM(COALESCE(lb.total_amount, 0))::numeric AS total_amount, COUNT(DISTINCT ad.dealer_code) AS dealer_count, SUM(COALESCE(cn.amount, 0))::numeric AS submitted_amount, COUNT(DISTINCT CASE WHEN COALESCE(cn.amount, 0) > 0 THEN ad.dealer_code END) AS submitted_dealer_count FROM active_dealers ad LEFT JOIN latest_base lb ON lb.dealer_code = ad.dealer_code LEFT JOIN form_16_credit_notes cn ON cn.submission_id = lb.id GROUP BY ad.zone ) SELECT * FROM by_zone ORDER BY CASE label WHEN 'North' THEN 1 WHEN 'Central' THEN 2 WHEN 'West' THEN 3 WHEN 'East' THEN 4 WHEN 'South' THEN 5 ELSE 99 END, label `, { type: QueryTypes.SELECT } ); const yearWise = (yearRowsRaw || []).map((r) => { const totalAmountRow = toNum(r.total_amount); const submittedAmountRow = toNum(r.submitted_amount); const dealerCountRow = Math.max(0, Math.trunc(toNum(r.dealer_count))); const submittedDealerCountRow = Math.max(0, Math.trunc(toNum(r.submitted_dealer_count))); return { label: r.label, totalAmount: totalAmountRow, dealerCount: dealerCountRow, submittedAmount: submittedAmountRow, submittedDealerCount: submittedDealerCountRow, pendingAmount: Math.max(0, totalAmountRow - submittedAmountRow), pendingDealerCount: Math.max(0, dealerCountRow - submittedDealerCountRow), }; }); const zoneWise = (zoneRowsRaw || []).map((r) => { const totalAmountRow = toNum(r.total_amount); const submittedAmountRow = toNum(r.submitted_amount); const dealerCountRow = Math.max(0, Math.trunc(toNum(r.dealer_count))); const submittedDealerCountRow = Math.max(0, Math.trunc(toNum(r.submitted_dealer_count))); return { label: r.label, totalAmount: totalAmountRow, dealerCount: dealerCountRow, submittedAmount: submittedAmountRow, submittedDealerCount: submittedDealerCountRow, pendingAmount: Math.max(0, totalAmountRow - submittedAmountRow), pendingDealerCount: Math.max(0, dealerCountRow - submittedDealerCountRow), }; }); return { kpi: { collectionPct: toPct(submittedDealerCount, totalDealers), pendingPct: toPct(pendingDealerCount, totalDealers), submittedPct: toPct(submittedAmount, totalAmount), submissionPendingPct: toPct(pendingAmount, totalAmount), }, overall: { totalAmount, submittedAmount, pendingAmount, totalDealers, submittedDealerCount, pendingDealerCount, }, yearWise, zoneWise, }; }