Updated email templates and csv

This commit is contained in:
Aaditya Jaiswal 2026-03-12 15:37:40 +05:30
parent e4d45b4fca
commit eb3db7cd3a
10 changed files with 443 additions and 177 deletions

View File

@ -106,3 +106,12 @@ SAP_REQUESTER=REFMS
# WARNING: Only use in development/testing environments
SAP_DISABLE_SSL_VERIFY=false
# WFM file paths (base path; dealer claims use DLR_INC_CLAIMS, Form 16 uses FORM_16)
# If unset: Windows defaults to C:\WFM; Linux/Mac defaults to <cwd>/wfm (paths are cross-platform).
# WFM_BASE_PATH=C:\WFM
# WFM_INCOMING_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_MAIN\DLR_INC_CLAIMS
# WFM_OUTGOING_CLAIMS_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\DLR_INC_CLAIMS
# Form 16 credit/debit note CSV: INCOMING/WFM_MAIN/FORM_16, OUTGOING/WFM_SAP_MAIN/FORM_16
# WFM_FORM16_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\Form_16
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\Form_16

View File

@ -391,38 +391,6 @@ export class Form16Controller {
}
}
/**
* POST /api/v1/form16/requests/:requestId/generate-credit-note
* RE only. Manually generate credit note (e.g. when OCR was partial). Body: { amount: number }.
*/
async generateForm16CreditNote(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 body = (req.body || {}) as { amount?: number };
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
const result = await form16Service.generateForm16CreditNoteManually(requestId, userId, amount);
const { triggerForm16ManualCreditNoteNotification } = await import('../services/form16Notification.service');
const cnNumber = (result.creditNote as any)?.creditNoteNumber;
if (cnNumber) {
triggerForm16ManualCreditNoteNotification(requestId, cnNumber).catch((err) =>
logger.error('[Form16Controller] Manual credit note notification failed:', err)
);
}
return ResponseHandler.success(
res,
{ creditNote: result.creditNote, submission: result.submission },
'Credit note generated (manually approved)'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] generateForm16CreditNote error:', error);
return ResponseHandler.error(res, errorMessage, 400);
}
}
/**
* POST /api/v1/form16/sap-simulate/credit-note
* Form 16 only. Simulate SAP credit note generation (dealer details + amount JSON response).
@ -636,6 +604,7 @@ export class Form16Controller {
}
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();
@ -665,6 +634,7 @@ export class Form16Controller {
file.buffer,
file.originalname || 'form16a.pdf',
{
dealerCode: dealerCode || undefined,
financialYear,
quarter,
form16aNumber,
@ -695,7 +665,7 @@ export class Form16Controller {
} catch (error: any) {
const message = error?.message || 'Unknown error';
logger.error('[Form16Controller] createSubmission error:', error);
if (message.includes('Dealer not found')) {
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.

View File

@ -0,0 +1,102 @@
/**
* Form 16 Email Template (generic wrapper for Form 16 notification types)
*
* Used by notification.service.ts when payload.type starts with `form16_`.
* Payload body comes from Form 16 admin-config templates (plain text with placeholders already substituted).
*/
import { Form16EmailData } from './types';
import { getEmailFooter, getEmailHeader, getEmailContainerStyles, getResponsiveStyles, HeaderStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getForm16Email(data: Form16EmailData): string {
const headerStyle =
data.variant === 'success'
? HeaderStyles.success
: data.variant === 'warning'
? HeaderStyles.warning
: data.variant === 'error'
? HeaderStyles.error
: HeaderStyles.info;
const requestBlock = data.requestId
? `
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 26px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 18px 20px;">
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 4px 0; color: #666666; font-size: 13px; width: 110px;"><strong>Request ID:</strong></td>
<td style="padding: 4px 0; color: #333333; font-size: 13px;">${data.requestId}</td>
</tr>
</table>
</td>
</tr>
</table>
`
: '';
const ctaBlock = data.viewDetailsLink
? `
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 10px 0 6px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 14px 34px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 15px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
`
: '';
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>${data.title}</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ title: data.title, ...headerStyle }))}
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 18px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
${requestBlock}
<div style="padding: 18px 18px; background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 6px; margin-bottom: 24px;">
<div style="margin: 0; color: #333333; font-size: 14px; line-height: 1.8;">
${data.messageHtml}
</div>
</div>
${ctaBlock}
<p style="margin: 18px 0 0; color: #666666; font-size: 13px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -36,4 +36,5 @@ export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmi
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template';
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';
export { getForm16Email } from './form_16_email.template';

View File

@ -12,6 +12,22 @@ export interface BaseEmailData {
companyName: string;
}
export interface Form16EmailData {
recipientName: string;
/** Email title shown in header + subject */
title: string;
/** Already-sanitized HTML (escaped) message body */
messageHtml: string;
/** Optional: request UUID for link + context */
requestId?: string;
/** Optional: deep link to /request/:requestId */
viewDetailsLink?: string;
/** Brand name */
companyName: string;
/** Controls header color */
variant?: 'info' | 'success' | 'warning' | 'error';
}
export interface RequestCreatedData extends BaseEmailData {
initiatorName: string;
firstApproverName: string;

View File

@ -83,7 +83,7 @@ router.get(
requireForm16SubmissionAccess,
asyncHandler(form16Controller.getCreditNoteByRequest.bind(form16Controller))
);
// RE only: Form 16 request actions (cancel, resubmission needed, manual credit note)
// RE only: Form 16 request actions (cancel, resubmission needed)
router.post(
'/requests/:requestId/cancel-submission',
requireForm16ReOnly,
@ -96,12 +96,6 @@ router.post(
requireForm16SubmissionAccess,
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller))
);
router.post(
'/requests/:requestId/generate-credit-note',
requireForm16ReOnly,
requireForm16SubmissionAccess,
asyncHandler(form16Controller.generateForm16CreditNote.bind(form16Controller))
);
// Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
router.post(

View File

@ -1,6 +1,9 @@
/**
* Form 16 (Form 16A TDS Credit) service.
* Quarter-based reconciliation: 26AS (aggregated by tan+fy+quarter), Form 16A match, credit/debit, ledger.
*
* Credit note generation: run26asMatchAndCreditNote only (on Form 16A submit, match 26AS Section 194Q/Booking F/O CN-F-16-{dealerCode}-{FY}-{quarter}, ledger, CSV to WFM FORM_16).
* Debit: generateForm16DebitNoteForCreditNote (manual) and process26asUploadAggregation (auto when 26AS total drops); DN-F-16-{dc}-{fy}-{q}.
*/
import crypto from 'crypto';
@ -25,17 +28,26 @@ import { Priority, WorkflowStatus } from '../types/common.types';
import { generateRequestNumber } from '../utils/helpers';
import { gcsStorageService } from './gcsStorage.service';
import { activityService } from './activity.service';
import { simulateCreditNoteFromSap, simulateDebitNoteFromSap } from './form16SapSimulation.service';
import { wfmFileService } from './wfmFile.service';
import logger from '../utils/logger';
/**
* Resolve dealer_code for the current user (by email match with dealers.dealer_principal_email_id).
* Returns null if user is not a dealer or no dealer found.
* Resolve dealer_code for the current user.
* Uses users.employee_number (DB column) first dealer code saved at login; else falls back to email match with dealers.dealer_principal_email_id.
* Same dealer code is used for submission, credit note, and debit note generation.
* Returns null if no dealer code found.
*/
export async function getDealerCodeForUser(userId: string): Promise<string | null> {
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
if (!user || !user.email) return null;
const [row] = await sequelize.query<{ employee_number: string | null }>(
`SELECT employee_number FROM users WHERE user_id = :userId LIMIT 1`,
{ replacements: { userId }, type: QueryTypes.SELECT }
);
if (row?.employee_number != null && String(row.employee_number).trim()) {
return String(row.employee_number).trim();
}
const user = await User.findByPk(userId, { attributes: ['userId', 'email'] });
if (!user?.email) return null;
const dealer = await Dealer.findOne({
where: {
dealerPrincipalEmailId: { [Op.iLike]: user.email },
@ -52,7 +64,12 @@ export async function getDealerCodeForUser(userId: string): Promise<string | nul
/** 26AS: only Section 194Q and Booking Status F or O are considered for aggregation and matching. */
const SECTION_26AS_194Q = '194Q';
/** Get aggregated TDS amount for (tan, fy, quarter) from all 26AS entries (Section 194Q, Booking F/O). */
/**
* Get aggregated TDS amount for (tan, fy, quarter) from the LATEST 26AS upload only (Section 194Q, Booking F/O).
* Use case: "Always match Form 16A only with the latest 26AS version." Each upload can be full cumulative;
* we sum only rows from the most recent upload for that quarter to avoid double-counting across uploads.
* If no rows have upload_log_id (legacy), falls back to summing all rows for that quarter.
*/
export async function getLatest26asAggregatedForQuarter(
tanNumber: string,
financialYear: string,
@ -62,12 +79,24 @@ export async function getLatest26asAggregatedForQuarter(
const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter;
const [row] = await sequelize.query<{ sum: string }>(
`SELECT COALESCE(SUM(tax_deducted), 0)::text AS sum
FROM tds_26as_entries
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
AND financial_year = :fy AND quarter = :qtr
AND section_code = :section
AND (status_oltas = 'F' OR status_oltas = 'O')`,
`WITH latest_upload AS (
SELECT MAX(upload_log_id) AS mid FROM tds_26as_entries
WHERE LOWER(REPLACE(TRIM(tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
AND financial_year = :fy AND quarter = :qtr
AND section_code = :section
AND (status_oltas = 'F' OR status_oltas = 'O')
AND upload_log_id IS NOT NULL
)
SELECT COALESCE(SUM(e.tax_deducted), 0)::text AS sum
FROM tds_26as_entries e
WHERE LOWER(REPLACE(TRIM(e.tan_number), ' ', '')) = LOWER(REPLACE(TRIM(:tan), ' ', ''))
AND e.financial_year = :fy AND e.quarter = :qtr
AND e.section_code = :section
AND (e.status_oltas = 'F' OR e.status_oltas = 'O')
AND (
e.upload_log_id = (SELECT mid FROM latest_upload)
OR (SELECT mid FROM latest_upload) IS NULL
)`,
{ replacements: { tan: normalized, fy, qtr: q, section: SECTION_26AS_194Q }, type: QueryTypes.SELECT }
);
return parseFloat(row?.sum ?? '0') || 0;
@ -272,6 +301,11 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
}
export interface CreateForm16SubmissionBody {
/**
* Optional override for RE/UAT users who are not mapped as a dealer by email.
* If user is a dealer, this (when provided) must match the resolved dealerCode.
*/
dealerCode?: string;
financialYear: string;
quarter: string;
form16aNumber: string;
@ -369,6 +403,38 @@ function normalizeQuarter(raw: string): string {
return (raw || '').trim() || '';
}
/** Compact FY for Form 16 note numbers: "2024-25" -> "24-25" */
function form16FyCompact(financialYear: string): string {
const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim();
if (!fy) return '';
const m = fy.match(/^(\d{2,4})-(\d{2})$/);
if (m) {
const start = m[1].length === 2 ? m[1] : m[1].slice(-2);
return `${start}-${m[2]}`;
}
return fy;
}
/**
* Form 16 credit note number: CN-F-16-DC-FY-Q (CN=credit note, F=form, 16, DC=dealer code, FY=financial year, Q=quarter)
*/
export function formatForm16CreditNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
const fy = form16FyCompact(financialYear) || 'XX';
const q = normalizeQuarter(quarter) || 'X';
return `CN-F-16-${dc}-${fy}-${q}`;
}
/**
* Form 16 debit note number: DN-F-16-DC-FY-Q (DN=debit note, F=form, 16, DC=dealer code, FY=financial year, Q=quarter)
*/
export function formatForm16DebitNoteNumber(dealerCode: string, financialYear: string, quarter: string): string {
const dc = (dealerCode || '').trim().replace(/\s+/g, '-') || 'XX';
const fy = form16FyCompact(financialYear) || 'XX';
const q = normalizeQuarter(quarter) || 'X';
return `DN-F-16-${dc}-${fy}-${q}`;
}
/**
* Match submission against latest 26AS aggregated amount (quarter-level). Only Section 194Q, Booking F/O.
* Reject if no 26AS data, amount mismatch, or duplicate (already settled with same amount).
@ -454,7 +520,9 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
}
}
const cnNumber = `CN-${new Date().getFullYear()}-${submission.id}-${Date.now().toString(36).toUpperCase()}`;
// Dealer code from submission (set at create from users.employee_number)
const dealerCode = (sub.dealerCode || '').toString().trim();
const cnNumber = formatForm16CreditNoteNumber(dealerCode, financialYear, quarter);
const now = new Date();
const creditNote = await Form16CreditNote.create({
submissionId: submission.id,
@ -482,6 +550,35 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
validationStatus: 'success',
validationNotes: null,
});
// Push Form 16 credit note CSV to WFM INCOMING/WFM_MAIN/FORM_16 (pipe | separator, no double quotes)
try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const csvRow = {
CREDIT_TYPE: 'Form16',
DEALER_CODE: dealerCode,
DEALER_NAME: dealerName,
AMOUNT: tdsAmount,
FINANCIAL_YEAR: financialYear,
QUARTER: quarter,
CREDIT_NOTE_NUMBER: cnNumber,
TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate,
};
const fileName = `${cnNumber}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
logger.info(`[Form16] Credit note CSV pushed to WFM FORM_16: ${cnNumber}`);
} catch (csvErr: any) {
logger.error('[Form16] Failed to push credit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
// Do not fail the flow; credit note and ledger are already created
}
logger.info(
`[Form16] 26AS MATCH RESULT: SUCCESS Credit note issued. Form 16A: TAN=${tanNumber}, FY=${financialYear}, Quarter=${quarter}, TDS amount=${tdsAmount} | 26AS aggregated=${aggregated26as} | Credit note=${cnNumber}.`
);
@ -494,9 +591,12 @@ export async function createSubmission(
originalName: string,
body: CreateForm16SubmissionBody
): Promise<CreateForm16SubmissionResult> {
const dealerCode = await getDealerCodeForUser(userId);
// Dealer: dealer code from users.employee_number (getDealerCodeForUser) or body.dealerCode for RE/UAT. Used for submission, credit note, and debit note.
const resolvedDealerCode = await getDealerCodeForUser(userId);
const overrideDealerCode = (body.dealerCode || '').trim() || null;
const dealerCode = resolvedDealerCode || overrideDealerCode;
if (!dealerCode) {
throw new Error('Dealer not found for this user. Only dealers can submit Form 16.');
throw new Error('dealerCode is required to submit Form 16.');
}
const version = await getNextVersionForDealerFyQuarter(dealerCode, body.financialYear, body.quarter);
@ -1065,62 +1165,6 @@ export async function setForm16ResubmissionNeeded(requestId: string, _userId: st
return { submission };
}
/**
* RE only. Manually generate credit note for a Form 16 request (e.g. when OCR was partial but RE verified).
* Sets validationStatus to 'manually_approved'.
*/
export async function generateForm16CreditNoteManually(
requestId: string,
userId: string,
amount: number
) {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate credit note.');
const submission = await Form16aSubmission.findOne({
where: { requestId },
attributes: ['id', 'requestId', 'dealerCode', 'financialYear', 'quarter', 'tdsAmount'],
});
if (!submission) throw new Error('Form 16 submission not found for this request.');
const sub = submission as any;
const existing = await Form16CreditNote.findOne({ where: { submissionId: submission.id }, attributes: ['id'] });
if (existing) throw new Error('A credit note already exists for this submission.');
const dealerCode = (sub.dealerCode || '').toString().trim();
const financialYear = (sub.financialYear || '').trim();
const quarter = (sub.quarter || '').trim();
if (dealerCode && (await hasActiveCreditNoteForDealerFyQuarter(dealerCode, financialYear, quarter))) {
throw new Error(
'A credit note has already been issued for this financial year and quarter (e.g. from another submission or a later upload that matched 26AS). You cannot generate another credit note. If the previous credit note was withdrawn (debit note issued), the dealer must submit Form 16 again to generate a new credit note.'
);
}
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
});
const dealerDetails = {
dealerCode: dealerCode || 'UNKNOWN',
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
};
const sapResponse = simulateCreditNoteFromSap(dealerDetails, amount);
const creditNote = await Form16CreditNote.create({
submissionId: submission.id,
creditNoteNumber: sapResponse.creditNoteNumber,
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
amount,
issueDate: new Date(sapResponse.issueDate),
financialYear,
quarter,
status: sapResponse.status || 'issued',
remarks: 'Manually approved; credit note generated via SAP (simulation).',
issuedBy: userId,
});
await submission.update({
validationStatus: 'manually_approved',
validationNotes: 'Credit note manually generated by RE user.',
});
return { creditNote, submission };
}
/** Get credit note linked to a Form 16 request (by requestId). Returns null if none. */
export async function getCreditNoteByRequestId(requestId: string) {
const submission = await Form16aSubmission.findOne({
@ -1228,8 +1272,7 @@ export async function getCreditNoteById(creditNoteId: number) {
}
/**
* RE only. Generate debit note for a credit note (Form 16). Calls SAP simulation with dealer code, dealer info, credit note number, amount; creates Form16DebitNote from response.
* When real SAP is integrated, replace simulateDebitNoteFromSap with the actual SAP API call.
* RE only. Generate debit note for a credit note (Form 16). Creates Form16DebitNote with DN-F-16-DC-FY-Q format and pushes CSV to WFM INCOMING/WFM_MAIN/FORM_16 for SAP.
*/
export async function generateForm16DebitNoteForCreditNote(
creditNoteId: number,
@ -1238,38 +1281,59 @@ export async function generateForm16DebitNoteForCreditNote(
): Promise<{ debitNote: Form16DebitNote; creditNote: Form16CreditNote }> {
if (!amount || amount <= 0) throw new Error('Valid amount is required to generate debit note.');
const creditNote = await Form16CreditNote.findByPk(creditNoteId, {
attributes: ['id', 'creditNoteNumber', 'amount', 'financialYear', 'quarter', 'issueDate'],
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['id', 'dealerCode'] }],
});
if (!creditNote || !(creditNote as any).submission) throw new Error('Credit note not found.');
const existing = await Form16DebitNote.findOne({ where: { creditNoteId }, attributes: ['id'] });
if (existing) throw new Error('A debit note already exists for this credit note.');
// Dealer code from submission (set at Form 16 submit from users.employee_number)
const dealerCode = ((creditNote as any).submission?.dealerCode || '').toString().trim();
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName', 'dealerPrincipalEmailId', 'dpContactNumber'],
});
const dealerInfo = {
dealerCode: dealerCode || 'UNKNOWN',
dealerName: dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode,
dealerEmail: (dealer as any)?.dealerPrincipalEmailId ?? undefined,
dealerContact: (dealer as any)?.dpContactNumber ?? undefined,
};
const sapResponse = simulateDebitNoteFromSap({
dealerCode: dealerCode || 'UNKNOWN',
dealerInfo,
creditNoteNumber: (creditNote as any).creditNoteNumber,
amount,
});
const financialYear = (creditNote as any).financialYear || '';
const quarter = (creditNote as any).quarter || '';
const dnNumber = formatForm16DebitNoteNumber(dealerCode || 'UNKNOWN', financialYear, quarter);
const now = new Date();
const debitNote = await Form16DebitNote.create({
creditNoteId,
debitNoteNumber: sapResponse.debitNoteNumber,
sapDocumentNumber: sapResponse.sapDocumentNumber ?? undefined,
debitNoteNumber: dnNumber,
amount,
issueDate: new Date(sapResponse.issueDate),
status: sapResponse.status || 'issued',
reason: 'Debit note generated via SAP (simulation).',
issueDate: now,
status: 'issued',
reason: 'Debit note pushed to WFM FORM16 for SAP.',
createdBy: userId,
});
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM_16
try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-DN-${creditNoteId}-${debitNote.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const creditNoteIssueDate = (creditNote as any).issueDate
? new Date((creditNote as any).issueDate).toISOString().slice(0, 10).replace(/-/g, '')
: '';
const csvRow = {
CREDIT_NOTE_NUMBER: (creditNote as any).creditNoteNumber,
DEALER_CODE: dealerCode || 'UNKNOWN',
DEALER_NAME: dealerName,
AMOUNT: amount,
FINANCIAL_YEAR: financialYear,
QUARTER: quarter,
DEBIT_NOTE_NUMBER: dnNumber,
TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate,
CREDIT_NOTE_DATE: creditNoteIssueDate,
};
const fileName = `${dnNumber}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
logger.info(`[Form16] Manual debit note CSV pushed to WFM FORM_16: ${dnNumber}`);
} catch (csvErr: any) {
logger.error('[Form16] Failed to push manual debit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
}
return { debitNote, creditNote };
}
@ -1894,9 +1958,11 @@ function build26asCreatePayload(row: Record<string, unknown>, uploadLogId?: numb
const v = row[k];
if (v !== undefined && v !== null) payload[k] = v;
}
payload.tanNumber = row.tanNumber ?? '';
payload.quarter = row.quarter ?? 'Q1';
payload.financialYear = row.financialYear ?? '';
payload.tanNumber = (row.tanNumber != null ? String(row.tanNumber).trim() : '') || '';
const rawFy = (row.financialYear != null ? String(row.financialYear).trim() : '') || '';
const rawQ = (row.quarter != null ? String(row.quarter).trim() : '') || 'Q1';
payload.financialYear = normalizeFinancialYear(rawFy) || rawFy;
payload.quarter = normalizeQuarter(rawQ) || rawQ;
payload.taxDeducted = row.taxDeducted ?? 0;
if (uploadLogId != null) payload.uploadLogId = uploadLogId;
return payload;
@ -1955,16 +2021,23 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
for (const [, { tanNumber, financialYear, quarter }] of keys) {
const fy = normalizeFinancialYear(financialYear) || financialYear;
const q = normalizeQuarter(quarter) || quarter;
const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, quarter);
const latest = await getLatest26asSnapshot(tanNumber, fy, quarter);
const newTotal = await getLatest26asAggregatedForQuarter(tanNumber, fy, q);
const latest = await getLatest26asSnapshot(tanNumber, fy, q);
const prevTotal = latest ? parseFloat(String((latest as any).aggregatedAmount ?? 0)) : 0;
if (Math.abs(newTotal - prevTotal) < 0.01) continue; // duplicate 26AS: no change
const status = await getQuarterStatus(tanNumber, fy, quarter);
const status = await getQuarterStatus(tanNumber, fy, q);
if (status?.status === 'SETTLED' && status.lastCreditNoteId) {
const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, { attributes: ['id', 'amount'] });
const creditNote = await Form16CreditNote.findByPk(status.lastCreditNoteId, {
attributes: ['id', 'amount', 'financialYear', 'quarter', 'creditNoteNumber', 'submissionId', 'issueDate'],
});
if (creditNote) {
const amount = parseFloat(String((creditNote as any).amount ?? 0));
const debitNum = `DN-${new Date().getFullYear()}-${creditNote.id}-${Date.now().toString(36).toUpperCase()}`;
const submission = await Form16aSubmission.findByPk((creditNote as any).submissionId, { attributes: ['dealerCode'] });
// Dealer code from submission (set at Form 16 submit from users.employee_number)
const dealerCode = submission ? ((submission as any).dealerCode || '').toString().trim() : '';
const cnFy = (creditNote as any).financialYear || fy;
const cnQuarter = (creditNote as any).quarter || q;
const debitNum = formatForm16DebitNoteNumber(dealerCode || 'XX', cnFy, cnQuarter);
const now = new Date();
const debit = await Form16DebitNote.create({
creditNoteId: creditNote.id,
@ -1984,8 +2057,39 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
amount,
debitNoteId: debit.id,
});
await setQuarterStatusDebitIssued(tanNumber, fy, quarter, debit.id);
await setQuarterStatusDebitIssued(tanNumber, fy, q, debit.id);
debitsCreated++;
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM_16
try {
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
});
const dealerName = dealer ? ((dealer as any).dealership || (dealer as any).dealerPrincipalName || dealerCode) : dealerCode;
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
const claimDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const creditNoteIssueDate = (creditNote as any).issueDate
? new Date((creditNote as any).issueDate).toISOString().slice(0, 10).replace(/-/g, '')
: '';
const csvRow = {
CREDIT_NOTE_NUMBER: (creditNote as any).creditNoteNumber,
DEALER_CODE: dealerCode || 'XX',
DEALER_NAME: dealerName,
AMOUNT: amount,
FINANCIAL_YEAR: cnFy,
QUARTER: cnQuarter,
DEBIT_NOTE_NUMBER: debitNum,
TRNS_UNIQ_NO: trnsUniqNo,
CLAIM_DATE: claimDate,
CREDIT_NOTE_DATE: creditNoteIssueDate,
};
const fileName = `${debitNum}.csv`;
await wfmFileService.generateForm16IncomingCSV([csvRow], fileName);
logger.info(`[Form16] Debit note CSV pushed to WFM FORM_16: ${debitNum}`);
} catch (csvErr: any) {
logger.error('[Form16] Failed to push debit note CSV to WFM FORM_16:', csvErr?.message || csvErr);
}
}
}
const normalized = (tanNumber || '').trim().replace(/\s+/g, ' ');

View File

@ -55,8 +55,8 @@ export async function getReUserIdsFor26As(): Promise<string[]> {
}
/**
* Trigger notifications when 26AS data is uploaded: RE users get templateRe, dealers get templateDealers.
* Called after successful 26AS upload (fire-and-forget or await in controller).
* Trigger notifications when 26AS data is uploaded: sent only to RE users (admins / 26AS viewers / submission viewers).
* Dealers no longer receive this notification.
*/
export async function trigger26AsDataAddedNotification(): Promise<void> {
try {
@ -68,7 +68,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
}
const { notificationService } = await import('./notification.service');
const reUserIds = await getReUserIdsFor26As();
const dealerIds = await getDealerUserIds();
const title = 'Form 16 26AS data updated';
if (reUserIds.length > 0 && n.templateRe) {
@ -79,14 +78,6 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
});
logger.info(`[Form16Notification] 26AS notification sent to ${reUserIds.length} RE user(s)`);
}
if (dealerIds.length > 0 && n.templateDealers) {
await notificationService.sendToUsers(dealerIds, {
title,
body: n.templateDealers,
type: 'form16_26as_added',
});
logger.info(`[Form16Notification] 26AS notification sent to ${dealerIds.length} dealer user(s)`);
}
} catch (e) {
logger.error('[Form16Notification] trigger26AsDataAddedNotification failed:', e);
}
@ -148,23 +139,6 @@ export async function triggerForm16SubmissionResultNotification(
}
}
/**
* Notify dealer when RE manually generates a credit note (so they see success).
*/
export async function triggerForm16ManualCreditNoteNotification(requestId: string, creditNoteNumber: string): Promise<void> {
try {
const req = await WorkflowRequest.findByPk(requestId, { attributes: ['initiatorId'], raw: true });
const initiatorId = (req as any)?.initiatorId;
if (!initiatorId) return;
await triggerForm16SubmissionResultNotification(initiatorId, 'manually_approved', {
creditNoteNumber,
requestId,
});
} catch (e) {
logger.error('[Form16Notification] triggerForm16ManualCreditNoteNotification failed:', e);
}
}
/**
* Notify dealer when RE marks submission as resubmission needed or cancels (unsuccessful outcome).
*/

View File

@ -319,7 +319,7 @@ class NotificationService {
const emailType = emailTypeMap[payload.type || ''];
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); templates come from payload
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); template comes from emailtemplates (HTML)
if (payload.type && payload.type.startsWith('form16_') && user?.email) {
if (user.emailNotificationsEnabled === false) {
logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`);
@ -327,12 +327,37 @@ class NotificationService {
}
try {
const { emailService } = await import('./email.service');
const { getForm16Email, CompanyInfo } = await import('../emailtemplates');
const escaped = (payload.body || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br/>');
const html = `<!DOCTYPE html><html><body><p>${escaped}</p></body></html>`;
const variant =
payload.type === 'form16_success_credit_note'
? 'success'
: payload.type === 'form16_unsuccessful'
? 'error'
: payload.type === 'form16_alert_submit' || payload.type === 'form16_reminder' || payload.type === 'form16_debit_note'
? 'warning'
: 'info';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const requestId = payload.requestId || undefined;
const viewDetailsLink = requestId ? `${frontendUrl.replace(/\/$/, '')}/request/${encodeURIComponent(requestId)}` : undefined;
const html = getForm16Email({
recipientName: user.displayName || user.email,
title: payload.title || 'Form 16 Notification',
messageHtml: escaped,
requestId,
viewDetailsLink,
companyName: CompanyInfo.name,
variant,
});
await emailService.sendEmail({
to: user.email,
subject: payload.title || 'Form 16 Notification',

View File

@ -2,19 +2,33 @@ import fs from 'fs';
import path from 'path';
import logger from '../utils/logger';
/** Default WFM folder names (joined with path.sep for current OS). */
const DEFAULT_CLAIMS_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_CLAIMS_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'DLR_INC_CLAIMS');
const DEFAULT_FORM16_INCOMING = path.join('WFM-QRE', 'INCOMING', 'WFM_MAIN', 'FORM_16');
const DEFAULT_FORM16_OUTGOING = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM_16');
/**
* WFM File Service
* Handles generation and storage of CSV files in the WFM folder structure
* Handles generation and storage of CSV files in the WFM folder structure.
* Dealer claims use DLR_INC_CLAIMS; Form 16 uses FORM_16 under INCOMING/WFM_MAIN and OUTGOING/WFM_SAP_MAIN.
* Paths are cross-platform; set WFM_BASE_PATH (and optional path overrides) in .env for production.
*/
export class WFMFileService {
private basePath: string;
private incomingClaimsPath: string;
private outgoingClaimsPath: string;
/** Form 16: INCOMING/WFM_MAIN/FORM_16 */
private form16IncomingPath: string;
/** Form 16: OUTGOING/WFM_SAP_MAIN/FORM_16 */
private form16OutgoingPath: string;
constructor() {
this.basePath = process.env.WFM_BASE_PATH || 'C:\\WFM';
this.incomingClaimsPath = process.env.WFM_INCOMING_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS';
this.outgoingClaimsPath = process.env.WFM_OUTGOING_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS';
this.basePath = process.env.WFM_BASE_PATH ?? (process.platform === 'win32' ? 'C:\\WFM' : path.join(process.cwd(), 'wfm'));
this.incomingClaimsPath = process.env.WFM_INCOMING_CLAIMS_PATH || DEFAULT_CLAIMS_INCOMING;
this.outgoingClaimsPath = process.env.WFM_OUTGOING_CLAIMS_PATH || DEFAULT_CLAIMS_OUTGOING;
this.form16IncomingPath = process.env.WFM_FORM16_INCOMING_PATH || DEFAULT_FORM16_INCOMING;
this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING;
}
/**
@ -78,6 +92,63 @@ export class WFMFileService {
getOutgoingPath(fileName: string): string {
return path.join(this.basePath, this.outgoingClaimsPath, fileName);
}
/**
* Generate a CSV file for Form 16 (credit/debit note) and store in INCOMING/WFM_MAIN/FORM_16.
* Format: pipe (|) as column separator, no double quotes around values (SAP/WFM requirement).
* @param data Array of one or more row objects (keys become header; use UPPER_SNAKE_CASE for column names)
* @param fileName File name (e.g. CN-F-16-6282-24-25-Q1.csv or DN-F-16-6282-24-25-Q1.csv)
*/
async generateForm16IncomingCSV(data: any[], fileName: string): Promise<string> {
const maxRetries = 3;
let retryCount = 0;
while (retryCount <= maxRetries) {
try {
const targetDir = path.join(this.basePath, this.form16IncomingPath);
this.ensureDirectoryExists(targetDir);
const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`);
// Pipe separator, no double quotes (values as plain strings)
const keys = Object.keys(data[0] || {});
const headers = keys.join('|');
const rows = data.map(item =>
keys.map(key => {
const val = item[key];
return val === null || val === undefined ? '' : String(val);
}).join('|')
).join('\n');
const csvContent = `${headers}\n${rows}`;
fs.writeFileSync(filePath, csvContent);
logger.info(`[WFMFileService] Form 16 CSV generated at: ${filePath}`);
return filePath;
} catch (error: any) {
if (error.code === 'EBUSY' && retryCount < maxRetries) {
retryCount++;
const delay = retryCount * 1000;
logger.warn(`[WFMFileService] Form 16 file busy, retrying in ${delay}ms (${retryCount}/${maxRetries}): ${fileName}`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (error.code === 'EBUSY') {
throw new Error(`Form 16 file is locked. Please close '${fileName}' and try again.`);
}
logger.error('[WFMFileService] Error generating Form 16 incoming CSV:', error);
throw error;
}
}
throw new Error(`Failed to generate Form 16 CSV after ${maxRetries} retries.`);
}
/**
* Get the absolute path for a Form 16 outgoing (response) file
*/
getForm16OutgoingPath(fileName: string): string {
return path.join(this.basePath, this.form16OutgoingPath, fileName);
}
}
export const wfmFileService = new WFMFileService();