826 lines
38 KiB
TypeScript
826 lines
38 KiB
TypeScript
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();
|