/** * Form 16 (Form 16A TDS Credit) service. * Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger. */ import crypto from 'crypto'; import { Op, fn, col, QueryTypes } from 'sequelize'; import { sequelize } from '../config/database'; import { Form16CreditNote, Form16DebitNote, Form16aSubmission, WorkflowRequest, Document, Form1626asQuarterSnapshot, Form16QuarterStatus, Form16LedgerEntry, } 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 } from '../utils/helpers'; import { gcsStorageService } from './gcsStorage.service'; import { activityService } from './activity.service'; import { simulateCreditNoteFromSap, simulateDebitNoteFromSap } from './form16SapSimulation.service'; import logger from '../utils/logger'; /** * Resolve dealer_code for the current user (by email match with dealers.dealer_principal_email_id). * Returns null if user is not a dealer or no dealer found. */ export async function getDealerCodeForUser(userId: string): Promise { const user = await User.findByPk(userId, { attributes: ['userId', 'email'] }); if (!user || !user.email) return null; const dealer = await Dealer.findOne({ where: { dealerPrincipalEmailId: { [Op.iLike]: user.email }, 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'; /** Get aggregated TDS amount for (tan, fy, quarter) from all 26AS entries (Section 194Q, Booking F/O). */ export async function getLatest26asAggregatedForQuarter( tanNumber: string, financialYear: string, quarter: string ): Promise { const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); const fy = normalizeFinancialYear(financialYear) || financialYear; const q = normalizeQuarter(quarter) || quarter; const [row] = await sequelize.query<{ sum: string }>( `SELECT COALESCE(SUM(tax_deducted), 0)::text AS sum FROM tds_26as_entries WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', '')) AND financial_year = :fy AND quarter = :qtr AND section_code = :section AND (status_oltas = 'F' OR status_oltas = 'O')`, { replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT } ); return parseFloat(row?.sum ?? '0') || 0; } /** 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 }; } 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 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, 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 { 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() || ''; } /** * 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 = tanNumberRaw.replace(/\s+/g, ' '); const tdsAmount = parseFloat(sub.tdsAmount) || 0; if (!tanNumber || 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 (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(); const financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw; const 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' }; } // Official quarter total from 26AS (Section 194Q, Booking F/O only) const aggregated26as = await getLatest26asAggregatedForQuarter(tanNumber, financialYear, quarter); if (aggregated26as <= 0) { 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).` ); await submission.update({ validationStatus: 'failed', validationNotes: 'No 26AS data found for this TAN, financial year and quarter. Please ensure 26AS has been uploaded for this period.', }); return { validationStatus: 'failed', validationNotes: 'No 26AS record found for this TAN, financial year and quarter.' }; } const amountTolerance = 1; // allow 1 rupee rounding if (Math.abs(tdsAmount - aggregated26as) > amountTolerance) { 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. 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. 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) <= amountTolerance) { 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.' }; } } const cnNumber = `CN-${new Date().getFullYear()}-${submission.id}-${Date.now().toString(36).toUpperCase()}`; 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, }); 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 ): Promise { const dealerCode = await getDealerCodeForUser(userId); if (!dealerCode) { throw new Error('Dealer not found for this user. Only dealers can submit Form 16.'); } const version = await getNextVersionForDealerFyQuarter(dealerCode, 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(dealerCode, 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}` : ''}.` ); } 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']], }); 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, 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); } /** * 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 }; } /** * RE only. Manually generate credit note for a Form 16 request (e.g. when OCR was partial but RE verified). * Sets validationStatus to 'manually_approved'. */ export async function generateForm16CreditNoteManually( requestId: string, userId: string, amount: number ) { if (!amount || amount <= 0) throw new Error('Valid amount is required to generate credit note.'); const submission = await Form16aSubmission.findOne({ where: { requestId }, attributes: ['id', 'requestId', 'dealerCode', 'financialYear', 'quarter', 'tdsAmount'], }); if (!submission) throw new Error('Form 16 submission not found for this request.'); const sub = submission as any; const existing = await Form16CreditNote.findOne({ where: { submissionId: submission.id }, attributes: ['id'] }); if (existing) throw new Error('A credit note already exists for this submission.'); const dealerCode = (sub.dealerCode || '').toString().trim(); const financialYear = (sub.financialYear || '').trim(); const quarter = (sub.quarter || '').trim(); if (dealerCode && (await hasActiveCreditNoteForDealerFyQuarter(dealerCode, financialYear, quarter))) { throw new Error( 'A credit note has already been issued for this financial year and quarter (e.g. from another submission or a later upload that matched 26AS). You cannot generate another credit note. If the previous credit note was withdrawn (debit note issued), the dealer must submit Form 16 again to generate a new credit note.' ); } const dealer = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'], }); const dealerDetails = { dealerCode: dealerCode || 'UNKNOWN', dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode, dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined, dealerContact: (dealer as any)?.dpContactNumber ?? undefined, }; const sapResponse = simulateCreditNoteFromSap(dealerDetails, amount); const creditNote = await Form16CreditNote.create({ submissionId: submission.id, creditNoteNumber: sapResponse.creditNoteNumber, sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined, amount, issueDate: new Date(sapResponse.issueDate), financialYear, quarter, status: sapResponse.status || 'issued', remarks: 'Manually approved; credit note generated via SAP (simulation).', issuedBy: userId, }); await submission.update({ validationStatus: 'manually_approved', validationNotes: 'Credit note manually generated by RE user.', }); return { creditNote, 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, }; }), }; } /** * RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation with dealer code, dealer info, credit note number, amount; creates Form16DebitNote from response. * When real SAP is integrated, replace simulateDebitNoteFromSap with the actual SAP API call. */ export async function generateForm16DebitNoteForCreditNote( creditNoteId: number, userId: string, amount: number ): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> { if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.'); const creditNote = await Form16CreditNote.findByPk(creditNoteId, { include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }], }); if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.'); const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] }); if (existing) throw new Error('A debit note already exists for this credit note.'); const dealerCode = ((creditNote as any).submission?.dealerCode || '').toString().trim(); const dealer = await Dealer.findOne({ where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true }, attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'], }); const dealerInfo = { dealerCode: dealerCode || 'UNKNOWN', dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode, dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined, dealerContact: (dealer as any)?.dpContactNumber ?? undefined, }; const sapResponse = simulateDebitNoteFromSap({ dealerCode: dealerCode || 'UNKNOWN', dealerInfo, creditNoteNumber: (creditNote as any).creditNoteNumber, amount, }); const debitNote = await Form16DebitNote.create({ creditNoteId, debitNoteNumber: sapResponse.debitNoteNumber, sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined, amount, issueDate: new Date(sapResponse.issueDate), status: sapResponse.status || 'issued', reason: 'Debit note generated via SAP (simulation).', createdBy: userId, }); return { debitNote, creditNote }; } // ---------- 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 dealers (initiator user IDs) who have at least one pending Form 16 submission (no credit note yet, request open). * Returns one entry per (userId, requestId) so the reminder can include the request ID. Used by the reminder scheduled job. */ export async function getDealersWithPendingForm16Submissions(): Promise<{ userId: string; requestId: 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', 'initiatorId'], raw: true, }); return requests.map((r) => ({ userId: (r as any).initiatorId, requestId: (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 = {}; if (filters?.financialYear) where.financialYear = filters.financialYear; if (filters?.quarter) where.quarter = filters.quarter; if (filters?.tanNumber) where.tanNumber = { [Op.iLike]: `%${filters.tanNumber}%` }; if (filters?.search?.trim()) where.deductorName = { [Op.iLike]: `%${filters.search.trim()}%` }; if (filters?.status) where.statusOltas = filters.status; if (filters?.assessmentYear) where.assessmentYear = filters.assessmentYear; if (filters?.sectionCode) where.sectionCode = filters.sectionCode; return where; } export async function list26asEntries(filters?: List26asFilters): Promise<{ rows: Tds26asEntry[]; total: number; summary: List26asSummary; }> { const where = build26asWhere(filters); const hasWhere = Object.keys(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: { 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.create({ 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, }); return entry; } export async function update26asEntry( id: number, data: Partial<{ 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; 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 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]; // 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({ 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.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 }; 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({ 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 = [ 'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode', 'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment', 'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId', ] as const; function build26asCreatePayload(row: Record, uploadLogId?: number | null): Record { const payload: Record = {}; for (const k of TDS_26AS_CREATE_KEYS) { if (k === 'uploadLogId') continue; const v = row[k]; if (v !== undefined && v !== null) payload[k] = v; } payload.tanNumber = row.tanNumber ?? ''; payload.quarter = row.quarter ?? 'Q1'; payload.financialYear = row.financialYear ?? ''; 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; 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)); 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, quarter); const latest = await getLatest26asSnapshot(tanNumber, fy, quarter); 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, quarter); if (status?.status === 'SETTLED' && status.lastCreditNoteId) { const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, { attributes: ['id', 'amount'] }); if (creditNote) { const amount = parseFloat(String((creditNote as any).amount ?? 0)); const debitNum = `DN-${new Date().getFullYear()}-${creditNote.id}-${Date.now().toString(36).toUpperCase()}`; 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, quarter, debit.id); debitsCreated++; } } 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): Promise { const rows = await Form1626asUploadLog.findAll({ limit, order: [['uploadedAt', 'DESC']], include: [{ model: User, as: 'uploadedByUser', attributes: ['email', 'displayName'], required: false }], }); return 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, }; }); }