Re_Backend/src/services/form16Notification.service.ts

418 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string[]> {
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<string[]> {
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<void> {
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<void> {
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, string>): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}