3321 lines
130 KiB
TypeScript
3321 lines
130 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|