Re_Backend/src/services/form16.service.ts
2026-04-14 20:12:41 +05:30

3321 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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