added multiple transactions read feature at CSV

This commit is contained in:
Aaditya Jaiswal 2026-03-24 20:27:28 +05:30
parent fd6032f21b
commit bae0b8017e
15 changed files with 750 additions and 509 deletions

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.

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,20 @@
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)',
}).catch(() => {});
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], {
name: 'idx_tds_26as_pan',
}).catch(() => {});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan').catch(() => {});
await queryInterface.removeColumn('tds_26as_entries', 'pan_number').catch(() => {});
},
};

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

@ -40,6 +40,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 = () => {
@ -226,7 +227,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

@ -70,6 +70,8 @@ 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/20260324090001-refactor-form16-sap-response-and-add-read-log';
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
interface Migration { interface Migration {
name: string; name: string;
@ -147,6 +149,8 @@ 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: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
]; ];

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';
@ -65,6 +64,23 @@ 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, '');
}
/** /**
* 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 +93,108 @@ 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 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
e.pan_number,
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,
}));
}
function normalizeDateOnly(value: unknown): string | null {
if (!value) return null;
const raw = String(value).trim();
if (!raw) return null;
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
const m = raw.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/);
if (!m) return null;
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}`;
}
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 +368,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,16 +624,16 @@ 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' };
} }
@ -560,8 +654,36 @@ 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);
// Latest 26AS upload rows for the same TAN + FY + Quarter.
const latestRows = await getLatest26asRowsForQuarter(tanNumber, financialYear, quarter);
const aggregated26as = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0);
if (normalizedSubmittedPan) {
const hasPanMatch = latestRows.some((r) => r.panNumber && r.panNumber === normalizedSubmittedPan);
if (!hasPanMatch) {
logger.warn(
`[Form16] 26AS MATCH RESULT: FAILED PAN mismatch. TAN=${tanNumber}, PAN(Form16A)=${normalizedSubmittedPan}, FY=${financialYear}, Quarter=${quarter}.`
);
await submission.update({
validationStatus: 'failed',
validationNotes: `PAN mismatch with latest 26AS for TAN no - ${tanNumber}. Form 16A PAN: ${normalizedSubmittedPan}.`,
});
return { validationStatus: 'failed', validationNotes: 'PAN mismatch with latest 26AS.' };
}
}
if (aggregated26as <= 0) { if (aggregated26as <= 0) {
logger.warn( logger.warn(
@ -574,8 +696,82 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
return { validationStatus: 'failed', validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.` }; return { validationStatus: 'failed', validationNotes: `No 26AS record found for this TAN no - ${tanNumber}, financial year and quarter.` };
} }
const amountTolerance = 1; // allow 1 rupee rounding // Validate against quarter-level aggregate from latest upload.
if (Math.abs(tdsAmount - aggregated26as) > amountTolerance) { // 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 +790,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 +836,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 +847,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(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
} }
@ -886,16 +1083,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 +1202,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 +1694,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 +1718,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 +2140,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;
@ -1970,6 +2157,7 @@ export async function create26asEntry(data: {
remarks?: string; remarks?: string;
}) { }) {
const entry = await Tds26asEntry.create({ const entry = await Tds26asEntry.create({
panNumber: data.panNumber,
tanNumber: data.tanNumber, tanNumber: data.tanNumber,
deductorName: data.deductorName, deductorName: data.deductorName,
quarter: data.quarter, quarter: data.quarter,
@ -1991,6 +2179,7 @@ export async function create26asEntry(data: {
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;
@ -2070,6 +2259,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 +2274,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 +2302,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 +2355,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 +2421,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,7 +2443,7 @@ 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;
@ -2254,7 +2455,8 @@ function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: numb
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 (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;
@ -2359,29 +2561,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;
} }
/** /**
@ -188,13 +202,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;
@ -257,8 +269,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;