Re_Backend/src/services/form16.service.ts

2052 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Form 16 (Form 16A TDS Credit) service.
* Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger.
*/
import crypto from 'crypto';
import { Op, fn, col, QueryTypes } from 'sequelize';
import { sequelize } from '../config/database';
import {
Form16CreditNote,
Form16DebitNote,
Form16aSubmission,
WorkflowRequest,
Document,
Form1626asQuarterSnapshot,
Form16QuarterStatus,
Form16LedgerEntry,
} 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 } from '../utils/helpers';
import { gcsStorageService } from './gcsStorage.service';
import { activityService } from './activity.service';
import { simulateCreditNoteFromSap, simulateDebitNoteFromSap } from './form16SapSimulation.service';
import logger from '../utils/logger';
/**
* Resolve dealer_code for the current user (by email match with dealers.dealer_principal_email_id).
* Returns null if user is not a dealer or no dealer found.
*/
export async function getDealerCodeForUser(userId: string): Promise<string | null> {
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
if (!user || !user.email) return null;
const dealer = await Dealer.findOne({
where: {
dealerPrincipalEmailId: { [Op.iLike]: user.email },
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';
/** Get aggregated TDS amount for (tan, fy, quarter) from all 26AS entries (Section 194Q, Booking F/O). */
export async function getLatest26asAggregatedForQuarter(
tanNumber: string,
financialYear: string,
quarter: string
): Promise<number> {
const normalized = (tanNumber || '').trim().replace(/\s+/g, ' ');
const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter;
const [row] = await sequelize.query<{ sum: string }>(
`SELECT COALESCE(SUM(tax_deducted), 0)::text AS sum
FROM tds_26as_entries
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
AND financial_year = :fy AND quarter = :qtr
AND section_code = :section
AND (status_oltas = 'F' OR status_oltas = 'O')`,
{ replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
);
return parseFloat(row?.sum ?? '0') || 0;
}
/** 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 };
}
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 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,
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 {
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() || '';
}
/**
* 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 = tanNumberRaw.replace(/\s+/g, ' ');
const tdsAmount = parseFloat(sub.tdsAmount) || 0;
if (!tanNumber || 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 (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();
const financialYear = normalizeFinancialYear(financialYearRaw) || financialYearRaw;
const 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' };
}
// Official quarter total from 26AS (Section 194Q, Booking F/O only)
const aggregated26as = await getLatest26asAggregatedForQuarter(tanNumber, financialYear, quarter);
if (aggregated26as <= 0) {
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).`
);
await submission.update({
validationStatus: 'failed',
validationNotes: 'No 26AS data found for this TAN, financial year and quarter. Please ensure 26AS has been uploaded for this period.',
});
return { validationStatus: 'failed', validationNotes: 'No 26AS record found for this TAN, financial year and quarter.' };
}
const amountTolerance = 1; // allow 1 rupee rounding
if (Math.abs(tdsAmount - aggregated26as) > amountTolerance) {
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. 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. 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) <= amountTolerance) {
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.' };
}
}
const cnNumber = `CN-${new Date().getFullYear()}-${submission.id}-${Date.now().toString(36).toUpperCase()}`;
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,
});
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
): Promise<CreateForm16SubmissionResult> {
const dealerCode = await getDealerCodeForUser(userId);
if (!dealerCode) {
throw new Error('Dealer not found for this user. Only dealers can submit Form 16.');
}
const version = await getNextVersionForDealerFyQuarter(dealerCode, 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(dealerCode, 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}` : ''}.`
);
} 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']],
});
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,
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);
}
/**
* 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 };
}
/**
* RE only. Manually generate credit note for a Form 16 request (e.g. when OCR was partial but RE verified).
* Sets validationStatus to 'manually_approved'.
*/
export async function generateForm16CreditNoteManually(
requestId: string,
userId: string,
amount: number
) {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate credit note.');
const submission = await Form16aSubmission.findOne({
where: { requestId },
attributes: ['id', 'requestId', 'dealerCode', 'financialYear', 'quarter', 'tdsAmount'],
});
if (!submission) throw new Error('Form 16 submission not found for this request.');
const sub = submission as any;
const existing = await Form16CreditNote.findOne({ where: { submissionId: submission.id }, attributes: ['id'] });
if (existing) throw new Error('A credit note already exists for this submission.');
const dealerCode = (sub.dealerCode || '').toString().trim();
const financialYear = (sub.financialYear || '').trim();
const quarter = (sub.quarter || '').trim();
if (dealerCode && (await hasActiveCreditNoteForDealerFyQuarter(dealerCode, financialYear, quarter))) {
throw new Error(
'A credit note has already been issued for this financial year and quarter (e.g. from another submission or a later upload that matched 26AS). You cannot generate another credit note. If the previous credit note was withdrawn (debit note issued), the dealer must submit Form 16 again to generate a new credit note.'
);
}
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
});
const dealerDetails = {
dealerCode: dealerCode || 'UNKNOWN',
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
};
const sapResponse = simulateCreditNoteFromSap(dealerDetails, amount);
const creditNote = await Form16CreditNote.create({
submissionId: submission.id,
creditNoteNumber: sapResponse.creditNoteNumber,
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
amount,
issueDate: new Date(sapResponse.issueDate),
financialYear,
quarter,
status: sapResponse.status || 'issued',
remarks: 'Manually approved; credit note generated via SAP (simulation).',
issuedBy: userId,
});
await submission.update({
validationStatus: 'manually_approved',
validationNotes: 'Credit note manually generated by RE user.',
});
return { creditNote, 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,
};
}),
};
}
/**
* RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation with dealer code, dealer info, credit note number, amount; creates Form16DebitNote from response.
* When real SAP is integrated, replace simulateDebitNoteFromSap with the actual SAP API call.
*/
export async function generateForm16DebitNoteForCreditNote(
creditNoteId: number,
userId: string,
amount: number
): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.');
const creditNote = await Form16CreditNote.findByPk(creditNoteId, {
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }],
});
if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.');
const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] });
if (existing) throw new Error('A debit note already exists for this credit note.');
const dealerCode = ((creditNote as any).submission?.dealerCode || '').toString().trim();
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
});
const dealerInfo = {
dealerCode: dealerCode || 'UNKNOWN',
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
};
const sapResponse = simulateDebitNoteFromSap({
dealerCode: dealerCode || 'UNKNOWN',
dealerInfo,
creditNoteNumber: (creditNote as any).creditNoteNumber,
amount,
});
const debitNote = await Form16DebitNote.create({
creditNoteId,
debitNoteNumber: sapResponse.debitNoteNumber,
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
amount,
issueDate: new Date(sapResponse.issueDate),
status: sapResponse.status || 'issued',
reason: 'Debit note generated via SAP (simulation).',
createdBy: userId,
});
return { debitNote, creditNote };
}
// ---------- 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 dealers (initiator user IDs) who have at least one pending Form 16 submission (no credit note yet, request open).
* Returns one entry per (userId, requestId) so the reminder can include the request ID. Used by the reminder scheduled job.
*/
export async function getDealersWithPendingForm16Submissions(): Promise<{ userId: string; requestId: 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', 'initiatorId'],
raw: true,
});
return requests.map((r) => ({ userId: (r as any).initiatorId, requestId: (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> = {};
if (filters?.financialYear) where.financialYear = filters.financialYear;
if (filters?.quarter) where.quarter = filters.quarter;
if (filters?.tanNumber) where.tanNumber = { [Op.iLike]: `%${filters.tanNumber}%` };
if (filters?.search?.trim()) where.deductorName = { [Op.iLike]: `%${filters.search.trim()}%` };
if (filters?.status) where.statusOltas = filters.status;
if (filters?.assessmentYear) where.assessmentYear = filters.assessmentYear;
if (filters?.sectionCode) where.sectionCode = filters.sectionCode;
return where;
}
export async function list26asEntries(filters?: List26asFilters): Promise<{
rows: Tds26asEntry[];
total: number;
summary: List26asSummary;
}> {
const where = build26asWhere(filters);
const hasWhere = Object.keys(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: {
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.create({
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,
});
return entry;
}
export async function update26asEntry(
id: number,
data: Partial<{
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;
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 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];
// 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({
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.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 };
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({
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 = [
'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): Record<string, unknown> {
const payload: Record<string, unknown> = {};
for (const k of TDS_26AS_CREATE_KEYS) {
if (k === 'uploadLogId') continue;
const v = row[k];
if (v !== undefined && v !== null) payload[k] = v;
}
payload.tanNumber = row.tanNumber ?? '';
payload.quarter = row.quarter ?? 'Q1';
payload.financialYear = row.financialYear ?? '';
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;
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));
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, quarter);
const latest = await getLatest26asSnapshot(tanNumber, fy, quarter);
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, quarter);
if (status?.status === 'SETTLED' && status.lastCreditNoteId) {
const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, { attributes: ['id', 'amount'] });
if (creditNote) {
const amount = parseFloat(String((creditNote as any).amount ?? 0));
const debitNum = `DN-${new Date().getFullYear()}-${creditNote.id}-${Date.now().toString(36).toUpperCase()}`;
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, quarter, debit.id);
debitsCreated++;
}
}
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): Promise<Form1626asUploadLogRow[]> {
const rows = await Form1626asUploadLog.findAll({
limit,
order: [['uploadedAt', 'DESC']],
include: [{ model: User, as: 'uploadedByUser', attributes: ['email', 'displayName'], required: false }],
});
return 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,
};
});
}