Compare commits
2 Commits
7d74bc43bc
...
fd6032f21b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd6032f21b | ||
|
|
4f36428593 |
@ -350,6 +350,49 @@ export class Form16Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/credit-notes/:id/sap-response
|
||||
* Returns persisted SAP response fields for the credit note from DB (and optional CSV URL).
|
||||
* If not yet available, returns 409 so UI can show "being generated, wait".
|
||||
*/
|
||||
async viewCreditNoteSapResponse(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const id = parseInt((req.params as { id: string }).id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return ResponseHandler.error(res, 'Invalid credit note id', 400);
|
||||
}
|
||||
let sapResponse = null;
|
||||
try {
|
||||
sapResponse = await form16Service.getCreditNoteSapResponseForUser(id, userId);
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || '');
|
||||
if (msg.toLowerCase().includes('not found')) {
|
||||
return ResponseHandler.error(res, 'Credit note not found', 404);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!sapResponse) {
|
||||
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
|
||||
}
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{
|
||||
sapResponse,
|
||||
url: sapResponse.storageUrl || null,
|
||||
},
|
||||
'OK'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] viewCreditNoteSapResponse error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note SAP response', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/credit-notes/:id/download
|
||||
* Returns a storage URL for the SAP response CSV if available.
|
||||
@ -397,11 +440,18 @@ export class Form16Controller {
|
||||
if (Number.isNaN(id)) {
|
||||
return ResponseHandler.error(res, 'Invalid debit note id', 400);
|
||||
}
|
||||
const url = await form16Service.getDebitNoteSapResponseUrl(id);
|
||||
if (!url) {
|
||||
const sapResponse = await form16Service.getDebitNoteSapResponse(id);
|
||||
if (!sapResponse) {
|
||||
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
|
||||
}
|
||||
return ResponseHandler.success(res, { url }, 'OK');
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{
|
||||
sapResponse,
|
||||
url: sapResponse.storageUrl || null,
|
||||
},
|
||||
'OK'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] viewDebitNoteSapResponse error:', error);
|
||||
|
||||
@ -87,6 +87,11 @@ router.get(
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/credit-notes/:id/sap-response',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.viewCreditNoteSapResponse.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/credit-notes/:id',
|
||||
requireForm16SubmissionAccess,
|
||||
|
||||
@ -1499,6 +1499,56 @@ export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise
|
||||
return url && String(url).trim() ? String(url) : null;
|
||||
}
|
||||
|
||||
export interface Form16SapResponseView {
|
||||
fileName: string | null;
|
||||
trnsUniqNo: string | null;
|
||||
tdsTransId: string | null;
|
||||
claimNumber: string | null;
|
||||
sapDocumentNumber: string | null;
|
||||
msgTyp: string | null;
|
||||
message: string | null;
|
||||
docDate: string | null;
|
||||
tdsAmt: string | null;
|
||||
storageUrl: string | null;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
function mapSapResponseView(row: any): Form16SapResponseView {
|
||||
return {
|
||||
fileName: row?.fileName ?? null,
|
||||
trnsUniqNo: row?.trnsUniqNo ?? null,
|
||||
tdsTransId: row?.tdsTransId ?? null,
|
||||
claimNumber: row?.claimNumber ?? null,
|
||||
sapDocumentNumber: row?.sapDocumentNumber ?? null,
|
||||
msgTyp: row?.msgTyp ?? null,
|
||||
message: row?.message ?? null,
|
||||
docDate: row?.docDate ?? null,
|
||||
tdsAmt: row?.tdsAmt ?? null,
|
||||
storageUrl: row?.storageUrl ?? null,
|
||||
createdAt: row?.createdAt ?? null,
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCreditNoteSapResponse(creditNoteId: number): Promise<Form16SapResponseView | null> {
|
||||
const row = await (Form16SapResponse as any).findOne({
|
||||
where: {
|
||||
type: 'credit',
|
||||
creditNoteId,
|
||||
[Op.or]: [
|
||||
{ storageUrl: { [Op.ne]: null } },
|
||||
{ sapDocumentNumber: { [Op.ne]: null } },
|
||||
{ trnsUniqNo: { [Op.ne]: null } },
|
||||
{ tdsTransId: { [Op.ne]: null } },
|
||||
],
|
||||
},
|
||||
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return row ? mapSapResponseView(row) : null;
|
||||
}
|
||||
|
||||
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
|
||||
const row = await (Form16DebitNoteSapResponse as any).findOne({
|
||||
where: { debitNoteId, storageUrl: { [Op.ne]: null } },
|
||||
@ -1509,6 +1559,23 @@ export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<s
|
||||
return url && String(url).trim() ? String(url) : null;
|
||||
}
|
||||
|
||||
export async function getDebitNoteSapResponse(debitNoteId: number): Promise<Form16SapResponseView | null> {
|
||||
const row = await (Form16DebitNoteSapResponse as any).findOne({
|
||||
where: {
|
||||
debitNoteId,
|
||||
[Op.or]: [
|
||||
{ storageUrl: { [Op.ne]: null } },
|
||||
{ sapDocumentNumber: { [Op.ne]: null } },
|
||||
{ trnsUniqNo: { [Op.ne]: null } },
|
||||
{ tdsTransId: { [Op.ne]: null } },
|
||||
],
|
||||
},
|
||||
attributes: ['fileName', 'trnsUniqNo', 'tdsTransId', 'claimNumber', 'sapDocumentNumber', 'msgTyp', 'message', 'docDate', 'tdsAmt', 'storageUrl', 'createdAt', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return row ? mapSapResponseView(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dealer-safe download: dealers can only download their own credit note.
|
||||
* RE/Admin (non-dealer users) can download any credit note.
|
||||
@ -1529,6 +1596,21 @@ export async function getCreditNoteSapResponseUrlForUser(creditNoteId: number, u
|
||||
return getCreditNoteSapResponseUrl(creditNoteId);
|
||||
}
|
||||
|
||||
export async function getCreditNoteSapResponseForUser(creditNoteId: number, userId: string): Promise<Form16SapResponseView | null> {
|
||||
const dealerCode = await getDealerCodeForUser(userId);
|
||||
if (dealerCode) {
|
||||
const note = await Form16CreditNote.findByPk(creditNoteId, {
|
||||
attributes: ['id', 'submissionId'],
|
||||
include: [{ model: Form16aSubmission, as: 'submission', attributes: ['dealerCode'] }],
|
||||
});
|
||||
const noteDealerCode = (note as any)?.submission?.dealerCode;
|
||||
if (!note || !noteDealerCode || String(noteDealerCode).trim() !== String(dealerCode).trim()) {
|
||||
throw new Error('Credit note not found');
|
||||
}
|
||||
}
|
||||
return getCreditNoteSapResponse(creditNoteId);
|
||||
}
|
||||
|
||||
// ---------- Non-submitted dealers (RE only) ----------
|
||||
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
|
||||
|
||||
@ -1756,9 +1838,9 @@ export async function getDealerUserIdsMissingQuarter(financialYear: string, quar
|
||||
|
||||
/**
|
||||
* Get dealers (initiator user IDs) who have at least one pending Form 16 submission (no credit note yet, request open).
|
||||
* Returns one entry per (userId, requestId) so the reminder can include the request ID. Used by the reminder scheduled job.
|
||||
* Returns one entry per pending request so reminders can include a human-readable request reference.
|
||||
*/
|
||||
export async function getDealersWithPendingForm16Submissions(): Promise<{ userId: string; requestId: string }[]> {
|
||||
export async function getDealersWithPendingForm16Submissions(): Promise<{ userId: string; requestId: string; requestNumber: string }[]> {
|
||||
const submissions = await Form16aSubmission.findAll({
|
||||
attributes: ['id', 'requestId'],
|
||||
raw: true,
|
||||
@ -1776,10 +1858,14 @@ export async function getDealersWithPendingForm16Submissions(): Promise<{ userId
|
||||
const { WorkflowStatus: WS } = await import('../types/common.types');
|
||||
const requests = await WorkflowRequest.findAll({
|
||||
where: { requestId: pendingRequestIds, templateType: 'FORM_16', status: { [Op.ne]: WS.CLOSED } },
|
||||
attributes: ['requestId', 'initiatorId'],
|
||||
attributes: ['requestId', 'requestNumber', 'initiatorId'],
|
||||
raw: true,
|
||||
});
|
||||
return requests.map((r) => ({ userId: (r as any).initiatorId, requestId: (r as any).requestId }));
|
||||
return requests.map((r) => ({
|
||||
userId: (r as any).initiatorId,
|
||||
requestId: (r as any).requestId,
|
||||
requestNumber: (r as any).requestNumber || (r as any).requestId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- 26AS (RE admin) ----------
|
||||
|
||||
@ -64,10 +64,10 @@ const defaults: Form16Config = {
|
||||
notificationForm16SuccessCreditNote: { enabled: true, template: 'Form 16 submitted successfully. Credit note: [CreditNoteRef].' },
|
||||
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
|
||||
reminderNotificationEnabled: true,
|
||||
reminderNotificationTemplate: 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.',
|
||||
reminderNotificationTemplate: 'Reminder: Dear [Name], your Form 16A submission is pending for request [Request ID]. Please complete it.',
|
||||
reminderRunAtTime: '10:00',
|
||||
alertSubmitForm16Enabled: true,
|
||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
||||
alertSubmitForm16Template: 'Dear [Name], please submit Form 16A for the pending period. Due: [DueDate].',
|
||||
alertSubmitForm16RunAtTime: '09:00',
|
||||
alertSubmitForm16AfterQuarterEndDays: 0,
|
||||
alertSubmitForm16EveryDays: 7,
|
||||
|
||||
@ -142,9 +142,31 @@ function replacePlaceholders(template: string, replacements: Record<string, stri
|
||||
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
|
||||
@ -290,8 +312,9 @@ export async function triggerForm16AlertSubmit(dealerUserIds: string[], placehol
|
||||
if (dealerUserIds.length === 0) return;
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.alertSubmitForm16Enabled || !config.alertSubmitForm16Template) return;
|
||||
const body = replacePlaceholders(config.alertSubmitForm16Template, {
|
||||
if (!config.alertSubmitForm16Enabled) return;
|
||||
const template = resolveAlertSubmitTemplate(config.alertSubmitForm16Template);
|
||||
const body = replacePlaceholders(template, {
|
||||
Name: placeholders?.name ?? 'Dealer',
|
||||
DueDate: placeholders?.dueDate ?? '—',
|
||||
});
|
||||
@ -315,8 +338,9 @@ export async function triggerForm16Reminder(dealerUserIds: string[], placeholder
|
||||
if (dealerUserIds.length === 0) return;
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.reminderNotificationEnabled || !config.reminderNotificationTemplate) return;
|
||||
const body = replacePlaceholders(config.reminderNotificationTemplate, {
|
||||
if (!config.reminderNotificationEnabled) return;
|
||||
const template = resolveReminderTemplate(config.reminderNotificationTemplate);
|
||||
const body = replacePlaceholders(template, {
|
||||
Name: placeholders?.name ?? 'Dealer',
|
||||
'Request ID': placeholders?.requestId ?? '—',
|
||||
});
|
||||
@ -383,8 +407,8 @@ export async function runForm16ReminderJob(): Promise<void> {
|
||||
logger.info('[Form16Notification] No pending Form 16 submissions for reminder, skipping');
|
||||
return;
|
||||
}
|
||||
for (const { userId, requestId } of pending) {
|
||||
await triggerForm16Reminder([userId], { name: 'Dealer', requestId });
|
||||
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user