/** * Form 16 notification triggers: 26AS added, success/unsuccess, alerts, reminders. * Uses Form 16 admin config templates and notificationService.sendToUsers. */ import { Op } from 'sequelize'; import { User } from '@models/User'; import { Dealer } from '@models/Dealer'; import { Form16CreditNote } from '@models/Form16CreditNote'; import { Form16aSubmission } from '@models/Form16aSubmission'; import { WorkflowRequest } from '@models/WorkflowRequest'; import { Form1626asQuarterSnapshot } from '@models/Form1626asQuarterSnapshot'; import { getForm16Config } from './form16Config.service'; import logger from '@utils/logger'; /** Get user IDs for dealers (principal email linked to a dealer). */ export async function getDealerUserIds(): Promise { const dealers = await Dealer.findAll({ where: { isActive: true }, attributes: ['dealerPrincipalEmailId'], raw: true, }); const emails = [...new Set((dealers.map((d) => (d as any).dealerPrincipalEmailId).filter(Boolean) as string[]).map((e) => e.trim().toLowerCase()))]; if (emails.length === 0) return []; const users = await User.findAll({ where: userWhereEmailIn(emails), attributes: ['userId'], raw: true, }); return users.map((u) => (u as any).userId); } function userWhereEmailIn(emails: string[]) { if (emails.length === 0) return { email: { [Op.eq]: '__no_match__' } } as any; return { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) }; } /** * Get user IDs who have 26AS management access (same intent as requireForm1626AsAccess). * - ADMIN: always included * - If twentySixAsViewerEmails is configured (non-empty): include those users (+ ADMIN) * - If empty: allow all RE users by default, but NEVER include dealers */ export async function getReUserIdsFor26As(): Promise { const config = await getForm16Config(); const viewerEmails = config.twentySixAsViewerEmails || []; const dealerUserIds = await getDealerUserIds(); const dealerSet = new Set(dealerUserIds); const admins = await User.findAll({ where: { role: { [Op.iLike]: 'ADMIN' } } as any, attributes: ['userId'], raw: true, }); const adminIds = admins.map((u) => (u as any).userId).filter(Boolean); if (viewerEmails.length > 0) { const users = await User.findAll({ where: userWhereEmailIn(viewerEmails), attributes: ['userId'], raw: true, }); const ids = users.map((u) => (u as any).userId).filter(Boolean); return [...new Set([...adminIds, ...ids])].filter((id) => !dealerSet.has(id)); } // Empty list = allow all RE users with 26AS access by default, but exclude dealers const allUsers = await User.findAll({ attributes: ['userId'], raw: true }); const allIds = allUsers.map((u) => (u as any).userId).filter(Boolean); return [...new Set([...adminIds, ...allIds])].filter((id) => !dealerSet.has(id)); } /** * 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 { const config = await getForm16Config(); const n = config.notification26AsDataAdded; if (!n?.enabled) { logger.info('[Form16Notification] 26AS notification disabled in config, skipping'); return; } const { notificationService } = await import('./notification.service'); // Base RE audience (admins / RE viewers). This helper already tries to exclude dealers, // but we defensively re-filter below so that 26AS notifications are never sent to dealers. const baseReUserIds = await getReUserIdsFor26As(); const dealerUserIds = await getDealerUserIds(); const dealerSet = new Set(dealerUserIds); const reUserIds = baseReUserIds.filter((id) => !dealerSet.has(id)); const title = 'Form 16 – 26AS data updated'; if (reUserIds.length > 0 && n.templateRe) { await notificationService.sendToUsers(reUserIds, { title, body: n.templateRe, type: 'form16_26as_added', }); logger.info(`[Form16Notification] 26AS notification sent to ${reUserIds.length} RE user(s)`); } } catch (e) { logger.error('[Form16Notification] trigger26AsDataAddedNotification failed:', e); } } /** * Dealer-triggered "contact admin" notification for 26AS mismatch / missing data. * Sent only to RE users who have 26AS access (26AS viewers). */ export async function triggerForm16MismatchContactAdminNotification(params: { requestId: string; requestNumber?: string; dealerUserId: string; }): Promise { try { const { notificationService } = await import('./notification.service'); const reUserIds = await getReUserIdsFor26As(); if (reUserIds.length === 0) return; const title = 'Form 16 – 26AS mismatch reported'; const body = `Contact administrator: FORM 26AS does not match FORM 16A.\nRequest ID: ${params.requestNumber || params.requestId}.`; await notificationService.sendToUsers(reUserIds, { title, body, type: 'form16_26as_mismatch_contact_admin', requestId: params.requestId, requestNumber: params.requestNumber, url: params.requestNumber ? `/request/${params.requestNumber}` : undefined, }); logger.info(`[Form16Notification] Mismatch contact-admin sent to ${reUserIds.length} RE user(s) for requestId=${params.requestId}`); } catch (e) { logger.error('[Form16Notification] triggerForm16MismatchContactAdminNotification failed:', e); } } /** Replace [CreditNoteRef] / [Issue] in template. */ function replacePlaceholders(template: string, replacements: Record): string { let out = template; for (const [key, value] of Object.entries(replacements)) { out = out.replace(new RegExp(`\\[${key}\\]`, 'gi'), value); } // Remove unresolved placeholders to avoid leaking raw tokens like [Request ID] in emails. out = out.replace(/\[[^\]]+\]/g, '').replace(/\s{2,}/g, ' ').trim(); return out; } function resolveAlertSubmitTemplate(configTemplate: string | undefined): string { const fallback = 'Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].'; const t = (configTemplate || '').trim(); if (!t) return fallback; // Guard against legacy misconfiguration where reminder template is saved into alert template. if (/\[request id\]/i.test(t) || /submission is pending/i.test(t)) return fallback; return t; } function resolveReminderTemplate(configTemplate: string | undefined): string { const fallback = 'Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.'; const t = (configTemplate || '').trim(); if (!t) return fallback; // Guard against swapped template in config. if (/\[duedate\]/i.test(t)) return fallback; return t; } function getQuarterInfoForDate(d: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } { const year = d.getFullYear(); const month = d.getMonth() + 1; // 1..12 const fyStartYear = month >= 4 ? year : year - 1; const fy = `${fyStartYear}-${String((fyStartYear + 1) % 100).padStart(2, '0')}`; if (month >= 4 && month <= 6) return { financialYear: fy, quarter: 'Q1' }; if (month >= 7 && month <= 9) return { financialYear: fy, quarter: 'Q2' }; if (month >= 10 && month <= 12) return { financialYear: fy, quarter: 'Q3' }; return { financialYear: fy, quarter: 'Q4' }; } /** Most recently ended quarter relative to now. */ function getMostRecentEndedQuarter(now: Date): { financialYear: string; quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' } { const current = getQuarterInfoForDate(now); const q = current.quarter; if (q === 'Q1') { const startYear = parseInt(current.financialYear.slice(0, 4), 10) - 1; const fy = `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`; return { financialYear: fy, quarter: 'Q4' }; } if (q === 'Q2') return { financialYear: current.financialYear, quarter: 'Q1' }; if (q === 'Q3') return { financialYear: current.financialYear, quarter: 'Q2' }; return { financialYear: current.financialYear, quarter: 'Q3' }; } function getQuarterEndUtc(financialYear: string, quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'): Date { const startYear = parseInt(financialYear.slice(0, 4), 10); const mk = (y: number, m: number, day: number) => new Date(Date.UTC(y, m - 1, day, 0, 0, 0)); if (quarter === 'Q1') return mk(startYear, 6, 30); if (quarter === 'Q2') return mk(startYear, 9, 30); if (quarter === 'Q3') return mk(startYear, 12, 31); return mk(startYear + 1, 3, 31); } function daysSinceUtc(dateUtc: Date, nowUtc: Date): number { return Math.floor((nowUtc.getTime() - dateUtc.getTime()) / (24 * 60 * 60 * 1000)); } /** * Scheduled job (RE): remind 26AS viewers to upload 26AS for the most recently ended quarter if it's missing. * Fires on quarter end + N days (config: reminder26AsUploadAfterQuarterEndDays). Repeats every 7 days by default. */ export async function runForm16Remind26AsUploadJob(): Promise { try { const config = await getForm16Config(); if (!config.reminder26AsUploadEnabled) return; const { financialYear, quarter } = getMostRecentEndedQuarter(new Date()); const nowUtc = new Date(); const quarterEndUtc = getQuarterEndUtc(financialYear, quarter); const sinceEndDays = daysSinceUtc(quarterEndUtc, nowUtc); const afterDays = Math.max(0, Math.min(365, Number((config as any).reminder26AsUploadAfterQuarterEndDays ?? 0))); const everyDays = Math.max(1, Math.min(365, Number((config as any).reminder26AsUploadEveryDays ?? 7))); if (sinceEndDays < afterDays) return; if (((sinceEndDays - afterDays) % everyDays) !== 0) return; const snapshotCount = await Form1626asQuarterSnapshot.count({ where: { financialYear, quarter } }); if (snapshotCount > 0) return; const reUserIds = await getReUserIdsFor26As(); if (reUserIds.length === 0) return; const { notificationService } = await import('./notification.service'); const body = replacePlaceholders(config.reminder26AsUploadTemplate || '', { FinancialYear: financialYear, Quarter: quarter }); await notificationService.sendToUsers(reUserIds, { title: 'Form 16 – 26AS upload reminder', body: body || `Reminder: Please upload 26AS for ${financialYear} ${quarter}.`, type: 'form16_26as_upload_reminder', }); } catch (e) { logger.error('[Form16Notification] runForm16Remind26AsUploadJob failed:', e); } } /** * Notify the dealer (initiator) after Form 16 submission result: success (credit note) or unsuccessful. */ export async function triggerForm16SubmissionResultNotification( initiatorUserId: string, validationStatus: string | undefined, opts: { creditNoteNumber?: string | null; requestId?: string; validationNotes?: string } ): Promise { try { const config = await getForm16Config(); const { notificationService } = await import('./notification.service'); const isSuccess = validationStatus === 'success' || validationStatus === 'manually_approved'; if (isSuccess && config.notificationForm16SuccessCreditNote?.enabled && config.notificationForm16SuccessCreditNote.template) { const body = replacePlaceholders(config.notificationForm16SuccessCreditNote.template, { CreditNoteRef: opts.creditNoteNumber || '—', }); await notificationService.sendToUsers([initiatorUserId], { title: 'Form 16 – Credit note issued', body, type: 'form16_success_credit_note', requestId: opts.requestId, }); logger.info(`[Form16Notification] Success notification sent to initiator ${initiatorUserId}`); return; } if ( !isSuccess && (validationStatus === 'failed' || validationStatus === 'duplicate' || validationStatus === 'resubmission_needed') && config.notificationForm16Unsuccessful?.enabled && config.notificationForm16Unsuccessful.template ) { const issue = opts.validationNotes || 'Submission could not be processed. Please check the request and resubmit if needed.'; const body = replacePlaceholders(config.notificationForm16Unsuccessful.template, { Issue: issue }); await notificationService.sendToUsers([initiatorUserId], { title: 'Form 16 – Submission unsuccessful', body, type: 'form16_unsuccessful', requestId: opts.requestId, }); logger.info(`[Form16Notification] Unsuccessful notification sent to initiator ${initiatorUserId}`); } } catch (e) { logger.error('[Form16Notification] triggerForm16SubmissionResultNotification failed:', e); } } /** * Notify dealer when RE marks submission as resubmission needed or cancels (unsuccessful outcome). */ export async function triggerForm16UnsuccessfulByRequestId(requestId: string, issueMessage: 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, 'resubmission_needed', { requestId, validationNotes: issueMessage, }); } catch (e) { logger.error('[Form16Notification] triggerForm16UnsuccessfulByRequestId failed:', e); } } /** * Send "submit Form 16" alert to dealers (e.g. those who haven't submitted for a quarter). * Call from a scheduled job using config alertSubmitForm16FrequencyDays/Hours. */ export async function triggerForm16AlertSubmit(dealerUserIds: string[], placeholders?: { name?: string; dueDate?: string }): Promise { if (dealerUserIds.length === 0) return; try { const config = await getForm16Config(); if (!config.alertSubmitForm16Enabled) return; const template = resolveAlertSubmitTemplate(config.alertSubmitForm16Template); const body = replacePlaceholders(template, { Name: placeholders?.name ?? 'Dealer', DueDate: placeholders?.dueDate ?? '—', }); const { notificationService } = await import('./notification.service'); await notificationService.sendToUsers(dealerUserIds, { title: 'Form 16 – Submit required', body, type: 'form16_alert_submit', }); logger.info(`[Form16Notification] Alert submit sent to ${dealerUserIds.length} dealer(s)`); } catch (e) { logger.error('[Form16Notification] triggerForm16AlertSubmit failed:', e); } } /** * Send pending Form 16 reminder to dealers (e.g. those with pending submission). * Call from a scheduled job using config reminderFrequencyDays/Hours. */ export async function triggerForm16Reminder(dealerUserIds: string[], placeholders?: { name?: string; requestId?: string }): Promise { if (dealerUserIds.length === 0) return; try { const config = await getForm16Config(); if (!config.reminderNotificationEnabled) return; const template = resolveReminderTemplate(config.reminderNotificationTemplate); const body = replacePlaceholders(template, { Name: placeholders?.name ?? 'Dealer', 'Request ID': placeholders?.requestId ?? '—', }); const { notificationService } = await import('./notification.service'); await notificationService.sendToUsers(dealerUserIds, { title: 'Form 16 – Pending submission reminder', body, type: 'form16_reminder', }); logger.info(`[Form16Notification] Reminder sent to ${dealerUserIds.length} dealer(s)`); } catch (e) { logger.error('[Form16Notification] triggerForm16Reminder failed:', e); } } /** * Scheduled job: send "submit Form 16" alert to all non-submitted dealers (current FY). * Call from cron (e.g. daily at 9 AM). */ export async function runForm16AlertSubmitJob(): Promise { try { const config = await getForm16Config(); if (!config.alertSubmitForm16Enabled) { logger.info('[Form16Notification] Alert submit disabled in config, skipping job'); return; } const { financialYear, quarter } = getMostRecentEndedQuarter(new Date()); const nowUtc = new Date(); const quarterEndUtc = getQuarterEndUtc(financialYear, quarter); const sinceEndDays = daysSinceUtc(quarterEndUtc, nowUtc); const afterDays = Math.max(0, Math.min(365, Number((config as any).alertSubmitForm16AfterQuarterEndDays ?? 0))); const everyDays = Math.max(1, Math.min(365, Number((config as any).alertSubmitForm16EveryDays ?? 7))); if (sinceEndDays < afterDays) return; if (((sinceEndDays - afterDays) % everyDays) !== 0) return; const { getDealerUserIdsMissingQuarter } = await import('./form16.service'); const dealerUserIds = await getDealerUserIdsMissingQuarter(financialYear, quarter); if (dealerUserIds.length === 0) { logger.info('[Form16Notification] No non-submitted dealers for alert, skipping'); return; } const dueDate = `${financialYear} ${quarter}`; await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate }); logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`, { financialYear, quarter }); } catch (e) { logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e); } } /** * Scheduled job: send "pending Form 16" reminder to dealers who have open submissions without credit note. * Call from cron (e.g. daily at 10 AM). */ export async function runForm16ReminderJob(): Promise { try { const config = await getForm16Config(); if (!config.reminderNotificationEnabled) { logger.info('[Form16Notification] Reminder disabled in config, skipping job'); return; } const { getDealersWithPendingForm16Submissions } = await import('./form16.service'); const pending = await getDealersWithPendingForm16Submissions(); if (pending.length === 0) { logger.info('[Form16Notification] No pending Form 16 submissions for reminder, skipping'); return; } for (const { userId, requestNumber } of pending) { await triggerForm16Reminder([userId], { name: 'Dealer', requestId: requestNumber }); } logger.info(`[Form16Notification] Reminder job completed: sent ${pending.length} reminder(s)`); } catch (e) { logger.error('[Form16Notification] runForm16ReminderJob failed:', e); } }