2052 lines
80 KiB
TypeScript
2052 lines
80 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
});
|
||
}
|