fixed the 26AS and from 16 matching issue, migration
This commit is contained in:
parent
bae0b8017e
commit
0aec45f7aa
@ -851,7 +851,8 @@ export class Form16Controller {
|
|||||||
deductorName,
|
deductorName,
|
||||||
version,
|
version,
|
||||||
ocrExtractedData,
|
ocrExtractedData,
|
||||||
}
|
},
|
||||||
|
(req as AuthenticatedRequest).user?.email
|
||||||
);
|
);
|
||||||
|
|
||||||
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
|
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
|
||||||
|
|||||||
@ -7,14 +7,12 @@ module.exports = {
|
|||||||
type: DataTypes.STRING(20),
|
type: DataTypes.STRING(20),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: 'PAN from 26AS header (assessee PAN)',
|
comment: 'PAN from 26AS header (assessee PAN)',
|
||||||
}).catch(() => {});
|
});
|
||||||
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], {
|
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
|
||||||
name: 'idx_tds_26as_pan',
|
|
||||||
}).catch(() => {});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
down: async (queryInterface: QueryInterface) => {
|
||||||
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan').catch(() => {});
|
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
|
||||||
await queryInterface.removeColumn('tds_26as_entries', 'pan_number').catch(() => {});
|
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
46
src/migrations/20260325090001-ensure-pan-number-in-26as.ts
Normal file
46
src/migrations/20260325090001-ensure-pan-number-in-26as.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { QueryInterface } from 'sequelize';
|
||||||
|
import { DataTypes, QueryTypes } from 'sequelize';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface: QueryInterface) => {
|
||||||
|
// Use information_schema so this migration is safe even if a previous run
|
||||||
|
// recorded as "executed" but didn't actually alter the schema.
|
||||||
|
const sequelize = (queryInterface as any).sequelize;
|
||||||
|
|
||||||
|
const [colRow] = await sequelize.query(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tds_26as_entries' AND column_name = 'pan_number'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colRow?.exists) {
|
||||||
|
await queryInterface.addColumn('tds_26as_entries', 'pan_number', {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'PAN from 26AS header (assessee PAN)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [idxRow] = await sequelize.query(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'tds_26as_entries'
|
||||||
|
AND indexname = 'idx_tds_26as_pan'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!idxRow?.exists) {
|
||||||
|
await queryInterface.addIndex('tds_26as_entries', ['pan_number'], { name: 'idx_tds_26as_pan' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface: QueryInterface) => {
|
||||||
|
// Best-effort rollback. If column/index already absent, these may throw.
|
||||||
|
// We intentionally keep down strict because rollback isn't required for forward fixes.
|
||||||
|
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_pan');
|
||||||
|
await queryInterface.removeColumn('tds_26as_entries', 'pan_number');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@ -72,6 +72,7 @@ import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-
|
|||||||
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
||||||
import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
|
import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
|
||||||
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
|
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
|
||||||
|
import * as m68 from '../migrations/20260325090001-ensure-pan-number-in-26as';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -151,6 +152,7 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||||
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
|
||||||
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
|
||||||
|
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -210,7 +212,7 @@ async function markMigrationExecuted(sequelize: any, name: string): Promise<void
|
|||||||
/**
|
/**
|
||||||
* Run all pending migrations
|
* Run all pending migrations
|
||||||
*/
|
*/
|
||||||
async function run() {
|
export async function runMigrations() {
|
||||||
try {
|
try {
|
||||||
console.log('🔐 Initializing secrets...');
|
console.log('🔐 Initializing secrets...');
|
||||||
await initializeGoogleSecretManager();
|
await initializeGoogleSecretManager();
|
||||||
@ -235,7 +237,6 @@ async function run() {
|
|||||||
|
|
||||||
if (pendingMigrations.length === 0) {
|
if (pendingMigrations.length === 0) {
|
||||||
console.log('✅ Migrations up-to-date');
|
console.log('✅ Migrations up-to-date');
|
||||||
process.exit(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,11 +256,15 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
|
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
|
||||||
process.exit(0);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Migration failed:', err.message);
|
console.error('❌ Migration failed:', err.message);
|
||||||
process.exit(1);
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
// When executed directly: behave like a script (set exit codes).
|
||||||
|
if (require.main === module) {
|
||||||
|
runMigrations()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
|
|||||||
@ -62,6 +62,21 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Initialize database connection explicitly after secrets are loaded
|
// Initialize database connection explicitly after secrets are loaded
|
||||||
await initializeAppDatabase();
|
await initializeAppDatabase();
|
||||||
|
|
||||||
|
// Apply pending DB migrations automatically in non-production to prevent schema drift.
|
||||||
|
// Can be disabled via RUN_MIGRATIONS_ON_STARTUP=false
|
||||||
|
const autoMigrate =
|
||||||
|
process.env.RUN_MIGRATIONS_ON_STARTUP === 'true' ||
|
||||||
|
process.env.NODE_ENV !== 'production';
|
||||||
|
if (autoMigrate) {
|
||||||
|
try {
|
||||||
|
const { runMigrations } = require('./scripts/migrate');
|
||||||
|
await runMigrations();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Auto migration on startup failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
require('./queues/tatWorker'); // Initialize TAT worker
|
require('./queues/tatWorker'); // Initialize TAT worker
|
||||||
const { logTatConfig } = require('./config/tat.config');
|
const { logTatConfig } = require('./config/tat.config');
|
||||||
const { logSystemConfig } = require('./config/system.config');
|
const { logSystemConfig } = require('./config/system.config');
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import logger from '../utils/logger';
|
|||||||
* Same dealer code is used for submission, credit note, and debit note generation.
|
* Same dealer code is used for submission, credit note, and debit note generation.
|
||||||
* Returns null if no dealer code found.
|
* Returns null if no dealer code found.
|
||||||
*/
|
*/
|
||||||
export async function getDealerCodeForUser(userId: string): Promise<string | null> {
|
export async function getDealerCodeForUser(userId: string, userEmail?: string | null): Promise<string | null> {
|
||||||
const [row] = await sequelize.query<{ employee_number: string | null }>(
|
const [row] = await sequelize.query<{ employee_number: string | null }>(
|
||||||
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
|
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
|
||||||
{ replacements: { userId }, type: QueryTypes.SELECT }
|
{ replacements: { userId }, type: QueryTypes.SELECT }
|
||||||
@ -48,10 +48,11 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
|
||||||
if (!user?.email) return null;
|
const emailToUse = (user?.email || userEmail || '').toString().trim();
|
||||||
|
if (!emailToUse) return null;
|
||||||
const dealer = await Dealer.findOne({
|
const dealer = await Dealer.findOne({
|
||||||
where: {
|
where: {
|
||||||
dealerPrincipalEmailId: { [Op.iLike]: user.email },
|
dealerPrincipalEmailId: { [Op.iLike]: emailToUse },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
attributes: ['salesCode', 'dlrcode', 'dealerId'],
|
attributes: ['salesCode', 'dlrcode', 'dealerId'],
|
||||||
@ -82,6 +83,28 @@ function normalizeTanNumber(raw: unknown): string {
|
|||||||
.replace(/[^A-Z0-9]/g, '');
|
.replace(/[^A-Z0-9]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some environments might still be on the old DB schema.
|
||||||
|
// If `tds_26as_entries.pan_number` is missing, uploads/selects will fail without this guard.
|
||||||
|
let _panNumberColumnPresent: boolean | null = null;
|
||||||
|
async function isPanNumberColumnPresent(): Promise<boolean> {
|
||||||
|
if (_panNumberColumnPresent !== null) return _panNumberColumnPresent;
|
||||||
|
try {
|
||||||
|
const [row] = await sequelize.query<{ exists: boolean }>(
|
||||||
|
`SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END AS exists
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tds_26as_entries' AND column_name = 'pan_number'`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
_panNumberColumnPresent = !!row?.exists;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Form16] pan_number column presence check failed; disabling PAN persistence/enforcement.', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
_panNumberColumnPresent = false;
|
||||||
|
}
|
||||||
|
return _panNumberColumnPresent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
|
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
|
||||||
* Use case: "Always match Form 16A only with the latest 26AS version." Each upload can be full cumulative;
|
* Use case: "Always match Form 16A only with the latest 26AS version." Each upload can be full cumulative;
|
||||||
@ -129,6 +152,9 @@ async function getLatest26asRowsForQuarter(
|
|||||||
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||||
const q = normalizeQuarter(quarter) || quarter;
|
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<{
|
const rows = await sequelize.query<{
|
||||||
pan_number: string | null;
|
pan_number: string | null;
|
||||||
amount_paid: string | null;
|
amount_paid: string | null;
|
||||||
@ -146,7 +172,7 @@ async function getLatest26asRowsForQuarter(
|
|||||||
AND upload_log_id IS NOT NULL
|
AND upload_log_id IS NOT NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
e.pan_number,
|
${panSelect},
|
||||||
e.amount_paid,
|
e.amount_paid,
|
||||||
e.tax_deducted,
|
e.tax_deducted,
|
||||||
e.total_tds_deposited,
|
e.total_tds_deposited,
|
||||||
@ -175,18 +201,109 @@ async function getLatest26asRowsForQuarter(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function get26asCoverageDebug(tanNumber: string, financialYear: string, quarter: string) {
|
||||||
|
const normalizedTan = normalizeTanNumber(tanNumber);
|
||||||
|
const fy = normalizeFinancialYear(financialYear) || financialYear;
|
||||||
|
const q = normalizeQuarter(quarter) || quarter;
|
||||||
|
|
||||||
|
// Overall counts + how many rows qualify for our matching rule:
|
||||||
|
const [counts] = await sequelize.query<{
|
||||||
|
total_rows: string;
|
||||||
|
matching_194q_f_o_rows: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*)::text AS total_rows,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(TRIM(COALESCE(section_code, ''))) = :section
|
||||||
|
AND UPPER(TRIM(COALESCE(status_oltas, ''))) IN ('F', 'O')
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
)::text AS matching_194q_f_o_rows
|
||||||
|
FROM tds_26as_entries e
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND e.financial_year = :fy
|
||||||
|
AND e.quarter = :q`,
|
||||||
|
{ replacements: { tan: normalizedTan, fy, q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section/status breakdown to make it obvious why latestRows became empty.
|
||||||
|
const breakdown = (await sequelize.query(
|
||||||
|
`SELECT
|
||||||
|
section_code,
|
||||||
|
status_oltas,
|
||||||
|
COUNT(*)::text AS cnt
|
||||||
|
FROM tds_26as_entries e
|
||||||
|
WHERE UPPER(REGEXP_REPLACE(TRIM(COALESCE(e.tan_number, '')), '[^A-Z0-9]', '', 'g')) = :tan
|
||||||
|
AND e.financial_year = :fy
|
||||||
|
AND e.quarter = :q
|
||||||
|
GROUP BY section_code, status_oltas
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 8`,
|
||||||
|
{ replacements: { tan: normalizedTan, fy, q }, type: QueryTypes.SELECT }
|
||||||
|
)) as Array<{ section_code: string | null; status_oltas: string | null; cnt: string }>;
|
||||||
|
|
||||||
|
const totalRows = parseInt(String(counts?.total_rows ?? '0'), 10) || 0;
|
||||||
|
const matchingRows = parseInt(String(counts?.matching_194q_f_o_rows ?? '0'), 10) || 0;
|
||||||
|
|
||||||
|
const breakdownLines = (breakdown || [])
|
||||||
|
.map((b) => {
|
||||||
|
const sec = (b.section_code ?? '').toString().trim() || '(null)';
|
||||||
|
const st = (b.status_oltas ?? '').toString().trim() || '(null)';
|
||||||
|
const c = parseInt(String(b.cnt ?? '0'), 10) || 0;
|
||||||
|
return `${sec}/${st}:${c}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return { totalRows, matchingRows, breakdownLines };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDateOnly(value: unknown): string | null {
|
function normalizeDateOnly(value: unknown): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const raw = String(value).trim();
|
const raw = String(value).trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const d = new Date(raw);
|
|
||||||
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
|
// 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})$/);
|
const m = raw.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/);
|
||||||
if (!m) return null;
|
if (m) {
|
||||||
const dd = m[1].padStart(2, '0');
|
const dd = m[1].padStart(2, '0');
|
||||||
const mm = m[2].padStart(2, '0');
|
const mm = m[2].padStart(2, '0');
|
||||||
const yyyy = m[3].length === 2 ? `20${m[3]}` : m[3];
|
const yyyy = m[3].length === 2 ? `20${m[3]}` : m[3];
|
||||||
return `${yyyy}-${mm}-${dd}`;
|
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 {
|
function toNumberOrNull(value: unknown): number | null {
|
||||||
@ -640,8 +757,8 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
|
|
||||||
const financialYearRaw = (sub.financialYear || '').trim();
|
const financialYearRaw = (sub.financialYear || '').trim();
|
||||||
const quarterRaw = (sub.quarter || '').trim();
|
const quarterRaw = (sub.quarter || '').trim();
|
||||||
const financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
|
let financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
|
||||||
const quarter = normalizeQuarter(quarterRaw) || quarterRaw;
|
let quarter = normalizeQuarter(quarterRaw) || quarterRaw;
|
||||||
|
|
||||||
if (!financialYear || !quarter) {
|
if (!financialYear || !quarter) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -668,10 +785,31 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
const submittedBookingDate = normalizeDateOnly(extracted.dateOfBooking);
|
const submittedBookingDate = normalizeDateOnly(extracted.dateOfBooking);
|
||||||
|
|
||||||
// Latest 26AS upload rows for the same TAN + FY + Quarter.
|
// Latest 26AS upload rows for the same TAN + FY + Quarter.
|
||||||
const latestRows = await getLatest26asRowsForQuarter(tanNumber, financialYear, quarter);
|
let latestRows = await getLatest26asRowsForQuarter(tanNumber, financialYear, quarter);
|
||||||
|
|
||||||
|
// If OCR extracted FY/Quarter incorrectly, derive FY/Quarter from OCR dates and retry.
|
||||||
|
if (latestRows.length === 0) {
|
||||||
|
const derivedFromTx = deriveFyAndQuarterFromDateOnly(submittedTransactionDate);
|
||||||
|
const derivedFromBooking = deriveFyAndQuarterFromDateOnly(submittedBookingDate);
|
||||||
|
const derived = derivedFromTx || derivedFromBooking;
|
||||||
|
if (derived && (derived.financialYear !== financialYear || derived.quarter !== quarter)) {
|
||||||
|
const altRows = await getLatest26asRowsForQuarter(tanNumber, derived.financialYear, derived.quarter);
|
||||||
|
if (altRows.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[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 aggregated26as = latestRows.reduce((sum, r) => sum + (r.taxDeducted || 0), 0);
|
||||||
|
|
||||||
if (normalizedSubmittedPan) {
|
const hasPanColumn = await isPanNumberColumnPresent();
|
||||||
|
if (normalizedSubmittedPan && hasPanColumn && latestRows.length > 0) {
|
||||||
const hasPanMatch = latestRows.some((r) => r.panNumber && r.panNumber === normalizedSubmittedPan);
|
const hasPanMatch = latestRows.some((r) => r.panNumber && r.panNumber === normalizedSubmittedPan);
|
||||||
if (!hasPanMatch) {
|
if (!hasPanMatch) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -683,17 +821,34 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
});
|
});
|
||||||
return { validationStatus: 'failed', validationNotes: 'PAN mismatch with latest 26AS.' };
|
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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aggregated26as <= 0) {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[Form16] 26AS MATCH RESULT: FAILED – No 26AS data. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS: no records for this TAN/FY/Quarter (Section 194Q, Booking F/O).`
|
`[Form16] 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({
|
await submission.update({
|
||||||
validationStatus: 'failed',
|
validationStatus: 'failed',
|
||||||
validationNotes: `No 26AS data found for TAN no - ${tanNumber}, financial year and quarter. Please ensure 26AS has been uploaded for this period.`,
|
validationNotes: `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.` };
|
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.
|
// Validate against quarter-level aggregate from latest upload.
|
||||||
@ -851,7 +1006,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
|
|||||||
DLR_TAN_NO: tanNumber,
|
DLR_TAN_NO: tanNumber,
|
||||||
'FIN_YEAR&QUARTER': finYearAndQuarter,
|
'FIN_YEAR&QUARTER': finYearAndQuarter,
|
||||||
DOC_DATE: docDate,
|
DOC_DATE: docDate,
|
||||||
TDS_AMT: Number(tdsAmount).toFixed(2),
|
TDS_AMT: `+${Number(Math.abs(tdsAmount)).toFixed(2)}`,
|
||||||
TDS_CERTIFICATE_NO: certificateNumber,
|
TDS_CERTIFICATE_NO: certificateNumber,
|
||||||
};
|
};
|
||||||
const fileName = `${cnNumber}.csv`;
|
const fileName = `${cnNumber}.csv`;
|
||||||
@ -872,17 +1027,23 @@ export async function createSubmission(
|
|||||||
userId: string,
|
userId: string,
|
||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
originalName: string,
|
originalName: string,
|
||||||
body: CreateForm16SubmissionBody
|
body: CreateForm16SubmissionBody,
|
||||||
|
userEmail?: string | null
|
||||||
): Promise<CreateForm16SubmissionResult> {
|
): Promise<CreateForm16SubmissionResult> {
|
||||||
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note.
|
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note.
|
||||||
const resolvedDealerCode = await getDealerCodeForUser(userId);
|
const resolvedDealerCode = await getDealerCodeForUser(userId, userEmail);
|
||||||
const overrideDealerCode = (body.dealerCode || '').trim() || null;
|
const overrideDealerCode = (body.dealerCode || '').trim() || null;
|
||||||
const dealerCode = resolvedDealerCode || overrideDealerCode;
|
const dealerCode = resolvedDealerCode || overrideDealerCode;
|
||||||
|
// Frontend may not provide dealerCode (dealer code may be absent in Form16/26AS),
|
||||||
|
// so we must not hard-fail. Use a deterministic fallback so note generation can continue.
|
||||||
|
const effectiveDealerCode = dealerCode || '000000';
|
||||||
if (!dealerCode) {
|
if (!dealerCode) {
|
||||||
throw new Error('dealerCode is required to submit Form 16.');
|
logger.warn(
|
||||||
|
'[Form16] dealerCode not resolved for submission; using fallback dealerCode="000000". Matching uses TAN/FY/Quarter, but note numbering/CSV dealer code will be for fallback.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = await getNextVersionForDealerFyQuarter(dealerCode, body.financialYear, body.quarter);
|
const version = await getNextVersionForDealerFyQuarter(effectiveDealerCode, body.financialYear, body.quarter);
|
||||||
|
|
||||||
const requestNumber = await generateRequestNumber();
|
const requestNumber = await generateRequestNumber();
|
||||||
const title = version > 1
|
const title = version > 1
|
||||||
@ -937,7 +1098,7 @@ export async function createSubmission(
|
|||||||
const safeStr = (s: string, max: number) => (s ?? '').slice(0, max);
|
const safeStr = (s: string, max: number) => (s ?? '').slice(0, max);
|
||||||
const submission = await Form16aSubmission.create({
|
const submission = await Form16aSubmission.create({
|
||||||
requestId,
|
requestId,
|
||||||
dealerCode: safeStr(dealerCode, 50),
|
dealerCode: safeStr(effectiveDealerCode, 50),
|
||||||
form16aNumber: safeStr(body.form16aNumber, 50),
|
form16aNumber: safeStr(body.form16aNumber, 50),
|
||||||
financialYear: safeStr(body.financialYear, 20),
|
financialYear: safeStr(body.financialYear, 20),
|
||||||
quarter: safeStr(body.quarter, 10),
|
quarter: safeStr(body.quarter, 10),
|
||||||
@ -2156,8 +2317,8 @@ export async function create26asEntry(data: {
|
|||||||
statusOltas?: string;
|
statusOltas?: string;
|
||||||
remarks?: string;
|
remarks?: string;
|
||||||
}) {
|
}) {
|
||||||
const entry = await Tds26asEntry.create({
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
panNumber: data.panNumber,
|
const payload: any = {
|
||||||
tanNumber: data.tanNumber,
|
tanNumber: data.tanNumber,
|
||||||
deductorName: data.deductorName,
|
deductorName: data.deductorName,
|
||||||
quarter: data.quarter,
|
quarter: data.quarter,
|
||||||
@ -2172,7 +2333,10 @@ export async function create26asEntry(data: {
|
|||||||
dateOfBooking: data.dateOfBooking,
|
dateOfBooking: data.dateOfBooking,
|
||||||
statusOltas: data.statusOltas,
|
statusOltas: data.statusOltas,
|
||||||
remarks: data.remarks,
|
remarks: data.remarks,
|
||||||
});
|
};
|
||||||
|
if (includePanNumber && data.panNumber != null) payload.panNumber = data.panNumber;
|
||||||
|
|
||||||
|
const entry = await Tds26asEntry.create(payload);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2198,6 +2362,10 @@ export async function update26asEntry(
|
|||||||
) {
|
) {
|
||||||
const entry = await Tds26asEntry.findByPk(id);
|
const entry = await Tds26asEntry.findByPk(id);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
if (data.panNumber != null) {
|
||||||
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
|
if (!includePanNumber) delete (data as any).panNumber;
|
||||||
|
}
|
||||||
await entry.update(data);
|
await entry.update(data);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@ -2448,15 +2616,20 @@ const TDS_26AS_CREATE_KEYS = [
|
|||||||
'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId',
|
'transactionDate', 'dateOfBooking', 'assessmentYear', 'statusOltas', 'remarks', 'uploadLogId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: number | null): Record<string, unknown> {
|
function build26asCreatePayload(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
uploadLogId: number | null | undefined,
|
||||||
|
includePanNumber: boolean
|
||||||
|
): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
for (const k of TDS_26AS_CREATE_KEYS) {
|
for (const k of TDS_26AS_CREATE_KEYS) {
|
||||||
if (k === 'uploadLogId') continue;
|
if (k === 'uploadLogId') continue;
|
||||||
|
if (k === 'panNumber' && !includePanNumber) continue;
|
||||||
const v = row[k];
|
const v = row[k];
|
||||||
if (v !== undefined && v !== null) payload[k] = v;
|
if (v !== undefined && v !== null) payload[k] = v;
|
||||||
}
|
}
|
||||||
payload.tanNumber = normalizeTanNumber(row.tanNumber);
|
payload.tanNumber = normalizeTanNumber(row.tanNumber);
|
||||||
if (row.panNumber != null) payload.panNumber = String(row.panNumber).trim().toUpperCase();
|
if (includePanNumber && row.panNumber != null) payload.panNumber = String(row.panNumber).trim().toUpperCase();
|
||||||
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
|
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
|
||||||
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
|
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
|
||||||
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
|
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
|
||||||
@ -2479,9 +2652,11 @@ export async function upload26asFile(buffer: Buffer, uploadLogId?: number | null
|
|||||||
}
|
}
|
||||||
const insertErrors: string[] = [];
|
const insertErrors: string[] = [];
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|
||||||
|
const includePanNumber = await isPanNumberColumnPresent();
|
||||||
for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) {
|
for (let i = 0; i < rows.length; i += TDS_26AS_BATCH_SIZE) {
|
||||||
const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE);
|
const batch = rows.slice(i, i + TDS_26AS_BATCH_SIZE);
|
||||||
const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId));
|
const payloads = batch.map((r) => build26asCreatePayload(r as Record<string, unknown>, uploadLogId, includePanNumber));
|
||||||
try {
|
try {
|
||||||
const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true });
|
const created = await Tds26asEntry.bulkCreate(payloads as any[], { validate: true });
|
||||||
imported += created.length;
|
imported += created.length;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user