418 lines
18 KiB
TypeScript
418 lines
18 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|