Re_Backend/src/controllers/form16.controller.ts
2026-03-18 12:59:20 +05:30

826 lines
38 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.

import { Request, Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { AuthenticatedRequest } from '../types/express';
import * as form16Service from '../services/form16.service';
import {
simulateCreditNoteFromSap,
simulateDebitNoteFromSap,
type SapCreditNoteDealerDetails,
type SapDebitNoteDealerInfo,
} from '../services/form16SapSimulation.service';
import { extractForm16ADetails } from '../services/form16Ocr.service';
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { Form16aSubmission } from '@models/Form16aSubmission';
/**
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
*/
export class Form16Controller {
/**
* GET /api/v1/form16/permissions
* Returns Form 16 permissions for the current user (API-driven from admin config).
*/
async getPermissions(req: Request, res: Response): Promise<void> {
try {
const user = (req as AuthenticatedRequest).user;
if (!user?.userId || !user?.email) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const role = (user as any).role;
const [canViewForm16SubmissionData, canView26AS] = await Promise.all([
canViewForm16Submission(user.email, user.userId, role),
canView26As(user.email, role),
]);
return ResponseHandler.success(
res,
{ canViewForm16Submission: canViewForm16SubmissionData, canView26AS },
'Form 16 permissions'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] getPermissions error:', error);
return ResponseHandler.error(res, 'Failed to get Form 16 permissions', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/credit-notes
* Dealer: list credit notes for the authenticated dealer.
* RE: list all credit notes (all dealers).
*/
async listCreditNotes(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const result = await form16Service.listCreditNotesDealerOrRe(userId, {
financialYear,
quarter,
});
const payload: { creditNotes: typeof result.rows; total: number; summary?: typeof result.summary } = {
creditNotes: result.rows,
total: result.total,
};
if ((result as any).summary) payload.summary = (result as any).summary;
return ResponseHandler.success(res, payload, 'Credit notes fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listCreditNotes error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit notes', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/debit-notes
* RE only. List all debit notes (all dealers).
*/
async listDebitNotes(req: Request, res: Response): Promise<void> {
try {
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const result = await form16Service.listAllDebitNotesForRe({ financialYear, quarter });
const payload: { debitNotes: typeof result.rows; total: number; summary?: typeof result.summary } = {
debitNotes: result.rows,
total: result.total,
summary: (result as any).summary,
};
return ResponseHandler.success(res, payload, 'Debit notes fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listDebitNotes error:', error);
return ResponseHandler.error(res, 'Failed to fetch debit notes', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/dealer/submissions
* Dealer only. List Form 16 submissions for the authenticated dealer (pending/failed for Pending Submissions page).
* Query: status (optional) pending | failed | pending,failed; financialYear; quarter.
*/
async listDealerSubmissions(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const status = req.query.status as string | undefined;
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const list = await form16Service.listDealerSubmissions(userId, { status, financialYear, quarter });
return ResponseHandler.success(res, list, 'Dealer submissions fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listDealerSubmissions error:', error);
return ResponseHandler.error(res, 'Failed to fetch dealer submissions', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/dealer/pending-quarters
* Dealer only. List quarters for which Form 16A has not been completed (no credit note).
*/
async listDealerPendingQuarters(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const list = await form16Service.listDealerPendingQuarters(userId);
return ResponseHandler.success(res, list, 'Pending quarters fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listDealerPendingQuarters error:', error);
return ResponseHandler.error(res, 'Failed to fetch pending quarters', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/non-submitted-dealers
* RE only. List dealers with missing Form 16A submissions for the given financial year (optional; defaults to current FY).
* Query: financialYear (optional) e.g. "2024-25"; omit or "" for default/current FY.
*/
async listNonSubmittedDealers(req: Request, res: Response): Promise<void> {
try {
const financialYear = (req.query.financialYear as string)?.trim() || undefined;
const result = await form16Service.listNonSubmittedDealers(financialYear);
return ResponseHandler.success(res, result, 'Non-submitted dealers fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listNonSubmittedDealers error:', error);
return ResponseHandler.error(res, 'Failed to fetch non-submitted dealers', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/non-submitted-dealers/notify
* RE only. Send "submit Form 16" notification to one non-submitted dealer and record it (last notified column updates).
* Body: { dealerCode: string, financialYear?: string }.
*/
async notifyNonSubmittedDealer(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const body = (req.body || {}) as { dealerCode?: string; financialYear?: string };
const dealerCode = (body.dealerCode || '').trim();
if (!dealerCode) {
return ResponseHandler.error(res, 'dealerCode is required', 400);
}
const financialYear = (body.financialYear || '').trim() || undefined;
const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId);
if (!updated) {
return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404);
}
return ResponseHandler.success(res, { dealer: updated }, 'Notification sent');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] notifyNonSubmittedDealer error:', error);
return ResponseHandler.error(res, 'Failed to send notification', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/26as
* RE only. List 26AS TDS entries with optional filters, pagination, and summary.
*/
async list26as(req: Request, res: Response): Promise<void> {
try {
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const tanNumber = req.query.tanNumber as string | undefined;
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const assessmentYear = req.query.assessmentYear as string | undefined;
const sectionCode = req.query.sectionCode as string | undefined;
const limit = req.query.limit != null ? parseInt(String(req.query.limit), 10) : undefined;
const offset = req.query.offset != null ? parseInt(String(req.query.offset), 10) : undefined;
const result = await form16Service.list26asEntries({
financialYear,
quarter,
tanNumber,
search,
status,
assessmentYear,
sectionCode,
limit,
offset,
});
return ResponseHandler.success(
res,
{ entries: result.rows, total: result.total, summary: result.summary },
'26AS entries fetched'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] list26as error:', error);
return ResponseHandler.error(res, 'Failed to fetch 26AS entries', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/26as
* RE only. Create a 26AS TDS entry.
*/
async create26as(req: Request, res: Response): Promise<void> {
try {
const body = req.body as Record<string, unknown>;
const tanNumber = (body.tanNumber as string)?.trim();
const financialYear = (body.financialYear as string)?.trim();
const quarter = (body.quarter as string)?.trim();
if (!tanNumber || !financialYear || !quarter) {
return ResponseHandler.error(res, 'tanNumber, financialYear and quarter are required', 400);
}
const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0));
const entry = await form16Service.create26asEntry({
tanNumber,
deductorName: (body.deductorName as string) || undefined,
quarter,
assessmentYear: (body.assessmentYear as string) || undefined,
financialYear,
sectionCode: (body.sectionCode as string) || undefined,
amountPaid: typeof body.amountPaid === 'number' ? body.amountPaid : (body.amountPaid ? parseFloat(String(body.amountPaid)) : undefined),
taxDeducted: Number.isNaN(taxDeducted) ? 0 : taxDeducted,
totalTdsDeposited: typeof body.totalTdsDeposited === 'number' ? body.totalTdsDeposited : (body.totalTdsDeposited ? parseFloat(String(body.totalTdsDeposited)) : undefined),
natureOfPayment: (body.natureOfPayment as string) || undefined,
transactionDate: (body.transactionDate as string) || undefined,
dateOfBooking: (body.dateOfBooking as string) || undefined,
statusOltas: (body.statusOltas as string) || undefined,
remarks: (body.remarks as string) || undefined,
});
return ResponseHandler.success(res, { entry }, '26AS entry created');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] create26as error:', error);
return ResponseHandler.error(res, 'Failed to create 26AS entry', 500, errorMessage);
}
}
/**
* PUT /api/v1/form16/26as/:id
* RE only. Update a 26AS TDS entry.
*/
async update26as(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid entry id', 400);
}
const body = req.body as Record<string, unknown>;
const updateData: Record<string, unknown> = {};
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
if (body.deductorName !== undefined) updateData.deductorName = body.deductorName;
if (body.quarter !== undefined) updateData.quarter = body.quarter;
if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear;
if (body.financialYear !== undefined) updateData.financialYear = body.financialYear;
if (body.sectionCode !== undefined) updateData.sectionCode = body.sectionCode;
if (body.amountPaid !== undefined) updateData.amountPaid = body.amountPaid;
if (body.taxDeducted !== undefined) updateData.taxDeducted = body.taxDeducted;
if (body.totalTdsDeposited !== undefined) updateData.totalTdsDeposited = body.totalTdsDeposited;
if (body.natureOfPayment !== undefined) updateData.natureOfPayment = body.natureOfPayment;
if (body.transactionDate !== undefined) updateData.transactionDate = body.transactionDate;
if (body.dateOfBooking !== undefined) updateData.dateOfBooking = body.dateOfBooking;
if (body.statusOltas !== undefined) updateData.statusOltas = body.statusOltas;
if (body.remarks !== undefined) updateData.remarks = body.remarks;
const entry = await form16Service.update26asEntry(id, updateData as any);
if (!entry) {
return ResponseHandler.error(res, '26AS entry not found', 404);
}
return ResponseHandler.success(res, { entry }, '26AS entry updated');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] update26as error:', error);
return ResponseHandler.error(res, 'Failed to update 26AS entry', 500, errorMessage);
}
}
/**
* DELETE /api/v1/form16/26as/:id
* RE only. Delete a 26AS TDS entry.
*/
async delete26as(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid entry id', 400);
}
const deleted = await form16Service.delete26asEntry(id);
if (!deleted) {
return ResponseHandler.error(res, '26AS entry not found', 404);
}
return ResponseHandler.success(res, {}, '26AS entry deleted');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] delete26as error:', error);
return ResponseHandler.error(res, 'Failed to delete 26AS entry', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/credit-notes/:id
* Get a single credit note by id with dealer info and dealer transaction history.
*/
async getCreditNoteById(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid credit note id', 400);
}
const result = await form16Service.getCreditNoteById(id);
if (!result) {
return ResponseHandler.error(res, 'Credit note not found', 404);
}
return ResponseHandler.success(res, result, 'Credit note fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] getCreditNoteById error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit note', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/credit-notes/:id/download
* Returns a storage URL for the SAP response CSV if available.
* If not yet available, returns 409 so UI can show "being generated, wait".
*/
async downloadCreditNote(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid credit note id', 400);
}
let url: string | null = null;
try {
url = await form16Service.getCreditNoteSapResponseUrlForUser(id, userId);
} catch (e: any) {
const msg = String(e?.message || '');
if (msg.toLowerCase().includes('not found')) {
return ResponseHandler.error(res, 'Credit note not found', 404);
}
throw e;
}
if (!url) {
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
}
return ResponseHandler.success(res, { url }, 'OK');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] downloadCreditNote error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit note download link', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/debit-notes/:id/sap-response
* RE only. Returns a storage URL for the SAP response CSV if available.
* If not yet available, returns 409 so UI can show "being generated, wait".
*/
async viewDebitNoteSapResponse(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid debit note id', 400);
}
const url = await form16Service.getDebitNoteSapResponseUrl(id);
if (!url) {
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
}
return ResponseHandler.success(res, { url }, 'OK');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] viewDebitNoteSapResponse error:', error);
return ResponseHandler.error(res, 'Failed to fetch debit note SAP response', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/requests/:requestId/credit-note
* Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab.
*/
async getCreditNoteByRequest(req: Request, res: Response): Promise<void> {
try {
const requestId = (req.params as { requestId: string }).requestId;
if (!requestId) {
return ResponseHandler.error(res, 'requestId required', 400);
}
const note = await form16Service.getCreditNoteByRequestId(requestId);
return ResponseHandler.success(res, { creditNote: note }, note ? 'Credit note fetched' : 'No credit note for this request');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] getCreditNoteByRequest error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit note', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/requests/:requestId/cancel-submission
* RE only. Cancel the Form 16 submission and mark workflow as rejected.
*/
async cancelForm16Submission(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
const requestId = (req.params as { requestId: string }).requestId;
if (!requestId) return ResponseHandler.error(res, 'Request ID required', 400);
const result = await form16Service.cancelForm16Submission(requestId, userId);
const { triggerForm16UnsuccessfulByRequestId } = await import('../services/form16Notification.service');
triggerForm16UnsuccessfulByRequestId(requestId, 'Your Form 16 submission was cancelled by RE. No credit note will be issued.').catch((err) =>
logger.error('[Form16Controller] Cancel notification failed:', err)
);
return ResponseHandler.success(res, result, 'Submission cancelled');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] cancelForm16Submission error:', error);
return ResponseHandler.error(res, errorMessage, 404);
}
}
/**
* POST /api/v1/form16/requests/:requestId/resubmission-needed
* RE only. Mark Form 16 submission as resubmission needed (e.g. partial OCR).
*/
async setForm16ResubmissionNeeded(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
const requestId = (req.params as { requestId: string }).requestId;
if (!requestId) return ResponseHandler.error(res, 'Request ID required', 400);
const result = await form16Service.setForm16ResubmissionNeeded(requestId, userId);
const { triggerForm16UnsuccessfulByRequestId } = await import('../services/form16Notification.service');
triggerForm16UnsuccessfulByRequestId(requestId, 'RE has marked this submission as resubmission needed. Please resubmit Form 16.').catch((err) =>
logger.error('[Form16Controller] Resubmission-needed notification failed:', err)
);
return ResponseHandler.success(res, result, 'Marked as resubmission needed');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] setForm16ResubmissionNeeded error:', error);
return ResponseHandler.error(res, errorMessage, 404);
}
}
/**
* POST /api/v1/form16/sap-simulate/credit-note
* Form 16 only. Simulate SAP credit note generation (dealer details + amount → JSON response).
* When real SAP is integrated, replace the simulation service with the actual SAP API call.
*/
async sapSimulateCreditNote(req: Request, res: Response): Promise<void> {
try {
const body = (req.body || {}) as { dealerDetails?: SapCreditNoteDealerDetails; amount?: number };
const dealerDetails = body.dealerDetails as SapCreditNoteDealerDetails | undefined;
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
if (!dealerDetails?.dealerCode || amount <= 0) {
return ResponseHandler.error(res, 'dealerDetails.dealerCode and a positive amount are required', 400);
}
const response = simulateCreditNoteFromSap(dealerDetails, amount);
return ResponseHandler.success(res, response, 'Simulated SAP credit note');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] sapSimulateCreditNote error:', error);
return ResponseHandler.error(res, 'SAP credit note simulation failed', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/sap-simulate/debit-note
* Form 16 only. Simulate SAP debit note generation (dealer code, dealer info, credit note number, amount → JSON response).
* When real SAP is integrated, replace the simulation service with the actual SAP API call.
*/
async sapSimulateDebitNote(req: Request, res: Response): Promise<void> {
try {
const body = (req.body || {}) as {
dealerCode?: string;
dealerInfo?: SapDebitNoteDealerInfo;
creditNoteNumber?: string;
amount?: number;
};
const dealerCode = (body.dealerCode || '').trim();
const dealerInfo = body.dealerInfo || { dealerCode };
const creditNoteNumber = (body.creditNoteNumber || '').trim();
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
if (!dealerCode || !creditNoteNumber || amount <= 0) {
return ResponseHandler.error(res, 'dealerCode, creditNoteNumber and a positive amount are required', 400);
}
const response = simulateDebitNoteFromSap({ dealerCode, dealerInfo, creditNoteNumber, amount });
return ResponseHandler.success(res, response, 'Simulated SAP debit note');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] sapSimulateDebitNote error:', error);
return ResponseHandler.error(res, 'SAP debit note simulation failed', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/26as/upload
* RE only. Upload a single TXT file containing 26AS data (all dealers). Data stored in tds_26as_entries.
* Only Section 194Q and Booking Status F/O are used for quarter aggregation. Log created first; then aggregation and auto-debit (if 26AS total changed and quarter was settled).
*/
async upload26as(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
const file = (req as any).file;
if (!file || !file.buffer) {
return ResponseHandler.error(res, 'No file uploaded. Please upload a .txt file.', 400);
}
if (!userId) {
return ResponseHandler.error(res, 'Authentication required', 401);
}
const log = await form16Service.log26asUpload(userId, {
fileName: file.originalname || undefined,
recordsImported: 0,
errorsCount: 0,
});
const result = await form16Service.upload26asFile(file.buffer, (log as any).id);
await (log as any).update({
recordsImported: result.imported,
errorsCount: result.errors.length,
});
if (result.imported > 0) {
const agg = await form16Service.process26asUploadAggregation((log as any).id);
logger.info(`[Form16] 26AS upload aggregation: ${agg.snapshotsCreated} snapshot(s), ${agg.debitsCreated} debit(s) created.`);
}
if (result.imported > 0) {
const { trigger26AsDataAddedNotification } = await import('../services/form16Notification.service');
trigger26AsDataAddedNotification().catch((err) =>
logger.error('[Form16Controller] 26AS notification trigger failed:', err)
);
}
return ResponseHandler.success(
res,
{ imported: result.imported, errors: result.errors },
result.imported > 0
? `26AS data uploaded: ${result.imported} record(s) imported.`
: 'No records imported.'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] upload26as error:', error);
return ResponseHandler.error(res, 'Failed to upload 26AS file', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/26as/upload-history
* RE only. List 26AS upload audit log (who uploaded, when, records imported) for management section.
*/
async get26asUploadHistory(req: Request, res: Response): Promise<void> {
try {
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10), 1), 200);
const history = await form16Service.list26asUploadHistory(limit);
return ResponseHandler.success(res, { history }, '26AS upload history fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] get26asUploadHistory error:', error);
return ResponseHandler.error(res, 'Failed to fetch 26AS upload history', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/extract
* Exact REform16 pattern: req.file.path from multer disk storage, ocrService.extractForm16ADetails, same response shape.
*/
async extractOcr(req: Request, res: Response): Promise<void> {
const file = (req as any).file;
if (!file) {
return ResponseHandler.error(res, 'No file uploaded', 400);
}
const filePath = file.path as string;
if (!filePath || !fs.existsSync(filePath)) {
return ResponseHandler.error(res, 'No file uploaded', 400);
}
try {
logger.info('[Form16Controller] Performing OCR extraction for preview...');
const ocrResult = await extractForm16ADetails(filePath);
if (ocrResult.success) {
logger.info('[Form16Controller] OCR extraction successful:', ocrResult.method || 'gemini');
return ResponseHandler.success(
res,
{
extractedData: ocrResult.data,
ocrMethod: ocrResult.method || 'gemini',
ocrProvider: ocrResult.ocrProvider || 'Google Gemini API',
filename: file.filename,
originalName: file.originalname,
size: file.size,
url: `/uploads/form16-extract/${file.filename}`,
},
'OCR extraction completed successfully'
);
}
logger.error('[Form16Controller] OCR extraction failed:', ocrResult.error);
return ResponseHandler.error(
res,
ocrResult.message || 'Failed to extract data from PDF',
400,
ocrResult.error
);
} catch (error: any) {
logger.error('[Form16Controller] extractOcr error:', error);
return ResponseHandler.error(res, 'OCR extraction failed', 500, error?.message);
} finally {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch (e) {
logger.warn('[Form16Controller] Failed to delete temp file:', e);
}
}
}
}
/**
* POST /api/v1/form16/submissions
* Create Form 16 submission: workflow_request + form16a_submissions, upload document.
*/
async createSubmission(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const file = (req as any).file;
if (!file || !file.buffer) {
return ResponseHandler.error(res, 'No file uploaded. Please upload Form 16A PDF.', 400);
}
const body = req.body as Record<string, string>;
const dealerCode = (body.dealerCode || '').trim(); // optional: required when user is not mapped as dealer
const financialYear = (body.financialYear || '').trim();
const quarter = (body.quarter || '').trim();
const form16aNumber = (body.form16aNumber || '').trim();
const tdsAmount = parseFloat(body.tdsAmount);
const totalAmount = parseFloat(body.totalAmount);
const tanNumber = (body.tanNumber || '').trim();
const deductorName = (body.deductorName || '').trim();
const version = body.version ? parseInt(body.version, 10) : 1;
let ocrExtractedData: Record<string, unknown> | undefined;
if (body.ocrExtractedData && typeof body.ocrExtractedData === 'string') {
try {
ocrExtractedData = JSON.parse(body.ocrExtractedData) as Record<string, unknown>;
} catch {
// ignore invalid JSON
}
}
if (!financialYear || !quarter || !form16aNumber || !tanNumber || !deductorName) {
return ResponseHandler.error(res, 'Missing required fields: financialYear, quarter, form16aNumber, tanNumber, deductorName', 400);
}
if (Number.isNaN(tdsAmount) || Number.isNaN(totalAmount) || tdsAmount < 0 || totalAmount < 0) {
return ResponseHandler.error(res, 'Valid tdsAmount and totalAmount (numbers >= 0) are required', 400);
}
const result = await form16Service.createSubmission(
userId,
file.buffer,
file.originalname || 'form16a.pdf',
{
dealerCode: dealerCode || undefined,
financialYear,
quarter,
form16aNumber,
tdsAmount,
totalAmount,
tanNumber,
deductorName,
version,
ocrExtractedData,
}
);
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
triggerForm16SubmissionResultNotification(userId, result.validationStatus, {
creditNoteNumber: result.creditNoteNumber ?? undefined,
requestId: result.requestId,
validationNotes: result.validationNotes,
}).catch((err) => logger.error('[Form16Controller] Form 16 result notification failed:', err));
return ResponseHandler.success(res, {
requestId: result.requestId,
requestNumber: result.requestNumber,
submissionId: result.submissionId,
validationStatus: result.validationStatus ?? undefined,
creditNoteNumber: result.creditNoteNumber ?? undefined,
validationNotes: result.validationNotes ?? undefined,
}, 'Form 16A submission created');
} catch (error: any) {
const message = error?.message || 'Unknown error';
logger.error('[Form16Controller] createSubmission error:', error);
if (message.includes('Dealer not found') || message.includes('dealerCode is required') || message.includes('Invalid dealerCode')) {
return ResponseHandler.error(res, message, 403);
}
// No validation on certificate number: revised Form 16A can reuse same certificate number for same quarter.
// Unique constraint only on request_id; duplicate (same quarter + amount + credit note) is enforced in 26AS match.
const isUniqueError = error?.name === 'SequelizeUniqueConstraintError' || message.includes('duplicate') || message.includes('unique') || message.includes('already been submitted');
if (isUniqueError) {
return ResponseHandler.error(res, 'This submission could not be created due to a conflict with an existing record.', 400);
}
// Surface Sequelize validation errors (e.g. field length, type) so client gets a clear message
const isSequelizeValidation = error?.name === 'SequelizeValidationError' && Array.isArray(error?.errors);
const detail = isSequelizeValidation
? error.errors.map((e: any) => e.message || e.type).join('; ')
: message;
const statusCode = isSequelizeValidation ? 400 : 500;
return ResponseHandler.error(res, 'Failed to create Form 16 submission', statusCode, detail);
}
}
/**
* POST /api/v1/form16/test-notification (dev only)
* Sends a Form 16 notification (in-app + push + email) to the current user for testing.
* With Ethereal: check backend logs for "📧 Preview URL" to open the email in browser.
*/
async testNotification(req: Request, res: Response): Promise<void> {
try {
const userId = (req as AuthenticatedRequest).user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const { notificationService } = await import('../services/notification.service');
await notificationService.sendToUsers([userId], {
title: 'Form 16 Test notification',
body: 'This is a test. If you see this in-app and in your email (Ethereal preview URL in backend logs), Form 16 notifications are working.',
type: 'form16_success_credit_note',
});
return ResponseHandler.success(res, { message: 'Test notification sent. Check in-app bell and backend logs for email preview URL (Ethereal).' }, 'OK');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] testNotification error:', error);
return ResponseHandler.error(res, 'Failed to send test notification', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/requests/:requestId/contact-admin
* Dealer UX: when submission fails because 26AS is missing for the quarter, dealer can notify RE admins.
*/
async contactAdmin(req: Request, res: Response): Promise<void> {
try {
const user = (req as AuthenticatedRequest).user;
const userId = user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const requestId = String(req.params.requestId || '').trim();
if (!requestId) return ResponseHandler.error(res, 'requestId is required', 400);
const reqRow = await WorkflowRequest.findByPk(requestId, {
attributes: ['requestId', 'requestNumber', 'templateType', 'initiatorId'],
raw: true,
});
if (!reqRow || (reqRow as any).templateType !== 'FORM_16') {
return ResponseHandler.error(res, 'Form 16 request not found', 404);
}
if ((reqRow as any).initiatorId !== userId) {
return ResponseHandler.error(res, 'Forbidden', 403);
}
const submission = await Form16aSubmission.findOne({
where: { requestId },
order: [['submittedDate', 'DESC']],
attributes: ['id', 'validationStatus', 'validationNotes'],
});
if (!submission) return ResponseHandler.error(res, 'Submission not found', 404);
const s: any = submission as any;
const notes = String(s.validationNotes || '').toLowerCase();
const isMissing26As =
notes.includes('no 26as') ||
notes.includes('no 26as record') ||
notes.includes('ensure 26as has been uploaded');
if (!isMissing26As) {
return ResponseHandler.error(res, 'Contact admin is available only when 26AS is missing for the quarter.', 400);
}
const { triggerForm16MismatchContactAdminNotification } = await import('../services/form16Notification.service');
await triggerForm16MismatchContactAdminNotification({
requestId,
requestNumber: (reqRow as any).requestNumber,
dealerUserId: userId,
});
return ResponseHandler.success(res, { ok: true }, 'Admin notified');
} catch (error: any) {
logger.error('[Form16Controller] contactAdmin error:', error);
return ResponseHandler.error(res, 'Failed to notify admin', 500, error?.message);
}
}
}
export const form16Controller = new Form16Controller();