diff --git a/env.example b/env.example index 42176fe..e0db438 100644 --- a/env.example +++ b/env.example @@ -113,3 +113,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 /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 + diff --git a/src/controllers/form16.controller.ts b/src/controllers/form16.controller.ts index 62f5c18..50b2b62 100644 --- a/src/controllers/form16.controller.ts +++ b/src/controllers/form16.controller.ts @@ -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 { - 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; + 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. diff --git a/src/emailtemplates/form_16_email.template.ts b/src/emailtemplates/form_16_email.template.ts new file mode 100644 index 0000000..4e92cd0 --- /dev/null +++ b/src/emailtemplates/form_16_email.template.ts @@ -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 + ? ` + + + + +
+ + + + + +
Request ID:${data.requestId}
+
+ ` + : ''; + + const ctaBlock = data.viewDetailsLink + ? ` + + + + +
+ + View Request Details + +
+ ` + : ''; + + return ` + + + + + + + + ${data.title} + ${getResponsiveStyles()} + + + + + + +
+ + ${getEmailHeader(getBrandedHeader({ title: data.title, ...headerStyle }))} + + + + + + ${getEmailFooter(data.companyName)} + +
+ + + `; +} + diff --git a/src/emailtemplates/index.ts b/src/emailtemplates/index.ts index eab9728..5fd31db 100644 --- a/src/emailtemplates/index.ts +++ b/src/emailtemplates/index.ts @@ -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'; diff --git a/src/emailtemplates/types.ts b/src/emailtemplates/types.ts index 1cd4c98..d565f13 100644 --- a/src/emailtemplates/types.ts +++ b/src/emailtemplates/types.ts @@ -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; diff --git a/src/routes/form16.routes.ts b/src/routes/form16.routes.ts index 56256ec..d9b3c83 100644 --- a/src/routes/form16.routes.ts +++ b/src/routes/form16.routes.ts @@ -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( diff --git a/src/services/form16.service.ts b/src/services/form16.service.ts index 448160f..f3c5a68 100644 --- a/src/services/form16.service.ts +++ b/src/services/form16.service.ts @@ -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 { - 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( - `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 { - 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, 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, ' '); diff --git a/src/services/form16Notification.service.ts b/src/services/form16Notification.service.ts index 4e45b73..3075434 100644 --- a/src/services/form16Notification.service.ts +++ b/src/services/form16Notification.service.ts @@ -55,8 +55,8 @@ export async function getReUserIdsFor26As(): Promise { } /** - * 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 { try { @@ -68,7 +68,6 @@ export async function trigger26AsDataAddedNotification(): Promise { } 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 { }); 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 { - 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). */ diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 3e25545..1e40477 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -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, '&') .replace(//g, '>') .replace(/\n/g, '
'); - const html = `

${escaped}

`; + + 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', diff --git a/src/services/wfmFile.service.ts b/src/services/wfmFile.service.ts index da7ad1d..cc63052 100644 --- a/src/services/wfmFile.service.ts +++ b/src/services/wfmFile.service.ts @@ -2,9 +2,17 @@ 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; @@ -12,6 +20,10 @@ export class WFMFileService { private incomingNonGstClaimsPath: string; private outgoingGstClaimsPath: string; private outgoingNonGstClaimsPath: 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'; @@ -19,6 +31,8 @@ export class WFMFileService { this.incomingNonGstClaimsPath = process.env.WFM_INCOMING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_NON_GST'; this.outgoingGstClaimsPath = process.env.WFM_OUTGOING_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_GST'; this.outgoingNonGstClaimsPath = process.env.WFM_OUTGOING_NON_GST_CLAIMS_PATH || 'WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_NON_GST'; + this.form16IncomingPath = process.env.WFM_FORM16_INCOMING_PATH || DEFAULT_FORM16_INCOMING; + this.form16OutgoingPath = process.env.WFM_FORM16_OUTGOING_PATH || DEFAULT_FORM16_OUTGOING; } /** @@ -39,7 +53,7 @@ export class WFMFileService { async generateIncomingClaimCSV(data: any[], fileName: string, isNonGst: boolean = false): Promise { const maxRetries = 3; let retryCount = 0; - + while (retryCount <= maxRetries) { try { const targetPath = isNonGst ? this.incomingNonGstClaimsPath : this.incomingGstClaimsPath; @@ -47,7 +61,7 @@ export class WFMFileService { this.ensureDirectoryExists(targetDir); const filePath = path.join(targetDir, fileName.endsWith('.csv') ? fileName : `${fileName}.csv`); - + // Simple CSV generation logic with pipe separator and no quotes const headers = Object.keys(data[0] || {}).join('|'); const rows = data.map(item => Object.values(item).map(val => val === null || val === undefined ? '' : String(val)).join('|')).join('\n'); @@ -55,7 +69,7 @@ export class WFMFileService { fs.writeFileSync(filePath, csvContent); logger.info(`[WFMFileService] Generated CSV at: ${filePath}`); - + return filePath; } catch (error: any) { if (error.code === 'EBUSY' && retryCount < maxRetries) { @@ -65,11 +79,11 @@ export class WFMFileService { await new Promise(resolve => setTimeout(resolve, delay)); continue; } - + if (error.code === 'EBUSY') { throw new Error(`File is locked or open in another program (e.g., Excel). Please close '${fileName}' and try again.`); } - + logger.error('[WFMFileService] Error generating incoming claim CSV:', error); throw error; } @@ -117,6 +131,63 @@ export class WFMFileService { return []; } } + + /** + * 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 { + 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();