form 16 changes pulled from remote and merged adding build

This commit is contained in:
laxmanhalaki 2026-03-25 20:01:15 +05:30
commit 975f266640
29 changed files with 1107 additions and 618 deletions

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{a as s}from"./index-CwFNZe2z.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BrA5VgBk.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion}; import{a as s}from"./index-BiBh7ROe.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,15 +13,15 @@
<!-- Preload essential fonts and icons --> <!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-CwFNZe2z.js"></script> <script type="module" crossorigin src="/assets/index-BiBh7ROe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BrA5VgBk.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-DgwXkk2Y.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js"> <link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-HW_ujxKo.js">
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css"> <link rel="stylesheet" crossorigin href="/assets/index-ZIAcQZA4.css">
</head> </head>
<body> <body>

View File

@ -556,12 +556,12 @@ const DEFAULT_FORM16_CONFIG = {
alertSubmitForm16FrequencyDays: 0, alertSubmitForm16FrequencyDays: 0,
alertSubmitForm16FrequencyHours: 24, alertSubmitForm16FrequencyHours: 24,
alertSubmitForm16RunAtTime: '09:00', alertSubmitForm16RunAtTime: '09:00',
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].', alertSubmitForm16Template: 'Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].',
reminderNotificationEnabled: true, reminderNotificationEnabled: true,
reminderFrequencyDays: 0, reminderFrequencyDays: 0,
reminderFrequencyHours: 12, reminderFrequencyHours: 12,
reminderRunAtTime: '10:00', reminderRunAtTime: '10:00',
reminderNotificationTemplate: 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.', reminderNotificationTemplate: 'Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.',
}; };
/** /**

View File

@ -16,12 +16,32 @@ import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { WorkflowRequest } from '@models/WorkflowRequest'; import { WorkflowRequest } from '@models/WorkflowRequest';
import { Form16aSubmission } from '@models/Form16aSubmission'; import { Form16aSubmission } from '@models/Form16aSubmission';
import { Dealer } from '@models/Dealer';
/** /**
* Form 16 controller: credit notes, OCR extract, and create submission for dealers. * Form 16 controller: credit notes, OCR extract, and create submission for dealers.
*/ */
export class Form16Controller { export class Form16Controller {
private toSapCsv(sap: {
trnsUniqNo?: string | null;
tdsTransId?: string | null;
sapDocumentNumber?: string | null;
msgTyp?: string | null;
message?: string | null;
}): string {
const header = ['TRNS_UNIQ_NO', 'TDS_TRNS_ID', 'DOC_NO', 'MSG_TYP', 'MESSAGE'].join('|');
const row = [
sap.trnsUniqNo || '',
sap.tdsTransId || '',
sap.sapDocumentNumber || '',
sap.msgTyp || '',
sap.message || '',
]
.map((v) => String(v).replace(/\r?\n/g, ' ').replace(/\|/g, ' '))
.join('|');
return `${header}\n${row}\n`;
}
/** /**
* GET /api/v1/form16/permissions * GET /api/v1/form16/permissions
* Returns Form 16 permissions for the current user (API-driven from admin config). * Returns Form 16 permissions for the current user (API-driven from admin config).
@ -174,12 +194,31 @@ export class Form16Controller {
if (!userId) { if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required'); return ResponseHandler.unauthorized(res, 'Authentication required');
} }
const body = (req.body || {}) as { dealerCode?: string; financialYear?: string }; const body = (req.body || {}) as { dealerCode?: string; dealerId?: string; email?: string; financialYear?: string };
const dealerCode = (body.dealerCode || '').trim(); const financialYear = (body.financialYear || '').trim() || undefined;
let dealerCode = (body.dealerCode || '').trim();
const dealerId = (body.dealerId || '').trim();
const dealerEmail = (body.email || '').trim().toLowerCase();
// Fallback 1: resolve by Dealer PK (when FE sends id but dealerCode is empty).
if (!dealerCode && dealerId) {
const dealer = await Dealer.findByPk(dealerId, { attributes: ['salesCode', 'dlrcode'] });
dealerCode = String((dealer as any)?.salesCode || (dealer as any)?.dlrcode || '').trim();
}
// Fallback 2: resolve from non-submitted list (supports id/email based payloads reliably).
if (!dealerCode) {
const list = await form16Service.listNonSubmittedDealers(financialYear);
const match = list.dealers.find((d) =>
(dealerId && String(d.id).trim() === dealerId) ||
(dealerEmail && String(d.email || '').trim().toLowerCase() === dealerEmail)
);
dealerCode = String(match?.dealerCode || '').trim();
}
if (!dealerCode) { if (!dealerCode) {
return ResponseHandler.error(res, 'dealerCode is required', 400); return ResponseHandler.error(res, 'dealerCode is required', 400);
} }
const financialYear = (body.financialYear || '').trim() || undefined;
const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId); const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId);
if (!updated) { if (!updated) {
return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404); return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404);
@ -245,6 +284,7 @@ export class Form16Controller {
} }
const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0)); const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0));
const entry = await form16Service.create26asEntry({ const entry = await form16Service.create26asEntry({
panNumber: (body.panNumber as string) || undefined,
tanNumber, tanNumber,
deductorName: (body.deductorName as string) || undefined, deductorName: (body.deductorName as string) || undefined,
quarter, quarter,
@ -281,6 +321,7 @@ export class Form16Controller {
const body = req.body as Record<string, unknown>; const body = req.body as Record<string, unknown>;
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber; if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
if (body.panNumber !== undefined) updateData.panNumber = body.panNumber;
if (body.deductorName !== undefined) updateData.deductorName = body.deductorName; if (body.deductorName !== undefined) updateData.deductorName = body.deductorName;
if (body.quarter !== undefined) updateData.quarter = body.quarter; if (body.quarter !== undefined) updateData.quarter = body.quarter;
if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear; if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear;
@ -382,7 +423,8 @@ export class Form16Controller {
res, res,
{ {
sapResponse, sapResponse,
url: sapResponse.storageUrl || null, // Use API-backed CSV URL so View works even when local /uploads file is unavailable in UAT.
url: `/api/v1/form16/credit-notes/${id}/sap-response/csv`,
}, },
'OK' 'OK'
); );
@ -395,8 +437,8 @@ export class Form16Controller {
/** /**
* GET /api/v1/form16/credit-notes/:id/download * GET /api/v1/form16/credit-notes/:id/download
* Returns a storage URL for the SAP response CSV if available. * Backward-compatible route that now always returns API-backed CSV URL.
* If not yet available, returns 409 so UI can show "being generated, wait". * The CSV itself is generated from persisted DB fields (no /uploads dependency).
*/ */
async downloadCreditNote(req: Request, res: Response): Promise<void> { async downloadCreditNote(req: Request, res: Response): Promise<void> {
try { try {
@ -408,9 +450,9 @@ export class Form16Controller {
if (Number.isNaN(id)) { if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid credit note id', 400); return ResponseHandler.error(res, 'Invalid credit note id', 400);
} }
let url: string | null = null; let sapResponse = null;
try { try {
url = await form16Service.getCreditNoteSapResponseUrlForUser(id, userId); sapResponse = await form16Service.getCreditNoteSapResponseForUser(id, userId);
} catch (e: any) { } catch (e: any) {
const msg = String(e?.message || ''); const msg = String(e?.message || '');
if (msg.toLowerCase().includes('not found')) { if (msg.toLowerCase().includes('not found')) {
@ -418,10 +460,10 @@ export class Form16Controller {
} }
throw e; throw e;
} }
if (!url) { if (!sapResponse) {
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409); return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
} }
return ResponseHandler.success(res, { url }, 'OK'); return ResponseHandler.success(res, { url: `/api/v1/form16/credit-notes/${id}/sap-response/csv` }, 'OK');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] downloadCreditNote error:', error); logger.error('[Form16Controller] downloadCreditNote error:', error);
@ -448,7 +490,8 @@ export class Form16Controller {
res, res,
{ {
sapResponse, sapResponse,
url: sapResponse.storageUrl || null, // Use API-backed CSV URL so View works even when local /uploads file is unavailable in UAT.
url: `/api/v1/form16/debit-notes/${id}/sap-response/csv`,
}, },
'OK' 'OK'
); );
@ -459,6 +502,64 @@ export class Form16Controller {
} }
} }
/**
* GET /api/v1/form16/credit-notes/:id/sap-response/csv
* Stream SAP response CSV generated from persisted DB fields.
*/
async downloadCreditNoteSapResponseCsv(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid credit note id', 400);
}
const sapResponse = await form16Service.getCreditNoteSapResponseForUser(id, userId);
if (!sapResponse) {
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
}
const csv = this.toSapCsv(sapResponse);
const fileName = (sapResponse.fileName || `credit-note-${id}.csv`).replace(/[^a-zA-Z0-9._-]/g, '_');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.status(200).send(csv);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] downloadCreditNoteSapResponseCsv error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit note SAP response CSV', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/debit-notes/:id/sap-response/csv
* Stream SAP response CSV generated from persisted DB fields.
*/
async downloadDebitNoteSapResponseCsv(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid debit note id', 400);
}
const sapResponse = await form16Service.getDebitNoteSapResponse(id);
if (!sapResponse) {
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
}
const csv = this.toSapCsv(sapResponse);
const fileName = (sapResponse.fileName || `debit-note-${id}.csv`).replace(/[^a-zA-Z0-9._-]/g, '_');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.status(200).send(csv);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] downloadDebitNoteSapResponseCsv error:', error);
return ResponseHandler.error(res, 'Failed to fetch debit note SAP response CSV', 500, errorMessage);
}
}
/** /**
* GET /api/v1/form16/requests/:requestId/credit-note * GET /api/v1/form16/requests/:requestId/credit-note
* Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab. * Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab.
@ -750,7 +851,8 @@ export class Form16Controller {
deductorName, deductorName,
version, version,
ocrExtractedData, ocrExtractedData,
} },
(req as AuthenticatedRequest).user?.email
); );
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service'); const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');

View File

@ -6,322 +6,97 @@ import {
Form16CreditNote, Form16CreditNote,
Form16DebitNote, Form16DebitNote,
Form16SapResponse, Form16SapResponse,
Form16DebitNoteSapResponse, From16SapReadFile,
Form16aSubmission,
WorkflowRequest,
} from '../models'; } from '../models';
import { gcsStorageService } from '../services/gcsStorage.service';
// ─── Helpers ───────────────────────────────────────────────────────────────── type CsvRow = Record<string, string | undefined>;
function safeFileName(name: string): string { function extractCsvFields(r: CsvRow) {
return (name || '').trim().replace(/[\\\/:*?"<>|]+/g, '-').slice(0, 180) || 'form16-sap-response.csv'; const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || '').trim() || null;
}
/** Columns we store in dedicated DB fields. Everything else goes into raw_row. */
const KNOWN_CSV_COLUMNS = new Set([
'TRNS_UNIQ_NO', 'TRNSUNIQNO', 'DMS_UNIQ_NO', 'DMSUNIQNO',
'TDS_TRNS_ID',
'CLAIM_NUMBER',
'DOC_NO', 'DOCNO', 'SAP_DOC_NO', 'SAPDOC',
'MSG_TYP', 'MSGTYP', 'MSG_TYPE',
'MESSAGE', 'MSG',
'DOC_DATE', 'DOCDATE',
'TDS_AMT', 'TDSAMT',
]);
/**
* Parse all columns from one CSV data row.
* Returns { known fields } + rawRow (only the columns NOT in KNOWN_CSV_COLUMNS).
*/
function extractCsvFields(r: Record<string, string | undefined>) {
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || r.DMS_UNIQ_NO || r.DMSUNIQNO || '').trim() || null;
const tdsTransId = (r.TDS_TRNS_ID || '').trim() || null; const tdsTransId = (r.TDS_TRNS_ID || '').trim() || null;
const claimNumber = (r.CLAIM_NUMBER || '').trim() || null; const docNo = (r.DOC_NO || r.DOCNO || '').trim() || null;
const sapDocNo = (r.DOC_NO || r.DOCNO || r.SAP_DOC_NO || r.SAPDOC || '').trim() || null; const msgTyp = (r.MSG_TYP || r.MSGTYP || '').trim() || null;
const msgTyp = (r.MSG_TYP || r.MSGTYP || r.MSG_TYPE || '').trim() || null; const message = (r.MESSAGE || '').trim() || null;
const message = (r.MESSAGE || r.MSG || '').trim() || null; return { trnsUniqNo, tdsTransId, docNo, msgTyp, message };
const docDate = (r.DOC_DATE || r.DOCDATE || '').trim() || null; }
const tdsAmt = (r.TDS_AMT || r.TDSAMT || '').trim() || null;
// Extra columns → raw_row (so nothing is ever lost) function isUsableRow(r: CsvRow): boolean {
const rawRow: Record<string, string> = {}; const { tdsTransId } = extractCsvFields(r);
for (const [key, val] of Object.entries(r)) { if (!tdsTransId) return false;
if (!KNOWN_CSV_COLUMNS.has(key.trim().toUpperCase()) && !KNOWN_CSV_COLUMNS.has(key.trim())) { const upper = tdsTransId.toUpperCase();
rawRow[key.trim()] = val || ''; if (upper === 'TDS_TRNS_ID' || upper === 'MSG_TYP' || upper === 'MESSAGE') return false;
return true;
}
async function saveRowsAndUpdateNotes(rows: CsvRow[]): Promise<{ totalRecords: number; totalCreditNotes: number; totalDebitNotes: number }> {
let totalRecords = 0;
let totalCreditNotes = 0;
let totalDebitNotes = 0;
for (const row of rows) {
if (!isUsableRow(row)) continue;
const parsed = extractCsvFields(row);
if (!parsed.tdsTransId) continue;
await (Form16SapResponse as any).create({
trnsUniqNo: parsed.trnsUniqNo,
tdsTransId: parsed.tdsTransId,
docNo: parsed.docNo,
msgTyp: parsed.msgTyp,
message: parsed.message,
createdAt: new Date(),
updatedAt: new Date(),
});
totalRecords++;
const idUpper = parsed.tdsTransId.toUpperCase();
if (idUpper.startsWith('CN')) {
totalCreditNotes++;
await (Form16CreditNote as any).update(
{
sapDocumentNumber: parsed.docNo,
status: 'completed',
},
{ where: { creditNoteNumber: parsed.tdsTransId } }
);
} else if (idUpper.startsWith('DN')) {
totalDebitNotes++;
await (Form16DebitNote as any).update(
{
sapDocumentNumber: parsed.docNo,
status: 'completed',
},
{ where: { debitNoteNumber: parsed.tdsTransId } }
);
} }
} }
return { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow }; return { totalRecords, totalCreditNotes, totalDebitNotes };
} }
// ─── Credit note matching ───────────────────────────────────────────────────── async function processOutgoingFile(fileName: string, resolvedOutgoingDir: string): Promise<{ totalRecords: number; totalCreditNotes: number; totalDebitNotes: number } | null> {
const alreadyRead = await (From16SapReadFile as any).findOne({
async function findCreditNoteId( where: { fileName },
trnsUniqNo: string | null,
tdsTransId: string | null,
claimNumber: string | null,
fileName: string,
): Promise<{ creditNoteId: number | null; requestId: string | null }> {
const CN = Form16CreditNote as any;
let cn: any = null;
// 1. Primary: TDS_TRNS_ID in SAP response = credit note number we sent
if (tdsTransId) {
cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id}`);
}
// 2. TRNS_UNIQ_NO (format: F16-CN-{submissionId}-{creditNoteId}-{ts})
if (!cn && trnsUniqNo) {
const m = trnsUniqNo.match(/^F16-CN-(\d+)-(\d+)-/);
if (m) {
cn = await CN.findByPk(parseInt(m[2]), { attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via TRNS_UNIQ_NO id-parse=${m[2]} → credit_note id=${cn.id}`);
}
if (!cn) {
cn = await CN.findOne({ where: { trnsUniqNo }, attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via trns_uniq_no=${trnsUniqNo} → credit_note id=${cn.id}`);
}
}
// 3. Filename (without .csv) = credit note number
if (!cn) {
const baseName = fileName.replace(/\.csv$/i, '').trim();
if (baseName) {
cn = await CN.findOne({ where: { creditNoteNumber: baseName }, attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via filename=${baseName} → credit_note id=${cn.id}`);
}
}
// 4. CLAIM_NUMBER = credit note number (seen in some SAP/WFM exports)
if (!cn && claimNumber) {
cn = await CN.findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via CLAIM_NUMBER=${claimNumber} → credit_note id=${cn.id}`);
}
if (!cn) return { creditNoteId: null, requestId: null };
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
return { creditNoteId: cn.id, requestId: submission?.requestId ?? null };
}
// ─── Debit note matching ──────────────────────────────────────────────────────
async function findDebitNoteId(
trnsUniqNo: string | null,
tdsTransId: string | null,
claimNumber: string | null,
fileName: string,
): Promise<number | null> {
const DN = Form16DebitNote as any;
const CN = Form16CreditNote as any;
let dn: any = null;
// 1. Primary: TRNS_UNIQ_NO (format: F16-DN-{creditNoteId}-{debitNoteId}-{ts})
if (trnsUniqNo) {
const m = trnsUniqNo.match(/^F16-DN-(\d+)-(\d+)-/);
if (m) {
dn = await DN.findByPk(parseInt(m[2]), { attributes: ['id'] });
if (dn) logger.info(`[Form16 SAP Job] Debit match via TRNS_UNIQ_NO id-parse=${m[2]} → debit_note id=${dn.id}`);
}
if (!dn) {
dn = await DN.findOne({ where: { trnsUniqNo }, attributes: ['id'] });
if (dn) logger.info(`[Form16 SAP Job] Debit match via trns_uniq_no=${trnsUniqNo} → debit_note id=${dn.id}`);
}
}
// 2. TDS_TRNS_ID = credit note number → find linked debit note
if (!dn && tdsTransId) {
const cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id'] });
if (cn) {
dn = await DN.findOne({
where: { creditNoteId: cn.id },
order: [['createdAt', 'DESC']],
attributes: ['id'], attributes: ['id'],
}); });
if (dn) logger.info(`[Form16 SAP Job] Debit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id} → debit_note id=${dn.id}`); if (alreadyRead) {
} logger.debug(`[Form16 SAP Job] Skipping already-read file: ${fileName}`);
return null;
} }
// 3. CLAIM_NUMBER = debit note number const rows = (await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName))) as CsvRow[];
if (!dn && claimNumber) { const counts = await saveRowsAndUpdateNotes(rows || []);
dn = await DN.findOne({ where: { debitNoteNumber: claimNumber }, attributes: ['id'] });
if (dn) logger.info(`[Form16 SAP Job] Debit match via CLAIM_NUMBER=${claimNumber} → debit_note id=${dn.id}`);
}
// 4. Filename (without .csv) = debit note number await (From16SapReadFile as any).create({
if (!dn) { fileName,
const baseName = fileName.replace(/\.csv$/i, '').trim(); totalRecords: counts.totalRecords,
if (baseName) { totalCreditNotes: counts.totalCreditNotes,
dn = await DN.findOne({ where: { debitNoteNumber: baseName }, attributes: ['id'] }); totalDebitNotes: counts.totalDebitNotes,
if (dn) logger.info(`[Form16 SAP Job] Debit match via filename=${baseName} → debit_note id=${dn.id}`); createdAt: new Date(),
}
}
return dn ? dn.id : null;
}
// ─── Core processor ───────────────────────────────────────────────────────────
async function processOutgoingFile(
fileName: string,
type: 'credit' | 'debit',
resolvedOutgoingDir: string,
): Promise<void> {
const CreditModel = Form16SapResponse as any;
const DebitModel = Form16DebitNoteSapResponse as any;
// Idempotency: skip if already fully linked
const existing =
type === 'credit'
? await CreditModel.findOne({ where: { fileName }, attributes: ['id', 'creditNoteId', 'sapDocumentNumber', 'storageUrl'] })
: await DebitModel.findOne({ where: { fileName }, attributes: ['id', 'debitNoteId', 'sapDocumentNumber', 'storageUrl'] });
if (existing && (existing.creditNoteId ?? existing.debitNoteId) && (existing.storageUrl || existing.sapDocumentNumber)) {
logger.debug(`[Form16 SAP Job] Skipping already-processed ${type} file: ${fileName}`);
return;
}
// ── Read CSV ──
const rows = await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName));
if (!rows || rows.length === 0) {
logger.warn(`[Form16 SAP Job] ${type} file ${fileName}: empty or unreadable CSV`);
const emptyPayload = { rawRow: null, updatedAt: new Date() };
if (existing) {
type === 'credit' ? await CreditModel.update(emptyPayload, { where: { id: existing.id } })
: await DebitModel.update(emptyPayload, { where: { id: existing.id } });
} else {
type === 'credit' ? await CreditModel.create({ type, fileName, ...emptyPayload, createdAt: new Date() })
: await DebitModel.create({ fileName, ...emptyPayload, createdAt: new Date() });
}
return;
}
// ── Pick the best data row ──
// Skip the degenerate "|MSG_TYP|MESSAGE|" lines that some SAP exports include after the header.
type CsvRow = Record<string, string | undefined>;
const normalizedRows = rows as CsvRow[];
const pick =
normalizedRows.find((row) => {
const trns = (row.TRNS_UNIQ_NO || row.TRNSUNIQNO || row.DMS_UNIQ_NO || '').trim();
return Boolean(trns);
}) ||
normalizedRows.find((row) => {
const tdsId = (row.TDS_TRNS_ID || '').trim();
const docNo = (row.DOC_NO || row.DOCNO || '').trim();
const msgTyp = (row.MSG_TYP || '').trim();
if (!tdsId) return false;
if (!docNo && !msgTyp) return false;
if (['MSG_TYP', 'MESSAGE', 'TDS_TRNS_ID'].includes(tdsId.toUpperCase())) return false;
return true;
}) ||
normalizedRows[0];
const r = pick as CsvRow;
const { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow } = extractCsvFields(r);
logger.info(
`[Form16 SAP Job] Processing ${type} file ${fileName}: TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}, DOC_NO=${sapDocNo ?? '—'}`
);
// ── Match to a note in DB ──
let creditNoteId: number | null = null;
let debitNoteId: number | null = null;
let requestId: string | null = null;
let requestNumber: string | null = null;
if (type === 'credit') {
const res = await findCreditNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
creditNoteId = res.creditNoteId;
requestId = res.requestId;
if (creditNoteId && sapDocNo) {
await (Form16CreditNote as any).update(
{ sapDocumentNumber: sapDocNo, status: 'completed' },
{ where: { id: creditNoteId } }
);
}
if (!creditNoteId) {
logger.warn(
`[Form16 SAP Job] Credit file ${fileName}: no matching credit note. TDS_TRNS_ID=${tdsTransId ?? '—'}, TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}.`
);
}
} else {
debitNoteId = await findDebitNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
if (debitNoteId && sapDocNo) {
await (Form16DebitNote as any).update(
{ sapDocumentNumber: sapDocNo, status: 'completed' },
{ where: { id: debitNoteId } }
);
// Fetch requestId from linked credit note → submission
const dn = await (Form16DebitNote as any).findByPk(debitNoteId, { attributes: ['creditNoteId'] });
if (dn?.creditNoteId) {
const cn = await (Form16CreditNote as any).findByPk(dn.creditNoteId, { attributes: ['submissionId'] });
if (cn?.submissionId) {
const sub = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
requestId = sub?.requestId ?? null;
}
}
}
if (!debitNoteId) {
logger.warn(
`[Form16 SAP Job] Debit file ${fileName}: no matching debit note. TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}.`
);
}
}
if (requestId) {
const req = await (WorkflowRequest as any).findOne({ where: { requestId }, attributes: ['requestNumber'] });
requestNumber = req?.requestNumber ?? null;
}
// ── Upload raw CSV to storage ──
const absPath = path.join(resolvedOutgoingDir, fileName);
let storageUrl: string | null = null;
try {
if (fs.existsSync(absPath)) {
const buffer = fs.readFileSync(absPath);
const upload = await gcsStorageService.uploadFileWithFallback({
buffer,
originalName: safeFileName(fileName),
mimeType: 'text/csv',
requestNumber: requestNumber || trnsUniqNo || 'FORM16',
fileType: 'documents',
});
storageUrl = upload.storageUrl || null;
}
} catch (e) {
logger.error('[Form16 SAP Job] Failed to upload response file:', fileName, e);
}
// ── Persist to DB ──
const commonFields = {
trnsUniqNo,
tdsTransId,
claimNumber,
sapDocumentNumber: sapDocNo,
msgTyp,
message,
docDate,
tdsAmt,
rawRow: Object.keys(rawRow).length ? rawRow : null,
storageUrl,
updatedAt: new Date(), updatedAt: new Date(),
}; });
if (type === 'credit') { return counts;
const payload = { type: 'credit' as const, fileName, creditNoteId, ...commonFields };
if (existing) await CreditModel.update(payload, { where: { id: existing.id } });
else await CreditModel.create({ ...payload, createdAt: new Date() });
} else {
const payload = { fileName, debitNoteId, ...commonFields };
if (existing) await DebitModel.update(payload, { where: { id: existing.id } });
else await DebitModel.create({ ...payload, createdAt: new Date() });
}
logger.info(
`[Form16 SAP Job] Saved ${type} SAP response for file ${fileName}${type === 'credit' ? `credit_note_id=${creditNoteId}` : `debit_note_id=${debitNoteId}`}, storage_url=${storageUrl ? 'yes' : 'no'}`
);
} }
// ─── Public API (called by Pull button controller) ──────────────────────────── // ─── Public API (called by Pull button controller) ────────────────────────────
@ -337,40 +112,36 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
processed: number; processed: number;
creditProcessed: number; creditProcessed: number;
debitProcessed: number; debitProcessed: number;
filesProcessed: number;
}> { }> {
let creditProcessed = 0; let creditProcessed = 0;
let debitProcessed = 0; let debitProcessed = 0;
let filesProcessed = 0;
const RELATIVE_CREDIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT'); const RELATIVE_FORM16_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16');
const RELATIVE_DEBIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT'); const resolvedDirs = [
path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit')),
const dirs: Array<{ dir: string; type: 'credit' | 'debit'; relSubdir: string }> = [ path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit')),
{
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit')),
type: 'credit',
relSubdir: RELATIVE_CREDIT_OUT,
},
{
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit')),
type: 'debit',
relSubdir: RELATIVE_DEBIT_OUT,
},
]; ];
const dirs: Array<{ dir: string; relSubdir: string }> = [...new Set(resolvedDirs)].map((d) => ({
dir: d,
relSubdir: RELATIVE_FORM16_OUT,
}));
try { try {
const base = process.env.WFM_BASE_PATH || 'C:\\WFM'; const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
for (const { dir, type, relSubdir } of dirs) { for (const { dir, relSubdir } of dirs) {
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir); let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
if (!fs.existsSync(abs)) { if (!fs.existsSync(abs)) {
const cwdFallback = path.join(process.cwd(), relSubdir); const cwdFallback = path.join(process.cwd(), relSubdir);
if (fs.existsSync(cwdFallback)) { if (fs.existsSync(cwdFallback)) {
abs = cwdFallback; abs = cwdFallback;
logger.info(`[Form16 SAP Job] ${type} OUTGOING dir resolved via cwd: ${abs}`); logger.info(`[Form16 SAP Job] OUTGOING dir resolved via cwd: ${abs}`);
} else { } else {
logger.warn( logger.warn(
`[Form16 SAP Job] ${type} OUTGOING dir not found. Tried: ${abs} | ${cwdFallback}. ` + `[Form16 SAP Job] OUTGOING dir not found. Tried: ${abs} | ${cwdFallback}. ` +
`Set WFM_BASE_PATH to the folder containing WFM-QRE.` `Set WFM_BASE_PATH to the folder containing WFM-QRE.`
); );
continue; continue;
@ -378,17 +149,17 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
} }
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv')); const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
logger.info( logger.info(`[Form16 SAP Job] OUTGOING dir: ${abs}${files.length} CSV file(s)${files.length ? ': ' + files.join(', ') : ''}`);
`[Form16 SAP Job] ${type} OUTGOING dir: ${abs}${files.length} CSV file(s)${files.length ? ': ' + files.join(', ') : ''}`
);
for (const f of files) { for (const f of files) {
try { try {
await processOutgoingFile(f, type, abs); const counts = await processOutgoingFile(f, abs);
if (type === 'credit') creditProcessed++; if (!counts) continue;
else debitProcessed++; filesProcessed++;
creditProcessed += counts.totalCreditNotes;
debitProcessed += counts.totalDebitNotes;
} catch (e) { } catch (e) {
logger.error(`[Form16 SAP Job] Error processing ${type} file ${f}:`, e); logger.error(`[Form16 SAP Job] Error processing file ${f}:`, e);
} }
} }
} }
@ -404,5 +175,6 @@ export async function runForm16SapResponseIngestionOnce(): Promise<{
processed: creditProcessed + debitProcessed, processed: creditProcessed + debitProcessed,
creditProcessed, creditProcessed,
debitProcessed, debitProcessed,
filesProcessed,
}; };
} }

View File

@ -0,0 +1,87 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
// 1) Create read-log table for processed SAP CSV files
await queryInterface.createTable('from16_sap_read_file', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
file_name: { type: DataTypes.STRING(255), allowNull: false, unique: true },
total_records: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
total_credit_notes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
total_debit_notes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
}).catch(() => {});
// 2) Add required new fields to form16_sap_responses
await queryInterface.addColumn('form16_sap_responses', 'doc_no', {
type: DataTypes.STRING(200),
allowNull: true,
}).catch(() => {});
// 3) Drop old fields from form16_sap_responses (as requested)
for (const col of [
'type',
'file_name',
'credit_note_id',
'debit_note_id',
'claim_number',
'sap_document_number',
'doc_date',
'tds_amt',
'raw_row',
'storage_url',
]) {
await queryInterface.removeColumn('form16_sap_responses', col).catch(() => {});
}
// 4) Ensure required columns exist for new contract
await queryInterface.addColumn('form16_sap_responses', 'trns_uniq_no', {
type: DataTypes.STRING(200),
allowNull: true,
}).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'tds_trns_id', {
type: DataTypes.STRING(200),
allowNull: true,
}).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'msg_typ', {
type: DataTypes.STRING(20),
allowNull: true,
}).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'message', {
type: DataTypes.TEXT,
allowNull: true,
}).catch(() => {});
await queryInterface.addIndex('form16_sap_responses', ['tds_trns_id'], {
name: 'idx_form16_sap_responses_tds_trns_id',
}).catch(() => {});
await queryInterface.addIndex('form16_sap_responses', ['trns_uniq_no'], {
name: 'idx_form16_sap_responses_trns_uniq_no',
}).catch(() => {});
await queryInterface.addIndex('from16_sap_read_file', ['file_name'], {
name: 'idx_from16_sap_read_file_name',
}).catch(() => {});
},
down: async (queryInterface: QueryInterface) => {
// Recreate old columns in form16_sap_responses
await queryInterface.addColumn('form16_sap_responses', 'type', { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'credit' }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'file_name', { type: DataTypes.STRING(255), allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'credit_note_id', { type: DataTypes.INTEGER, allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'debit_note_id', { type: DataTypes.INTEGER, allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'claim_number', { type: DataTypes.STRING(100), allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'sap_document_number', { type: DataTypes.STRING(100), allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'doc_date', { type: DataTypes.STRING(20), allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'tds_amt', { type: DataTypes.STRING(50), allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'raw_row', { type: DataTypes.JSONB, allowNull: true }).catch(() => {});
await queryInterface.addColumn('form16_sap_responses', 'storage_url', { type: DataTypes.STRING(500), allowNull: true }).catch(() => {});
await queryInterface.removeColumn('form16_sap_responses', 'doc_no').catch(() => {});
await queryInterface.dropTable('from16_sap_read_file').catch(() => {});
},
};

View File

@ -0,0 +1,18 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('tds_26as_entries', 'pan_number', {
type: DataTypes.STRING(20),
allowNull: true,
comment: 'PAN from 26AS header (assessee PAN)',
});
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
},
};

View File

@ -0,0 +1,46 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes, QueryTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
// Use information_schema so this migration is safe even if a previous run
// recorded as "executed" but didn't actually alter the schema.
const sequelize = (queryInterface as any).sequelize;
const [colRow] = await sequelize.query(
`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 }
);
if (!colRow?.exists) {
await queryInterface.addColumn('tds_26as_entries', 'pan_number', {
type: DataTypes.STRING(20),
allowNull: true,
comment: 'PAN from 26AS header (assessee PAN)',
});
}
const [idxRow] = await sequelize.query(
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'tds_26as_entries'
AND indexname = 'idx_tds_26as_pan'`,
{ type: QueryTypes.SELECT }
);
if (!idxRow?.exists) {
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
}
},
down: async (queryInterface: QueryInterface) => {
// Best-effort rollback. If column/index already absent, these may throw.
// We intentionally keep down strict because rollback isn't required for forward fixes.
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
},
};

View File

@ -1,23 +1,13 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database'; import { sequelize } from '@config/database';
import { Form16CreditNote } from './Form16CreditNote';
export interface Form16SapResponseAttributes { export interface Form16SapResponseAttributes {
id: number; id: number;
type: 'credit'; trnsUniqNo: string | null;
fileName: string; tdsTransId: string | null;
creditNoteId?: number | null; docNo: string | null;
// Well-known SAP CSV columns stored as individual fields msgTyp: string | null;
trnsUniqNo?: string | null; // TRNS_UNIQ_NO our unique ID echoed back by SAP message: string | null;
tdsTransId?: string | null; // TDS_TRNS_ID credit note number echoed back (primary match key)
claimNumber?: string | null; // CLAIM_NUMBER (alias / fallback)
sapDocumentNumber?: string | null;// DOC_NO SAP-generated document number
msgTyp?: string | null; // MSG_TYP
message?: string | null; // MESSAGE
docDate?: string | null; // DOC_DATE
tdsAmt?: string | null; // TDS_AMT
rawRow?: Record<string, unknown> | null; // any extra / unknown columns from the CSV
storageUrl?: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -26,17 +16,11 @@ interface Form16SapResponseCreationAttributes
extends Optional< extends Optional<
Form16SapResponseAttributes, Form16SapResponseAttributes,
| 'id' | 'id'
| 'creditNoteId'
| 'trnsUniqNo' | 'trnsUniqNo'
| 'tdsTransId' | 'tdsTransId'
| 'claimNumber' | 'docNo'
| 'sapDocumentNumber'
| 'msgTyp' | 'msgTyp'
| 'message' | 'message'
| 'docDate'
| 'tdsAmt'
| 'rawRow'
| 'storageUrl'
| 'createdAt' | 'createdAt'
| 'updatedAt' | 'updatedAt'
> {} > {}
@ -46,41 +30,23 @@ class Form16SapResponse
implements Form16SapResponseAttributes implements Form16SapResponseAttributes
{ {
public id!: number; public id!: number;
public type!: 'credit'; public trnsUniqNo!: string | null;
public fileName!: string; public tdsTransId!: string | null;
public creditNoteId?: number | null; public docNo!: string | null;
public trnsUniqNo?: string | null; public msgTyp!: string | null;
public tdsTransId?: string | null; public message!: string | null;
public claimNumber?: string | null;
public sapDocumentNumber?: string | null;
public msgTyp?: string | null;
public message?: string | null;
public docDate?: string | null;
public tdsAmt?: string | null;
public rawRow?: Record<string, unknown> | null;
public storageUrl?: string | null;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
public creditNote?: Form16CreditNote;
} }
Form16SapResponse.init( Form16SapResponse.init(
{ {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
type: { type: DataTypes.STRING(10), allowNull: false },
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
creditNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'credit_note_id' },
trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' }, trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' },
tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' }, tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' },
claimNumber: { type: DataTypes.STRING(200), allowNull: true, field: 'claim_number' }, docNo: { type: DataTypes.STRING(200), allowNull: true, field: 'doc_no' },
sapDocumentNumber:{ type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' }, msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
message: { type: DataTypes.TEXT, allowNull: true }, message: { type: DataTypes.TEXT, allowNull: true },
docDate: { type: DataTypes.STRING(20), allowNull: true, field: 'doc_date' },
tdsAmt: { type: DataTypes.STRING(50), allowNull: true, field: 'tds_amt' },
rawRow: { type: DataTypes.JSONB, allowNull: true, field: 'raw_row' },
storageUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'storage_url' },
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' }, createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' }, updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
}, },
@ -94,10 +60,4 @@ Form16SapResponse.init(
} }
); );
Form16SapResponse.belongsTo(Form16CreditNote, {
as: 'creditNote',
foreignKey: 'creditNoteId',
targetKey: 'id',
});
export { Form16SapResponse }; export { Form16SapResponse };

View File

@ -0,0 +1,50 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
export interface From16SapReadFileAttributes {
id: number;
fileName: string;
totalRecords: number;
totalCreditNotes: number;
totalDebitNotes: number;
createdAt: Date;
updatedAt: Date;
}
interface From16SapReadFileCreationAttributes
extends Optional<From16SapReadFileAttributes, 'id' | 'createdAt' | 'updatedAt'> {}
class From16SapReadFile
extends Model<From16SapReadFileAttributes, From16SapReadFileCreationAttributes>
implements From16SapReadFileAttributes
{
public id!: number;
public fileName!: string;
public totalRecords!: number;
public totalCreditNotes!: number;
public totalDebitNotes!: number;
public createdAt!: Date;
public updatedAt!: Date;
}
From16SapReadFile.init(
{
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
totalRecords: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_records' },
totalCreditNotes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_credit_notes' },
totalDebitNotes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: 'total_debit_notes' },
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
},
{
sequelize,
tableName: 'from16_sap_read_file',
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
export { From16SapReadFile };

View File

@ -3,6 +3,7 @@ import { sequelize } from '@config/database';
export interface Tds26asEntryAttributes { export interface Tds26asEntryAttributes {
id: number; id: number;
panNumber?: string;
tanNumber: string; tanNumber: string;
deductorName?: string; deductorName?: string;
quarter: string; quarter: string;
@ -26,6 +27,7 @@ interface Tds26asEntryCreationAttributes
extends Optional< extends Optional<
Tds26asEntryAttributes, Tds26asEntryAttributes,
| 'id' | 'id'
| 'panNumber'
| 'deductorName' | 'deductorName'
| 'assessmentYear' | 'assessmentYear'
| 'sectionCode' | 'sectionCode'
@ -46,6 +48,7 @@ class Tds26asEntry
implements Tds26asEntryAttributes implements Tds26asEntryAttributes
{ {
public id!: number; public id!: number;
public panNumber?: string;
public tanNumber!: string; public tanNumber!: string;
public deductorName?: string; public deductorName?: string;
public quarter!: string; public quarter!: string;
@ -72,6 +75,11 @@ Tds26asEntry.init(
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
}, },
panNumber: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'pan_number',
},
tanNumber: { tanNumber: {
type: DataTypes.STRING(20), type: DataTypes.STRING(20),
allowNull: false, allowNull: false,

View File

@ -41,6 +41,7 @@ import { Form16QuarterStatus } from './Form16QuarterStatus';
import { Form16LedgerEntry } from './Form16LedgerEntry'; import { Form16LedgerEntry } from './Form16LedgerEntry';
import { Form16SapResponse } from './Form16SapResponse'; import { Form16SapResponse } from './Form16SapResponse';
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse'; import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
import { From16SapReadFile } from './From16SapReadFile';
// Define associations // Define associations
const defineAssociations = () => { const defineAssociations = () => {
@ -235,7 +236,8 @@ export {
Form16QuarterStatus, Form16QuarterStatus,
Form16LedgerEntry, Form16LedgerEntry,
Form16SapResponse, Form16SapResponse,
Form16DebitNoteSapResponse Form16DebitNoteSapResponse,
From16SapReadFile
}; };
// Export default sequelize instance // Export default sequelize instance

View File

@ -87,11 +87,22 @@ router.get(
requireForm16SubmissionAccess, requireForm16SubmissionAccess,
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller)) asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
); );
router.get(
'/debit-notes/:id/sap-response/csv',
requireForm16ReOnly,
requireForm16SubmissionAccess,
asyncHandler(form16Controller.downloadDebitNoteSapResponseCsv.bind(form16Controller))
);
router.get( router.get(
'/credit-notes/:id/sap-response', '/credit-notes/:id/sap-response',
requireForm16SubmissionAccess, requireForm16SubmissionAccess,
asyncHandler(form16Controller.viewCreditNoteSapResponse.bind(form16Controller)) asyncHandler(form16Controller.viewCreditNoteSapResponse.bind(form16Controller))
); );
router.get(
'/credit-notes/:id/sap-response/csv',
requireForm16SubmissionAccess,
asyncHandler(form16Controller.downloadCreditNoteSapResponseCsv.bind(form16Controller))
);
router.get( router.get(
'/credit-notes/:id', '/credit-notes/:id',
requireForm16SubmissionAccess, requireForm16SubmissionAccess,

View File

@ -180,8 +180,11 @@ async function runMigrations(): Promise<void> {
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no'); const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses'); const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields'); const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
const m66 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes'); const m66 = require('../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log');
const m67 = require('../migrations/20260325175000-update-credit-notes-and-add-items'); const m67 = require('../migrations/20260324110001-add-pan-number-to-26as');
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -254,8 +257,11 @@ async function runMigrations(): Promise<void> {
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 }, { name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 }, { name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 }, { name: '20260318200001-add-sap-response-csv-fields', module: m65 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 }, { name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m67 }, { name: '20260324110001-add-pan-number-to-26as', module: m67 },
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
]; ];
// Dynamically import sequelize after secrets are loaded // Dynamically import sequelize after secrets are loaded

View File

@ -70,7 +70,11 @@ import * as m62 from '../migrations/20260317100001-create-form16-sap-responses';
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no'; import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses'; import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields'; import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
import * as m66 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes'; import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
import * as m68 from '../migrations/20260325090001-ensure-pan-number-in-26as';
import * as m69 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
import * as m70 from '../migrations/20260325175000-update-credit-notes-and-add-items';
interface Migration { interface Migration {
name: string; name: string;
@ -148,7 +152,11 @@ const migrations: Migration[] = [
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 }, { name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 }, { name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 }, { name: '20260318200001-add-sap-response-csv-fields', module: m65 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 }, { name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
]; ];
/** /**
@ -207,7 +215,7 @@ async function markMigrationExecuted(sequelize: any, name: string): Promise<void
/** /**
* Run all pending migrations * Run all pending migrations
*/ */
async function run() { export async function runMigrations() {
try { try {
console.log('🔐 Initializing secrets...'); console.log('🔐 Initializing secrets...');
await initializeGoogleSecretManager(); await initializeGoogleSecretManager();
@ -232,7 +240,6 @@ async function run() {
if (pendingMigrations.length === 0) { if (pendingMigrations.length === 0) {
console.log('✅ Migrations up-to-date'); console.log('✅ Migrations up-to-date');
process.exit(0);
return; return;
} }
@ -252,11 +259,15 @@ async function run() {
} }
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`); console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
process.exit(0);
} catch (err: any) { } catch (err: any) {
console.error('❌ Migration failed:', err.message); console.error('❌ Migration failed:', err.message);
process.exit(1); throw err;
} }
} }
run(); // When executed directly: behave like a script (set exit codes).
if (require.main === module) {
runMigrations()
.then(() => process.exit(0))
.catch(() => process.exit(1));
}

View File

@ -62,6 +62,21 @@ const startServer = async (): Promise<void> => {
// Initialize database connection explicitly after secrets are loaded // Initialize database connection explicitly after secrets are loaded
await initializeAppDatabase(); await initializeAppDatabase();
// Apply pending DB migrations automatically in non-production to prevent schema drift.
// Can be disabled via RUN_MIGRATIONS_ON_STARTUP=false
const autoMigrate =
process.env.RUN_MIGRATIONS_ON_STARTUP === 'true' ||
process.env.NODE_ENV !== 'production';
if (autoMigrate) {
try {
const { runMigrations } = require('./scripts/migrate');
await runMigrations();
} catch (e) {
console.error('❌ Auto migration on startup failed:', e);
process.exit(1);
}
}
require('./queues/tatWorker'); // Initialize TAT worker require('./queues/tatWorker'); // Initialize TAT worker
const { logTatConfig } = require('./config/tat.config'); const { logTatConfig } = require('./config/tat.config');
const { logSystemConfig } = require('./config/system.config'); const { logSystemConfig } = require('./config/system.config');

View File

@ -585,7 +585,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
gen_random_uuid(), gen_random_uuid(),
'FORM16_ADMIN_CONFIG', 'FORM16_ADMIN_CONFIG',
'SYSTEM_SETTINGS', 'SYSTEM_SETTINGS',
'{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7,"notification26AsDataAdded":{"enabled":true,"template":"26AS data has been added. Please review."},"notificationForm16SuccessCreditNote":{"enabled":true,"template":"Form 16 submitted successfully. Credit note: [CreditNoteRef]."},"notificationForm16Unsuccessful":{"enabled":true,"template":"Form 16 submission was unsuccessful. Issue: [Issue]."},"alertSubmitForm16Enabled":true,"alertSubmitForm16FrequencyDays":0,"alertSubmitForm16FrequencyHours":24,"alertSubmitForm16Template":"Please submit your Form 16 at your earliest. [Name], due date: [DueDate].","reminderNotificationEnabled":true,"reminderFrequencyDays":0,"reminderFrequencyHours":12,"reminderNotificationTemplate":"Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.","debitNoteNotification":{"enabled":true,"template":"Debit note issued: [DebitNoteRef]. Please review."}}', '{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7,"notification26AsDataAdded":{"enabled":true,"template":"26AS data has been added. Please review."},"notificationForm16SuccessCreditNote":{"enabled":true,"template":"Form 16 submitted successfully. Credit note: [CreditNoteRef]."},"notificationForm16Unsuccessful":{"enabled":true,"template":"Form 16 submission was unsuccessful. Issue: [Issue]."},"alertSubmitForm16Enabled":true,"alertSubmitForm16FrequencyDays":0,"alertSubmitForm16FrequencyHours":24,"alertSubmitForm16Template":"Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].","reminderNotificationEnabled":true,"reminderFrequencyDays":0,"reminderFrequencyHours":12,"reminderNotificationTemplate":"Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.","debitNoteNotification":{"enabled":true,"template":"Debit note issued: [DebitNoteRef]. Please review."}}',
'JSON', 'JSON',
'Form 16 Admin Config', 'Form 16 Admin Config',
'Form 16 visibility (submission data viewers, 26AS viewers), reminders and notification settings', 'Form 16 visibility (submission data viewers, 26AS viewers), reminders and notification settings',

View File

@ -19,7 +19,6 @@ import {
Form16QuarterStatus, Form16QuarterStatus,
Form16LedgerEntry, Form16LedgerEntry,
Form16SapResponse, Form16SapResponse,
Form16DebitNoteSapResponse,
} from '../models'; } from '../models';
import { Tds26asEntry } from '../models/Tds26asEntry'; import { Tds26asEntry } from '../models/Tds26asEntry';
import { Form1626asUploadLog } from '../models/Form1626asUploadLog'; import { Form1626asUploadLog } from '../models/Form1626asUploadLog';
@ -39,7 +38,7 @@ import logger from '../utils/logger';
* Same dealer code is used for submission, credit note, and debit note generation. * Same dealer code is used for submission, credit note, and debit note generation.
* Returns null if no dealer code found. * Returns null if no dealer code found.
*/ */
export async function getDealerCodeForUser(userId: string): Promise<string | null> { export async function getDealerCodeForUser(userId: string, userEmail?: string | null): Promise<string | null> {
const [row] = await sequelize.query<{ employee_number: string | null }>( const [row] = await sequelize.query<{ employee_number: string | null }>(
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`, `SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
{ replacements: { userId }, type: QueryTypes.SELECT } { replacements: { userId }, type: QueryTypes.SELECT }
@ -49,10 +48,11 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
} }
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] }); const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
if (!user?.email) return null; const emailToUse = (user?.email || userEmail || '').toString().trim();
if (!emailToUse) return null;
const dealer = await Dealer.findOne({ const dealer = await Dealer.findOne({
where: { where: {
dealerPrincipalEmailId: { [Op.iLike]: user.email }, dealerPrincipalEmailId: { [Op.iLike]: emailToUse },
isActive: true, isActive: true,
}, },
attributes: ['salesCode', 'dlrcode', 'dealerId'], attributes: ['salesCode', 'dlrcode', 'dealerId'],
@ -65,6 +65,45 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
/** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */ /** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */
const SECTION_26AS_194Q = '194Q'; const SECTION_26AS_194Q = '194Q';
const AMOUNT_MATCH_TOLERANCE = 1;
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<boolean> {
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). * Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
@ -77,33 +116,202 @@ export async function getLatest26asAggregatedForQuarter(
financialYear: string, financialYear: string,
quarter: string quarter: string
): Promise<number> { ): Promise<number> {
const normalized = (tanNumber || '').trim().replace(/\s+/g, ' '); const normalizedTan = normalizeTanNumber(tanNumber);
const fy = normalizeFinancialYear(financialYear) || financialYear; const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter; const q = normalizeQuarter(quarter) || quarter;
const [row] = await sequelize.query<{ sum: string }>( const [row] = await sequelize.query<{ sum: string }>(
`WITH latest_upload AS ( `WITH latest_upload AS (
SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', '')) WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
AND financial_year = :fy AND quarter = :qtr AND financial_year = :fy AND quarter = :qtr
AND section_code = :section AND UPPER(TRIM(COALESCE(section_code, ''))) = :section
AND (status_oltas = 'F' OR status_oltas = 'O') AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O')
AND upload_log_id IS NOT NULL AND upload_log_id IS NOT NULL
) )
SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum
FROM tds_26as_entries e FROM tds_26as_entries e
WHERE LOWER(REPLACE(TRIM(e.tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', '')) WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
AND e.financial_year = :fy AND e.quarter = :qtr AND e.financial_year = :fy AND e.quarter = :qtr
AND e.section_code = :section AND UPPER(TRIM(COALESCE(e.section_code, ''))) = :section
AND (e.status_oltas = 'F' OR e.status_oltas = 'O') AND UPPER(TRIM(COALESCE(e.status_oltas, ''))) IN ('F', 'O')
AND ( AND (
e.upload_log_id = (SELECT mid FROM latest_upload) e.upload_log_id = (SELECT mid FROM latest_upload)
OR (SELECT mid FROM latest_upload) IS NULL OR (SELECT mid FROM latest_upload) IS NULL
)`, )`,
{ replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT } { replacements: { tan: normalizedTan, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
); );
return parseFloat(row?.sum ?? '0') || 0; return parseFloat(row?.sum ?? '0') || 0;
} }
async function getLatest26asRowsForQuarter(
tanNumber: string,
financialYear: string,
quarter: string
): Promise<Latest26asRow[]> {
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-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-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-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-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}`;
}
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). */ /** Get latest 26AS quarter snapshot for (tan, fy, quarter). */
export async function getLatest26asSnapshot( export async function getLatest26asSnapshot(
tanNumber: string, tanNumber: string,
@ -277,16 +485,19 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
let sapSet = new Set<number>(); let sapSet = new Set<number>();
if (hasTrnsUniqNoColumn && noteIds.length) { if (hasTrnsUniqNoColumn && noteIds.length) {
try { 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({ const sapRows = await (Form16SapResponse as any).findAll({
where: { where: { tdsTransId: { [Op.in]: creditNumbers } },
type: 'credit', attributes: ['tdsTransId'],
creditNoteId: { [Op.in]: noteIds },
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
},
attributes: ['creditNoteId'],
raw: true, raw: true,
}); });
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId)); 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) { } catch (e: any) {
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e); logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>(); sapSet = new Set<number>();
@ -530,24 +741,24 @@ export function formatForm16DebitNoteNumber(
async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise<{ validationStatus: string; creditNoteNumber?: string | null; validationNotes?: string }> { async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise<{ validationStatus: string; creditNoteNumber?: string | null; validationNotes?: string }> {
const sub = submission as any; const sub = submission as any;
const tanNumberRaw = (sub.tanNumber || '').toString().trim(); const tanNumberRaw = (sub.tanNumber || '').toString().trim();
const tanNumber = tanNumberRaw.replace(/\s+/g, ' '); const tanNumber = normalizeTanNumber(tanNumberRaw);
const tdsAmount = parseFloat(sub.tdsAmount) || 0; const tdsAmount = parseFloat(sub.tdsAmount) || 0;
if (!tanNumber || tdsAmount <= 0) { if (!tanNumber || tanNumber.length < 10 || tdsAmount <= 0) {
logger.warn( 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.` `[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({ await submission.update({
validationStatus: 'resubmission_needed', validationStatus: 'resubmission_needed',
validationNotes: 'OCR data incomplete (TAN or TDS amount missing). Please resubmit Form 16 or contact RE for manual approval.', validationNotes: 'OCR data incomplete/invalid (TAN or TDS amount missing). Please resubmit Form 16 or contact RE for manual approval.',
}); });
return { validationStatus: 'resubmission_needed' }; return { validationStatus: 'resubmission_needed' };
} }
const financialYearRaw = (sub.financialYear || '').trim(); const financialYearRaw = (sub.financialYear || '').trim();
const quarterRaw = (sub.quarter || '').trim(); const quarterRaw = (sub.quarter || '').trim();
const financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw; let financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
const quarter = normalizeQuarter(quarterRaw) || quarterRaw; let quarter = normalizeQuarter(quarterRaw) || quarterRaw;
if (!financialYear || !quarter) { if (!financialYear || !quarter) {
logger.warn( logger.warn(
@ -560,22 +771,162 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
return { validationStatus: 'resubmission_needed' }; return { validationStatus: 'resubmission_needed' };
} }
// Official quarter total from 26AS (Section 194Q, Booking F/O only) const extracted = (sub.ocrExtractedData || {}) as Record<string, unknown>;
const aggregated26as = await getLatest26asAggregatedForQuarter(tanNumber, financialYear, quarter); 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 submittedBookingDate = normalizeDateOnly(extracted.dateOfBooking);
if (aggregated26as <= 0) { // 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(submittedBookingDate);
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( 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).` `[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({ await submission.update({
validationStatus: 'failed', validationStatus: 'failed',
validationNotes: `No 26AS data found for TAN no - ${tanNumber}, financial year and quarter. Please ensure 26AS has been uploaded for this period.`, validationNotes: `PAN mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A PAN: ${normalizedSubmittedPan}.`,
}); });
return { validationStatus: 'failed', validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.` }; 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}.`
);
} }
const amountTolerance = 1; // allow 1 rupee rounding if (latestRows.length === 0) {
if (Math.abs(tdsAmount - aggregated26as) > amountTolerance) { // 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.' };
}
}
if (submittedBookingDate) {
const hasBookingDate = latestRows.some((r) => normalizeDateOnly(r.dateOfBooking) === submittedBookingDate);
if (!hasBookingDate) {
await submission.update({
validationStatus: 'failed',
validationNotes:
`Booking date mismatch with latest 26AS for TAN no - ${tanNumber}. No latest 26AS record found with booking date ${submittedBookingDate}.`,
});
return { validationStatus: 'failed', validationNotes: 'Booking date mismatch with latest 26AS.' };
}
}
if (Math.abs(tdsAmount - aggregated26as) > AMOUNT_MATCH_TOLERANCE) {
logger.warn( 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)}.` `[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)}.`
); );
@ -594,7 +945,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
if (qStatus && qStatus.status === 'SETTLED' && qStatus.lastCreditNoteId) { if (qStatus && qStatus.status === 'SETTLED' && qStatus.lastCreditNoteId) {
const lastCn = await Form16CreditNote.findByPk(qStatus.lastCreditNoteId, { attributes: ['amount'] }); const lastCn = await Form16CreditNote.findByPk(qStatus.lastCreditNoteId, { attributes: ['amount'] });
const lastAmount = lastCn ? parseFloat((lastCn as any).amount as string) : 0; const lastAmount = lastCn ? parseFloat((lastCn as any).amount as string) : 0;
if (Math.abs(lastAmount - tdsAmount) <= amountTolerance) { if (Math.abs(lastAmount - tdsAmount) <= AMOUNT_MATCH_TOLERANCE) {
logger.warn( 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.` `[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.`
); );
@ -640,7 +991,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
validationNotes: null, validationNotes: null,
}); });
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation exact fields only) // Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16
try { try {
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`; const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
await creditNote.update({ trnsUniqNo }); await creditNote.update({ trnsUniqNo });
@ -651,17 +1002,18 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
TRNS_UNIQ_NO: trnsUniqNo, TRNS_UNIQ_NO: trnsUniqNo,
TDS_TRNS_ID: cnNumber, TDS_TRNS_ID: cnNumber,
DEALER_CODE: padDealerCode(dealerCode), DEALER_CODE: padDealerCode(dealerCode),
TDS_TRNS_DOC_TYP: 'ZTDS', TDS_TRNS_DOC_TYPE: 'ZTDS',
DLR_TAN_NO: tanNumber, DLR_TAN_NO: tanNumber,
'FIN_YEAR&QUARTER': finYearAndQuarter, 'FIN_YEAR&QUARTER': finYearAndQuarter,
DOC_DATE: docDate, DOC_DATE: docDate,
TDS_AMT: Number(tdsAmount).toFixed(2), TDS_AMT: `+${Number(Math.abs(tdsAmount)).toFixed(2)}`,
TDS_CERTIFICATE_NO: certificateNumber,
}; };
const fileName = `${cnNumber}.csv`; const fileName = `${cnNumber}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit'); await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'credit');
logger.info(`[Form16] Credit note CSV pushed to WFM FORM16_CRDT: ${cnNumber}`); logger.info(`[Form16] Credit note CSV pushed to WFM FORM16: ${cnNumber}`);
} catch (csvErr: any) { } catch (csvErr: any) {
logger.error('[Form16] Failed to push credit note CSV to WFM FORM16_CRDT:', csvErr?.message || csvErr); 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 // Do not fail the flow; credit note and ledger are already created
} }
@ -675,17 +1027,23 @@ export async function createSubmission(
userId: string, userId: string,
fileBuffer: Buffer, fileBuffer: Buffer,
originalName: string, originalName: string,
body: CreateForm16SubmissionBody body: CreateForm16SubmissionBody,
userEmail?: string | null
): Promise<CreateForm16SubmissionResult> { ): Promise<CreateForm16SubmissionResult> {
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note. // 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); const resolvedDealerCode = await getDealerCodeForUser(userId, userEmail);
const overrideDealerCode = (body.dealerCode || '').trim() || null; const overrideDealerCode = (body.dealerCode || '').trim() || null;
const dealerCode = resolvedDealerCode || overrideDealerCode; 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) { if (!dealerCode) {
throw new Error('dealerCode is required to submit Form 16.'); 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(dealerCode, body.financialYear, body.quarter); const version = await getNextVersionForDealerFyQuarter(effectiveDealerCode, body.financialYear, body.quarter);
const requestNumber = await generateRequestNumber(); const requestNumber = await generateRequestNumber();
const title = version > 1 const title = version > 1
@ -740,7 +1098,7 @@ export async function createSubmission(
const safeStr = (s: string, max: number) => (s ?? '').slice(0, max); const safeStr = (s: string, max: number) => (s ?? '').slice(0, max);
const submission = await Form16aSubmission.create({ const submission = await Form16aSubmission.create({
requestId, requestId,
dealerCode: safeStr(dealerCode, 50), dealerCode: safeStr(effectiveDealerCode, 50),
form16aNumber: safeStr(body.form16aNumber, 50), form16aNumber: safeStr(body.form16aNumber, 50),
financialYear: safeStr(body.financialYear, 20), financialYear: safeStr(body.financialYear, 20),
quarter: safeStr(body.quarter, 10), quarter: safeStr(body.quarter, 10),
@ -886,16 +1244,19 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
let sapSet = new Set<number>(); let sapSet = new Set<number>();
if (hasTrnsUniqNoColumn && noteIds.length) { if (hasTrnsUniqNoColumn && noteIds.length) {
try { 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({ const sapRows = await (Form16SapResponse as any).findAll({
where: { where: { tdsTransId: { [Op.in]: creditNumbers } },
type: 'credit', attributes: ['tdsTransId'],
creditNoteId: { [Op.in]: noteIds },
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
},
attributes: ['creditNoteId'],
raw: true, raw: true,
}); });
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId)); 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) { } catch (e: any) {
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e); logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>(); sapSet = new Set<number>();
@ -1002,15 +1363,19 @@ export async function listAllDebitNotesForRe(filters?: { financialYear?: string;
let sapSet = new Set<number>(); let sapSet = new Set<number>();
if (noteIds.length) { if (noteIds.length) {
try { try {
const sapRows = await (Form16DebitNoteSapResponse as any).findAll({ const debitNotes = await Form16DebitNote.findAll({
where: { where: { id: { [Op.in]: noteIds } },
debitNoteId: { [Op.in]: noteIds }, attributes: ['id', 'debitNoteNumber'],
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }], raw: true,
}, }) as any[];
attributes: ['debitNoteId'], 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, raw: true,
}); });
sapSet = new Set((sapRows as any[]).map((r) => r.debitNoteId ?? r.debit_note_id)); 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) { } catch (e: any) {
logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e); logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>(); sapSet = new Set<number>();
@ -1490,20 +1855,15 @@ export async function getCreditNoteById(creditNoteId: number) {
} }
export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> { export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> {
const row = await (Form16SapResponse as any).findOne({ // API-backed CSV generation is used now; URL is deterministic when SAP response exists.
where: { type: 'credit', creditNoteId, storageUrl: { [Op.ne]: null } }, const row = await getCreditNoteSapResponse(creditNoteId);
attributes: ['storageUrl', 'createdAt'], return row ? `/api/v1/form16/credit-notes/${creditNoteId}/sap-response/csv` : null;
order: [['createdAt', 'DESC']],
});
const url = row?.storageUrl;
return url && String(url).trim() ? String(url) : null;
} }
export interface Form16SapResponseView { export interface Form16SapResponseView {
fileName: string | null; fileName: string | null;
trnsUniqNo: string | null; trnsUniqNo: string | null;
tdsTransId: string | null; tdsTransId: string | null;
claimNumber: string | null;
sapDocumentNumber: string | null; sapDocumentNumber: string | null;
msgTyp: string | null; msgTyp: string | null;
message: string | null; message: string | null;
@ -1519,61 +1879,48 @@ function mapSapResponseView(row: any): Form16SapResponseView {
fileName: row?.fileName ?? null, fileName: row?.fileName ?? null,
trnsUniqNo: row?.trnsUniqNo ?? null, trnsUniqNo: row?.trnsUniqNo ?? null,
tdsTransId: row?.tdsTransId ?? null, tdsTransId: row?.tdsTransId ?? null,
claimNumber: row?.claimNumber ?? null, sapDocumentNumber: row?.docNo ?? null,
sapDocumentNumber: row?.sapDocumentNumber ?? null,
msgTyp: row?.msgTyp ?? null, msgTyp: row?.msgTyp ?? null,
message: row?.message ?? null, message: row?.message ?? null,
docDate: row?.docDate ?? null, docDate: null,
tdsAmt: row?.tdsAmt ?? null, tdsAmt: null,
storageUrl: row?.storageUrl ?? null, storageUrl: null,
createdAt: row?.createdAt ?? null, createdAt: row?.createdAt ?? null,
updatedAt: row?.updatedAt ?? null, updatedAt: row?.updatedAt ?? null,
}; };
} }
export async function getCreditNoteSapResponse(creditNoteId: number): Promise<Form16SapResponseView | null> { export async function getCreditNoteSapResponse(creditNoteId: number): Promise<Form16SapResponseView | null> {
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({ const row = await (Form16SapResponse as any).findOne({
where: { where: {
type: 'credit', tdsTransId: creditNoteNumber,
creditNoteId,
[Op.or]: [
{ storageUrl: { [Op.ne]: null } },
{ sapDocumentNumber: { [Op.ne]: null } },
{ trnsUniqNo: { [Op.ne]: null } },
{ tdsTransId: { [Op.ne]: null } },
],
}, },
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'], attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
return row ? mapSapResponseView(row) : null; return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${creditNoteNumber}.csv` }) : null;
} }
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> { export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
const row = await (Form16DebitNoteSapResponse as any).findOne({ const row = await getDebitNoteSapResponse(debitNoteId);
where: { debitNoteId, storageUrl: { [Op.ne]: null } }, return row ? `/api/v1/form16/debit-notes/${debitNoteId}/sap-response/csv` : null;
attributes: ['storageUrl', 'createdAt'],
order: [['createdAt', 'DESC']],
});
const url = row?.storageUrl;
return url && String(url).trim() ? String(url) : null;
} }
export async function getDebitNoteSapResponse(debitNoteId: number): Promise<Form16SapResponseView | null> { export async function getDebitNoteSapResponse(debitNoteId: number): Promise<Form16SapResponseView | null> {
const row = await (Form16DebitNoteSapResponse as any).findOne({ 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: { where: {
debitNoteId, tdsTransId: debitNoteNumber,
[Op.or]: [
{ storageUrl: { [Op.ne]: null } },
{ sapDocumentNumber: { [Op.ne]: null } },
{ trnsUniqNo: { [Op.ne]: null } },
{ tdsTransId: { [Op.ne]: null } },
],
}, },
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'], attributes: ['trnsUniqNo', 'tdsTransId', 'docNo', 'msgTyp', 'message', 'createdAt', 'updatedAt'],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
return row ? mapSapResponseView(row) : null; return row ? mapSapResponseView({ ...row.toJSON(), fileName: `${debitNoteNumber}.csv` }) : null;
} }
/** /**
@ -1954,6 +2301,7 @@ export async function list26asEntries(filters?: List26asFilters): Promise<{
} }
export async function create26asEntry(data: { export async function create26asEntry(data: {
panNumber?: string;
tanNumber: string; tanNumber: string;
deductorName?: string; deductorName?: string;
quarter: string; quarter: string;
@ -1969,7 +2317,8 @@ export async function create26asEntry(data: {
statusOltas?: string; statusOltas?: string;
remarks?: string; remarks?: string;
}) { }) {
const entry = await Tds26asEntry.create({ const includePanNumber = await isPanNumberColumnPresent();
const payload: any = {
tanNumber: data.tanNumber, tanNumber: data.tanNumber,
deductorName: data.deductorName, deductorName: data.deductorName,
quarter: data.quarter, quarter: data.quarter,
@ -1984,13 +2333,17 @@ export async function create26asEntry(data: {
dateOfBooking: data.dateOfBooking, dateOfBooking: data.dateOfBooking,
statusOltas: data.statusOltas, statusOltas: data.statusOltas,
remarks: data.remarks, remarks: data.remarks,
}); };
if (includePanNumber && data.panNumber != null) payload.panNumber = data.panNumber;
const entry = await Tds26asEntry.create(payload);
return entry; return entry;
} }
export async function update26asEntry( export async function update26asEntry(
id: number, id: number,
data: Partial<{ data: Partial<{
panNumber: string;
tanNumber: string; tanNumber: string;
deductorName: string; deductorName: string;
quarter: string; quarter: string;
@ -2009,6 +2362,10 @@ export async function update26asEntry(
) { ) {
const entry = await Tds26asEntry.findByPk(id); const entry = await Tds26asEntry.findByPk(id);
if (!entry) return null; if (!entry) return null;
if (data.panNumber != null) {
const includePanNumber = await isPanNumberColumnPresent();
if (!includePanNumber) delete (data as any).panNumber;
}
await entry.update(data); await entry.update(data);
return entry; return entry;
} }
@ -2070,6 +2427,7 @@ function getCurrentFinancialYear(): string {
function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string[] } { function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string[] } {
const errors: string[] = []; const errors: string[] = [];
const rows: any[] = []; const rows: any[] = [];
let currentPAN = '';
let currentTAN = ''; let currentTAN = '';
let currentDeductorName = ''; let currentDeductorName = '';
const transactionDateRe = /^\d{1,2}-\w{3}-\d{4}$/i; const transactionDateRe = /^\d{1,2}-\w{3}-\d{4}$/i;
@ -2084,6 +2442,14 @@ function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string
const c2 = cells[2]; const c2 = cells[2];
const c3 = cells[3]; 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 // 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 srNoNum = /^\d+$/.test(c0);
const looksLikeTan = c2 && c2.length >= 8 && /^[A-Z0-9]+$/i.test(c2); const looksLikeTan = c2 && c2.length >= 8 && /^[A-Z0-9]+$/i.test(c2);
@ -2104,6 +2470,7 @@ function parse26asOfficialFormat(lines: string[]): { rows: any[]; errors: string
const totalTds = parseDecimal(cells[9]); const totalTds = parseDecimal(cells[9]);
const { financialYear, quarter } = dateToFyAndQuarter(cells[3]); const { financialYear, quarter } = dateToFyAndQuarter(cells[3]);
rows.push({ rows.push({
panNumber: currentPAN || undefined,
tanNumber: currentTAN, tanNumber: currentTAN,
deductorName: currentDeductorName || undefined, deductorName: currentDeductorName || undefined,
quarter, quarter,
@ -2156,6 +2523,7 @@ function buildColumnMap(headerCells: string[]): Record<string, number> {
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, ''); const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, '');
headerCells.forEach((cell, idx) => { headerCells.forEach((cell, idx) => {
const n = norm(cell); 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; 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('deductor') && (n.includes('name') || n.length < 20)) map['deductorName'] = idx;
else if (n.includes('quarter') || n === 'q') map['quarter'] = idx; else if (n.includes('quarter') || n === 'q') map['quarter'] = idx;
@ -2221,6 +2589,7 @@ export function parse26asTxtFile(buffer: Buffer): { rows: any[]; errors: string[
const financialYear = get(cells, 'financialYear') || defaultFY; const financialYear = get(cells, 'financialYear') || defaultFY;
const taxDeductedNum = parseDecimal(get(cells, 'taxDeducted')) ?? 0; const taxDeductedNum = parseDecimal(get(cells, 'taxDeducted')) ?? 0;
rows.push({ rows.push({
panNumber: get(cells, 'panNumber') || undefined,
tanNumber, tanNumber,
deductorName: get(cells, 'deductorName') || undefined, deductorName: get(cells, 'deductorName') || undefined,
quarter, quarter,
@ -2242,19 +2611,25 @@ export function parse26asTxtFile(buffer: Buffer): { rows: any[]; errors: string[
/** Allowed fields for 26AS create exclude timestamps so Sequelize sets them. */ /** Allowed fields for 26AS create exclude timestamps so Sequelize sets them. */
const TDS_26AS_CREATE_KEYS = [ const TDS_26AS_CREATE_KEYS = [
'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode', 'panNumber', 'tanNumber', 'deductorName', 'quarter', 'financialYear', 'sectionCode',
'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment', 'amountPaid', 'taxDeducted', 'totalTdsDeposited', 'natureOfPayment',
'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId', 'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId',
] as const; ] as const;
function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: number | null): Record<string, unknown> { function build26asCreatePayload(
row: Record<string, unknown>,
uploadLogId: number | null | undefined,
includePanNumber: boolean
): Record<string, unknown> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
for (const k of TDS_26AS_CREATE_KEYS) { for (const k of TDS_26AS_CREATE_KEYS) {
if (k === 'uploadLogId') continue; if (k === 'uploadLogId') continue;
if (k === 'panNumber' && !includePanNumber) continue;
const v = row[k]; const v = row[k];
if (v !== undefined && v !== null) payload[k] = v; if (v !== undefined && v !== null) payload[k] = v;
} }
payload.tanNumber = (row.tanNumber != null ? String(row.tanNumber).trim() : '') || ''; 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 rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1'; const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy; payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
@ -2277,9 +2652,11 @@ export async function upload26asFile(buffer: Buffer, uploadLogId?: number | null
} }
const insertErrors: string[] = []; const insertErrors: string[] = [];
let imported = 0; let imported = 0;
const includePanNumber = await isPanNumberColumnPresent();
for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) { for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) {
const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE); const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE);
const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId)); const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId, includePanNumber));
try { try {
const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true }); const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true });
imported += created.length; imported += created.length;
@ -2359,29 +2736,29 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id); await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
debitsCreated++; debitsCreated++;
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation) // Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16
try { try {
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`; const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
await debit.update({ trnsUniqNo }); await debit.update({ trnsUniqNo });
const docDate = now.toISOString().slice(0, 10).replace(/-/g, ''); const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const fyCompact = form16FyCompact(cnFy) || ''; const fyCompact = form16FyCompact(cnFy) || '';
const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : ''; const finYearAndQuarter = fyCompact && cnQuarter ? `FY_${fyCompact}_${cnQuarter}` : '';
const csvRow: Record<string, string | number> = { const csvRow: Record<string, string | number> = {
TRNS_UNIQ_NO: trnsUniqNo, TRNS_UNIQ_NO: trnsUniqNo,
TDS_TRNS_ID: creditNoteNumber, TDS_TRNS_ID: debitNum,
DEALER_CODE: padDealerCode(dealerCode), DEALER_CODE: padDealerCode(dealerCode),
TDS_TRNS_DOC_TYP: 'ZTDS', TDS_TRNS_DOC_TYPE: 'ZTDS',
'Org.Document Number': debit.id,
DLR_TAN_NO: tanNumber, DLR_TAN_NO: tanNumber,
'FIN_YEAR&QUARTER': finYearAndQuarter, 'FIN_YEAR&QUARTER': finYearAndQuarter,
DOC_DATE: docDate, DOC_DATE: docDate,
TDS_AMT: Number(amount).toFixed(2), TDS_AMT: `-${Math.abs(Number(amount)).toFixed(2)}`,
TDS_CERTIFICATE_NO: creditNoteCertNumber,
}; };
const fileName = `${debitNum}.csv`; const fileName = `${debitNum}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit'); await wfmFileService.generateForm16IncomingCSV([csvRow], fileName, 'debit');
logger.info(`[Form16] Debit note CSV pushed to WFM FORM16_DEBT: ${debitNum}`); logger.info(`[Form16] Debit note CSV pushed to WFM FORM16: ${debitNum}`);
} catch (csvErr: any) { } catch (csvErr: any) {
logger.error('[Form16] Failed to push debit note CSV to WFM FORM16_DEBT:', csvErr?.message || csvErr); logger.error('[Form16] Failed to push debit note CSV to WFM FORM16:', csvErr?.message || csvErr);
} }
} }
} }

View File

@ -164,9 +164,22 @@ function resolveReminderTemplate(configTemplate: string | undefined): string {
if (!t) return fallback; if (!t) return fallback;
// Guard against swapped template in config. // Guard against swapped template in config.
if (/\[duedate\]/i.test(t)) return fallback; if (/\[duedate\]/i.test(t)) return fallback;
// Guard against legacy awkward template currently seen in UAT screenshots.
if (/form 16 submission is pending\.\s*\[name\],\s*\[request id\]\.\s*please review\.?/i.test(t)) return fallback;
// Enforce required placeholders to keep reminder body meaningful and consistent.
if (!/\[name\]/i.test(t) || !/\[request id\]/i.test(t)) return fallback;
return t; return t;
} }
function sanitizeRequestRef(value: string | undefined): string {
const v = (value || '').trim();
if (!v) return '—';
// Avoid sending internal UUIDs in user-facing reminder templates.
const uuidV4Like = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (uuidV4Like.test(v)) return 'pending Form 16 request';
return v;
}
function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } { function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } {
const year = d.getFullYear(); const year = d.getFullYear();
const month = d.getMonth() + 1; // 1..12 const month = d.getMonth() + 1; // 1..12
@ -342,7 +355,7 @@ export async function triggerForm16Reminder(dealerUserIds: string[], placeholder
const template = resolveReminderTemplate(config.reminderNotificationTemplate); const template = resolveReminderTemplate(config.reminderNotificationTemplate);
const body = replacePlaceholders(template, { const body = replacePlaceholders(template, {
Name: placeholders?.name ?? 'Dealer', Name: placeholders?.name ?? 'Dealer',
'Request ID': placeholders?.requestId ?? '—', 'Request ID': sanitizeRequestRef(placeholders?.requestId),
}); });
const { notificationService } = await import('./notification.service'); const { notificationService } = await import('./notification.service');
await notificationService.sendToUsers(dealerUserIds, { await notificationService.sendToUsers(dealerUserIds, {

View File

@ -5,17 +5,19 @@ import logger from '../utils/logger';
/** Default WFM folder names (joined with path.sep for current OS). */ /** Default WFM folder names (joined with path.sep for current OS). */
const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS'); const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS'); const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_FORM16_CREDIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_CRDT'); const DEFAULT_FORM16_INCOMING_MAIN = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16');
const DEFAULT_FORM16_DEBIT_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM16_DBT'); const DEFAULT_FORM16_INCOMING_ARCHIVE = path.join('WFM-QRE', 'INCOMING', 'WFM_ARCHIVE', 'FORM16');
const DEFAULT_FORM16_CREDIT_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT'); const DEFAULT_FORM16_OUTGOING_MAIN = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16');
const DEFAULT_FORM16_DEBIT_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT'); const DEFAULT_FORM16_OUTGOING_ARCHIVE = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_ARCHIVE', 'FORM16');
/** /**
* WFM File Service * WFM File Service
* Handles generation and storage of CSV files in the WFM folder structure. * Handles generation and storage of CSV files in the WFM folder structure.
* Dealer claims use DLR_INC_CLAIMS; Form 16 uses: * Dealer claims use DLR_INC_CLAIMS; Form 16 uses unified folders:
* - FORM16_CRDT (credit) and FORM16_DEBT (debit) under INCOMING/WFM_MAIN * - INCOMING/WFM_MAIN/FORM16
* - FORM16_CRDT (credit) and FORM16_DBT (debit) under OUTGOING/WFM_SAP_MAIN * - INCOMING/WFM_ARCHIVE/FORM16
* - OUTGOING/WFM_SAP_MAIN/FORM16
* - OUTGOING/WFM_SAP_ARCHIVE/FORM16
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production. * Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
*/ */
export class WFMFileService { export class WFMFileService {
@ -34,13 +36,13 @@ export class WFMFileService {
private form16IncomingDebitPath: string; private form16IncomingDebitPath: string;
private incomingArchiveForm16DebitPath: string; private incomingArchiveForm16DebitPath: string;
// --- OUTGOING PATHS (WFM_SAP_MAIN) --- // --- OUTGOING PATHS (WFM_SAP_MAIN / WFM_SAP_ARCHIVE) ---
private outgoingGstClaimsPath: string; private outgoingGstClaimsPath: string;
private outgoingNonGstClaimsPath: string; private outgoingNonGstClaimsPath: string;
/** Form 16 credit responses: OUTGOING/WFM_SAP_MAIN/FORM16_CRDT */ /** Form 16 credit responses: OUTGOING/WFM_SAP_MAIN/FORM16 */
private form16OutgoingCreditPath: string; private form16OutgoingCreditPath: string;
/** Form 16 debit responses: OUTGOING/WFM_SAP_MAIN/FORM16_DBT */ /** Form 16 debit responses: OUTGOING/WFM_SAP_MAIN/FORM16 */
private form16OutgoingDebitPath: string; private form16OutgoingDebitPath: string;
constructor() { constructor() {
@ -53,27 +55,39 @@ export class WFMFileService {
this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING + '_NON_GST'; this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING + '_NON_GST';
this.incomingArchiveNonGstClaimsPath = process.env.WFM_ARCHIVE_NON_GST_CLAIMS_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'DLR_INC_CLAIMS_NON_GST'); this.incomingArchiveNonGstClaimsPath = process.env.WFM_ARCHIVE_NON_GST_CLAIMS_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'DLR_INC_CLAIMS_NON_GST');
// Backwards-compatible: support legacy WFM_FORM16_INCOMING_PATH if specific credit/debit paths are not set // Form16 unified path (credit/debit both use FORM16). Keep legacy vars as fallback.
const legacyForm16Incoming = process.env.WFM_FORM16_INCOMING_PATH; const legacyForm16Incoming = process.env.WFM_FORM16_INCOMING_PATH;
const form16IncomingMain =
process.env.WFM_FORM16_INCOMING_MAIN_PATH ||
process.env.WFM_FORM16_CREDIT_INCOMING_PATH ||
process.env.WFM_FORM16_DEBIT_INCOMING_PATH ||
legacyForm16Incoming ||
DEFAULT_FORM16_INCOMING_MAIN;
const form16IncomingArchive =
process.env.WFM_FORM16_INCOMING_ARCHIVE_PATH ||
process.env.WFM_FORM16_CREDIT_ARCHIVE_PATH ||
process.env.WFM_FORM16_DEBIT_ARCHIVE_PATH ||
DEFAULT_FORM16_INCOMING_ARCHIVE;
this.form16IncomingCreditPath = this.form16IncomingCreditPath = form16IncomingMain;
process.env.WFM_FORM16_CREDIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_CREDIT_INCOMING; this.form16IncomingDebitPath = form16IncomingMain;
this.incomingArchiveForm16CreditPath = process.env.WFM_FORM16_CREDIT_ARCHIVE_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'FORM16_CRDT'); this.incomingArchiveForm16CreditPath = form16IncomingArchive;
this.incomingArchiveForm16DebitPath = form16IncomingArchive;
this.form16IncomingDebitPath =
process.env.WFM_FORM16_DEBIT_INCOMING_PATH || legacyForm16Incoming || DEFAULT_FORM16_DEBIT_INCOMING;
this.incomingArchiveForm16DebitPath = process.env.WFM_FORM16_DEBIT_ARCHIVE_PATH || path.join('WFM-QRE', 'INCOMING', 'WFM_ARACHIVE', 'FORM16_DBT');
// Initialize Outgoing Paths from .env or defaults // Initialize Outgoing Paths from .env or defaults
this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_GST'; this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_GST';
this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_NON_GST'; this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING + '_NON_GST';
// Outgoing: allow specific credit/debit overrides; fall back to legacy single path for credit // Outgoing unified path (credit/debit both use FORM16). Keep legacy vars as fallback.
const legacyForm16Outgoing = process.env.WFM_FORM16_OUTGOING_PATH; const legacyForm16Outgoing = process.env.WFM_FORM16_OUTGOING_PATH;
this.form16OutgoingCreditPath = const form16OutgoingMain =
process.env.WFM_FORM16_CREDIT_OUTGOING_PATH || legacyForm16Outgoing || DEFAULT_FORM16_CREDIT_OUTGOING; process.env.WFM_FORM16_OUTGOING_MAIN_PATH ||
this.form16OutgoingDebitPath = process.env.WFM_FORM16_CREDIT_OUTGOING_PATH ||
process.env.WFM_FORM16_DEBIT_OUTGOING_PATH || DEFAULT_FORM16_DEBIT_OUTGOING; process.env.WFM_FORM16_DEBIT_OUTGOING_PATH ||
legacyForm16Outgoing ||
DEFAULT_FORM16_OUTGOING_MAIN;
this.form16OutgoingCreditPath = form16OutgoingMain;
this.form16OutgoingDebitPath = form16OutgoingMain;
} }
/** /**
@ -261,13 +275,11 @@ export class WFMFileService {
} }
/** /**
* Generate a CSV file for Form 16 (credit/debit note) and store in the appropriate INCOMING/WFM_MAIN folder. * Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM16.
* - Credit: FORM16_CRDT
* - Debit: FORM16_DEBT
* Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement). * Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement).
* @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names) * @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names)
* @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv) * @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
* @param type 'credit' (default) or 'debit' selects FORM16_CRDT vs FORM16_DEBT * @param type 'credit' (default) or 'debit' logical type only; folder is unified FORM16
*/ */
async generateForm16IncomingCSV(data: any[], fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<string> { async generateForm16IncomingCSV(data: any[], fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<string> {
const maxRetries = 3; const maxRetries = 3;
@ -330,8 +342,7 @@ export class WFMFileService {
/** /**
* Get the absolute path for a Form 16 outgoing (response) file. * Get the absolute path for a Form 16 outgoing (response) file.
* - Credit: WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16_CRDT * Both credit and debit use WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16.
* - Debit: WFM/WFM-QRE/OUTGOING/WFM_SAP_MAIN/FORM16_DBT
*/ */
getForm16OutgoingPath(fileName: string, type: 'credit' | 'debit' = 'credit'): string { getForm16OutgoingPath(fileName: string, type: 'credit' | 'debit' = 'credit'): string {
const targetPath = type === 'debit' ? this.form16OutgoingDebitPath : this.form16OutgoingCreditPath; const targetPath = type === 'debit' ? this.form16OutgoingDebitPath : this.form16OutgoingCreditPath;