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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { try { const body = req.body as Record; 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 { 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; const updateData: Record = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; 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 | undefined; if (body.ocrExtractedData && typeof body.ocrExtractedData === 'string') { try { ocrExtractedData = JSON.parse(body.ocrExtractedData) as Record; } 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 { 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 { 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();