backend code SAP response

This commit is contained in:
Aaditya Jaiswal 2026-03-18 12:59:20 +05:30
parent 0f99fe68d5
commit 62ca4f985a
30 changed files with 1448 additions and 101 deletions

View File

@ -1 +1 @@
import{a as s}from"./index-BgKXDGEk.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CxsBWvVP.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
import{a as s}from"./index-BLn8bK0r.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BrA5VgBk.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,15 +13,15 @@
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-BgKXDGEk.js"></script>
<script type="module" crossorigin src="/assets/index-BLn8bK0r.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CxsBWvVP.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BrA5VgBk.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
<link rel="stylesheet" crossorigin href="/assets/index-D2NzWWdB.css">
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
</head>
<body>

View File

@ -0,0 +1,137 @@
# Form 16 Full Process: Credit & Debit Notes (Incoming & Outgoing)
This document describes the end-to-end flow for Form 16 (Form 16A TDS Credit): 26AS reconciliation, credit/debit note creation, WFM/SAP incoming and outgoing file handling, and how users view SAP responses.
---
## 1. High-Level Flow
- **26AS**: TDS entries are uploaded (RE) and aggregated by TAN + Financial Year + Quarter (Section 194Q, Booking F/O only).
- **Form 16A submission**: Dealer submits Form 16A (PDF). OCR extracts TAN, FY, Quarter, TDS amount, certificate number, etc.
- **Credit note**: When a submission is validated, the system matches it against the latest 26AS aggregate for that TAN/FY/Quarter. On match, a **credit note** is created, ledger updated, quarter marked **SETTLED**, and a CSV is pushed to WFM **INCOMING** for SAP (credit note generation).
- **Debit note**: When a new 26AS upload changes the quarter total and that quarter was already **SETTLED**, the system creates a **debit note** (reversing the earlier credit), updates ledger, sets quarter to **DEBIT_ISSUED_PENDING_FORM16**, and pushes a CSV to WFM **INCOMING** for SAP (debit note generation).
- **SAP responses**: SAP processes the INCOMING files and drops response CSVs in WFM **OUTGOING**. The backend ingests these (scheduler every 5 min or on-demand Pull), stores them in DB, and users can **View** (and for credit, **Download**) the SAP response.
---
## 2. Paths (WFM Folder Structure)
All paths are relative to **WFM_BASE_PATH** (default `C:\WFM`). Can be overridden via `.env` (e.g. `WFM_BASE_PATH=D:\Form-16 Main`). The job also tries `<process.cwd()>\WFM-QRE\...` if the default path does not exist.
| Direction | Type | Default path (under WFM_BASE_PATH) |
|------------|--------|-------------------------------------|
| **INCOMING** | Credit | `WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT` |
| **INCOMING** | Debit | `WFM-QRE\INCOMING\WFM_MAIN\FORM16_DEBT` |
| **OUTGOING** | Credit | `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT` |
| **OUTGOING** | Debit | `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_DBT` |
- **INCOMING** = files we **push** to WFM (for SAP to pick up and process).
- **OUTGOING** = files **SAP drops** (responses); we read and store them.
---
## 3. Credit Note Flow
### 3.1 When is a credit note created?
- On **Form 16A submission validation** (after OCR and 26AS check).
- `run26asMatchAndCreditNote(submission)` is called (e.g. from submission validation flow).
- Conditions: TAN + FY + Quarter match latest 26AS aggregate (Section 194Q, F/O), amount within tolerance, quarter not already settled with same amount.
- On success: create `Form16CreditNote`, ledger entry (CREDIT), set quarter status **SETTLED**, then push **INCOMING** CSV.
### 3.2 Credit note INCOMING (we push to WFM/SAP)
- **Path**: `WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT`
- **When**: Immediately after credit note is created.
- **File name**: `{creditNoteNumber}.csv` (e.g. `CN00628226Q20001.csv`).
- **Content** (pipe `|` separated):
`TRNS_UNIQ_NO` (e.g. `F16-CN-{submissionId}-{creditNoteId}-{timestamp}`),
`TDS_TRNS_ID` (= credit note number),
`DEALER_CODE`, `TDS_TRNS_DOC_TYP`, `DLR_TAN_NO`, `FIN_YEAR & QUARTER`, `DOC_DATE`, `TDS_AMT`.
- **TDS_TRNS_ID** = credit note number (format: `CN` + 6-digit dealer code + 2-digit FY + quarter + 4-digit sequence, e.g. `CN00628226Q20001`).
- A copy is also written to the Form 16 credit archive path (INCOMING archive).
### 3.3 Credit note OUTGOING (SAP response)
- **Path**: `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT`
- **Who writes**: SAP (response CSVs placed here by SAP/WFM).
- **Who reads**: Backend **Form 16 SAP response job** (scheduler every 5 min + on **Pull** button).
- **What we do**: Read each CSV, parse first “real” data row, match to credit note by `TRNS_UNIQ_NO` or `creditNoteNumber` (TDS_TRNS_ID in response), upload file to storage, insert/update row in **`form16_sap_responses`** with `type = 'credit'`, `credit_note_id`, `storage_url`, etc.
- **User**: Credit notes list shows **View** when a response exists; **View** opens popup with SAP fields and **Download CSV**; **Pull** triggers ingestion and list refresh.
---
## 4. Debit Note Flow
### 4.1 When is a debit note created?
- On **26AS upload** that changes the quarter aggregate for a quarter that is already **SETTLED** (had a credit note).
- `process26asUploadAggregation(uploadLogId)` is called after 26AS file upload (controller calls it when records are imported).
- For each (TAN, FY, Quarter) where new 26AS total ≠ previous snapshot and status is SETTLED: create `Form16DebitNote` (linked to the last credit note for that quarter), ledger entry (DEBIT), set quarter status **DEBIT_ISSUED_PENDING_FORM16**, then push **INCOMING** CSV.
### 4.2 Debit note INCOMING (we push to WFM/SAP)
- **Path**: `WFM-QRE\INCOMING\WFM_MAIN\FORM16_DEBT`
- **When**: Immediately after debit note is created in `process26asUploadAggregation`.
- **File name**: `{debitNoteNumber}.csv` (e.g. `DN00628226Q20001.csv`).
- **Content** (pipe `|` separated):
`TRNS_UNIQ_NO` (e.g. `F16-DN-{creditNoteId}-{debitId}-{timestamp}`),
**`TDS_TRNS_ID`** = **credit note number** (not debit note number),
`DEALER_CODE`, `TDS_TRNS_DOC_TYP`, `Org.Document Number` (= debit id), `DLR_TAN_NO`, `FIN_YEAR & QUARTER`, `DOC_DATE`, `TDS_AMT`.
- **TDS_TRNS_ID** in debit incoming = credit note number (same format as credit, e.g. `CN00628226Q20001`). Debit note number = same string with `CN` replaced by `DN` (e.g. `DN00628226Q20001`).
- A copy is also written to the Form 16 debit archive path.
### 4.3 Debit note OUTGOING (SAP response)
- **Path**: `WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_DBT`
- **Who writes**: SAP (response CSVs placed here).
- **Who reads**: Same **Form 16 SAP response job** (every 5 min + **Pull** on Debit Notes page).
- **What we do**: Read each CSV, parse, match to debit note by (in order):
(1) `TRNS_UNIQ_NO``form_16_debit_notes.trns_uniq_no`,
(2) `CLAIM_NUMBER``form_16_debit_notes.debit_note_number`,
(3) **filename (without .csv)**`form_16_debit_notes.debit_note_number`.
Upload file to storage, insert/update row in **`form16_debit_note_sap_responses`** (separate table from credit) with `debit_note_id`, `storage_url`, etc.
- **User**: Debit notes list shows **View** when a response exists; **View** opens popup (no download); **Pull** triggers ingestion and list refresh.
---
## 5. Database Tables for SAP Responses
| Table | Purpose |
|-----------------------------------|--------|
| **form16_sap_responses** | Credit note SAP responses only. Columns: `type` ('credit'), `file_name`, `credit_note_id`, `claim_number`, `sap_document_number`, `msg_typ`, `message`, `raw_row`, `storage_url`, timestamps. |
| **form16_debit_note_sap_responses**| Debit note SAP responses only. Columns: `file_name`, `debit_note_id`, `claim_number`, `sap_document_number`, `msg_typ`, `message`, `raw_row`, `storage_url`, timestamps. No `type` or `credit_note_id`. |
Credit and debit SAP responses are **not** mixed; each has its own table.
---
## 6. Scheduler and Pull
- **Scheduler**: `startForm16SapResponseJob()` runs **every 5 minutes** (cron `*/5 * * * *`). It calls `runForm16SapResponseIngestionOnce()`, which:
- Scans **OUTGOING** credit dir (`FORM16_CRDT`) and **OUTGOING** debit dir (`FORM16_DBT`) for `.csv` files.
- For each file: parse, match to credit or debit note, upload to storage, write to `form16_sap_responses` (credit) or `form16_debit_note_sap_responses` (debit).
- **Pull button** (Credit Notes page and Debit Notes page): `POST /api/v1/form16/sap/pull` triggers the **same** `runForm16SapResponseIngestionOnce()`, then the frontend refetches the list. So Pull = one-off run of the same ingestion logic; no separate “pull-only” path.
- **View** appears when the corresponding table has a row for that note with a non-null `storage_url` (and for list, we check by `credit_note_id` / `debit_note_id`).
---
## 7. End-to-End Summary
| Step | Credit note | Debit note |
|------|-------------|------------|
| **Trigger** | Form 16A submission validated, 26AS match | 26AS upload changes total for a SETTLED quarter |
| **INCOMING (we push)** | CSV to `INCOMING\WFM_MAIN\FORM16_CRDT` | CSV to `INCOMING\WFM_MAIN\FORM16_DEBT` |
| **TDS_TRNS_ID in CSV** | Credit note number | Credit note number |
| **File name** | `{creditNoteNumber}.csv` | `{debitNoteNumber}.csv` |
| **OUTGOING (SAP writes)** | SAP drops response in `OUTGOING\WFM_SAP_MAIN\FORM16_CRDT` | SAP drops response in `OUTGOING\WFM_SAP_MAIN\FORM16_DBT` |
| **We read & store** | Job reads CSV, matches, stores in `form16_sap_responses` | Job reads CSV, matches, stores in `form16_debit_note_sap_responses` |
| **User action** | View / Download CSV (Pull to refresh) | View only (Pull to refresh) |
---
## 8. Env / Config (relevant)
- **WFM_BASE_PATH**: Base folder that contains `WFM-QRE` (e.g. `C:\WFM` or `D:\Form-16 Main`). If not set and default path missing, job tries `process.cwd()\WFM-QRE\...`.
- **WFM_FORM16_CREDIT_INCOMING_PATH**, **WFM_FORM16_DEBIT_INCOMING_PATH**: Override INCOMING paths.
- **WFM_FORM16_CREDIT_OUTGOING_PATH**, **WFM_FORM16_DEBIT_OUTGOING_PATH**: Override OUTGOING paths.

View File

@ -14,6 +14,8 @@ import { extractForm16ADetails } from '../services/form16Ocr.service';
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { Form16aSubmission } from '@models/Form16aSubmission';
/**
* Form 16 controller: credit notes, OCR extract, and create submission for dealers.
@ -80,6 +82,28 @@ export class Form16Controller {
}
}
/**
* GET /api/v1/form16/debit-notes
* RE only. List all debit notes (all dealers).
*/
async listDebitNotes(req: Request, res: Response): Promise<void> {
try {
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const result = await form16Service.listAllDebitNotesForRe({ financialYear, quarter });
const payload: { debitNotes: typeof result.rows; total: number; summary?: typeof result.summary } = {
debitNotes: result.rows,
total: result.total,
summary: (result as any).summary,
};
return ResponseHandler.success(res, payload, 'Debit notes fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] listDebitNotes error:', error);
return ResponseHandler.error(res, 'Failed to fetch debit notes', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/dealer/submissions
* Dealer only. List Form 16 submissions for the authenticated dealer (pending/failed for Pending Submissions page).
@ -326,6 +350,65 @@ export class Form16Controller {
}
}
/**
* GET /api/v1/form16/credit-notes/:id/download
* Returns a storage URL for the SAP response CSV if available.
* If not yet available, returns 409 so UI can show "being generated, wait".
*/
async downloadCreditNote(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 url: string | null = null;
try {
url = await form16Service.getCreditNoteSapResponseUrlForUser(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 (!url) {
return ResponseHandler.error(res, 'The credit note is being generated. Please wait.', 409);
}
return ResponseHandler.success(res, { url }, 'OK');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] downloadCreditNote error:', error);
return ResponseHandler.error(res, 'Failed to fetch credit note download link', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/debit-notes/:id/sap-response
* RE only. Returns a storage URL for the SAP response CSV if available.
* If not yet available, returns 409 so UI can show "being generated, wait".
*/
async viewDebitNoteSapResponse(req: Request, res: Response): Promise<void> {
try {
const id = parseInt((req.params as { id: string }).id, 10);
if (Number.isNaN(id)) {
return ResponseHandler.error(res, 'Invalid debit note id', 400);
}
const url = await form16Service.getDebitNoteSapResponseUrl(id);
if (!url) {
return ResponseHandler.error(res, 'The debit note is being generated. Please wait.', 409);
}
return ResponseHandler.success(res, { url }, 'OK');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[Form16Controller] viewDebitNoteSapResponse error:', error);
return ResponseHandler.error(res, 'Failed to fetch debit note SAP response', 500, errorMessage);
}
}
/**
* GET /api/v1/form16/requests/:requestId/credit-note
* Get credit note (if any) linked to a Form 16 request. Used on Form 16 details workflow tab.
@ -681,6 +764,62 @@ export class Form16Controller {
return ResponseHandler.error(res, 'Failed to send test notification', 500, errorMessage);
}
}
/**
* POST /api/v1/form16/requests/:requestId/contact-admin
* Dealer UX: when submission fails because 26AS is missing for the quarter, dealer can notify RE admins.
*/
async contactAdmin(req: Request, res: Response): Promise<void> {
try {
const user = (req as AuthenticatedRequest).user;
const userId = user?.userId;
if (!userId) {
return ResponseHandler.unauthorized(res, 'Authentication required');
}
const requestId = String(req.params.requestId || '').trim();
if (!requestId) return ResponseHandler.error(res, 'requestId is required', 400);
const reqRow = await WorkflowRequest.findByPk(requestId, {
attributes: ['requestId', 'requestNumber', 'templateType', 'initiatorId'],
raw: true,
});
if (!reqRow || (reqRow as any).templateType !== 'FORM_16') {
return ResponseHandler.error(res, 'Form 16 request not found', 404);
}
if ((reqRow as any).initiatorId !== userId) {
return ResponseHandler.error(res, 'Forbidden', 403);
}
const submission = await Form16aSubmission.findOne({
where: { requestId },
order: [['submittedDate', 'DESC']],
attributes: ['id', 'validationStatus', 'validationNotes'],
});
if (!submission) return ResponseHandler.error(res, 'Submission not found', 404);
const s: any = submission as any;
const notes = String(s.validationNotes || '').toLowerCase();
const isMissing26As =
notes.includes('no 26as') ||
notes.includes('no 26as record') ||
notes.includes('ensure 26as has been uploaded');
if (!isMissing26As) {
return ResponseHandler.error(res, 'Contact admin is available only when 26AS is missing for the quarter.', 400);
}
const { triggerForm16MismatchContactAdminNotification } = await import('../services/form16Notification.service');
await triggerForm16MismatchContactAdminNotification({
requestId,
requestNumber: (reqRow as any).requestNumber,
dealerUserId: userId,
});
return ResponseHandler.success(res, { ok: true }, 'Admin notified');
} catch (error: any) {
logger.error('[Form16Controller] contactAdmin error:', error);
return ResponseHandler.error(res, 'Failed to notify admin', 500, error?.message);
}
}
}
export const form16Controller = new Form16Controller();

View File

@ -0,0 +1,24 @@
import { Request, Response } from 'express';
import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
import { runForm16SapResponseIngestionOnce } from '../jobs/form16SapResponseJob';
export class Form16SapController {
/**
* POST /api/v1/form16/sap/pull
* Trigger an immediate scan of the SAP OUTGOING directories for new Form 16 response CSVs.
* Safe to call multiple times; ingestion is idempotent by file name.
*/
async pull(req: Request, res: Response): Promise<void> {
try {
const result = await runForm16SapResponseIngestionOnce();
return ResponseHandler.success(res, result, 'Pulled SAP responses');
} catch (e: any) {
logger.error('[Form16SapController] pull error:', e);
return ResponseHandler.error(res, 'Failed to pull SAP responses', 500, e?.message || 'Unknown error');
}
}
}
export const form16SapController = new Form16SapController();

View File

@ -1,13 +1,17 @@
import { getForm16Config } from '../services/form16Config.service';
import { runForm16AlertSubmitJob, runForm16ReminderJob } from '../services/form16Notification.service';
import { runForm16AlertSubmitJob, runForm16ReminderJob, runForm16Remind26AsUploadJob } from '../services/form16Notification.service';
import logger from '../utils/logger';
const TZ = process.env.TZ || 'Asia/Kolkata';
// 26AS reminder is quarter-based; we evaluate once daily at this fixed time.
const RE_26AS_REMINDER_CHECK_TIME = '08:30';
/** Last date (YYYY-MM-DD) we ran the alert job in the configured timezone. */
let lastAlertRunDate: string | null = null;
/** Last date (YYYY-MM-DD) we ran the reminder job in the configured timezone. */
let lastReminderRunDate: string | null = null;
/** Last date (YYYY-MM-DD) we ran the 26AS upload reminder job in the configured timezone. */
let last26AsReminderRunDate: string | null = null;
/**
* Get current time in configured TZ as HH:mm (24h, zero-padded).
@ -51,6 +55,12 @@ async function form16NotificationTick(): Promise<void> {
logger.info(`[Form16 Job] Running reminder job (scheduled at ${reminderTime})`);
await runForm16ReminderJob();
}
if (config.reminder26AsUploadEnabled && RE_26AS_REMINDER_CHECK_TIME === nowTime && last26AsReminderRunDate !== today) {
last26AsReminderRunDate = today;
logger.info(`[Form16 Job] Running 26AS upload reminder job (daily check at ${RE_26AS_REMINDER_CHECK_TIME})`);
await runForm16Remind26AsUploadJob();
}
} catch (e) {
logger.error('[Form16 Job] Tick error:', e);
}

View File

@ -0,0 +1,240 @@
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger';
import { wfmFileService } from '../services/wfmFile.service';
import { Form16CreditNote, Form16DebitNote, Form16SapResponse, Form16DebitNoteSapResponse, Form16aSubmission, WorkflowRequest } from '../models';
import { gcsStorageService } from '../services/gcsStorage.service';
type ResponseRow = Record<string, string | undefined>;
function safeFileName(name: string): string {
return (name || '').trim().replace(/[\\\/:*?"<>|]+/g, '-').slice(0, 180) || 'form16-sap-response.csv';
}
async function processOutgoingFile(fileName: string, type: 'credit' | 'debit', resolvedOutgoingDir?: string): Promise<void> {
// Idempotency by file name. Credit uses form16_sap_responses; debit uses form16_debit_note_sap_responses.
const CreditModel = Form16SapResponse as any;
const DebitModel = Form16DebitNoteSapResponse as any;
const existingCredit = type === 'credit' ? await CreditModel.findOne({ where: { fileName }, attributes: ['id', 'creditNoteId', 'sapDocumentNumber', 'storageUrl'] }) : null;
const existingDebit = type === 'debit' ? await DebitModel.findOne({ where: { fileName }, attributes: ['id', 'debitNoteId', 'sapDocumentNumber', 'storageUrl'] }) : null;
const existing = existingCredit || existingDebit;
if (existing && (existing.creditNoteId ?? existing.debitNoteId) && (existing.storageUrl || existing.sapDocumentNumber)) {
return;
}
const rows = resolvedOutgoingDir
? await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName))
: await wfmFileService.readForm16OutgoingResponse(fileName, type);
if (!rows || rows.length === 0) {
// Still record as processed so we don't retry empty/invalid files forever
if (existing) {
if (type === 'credit') await CreditModel.update({ rawRow: null, updatedAt: new Date() }, { where: { id: existing.id } });
else await DebitModel.update({ rawRow: null, updatedAt: new Date() }, { where: { id: existing.id } });
} else {
if (type === 'credit') await CreditModel.create({ type, fileName, rawRow: null, createdAt: new Date(), updatedAt: new Date() });
else await DebitModel.create({ fileName, rawRow: null, createdAt: new Date(), updatedAt: new Date() });
}
return;
}
// Choose the first "real" row. Some SAP/WFM exports include an extra line like "|MSG_TYP|MESSAGE|"
// after the header; in that case rows[0] will not have TRNS_UNIQ_NO and mapping will fail.
const normalizedRows = rows as ResponseRow[];
const pick =
// Prefer proper transaction id rows
normalizedRows.find((row) => {
const id = (row.TRNS_UNIQ_NO || row.TRNSUNIQNO || row.DMS_UNIQ_NO || row.DMSUNIQNO || '')?.toString().trim();
return Boolean(id);
}) ||
// Fallback: require BOTH a claim ref and a doc/status field to avoid picking the "|MSG_TYP|MESSAGE|" line
normalizedRows.find((row) => {
const claimRef = (row.TDS_TRNS_ID || row.CLAIM_NUMBER || '')?.toString().trim();
const docNo = (row.DOC_NO || row.DOCNO || row.SAP_DOC_NO || row.SAPDOC || '')?.toString().trim();
const msgTyp = (row.MSG_TYP || row.MSGTYP || row.MSG_TYPE || '')?.toString().trim();
if (!claimRef) return false;
if (!docNo && !msgTyp) return false;
// guard against the "MSG_TYP" literal being inside claimRef column
if (claimRef.toUpperCase() === 'MSG_TYP' || claimRef.toUpperCase() === 'MESSAGE') return false;
return true;
});
const r = (pick || (rows[0] as ResponseRow)) as ResponseRow;
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || r.DMS_UNIQ_NO || r.DMS_UNIQ_NO || '')?.toString().trim() || null;
// SAP claim number: credit uses TDS_TRNS_ID; debit uses CLAIM_NUMBER
const claimNumber = (
(type === 'credit' ? r.TDS_TRNS_ID : r.CLAIM_NUMBER) ||
r.CLAIM_NUMBER ||
r.TDS_TRNS_ID ||
''
)?.toString().trim() || null;
const sapDocNo = (r.DOC_NO || r.DOCNO || r.SAP_DOC_NO || r.SAPDOC || '')?.toString().trim() || null;
const msgTyp = (r.MSG_TYP || r.MSGTYP || r.MSG_TYPE || '')?.toString().trim() || null;
const message = (r.MESSAGE || r.MSG || '')?.toString().trim() || null;
let creditNoteId: number | null = null;
let debitNoteId: number | null = null;
let requestId: string | null = null;
let requestNumber: string | null = null;
if (type === 'credit' && trnsUniqNo) {
const cn = await (Form16CreditNote as any).findOne({ where: { trnsUniqNo }, attributes: ['id', 'submissionId'] });
if (cn) {
creditNoteId = cn.id;
if (sapDocNo) await (Form16CreditNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: cn.id } });
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
requestId = submission?.requestId ?? null;
}
}
// Backward compatibility: old credit notes may not have trnsUniqNo stored. Match by creditNoteNumber (SAP sends it as TDS_TRNS_ID).
if (type === 'credit' && !creditNoteId && claimNumber) {
const cn = await (Form16CreditNote as any).findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
if (cn) {
creditNoteId = cn.id;
if (sapDocNo) await (Form16CreditNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: cn.id } });
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
requestId = submission?.requestId ?? null;
}
}
if (type === 'debit' && trnsUniqNo) {
const dn = await (Form16DebitNote as any).findOne({ where: { trnsUniqNo }, attributes: ['id'] });
if (dn) {
debitNoteId = dn.id;
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
}
}
if (type === 'debit' && !debitNoteId && claimNumber) {
const dn = await (Form16DebitNote as any).findOne({ where: { debitNoteNumber: claimNumber }, attributes: ['id'] });
if (dn) {
debitNoteId = dn.id;
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
}
}
// Fallback: match by filename (without .csv) to debit_note_number when SAP uses different TRNS_UNIQ_NO/CLAIM_NUMBER
if (type === 'debit' && !debitNoteId) {
const baseName = fileName.replace(/\.csv$/i, '').trim();
if (baseName) {
const dn = await (Form16DebitNote as any).findOne({ where: { debitNoteNumber: baseName }, attributes: ['id'] });
if (dn) {
debitNoteId = dn.id;
if (sapDocNo) await (Form16DebitNote as any).update({ sapDocumentNumber: sapDocNo, status: 'completed' }, { where: { id: dn.id } });
}
}
}
if (requestId) {
const req = await (WorkflowRequest as any).findOne({ where: { requestId }, attributes: ['requestNumber'] });
requestNumber = req?.requestNumber ?? null;
}
// Read the raw file bytes and upload to storage so it can be downloaded later
const absPath = resolvedOutgoingDir ? path.join(resolvedOutgoingDir, fileName) : wfmFileService.getForm16OutgoingPath(fileName, type);
let storageUrl: string | null = null;
try {
if (fs.existsSync(absPath)) {
const buffer = fs.readFileSync(absPath);
const upload = await gcsStorageService.uploadFileWithFallback({
buffer,
originalName: safeFileName(fileName),
mimeType: 'text/csv',
requestNumber: requestNumber || trnsUniqNo || 'FORM16',
fileType: 'documents',
});
storageUrl = upload.storageUrl || null;
}
} catch (e) {
logger.error('[Form16 SAP Job] Failed to upload outgoing response file:', fileName, e);
}
if (type === 'credit') {
const payload = {
type: 'credit' as const,
fileName,
creditNoteId,
claimNumber,
sapDocumentNumber: sapDocNo,
msgTyp,
message,
rawRow: r as any,
storageUrl,
updatedAt: new Date(),
};
if (existing) await CreditModel.update(payload, { where: { id: existing.id } });
else await CreditModel.create({ ...payload, createdAt: new Date() });
} else {
if (debitNoteId == null && (trnsUniqNo || claimNumber)) {
logger.warn(`[Form16 SAP Job] Debit file ${fileName}: no matching debit note in DB. TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}. Ensure a debit note exists with matching trns_uniq_no or debit_note_number.`);
}
const payload = {
fileName,
debitNoteId,
claimNumber,
sapDocumentNumber: sapDocNo,
msgTyp,
message,
rawRow: r as any,
storageUrl,
updatedAt: new Date(),
};
if (existing) await DebitModel.update(payload, { where: { id: existing.id } });
else await DebitModel.create({ ...payload, createdAt: new Date() });
}
}
export async function runForm16SapResponseIngestionOnce(): Promise<{
processed: number;
creditProcessed: number;
debitProcessed: number;
}> {
let creditProcessed = 0;
let debitProcessed = 0;
try {
const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
const creditDir = path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit'));
const debitDir = path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit'));
const dirs: Array<{ dir: string; type: 'credit' | 'debit' }> = [
{ dir: creditDir, type: 'credit' },
{ dir: debitDir, type: 'debit' },
];
const RELATIVE_DEBIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT');
const RELATIVE_CREDIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
for (const { dir, type } of dirs) {
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
if (!fs.existsSync(abs)) {
const relSubdir = type === 'debit' ? RELATIVE_DEBIT_OUT : RELATIVE_CREDIT_OUT;
const cwdFallback = path.join(process.cwd(), relSubdir);
if (fs.existsSync(cwdFallback)) {
abs = cwdFallback;
logger.info(`[Form16 SAP Job] Using ${type} outgoing dir from current working directory: ${abs}`);
} else {
logger.warn(`[Form16 SAP Job] ${type} outgoing dir does not exist, skipping. Tried: ${abs} and ${cwdFallback}. Set WFM_BASE_PATH to the folder that contains WFM-QRE, or place WFM-QRE under project root.`);
continue;
}
}
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
logger.info(`[Form16 SAP Job] ${type} outgoing dir: ${abs}, found ${files.length} CSV file(s): ${files.length ? files.join(', ') : '(none)'}`);
for (const f of files) {
await processOutgoingFile(f, type, abs);
if (type === 'credit') creditProcessed++;
else debitProcessed++;
}
}
} catch (e) {
logger.error('[Form16 SAP Job] Tick error:', e);
}
return {
processed: creditProcessed + debitProcessed,
creditProcessed,
debitProcessed,
};
}
/** Start scheduler that ingests SAP response files every 5 minutes. */
export function startForm16SapResponseJob(): void {
const cron = require('node-cron');
cron.schedule('*/5 * * * *', () => {
runForm16SapResponseIngestionOnce();
});
logger.info('[Form16 SAP Job] Scheduled SAP response ingestion (every 5 minutes)');
}

View File

@ -0,0 +1,85 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes } from 'sequelize';
/**
* Stores SAP/WFM outgoing response files for Form 16 credit/debit notes.
* Used for:
* - Showing "credit note is being generated, wait" until SAP response is received
* - Allowing users to download the SAP response file later
*/
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('form16_sap_responses', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
type: {
type: DataTypes.STRING(10),
allowNull: false, // 'credit' | 'debit'
},
file_name: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
},
credit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'SET NULL',
},
debit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
claim_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
sap_document_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
msg_typ: {
type: DataTypes.STRING(20),
allowNull: true,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
raw_row: {
type: DataTypes.JSONB,
allowNull: true,
},
storage_url: {
type: DataTypes.STRING(500),
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form16_sap_responses', ['type']);
await queryInterface.addIndex('form16_sap_responses', ['credit_note_id']);
await queryInterface.addIndex('form16_sap_responses', ['debit_note_id']);
await queryInterface.addIndex('form16_sap_responses', ['claim_number']);
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('form16_sap_responses');
},
};

View File

@ -0,0 +1,26 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('form_16_credit_notes', 'trns_uniq_no', {
type: DataTypes.STRING(120),
allowNull: true,
});
await queryInterface.addColumn('form_16_debit_notes', 'trns_uniq_no', {
type: DataTypes.STRING(120),
allowNull: true,
});
await queryInterface.addIndex('form_16_credit_notes', ['trns_uniq_no'], { name: 'idx_form16_credit_notes_trns_uniq_no' });
await queryInterface.addIndex('form_16_debit_notes', ['trns_uniq_no'], { name: 'idx_form16_debit_notes_trns_uniq_no' });
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeIndex('form_16_debit_notes', 'idx_form16_debit_notes_trns_uniq_no');
await queryInterface.removeIndex('form_16_credit_notes', 'idx_form16_credit_notes_trns_uniq_no');
await queryInterface.removeColumn('form_16_debit_notes', 'trns_uniq_no');
await queryInterface.removeColumn('form_16_credit_notes', 'trns_uniq_no');
},
};

View File

@ -0,0 +1,70 @@
import type { QueryInterface } from 'sequelize';
import { DataTypes } from 'sequelize';
/**
* Separate table for Form 16 debit note SAP responses (OUTGOING FORM16_DBT).
* Credit note SAP responses remain in form16_sap_responses only.
*/
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('form16_debit_note_sap_responses', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
file_name: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
},
debit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
claim_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
sap_document_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
msg_typ: {
type: DataTypes.STRING(20),
allowNull: true,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
raw_row: {
type: DataTypes.JSONB,
allowNull: true,
},
storage_url: {
type: DataTypes.STRING(500),
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form16_debit_note_sap_responses', ['debit_note_id']);
await queryInterface.addIndex('form16_debit_note_sap_responses', ['claim_number']);
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('form16_debit_note_sap_responses');
},
};

View File

@ -7,6 +7,7 @@ export interface Form16CreditNoteAttributes {
id: number;
submissionId: number;
creditNoteNumber: string;
trnsUniqNo?: string;
sapDocumentNumber?: string;
amount: number;
issueDate: Date;
@ -32,6 +33,7 @@ class Form16CreditNote
public id!: number;
public submissionId!: number;
public creditNoteNumber!: string;
public trnsUniqNo?: string;
public sapDocumentNumber?: string;
public amount!: number;
public issueDate!: Date;
@ -66,6 +68,11 @@ Form16CreditNote.init(
unique: true,
field: 'credit_note_number',
},
trnsUniqNo: {
type: DataTypes.STRING(120),
allowNull: true,
field: 'trns_uniq_no',
},
sapDocumentNumber: {
type: DataTypes.STRING(50),
allowNull: true,

View File

@ -7,6 +7,7 @@ export interface Form16DebitNoteAttributes {
id: number;
creditNoteId: number;
debitNoteNumber: string;
trnsUniqNo?: string;
sapDocumentNumber?: string;
amount: number;
issueDate: Date;
@ -30,6 +31,7 @@ class Form16DebitNote
public id!: number;
public creditNoteId!: number;
public debitNoteNumber!: string;
public trnsUniqNo?: string;
public sapDocumentNumber?: string;
public amount!: number;
public issueDate!: Date;
@ -64,6 +66,11 @@ Form16DebitNote.init(
unique: true,
field: 'debit_note_number',
},
trnsUniqNo: {
type: DataTypes.STRING(120),
allowNull: true,
field: 'trns_uniq_no',
},
sapDocumentNumber: {
type: DataTypes.STRING(50),
allowNull: true,

View File

@ -0,0 +1,83 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { Form16DebitNote } from './Form16DebitNote';
export interface Form16DebitNoteSapResponseAttributes {
id: number;
fileName: string;
debitNoteId?: number | null;
claimNumber?: string | null;
sapDocumentNumber?: string | null;
msgTyp?: string | null;
message?: string | null;
rawRow?: Record<string, unknown> | null;
storageUrl?: string | null;
createdAt: Date;
updatedAt: Date;
}
interface Form16DebitNoteSapResponseCreationAttributes
extends Optional<
Form16DebitNoteSapResponseAttributes,
| 'id'
| 'debitNoteId'
| 'claimNumber'
| 'sapDocumentNumber'
| 'msgTyp'
| 'message'
| 'rawRow'
| 'storageUrl'
| 'createdAt'
| 'updatedAt'
> {}
class Form16DebitNoteSapResponse
extends Model<Form16DebitNoteSapResponseAttributes, Form16DebitNoteSapResponseCreationAttributes>
implements Form16DebitNoteSapResponseAttributes
{
public id!: number;
public fileName!: string;
public debitNoteId?: number | null;
public claimNumber?: string | null;
public sapDocumentNumber?: string | null;
public msgTyp?: string | null;
public message?: string | null;
public rawRow?: Record<string, unknown> | null;
public storageUrl?: string | null;
public createdAt!: Date;
public updatedAt!: Date;
public debitNote?: Form16DebitNote;
}
Form16DebitNoteSapResponse.init(
{
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
debitNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'debit_note_id' },
claimNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'claim_number' },
sapDocumentNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
message: { type: DataTypes.TEXT, allowNull: true },
rawRow: { type: DataTypes.JSONB, allowNull: true, field: 'raw_row' },
storageUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'storage_url' },
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
},
{
sequelize,
tableName: 'form16_debit_note_sap_responses',
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
Form16DebitNoteSapResponse.belongsTo(Form16DebitNote, {
as: 'debitNote',
foreignKey: 'debitNoteId',
targetKey: 'id',
});
export { Form16DebitNoteSapResponse };

View File

@ -0,0 +1,99 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { Form16CreditNote } from './Form16CreditNote';
import { Form16DebitNote } from './Form16DebitNote';
export interface Form16SapResponseAttributes {
id: number;
type: 'credit' | 'debit';
fileName: string;
creditNoteId?: number | null;
debitNoteId?: number | null;
claimNumber?: string | null;
sapDocumentNumber?: string | null;
msgTyp?: string | null;
message?: string | null;
rawRow?: Record<string, unknown> | null;
storageUrl?: string | null;
createdAt: Date;
updatedAt: Date;
}
interface Form16SapResponseCreationAttributes
extends Optional<
Form16SapResponseAttributes,
| 'id'
| 'creditNoteId'
| 'debitNoteId'
| 'claimNumber'
| 'sapDocumentNumber'
| 'msgTyp'
| 'message'
| 'rawRow'
| 'storageUrl'
| 'createdAt'
| 'updatedAt'
> {}
class Form16SapResponse
extends Model<Form16SapResponseAttributes, Form16SapResponseCreationAttributes>
implements Form16SapResponseAttributes
{
public id!: number;
public type!: 'credit' | 'debit';
public fileName!: string;
public creditNoteId?: number | null;
public debitNoteId?: number | null;
public claimNumber?: string | null;
public sapDocumentNumber?: string | null;
public msgTyp?: string | null;
public message?: string | null;
public rawRow?: Record<string, unknown> | null;
public storageUrl?: string | null;
public createdAt!: Date;
public updatedAt!: Date;
public creditNote?: Form16CreditNote;
public debitNote?: Form16DebitNote;
}
Form16SapResponse.init(
{
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
type: { type: DataTypes.STRING(10), allowNull: false },
fileName: { type: DataTypes.STRING(255), allowNull: false, unique: true, field: 'file_name' },
creditNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'credit_note_id' },
debitNoteId: { type: DataTypes.INTEGER, allowNull: true, field: 'debit_note_id' },
claimNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'claim_number' },
sapDocumentNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'sap_document_number' },
msgTyp: { type: DataTypes.STRING(20), allowNull: true, field: 'msg_typ' },
message: { type: DataTypes.TEXT, allowNull: true },
rawRow: { type: DataTypes.JSONB, allowNull: true, field: 'raw_row' },
storageUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'storage_url' },
createdAt: { type: DataTypes.DATE, allowNull: false, field: 'created_at' },
updatedAt: { type: DataTypes.DATE, allowNull: false, field: 'updated_at' },
},
{
sequelize,
tableName: 'form16_sap_responses',
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
Form16SapResponse.belongsTo(Form16CreditNote, {
as: 'creditNote',
foreignKey: 'creditNoteId',
targetKey: 'id',
});
Form16SapResponse.belongsTo(Form16DebitNote, {
as: 'debitNote',
foreignKey: 'debitNoteId',
targetKey: 'id',
});
export { Form16SapResponse };

View File

@ -38,6 +38,8 @@ import { Form16NonSubmittedNotification } from './Form16NonSubmittedNotification
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
import { Form16QuarterStatus } from './Form16QuarterStatus';
import { Form16LedgerEntry } from './Form16LedgerEntry';
import { Form16SapResponse } from './Form16SapResponse';
import { Form16DebitNoteSapResponse } from './Form16DebitNoteSapResponse';
// Define associations
const defineAssociations = () => {
@ -222,7 +224,9 @@ export {
Form16NonSubmittedNotification,
Form1626asQuarterSnapshot,
Form16QuarterStatus,
Form16LedgerEntry
Form16LedgerEntry,
Form16SapResponse,
Form16DebitNoteSapResponse
};
// Export default sequelize instance

View File

@ -5,6 +5,7 @@ import fs from 'fs';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireForm16SubmissionAccess, requireForm1626AsAccess, requireForm16ReOnly } from '../middlewares/form16Permission.middleware';
import { form16Controller } from '../controllers/form16.controller';
import { form16SapController } from '../controllers/form16Sap.controller';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { UPLOAD_DIR } from '../config/storage';
@ -73,11 +74,29 @@ router.get(
requireForm16SubmissionAccess,
asyncHandler(form16Controller.listCreditNotes.bind(form16Controller))
);
// RE only: list debit notes
router.get(
'/debit-notes',
requireForm16ReOnly,
requireForm16SubmissionAccess,
asyncHandler(form16Controller.listDebitNotes.bind(form16Controller))
);
router.get(
'/debit-notes/:id/sap-response',
requireForm16ReOnly,
requireForm16SubmissionAccess,
asyncHandler(form16Controller.viewDebitNoteSapResponse.bind(form16Controller))
);
router.get(
'/credit-notes/:id',
requireForm16SubmissionAccess,
asyncHandler(form16Controller.getCreditNoteById.bind(form16Controller))
);
router.get(
'/credit-notes/:id/download',
requireForm16SubmissionAccess,
asyncHandler(form16Controller.downloadCreditNote.bind(form16Controller))
);
router.get(
'/requests/:requestId/credit-note',
requireForm16SubmissionAccess,
@ -97,6 +116,20 @@ router.post(
asyncHandler(form16Controller.setForm16ResubmissionNeeded.bind(form16Controller))
);
// Dealer: contact admin when 26AS missing for quarter
router.post(
'/requests/:requestId/contact-admin',
requireForm16SubmissionAccess,
asyncHandler(form16Controller.contactAdmin.bind(form16Controller))
);
// Pull SAP outgoing responses now (credit/debit). Used by Pull button in UI.
router.post(
'/sap/pull',
requireForm16SubmissionAccess,
asyncHandler(form16SapController.pull.bind(form16SapController))
);
// Form 16 SAP simulation (credit note / debit note). Replace with real SAP when integrating.
router.post(
'/sap-simulate/credit-note',

View File

@ -176,6 +176,9 @@ async function runMigrations(): Promise<void> {
const m59 = require('../migrations/20260309-add-wfm-push-fields');
const m60 = require('../migrations/20260316-update-holiday-type-enum');
const m61 = require('../migrations/20260317-refactor-activity-types-columns');
const m62 = require('../migrations/20260317100001-create-form16-sap-responses');
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -244,6 +247,9 @@ async function runMigrations(): Promise<void> {
{ name: '20260309-add-wfm-push-fields', module: m59 },
{ name: '20260316-update-holiday-type-enum', module: m60 },
{ name: '20260317-refactor-activity-types-columns', module: m61 },
{ name: '20260317100001-create-form16-sap-responses', module: m62 },
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
];
// Dynamically import sequelize after secrets are loaded

View File

@ -66,6 +66,9 @@ import * as m58 from '../migrations/20260303100001-drop-form16a-number-unique';
import * as m59 from '../migrations/20260309-add-wfm-push-fields';
import * as m60 from '../migrations/20260316-update-holiday-type-enum';
import * as m61 from '../migrations/20260317-refactor-activity-types-columns';
import * as m62 from '../migrations/20260317100001-create-form16-sap-responses';
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
interface Migration {
name: string;
@ -139,6 +142,9 @@ const migrations: Migration[] = [
{ name: '20260309-add-wfm-push-fields', module: m59 },
{ name: '20260316-update-holiday-type-enum', module: m60 },
{ name: '20260317-refactor-activity-types-columns', module: m61 },
{ name: '20260317100001-create-form16-sap-responses', module: m62 },
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
];

View File

@ -117,6 +117,8 @@ const startServer = async (): Promise<void> => {
startPauseResumeJob();
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
startForm16NotificationJobs();
const { startForm16SapResponseJob } = require('./jobs/form16SapResponseJob');
startForm16SapResponseJob();
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
startForm16ArchiveJob();

View File

@ -18,6 +18,8 @@ import {
Form1626asQuarterSnapshot,
Form16QuarterStatus,
Form16LedgerEntry,
Form16SapResponse,
Form16DebitNoteSapResponse,
} from '../models';
import { Tds26asEntry } from '../models/Tds26asEntry';
import { Form1626asUploadLog } from '../models/Form1626asUploadLog';
@ -237,6 +239,15 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
return { rows: [], total: 0 };
}
// If DB migrations for TRNS uniq no are not applied yet, do not break listing.
// We'll return sapResponseAvailable=false in that case.
let hasTrnsUniqNoColumn = true;
try {
await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT });
} catch {
hasTrnsUniqNoColumn = false;
}
const whereSubmission: any = { dealerCode };
if (filters?.financialYear) whereSubmission.financialYear = filters.financialYear;
if (filters?.quarter) whereSubmission.quarter = filters.quarter;
@ -262,6 +273,22 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
});
const noteIds = rows.map((r) => r.id);
let sapSet = new Set<number>();
if (hasTrnsUniqNoColumn && noteIds.length) {
try {
const sapRows = await (Form16SapResponse as any).findAll({
where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
attributes: ['creditNoteId'],
raw: true,
});
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
} catch (e: any) {
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>();
}
}
const dealer = await Dealer.findOne({
where: { [Op.or]: [{ salesCode: dealerCode }, { dlrcode: dealerCode }], isActive: true },
attributes: ['dealership', 'dealerPrincipalName'],
@ -273,6 +300,7 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
id: r.id,
creditNoteNumber: r.creditNoteNumber,
sapDocumentNumber: r.sapDocumentNumber,
sapResponseAvailable: sapSet.has(r.id),
amount: r.amount,
issueDate: r.issueDate,
financialYear: r.financialYear,
@ -611,6 +639,7 @@ async function run26asMatchAndCreditNote(submission: Form16aSubmission): Promise
// Push Form 16 credit note incoming CSV to WFM INCOMING/WFM_MAIN/FORM16_CRDT (SAP credit note generation exact fields only)
try {
const trnsUniqNo = `F16-CN-${submission.id}-${creditNote.id}-${Date.now()}`;
await creditNote.update({ trnsUniqNo });
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const fyCompact = form16FyCompact(financialYear) || '';
const finYearAndQuarter = fyCompact && quarter ? `FY_${fyCompact}_${quarter}` : '';
@ -842,6 +871,29 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
});
let hasTrnsUniqNoColumn = true;
try {
await sequelize.query(`SELECT trns_uniq_no FROM form_16_credit_notes LIMIT 1`, { type: QueryTypes.SELECT });
} catch {
hasTrnsUniqNoColumn = false;
}
const noteIds = rows.map((r) => r.id);
let sapSet = new Set<number>();
if (hasTrnsUniqNoColumn && noteIds.length) {
try {
const sapRows = await (Form16SapResponse as any).findAll({
where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
attributes: ['creditNoteId'],
raw: true,
});
sapSet = new Set((sapRows as any[]).map((r) => r.creditNoteId));
} catch (e: any) {
logger.warn('[Form16] SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>();
}
}
const dealerCodes = [...new Set(rows.map((r) => (r as any).submission?.dealerCode).filter(Boolean))] as string[];
const dealers = dealerCodes.length
? await Dealer.findAll({
@ -870,6 +922,7 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
id: r.id,
creditNoteNumber: r.creditNoteNumber,
sapDocumentNumber: r.sapDocumentNumber,
sapResponseAvailable: sapSet.has(r.id),
amount: r.amount,
issueDate: r.issueDate,
financialYear: r.financialYear,
@ -909,6 +962,99 @@ export async function listCreditNotesDealerOrRe(userId: string, filters?: { fina
return listAllCreditNotesForRe(filters);
}
// ---------- RE-only: list all debit notes (all dealers) ----------
export async function listAllDebitNotesForRe(filters?: { financialYear?: string; quarter?: string }) {
const whereDebit: any = {};
if (filters?.financialYear) whereDebit.financialYear = filters.financialYear;
if (filters?.quarter) whereDebit.quarter = filters.quarter;
const { rows, count } = await Form16DebitNote.findAndCountAll({
include: [
{
model: Form16CreditNote,
as: 'creditNote',
attributes: ['id', 'creditNoteNumber', 'financialYear', 'quarter', 'submissionId'],
required: true,
where: Object.keys(whereDebit).length ? whereDebit : undefined,
include: [
{
model: Form16aSubmission,
as: 'submission',
attributes: ['dealerCode', 'form16aNumber'],
required: false,
},
],
},
],
order: [['issueDate', 'DESC'], ['createdAt', 'DESC']],
});
// Mark which debit notes have ingested SAP response CSV available.
const noteIds = rows.map((r: any) => r.id);
let sapSet = new Set<number>();
if (noteIds.length) {
try {
const sapRows = await (Form16DebitNoteSapResponse as any).findAll({
where: { debitNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
attributes: ['debitNoteId'],
raw: true,
});
sapSet = new Set((sapRows as any[]).map((r) => r.debitNoteId ?? r.debit_note_id));
} catch (e: any) {
logger.warn('[Form16] Debit SAP response lookup failed (will treat as unavailable):', e?.message || e);
sapSet = new Set<number>();
}
}
const dealerCodes = [...new Set(rows.map((r: any) => r.creditNote?.submission?.dealerCode).filter(Boolean))] as string[];
const dealers = dealerCodes.length
? await Dealer.findAll({
where: {
isActive: true,
[Op.or]: [
...dealerCodes.map((c) => ({ salesCode: c })),
...dealerCodes.map((c) => ({ dlrcode: c })),
],
},
attributes: ['salesCode', 'dlrcode', 'dealership', 'dealerPrincipalName'],
})
: [];
const codeToName = new Map<string, string>();
for (const d of dealers) {
const code = (d as any).salesCode || (d as any).dlrcode;
if (code) codeToName.set(code, (d as any).dealership || (d as any).dealerPrincipalName || code);
}
const totalAmount = rows.reduce((sum: number, r: any) => sum + (Number(r.amount) || 0), 0);
return {
rows: rows.map((r: any) => {
const dc = r.creditNote?.submission?.dealerCode ?? null;
return {
id: r.id,
debitNoteNumber: r.debitNoteNumber,
sapDocumentNumber: r.sapDocumentNumber ?? null,
sapResponseAvailable: sapSet.has(r.id),
amount: r.amount ?? null,
issueDate: r.issueDate ?? null,
status: r.status ?? null,
financialYear: r.creditNote?.financialYear ?? null,
quarter: r.creditNote?.quarter ?? null,
creditNoteNumber: r.creditNote?.creditNoteNumber ?? null,
dealerCode: dc,
dealerName: (dc && codeToName.get(dc)) || dc || '—',
form16aNumber: r.creditNote?.submission?.form16aNumber ?? null,
};
}),
total: count,
summary: {
totalDebitNotes: count,
totalAmount,
impactedDealersCount: dealerCodes.length,
},
};
}
/**
* List Form 16 submissions for the authenticated dealer (for Pending Submissions page).
* Optional filter: status = pending | failed | pending,failed (default: pending,failed).
@ -1332,6 +1478,46 @@ export async function getCreditNoteById(creditNoteId: number) {
};
}
export async function getCreditNoteSapResponseUrl(creditNoteId: number): Promise<string | null> {
const row = await (Form16SapResponse as any).findOne({
where: { type: 'credit', creditNoteId, storageUrl: { [Op.ne]: null } },
attributes: ['storageUrl', 'createdAt'],
order: [['createdAt', 'DESC']],
});
const url = row?.storageUrl;
return url && String(url).trim() ? String(url) : null;
}
export async function getDebitNoteSapResponseUrl(debitNoteId: number): Promise<string | null> {
const row = await (Form16DebitNoteSapResponse as any).findOne({
where: { debitNoteId, storageUrl: { [Op.ne]: null } },
attributes: ['storageUrl', 'createdAt'],
order: [['createdAt', 'DESC']],
});
const url = row?.storageUrl;
return url && String(url).trim() ? String(url) : null;
}
/**
* Dealer-safe download: dealers can only download their own credit note.
* RE/Admin (non-dealer users) can download any credit note.
*/
export async function getCreditNoteSapResponseUrlForUser(creditNoteId: number, userId: string): Promise<string | 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()) {
// Hide existence for unauthorized dealer
throw new Error('Credit note not found');
}
}
return getCreditNoteSapResponseUrl(creditNoteId);
}
// ---------- Non-submitted dealers (RE only) ----------
const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'] as const;
@ -1539,6 +1725,24 @@ export async function getDealerUserIdsFromNonSubmittedDealers(financialYear?: st
return users.map((u) => (u as any).userId);
}
/**
* Get dealer user IDs missing a specific FY+quarter submission.
* Used by quarter-based submit reminders.
*/
export async function getDealerUserIdsMissingQuarter(financialYear: string, quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4'): Promise<string[]> {
const result = await listNonSubmittedDealers(financialYear);
const key = `${quarter} ${financialYear}`;
const emails = [...new Set(result.dealers.filter((d) => (d.missingQuarters || []).includes(key)).map((d) => d.email).filter(Boolean))]
.map((e) => e.trim().toLowerCase());
if (emails.length === 0) return [];
const users = await User.findAll({
where: { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) },
attributes: ['userId'],
raw: true,
});
return users.map((u) => (u as any).userId);
}
/**
* 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.
@ -2061,6 +2265,7 @@ export async function process26asUploadAggregation(uploadLogId: number): Promise
// Push Form 16 debit note CSV to WFM INCOMING/WFM_MAIN/FORM16_DEBT (same column set as credit note / SAP expectation)
try {
const trnsUniqNo = `F16-DN-${creditNote.id}-${debit.id}-${Date.now()}`;
await debit.update({ trnsUniqNo });
const docDate = now.toISOString().slice(0, 10).replace(/-/g, '');
const fyCompact = form16FyCompact(cnFy) || '';
const finYearAndQuarter = fyCompact && cnQuarter ? `FY ${fyCompact}_${cnQuarter}` : '';

View File

@ -25,6 +25,13 @@ export interface Form16Notification26AsConfig {
export interface Form16Config {
notification26AsDataAdded: Form16Notification26AsConfig;
/** RE reminder: prompt 26AS viewers to upload 26AS if missing for the most recently ended quarter. */
reminder26AsUploadEnabled: boolean;
reminder26AsUploadTemplate: string;
/** Start reminders after quarter ends + N days. */
reminder26AsUploadAfterQuarterEndDays: number;
/** Repeat reminders every N days after start. */
reminder26AsUploadEveryDays: number;
notificationForm16SuccessCreditNote: { enabled: boolean; template: string };
notificationForm16Unsuccessful: { enabled: boolean; template: string };
reminderNotificationEnabled: boolean;
@ -35,6 +42,10 @@ export interface Form16Config {
alertSubmitForm16Template: string;
/** When to run the alert job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
alertSubmitForm16RunAtTime: string;
/** Dealer reminder: start after quarter ends + N days. */
alertSubmitForm16AfterQuarterEndDays: number;
/** Dealer reminder: repeat every N days after start. */
alertSubmitForm16EveryDays: number;
twentySixAsViewerEmails: string[];
}
@ -46,6 +57,10 @@ const default26As = (): Form16Notification26AsConfig => ({
const defaults: Form16Config = {
notification26AsDataAdded: default26As(),
reminder26AsUploadEnabled: true,
reminder26AsUploadTemplate: 'Reminder: 26AS data for [FinancialYear] [Quarter] is not uploaded yet. Please upload it in 26AS Management.',
reminder26AsUploadAfterQuarterEndDays: 0,
reminder26AsUploadEveryDays: 7,
notificationForm16SuccessCreditNote: { enabled: true, template: 'Form 16 submitted successfully. Credit note: [CreditNoteRef].' },
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
reminderNotificationEnabled: true,
@ -54,6 +69,8 @@ const defaults: Form16Config = {
alertSubmitForm16Enabled: true,
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
alertSubmitForm16RunAtTime: '09:00',
alertSubmitForm16AfterQuarterEndDays: 0,
alertSubmitForm16EveryDays: 7,
twentySixAsViewerEmails: [],
};
@ -86,6 +103,16 @@ export async function getForm16Config(): Promise<Form16Config> {
return {
notification26AsDataAdded: merge26As(),
reminder26AsUploadEnabled: typeof parsed.reminder26AsUploadEnabled === 'boolean' ? parsed.reminder26AsUploadEnabled : defaults.reminder26AsUploadEnabled,
reminder26AsUploadTemplate: typeof parsed.reminder26AsUploadTemplate === 'string' ? parsed.reminder26AsUploadTemplate : defaults.reminder26AsUploadTemplate,
reminder26AsUploadAfterQuarterEndDays:
typeof parsed.reminder26AsUploadAfterQuarterEndDays === 'number'
? Math.max(0, Math.min(365, parsed.reminder26AsUploadAfterQuarterEndDays))
: defaults.reminder26AsUploadAfterQuarterEndDays,
reminder26AsUploadEveryDays:
typeof parsed.reminder26AsUploadEveryDays === 'number'
? Math.max(1, Math.min(365, parsed.reminder26AsUploadEveryDays))
: defaults.reminder26AsUploadEveryDays,
notificationForm16SuccessCreditNote:
parsed.notificationForm16SuccessCreditNote && typeof (parsed.notificationForm16SuccessCreditNote as any).template === 'string'
? { enabled: (parsed.notificationForm16SuccessCreditNote as any).enabled !== false, template: (parsed.notificationForm16SuccessCreditNote as any).template }
@ -100,6 +127,14 @@ export async function getForm16Config(): Promise<Form16Config> {
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : defaults.alertSubmitForm16Enabled,
alertSubmitForm16Template: typeof parsed.alertSubmitForm16Template === 'string' ? parsed.alertSubmitForm16Template : defaults.alertSubmitForm16Template,
alertSubmitForm16RunAtTime: typeof parsed.alertSubmitForm16RunAtTime === 'string' && parsed.alertSubmitForm16RunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.alertSubmitForm16RunAtTime.trim()) ? normalizeRunAtTime(parsed.alertSubmitForm16RunAtTime.trim()) : defaults.alertSubmitForm16RunAtTime) : '',
alertSubmitForm16AfterQuarterEndDays:
typeof parsed.alertSubmitForm16AfterQuarterEndDays === 'number'
? Math.max(0, Math.min(365, parsed.alertSubmitForm16AfterQuarterEndDays))
: defaults.alertSubmitForm16AfterQuarterEndDays,
alertSubmitForm16EveryDays:
typeof parsed.alertSubmitForm16EveryDays === 'number'
? Math.max(1, Math.min(365, parsed.alertSubmitForm16EveryDays))
: defaults.alertSubmitForm16EveryDays,
twentySixAsViewerEmails: Array.isArray(parsed.twentySixAsViewerEmails)
? (parsed.twentySixAsViewerEmails as string[]).map((e) => String(e).trim().toLowerCase()).filter(Boolean)
: defaults.twentySixAsViewerEmails,

View File

@ -9,6 +9,7 @@ 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';
@ -34,24 +35,40 @@ function userWhereEmailIn(emails: string[]) {
return { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) };
}
/** Get user IDs who should receive 26AS-added notification (RE side): config list or all non-dealer users. */
/**
* 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,
});
return users.map((u) => (u as any).userId);
const ids = users.map((u) => (u as any).userId).filter(Boolean);
return [...new Set([...adminIds, ...ids])].filter((id) => !dealerSet.has(id));
}
const dealerIds = await getDealerUserIds();
// 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);
if (dealerIds.length === 0) return allIds;
const dealerSet = new Set(dealerIds);
return allIds.filter((id) => !dealerSet.has(id));
const allIds = allUsers.map((u) => (u as any).userId).filter(Boolean);
return [...new Set([...adminIds, ...allIds])].filter((id) => !dealerSet.has(id));
}
/**
@ -89,6 +106,36 @@ export async function trigger26AsDataAddedNotification(): Promise<void> {
}
}
/**
* 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;
@ -98,6 +145,79 @@ function replacePlaceholders(template: string, replacements: Record<string, stri
return out;
}
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.
*/
@ -223,17 +343,24 @@ export async function runForm16AlertSubmitJob(): Promise<void> {
logger.info('[Form16Notification] Alert submit disabled in config, skipping job');
return;
}
const { getDealerUserIdsFromNonSubmittedDealers } = await import('./form16.service');
const dealerUserIds = await getDealerUserIdsFromNonSubmittedDealers();
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 y = new Date().getFullYear();
const fy = `${y}-${(y + 1).toString().slice(-2)}`;
const dueDate = `FY ${fy} (as per policy)`;
const dueDate = `${financialYear} ${quarter}`;
await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate });
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`);
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`, { financialYear, quarter });
} catch (e) {
logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e);
}

View File

@ -363,6 +363,8 @@ class NotificationService {
to: user.email,
subject: payload.title || 'Form 16 Notification',
html,
// Requirement: always BCC this mailbox for all Form 16 notification/reminder emails
bcc: ['rohitm_ext@royalenfield.com'],
});
logger.info(`[Email] Form 16 email sent to ${user.email} (type: ${payload.type})`);
} catch (err) {

View File

@ -266,39 +266,39 @@ export class WFMFileService {
}
/**
* Read a Form 16 outgoing (SAP) response CSV and return rows as objects keyed by header:
* Expected columns (both credit and debit):
* - DMS_UNIQ_NO
* - CLAIM_NUMBER
* - DOC_NO
* - MSG_TYP
* - MESSAGE
* Read a Form 16 outgoing (SAP) response CSV and return rows as objects keyed by header.
* Uses getForm16OutgoingPath(fileName, type) to resolve the file path.
*/
async readForm16OutgoingResponse(fileName: string, type: 'credit' | 'debit' = 'credit'): Promise<any[]> {
const filePath = this.getForm16OutgoingPath(fileName, type);
return this.readForm16OutgoingResponseByPath(filePath);
}
/**
* Read a Form 16 outgoing (SAP) response CSV from an absolute path.
* Expected columns (both credit and debit): DMS_UNIQ_NO, CLAIM_NUMBER, DOC_NO, MSG_TYP, MESSAGE.
* Delimiter: pipe (|).
*/
async readForm16OutgoingResponseByPath(filePath: string): Promise<any[]> {
try {
if (!fs.existsSync(filePath)) {
return [];
}
const fileContent = fs.readFileSync(filePath, 'utf-8');
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
if (lines.length <= 1) return []; // Only headers or empty
const headers = lines[0].split('|');
const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length <= 1) return [];
const headers = lines[0].split('|').map(h => h.trim());
const data = lines.slice(1).map(line => {
const values = line.split('|');
const row: any = {};
headers.forEach((header, index) => {
row[header.trim()] = values[index]?.trim() || '';
row[header] = values[index]?.trim() || '';
});
return row;
});
return data;
} catch (error) {
logger.error(`[WFMFileService] Error reading Form 16 response CSV (${type}): ${fileName}`, error);
logger.error('[WFMFileService] Error reading Form 16 response CSV:', filePath, error);
return [];
}
}