Compare commits
33 Commits
main
...
laxman_dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d74bc43bc | |||
|
|
d3ff1791ac | ||
|
|
604dcfbef5 | ||
| d699a5f31c | |||
| 830aa8d140 | |||
| 5a65acd333 | |||
|
|
06e70435c0 | ||
|
|
02e2f1e2a0 | ||
| 3ae0504ce3 | |||
| bf7574734e | |||
| 41b8b57efe | |||
|
|
62ca4f985a | ||
| 0f99fe68d5 | |||
| 64e8c2237a | |||
| bb66c930a8 | |||
| 26be132945 | |||
|
|
fe42078e88 | ||
| 068ff023ef | |||
| 9c003e9a16 | |||
|
|
89beffee2e | ||
|
|
b3dcaca697 | ||
| 9f3327ce38 | |||
| e9ed4ca4d3 | |||
| e4948e5cab | |||
|
|
eb3db7cd3a | ||
| 07577b4156 | |||
| e4d45b4fca | |||
| c50d481698 | |||
| 7b1df12a5b | |||
| cf95347fd7 | |||
| a67eb9da3c | |||
| 185d96e8b1 | |||
| 56d9d9c169 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -135,4 +135,5 @@ uploads/
|
||||
|
||||
# GCP Service Account Key
|
||||
config/gcp-key.json
|
||||
Jenkinsfile
|
||||
Jenkinsfile
|
||||
clear-26as-data.ts
|
||||
325
Jenkinsfile
vendored
325
Jenkinsfile
vendored
@ -1,325 +0,0 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
SSH_CREDENTIALS = 'cloudtopiaa'
|
||||
REMOTE_SERVER = 'ubuntu@160.187.166.17'
|
||||
PROJECT_NAME = 'Royal-Enfield-Backend'
|
||||
DEPLOY_PATH = '/home/ubuntu/Royal-Enfield/Re_Backend'
|
||||
GIT_CREDENTIALS = 'git-cred'
|
||||
REPO_URL = 'https://git.tech4biz.wiki/laxmanhalaki/Re_Backend.git'
|
||||
GIT_BRANCH = 'main'
|
||||
NPM_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/npm'
|
||||
NODE_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/node'
|
||||
PM2_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/pm2'
|
||||
PM2_APP_NAME = 'royal-enfield-backend'
|
||||
APP_PORT = '5000'
|
||||
EMAIL_RECIPIENT = 'laxman.halaki@tech4biz.org'
|
||||
}
|
||||
|
||||
options {
|
||||
timeout(time: 20, unit: 'MINUTES')
|
||||
disableConcurrentBuilds()
|
||||
timestamps()
|
||||
buildDiscarder(logRotator(numToKeepStr: '10', daysToKeepStr: '30'))
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Pre-deployment Check') {
|
||||
steps {
|
||||
script {
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "🚀 Starting ${PROJECT_NAME} Deployment"
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "Server: ${REMOTE_SERVER}"
|
||||
echo "Deploy Path: ${DEPLOY_PATH}"
|
||||
echo "PM2 App: ${PM2_APP_NAME}"
|
||||
echo "Build #: ${BUILD_NUMBER}"
|
||||
echo "═══════════════════════════════════════════"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Pull Latest Code') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
withCredentials([usernamePassword(credentialsId: GIT_CREDENTIALS, usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
|
||||
echo "📦 Git Operations..."
|
||||
|
||||
if [ -d "${DEPLOY_PATH}/.git" ]; then
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
echo "Configuring git..."
|
||||
git config --global --add safe.directory ${DEPLOY_PATH}
|
||||
git config credential.helper store
|
||||
|
||||
echo "Fetching updates..."
|
||||
git fetch https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/laxmanhalaki/Re_Backend.git ${GIT_BRANCH}
|
||||
|
||||
CURRENT_COMMIT=\$(git rev-parse HEAD)
|
||||
LATEST_COMMIT=\$(git rev-parse FETCH_HEAD)
|
||||
|
||||
if [ "\$CURRENT_COMMIT" = "\$LATEST_COMMIT" ]; then
|
||||
echo "⚠️ Already up to date. No changes to deploy."
|
||||
echo "Current: \$CURRENT_COMMIT"
|
||||
else
|
||||
echo "Pulling new changes..."
|
||||
git reset --hard FETCH_HEAD
|
||||
git clean -fd
|
||||
echo "✓ Updated from \${CURRENT_COMMIT:0:7} to \${LATEST_COMMIT:0:7}"
|
||||
fi
|
||||
else
|
||||
echo "Cloning repository..."
|
||||
rm -rf ${DEPLOY_PATH}
|
||||
mkdir -p /home/ubuntu/Royal-Enfield
|
||||
cd /home/ubuntu/Royal-Enfield
|
||||
git clone https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/laxmanhalaki/Re_Backend.git Re_Backend
|
||||
cd ${DEPLOY_PATH}
|
||||
git checkout ${GIT_BRANCH}
|
||||
git config --global --add safe.directory ${DEPLOY_PATH}
|
||||
echo "✓ Repository cloned successfully"
|
||||
fi
|
||||
|
||||
cd ${DEPLOY_PATH}
|
||||
echo "Current commit: \$(git log -1 --oneline)"
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Install Dependencies') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
echo "🔧 Environment Check..."
|
||||
echo "Node: \$(${NODE_PATH} -v)"
|
||||
echo "NPM: \$(${NPM_PATH} -v)"
|
||||
|
||||
echo ""
|
||||
echo "📥 Installing Dependencies..."
|
||||
${NPM_PATH} install --prefer-offline --no-audit --progress=false
|
||||
|
||||
echo ""
|
||||
echo "✅ Dependencies installed successfully!"
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Application') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
echo "🔨 Building application..."
|
||||
${NPM_PATH} run build
|
||||
echo "✅ Build completed successfully!"
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Stop PM2 Process') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
|
||||
echo "🛑 Stopping existing PM2 process..."
|
||||
|
||||
if ${PM2_PATH} list | grep -q "${PM2_APP_NAME}"; then
|
||||
echo "Stopping ${PM2_APP_NAME}..."
|
||||
${PM2_PATH} stop ${PM2_APP_NAME} || true
|
||||
${PM2_PATH} delete ${PM2_APP_NAME} || true
|
||||
echo "✓ Process stopped"
|
||||
else
|
||||
echo "No existing process found"
|
||||
fi
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Start with PM2') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
echo "🚀 Starting application with PM2..."
|
||||
|
||||
# Start with PM2
|
||||
${PM2_PATH} start ${NPM_PATH} --name "${PM2_APP_NAME}" -- start
|
||||
|
||||
echo ""
|
||||
echo "⏳ Waiting for application to start..."
|
||||
sleep 5
|
||||
|
||||
# Save PM2 configuration
|
||||
${PM2_PATH} save
|
||||
|
||||
# Show PM2 status
|
||||
echo ""
|
||||
echo "📊 PM2 Process Status:"
|
||||
${PM2_PATH} list
|
||||
|
||||
# Show logs (last 20 lines)
|
||||
echo ""
|
||||
echo "📝 Application Logs:"
|
||||
${PM2_PATH} logs ${PM2_APP_NAME} --lines 20 --nostream || true
|
||||
|
||||
echo ""
|
||||
echo "✅ Application started successfully!"
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
|
||||
echo "🔍 Deployment Verification..."
|
||||
|
||||
# Check if PM2 process is running
|
||||
if ${PM2_PATH} list | grep -q "${PM2_APP_NAME}.*online"; then
|
||||
echo "✓ PM2 process is running"
|
||||
else
|
||||
echo "✗ PM2 process is NOT running!"
|
||||
${PM2_PATH} logs ${PM2_APP_NAME} --lines 50 --nostream || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if port is listening
|
||||
echo ""
|
||||
echo "Checking if port ${APP_PORT} is listening..."
|
||||
if ss -tuln | grep -q ":${APP_PORT} "; then
|
||||
echo "✓ Application is listening on port ${APP_PORT}"
|
||||
else
|
||||
echo "⚠️ Port ${APP_PORT} not detected (may take a moment to start)"
|
||||
fi
|
||||
|
||||
# Show process info
|
||||
echo ""
|
||||
echo "📊 Process Information:"
|
||||
${PM2_PATH} info ${PM2_APP_NAME}
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "✅ DEPLOYMENT SUCCESSFUL"
|
||||
echo "═══════════════════════════════════════════"
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
success {
|
||||
script {
|
||||
def duration = currentBuild.durationString.replace(' and counting', '')
|
||||
mail to: "${EMAIL_RECIPIENT}",
|
||||
subject: "✅ ${PROJECT_NAME} - Deployment Successful #${BUILD_NUMBER}",
|
||||
body: """
|
||||
Deployment completed successfully!
|
||||
|
||||
Project: ${PROJECT_NAME}
|
||||
Build: #${BUILD_NUMBER}
|
||||
Duration: ${duration}
|
||||
Server: ${REMOTE_SERVER}
|
||||
PM2 App: ${PM2_APP_NAME}
|
||||
Port: ${APP_PORT}
|
||||
|
||||
Deployed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
|
||||
|
||||
Console: ${BUILD_URL}console
|
||||
|
||||
Commands to manage:
|
||||
- View logs: pm2 logs ${PM2_APP_NAME}
|
||||
- Restart: pm2 restart ${PM2_APP_NAME}
|
||||
- Stop: pm2 stop ${PM2_APP_NAME}
|
||||
"""
|
||||
}
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
try {
|
||||
def logs = sh(
|
||||
script: """ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
|
||||
${PM2_PATH} logs ${PM2_APP_NAME} --lines 50 --nostream || echo "No logs available"
|
||||
'""",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
mail to: "${EMAIL_RECIPIENT}",
|
||||
subject: "❌ ${PROJECT_NAME} - Deployment Failed #${BUILD_NUMBER}",
|
||||
body: """
|
||||
Deployment FAILED!
|
||||
|
||||
Project: ${PROJECT_NAME}
|
||||
Build: #${BUILD_NUMBER}
|
||||
Server: ${REMOTE_SERVER}
|
||||
Failed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
|
||||
|
||||
Console Log: ${BUILD_URL}console
|
||||
|
||||
Recent PM2 Logs:
|
||||
${logs}
|
||||
|
||||
Action required immediately!
|
||||
"""
|
||||
} catch (Exception e) {
|
||||
mail to: "${EMAIL_RECIPIENT}",
|
||||
subject: "❌ ${PROJECT_NAME} - Deployment Failed #${BUILD_NUMBER}",
|
||||
body: """
|
||||
Deployment FAILED!
|
||||
|
||||
Project: ${PROJECT_NAME}
|
||||
Build: #${BUILD_NUMBER}
|
||||
Server: ${REMOTE_SERVER}
|
||||
Failed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
|
||||
|
||||
Console Log: ${BUILD_URL}console
|
||||
|
||||
Could not retrieve PM2 logs. Please check manually.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
README.md
35
README.md
@ -83,6 +83,41 @@ A comprehensive backend API for the Royal Enfield Workflow Management System bui
|
||||
|
||||
The API will be available at `http://localhost:5000`
|
||||
|
||||
### Redis (for TAT and Pause-Resume jobs)
|
||||
|
||||
The backend uses **Redis** for TAT (turnaround time) alerts and Pause-Resume workflow jobs. The app runs without Redis, but those features need Redis on `localhost:6379`.
|
||||
|
||||
**Option 1 – Docker (easiest if you have Docker)**
|
||||
|
||||
```bash
|
||||
# Start Redis in the background (port 6379)
|
||||
npm run redis:start
|
||||
|
||||
# Stop when done
|
||||
npm run redis:stop
|
||||
```
|
||||
|
||||
**Option 2 – Windows (Memurai or WSL)**
|
||||
|
||||
- **Memurai** (Redis-compatible, native Windows): Download from [memurai.com](https://www.memurai.com/) and install. Default port 6379. You can install as a Windows service.
|
||||
- **WSL2**: Install Ubuntu from Microsoft Store, then:
|
||||
```bash
|
||||
sudo apt update && sudo apt install redis-server -y
|
||||
redis-server --daemonize yes
|
||||
```
|
||||
|
||||
**Option 3 – macOS / Linux**
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install redis && brew services start redis
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install redis-server -y && sudo systemctl start redis-server
|
||||
```
|
||||
|
||||
**Verify:** `redis-cli ping` should return `PONG`. Then restart the backend so it connects to Redis.
|
||||
|
||||
### Docker Setup
|
||||
|
||||
```bash
|
||||
|
||||
@ -1 +1 @@
|
||||
import{a as s}from"./index-CULgQ-8S.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CX5oLBI_.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.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-CwFNZe2z.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};
|
||||
1
build/assets/index-C9eBMrZm.css
Normal file
1
build/assets/index-C9eBMrZm.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
64
build/assets/index-CwFNZe2z.js
Normal file
64
build/assets/index-CwFNZe2z.js
Normal file
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
2
build/assets/ui-vendor-BrA5VgBk.js
Normal file
2
build/assets/ui-vendor-BrA5VgBk.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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-CULgQ-8S.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CwFNZe2z.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-CX5oLBI_.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-B_rK4TXr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XBJXaMj2.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C9eBMrZm.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
137
docs/FORM16_CREDIT_DEBIT_PROCESS.md
Normal file
137
docs/FORM16_CREDIT_DEBIT_PROCESS.md
Normal 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.
|
||||
33
env.example
33
env.example
@ -73,6 +73,23 @@ RATE_LIMIT_MAX_REQUESTS=100
|
||||
MAX_FILE_SIZE_MB=10
|
||||
ALLOWED_FILE_TYPES=pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif
|
||||
|
||||
# WFM Folder Structure Configuration
|
||||
WFM_BASE_PATH=C:\\WFM
|
||||
WFM_INCOMING_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_GST
|
||||
WFM_INCOMING_NON_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\DLR_INC_CLAIMS_NON_GST
|
||||
WFM_OUTGOING_GST_CLAIMS_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_GST
|
||||
WFM_OUTGOING_NON_GST_CLAIMS_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\DLR_INC_CLAIMS_NON_GST
|
||||
WFM_FORM16_CREDIT_INCOMING_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\FORM16_CRDT
|
||||
WFM_FORM16_DEBIT_INCOMING_PATH=WFM-QRE\\INCOMING\\WFM_MAIN\\FORM16_DBT
|
||||
WFM_FORM16_CREDIT_OUTGOING_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\FORM16_CRDT
|
||||
WFM_FORM16_DEBIT_OUTGOING_PATH=WFM-QRE\\OUTGOING\\WFM_SAP_MAIN\\FORM16_DBT
|
||||
|
||||
# WFM Archive Configuration (INCOMING)
|
||||
WFM_ARCHIVE_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_ARACHIVE\\DLR_INC_CLAIMS_GST
|
||||
WFM_ARCHIVE_NON_GST_CLAIMS_PATH=WFM-QRE\\INCOMING\\WFM_ARACHIVE\\DLR_INC_CLAIMS_NON_GST
|
||||
WFM_FORM16_CREDIT_ARCHIVE_PATH=WFM-QRE\\INCOMING\\WFM_ARACHIVE\\FORM16_CRDT
|
||||
WFM_FORM16_DEBIT_ARCHIVE_PATH=WFM-QRE\\INCOMING\\WFM_ARACHIVE\\FORM16_DBT
|
||||
|
||||
# TAT Monitoring
|
||||
TAT_CHECK_INTERVAL_MINUTES=30
|
||||
TAT_REMINDER_THRESHOLD_1=50
|
||||
@ -106,3 +123,19 @@ SAP_REQUESTER=REFMS
|
||||
# WARNING: Only use in development/testing environments
|
||||
SAP_DISABLE_SSL_VERIFY=false
|
||||
|
||||
# WFM file paths (base path; dealer claims use DLR_INC_CLAIMS, Form 16 uses FORM16_CRDT / FORM16_DEBT)
|
||||
# If unset: Windows defaults to C:\WFM; Linux/Mac defaults to <cwd>/wfm (paths are cross-platform).
|
||||
# WFM_BASE_PATH=C:\WFM
|
||||
# WFM_INCOMING_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_MAIN\DLR_INC_CLAIMS
|
||||
# WFM_OUTGOING_CLAIMS_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\DLR_INC_CLAIMS
|
||||
# Form 16 credit note CSV (incoming): INCOMING/WFM_MAIN/FORM16_CRDT
|
||||
# Form 16 debit note CSV (incoming): INCOMING/WFM_MAIN/FORM16_DBT
|
||||
# Form 16 SAP responses (outgoing): OUTGOING/WFM_SAP_MAIN/FORM16_CRDT
|
||||
# WFM_FORM16_CREDIT_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\FORM16_CRDT
|
||||
# WFM_FORM16_DEBIT_INCOMING_PATH=WFM-QRE\INCOMING\WFM_MAIN\FORM16_DBT
|
||||
# WFM_FORM16_OUTGOING_PATH=WFM-QRE\OUTGOING\WFM_SAP_MAIN\FORM16_CRDT
|
||||
|
||||
# WFM Archive configuration examples (if overrides are needed)
|
||||
# WFM_ARCHIVE_GST_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\DLR_INC_CLAIMS_GST
|
||||
# WFM_FORM16_CREDIT_ARCHIVE_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\FORM16_CRDT
|
||||
|
||||
|
||||
BIN
form 16 build (6 march).zip
Normal file
BIN
form 16 build (6 march).zip
Normal file
Binary file not shown.
@ -14,7 +14,7 @@ module.exports = {
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
moduleNameMapping: {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@controllers/(.*)$': '<rootDir>/src/controllers/$1',
|
||||
'^@services/(.*)$': '<rootDir>/src/services/$1',
|
||||
@ -23,5 +23,6 @@ module.exports = {
|
||||
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
|
||||
'^@types/(.*)$': '<rootDir>/src/types/$1',
|
||||
'^@config/(.*)$': '<rootDir>/src/config/$1',
|
||||
'^@validators/(.*)$': '<rootDir>/src/validators/$1',
|
||||
},
|
||||
};
|
||||
|
||||
227
package-lock.json
generated
227
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/vertexai": "^1.10.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"axios": "^1.7.9",
|
||||
@ -34,6 +35,7 @@
|
||||
"openai": "^6.8.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.13.1",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"prom-client": "^15.1.3",
|
||||
@ -1698,6 +1700,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.24.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
@ -2430,6 +2441,190 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/snappy-android-arm-eabi": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm-eabi/-/snappy-android-arm-eabi-7.3.3.tgz",
|
||||
@ -10178,6 +10373,38 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
15
package.json
15
package.json
@ -19,12 +19,22 @@
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
|
||||
"seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts",
|
||||
"seed:test-dealer": "ts-node -r tsconfig-paths/register src/scripts/seed-test-dealer.ts",
|
||||
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
|
||||
"seed:dealer-user": "ts-node -r tsconfig-paths/register src/scripts/seed-dealer-user.ts",
|
||||
"seed:rohit-user": "ts-node -r tsconfig-paths/register src/scripts/seed-rohit-user.ts",
|
||||
"seed:admin-user": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-user.ts",
|
||||
"seed:demo-requests": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-requests.ts",
|
||||
"seed:demo-dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-dealers.ts",
|
||||
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts",
|
||||
"redis:start": "docker run -d --name redis-workflow -p 6379:6379 redis:7-alpine",
|
||||
"redis:stop": "docker rm -f redis-workflow",
|
||||
"test": "jest --passWithNoTests --forceExit",
|
||||
"test:ci": "jest --ci --coverage --passWithNoTests --forceExit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/vertexai": "^1.10.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"axios": "^1.7.9",
|
||||
@ -48,6 +58,7 @@
|
||||
"openai": "^6.8.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.13.1",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"prom-client": "^15.1.3",
|
||||
@ -97,4 +108,4 @@
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
120
src/__tests__/api/smoke.test.ts
Normal file
120
src/__tests__/api/smoke.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* API smoke tests for UAT / production readiness.
|
||||
* Tests health, routing, and auth validation without requiring full DB/Redis in CI.
|
||||
*
|
||||
* Run: npm test -- smoke
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
|
||||
// Load app without starting server (server.ts is not imported)
|
||||
// Suppress DB config console logs in test
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
|
||||
|
||||
let app: import('express').Application;
|
||||
beforeAll(() => {
|
||||
app = require('../../app').default;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
describe('API Smoke – Health & Routing', () => {
|
||||
it('SMK-01: GET /health returns 200 and status OK', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('status', 'OK');
|
||||
expect(res.body).toHaveProperty('timestamp');
|
||||
expect(res.body).toHaveProperty('uptime');
|
||||
});
|
||||
|
||||
it('SMK-02: GET /api/v1/health returns 200 and service name', async () => {
|
||||
const res = await request(app).get('/api/v1/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('status', 'OK');
|
||||
expect(res.body).toHaveProperty('service', 're-workflow-backend');
|
||||
});
|
||||
|
||||
it('SMK-03: GET /api/v1/health/db returns 200 (connected) or 503 (disconnected)', async () => {
|
||||
const res = await request(app).get('/api/v1/health/db');
|
||||
expect([200, 503]).toContain(res.status);
|
||||
if (res.status === 200) {
|
||||
expect(res.body).toHaveProperty('database', 'connected');
|
||||
} else {
|
||||
expect(res.body).toHaveProperty('database', 'disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
it('SMK-04: Invalid API route returns 404 with JSON', async () => {
|
||||
const res = await request(app).get('/api/v1/invalid-route-xyz');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty('success', false);
|
||||
expect(res.body).toHaveProperty('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Smoke – Authentication', () => {
|
||||
it('AUTH-01: POST /api/v1/auth/sso-callback with empty body returns 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/sso-callback')
|
||||
.send({})
|
||||
.set('Content-Type', 'application/json');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('success', false);
|
||||
// Auth route validator returns "Request body validation failed"; legacy app route returns "email and oktaSub required"
|
||||
expect(res.body.message).toMatch(/email|oktaSub|required|validation failed/i);
|
||||
});
|
||||
|
||||
it('AUTH-02: POST /api/v1/auth/sso-callback without oktaSub returns 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/sso-callback')
|
||||
.send({ email: 'test@example.com' })
|
||||
.set('Content-Type', 'application/json');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('success', false);
|
||||
});
|
||||
|
||||
it('AUTH-03: POST /api/v1/auth/sso-callback without email returns 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/sso-callback')
|
||||
.send({ oktaSub: 'okta-123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('success', false);
|
||||
});
|
||||
|
||||
it('AUTH-04: GET /api/v1/users without token returns 401', async () => {
|
||||
const res = await request(app).get('/api/v1/users');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('AUTH-05: GET /api/v1/users with invalid token returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/users')
|
||||
.set('Authorization', 'Bearer invalid-token');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Smoke – Security Headers (SEC-01, SEC-02, SEC-03)', () => {
|
||||
it('SEC-01: Response includes Content-Security-Policy header', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers).toHaveProperty('content-security-policy');
|
||||
expect(res.headers['content-security-policy']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('SEC-02: Response includes X-Frame-Options (SAMEORIGIN or deny)', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers).toHaveProperty('x-frame-options');
|
||||
expect(res.headers['x-frame-options'].toUpperCase()).toMatch(/SAMEORIGIN|DENY/);
|
||||
});
|
||||
|
||||
it('SEC-03: GET /metrics without admin auth returns 401', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
67
src/__tests__/form16-reconciliation.test.ts
Normal file
67
src/__tests__/form16-reconciliation.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Form 16 reconciliation tests.
|
||||
* - Ledger: one CREDIT/DEBIT row per credit/debit note; no deletion (see docs/form16/LEDGER.md).
|
||||
* - 26AS: only Section 194Q, Booking F/O; quarter aggregation; snapshot + auto-debit when total changes.
|
||||
* - Form 16 match: latest 26AS aggregate only; reject mismatch/duplicate.
|
||||
*
|
||||
* Run: npm test -- form16-reconciliation
|
||||
* Or: npm test -- --testPathPattern=form16
|
||||
*
|
||||
* Optional: FORM16_TEST_DB=1 to run integration tests (requires DB).
|
||||
*/
|
||||
|
||||
import {
|
||||
getLatest26asAggregatedForQuarter,
|
||||
getLatest26asSnapshot,
|
||||
getQuarterStatus,
|
||||
process26asUploadAggregation,
|
||||
upload26asFile,
|
||||
parse26asTxtFile,
|
||||
} from '../services/form16.service';
|
||||
|
||||
describe('Form 16 reconciliation', () => {
|
||||
describe('parse26asTxtFile', () => {
|
||||
it('parses 26AS official format (^ delimiter) and extracts sectionCode 194Q and statusOltas F', () => {
|
||||
const header = '1^Deductor Name^TAN12345G^^^^^Total^Tax^TDS';
|
||||
const line = '^1^194Q^30-Sep-2024^F^24-Oct-2024^-^1000^100^100';
|
||||
const buffer = Buffer.from([header, line].join('\n'), 'utf8');
|
||||
const { rows, errors } = parse26asTxtFile(buffer);
|
||||
expect(errors).toEqual([]);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].sectionCode).toBe('194Q');
|
||||
expect(rows[0].statusOltas).toBe('F');
|
||||
expect(rows[0].taxDeducted).toBe(100);
|
||||
});
|
||||
|
||||
it('returns empty rows for empty buffer', () => {
|
||||
const { rows, errors } = parse26asTxtFile(Buffer.from('', 'utf8'));
|
||||
expect(rows).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by section and booking status in aggregation (194Q, F/O only) – documented behavior', () => {
|
||||
expect(parse26asTxtFile(Buffer.from('x', 'utf8'))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregation and snapshot helpers', () => {
|
||||
it('getLatest26asAggregatedForQuarter is a function', () => {
|
||||
expect(typeof getLatest26asAggregatedForQuarter).toBe('function');
|
||||
});
|
||||
it('getLatest26asSnapshot is a function', () => {
|
||||
expect(typeof getLatest26asSnapshot).toBe('function');
|
||||
});
|
||||
it('getQuarterStatus is a function', () => {
|
||||
expect(typeof getQuarterStatus).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload and process', () => {
|
||||
it('upload26asFile accepts buffer and optional uploadLogId', () => {
|
||||
expect(typeof upload26asFile).toBe('function');
|
||||
});
|
||||
it('process26asUploadAggregation returns snapshotsCreated and debitsCreated', () => {
|
||||
expect(typeof process26asUploadAggregation).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
92
src/__tests__/workflow-validator.test.ts
Normal file
92
src/__tests__/workflow-validator.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Workflow validator (Zod schema) tests for UAT.
|
||||
* Covers create and update workflow validation – WF-01 to WF-04.
|
||||
*
|
||||
* Run: npm test -- workflow-validator
|
||||
*/
|
||||
|
||||
import {
|
||||
createWorkflowSchema,
|
||||
updateWorkflowSchema,
|
||||
validateCreateWorkflow,
|
||||
validateUpdateWorkflow,
|
||||
} from '../validators/workflow.validator';
|
||||
|
||||
const validApprovalLevel = {
|
||||
email: 'approver@example.com',
|
||||
tatHours: 24,
|
||||
};
|
||||
|
||||
describe('Workflow validator', () => {
|
||||
describe('createWorkflowSchema (WF-01 to WF-04)', () => {
|
||||
it('WF-01: rejects missing title', () => {
|
||||
const data = {
|
||||
templateType: 'CUSTOM',
|
||||
description: 'Test description',
|
||||
priority: 'STANDARD',
|
||||
approvalLevels: [validApprovalLevel],
|
||||
};
|
||||
expect(() => createWorkflowSchema.parse(data)).toThrow();
|
||||
});
|
||||
|
||||
it('WF-02: rejects invalid priority', () => {
|
||||
const data = {
|
||||
templateType: 'CUSTOM',
|
||||
title: 'Test',
|
||||
description: 'Desc',
|
||||
priority: 'INVALID_PRIORITY',
|
||||
approvalLevels: [validApprovalLevel],
|
||||
};
|
||||
expect(() => createWorkflowSchema.parse(data)).toThrow();
|
||||
});
|
||||
|
||||
it('WF-03: rejects empty approval levels', () => {
|
||||
const data = {
|
||||
templateType: 'CUSTOM',
|
||||
title: 'Test',
|
||||
description: 'Desc',
|
||||
priority: 'STANDARD',
|
||||
approvalLevels: [],
|
||||
};
|
||||
expect(() => createWorkflowSchema.parse(data)).toThrow();
|
||||
});
|
||||
|
||||
it('WF-04: accepts valid minimal create payload', () => {
|
||||
const data = {
|
||||
templateType: 'CUSTOM',
|
||||
title: 'Valid Title',
|
||||
description: 'Valid description',
|
||||
priority: 'STANDARD',
|
||||
approvalLevels: [validApprovalLevel],
|
||||
};
|
||||
const result = validateCreateWorkflow(data);
|
||||
expect(result.title).toBe('Valid Title');
|
||||
expect(result.priority).toBe('STANDARD');
|
||||
expect(result.approvalLevels).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepts EXPRESS priority', () => {
|
||||
const data = {
|
||||
templateType: 'CUSTOM',
|
||||
title: 'Express',
|
||||
description: 'Desc',
|
||||
priority: 'EXPRESS',
|
||||
approvalLevels: [validApprovalLevel],
|
||||
};
|
||||
const result = createWorkflowSchema.parse(data);
|
||||
expect(result.priority).toBe('EXPRESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateWorkflowSchema', () => {
|
||||
it('accepts partial update with valid status', () => {
|
||||
const data = { status: 'APPROVED' };
|
||||
const result = updateWorkflowSchema.parse(data);
|
||||
expect(result.status).toBe('APPROVED');
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
expect(() => updateWorkflowSchema.parse({ status: 'INVALID' })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,7 @@ import { authenticateToken } from './middlewares/auth.middleware';
|
||||
import { requireAdmin } from './middlewares/authorization.middleware';
|
||||
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
||||
import routes from './routes/index';
|
||||
import form16Routes from './routes/form16.routes';
|
||||
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
||||
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
||||
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
|
||||
@ -112,6 +113,11 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// Form 16 extract MUST be mounted BEFORE body parsers so multipart stream is not consumed
|
||||
// (REform16 pattern: extract uses multer disk storage; mounting first guarantees raw stream for multer)
|
||||
ensureUploadDir();
|
||||
app.use('/api/v1/form16', form16Routes);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
@ -141,7 +147,7 @@ app.get('/health', (_req: express.Request, res: express.Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Mount API routes - MUST be before static file serving
|
||||
// Mount API routes (form16 already mounted above before body parser)
|
||||
app.use('/api/v1', routes);
|
||||
|
||||
// Serve uploaded files statically
|
||||
|
||||
@ -8,7 +8,7 @@ import logger from '@utils/logger';
|
||||
import { initializeHolidaysCache, clearWorkingHoursCache } from '@utils/tatTimeUtils';
|
||||
import { clearConfigCache } from '@services/configReader.service';
|
||||
import { User, UserRole } from '@models/User';
|
||||
import { sanitizeHtml } from '@utils/sanitizer';
|
||||
import { sanitizeHtml, sanitizeObject, isHtmlEmpty } from '@utils/sanitizer';
|
||||
|
||||
/**
|
||||
* Get all holidays (with optional year filter)
|
||||
@ -125,7 +125,9 @@ export const createHoliday = async (req: Request, res: Response): Promise<void>
|
||||
logger.error('[Admin] Error creating holiday:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to create holiday'
|
||||
message: 'Failed to create holiday',
|
||||
error: error.message,
|
||||
details: error.errors // Sequelize validation errors are usually in .errors
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -172,7 +174,9 @@ export const updateHoliday = async (req: Request, res: Response): Promise<void>
|
||||
logger.error('[Admin] Error updating holiday:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to update holiday'
|
||||
message: 'Failed to update holiday',
|
||||
error: error.message,
|
||||
details: error.errors
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -403,10 +407,18 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize config value if it's likely to be rendered as HTML
|
||||
// We can be selective or just sanitize all strings for safety
|
||||
if (typeof configValue === 'string') {
|
||||
configValue = sanitizeHtml(configValue);
|
||||
// Sanitize config value using unified sanitizeObject
|
||||
// This will handle strings, numbers, and nested objects consistently
|
||||
const sanitizedObj = sanitizeObject({ [configKey]: configValue });
|
||||
configValue = sanitizedObj[configKey];
|
||||
|
||||
// If it's a string, ensure it's not effectively empty after sanitization
|
||||
if (typeof configValue === 'string' && isHtmlEmpty(configValue)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Config value is required and must contain valid content'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@ -512,6 +524,203 @@ export const resetConfiguration = async (req: Request, res: Response): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
/** Form 16 admin config – stored in same admin_configurations table as other workflow/admin configs (config_key = FORM16_ADMIN_CONFIG, config_value = JSON). */
|
||||
const FORM16_CONFIG_KEY = 'FORM16_ADMIN_CONFIG';
|
||||
|
||||
/** Normalize run-at time to HH:mm (e.g. "9:0" -> "09:00"). */
|
||||
function normalizeRunAtTime(s: string): string {
|
||||
const [h, m] = s.split(':').map((x) => parseInt(x, 10));
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return '09:00';
|
||||
const hh = Math.max(0, Math.min(23, h));
|
||||
const mm = Math.max(0, Math.min(59, m));
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const defaultNotificationItem = (template: string) => ({ enabled: true, template });
|
||||
/** 26AS data added: separate message for RE users and for dealers */
|
||||
const default26AsNotification = () => ({
|
||||
enabled: true,
|
||||
templateRe: '26AS data has been added. Please review and use for matching dealer Form 16 submissions.',
|
||||
templateDealers: 'New 26AS data has been uploaded. You can now submit your Form 16 for the relevant quarter if you haven’t already.',
|
||||
});
|
||||
const DEFAULT_FORM16_CONFIG = {
|
||||
submissionViewerEmails: [] as string[],
|
||||
twentySixAsViewerEmails: [] as string[],
|
||||
reminderEnabled: true,
|
||||
reminderDays: 7,
|
||||
// Form 16 notification events (each: enabled + optional template(s))
|
||||
notification26AsDataAdded: default26AsNotification(),
|
||||
notificationForm16SuccessCreditNote: defaultNotificationItem('Form 16 submitted successfully. Credit note: [CreditNoteRef].'),
|
||||
notificationForm16Unsuccessful: defaultNotificationItem('Form 16 submission was unsuccessful. Issue: [Issue].'),
|
||||
alertSubmitForm16Enabled: true,
|
||||
alertSubmitForm16FrequencyDays: 0,
|
||||
alertSubmitForm16FrequencyHours: 24,
|
||||
alertSubmitForm16RunAtTime: '09:00',
|
||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
||||
reminderNotificationEnabled: true,
|
||||
reminderFrequencyDays: 0,
|
||||
reminderFrequencyHours: 12,
|
||||
reminderRunAtTime: '10:00',
|
||||
reminderNotificationTemplate: 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Form 16 admin configuration (who can see submission data, 26AS, reminders)
|
||||
*/
|
||||
export const getForm16Config = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await sequelize.query<{ config_value: string }>(
|
||||
`SELECT config_value FROM admin_configurations WHERE config_key = :configKey LIMIT 1`,
|
||||
{ replacements: { configKey: FORM16_CONFIG_KEY }, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (result && result.length > 0 && result[0].config_value) {
|
||||
try {
|
||||
const parsed = JSON.parse(result[0].config_value);
|
||||
const mergeNotification = (key: keyof typeof DEFAULT_FORM16_CONFIG) => {
|
||||
const val = parsed[key];
|
||||
const def = DEFAULT_FORM16_CONFIG[key] as any;
|
||||
if (val && typeof val === 'object' && typeof val.enabled === 'boolean') {
|
||||
return { enabled: val.enabled, template: typeof val.template === 'string' ? val.template : (def?.template ?? '') };
|
||||
}
|
||||
return def;
|
||||
};
|
||||
const def26As = DEFAULT_FORM16_CONFIG.notification26AsDataAdded as { enabled: boolean; templateRe: string; templateDealers: string };
|
||||
const merge26As = () => {
|
||||
const val = parsed.notification26AsDataAdded;
|
||||
if (val && typeof val === 'object' && typeof val.enabled === 'boolean') {
|
||||
return {
|
||||
enabled: val.enabled,
|
||||
templateRe: typeof val.templateRe === 'string' ? val.templateRe : (typeof val.template === 'string' ? val.template : def26As.templateRe),
|
||||
templateDealers: typeof val.templateDealers === 'string' ? val.templateDealers : def26As.templateDealers,
|
||||
};
|
||||
}
|
||||
return def26As;
|
||||
};
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
submissionViewerEmails: Array.isArray(parsed.submissionViewerEmails) ? parsed.submissionViewerEmails : DEFAULT_FORM16_CONFIG.submissionViewerEmails,
|
||||
twentySixAsViewerEmails: Array.isArray(parsed.twentySixAsViewerEmails) ? parsed.twentySixAsViewerEmails : DEFAULT_FORM16_CONFIG.twentySixAsViewerEmails,
|
||||
reminderEnabled: typeof parsed.reminderEnabled === 'boolean' ? parsed.reminderEnabled : DEFAULT_FORM16_CONFIG.reminderEnabled,
|
||||
reminderDays: typeof parsed.reminderDays === 'number' ? parsed.reminderDays : DEFAULT_FORM16_CONFIG.reminderDays,
|
||||
notification26AsDataAdded: merge26As(),
|
||||
notificationForm16SuccessCreditNote: mergeNotification('notificationForm16SuccessCreditNote'),
|
||||
notificationForm16Unsuccessful: mergeNotification('notificationForm16Unsuccessful'),
|
||||
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : DEFAULT_FORM16_CONFIG.alertSubmitForm16Enabled,
|
||||
alertSubmitForm16FrequencyDays: typeof parsed.alertSubmitForm16FrequencyDays === 'number' ? parsed.alertSubmitForm16FrequencyDays : DEFAULT_FORM16_CONFIG.alertSubmitForm16FrequencyDays,
|
||||
alertSubmitForm16FrequencyHours: typeof parsed.alertSubmitForm16FrequencyHours === 'number' ? parsed.alertSubmitForm16FrequencyHours : DEFAULT_FORM16_CONFIG.alertSubmitForm16FrequencyHours,
|
||||
alertSubmitForm16RunAtTime: typeof parsed.alertSubmitForm16RunAtTime === 'string' ? (parsed.alertSubmitForm16RunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.alertSubmitForm16RunAtTime.trim()) ? normalizeRunAtTime(parsed.alertSubmitForm16RunAtTime.trim()) : DEFAULT_FORM16_CONFIG.alertSubmitForm16RunAtTime) : '') : DEFAULT_FORM16_CONFIG.alertSubmitForm16RunAtTime,
|
||||
alertSubmitForm16Template: typeof parsed.alertSubmitForm16Template === 'string' ? parsed.alertSubmitForm16Template : DEFAULT_FORM16_CONFIG.alertSubmitForm16Template,
|
||||
reminderNotificationEnabled: typeof parsed.reminderNotificationEnabled === 'boolean' ? parsed.reminderNotificationEnabled : DEFAULT_FORM16_CONFIG.reminderNotificationEnabled,
|
||||
reminderFrequencyDays: typeof parsed.reminderFrequencyDays === 'number' ? parsed.reminderFrequencyDays : DEFAULT_FORM16_CONFIG.reminderFrequencyDays,
|
||||
reminderFrequencyHours: typeof parsed.reminderFrequencyHours === 'number' ? parsed.reminderFrequencyHours : DEFAULT_FORM16_CONFIG.reminderFrequencyHours,
|
||||
reminderRunAtTime: typeof parsed.reminderRunAtTime === 'string' ? (parsed.reminderRunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.reminderRunAtTime.trim()) ? normalizeRunAtTime(parsed.reminderRunAtTime.trim()) : DEFAULT_FORM16_CONFIG.reminderRunAtTime) : '') : DEFAULT_FORM16_CONFIG.reminderRunAtTime,
|
||||
reminderNotificationTemplate: typeof parsed.reminderNotificationTemplate === 'string' ? parsed.reminderNotificationTemplate : DEFAULT_FORM16_CONFIG.reminderNotificationTemplate,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
res.json({ success: true, data: DEFAULT_FORM16_CONFIG });
|
||||
} catch (error: any) {
|
||||
logger.error('[Admin] Error fetching Form 16 config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch Form 16 configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Form 16 admin configuration
|
||||
*/
|
||||
export const putForm16Config = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
const body = sanitizeObject(req.body as Record<string, unknown>);
|
||||
const normalizeEmail = (e: unknown) => String(e ?? '').trim().toLowerCase();
|
||||
const submissionViewerEmails = Array.isArray(body.submissionViewerEmails)
|
||||
? body.submissionViewerEmails.map(normalizeEmail).filter(Boolean)
|
||||
: DEFAULT_FORM16_CONFIG.submissionViewerEmails;
|
||||
const twentySixAsViewerEmails = Array.isArray(body.twentySixAsViewerEmails)
|
||||
? body.twentySixAsViewerEmails.map(normalizeEmail).filter(Boolean)
|
||||
: DEFAULT_FORM16_CONFIG.twentySixAsViewerEmails;
|
||||
const reminderEnabled = typeof body.reminderEnabled === 'boolean' ? body.reminderEnabled : DEFAULT_FORM16_CONFIG.reminderEnabled;
|
||||
const reminderDays = typeof body.reminderDays === 'number' ? body.reminderDays : DEFAULT_FORM16_CONFIG.reminderDays;
|
||||
const mergeNotif = (key: keyof typeof DEFAULT_FORM16_CONFIG) => {
|
||||
const v = body[key];
|
||||
const d = DEFAULT_FORM16_CONFIG[key] as any;
|
||||
if (v && typeof v === 'object' && typeof (v as any).enabled === 'boolean') {
|
||||
return { enabled: (v as any).enabled, template: typeof (v as any).template === 'string' ? (v as any).template : (d?.template ?? '') };
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const d26As = DEFAULT_FORM16_CONFIG.notification26AsDataAdded as { enabled: boolean; templateRe: string; templateDealers: string };
|
||||
const merge26As = () => {
|
||||
const v = body.notification26AsDataAdded;
|
||||
if (v && typeof v === 'object' && typeof (v as any).enabled === 'boolean') {
|
||||
return {
|
||||
enabled: (v as any).enabled,
|
||||
templateRe: typeof (v as any).templateRe === 'string' ? (v as any).templateRe : d26As.templateRe,
|
||||
templateDealers: typeof (v as any).templateDealers === 'string' ? (v as any).templateDealers : d26As.templateDealers,
|
||||
};
|
||||
}
|
||||
return d26As;
|
||||
};
|
||||
const configValue = JSON.stringify({
|
||||
submissionViewerEmails,
|
||||
twentySixAsViewerEmails,
|
||||
reminderEnabled,
|
||||
reminderDays,
|
||||
notification26AsDataAdded: merge26As(),
|
||||
notificationForm16SuccessCreditNote: mergeNotif('notificationForm16SuccessCreditNote'),
|
||||
notificationForm16Unsuccessful: mergeNotif('notificationForm16Unsuccessful'),
|
||||
alertSubmitForm16Enabled: typeof body.alertSubmitForm16Enabled === 'boolean' ? body.alertSubmitForm16Enabled : DEFAULT_FORM16_CONFIG.alertSubmitForm16Enabled,
|
||||
alertSubmitForm16FrequencyDays: typeof body.alertSubmitForm16FrequencyDays === 'number' ? body.alertSubmitForm16FrequencyDays : DEFAULT_FORM16_CONFIG.alertSubmitForm16FrequencyDays,
|
||||
alertSubmitForm16FrequencyHours: typeof body.alertSubmitForm16FrequencyHours === 'number' ? body.alertSubmitForm16FrequencyHours : DEFAULT_FORM16_CONFIG.alertSubmitForm16FrequencyHours,
|
||||
alertSubmitForm16RunAtTime: typeof body.alertSubmitForm16RunAtTime === 'string' ? (String(body.alertSubmitForm16RunAtTime).trim() ? (/^\d{1,2}:\d{2}$/.test(String(body.alertSubmitForm16RunAtTime).trim()) ? normalizeRunAtTime(String(body.alertSubmitForm16RunAtTime).trim()) : DEFAULT_FORM16_CONFIG.alertSubmitForm16RunAtTime) : '') : DEFAULT_FORM16_CONFIG.alertSubmitForm16RunAtTime,
|
||||
alertSubmitForm16Template: typeof body.alertSubmitForm16Template === 'string' ? body.alertSubmitForm16Template : DEFAULT_FORM16_CONFIG.alertSubmitForm16Template,
|
||||
reminderNotificationEnabled: typeof body.reminderNotificationEnabled === 'boolean' ? body.reminderNotificationEnabled : DEFAULT_FORM16_CONFIG.reminderNotificationEnabled,
|
||||
reminderFrequencyDays: typeof body.reminderFrequencyDays === 'number' ? body.reminderFrequencyDays : DEFAULT_FORM16_CONFIG.reminderFrequencyDays,
|
||||
reminderFrequencyHours: typeof body.reminderFrequencyHours === 'number' ? body.reminderFrequencyHours : DEFAULT_FORM16_CONFIG.reminderFrequencyHours,
|
||||
reminderRunAtTime: typeof body.reminderRunAtTime === 'string' ? (String(body.reminderRunAtTime).trim() ? (/^\d{1,2}:\d{2}$/.test(String(body.reminderRunAtTime).trim()) ? normalizeRunAtTime(String(body.reminderRunAtTime).trim()) : DEFAULT_FORM16_CONFIG.reminderRunAtTime) : '') : DEFAULT_FORM16_CONFIG.reminderRunAtTime,
|
||||
reminderNotificationTemplate: typeof body.reminderNotificationTemplate === 'string' ? body.reminderNotificationTemplate : DEFAULT_FORM16_CONFIG.reminderNotificationTemplate,
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
`INSERT INTO admin_configurations (
|
||||
config_id, config_key, config_category, config_value, value_type, display_name, description, is_editable, is_sensitive, sort_order, created_at, updated_at, last_modified_by, last_modified_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), :configKey, 'SYSTEM_SETTINGS', :configValue, 'JSON', 'Form 16 Admin Config', 'Form 16 visibility and reminder settings', true, false, 0, NOW(), NOW(), :userId, NOW()
|
||||
)
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_value = EXCLUDED.config_value,
|
||||
last_modified_by = EXCLUDED.last_modified_by,
|
||||
last_modified_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
{
|
||||
replacements: { configKey: FORM16_CONFIG_KEY, configValue, userId },
|
||||
type: QueryTypes.RAW,
|
||||
}
|
||||
);
|
||||
clearConfigCache();
|
||||
logger.info('[Admin] Form 16 configuration updated');
|
||||
res.json({ success: true, message: 'Form 16 configuration saved' });
|
||||
} catch (error: any) {
|
||||
logger.error('[Admin] Error updating Form 16 config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to save Form 16 configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ============================================
|
||||
* USER ROLE MANAGEMENT (RBAC)
|
||||
@ -980,6 +1189,7 @@ export const createActivityType = async (req: Request, res: Response): Promise<v
|
||||
itemCode: itemCode || null,
|
||||
taxationType: taxationType || null,
|
||||
sapRefNo: sapRefNo || null,
|
||||
creditPostingOn: req.body.creditPostingOn || null,
|
||||
createdBy: userId
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { WorkflowRequest, ApprovalLevel, WorkNote, Document, Activity, ConclusionRemark } from '@models/index';
|
||||
import { isHtmlEmpty } from '../utils/sanitizer';
|
||||
import { aiService } from '@services/ai.service';
|
||||
import { activityService } from '@services/activity.service';
|
||||
import logger from '@utils/logger';
|
||||
@ -227,8 +228,8 @@ export class ConclusionController {
|
||||
const { finalRemark } = req.body;
|
||||
const userId = (req as any).user?.userId;
|
||||
|
||||
if (!finalRemark || typeof finalRemark !== 'string') {
|
||||
return res.status(400).json({ error: 'Final remark is required' });
|
||||
if (isHtmlEmpty(finalRemark)) {
|
||||
return res.status(400).json({ error: 'A valid final remark is required. Please ensure the remark contains valid content.' });
|
||||
}
|
||||
|
||||
// Fetch request
|
||||
|
||||
@ -12,11 +12,14 @@ import { sapIntegrationService } from '../services/sapIntegration.service';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||
import { ClaimInvoice } from '../models/ClaimInvoice';
|
||||
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
|
||||
import { ActivityType } from '../models/ActivityType';
|
||||
import { Participant } from '../models/Participant';
|
||||
import { sanitizeObject, sanitizePermissive } from '../utils/sanitizer';
|
||||
import { padDealerCode } from '../utils/helpers';
|
||||
import { costBreakupSchema, closedExpensesSchema, updateEInvoiceSchema, updateIOSchema } from '../validators/dealerClaim.validator';
|
||||
|
||||
export class DealerClaimController {
|
||||
private dealerClaimService = new DealerClaimService();
|
||||
@ -50,8 +53,25 @@ export class DealerClaimController {
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
if (!activityName || !activityType || !dealerCode || !dealerName || !location || !requestDescription) {
|
||||
return ResponseHandler.error(res, 'Missing required fields', 400);
|
||||
const requiredFields = [
|
||||
{ key: 'activityName', label: 'Activity Name' },
|
||||
{ key: 'activityType', label: 'Activity Type' },
|
||||
{ key: 'dealerCode', label: 'Dealer Code' },
|
||||
{ key: 'dealerName', label: 'Dealer Name' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'requestDescription', label: 'Request Description' },
|
||||
];
|
||||
|
||||
const missingFields = requiredFields
|
||||
.filter(field => !req.body[field.key])
|
||||
.map(field => field.label);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
`Required fields are missing or contain invalid content: ${missingFields.join(', ')}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const claimRequest = await this.dealerClaimService.createClaimRequest(userId, {
|
||||
@ -76,9 +96,16 @@ export class DealerClaimController {
|
||||
message: 'Claim request created successfully'
|
||||
}, 'Claim request created');
|
||||
} catch (error: any) {
|
||||
// Handle approver validation errors
|
||||
if (error.message && error.message.includes('Approver')) {
|
||||
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
|
||||
// Handle validation and business logic errors
|
||||
const isValidationError = error.message && (
|
||||
error.message.includes('Approver') ||
|
||||
error.message.includes('Valid content is required') ||
|
||||
error.message.includes('invalid script') ||
|
||||
error.message.includes('empty input detected')
|
||||
);
|
||||
|
||||
if (isValidationError) {
|
||||
logger.warn('[DealerClaimController] Validation error:', { message: error.message });
|
||||
return ResponseHandler.error(res, error.message, 400);
|
||||
}
|
||||
|
||||
@ -173,6 +200,8 @@ export class DealerClaimController {
|
||||
if (typeof costBreakup === 'string') {
|
||||
try {
|
||||
parsedCostBreakup = JSON.parse(costBreakup);
|
||||
// Sanitize cost items
|
||||
parsedCostBreakup = sanitizeObject(parsedCostBreakup);
|
||||
} catch (parseError) {
|
||||
logger.error('[DealerClaimController] Failed to parse costBreakup JSON:', parseError);
|
||||
return ResponseHandler.error(res, 'Invalid costBreakup format. Expected JSON array.', 400);
|
||||
@ -185,17 +214,15 @@ export class DealerClaimController {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate costBreakup is an array
|
||||
if (!Array.isArray(parsedCostBreakup)) {
|
||||
logger.error('[DealerClaimController] costBreakup is not an array after parsing:', parsedCostBreakup);
|
||||
return ResponseHandler.error(res, 'costBreakup must be an array of cost items', 400);
|
||||
}
|
||||
|
||||
// Validate each cost item has required fields
|
||||
for (const item of parsedCostBreakup) {
|
||||
if (!item.description || item.amount === undefined || item.amount === null) {
|
||||
return ResponseHandler.error(res, 'Each cost item must have description and amount', 400);
|
||||
}
|
||||
// Validate costBreakup array using Zod schema
|
||||
const costValidation = costBreakupSchema.safeParse(parsedCostBreakup);
|
||||
if (!costValidation.success) {
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
`Invalid cost breakup data: ${costValidation.error.errors.map((e: any) => e.message).join(', ')}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Handle file upload if present
|
||||
@ -232,7 +259,7 @@ export class DealerClaimController {
|
||||
timelineMode: timelineMode || 'date',
|
||||
expectedCompletionDate: expectedCompletionDate ? new Date(expectedCompletionDate) : undefined,
|
||||
expectedCompletionDays: expectedCompletionDays ? parseInt(expectedCompletionDays) : undefined,
|
||||
dealerComments: dealerComments || '',
|
||||
dealerComments: dealerComments ? sanitizePermissive(dealerComments) : '',
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, { message: 'Proposal submitted successfully' }, 'Proposal submitted');
|
||||
@ -264,12 +291,24 @@ export class DealerClaimController {
|
||||
if (closedExpenses) {
|
||||
try {
|
||||
parsedClosedExpenses = typeof closedExpenses === 'string' ? JSON.parse(closedExpenses) : closedExpenses;
|
||||
// Sanitize expenses
|
||||
parsedClosedExpenses = sanitizeObject(parsedClosedExpenses);
|
||||
} catch (e) {
|
||||
logger.warn('[DealerClaimController] Failed to parse closedExpenses JSON:', e);
|
||||
parsedClosedExpenses = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate closed expenses using Zod schema
|
||||
const expenseValidation = closedExpensesSchema.safeParse(parsedClosedExpenses);
|
||||
if (!expenseValidation.success) {
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
`Invalid closed expenses: ${expenseValidation.error.errors.map((e: any) => e.message).join(', ')}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Get files from multer
|
||||
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
|
||||
const completionDocumentsFiles = files?.completionDocuments || [];
|
||||
@ -547,7 +586,7 @@ export class DealerClaimController {
|
||||
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
||||
attendanceSheet: attendanceSheet || undefined,
|
||||
completionDescription: completionDescription || undefined,
|
||||
completionDescription: completionDescription ? sanitizePermissive(completionDescription) : undefined,
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
||||
@ -624,8 +663,14 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||
}
|
||||
|
||||
if (!ioNumber) {
|
||||
return ResponseHandler.error(res, 'IO number is required', 400);
|
||||
// Validate request body using Zod schema
|
||||
const ioValidation = updateIOSchema.safeParse(req.body);
|
||||
if (!ioValidation.success) {
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
`Invalid IO details: ${ioValidation.error.errors.map((e: any) => e.message).join(', ')}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
||||
@ -730,6 +775,16 @@ export class DealerClaimController {
|
||||
description,
|
||||
} = req.body;
|
||||
|
||||
// Validate request body using Zod schema
|
||||
const einvoiceValidation = updateEInvoiceSchema.safeParse(req.body);
|
||||
if (!einvoiceValidation.success) {
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
`Invalid e-invoice details: ${einvoiceValidation.error.errors.map((e: any) => e.message).join(', ')}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Find workflow to get actual UUID
|
||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||
if (!workflow) {
|
||||
@ -784,6 +839,20 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||
}
|
||||
|
||||
// Authorization Check
|
||||
const userRole = (req as any).user?.role;
|
||||
const userId = (req as any).user?.userId;
|
||||
|
||||
if (userRole !== 'ADMIN' && userRole !== 'MANAGEMENT') {
|
||||
const participant = await Participant.findOne({
|
||||
where: { requestId, userId, isActive: true }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
return ResponseHandler.error(res, 'Access denied. You are not a participant in this workflow.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
const { ClaimInvoice } = await import('../models/ClaimInvoice');
|
||||
let invoice = await ClaimInvoice.findOne({ where: { requestId } });
|
||||
|
||||
@ -1005,6 +1074,24 @@ export class DealerClaimController {
|
||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
|
||||
|
||||
if (!requestId) {
|
||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||
}
|
||||
|
||||
// Authorization Check
|
||||
const userRole = (req as any).user?.role;
|
||||
const userId = (req as any).user?.userId;
|
||||
|
||||
if (userRole !== 'ADMIN' && userRole !== 'MANAGEMENT') {
|
||||
const participant = await Participant.findOne({
|
||||
where: { requestId, userId, isActive: true }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
return ResponseHandler.error(res, 'Access denied. You are not a participant in this workflow.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch related data
|
||||
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
|
||||
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
|
||||
@ -1024,7 +1111,9 @@ export class DealerClaimController {
|
||||
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
||||
}
|
||||
|
||||
// Construct CSV
|
||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
|
||||
// Construct CSV with pipe separator
|
||||
const headers = [
|
||||
'TRNS_UNIQ_NO',
|
||||
'CLAIM_NUMBER',
|
||||
@ -1032,44 +1121,48 @@ export class DealerClaimController {
|
||||
'DEALER_CODE',
|
||||
'IO_NUMBER',
|
||||
'CLAIM_DOC_TYP',
|
||||
'CLAIM_TYPE',
|
||||
'CLAIM_DATE',
|
||||
'CLAIM_AMT',
|
||||
'GST_AMT',
|
||||
'GST_PERCENTAG'
|
||||
'CLAIM_AMT'
|
||||
];
|
||||
|
||||
const rows = items.map(item => {
|
||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
if (!isNonGst) {
|
||||
headers.push('GST_AMT', 'GST_PERCENTAGE');
|
||||
}
|
||||
|
||||
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
|
||||
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
|
||||
const rows = items.map(item => {
|
||||
// For Non-GST, we hide HSN (often stored in transactionCode)
|
||||
const trnsUniqNo = item.transactionCode || '';
|
||||
const claimNumber = requestNumber;
|
||||
const invNumber = invoice?.invoiceNumber || '';
|
||||
const dealerCode = claimDetails?.dealerCode || '';
|
||||
const dealerCode = padDealerCode(claimDetails?.dealerCode || '');
|
||||
const ioNumber = internalOrder?.ioNumber || '';
|
||||
const claimDocTyp = sapRefNo;
|
||||
const claimType = claimDetails?.activityType || '';
|
||||
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
|
||||
const claimAmt = item.assAmt;
|
||||
|
||||
// Zero out tax for Non-GST
|
||||
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
|
||||
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
|
||||
|
||||
return [
|
||||
const rowItems = [
|
||||
trnsUniqNo,
|
||||
claimNumber,
|
||||
invNumber,
|
||||
dealerCode,
|
||||
ioNumber,
|
||||
claimDocTyp,
|
||||
claimType,
|
||||
claimDate,
|
||||
claimAmt,
|
||||
totalTax.toFixed(2),
|
||||
gstPercentag
|
||||
].join(',');
|
||||
claimAmt
|
||||
];
|
||||
|
||||
if (!isNonGst) {
|
||||
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
|
||||
rowItems.push(totalTax.toFixed(2), item.gstRt || 0);
|
||||
}
|
||||
|
||||
return rowItems.join('|');
|
||||
});
|
||||
|
||||
const csvContent = [headers.join(','), ...rows].join('\n');
|
||||
const csvContent = [headers.join('|'), ...rows].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
|
||||
@ -1090,16 +1183,16 @@ export class DealerClaimController {
|
||||
async retriggerWFMPush(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { requestId: identifier } = req.params;
|
||||
|
||||
|
||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||
if (!workflow) {
|
||||
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||
}
|
||||
|
||||
const requestId = (workflow as any).id || (workflow as any).requestId;
|
||||
|
||||
|
||||
await this.dealerClaimService.pushWFMCSV(requestId);
|
||||
|
||||
|
||||
return ResponseHandler.success(res, {
|
||||
message: 'WFM CSV push re-triggered successfully'
|
||||
}, 'WFM push re-triggered');
|
||||
@ -1109,4 +1202,43 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Failed to re-trigger WFM push', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parsed WFM credit note CSV from outgoing folder
|
||||
* GET /api/v1/dealer-claims/:requestId/credit-note-wfm
|
||||
*/
|
||||
async fetchCreditNoteWfm(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const identifier = req.params.requestId;
|
||||
|
||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
||||
if (!workflow) {
|
||||
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
||||
}
|
||||
|
||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
||||
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
|
||||
|
||||
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
|
||||
if (!claimDetails) {
|
||||
return ResponseHandler.error(res, 'Dealer claim details not found', 404);
|
||||
}
|
||||
|
||||
let isNonGst = false;
|
||||
if (claimDetails.activityType) {
|
||||
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
|
||||
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
||||
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||
}
|
||||
|
||||
const { wfmFileService } = await import('../services/wfmFile.service');
|
||||
const creditNoteData = await wfmFileService.getCreditNoteDetails(claimDetails.dealerCode, requestNumber, isNonGst);
|
||||
|
||||
return ResponseHandler.success(res, creditNoteData, 'Credit note data fetched successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerClaimController] Error fetching credit note WFM data:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note CSV data', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { User } from '@models/User';
|
||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||
import { Participant } from '@models/Participant';
|
||||
import { ApprovalLevel } from '@models/ApprovalLevel';
|
||||
import { WorkNote } from '@models/WorkNote';
|
||||
import { WorkNoteAttachment } from '@models/WorkNoteAttachment';
|
||||
import { Op } from 'sequelize';
|
||||
import { ResponseHandler } from '@utils/responseHandler';
|
||||
import { activityService } from '@services/activity.service';
|
||||
@ -17,6 +19,9 @@ import type { AuthenticatedRequest } from '../types/express';
|
||||
import { getRequestMetadata } from '@utils/requestUtils';
|
||||
import { getConfigNumber, getConfigValue } from '@services/configReader.service';
|
||||
import { logDocumentEvent, logWithContext } from '@utils/logger';
|
||||
import { UPLOAD_DIR } from '../config/storage';
|
||||
import { Storage } from '@google-cloud/storage';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
export class DocumentController {
|
||||
async upload(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
@ -517,6 +522,196 @@ export class DocumentController {
|
||||
ResponseHandler.error(res, 'Upload failed', 500, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create proper Content-Disposition header
|
||||
*/
|
||||
private createContentDisposition(disposition: 'inline' | 'attachment', filename: string): string {
|
||||
const cleanFilename = filename
|
||||
.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_')
|
||||
.replace(/\\/g, '_')
|
||||
.trim();
|
||||
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||
|
||||
if (hasNonASCII) {
|
||||
const encodedFilename = encodeURIComponent(filename);
|
||||
return `${disposition}; filename="${cleanFilename}"; filename*=UTF-8''${encodedFilename}`;
|
||||
} else {
|
||||
return `${disposition}; filename="${cleanFilename}"`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview or Download a standard workflow document
|
||||
*/
|
||||
async getWorkflowDocument(req: AuthenticatedRequest, res: Response, mode: 'preview' | 'download'): Promise<void> {
|
||||
try {
|
||||
const { documentId } = req.params;
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
const document = await Document.findOne({ where: { documentId } });
|
||||
if (!document) {
|
||||
ResponseHandler.error(res, 'Document not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorization Check
|
||||
if (userRole !== 'ADMIN' && userRole !== 'MANAGEMENT') {
|
||||
const participant = await Participant.findOne({
|
||||
where: { requestId: document.requestId, userId, isActive: true }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
ResponseHandler.error(res, 'Access denied. You are not a participant in this workflow.', 403);
|
||||
return;
|
||||
}
|
||||
|
||||
const canAccess = mode === 'download' ? participant.canDownloadDocuments : participant.canViewDocuments;
|
||||
if (!canAccess) {
|
||||
ResponseHandler.error(res, `Access denied. You do not have permission to ${mode} documents in this workflow.`, 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return this.serveFile(res, {
|
||||
storageUrl: (document as any).storageUrl || (document as any).storage_url,
|
||||
filePath: (document as any).filePath || (document as any).file_path,
|
||||
fileName: (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName,
|
||||
mimeType: (document as any).mimeType || (document as any).mime_type,
|
||||
mode
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[DocumentController] Error getting workflow document:`, error);
|
||||
ResponseHandler.error(res, 'Failed to access document', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview or Download a work note attachment
|
||||
*/
|
||||
async getWorkNoteAttachment(req: AuthenticatedRequest, res: Response, mode: 'preview' | 'download'): Promise<void> {
|
||||
try {
|
||||
const { attachmentId } = req.params;
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
const attachment = await WorkNoteAttachment.findOne({ where: { attachmentId } });
|
||||
if (!attachment) {
|
||||
ResponseHandler.error(res, 'Attachment not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await WorkNote.findOne({ where: { noteId: attachment.noteId } });
|
||||
if (!note) {
|
||||
ResponseHandler.error(res, 'Associated work note not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorization Check (Work note attachments follow general document permissions)
|
||||
if (userRole !== 'ADMIN' && userRole !== 'MANAGEMENT') {
|
||||
const participant = await Participant.findOne({
|
||||
where: { requestId: note.requestId, userId, isActive: true }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
ResponseHandler.error(res, 'Access denied. You are not a participant in this workflow.', 403);
|
||||
return;
|
||||
}
|
||||
|
||||
const canAccess = mode === 'download' ? participant.canDownloadDocuments : participant.canViewDocuments;
|
||||
if (!canAccess) {
|
||||
ResponseHandler.error(res, `Access denied. You do not have permission to ${mode} documentation in this workflow.`, 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return this.serveFile(res, {
|
||||
storageUrl: (attachment as any).storageUrl || (attachment as any).storage_url,
|
||||
filePath: (attachment as any).filePath || (attachment as any).file_path,
|
||||
fileName: (attachment as any).fileName || (attachment as any).file_name,
|
||||
mimeType: (attachment as any).fileType || (attachment as any).file_type,
|
||||
mode
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[DocumentController] Error getting work note attachment:`, error);
|
||||
ResponseHandler.error(res, 'Failed to access attachment', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common logic to serve files from GCS or local storage
|
||||
*/
|
||||
private async serveFile(res: Response, options: {
|
||||
storageUrl?: string,
|
||||
filePath?: string,
|
||||
fileName: string,
|
||||
mimeType?: string,
|
||||
mode: 'preview' | 'download'
|
||||
}): Promise<void> {
|
||||
const { storageUrl, filePath, fileName, mimeType, mode } = options;
|
||||
const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://'));
|
||||
|
||||
// Set CORS and basic headers
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
const dispositionType = mode === 'download' ? 'attachment' : (mimeType?.includes('pdf') || mimeType?.includes('image') ? 'inline' : 'attachment');
|
||||
res.setHeader('Content-Disposition', this.createContentDisposition(dispositionType, fileName));
|
||||
res.contentType(mimeType || 'application/octet-stream');
|
||||
|
||||
if (isGcsUrl) {
|
||||
res.redirect(storageUrl!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream from GCS if filePath is a GCS path
|
||||
if (!storageUrl && filePath && (filePath.startsWith('requests/') || filePath.startsWith('worknotes/'))) {
|
||||
try {
|
||||
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||
const bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||
const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath);
|
||||
|
||||
const storage = new Storage({
|
||||
projectId: process.env.GCP_PROJECT_ID || '',
|
||||
keyFilename: resolvedKeyPath,
|
||||
});
|
||||
|
||||
const bucket = storage.bucket(bucketName);
|
||||
const file = bucket.file(filePath);
|
||||
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
ResponseHandler.error(res, 'File not found in storage', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
file.createReadStream()
|
||||
.on('error', (err) => {
|
||||
logger.error('[DocumentController] GCS Stream Error:', err);
|
||||
if (!res.headersSent) ResponseHandler.error(res, 'Streaming failed', 500);
|
||||
})
|
||||
.pipe(res);
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.error('[DocumentController] GCS Access Error:', err);
|
||||
ResponseHandler.error(res, 'Failed to access cloud storage', 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local file handling
|
||||
const absolutePath = filePath && !path.isAbsolute(filePath) ? path.join(UPLOAD_DIR, filePath) : filePath;
|
||||
if (absolutePath && fs.existsSync(absolutePath)) {
|
||||
res.sendFile(absolutePath, (err) => {
|
||||
if (err && !res.headersSent) ResponseHandler.error(res, 'Failed to send file', 500);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ResponseHandler.error(res, 'File not found on server', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export const documentController = new DocumentController();
|
||||
|
||||
|
||||
|
||||
825
src/controllers/form16.controller.ts
Normal file
825
src/controllers/form16.controller.ts
Normal file
@ -0,0 +1,825 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { AuthenticatedRequest } from '../types/express';
|
||||
import * as form16Service from '../services/form16.service';
|
||||
import {
|
||||
simulateCreditNoteFromSap,
|
||||
simulateDebitNoteFromSap,
|
||||
type SapCreditNoteDealerDetails,
|
||||
type SapDebitNoteDealerInfo,
|
||||
} from '../services/form16SapSimulation.service';
|
||||
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.
|
||||
*/
|
||||
|
||||
export class Form16Controller {
|
||||
/**
|
||||
* GET /api/v1/form16/permissions
|
||||
* Returns Form 16 permissions for the current user (API-driven from admin config).
|
||||
*/
|
||||
async getPermissions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user;
|
||||
if (!user?.userId || !user?.email) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const role = (user as any).role;
|
||||
const [canViewForm16SubmissionData, canView26AS] = await Promise.all([
|
||||
canViewForm16Submission(user.email, user.userId, role),
|
||||
canView26As(user.email, role),
|
||||
]);
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ canViewForm16Submission: canViewForm16SubmissionData, canView26AS },
|
||||
'Form 16 permissions'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] getPermissions error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to get Form 16 permissions', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/credit-notes
|
||||
* Dealer: list credit notes for the authenticated dealer.
|
||||
* RE: list all credit notes (all dealers).
|
||||
*/
|
||||
async listCreditNotes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
|
||||
const financialYear = req.query.financialYear as string | undefined;
|
||||
const quarter = req.query.quarter as string | undefined;
|
||||
|
||||
const result = await form16Service.listCreditNotesDealerOrRe(userId, {
|
||||
financialYear,
|
||||
quarter,
|
||||
});
|
||||
|
||||
const payload: { creditNotes: typeof result.rows; total: number; summary?: typeof result.summary } = {
|
||||
creditNotes: result.rows,
|
||||
total: result.total,
|
||||
};
|
||||
if ((result as any).summary) payload.summary = (result as any).summary;
|
||||
return ResponseHandler.success(res, payload, 'Credit notes fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] listCreditNotes error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit notes', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Query: status (optional) pending | failed | pending,failed; financialYear; quarter.
|
||||
*/
|
||||
async listDealerSubmissions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const status = req.query.status as string | undefined;
|
||||
const financialYear = req.query.financialYear as string | undefined;
|
||||
const quarter = req.query.quarter as string | undefined;
|
||||
const list = await form16Service.listDealerSubmissions(userId, { status, financialYear, quarter });
|
||||
return ResponseHandler.success(res, list, 'Dealer submissions fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] listDealerSubmissions error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch dealer submissions', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/dealer/pending-quarters
|
||||
* Dealer only. List quarters for which Form 16A has not been completed (no credit note).
|
||||
*/
|
||||
async listDealerPendingQuarters(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const list = await form16Service.listDealerPendingQuarters(userId);
|
||||
return ResponseHandler.success(res, list, 'Pending quarters fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] listDealerPendingQuarters error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch pending quarters', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/non-submitted-dealers
|
||||
* RE only. List dealers with missing Form 16A submissions for the given financial year (optional; defaults to current FY).
|
||||
* Query: financialYear (optional) e.g. "2024-25"; omit or "" for default/current FY.
|
||||
*/
|
||||
async listNonSubmittedDealers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const financialYear = (req.query.financialYear as string)?.trim() || undefined;
|
||||
const result = await form16Service.listNonSubmittedDealers(financialYear);
|
||||
return ResponseHandler.success(res, result, 'Non-submitted dealers fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] listNonSubmittedDealers error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch non-submitted dealers', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/non-submitted-dealers/notify
|
||||
* RE only. Send "submit Form 16" notification to one non-submitted dealer and record it (last notified column updates).
|
||||
* Body: { dealerCode: string, financialYear?: string }.
|
||||
*/
|
||||
async notifyNonSubmittedDealer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const body = (req.body || {}) as { dealerCode?: string; financialYear?: string };
|
||||
const dealerCode = (body.dealerCode || '').trim();
|
||||
if (!dealerCode) {
|
||||
return ResponseHandler.error(res, 'dealerCode is required', 400);
|
||||
}
|
||||
const financialYear = (body.financialYear || '').trim() || undefined;
|
||||
const updated = await form16Service.recordNonSubmittedDealerNotification(dealerCode, financialYear || '', userId);
|
||||
if (!updated) {
|
||||
return ResponseHandler.error(res, 'Dealer not found in non-submitted list for this financial year', 404);
|
||||
}
|
||||
return ResponseHandler.success(res, { dealer: updated }, 'Notification sent');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] notifyNonSubmittedDealer error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to send notification', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/26as
|
||||
* RE only. List 26AS TDS entries with optional filters, pagination, and summary.
|
||||
*/
|
||||
async list26as(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const financialYear = req.query.financialYear as string | undefined;
|
||||
const quarter = req.query.quarter as string | undefined;
|
||||
const tanNumber = req.query.tanNumber as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const assessmentYear = req.query.assessmentYear as string | undefined;
|
||||
const sectionCode = req.query.sectionCode as string | undefined;
|
||||
const limit = req.query.limit != null ? parseInt(String(req.query.limit), 10) : undefined;
|
||||
const offset = req.query.offset != null ? parseInt(String(req.query.offset), 10) : undefined;
|
||||
const result = await form16Service.list26asEntries({
|
||||
financialYear,
|
||||
quarter,
|
||||
tanNumber,
|
||||
search,
|
||||
status,
|
||||
assessmentYear,
|
||||
sectionCode,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ entries: result.rows, total: result.total, summary: result.summary },
|
||||
'26AS entries fetched'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] list26as error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch 26AS entries', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/26as
|
||||
* RE only. Create a 26AS TDS entry.
|
||||
*/
|
||||
async create26as(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const tanNumber = (body.tanNumber as string)?.trim();
|
||||
const financialYear = (body.financialYear as string)?.trim();
|
||||
const quarter = (body.quarter as string)?.trim();
|
||||
if (!tanNumber || !financialYear || !quarter) {
|
||||
return ResponseHandler.error(res, 'tanNumber, financialYear and quarter are required', 400);
|
||||
}
|
||||
const taxDeducted = typeof body.taxDeducted === 'number' ? body.taxDeducted : parseFloat(String(body.taxDeducted ?? 0));
|
||||
const entry = await form16Service.create26asEntry({
|
||||
tanNumber,
|
||||
deductorName: (body.deductorName as string) || undefined,
|
||||
quarter,
|
||||
assessmentYear: (body.assessmentYear as string) || undefined,
|
||||
financialYear,
|
||||
sectionCode: (body.sectionCode as string) || undefined,
|
||||
amountPaid: typeof body.amountPaid === 'number' ? body.amountPaid : (body.amountPaid ? parseFloat(String(body.amountPaid)) : undefined),
|
||||
taxDeducted: Number.isNaN(taxDeducted) ? 0 : taxDeducted,
|
||||
totalTdsDeposited: typeof body.totalTdsDeposited === 'number' ? body.totalTdsDeposited : (body.totalTdsDeposited ? parseFloat(String(body.totalTdsDeposited)) : undefined),
|
||||
natureOfPayment: (body.natureOfPayment as string) || undefined,
|
||||
transactionDate: (body.transactionDate as string) || undefined,
|
||||
dateOfBooking: (body.dateOfBooking as string) || undefined,
|
||||
statusOltas: (body.statusOltas as string) || undefined,
|
||||
remarks: (body.remarks as string) || undefined,
|
||||
});
|
||||
return ResponseHandler.success(res, { entry }, '26AS entry created');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] create26as error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to create 26AS entry', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/form16/26as/:id
|
||||
* RE only. Update a 26AS TDS entry.
|
||||
*/
|
||||
async update26as(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 entry id', 400);
|
||||
}
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
|
||||
if (body.deductorName !== undefined) updateData.deductorName = body.deductorName;
|
||||
if (body.quarter !== undefined) updateData.quarter = body.quarter;
|
||||
if (body.assessmentYear !== undefined) updateData.assessmentYear = body.assessmentYear;
|
||||
if (body.financialYear !== undefined) updateData.financialYear = body.financialYear;
|
||||
if (body.sectionCode !== undefined) updateData.sectionCode = body.sectionCode;
|
||||
if (body.amountPaid !== undefined) updateData.amountPaid = body.amountPaid;
|
||||
if (body.taxDeducted !== undefined) updateData.taxDeducted = body.taxDeducted;
|
||||
if (body.totalTdsDeposited !== undefined) updateData.totalTdsDeposited = body.totalTdsDeposited;
|
||||
if (body.natureOfPayment !== undefined) updateData.natureOfPayment = body.natureOfPayment;
|
||||
if (body.transactionDate !== undefined) updateData.transactionDate = body.transactionDate;
|
||||
if (body.dateOfBooking !== undefined) updateData.dateOfBooking = body.dateOfBooking;
|
||||
if (body.statusOltas !== undefined) updateData.statusOltas = body.statusOltas;
|
||||
if (body.remarks !== undefined) updateData.remarks = body.remarks;
|
||||
const entry = await form16Service.update26asEntry(id, updateData as any);
|
||||
if (!entry) {
|
||||
return ResponseHandler.error(res, '26AS entry not found', 404);
|
||||
}
|
||||
return ResponseHandler.success(res, { entry }, '26AS entry updated');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] update26as error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to update 26AS entry', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/form16/26as/:id
|
||||
* RE only. Delete a 26AS TDS entry.
|
||||
*/
|
||||
async delete26as(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 entry id', 400);
|
||||
}
|
||||
const deleted = await form16Service.delete26asEntry(id);
|
||||
if (!deleted) {
|
||||
return ResponseHandler.error(res, '26AS entry not found', 404);
|
||||
}
|
||||
return ResponseHandler.success(res, {}, '26AS entry deleted');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] delete26as error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to delete 26AS entry', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/credit-notes/:id
|
||||
* Get a single credit note by id with dealer info and dealer transaction history.
|
||||
*/
|
||||
async getCreditNoteById(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 credit note id', 400);
|
||||
}
|
||||
const result = await form16Service.getCreditNoteById(id);
|
||||
if (!result) {
|
||||
return ResponseHandler.error(res, 'Credit note not found', 404);
|
||||
}
|
||||
return ResponseHandler.success(res, result, 'Credit note fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] getCreditNoteById error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async getCreditNoteByRequest(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const requestId = (req.params as { requestId: string }).requestId;
|
||||
if (!requestId) {
|
||||
return ResponseHandler.error(res, 'requestId required', 400);
|
||||
}
|
||||
const note = await form16Service.getCreditNoteByRequestId(requestId);
|
||||
return ResponseHandler.success(res, { creditNote: note }, note ? 'Credit note fetched' : 'No credit note for this request');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] getCreditNoteByRequest error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch credit note', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/requests/:requestId/cancel-submission
|
||||
* RE only. Cancel the Form 16 submission and mark workflow as rejected.
|
||||
*/
|
||||
async cancelForm16Submission(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
const requestId = (req.params as { requestId: string }).requestId;
|
||||
if (!requestId) return ResponseHandler.error(res, 'Request ID required', 400);
|
||||
const result = await form16Service.cancelForm16Submission(requestId, userId);
|
||||
const { triggerForm16UnsuccessfulByRequestId } = await import('../services/form16Notification.service');
|
||||
triggerForm16UnsuccessfulByRequestId(requestId, 'Your Form 16 submission was cancelled by RE. No credit note will be issued.').catch((err) =>
|
||||
logger.error('[Form16Controller] Cancel notification failed:', err)
|
||||
);
|
||||
return ResponseHandler.success(res, result, 'Submission cancelled');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] cancelForm16Submission error:', error);
|
||||
return ResponseHandler.error(res, errorMessage, 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/requests/:requestId/resubmission-needed
|
||||
* RE only. Mark Form 16 submission as resubmission needed (e.g. partial OCR).
|
||||
*/
|
||||
async setForm16ResubmissionNeeded(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
const requestId = (req.params as { requestId: string }).requestId;
|
||||
if (!requestId) return ResponseHandler.error(res, 'Request ID required', 400);
|
||||
const result = await form16Service.setForm16ResubmissionNeeded(requestId, userId);
|
||||
const { triggerForm16UnsuccessfulByRequestId } = await import('../services/form16Notification.service');
|
||||
triggerForm16UnsuccessfulByRequestId(requestId, 'RE has marked this submission as resubmission needed. Please resubmit Form 16.').catch((err) =>
|
||||
logger.error('[Form16Controller] Resubmission-needed notification failed:', err)
|
||||
);
|
||||
return ResponseHandler.success(res, result, 'Marked as resubmission needed');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] setForm16ResubmissionNeeded error:', error);
|
||||
return ResponseHandler.error(res, errorMessage, 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/sap-simulate/credit-note
|
||||
* Form 16 only. Simulate SAP credit note generation (dealer details + amount → JSON response).
|
||||
* When real SAP is integrated, replace the simulation service with the actual SAP API call.
|
||||
*/
|
||||
async sapSimulateCreditNote(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const body = (req.body || {}) as { dealerDetails?: SapCreditNoteDealerDetails; amount?: number };
|
||||
const dealerDetails = body.dealerDetails as SapCreditNoteDealerDetails | undefined;
|
||||
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
|
||||
if (!dealerDetails?.dealerCode || amount <= 0) {
|
||||
return ResponseHandler.error(res, 'dealerDetails.dealerCode and a positive amount are required', 400);
|
||||
}
|
||||
const response = simulateCreditNoteFromSap(dealerDetails, amount);
|
||||
return ResponseHandler.success(res, response, 'Simulated SAP credit note');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] sapSimulateCreditNote error:', error);
|
||||
return ResponseHandler.error(res, 'SAP credit note simulation failed', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/sap-simulate/debit-note
|
||||
* Form 16 only. Simulate SAP debit note generation (dealer code, dealer info, credit note number, amount → JSON response).
|
||||
* When real SAP is integrated, replace the simulation service with the actual SAP API call.
|
||||
*/
|
||||
async sapSimulateDebitNote(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const body = (req.body || {}) as {
|
||||
dealerCode?: string;
|
||||
dealerInfo?: SapDebitNoteDealerInfo;
|
||||
creditNoteNumber?: string;
|
||||
amount?: number;
|
||||
};
|
||||
const dealerCode = (body.dealerCode || '').trim();
|
||||
const dealerInfo = body.dealerInfo || { dealerCode };
|
||||
const creditNoteNumber = (body.creditNoteNumber || '').trim();
|
||||
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
|
||||
if (!dealerCode || !creditNoteNumber || amount <= 0) {
|
||||
return ResponseHandler.error(res, 'dealerCode, creditNoteNumber and a positive amount are required', 400);
|
||||
}
|
||||
const response = simulateDebitNoteFromSap({ dealerCode, dealerInfo, creditNoteNumber, amount });
|
||||
return ResponseHandler.success(res, response, 'Simulated SAP debit note');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] sapSimulateDebitNote error:', error);
|
||||
return ResponseHandler.error(res, 'SAP debit note simulation failed', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/26as/upload
|
||||
* RE only. Upload a single TXT file containing 26AS data (all dealers). Data stored in tds_26as_entries.
|
||||
* Only Section 194Q and Booking Status F/O are used for quarter aggregation. Log created first; then aggregation and auto-debit (if 26AS total changed and quarter was settled).
|
||||
*/
|
||||
async upload26as(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
const file = (req as any).file;
|
||||
if (!file || !file.buffer) {
|
||||
return ResponseHandler.error(res, 'No file uploaded. Please upload a .txt file.', 400);
|
||||
}
|
||||
if (!userId) {
|
||||
return ResponseHandler.error(res, 'Authentication required', 401);
|
||||
}
|
||||
const log = await form16Service.log26asUpload(userId, {
|
||||
fileName: file.originalname || undefined,
|
||||
recordsImported: 0,
|
||||
errorsCount: 0,
|
||||
});
|
||||
const result = await form16Service.upload26asFile(file.buffer, (log as any).id);
|
||||
await (log as any).update({
|
||||
recordsImported: result.imported,
|
||||
errorsCount: result.errors.length,
|
||||
});
|
||||
if (result.imported > 0) {
|
||||
const agg = await form16Service.process26asUploadAggregation((log as any).id);
|
||||
logger.info(`[Form16] 26AS upload aggregation: ${agg.snapshotsCreated} snapshot(s), ${agg.debitsCreated} debit(s) created.`);
|
||||
}
|
||||
if (result.imported > 0) {
|
||||
const { trigger26AsDataAddedNotification } = await import('../services/form16Notification.service');
|
||||
trigger26AsDataAddedNotification().catch((err) =>
|
||||
logger.error('[Form16Controller] 26AS notification trigger failed:', err)
|
||||
);
|
||||
}
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ imported: result.imported, errors: result.errors },
|
||||
result.imported > 0
|
||||
? `26AS data uploaded: ${result.imported} record(s) imported.`
|
||||
: 'No records imported.'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] upload26as error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to upload 26AS file', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/form16/26as/upload-history
|
||||
* RE only. List 26AS upload audit log (who uploaded, when, records imported) for management section.
|
||||
*/
|
||||
async get26asUploadHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10), 1), 200);
|
||||
const history = await form16Service.list26asUploadHistory(limit);
|
||||
return ResponseHandler.success(res, { history }, '26AS upload history fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] get26asUploadHistory error:', error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch 26AS upload history', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/extract
|
||||
* Exact REform16 pattern: req.file.path from multer disk storage, ocrService.extractForm16ADetails, same response shape.
|
||||
*/
|
||||
async extractOcr(req: Request, res: Response): Promise<void> {
|
||||
const file = (req as any).file;
|
||||
if (!file) {
|
||||
return ResponseHandler.error(res, 'No file uploaded', 400);
|
||||
}
|
||||
const filePath = file.path as string;
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return ResponseHandler.error(res, 'No file uploaded', 400);
|
||||
}
|
||||
try {
|
||||
logger.info('[Form16Controller] Performing OCR extraction for preview...');
|
||||
const ocrResult = await extractForm16ADetails(filePath);
|
||||
if (ocrResult.success) {
|
||||
logger.info('[Form16Controller] OCR extraction successful:', ocrResult.method || 'gemini');
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{
|
||||
extractedData: ocrResult.data,
|
||||
ocrMethod: ocrResult.method || 'gemini',
|
||||
ocrProvider: ocrResult.ocrProvider || 'Google Gemini API',
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
url: `/uploads/form16-extract/${file.filename}`,
|
||||
},
|
||||
'OCR extraction completed successfully'
|
||||
);
|
||||
}
|
||||
logger.error('[Form16Controller] OCR extraction failed:', ocrResult.error);
|
||||
return ResponseHandler.error(
|
||||
res,
|
||||
ocrResult.message || 'Failed to extract data from PDF',
|
||||
400,
|
||||
ocrResult.error
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error('[Form16Controller] extractOcr error:', error);
|
||||
return ResponseHandler.error(res, 'OCR extraction failed', 500, error?.message);
|
||||
} finally {
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (e) {
|
||||
logger.warn('[Form16Controller] Failed to delete temp file:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/submissions
|
||||
* Create Form 16 submission: workflow_request + form16a_submissions, upload document.
|
||||
*/
|
||||
async createSubmission(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
|
||||
const file = (req as any).file;
|
||||
if (!file || !file.buffer) {
|
||||
return ResponseHandler.error(res, 'No file uploaded. Please upload Form 16A PDF.', 400);
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, string>;
|
||||
const dealerCode = (body.dealerCode || '').trim(); // optional: required when user is not mapped as dealer
|
||||
const financialYear = (body.financialYear || '').trim();
|
||||
const quarter = (body.quarter || '').trim();
|
||||
const form16aNumber = (body.form16aNumber || '').trim();
|
||||
const tdsAmount = parseFloat(body.tdsAmount);
|
||||
const totalAmount = parseFloat(body.totalAmount);
|
||||
const tanNumber = (body.tanNumber || '').trim();
|
||||
const deductorName = (body.deductorName || '').trim();
|
||||
const version = body.version ? parseInt(body.version, 10) : 1;
|
||||
let ocrExtractedData: Record<string, unknown> | undefined;
|
||||
if (body.ocrExtractedData && typeof body.ocrExtractedData === 'string') {
|
||||
try {
|
||||
ocrExtractedData = JSON.parse(body.ocrExtractedData) as Record<string, unknown>;
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
if (!financialYear || !quarter || !form16aNumber || !tanNumber || !deductorName) {
|
||||
return ResponseHandler.error(res, 'Missing required fields: financialYear, quarter, form16aNumber, tanNumber, deductorName', 400);
|
||||
}
|
||||
if (Number.isNaN(tdsAmount) || Number.isNaN(totalAmount) || tdsAmount < 0 || totalAmount < 0) {
|
||||
return ResponseHandler.error(res, 'Valid tdsAmount and totalAmount (numbers >= 0) are required', 400);
|
||||
}
|
||||
|
||||
const result = await form16Service.createSubmission(
|
||||
userId,
|
||||
file.buffer,
|
||||
file.originalname || 'form16a.pdf',
|
||||
{
|
||||
dealerCode: dealerCode || undefined,
|
||||
financialYear,
|
||||
quarter,
|
||||
form16aNumber,
|
||||
tdsAmount,
|
||||
totalAmount,
|
||||
tanNumber,
|
||||
deductorName,
|
||||
version,
|
||||
ocrExtractedData,
|
||||
}
|
||||
);
|
||||
|
||||
const { triggerForm16SubmissionResultNotification } = await import('../services/form16Notification.service');
|
||||
triggerForm16SubmissionResultNotification(userId, result.validationStatus, {
|
||||
creditNoteNumber: result.creditNoteNumber ?? undefined,
|
||||
requestId: result.requestId,
|
||||
validationNotes: result.validationNotes,
|
||||
}).catch((err) => logger.error('[Form16Controller] Form 16 result notification failed:', err));
|
||||
|
||||
return ResponseHandler.success(res, {
|
||||
requestId: result.requestId,
|
||||
requestNumber: result.requestNumber,
|
||||
submissionId: result.submissionId,
|
||||
validationStatus: result.validationStatus ?? undefined,
|
||||
creditNoteNumber: result.creditNoteNumber ?? undefined,
|
||||
validationNotes: result.validationNotes ?? undefined,
|
||||
}, 'Form 16A submission created');
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'Unknown error';
|
||||
logger.error('[Form16Controller] createSubmission error:', error);
|
||||
if (message.includes('Dealer not found') || message.includes('dealerCode is required') || message.includes('Invalid dealerCode')) {
|
||||
return ResponseHandler.error(res, message, 403);
|
||||
}
|
||||
// No validation on certificate number: revised Form 16A can reuse same certificate number for same quarter.
|
||||
// Unique constraint only on request_id; duplicate (same quarter + amount + credit note) is enforced in 26AS match.
|
||||
const isUniqueError = error?.name === 'SequelizeUniqueConstraintError' || message.includes('duplicate') || message.includes('unique') || message.includes('already been submitted');
|
||||
if (isUniqueError) {
|
||||
return ResponseHandler.error(res, 'This submission could not be created due to a conflict with an existing record.', 400);
|
||||
}
|
||||
// Surface Sequelize validation errors (e.g. field length, type) so client gets a clear message
|
||||
const isSequelizeValidation = error?.name === 'SequelizeValidationError' && Array.isArray(error?.errors);
|
||||
const detail = isSequelizeValidation
|
||||
? error.errors.map((e: any) => e.message || e.type).join('; ')
|
||||
: message;
|
||||
const statusCode = isSequelizeValidation ? 400 : 500;
|
||||
return ResponseHandler.error(res, 'Failed to create Form 16 submission', statusCode, detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/form16/test-notification (dev only)
|
||||
* Sends a Form 16 notification (in-app + push + email) to the current user for testing.
|
||||
* With Ethereal: check backend logs for "📧 Preview URL" to open the email in browser.
|
||||
*/
|
||||
async testNotification(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) {
|
||||
return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
}
|
||||
const { notificationService } = await import('../services/notification.service');
|
||||
await notificationService.sendToUsers([userId], {
|
||||
title: 'Form 16 – Test notification',
|
||||
body: 'This is a test. If you see this in-app and in your email (Ethereal preview URL in backend logs), Form 16 notifications are working.',
|
||||
type: 'form16_success_credit_note',
|
||||
});
|
||||
return ResponseHandler.success(res, { message: 'Test notification sent. Check in-app bell and backend logs for email preview URL (Ethereal).' }, 'OK');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] testNotification error:', error);
|
||||
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();
|
||||
24
src/controllers/form16Sap.controller.ts
Normal file
24
src/controllers/form16Sap.controller.ts
Normal 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();
|
||||
|
||||
@ -14,11 +14,27 @@ import crypto from 'crypto';
|
||||
import { getRequestMetadata } from '@utils/requestUtils';
|
||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service';
|
||||
import { DealerClaimService } from '@services/dealerClaim.service';
|
||||
import { canViewForm16Submission } from '@services/form16Permission.service';
|
||||
import { sanitizeObject, isHtmlEmpty } from '@utils/sanitizer';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
const workflowService = new WorkflowService();
|
||||
const dealerClaimService = new DealerClaimService();
|
||||
|
||||
/** Filter FORM_16 from list result when user does not have Form 16 submission access. Admin always sees all. */
|
||||
async function filterForm16FromListIfNeeded<T extends { data: any[]; pagination: any }>(
|
||||
req: Request,
|
||||
result: T
|
||||
): Promise<T> {
|
||||
if (!req.user?.email || !req.user?.userId || !result?.data?.length) return result;
|
||||
const role = (req.user as any).role;
|
||||
if (role === 'ADMIN') return result;
|
||||
const allowed = await canViewForm16Submission(req.user.email, req.user.userId, role);
|
||||
if (allowed) return result;
|
||||
const filtered = result.data.filter((w: any) => ((w.templateType || '').toString().toUpperCase() !== 'FORM_16'));
|
||||
return { ...result, data: filtered } as T;
|
||||
}
|
||||
|
||||
export class WorkflowController {
|
||||
async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@ -124,7 +140,7 @@ export class WorkflowController {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[WorkflowController] Failed to create workflow:', error);
|
||||
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
|
||||
ResponseHandler.error(res, errorMessage, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,10 +162,24 @@ export class WorkflowController {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
// Explicitly sanitize the parsed object since multipart bypasses global middleware
|
||||
parsed = sanitizeObject(parsed);
|
||||
} catch (parseError) {
|
||||
ResponseHandler.error(res, 'Invalid JSON in payload', 400, parseError instanceof Error ? parseError.message : 'JSON parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Explicitly check for empty content after sanitization for non-drafts
|
||||
if (parsed.isDraft !== true) {
|
||||
if (!parsed.title || !parsed.title.trim()) {
|
||||
ResponseHandler.error(res, 'A valid title is required. Please ensure the title contains valid content.', 400);
|
||||
return;
|
||||
}
|
||||
if (isHtmlEmpty(parsed.description)) {
|
||||
ResponseHandler.error(res, 'A valid description is required. Please ensure the description contains valid content.', 400);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform frontend format to backend format BEFORE validation
|
||||
// Map 'approvers' -> 'approvalLevels' for backward compatibility
|
||||
@ -435,7 +465,7 @@ export class WorkflowController {
|
||||
userId: req.user?.userId,
|
||||
filesCount: (req as any).files?.length || 0,
|
||||
});
|
||||
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
|
||||
ResponseHandler.error(res, errorMessage, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@ -449,6 +479,20 @@ export class WorkflowController {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateType = (workflow as any).templateType || '';
|
||||
if (templateType.toString().toUpperCase() === 'FORM_16') {
|
||||
if (!req.user?.email || !req.user?.userId) {
|
||||
ResponseHandler.forbidden(res, 'Authentication required to view Form 16 request');
|
||||
return;
|
||||
}
|
||||
const role = (req.user as any).role;
|
||||
const allowed = await canViewForm16Submission(req.user.email, req.user.userId, role);
|
||||
if (!allowed) {
|
||||
ResponseHandler.forbidden(res, 'You do not have permission to view this Form 16 request');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, workflow, 'Workflow retrieved successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -478,6 +522,15 @@ export class WorkflowController {
|
||||
ResponseHandler.notFound(res, 'Workflow not found');
|
||||
return;
|
||||
}
|
||||
const templateType = (result as any).templateType || (result as any).workflow?.templateType || '';
|
||||
if (templateType.toString().toUpperCase() === 'FORM_16') {
|
||||
const role = (req.user as any).role;
|
||||
const allowed = await canViewForm16Submission(req.user!.email, req.user!.userId, role);
|
||||
if (!allowed) {
|
||||
ResponseHandler.forbidden(res, 'You do not have permission to view this Form 16 request');
|
||||
return;
|
||||
}
|
||||
}
|
||||
ResponseHandler.success(res, result, 'Workflow details fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -490,12 +543,14 @@ export class WorkflowController {
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters
|
||||
// Extract filter parameters (financialYear, quarter for Form 16)
|
||||
const filters = {
|
||||
search: req.query.search as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
priority: req.query.priority as string | undefined,
|
||||
templateType: req.query.templateType as string | undefined,
|
||||
financialYear: req.query.financialYear as string | undefined,
|
||||
quarter: req.query.quarter as string | undefined,
|
||||
department: req.query.department as string | undefined,
|
||||
initiator: req.query.initiator as string | undefined,
|
||||
approver: req.query.approver as string | undefined,
|
||||
@ -506,7 +561,15 @@ export class WorkflowController {
|
||||
endDate: req.query.endDate as string | undefined,
|
||||
};
|
||||
|
||||
const result = await workflowService.listWorkflows(page, limit, filters);
|
||||
let result = await workflowService.listWorkflows(page, limit, filters);
|
||||
if (req.user?.email && req.user?.userId) {
|
||||
const role = (req.user as any).role;
|
||||
const allowed = await canViewForm16Submission(req.user.email, req.user.userId, role);
|
||||
if (!allowed && result?.data?.length) {
|
||||
const filtered = result.data.filter((w: any) => ((w.templateType || '').toString().toUpperCase() !== 'FORM_16'));
|
||||
result = { ...result, data: filtered };
|
||||
}
|
||||
}
|
||||
ResponseHandler.success(res, result, 'Workflows fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -535,7 +598,8 @@ export class WorkflowController {
|
||||
|
||||
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listMyRequests(userId, page, limit, filters);
|
||||
let result = await workflowService.listMyRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
ResponseHandler.success(res, result, 'My requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -566,10 +630,13 @@ export class WorkflowController {
|
||||
const dateRange = req.query.dateRange as string | undefined;
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
const financialYear = req.query.financialYear as string | undefined;
|
||||
const quarter = req.query.quarter as string | undefined;
|
||||
|
||||
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
const filters = { search, status, priority, templateType, financialYear, quarter, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
let result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
ResponseHandler.success(res, result, 'Participant requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -599,7 +666,8 @@ export class WorkflowController {
|
||||
|
||||
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
let result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -613,19 +681,22 @@ export class WorkflowController {
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters
|
||||
// Extract filter parameters (Form 16: financialYear, quarter when templateType is FORM_16)
|
||||
const filters = {
|
||||
search: req.query.search as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
priority: req.query.priority as string | undefined,
|
||||
templateType: req.query.templateType as string | undefined
|
||||
templateType: req.query.templateType as string | undefined,
|
||||
financialYear: req.query.financialYear as string | undefined,
|
||||
quarter: req.query.quarter as string | undefined,
|
||||
};
|
||||
|
||||
// Extract sorting parameters
|
||||
const sortBy = req.query.sortBy as string | undefined;
|
||||
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
|
||||
|
||||
const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
let result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
ResponseHandler.success(res, result, 'Open requests for user fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -639,19 +710,22 @@ export class WorkflowController {
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters
|
||||
// Extract filter parameters (Form 16: financialYear, quarter when templateType is FORM_16)
|
||||
const filters = {
|
||||
search: req.query.search as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
priority: req.query.priority as string | undefined,
|
||||
templateType: req.query.templateType as string | undefined
|
||||
templateType: req.query.templateType as string | undefined,
|
||||
financialYear: req.query.financialYear as string | undefined,
|
||||
quarter: req.query.quarter as string | undefined,
|
||||
};
|
||||
|
||||
// Extract sorting parameters
|
||||
const sortBy = req.query.sortBy as string | undefined;
|
||||
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
|
||||
|
||||
const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
let result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
ResponseHandler.success(res, result, 'Closed requests by user fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ApprovalRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
@ -102,6 +102,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Custom Message Section -->
|
||||
${getCustomMessageSection(data.customMessage)}
|
||||
|
||||
<!-- Description (supports rich text HTML including tables) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { DealerProposalRequiredData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredData): string {
|
||||
@ -103,6 +103,9 @@ export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredDat
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Custom Message Section -->
|
||||
${getCustomMessageSection(data.customMessage)}
|
||||
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Need to Submit:</h3>
|
||||
<ul style="margin: 0; padding: 0 0 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { DealerProposalRequiredData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData): string {
|
||||
@ -152,6 +152,9 @@ export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData)
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Custom Message Section -->
|
||||
${getCustomMessageSection(data.customMessage)}
|
||||
|
||||
<!-- Description (supports rich text HTML including tables) -->
|
||||
${data.requestDescription ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
|
||||
@ -32,7 +32,8 @@ export enum EmailNotificationType {
|
||||
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
|
||||
EINVOICE_GENERATED = 'einvoice_generated',
|
||||
CREDIT_NOTE_SENT = 'credit_note_sent',
|
||||
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added'
|
||||
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added',
|
||||
RE_QUOTATION = 're_quotation',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
102
src/emailtemplates/form_16_email.template.ts
Normal file
102
src/emailtemplates/form_16_email.template.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Form 16 Email Template (generic wrapper for Form 16 notification types)
|
||||
*
|
||||
* Used by notification.service.ts when payload.type starts with `form16_`.
|
||||
* Payload body comes from Form 16 admin-config templates (plain text with placeholders already substituted).
|
||||
*/
|
||||
|
||||
import { Form16EmailData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, getEmailContainerStyles, getResponsiveStyles, HeaderStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getForm16Email(data: Form16EmailData): string {
|
||||
const headerStyle =
|
||||
data.variant === 'success'
|
||||
? HeaderStyles.success
|
||||
: data.variant === 'warning'
|
||||
? HeaderStyles.warning
|
||||
: data.variant === 'error'
|
||||
? HeaderStyles.error
|
||||
: HeaderStyles.info;
|
||||
|
||||
const requestBlock = data.requestId
|
||||
? `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 26px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 18px 20px;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #666666; font-size: 13px; width: 110px;"><strong>Request ID:</strong></td>
|
||||
<td style="padding: 4px 0; color: #333333; font-size: 13px;">${data.requestId}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
: '';
|
||||
|
||||
const ctaBlock = data.viewDetailsLink
|
||||
? `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 10px 0 6px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 14px 34px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 15px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<title>${data.title}</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 40px 0;">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({ title: data.title, ...headerStyle }))}
|
||||
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 18px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
${requestBlock}
|
||||
|
||||
<div style="padding: 18px 18px; background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 6px; margin-bottom: 24px;">
|
||||
<div style="margin: 0; color: #333333; font-size: 14px; line-height: 1.8;">
|
||||
${data.messageHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${ctaBlock}
|
||||
|
||||
<p style="margin: 18px 0 0; color: #666666; font-size: 13px; line-height: 1.6; text-align: center;">
|
||||
Thank you for using the ${data.companyName} Workflow System.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -799,6 +799,22 @@ export function getRoleDescription(role: 'Approver' | 'Spectator'): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom message section (e.g., for re-quotation notes)
|
||||
*/
|
||||
export function getCustomMessageSection(message?: string): string {
|
||||
if (!message) return '';
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 30px; padding: 20px; background-color: #f0f7ff; border-left: 4px solid #667eea; border-radius: 4px;">
|
||||
<h3 style="margin: 0 0 10px; color: #333333; font-size: 16px; font-weight: 600;">Note/Instructions:</h3>
|
||||
<p style="margin: 0; color: #444444; font-size: 15px; line-height: 1.6; font-style: italic;">
|
||||
"${message}"
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action required section for workflow resumed
|
||||
*/
|
||||
@ -882,6 +898,11 @@ export function getTemplateTypeLabel(templateType?: string): string {
|
||||
if (upper === 'DEALER CLAIM' || upper === 'DEALER_CLAIM' || upper === 'CLAIM-MANAGEMENT' || upper === 'CLAIM_MANAGEMENT') {
|
||||
return 'Dealer Claim';
|
||||
}
|
||||
|
||||
// Form 16 (Form 16A TDS)
|
||||
if (upper === 'FORM_16' || upper === 'FORM16') {
|
||||
return 'Form 16';
|
||||
}
|
||||
|
||||
// Handle template type
|
||||
if (upper === 'TEMPLATE') {
|
||||
|
||||
@ -36,4 +36,5 @@ export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmi
|
||||
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
|
||||
export { getCreditNoteSentEmail } from './creditNoteSent.template';
|
||||
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';
|
||||
export { getForm16Email } from './form_16_email.template';
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<title>Request Rejected</title>
|
||||
<title>${data.isReturnedForRevision ? 'Request Returned for Revision' : 'Request Rejected'}</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
@ -24,63 +24,65 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
<td style="padding: 40px 0;">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Rejected',
|
||||
...HeaderStyles.error
|
||||
title: data.isReturnedForRevision ? 'Revision Required' : 'Request Rejected',
|
||||
...(data.isReturnedForRevision ? HeaderStyles.warning : HeaderStyles.error)
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #dc3545;">${data.initiatorName}</strong>,
|
||||
Dear <strong style="color: ${data.isReturnedForRevision ? '#856404' : '#dc3545'};">${data.initiatorName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>.
|
||||
${data.isReturnedForRevision
|
||||
? `Your request has been <strong>returned for revision</strong> by <strong>${data.approverName}</strong>.`
|
||||
: `We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>.`}
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: ${data.isReturnedForRevision ? '#fff3cd' : '#f8d7da'}; border: 1px solid ${data.isReturnedForRevision ? '#ffeeba' : '#f5c6cb'}; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #721c24; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
<h2 style="margin: 0 0 20px; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px; width: 140px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Rejected By:</strong>
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
<strong>Action By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
${data.approverName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Rejected On:</strong>
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
<strong>Action On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
${data.rejectionDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
${data.rejectionTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<td style="padding: 8px 0; color: ${data.isReturnedForRevision ? '#856404' : '#721c24'}; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
@ -90,8 +92,8 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">${data.isReturnedForRevision ? 'Reason for Revision:' : 'Reason for Rejection:'}</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid ${data.isReturnedForRevision ? '#ffc107' : '#dc3545'}; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.rejectionReason)}
|
||||
</div>
|
||||
</div>
|
||||
@ -99,9 +101,13 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Can Do:</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
|
||||
<li>Review the rejection reason carefully</li>
|
||||
<li>Make necessary adjustments to your request</li>
|
||||
<li>Submit a new request with the required changes</li>
|
||||
${data.isReturnedForRevision
|
||||
? `<li>Review the requested changes carefully</li>
|
||||
<li>Adjust the proposal or documents as needed</li>
|
||||
<li>Resubmit the request for approval</li>`
|
||||
: `<li>Review the rejection reason carefully</li>
|
||||
<li>Make necessary adjustments to your request</li>
|
||||
<li>Submit a new request with the required changes</li>`}
|
||||
<li>Contact ${data.approverName} for more clarification if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,22 @@ export interface BaseEmailData {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export interface Form16EmailData {
|
||||
recipientName: string;
|
||||
/** Email title shown in header + subject */
|
||||
title: string;
|
||||
/** Already-sanitized HTML (escaped) message body */
|
||||
messageHtml: string;
|
||||
/** Optional: request UUID for link + context */
|
||||
requestId?: string;
|
||||
/** Optional: deep link to /request/:requestId */
|
||||
viewDetailsLink?: string;
|
||||
/** Brand name */
|
||||
companyName: string;
|
||||
/** Controls header color */
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export interface RequestCreatedData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
firstApproverName: string;
|
||||
@ -31,6 +47,7 @@ export interface ApprovalRequestData extends BaseEmailData {
|
||||
requestType: string;
|
||||
requestDescription: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
customMessage?: string;
|
||||
}
|
||||
|
||||
export interface MultiApproverRequestData extends ApprovalRequestData {
|
||||
@ -64,6 +81,7 @@ export interface RejectionNotificationData extends BaseEmailData {
|
||||
rejectionTime: string;
|
||||
requestType: string;
|
||||
rejectionReason: string;
|
||||
isReturnedForRevision?: boolean;
|
||||
}
|
||||
|
||||
export interface TATReminderData extends BaseEmailData {
|
||||
@ -234,6 +252,7 @@ export interface DealerProposalRequiredData extends BaseEmailData {
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
tatHours?: number;
|
||||
dueDate?: string;
|
||||
customMessage?: string;
|
||||
}
|
||||
|
||||
export interface AdditionalDocumentAddedData extends BaseEmailData {
|
||||
|
||||
81
src/jobs/form16NotificationJob.ts
Normal file
81
src/jobs/form16NotificationJob.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { getForm16Config } from '../services/form16Config.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).
|
||||
*/
|
||||
function getCurrentTimeHHmm(): string {
|
||||
const now = new Date();
|
||||
const str = now.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: TZ });
|
||||
const [h, m] = str.split(':').map((x) => parseInt(x, 10));
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return '00:00';
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current date in configured TZ as YYYY-MM-DD.
|
||||
*/
|
||||
function getCurrentDateString(): string {
|
||||
const now = new Date();
|
||||
const str = now.toLocaleDateString('en-CA', { timeZone: TZ });
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick: run every minute; if current time matches config run-at time and we haven't run today, run the job.
|
||||
*/
|
||||
async function form16NotificationTick(): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
const nowTime = getCurrentTimeHHmm();
|
||||
const today = getCurrentDateString();
|
||||
|
||||
const alertTime = (config.alertSubmitForm16RunAtTime || '').trim();
|
||||
if (config.alertSubmitForm16Enabled && alertTime && alertTime === nowTime && lastAlertRunDate !== today) {
|
||||
lastAlertRunDate = today;
|
||||
logger.info(`[Form16 Job] Running alert submit job (scheduled at ${alertTime})`);
|
||||
await runForm16AlertSubmitJob();
|
||||
}
|
||||
|
||||
const reminderTime = (config.reminderRunAtTime || '').trim();
|
||||
if (config.reminderNotificationEnabled && reminderTime && reminderTime === nowTime && lastReminderRunDate !== today) {
|
||||
lastReminderRunDate = today;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Form 16 scheduled notification jobs.
|
||||
* Schedule is read from Form 16 admin config (alertSubmitForm16RunAtTime, reminderRunAtTime).
|
||||
* A tick runs every minute; when server time (in configured TZ) matches the run-at time, the job runs once that day.
|
||||
*/
|
||||
export function startForm16NotificationJobs(): void {
|
||||
const cron = require('node-cron');
|
||||
cron.schedule('* * * * *', () => {
|
||||
form16NotificationTick();
|
||||
}, { timezone: TZ });
|
||||
|
||||
logger.info(`[Form16 Job] Form 16 notification jobs scheduled (config-driven run times, TZ: ${TZ})`);
|
||||
}
|
||||
408
src/jobs/form16SapResponseJob.ts
Normal file
408
src/jobs/form16SapResponseJob.ts
Normal file
@ -0,0 +1,408 @@
|
||||
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';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function safeFileName(name: string): string {
|
||||
return (name || '').trim().replace(/[\\\/:*?"<>|]+/g, '-').slice(0, 180) || 'form16-sap-response.csv';
|
||||
}
|
||||
|
||||
/** Columns we store in dedicated DB fields. Everything else goes into raw_row. */
|
||||
const KNOWN_CSV_COLUMNS = new Set([
|
||||
'TRNS_UNIQ_NO', 'TRNSUNIQNO', 'DMS_UNIQ_NO', 'DMSUNIQNO',
|
||||
'TDS_TRNS_ID',
|
||||
'CLAIM_NUMBER',
|
||||
'DOC_NO', 'DOCNO', 'SAP_DOC_NO', 'SAPDOC',
|
||||
'MSG_TYP', 'MSGTYP', 'MSG_TYPE',
|
||||
'MESSAGE', 'MSG',
|
||||
'DOC_DATE', 'DOCDATE',
|
||||
'TDS_AMT', 'TDSAMT',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse all columns from one CSV data row.
|
||||
* Returns { known fields } + rawRow (only the columns NOT in KNOWN_CSV_COLUMNS).
|
||||
*/
|
||||
function extractCsvFields(r: Record<string, string | undefined>) {
|
||||
const trnsUniqNo = (r.TRNS_UNIQ_NO || r.TRNSUNIQNO || r.DMS_UNIQ_NO || r.DMSUNIQNO || '').trim() || null;
|
||||
const tdsTransId = (r.TDS_TRNS_ID || '').trim() || null;
|
||||
const claimNumber = (r.CLAIM_NUMBER || '').trim() || null;
|
||||
const sapDocNo = (r.DOC_NO || r.DOCNO || r.SAP_DOC_NO || r.SAPDOC || '').trim() || null;
|
||||
const msgTyp = (r.MSG_TYP || r.MSGTYP || r.MSG_TYPE || '').trim() || null;
|
||||
const message = (r.MESSAGE || r.MSG || '').trim() || null;
|
||||
const docDate = (r.DOC_DATE || r.DOCDATE || '').trim() || null;
|
||||
const tdsAmt = (r.TDS_AMT || r.TDSAMT || '').trim() || null;
|
||||
|
||||
// Extra columns → raw_row (so nothing is ever lost)
|
||||
const rawRow: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(r)) {
|
||||
if (!KNOWN_CSV_COLUMNS.has(key.trim().toUpperCase()) && !KNOWN_CSV_COLUMNS.has(key.trim())) {
|
||||
rawRow[key.trim()] = val || '';
|
||||
}
|
||||
}
|
||||
|
||||
return { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow };
|
||||
}
|
||||
|
||||
// ─── Credit note matching ─────────────────────────────────────────────────────
|
||||
|
||||
async function findCreditNoteId(
|
||||
trnsUniqNo: string | null,
|
||||
tdsTransId: string | null,
|
||||
claimNumber: string | null,
|
||||
fileName: string,
|
||||
): Promise<{ creditNoteId: number | null; requestId: string | null }> {
|
||||
const CN = Form16CreditNote as any;
|
||||
let cn: any = null;
|
||||
|
||||
// 1. Primary: TDS_TRNS_ID in SAP response = credit note number we sent
|
||||
if (tdsTransId) {
|
||||
cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id}`);
|
||||
}
|
||||
|
||||
// 2. TRNS_UNIQ_NO (format: F16-CN-{submissionId}-{creditNoteId}-{ts})
|
||||
if (!cn && trnsUniqNo) {
|
||||
const m = trnsUniqNo.match(/^F16-CN-(\d+)-(\d+)-/);
|
||||
if (m) {
|
||||
cn = await CN.findByPk(parseInt(m[2]), { attributes: ['id', 'submissionId'] });
|
||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via TRNS_UNIQ_NO id-parse=${m[2]} → credit_note id=${cn.id}`);
|
||||
}
|
||||
if (!cn) {
|
||||
cn = await CN.findOne({ where: { trnsUniqNo }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via trns_uniq_no=${trnsUniqNo} → credit_note id=${cn.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Filename (without .csv) = credit note number
|
||||
if (!cn) {
|
||||
const baseName = fileName.replace(/\.csv$/i, '').trim();
|
||||
if (baseName) {
|
||||
cn = await CN.findOne({ where: { creditNoteNumber: baseName }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via filename=${baseName} → credit_note id=${cn.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. CLAIM_NUMBER = credit note number (seen in some SAP/WFM exports)
|
||||
if (!cn && claimNumber) {
|
||||
cn = await CN.findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
|
||||
if (cn) logger.info(`[Form16 SAP Job] Credit match via CLAIM_NUMBER=${claimNumber} → credit_note id=${cn.id}`);
|
||||
}
|
||||
|
||||
if (!cn) return { creditNoteId: null, requestId: null };
|
||||
|
||||
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
||||
return { creditNoteId: cn.id, requestId: submission?.requestId ?? null };
|
||||
}
|
||||
|
||||
// ─── Debit note matching ──────────────────────────────────────────────────────
|
||||
|
||||
async function findDebitNoteId(
|
||||
trnsUniqNo: string | null,
|
||||
tdsTransId: string | null,
|
||||
claimNumber: string | null,
|
||||
fileName: string,
|
||||
): Promise<number | null> {
|
||||
const DN = Form16DebitNote as any;
|
||||
const CN = Form16CreditNote as any;
|
||||
let dn: any = null;
|
||||
|
||||
// 1. Primary: TRNS_UNIQ_NO (format: F16-DN-{creditNoteId}-{debitNoteId}-{ts})
|
||||
if (trnsUniqNo) {
|
||||
const m = trnsUniqNo.match(/^F16-DN-(\d+)-(\d+)-/);
|
||||
if (m) {
|
||||
dn = await DN.findByPk(parseInt(m[2]), { attributes: ['id'] });
|
||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via TRNS_UNIQ_NO id-parse=${m[2]} → debit_note id=${dn.id}`);
|
||||
}
|
||||
if (!dn) {
|
||||
dn = await DN.findOne({ where: { trnsUniqNo }, attributes: ['id'] });
|
||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via trns_uniq_no=${trnsUniqNo} → debit_note id=${dn.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. TDS_TRNS_ID = credit note number → find linked debit note
|
||||
if (!dn && tdsTransId) {
|
||||
const cn = await CN.findOne({ where: { creditNoteNumber: tdsTransId }, attributes: ['id'] });
|
||||
if (cn) {
|
||||
dn = await DN.findOne({
|
||||
where: { creditNoteId: cn.id },
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: ['id'],
|
||||
});
|
||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via TDS_TRNS_ID=${tdsTransId} → credit_note id=${cn.id} → debit_note id=${dn.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CLAIM_NUMBER = debit note number
|
||||
if (!dn && claimNumber) {
|
||||
dn = await DN.findOne({ where: { debitNoteNumber: claimNumber }, attributes: ['id'] });
|
||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via CLAIM_NUMBER=${claimNumber} → debit_note id=${dn.id}`);
|
||||
}
|
||||
|
||||
// 4. Filename (without .csv) = debit note number
|
||||
if (!dn) {
|
||||
const baseName = fileName.replace(/\.csv$/i, '').trim();
|
||||
if (baseName) {
|
||||
dn = await DN.findOne({ where: { debitNoteNumber: baseName }, attributes: ['id'] });
|
||||
if (dn) logger.info(`[Form16 SAP Job] Debit match via filename=${baseName} → debit_note id=${dn.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return dn ? dn.id : null;
|
||||
}
|
||||
|
||||
// ─── Core processor ───────────────────────────────────────────────────────────
|
||||
|
||||
async function processOutgoingFile(
|
||||
fileName: string,
|
||||
type: 'credit' | 'debit',
|
||||
resolvedOutgoingDir: string,
|
||||
): Promise<void> {
|
||||
const CreditModel = Form16SapResponse as any;
|
||||
const DebitModel = Form16DebitNoteSapResponse as any;
|
||||
|
||||
// Idempotency: skip if already fully linked
|
||||
const existing =
|
||||
type === 'credit'
|
||||
? await CreditModel.findOne({ where: { fileName }, attributes: ['id', 'creditNoteId', 'sapDocumentNumber', 'storageUrl'] })
|
||||
: await DebitModel.findOne({ where: { fileName }, attributes: ['id', 'debitNoteId', 'sapDocumentNumber', 'storageUrl'] });
|
||||
|
||||
if (existing && (existing.creditNoteId ?? existing.debitNoteId) && (existing.storageUrl || existing.sapDocumentNumber)) {
|
||||
logger.debug(`[Form16 SAP Job] Skipping already-processed ${type} file: ${fileName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Read CSV ──
|
||||
const rows = await wfmFileService.readForm16OutgoingResponseByPath(path.join(resolvedOutgoingDir, fileName));
|
||||
if (!rows || rows.length === 0) {
|
||||
logger.warn(`[Form16 SAP Job] ${type} file ${fileName}: empty or unreadable CSV`);
|
||||
const emptyPayload = { rawRow: null, updatedAt: new Date() };
|
||||
if (existing) {
|
||||
type === 'credit' ? await CreditModel.update(emptyPayload, { where: { id: existing.id } })
|
||||
: await DebitModel.update(emptyPayload, { where: { id: existing.id } });
|
||||
} else {
|
||||
type === 'credit' ? await CreditModel.create({ type, fileName, ...emptyPayload, createdAt: new Date() })
|
||||
: await DebitModel.create({ fileName, ...emptyPayload, createdAt: new Date() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Pick the best data row ──
|
||||
// Skip the degenerate "|MSG_TYP|MESSAGE|" lines that some SAP exports include after the header.
|
||||
type CsvRow = Record<string, string | undefined>;
|
||||
const normalizedRows = rows as CsvRow[];
|
||||
const pick =
|
||||
normalizedRows.find((row) => {
|
||||
const trns = (row.TRNS_UNIQ_NO || row.TRNSUNIQNO || row.DMS_UNIQ_NO || '').trim();
|
||||
return Boolean(trns);
|
||||
}) ||
|
||||
normalizedRows.find((row) => {
|
||||
const tdsId = (row.TDS_TRNS_ID || '').trim();
|
||||
const docNo = (row.DOC_NO || row.DOCNO || '').trim();
|
||||
const msgTyp = (row.MSG_TYP || '').trim();
|
||||
if (!tdsId) return false;
|
||||
if (!docNo && !msgTyp) return false;
|
||||
if (['MSG_TYP', 'MESSAGE', 'TDS_TRNS_ID'].includes(tdsId.toUpperCase())) return false;
|
||||
return true;
|
||||
}) ||
|
||||
normalizedRows[0];
|
||||
|
||||
const r = pick as CsvRow;
|
||||
const { trnsUniqNo, tdsTransId, claimNumber, sapDocNo, msgTyp, message, docDate, tdsAmt, rawRow } = extractCsvFields(r);
|
||||
|
||||
logger.info(
|
||||
`[Form16 SAP Job] Processing ${type} file ${fileName}: TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}, DOC_NO=${sapDocNo ?? '—'}`
|
||||
);
|
||||
|
||||
// ── Match to a note in DB ──
|
||||
let creditNoteId: number | null = null;
|
||||
let debitNoteId: number | null = null;
|
||||
let requestId: string | null = null;
|
||||
let requestNumber: string | null = null;
|
||||
|
||||
if (type === 'credit') {
|
||||
const res = await findCreditNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
|
||||
creditNoteId = res.creditNoteId;
|
||||
requestId = res.requestId;
|
||||
if (creditNoteId && sapDocNo) {
|
||||
await (Form16CreditNote as any).update(
|
||||
{ sapDocumentNumber: sapDocNo, status: 'completed' },
|
||||
{ where: { id: creditNoteId } }
|
||||
);
|
||||
}
|
||||
if (!creditNoteId) {
|
||||
logger.warn(
|
||||
`[Form16 SAP Job] Credit file ${fileName}: no matching credit note. TDS_TRNS_ID=${tdsTransId ?? '—'}, TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debitNoteId = await findDebitNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName);
|
||||
if (debitNoteId && sapDocNo) {
|
||||
await (Form16DebitNote as any).update(
|
||||
{ sapDocumentNumber: sapDocNo, status: 'completed' },
|
||||
{ where: { id: debitNoteId } }
|
||||
);
|
||||
// Fetch requestId from linked credit note → submission
|
||||
const dn = await (Form16DebitNote as any).findByPk(debitNoteId, { attributes: ['creditNoteId'] });
|
||||
if (dn?.creditNoteId) {
|
||||
const cn = await (Form16CreditNote as any).findByPk(dn.creditNoteId, { attributes: ['submissionId'] });
|
||||
if (cn?.submissionId) {
|
||||
const sub = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
|
||||
requestId = sub?.requestId ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!debitNoteId) {
|
||||
logger.warn(
|
||||
`[Form16 SAP Job] Debit file ${fileName}: no matching debit note. TRNS_UNIQ_NO=${trnsUniqNo ?? '—'}, TDS_TRNS_ID=${tdsTransId ?? '—'}, CLAIM_NUMBER=${claimNumber ?? '—'}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const req = await (WorkflowRequest as any).findOne({ where: { requestId }, attributes: ['requestNumber'] });
|
||||
requestNumber = req?.requestNumber ?? null;
|
||||
}
|
||||
|
||||
// ── Upload raw CSV to storage ──
|
||||
const absPath = path.join(resolvedOutgoingDir, fileName);
|
||||
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 response file:', fileName, e);
|
||||
}
|
||||
|
||||
// ── Persist to DB ──
|
||||
const commonFields = {
|
||||
trnsUniqNo,
|
||||
tdsTransId,
|
||||
claimNumber,
|
||||
sapDocumentNumber: sapDocNo,
|
||||
msgTyp,
|
||||
message,
|
||||
docDate,
|
||||
tdsAmt,
|
||||
rawRow: Object.keys(rawRow).length ? rawRow : null,
|
||||
storageUrl,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (type === 'credit') {
|
||||
const payload = { type: 'credit' as const, fileName, creditNoteId, ...commonFields };
|
||||
if (existing) await CreditModel.update(payload, { where: { id: existing.id } });
|
||||
else await CreditModel.create({ ...payload, createdAt: new Date() });
|
||||
} else {
|
||||
const payload = { fileName, debitNoteId, ...commonFields };
|
||||
if (existing) await DebitModel.update(payload, { where: { id: existing.id } });
|
||||
else await DebitModel.create({ ...payload, createdAt: new Date() });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Form16 SAP Job] Saved ${type} SAP response for file ${fileName} → ${type === 'credit' ? `credit_note_id=${creditNoteId}` : `debit_note_id=${debitNoteId}`}, storage_url=${storageUrl ? 'yes' : 'no'}`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Public API (called by Pull button controller) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan both OUTGOING dirs, read every CSV, match to a DB note via TDS_TRNS_ID (primary),
|
||||
* TRNS_UNIQ_NO, CLAIM_NUMBER, or filename (fallbacks), save all known CSV columns to their
|
||||
* own DB columns and any extra columns to raw_row.
|
||||
*
|
||||
* Called by POST /form16/sap/pull – no scheduler, Pull button is the only trigger.
|
||||
*/
|
||||
export async function runForm16SapResponseIngestionOnce(): Promise<{
|
||||
processed: number;
|
||||
creditProcessed: number;
|
||||
debitProcessed: number;
|
||||
}> {
|
||||
let creditProcessed = 0;
|
||||
let debitProcessed = 0;
|
||||
|
||||
const RELATIVE_CREDIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_CRDT');
|
||||
const RELATIVE_DEBIT_OUT = path.join('WFM-QRE', 'OUTGOING', 'WFM_SAP_MAIN', 'FORM16_DBT');
|
||||
|
||||
const dirs: Array<{ dir: string; type: 'credit' | 'debit'; relSubdir: string }> = [
|
||||
{
|
||||
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'credit')),
|
||||
type: 'credit',
|
||||
relSubdir: RELATIVE_CREDIT_OUT,
|
||||
},
|
||||
{
|
||||
dir: path.dirname(wfmFileService.getForm16OutgoingPath('__probe__.csv', 'debit')),
|
||||
type: 'debit',
|
||||
relSubdir: RELATIVE_DEBIT_OUT,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const base = process.env.WFM_BASE_PATH || 'C:\\WFM';
|
||||
|
||||
for (const { dir, type, relSubdir } of dirs) {
|
||||
let abs = path.isAbsolute(dir) ? dir : path.join(base, dir);
|
||||
|
||||
if (!fs.existsSync(abs)) {
|
||||
const cwdFallback = path.join(process.cwd(), relSubdir);
|
||||
if (fs.existsSync(cwdFallback)) {
|
||||
abs = cwdFallback;
|
||||
logger.info(`[Form16 SAP Job] ${type} OUTGOING dir resolved via cwd: ${abs}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[Form16 SAP Job] ${type} OUTGOING dir not found. Tried: ${abs} | ${cwdFallback}. ` +
|
||||
`Set WFM_BASE_PATH to the folder containing WFM-QRE.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(abs).filter((f) => f.toLowerCase().endsWith('.csv'));
|
||||
logger.info(
|
||||
`[Form16 SAP Job] ${type} OUTGOING dir: ${abs} → ${files.length} CSV file(s)${files.length ? ': ' + files.join(', ') : ''}`
|
||||
);
|
||||
|
||||
for (const f of files) {
|
||||
try {
|
||||
await processOutgoingFile(f, type, abs);
|
||||
if (type === 'credit') creditProcessed++;
|
||||
else debitProcessed++;
|
||||
} catch (e) {
|
||||
logger.error(`[Form16 SAP Job] Error processing ${type} file ${f}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16 SAP Job] Ingestion error:', e);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Form16 SAP Job] Pull complete – credit: ${creditProcessed}, debit: ${debitProcessed}, total: ${creditProcessed + debitProcessed}`
|
||||
);
|
||||
|
||||
return {
|
||||
processed: creditProcessed + debitProcessed,
|
||||
creditProcessed,
|
||||
debitProcessed,
|
||||
};
|
||||
}
|
||||
@ -31,13 +31,18 @@ const getAllowedOrigins = (): string[] | boolean => {
|
||||
}
|
||||
|
||||
// Parse comma-separated URLs or use single URL
|
||||
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
||||
let origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
||||
|
||||
if (origins.length === 0) {
|
||||
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
|
||||
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
|
||||
}
|
||||
|
||||
// In development always allow localhost:3000 (Vite default) so frontend works even if FRONTEND_URL is 3001
|
||||
if (!isProduction && !origins.includes('http://localhost:3000')) {
|
||||
origins = ['http://localhost:3000', ...origins];
|
||||
}
|
||||
|
||||
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
||||
return origins;
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export const errorHandlerMiddleware = (
|
||||
error: Error,
|
||||
error: Error & { code?: string },
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@ -15,6 +15,24 @@ export const errorHandlerMiddleware = (
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// Multer errors (e.g. LIMIT_FILE_SIZE, file filter) → 400
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'File too large. Maximum size is 15MB.',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error.message === 'Only PDF files are allowed' || (error as any).code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || 'Invalid file type.',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal Server Error',
|
||||
|
||||
95
src/middlewares/form16Permission.middleware.ts
Normal file
95
src/middlewares/form16Permission.middleware.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Form 16 permission middleware – enforces API-driven config (submission viewers, 26AS viewers).
|
||||
* Use after authenticateToken so req.user is set.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
|
||||
import { getDealerCodeForUser } from '../services/form16.service';
|
||||
|
||||
/**
|
||||
* Require RE user only (block dealers). Use for endpoints that are RE-only (e.g. withdraw credit note, non-submitted dealers).
|
||||
* Call after authenticateToken; use before or with requireForm16SubmissionAccess as needed.
|
||||
*/
|
||||
export const requireForm16ReOnly = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user?.userId || !user?.email) {
|
||||
ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
const dealerCode = await getDealerCodeForUser(user.userId);
|
||||
if (dealerCode) {
|
||||
ResponseHandler.forbidden(res, 'This action is only available to RE users, not dealers');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Require Form 16 submission data access.
|
||||
* Admin has full access. Dealers are always allowed. RE users must be in submissionViewerEmails (or list empty).
|
||||
*/
|
||||
export const requireForm16SubmissionAccess = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user?.userId || !user?.email) {
|
||||
ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
if ((user as any).role === 'ADMIN') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const allowed = await canViewForm16Submission(user.email, user.userId, (user as any).role);
|
||||
if (!allowed) {
|
||||
ResponseHandler.forbidden(res, 'You do not have permission to view Form 16 submission data');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Require 26AS access (view/upload/manage 26AS data).
|
||||
* Admin has full access. Otherwise user must be in twentySixAsViewerEmails (or list empty).
|
||||
*/
|
||||
export const requireForm1626AsAccess = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user?.userId || !user?.email) {
|
||||
ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
if ((user as any).role === 'ADMIN') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const allowed = await canView26As(user.email, (user as any).role);
|
||||
if (!allowed) {
|
||||
ResponseHandler.forbidden(res, 'You do not have permission to access 26AS data');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
@ -390,6 +390,10 @@ export function recordAIServiceCall(provider: string, operation: string, success
|
||||
// QUEUE METRICS COLLECTION
|
||||
// ============================================================================
|
||||
|
||||
// Throttle queue-metrics error logs when Redis is down (avoid flooding terminal)
|
||||
const queueMetricsLastErrorLog = new Map<string, number>();
|
||||
const QUEUE_METRICS_ERROR_LOG_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Update queue metrics for a specific queue
|
||||
* Call this periodically or on queue events
|
||||
@ -410,8 +414,13 @@ export async function updateQueueMetrics(queueName: string, queue: any): Promise
|
||||
queueJobsFailed.set({ queue_name: queueName }, failed);
|
||||
queueJobsDelayed.set({ queue_name: queueName }, delayed);
|
||||
} catch (error) {
|
||||
// Silently fail to avoid breaking metrics collection
|
||||
console.error(`[Metrics] Failed to update queue metrics for ${queueName}:`, error);
|
||||
// Log at most once per queue per 5 min when Redis is down so terminal stays readable
|
||||
const now = Date.now();
|
||||
const last = queueMetricsLastErrorLog.get(queueName) ?? 0;
|
||||
if (now - last >= QUEUE_METRICS_ERROR_LOG_INTERVAL_MS) {
|
||||
queueMetricsLastErrorLog.set(queueName, now);
|
||||
console.warn(`[Metrics] Queue metrics unavailable for ${queueName} (Redis may be down). Next log in 5 min.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,133 +2,15 @@
|
||||
* Sanitization Middleware
|
||||
* Sanitizes string inputs in req.body and req.query to prevent stored XSS.
|
||||
*
|
||||
* Uses TWO strategies:
|
||||
* 1. STRICT — strips ALL HTML tags (for normal text fields like names, emails, titles)
|
||||
* 2. PERMISSIVE — allows safe formatting tags (for rich text fields like description, message, comments)
|
||||
* Uses the unified sanitizeObject utility from @utils/sanitizer.
|
||||
*
|
||||
* This middleware runs AFTER body parsing and BEFORE route handlers.
|
||||
* File upload routes (multipart) are skipped — those are handled
|
||||
* by the malwareScan middleware pipeline.
|
||||
* by the malwareScan middleware pipeline (but can be manually sanitized in controllers).
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
/**
|
||||
* Fields that intentionally store HTML from rich text editors.
|
||||
* These get PERMISSIVE sanitization (safe formatting tags allowed).
|
||||
* All other string fields get STRICT sanitization (all tags stripped).
|
||||
*/
|
||||
const RICH_TEXT_FIELDS = new Set([
|
||||
'description',
|
||||
'requestDescription',
|
||||
'message',
|
||||
'content',
|
||||
'comments',
|
||||
'rejectionReason',
|
||||
'pauseReason',
|
||||
'conclusionRemark',
|
||||
'aiGeneratedRemark',
|
||||
'finalRemark',
|
||||
'closingRemarks',
|
||||
'effectiveFinalRemark',
|
||||
'keyDiscussionPoints',
|
||||
'keyPoints',
|
||||
'remarksText',
|
||||
'remark',
|
||||
'remarks',
|
||||
'feedback',
|
||||
'note',
|
||||
'notes',
|
||||
'skipReason',
|
||||
]);
|
||||
|
||||
// Strict config: zero allowed tags, zero allowed attributes
|
||||
const strictSanitizeConfig: sanitizeHtml.IOptions = {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
allowedIframeHostnames: [],
|
||||
disallowedTagsMode: 'discard',
|
||||
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object'],
|
||||
};
|
||||
|
||||
// Permissive config: allow safe formatting tags from rich text editors
|
||||
// Blocks dangerous elements (script, iframe, object, embed, form, input)
|
||||
const permissiveSanitizeConfig: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
// Text formatting
|
||||
'p', 'br', 'b', 'i', 'u', 'em', 'strong', 's', 'strike', 'del', 'sub', 'sup', 'mark', 'small',
|
||||
// Headings
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
// Lists
|
||||
'ul', 'ol', 'li',
|
||||
// Block elements
|
||||
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
|
||||
// Tables
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
||||
// Links (href checked below)
|
||||
'a',
|
||||
// Images (src checked below)
|
||||
'img',
|
||||
],
|
||||
allowedAttributes: {
|
||||
'a': ['href', 'title', 'target', 'rel'],
|
||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
||||
'td': ['colspan', 'rowspan', 'style'],
|
||||
'th': ['colspan', 'rowspan', 'style'],
|
||||
'span': ['class', 'style'],
|
||||
'div': ['class', 'style'],
|
||||
'pre': ['class', 'style'],
|
||||
'code': ['class', 'style'],
|
||||
'p': ['class', 'style'],
|
||||
'h1': ['class', 'style'],
|
||||
'h2': ['class', 'style'],
|
||||
'h3': ['class', 'style'],
|
||||
'h4': ['class', 'style'],
|
||||
'h5': ['class', 'style'],
|
||||
'h6': ['class', 'style'],
|
||||
'ul': ['class', 'style'],
|
||||
'ol': ['class', 'style', 'start', 'type'],
|
||||
'li': ['class', 'style'],
|
||||
'blockquote': ['class', 'style'],
|
||||
'table': ['class', 'style'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowedIframeHostnames: [],
|
||||
disallowedTagsMode: 'discard',
|
||||
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object', 'applet', 'form', 'input', 'textarea', 'select', 'button'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively sanitize all string values in an object or array
|
||||
* Uses the field key to decide strict vs permissive sanitization
|
||||
*/
|
||||
function sanitizeValue(value: any, fieldKey?: string): any {
|
||||
if (typeof value === 'string') {
|
||||
const isRichTextField = fieldKey && RICH_TEXT_FIELDS.has(fieldKey);
|
||||
const config = isRichTextField ? permissiveSanitizeConfig : strictSanitizeConfig;
|
||||
return sanitizeHtml(value, config);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeValue(item, fieldKey));
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return sanitizeObject(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize all string properties of an object (recursively)
|
||||
* Passes the key name to sanitizeValue so it can choose the right config
|
||||
*/
|
||||
function sanitizeObject(obj: Record<string, any>): Record<string, any> {
|
||||
const sanitized: Record<string, any> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
sanitized[key] = sanitizeValue(obj[key], key);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
import { sanitizeObject, sanitizeStrict } from '@utils/sanitizer';
|
||||
|
||||
/**
|
||||
* Express middleware that sanitizes req.body and req.query
|
||||
@ -137,6 +19,7 @@ function sanitizeObject(obj: Record<string, any>): Record<string, any> {
|
||||
export const sanitizationMiddleware = (req: Request, _res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
// Skip multipart requests — file uploads are sanitized by the malware scan pipeline
|
||||
// Note: Multipart payloads should be manually sanitized in the controller if used.
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
return next();
|
||||
@ -153,7 +36,7 @@ export const sanitizationMiddleware = (req: Request, _res: Response, next: NextF
|
||||
for (const key of Object.keys(req.query)) {
|
||||
const val = req.query[key];
|
||||
if (typeof val === 'string') {
|
||||
strictQuery[key] = sanitizeHtml(val, strictSanitizeConfig);
|
||||
strictQuery[key] = sanitizeStrict(val);
|
||||
} else {
|
||||
strictQuery[key] = val;
|
||||
}
|
||||
|
||||
@ -13,12 +13,8 @@ export const validateRequest = (schema: ZodSchema) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessages = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
ResponseHandler.validationError(res, 'Validation failed', errorMessages);
|
||||
const errorMessage = error.errors.map(err => err.message).join(', ');
|
||||
ResponseHandler.validationError(res, 'Validation failed', errorMessage);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Validation error', 400, errorMessage);
|
||||
@ -34,12 +30,8 @@ export const validateBody = (schema: ZodSchema) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessages = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
ResponseHandler.validationError(res, 'Request body validation failed', errorMessages);
|
||||
const errorMessage = error.errors.map(err => err.message).join(', ');
|
||||
ResponseHandler.validationError(res, 'Request body validation failed', errorMessage);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Validation error', 400, errorMessage);
|
||||
@ -55,12 +47,8 @@ export const validateQuery = (schema: ZodSchema) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessages = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
ResponseHandler.validationError(res, 'Query parameters validation failed', errorMessages);
|
||||
const errorMessage = error.errors.map(err => err.message).join(', ');
|
||||
ResponseHandler.validationError(res, 'Query parameters validation failed', errorMessage);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Validation error', 400, errorMessage);
|
||||
@ -76,12 +64,8 @@ export const validateParams = (schema: ZodSchema) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessages = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
ResponseHandler.validationError(res, 'URL parameters validation failed', errorMessages);
|
||||
const errorMessage = error.errors.map(err => err.message).join(', ');
|
||||
ResponseHandler.validationError(res, 'URL parameters validation failed', errorMessage);
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Validation error', 400, errorMessage);
|
||||
|
||||
179
src/migrations/20260220-create-form16-tables.ts
Normal file
179
src/migrations/20260220-create-form16-tables.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Form 16 integration: form16a_submissions (linked to workflow_requests) and form_16_credit_notes.
|
||||
* Single workflow DB; Form16 submissions appear in Open/Closed requests via workflow_requests row.
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// 1. form16a_submissions: one row per Form 16A submission; request_id links to workflow_requests
|
||||
await queryInterface.createTable('form16a_submissions', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'workflow_requests', key: 'request_id' },
|
||||
onDelete: 'CASCADE',
|
||||
unique: true,
|
||||
},
|
||||
dealer_code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
},
|
||||
form16a_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
tds_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
total_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
deductor_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
document_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
validation_status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
validation_notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
submitted_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
processed_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
processed_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form16a_submissions', ['request_id'], { name: 'idx_form16a_submissions_request_id' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['dealer_code'], { name: 'idx_form16a_submissions_dealer_code' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['status'], { name: 'idx_form16a_submissions_status' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['financial_year', 'quarter'], { name: 'idx_form16a_submissions_fy_quarter' });
|
||||
|
||||
// 2. form_16_credit_notes: credit notes generated (e.g. via SAP); linked to form16a_submissions
|
||||
await queryInterface.createTable('form_16_credit_notes', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
submission_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: { model: 'form16a_submissions', key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
credit_note_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
issue_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
issued_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form_16_credit_notes', ['submission_id'], { name: 'idx_form_16_credit_notes_submission_id' });
|
||||
await queryInterface.addIndex('form_16_credit_notes', ['status'], { name: 'idx_form_16_credit_notes_status' });
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('form_16_credit_notes');
|
||||
await queryInterface.dropTable('form16a_submissions');
|
||||
}
|
||||
228
src/migrations/20260220000001-add-form16-ocr-extracted-data.ts
Normal file
228
src/migrations/20260220000001-add-form16-ocr-extracted-data.ts
Normal file
@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Add ocr_extracted_data (JSONB) to form16a_submissions for audit/support.
|
||||
* Stores the raw OCR response when a submission is created from an extracted PDF.
|
||||
*
|
||||
* If form16a_submissions does not exist (e.g. 20260220-create-form16-tables was marked
|
||||
* run but used the wrong module on a previous deploy), this migration creates the table
|
||||
* and form_16_credit_notes first, then adds the column. Safe for UAT/fresh DBs.
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
async function tableExists(queryInterface: QueryInterface, tableName: string): Promise<boolean> {
|
||||
const [rows] = await queryInterface.sequelize.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = :tableName
|
||||
) AS "exists"`,
|
||||
{ replacements: { tableName } }
|
||||
);
|
||||
const val = (rows as { exists: boolean | string }[])?.[0]?.exists;
|
||||
return val === true || val === 't';
|
||||
}
|
||||
|
||||
async function columnExists(queryInterface: QueryInterface, tableName: string, columnName: string): Promise<boolean> {
|
||||
const [rows] = await queryInterface.sequelize.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = :tableName AND column_name = :columnName
|
||||
) AS "exists"`,
|
||||
{ replacements: { tableName, columnName } }
|
||||
);
|
||||
const val = (rows as { exists: boolean | string }[])?.[0]?.exists;
|
||||
return val === true || val === 't';
|
||||
}
|
||||
|
||||
/** Create form16a_submissions and form_16_credit_notes if missing (same as 20260220-create-form16-tables). */
|
||||
async function ensureForm16Tables(queryInterface: QueryInterface): Promise<void> {
|
||||
const exists = await tableExists(queryInterface, 'form16a_submissions');
|
||||
if (exists) return;
|
||||
|
||||
await queryInterface.createTable('form16a_submissions', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'workflow_requests', key: 'request_id' },
|
||||
onDelete: 'CASCADE',
|
||||
unique: true,
|
||||
},
|
||||
dealer_code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
},
|
||||
form16a_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
tds_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
total_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
deductor_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
document_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
validation_status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
},
|
||||
validation_notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
submitted_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
processed_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
processed_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form16a_submissions', ['request_id'], { name: 'idx_form16a_submissions_request_id' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['dealer_code'], { name: 'idx_form16a_submissions_dealer_code' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['status'], { name: 'idx_form16a_submissions_status' });
|
||||
await queryInterface.addIndex('form16a_submissions', ['financial_year', 'quarter'], { name: 'idx_form16a_submissions_fy_quarter' });
|
||||
|
||||
if (!(await tableExists(queryInterface, 'form_16_credit_notes'))) {
|
||||
await queryInterface.createTable('form_16_credit_notes', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
submission_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: { model: 'form16a_submissions', key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
credit_note_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
issue_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
issued_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex('form_16_credit_notes', ['submission_id'], { name: 'idx_form_16_credit_notes_submission_id' });
|
||||
await queryInterface.addIndex('form_16_credit_notes', ['status'], { name: 'idx_form_16_credit_notes_status' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await ensureForm16Tables(queryInterface);
|
||||
|
||||
const hasColumn = await columnExists(queryInterface, 'form16a_submissions', 'ocr_extracted_data');
|
||||
if (!hasColumn) {
|
||||
await queryInterface.addColumn(
|
||||
'form16a_submissions',
|
||||
'ocr_extracted_data',
|
||||
{
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
const hasColumn = await columnExists(queryInterface, 'form16a_submissions', 'ocr_extracted_data');
|
||||
if (hasColumn) {
|
||||
await queryInterface.removeColumn('form16a_submissions', 'ocr_extracted_data');
|
||||
}
|
||||
}
|
||||
97
src/migrations/20260222000001-create-tds-26as-entries.ts
Normal file
97
src/migrations/20260222000001-create-tds-26as-entries.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Form 16 – 26AS TDS entries table for RE admin.
|
||||
* Stores TDS credit data from 26AS (Income Tax portal) for validation against Form 16A submissions.
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('tds_26as_entries', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
comment: 'TAN of deductor',
|
||||
},
|
||||
deductor_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
comment: 'Q1, Q2, Q3, Q4',
|
||||
},
|
||||
assessment_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
comment: 'e.g. 2024-25',
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
comment: 'e.g. 2024-25',
|
||||
},
|
||||
section_code: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
comment: 'e.g. 194C, 194A',
|
||||
},
|
||||
amount_paid: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
},
|
||||
tax_deducted: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
total_tds_deposited: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
},
|
||||
nature_of_payment: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
transaction_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
},
|
||||
date_of_booking: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
},
|
||||
status_oltas: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
comment: 'Status of matching with OLTAS',
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
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('tds_26as_entries', ['tan_number'], { name: 'idx_tds_26as_tan' });
|
||||
await queryInterface.addIndex('tds_26as_entries', ['financial_year', 'quarter'], { name: 'idx_tds_26as_fy_quarter' });
|
||||
await queryInterface.addIndex('tds_26as_entries', ['financial_year'], { name: 'idx_tds_26as_fy' });
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('tds_26as_entries');
|
||||
}
|
||||
75
src/migrations/20260223000001-create-form-16-debit-notes.ts
Normal file
75
src/migrations/20260223000001-create-form-16-debit-notes.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Form 16 Debit Notes: issued when RE withdraws a credit note (e.g. duplicate/wrong).
|
||||
* One debit note per withdrawn credit note. SAP document number filled when SAP API is integrated.
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('form_16_debit_notes', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
credit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
unique: true,
|
||||
},
|
||||
debit_note_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
issue_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
reason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
created_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form_16_debit_notes', ['credit_note_id'], {
|
||||
name: 'idx_form_16_debit_notes_credit_note_id',
|
||||
});
|
||||
await queryInterface.addIndex('form_16_debit_notes', ['status'], {
|
||||
name: 'idx_form_16_debit_notes_status',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('form_16_debit_notes');
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Form 16 – 26AS upload audit log.
|
||||
* Records each 26AS file upload: who uploaded, when, and how many records were imported.
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('form_16_26as_upload_log', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uploaded_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
uploaded_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
file_name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
records_imported: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
errors_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form_16_26as_upload_log', ['uploaded_at'], {
|
||||
name: 'idx_form_16_26as_upload_log_uploaded_at',
|
||||
});
|
||||
await queryInterface.addIndex('form_16_26as_upload_log', ['uploaded_by'], {
|
||||
name: 'idx_form_16_26as_upload_log_uploaded_by',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('form_16_26as_upload_log');
|
||||
}
|
||||
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Form 16 critical changes:
|
||||
* 1. Add upload_log_id to tds_26as_entries to link each record to an upload.
|
||||
* 2. form_16_26as_quarter_snapshots: stores aggregated 26AS total per (tan, fy, quarter) per upload version.
|
||||
* 3. form_16_quarter_status: current status per (tan, fy, quarter) - SETTLED | DEBIT_ISSUED_PENDING_FORM16.
|
||||
* 4. form_16_ledger_entries: full audit trail of every credit/debit (no deletion).
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// 1. Add upload_log_id to tds_26as_entries (nullable for existing rows)
|
||||
await queryInterface.addColumn('tds_26as_entries', 'upload_log_id', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_26as_upload_log', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
await queryInterface.addIndex('tds_26as_entries', ['upload_log_id'], { name: 'idx_tds_26as_upload_log_id' });
|
||||
|
||||
// 2. form_16_26as_quarter_snapshots: one row per "version" of 26AS aggregated total for (tan, fy, quarter)
|
||||
await queryInterface.createTable('form_16_26as_quarter_snapshots', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
aggregated_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
comment: 'Sum of tax_deducted for this tan+fy+quarter (Section 194Q, Booking F/O only)',
|
||||
},
|
||||
upload_log_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_26as_upload_log', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex('form_16_26as_quarter_snapshots', ['tan_number', 'financial_year', 'quarter'], {
|
||||
name: 'idx_form16_26as_snap_tan_fy_qtr',
|
||||
});
|
||||
await queryInterface.addIndex('form_16_26as_quarter_snapshots', ['upload_log_id'], {
|
||||
name: 'idx_form16_26as_snap_upload_log_id',
|
||||
});
|
||||
|
||||
// 3. form_16_quarter_status: current status per (tan, fy, quarter) for reverse-first logic
|
||||
await queryInterface.createTable('form_16_quarter_status', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
comment: 'SETTLED | DEBIT_ISSUED_PENDING_FORM16',
|
||||
},
|
||||
last_credit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
last_debit_note_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_debit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex('form_16_quarter_status', ['tan_number', 'financial_year', 'quarter'], {
|
||||
name: 'idx_form16_quarter_status_tan_fy_qtr',
|
||||
unique: true,
|
||||
});
|
||||
|
||||
// 4. form_16_ledger_entries: full history of every credit and debit (no deletion)
|
||||
await queryInterface.createTable('form_16_ledger_entries', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
tan_number: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
entry_type: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
comment: 'CREDIT | DEBIT',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
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',
|
||||
},
|
||||
form16_submission_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form16a_submissions', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
snapshot_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'form_16_26as_quarter_snapshots', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex('form_16_ledger_entries', ['tan_number', 'financial_year', 'quarter'], {
|
||||
name: 'idx_form16_ledger_tan_fy_qtr',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('form_16_ledger_entries');
|
||||
await queryInterface.dropTable('form_16_quarter_status');
|
||||
await queryInterface.dropTable('form_16_26as_quarter_snapshots');
|
||||
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_upload_log_id');
|
||||
await queryInterface.removeColumn('tds_26as_entries', 'upload_log_id');
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Form 16 – non-submitted dealer notification log.
|
||||
* Records each time an RE user sends a "submit Form 16" notification to a non-submitted dealer (per FY).
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('form16_non_submitted_notifications', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
dealer_code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
},
|
||||
financial_year: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
},
|
||||
notified_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
notified_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('form16_non_submitted_notifications', ['dealer_code', 'financial_year'], {
|
||||
name: 'idx_form16_ns_notif_dealer_fy',
|
||||
});
|
||||
await queryInterface.addIndex('form16_non_submitted_notifications', ['notified_at'], {
|
||||
name: 'idx_form16_ns_notif_notified_at',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('form16_non_submitted_notifications');
|
||||
}
|
||||
36
src/migrations/20260225100001-add-form16-archived-at.ts
Normal file
36
src/migrations/20260225100001-add-form16-archived-at.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Form 16 / 26AS data retention: add archived_at to keep last 5 FY active.
|
||||
* Records with financial_year older than 5 years get archived_at set by scheduler (no deletion).
|
||||
*/
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
const TABLES_WITH_FY = [
|
||||
'tds_26as_entries',
|
||||
'form_16_26as_quarter_snapshots',
|
||||
'form_16_quarter_status',
|
||||
'form_16_ledger_entries',
|
||||
'form_16_credit_notes',
|
||||
'form16a_submissions',
|
||||
] as const;
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
for (const table of TABLES_WITH_FY) {
|
||||
await queryInterface.addColumn(table, 'archived_at', {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Set when record is older than 5 financial years; active when NULL',
|
||||
});
|
||||
}
|
||||
await queryInterface.addColumn('form_16_debit_notes', 'archived_at', {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Set when linked credit_note is archived; active when NULL',
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
for (const table of [...TABLES_WITH_FY, 'form_16_debit_notes']) {
|
||||
await queryInterface.removeColumn(table, 'archived_at');
|
||||
}
|
||||
}
|
||||
33
src/migrations/20260303100001-drop-form16a-number-unique.ts
Normal file
33
src/migrations/20260303100001-drop-form16a-number-unique.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Allow multiple Form 16A submissions with the same certificate number.
|
||||
* Duplicate submission is only when a credit note already exists for that dealer/quarter/amount
|
||||
* (enforced in run26asMatchAndCreditNote). Same certificate number without an existing
|
||||
* credit note (e.g. resubmission after 26AS upload) is allowed.
|
||||
*/
|
||||
|
||||
import { QueryInterface } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
const sequelize = queryInterface.sequelize;
|
||||
// Drop unique constraint (PostgreSQL names it tablename_columnname_key when created via CREATE TABLE)
|
||||
await sequelize.query(
|
||||
`ALTER TABLE form16a_submissions DROP CONSTRAINT IF EXISTS form16a_submissions_form16a_number_key;`
|
||||
);
|
||||
// If a unique index exists on form16a_number (e.g. from Sequelize), drop it
|
||||
const [indexRows] = (await sequelize.query(
|
||||
`SELECT indexname FROM pg_indexes WHERE tablename = 'form16a_submissions' AND indexdef LIKE '%form16a_number%' AND indexdef LIKE '%UNIQUE%';`
|
||||
)) as [{ indexname: string }[], unknown];
|
||||
for (const row of indexRows || []) {
|
||||
if (row?.indexname) {
|
||||
await sequelize.query(`DROP INDEX IF EXISTS "${row.indexname}";`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.addConstraint('form16a_submissions', {
|
||||
fields: ['form16a_number'],
|
||||
type: 'unique',
|
||||
name: 'form16a_submissions_form16a_number_key',
|
||||
});
|
||||
}
|
||||
38
src/migrations/20260316-update-holiday-type-enum.ts
Normal file
38
src/migrations/20260316-update-holiday-type-enum.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { QueryInterface } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Migration to ensure 'ORGANIZATIONAL' exists in the holiday_type enum
|
||||
* and set 'NATIONAL' as the default value for the holiday_type column.
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// 1. Add 'ORGANIZATIONAL' to the enum_holidays_holiday_type enum type if it doesn't exist
|
||||
// PostgreSQL doesn't support IF NOT EXISTS for ALTER TYPE ADD VALUE,
|
||||
// so we check if it exists first using a PL/pgSQL block
|
||||
await queryInterface.sequelize.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = 'ORGANIZATIONAL'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_holidays_holiday_type')
|
||||
) THEN
|
||||
ALTER TYPE enum_holidays_holiday_type ADD VALUE 'ORGANIZATIONAL';
|
||||
END IF;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
// 2. Set 'ORGANIZATIONAL' as the default value for the holiday_type column
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE "holidays" ALTER COLUMN "holiday_type" SET DEFAULT 'ORGANIZATIONAL';
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// PostgreSQL doesn't support removing enum values directly.
|
||||
// We can revert the default value back to 'ORGANIZATIONAL' if needed.
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE "holidays" ALTER COLUMN "holiday_type" SET DEFAULT 'NATIONAL';
|
||||
`);
|
||||
|
||||
console.log('[Migration] Note: Cannot remove enum values in PostgreSQL. ORGANIZATIONAL will remain in enum_holidays_holiday_type.');
|
||||
}
|
||||
86
src/migrations/20260317-refactor-activity-types-columns.ts
Normal file
86
src/migrations/20260317-refactor-activity-types-columns.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Helper: returns true if the column exists in the table
|
||||
*/
|
||||
async function columnExists(
|
||||
queryInterface: QueryInterface,
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const description = await queryInterface.describeTable(tableName);
|
||||
return columnName in description;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration: Refactor activity_types table
|
||||
*
|
||||
* Drops deprecated columns that will not be used going forward:
|
||||
* hsn_code, sac_code, gst_rate, gl_code, credit_nature
|
||||
*
|
||||
* Adds new column:
|
||||
* credit_posting_on VARCHAR(50) – indicates posting target (e.g. 'Spares', 'Vehicle')
|
||||
*
|
||||
* All drops are guarded so this migration is safe to run on a fresh database
|
||||
* where these columns were never added.
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
const TABLE = 'activity_types';
|
||||
|
||||
// ── Drop deprecated columns (safe: only if they exist) ──────────────────────
|
||||
const columnsToDrop = ['hsn_code', 'sac_code', 'gst_rate', 'gl_code', 'credit_nature'];
|
||||
|
||||
for (const col of columnsToDrop) {
|
||||
if (await columnExists(queryInterface, TABLE, col)) {
|
||||
await queryInterface.removeColumn(TABLE, col);
|
||||
console.log(`[Migration] Dropped column ${TABLE}.${col}`);
|
||||
} else {
|
||||
console.log(`[Migration] Column ${TABLE}.${col} does not exist – skipping drop`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add new column ────────────────────────────────────────────────────────────
|
||||
if (!(await columnExists(queryInterface, TABLE, 'credit_posting_on'))) {
|
||||
await queryInterface.addColumn(TABLE, 'credit_posting_on', {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: 'Indicates what the credit note is posted against (e.g. "Spares", "Vehicle")'
|
||||
});
|
||||
console.log(`[Migration] Added column ${TABLE}.credit_posting_on`);
|
||||
} else {
|
||||
console.log(`[Migration] Column ${TABLE}.credit_posting_on already exists – skipping add`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback: re-add the dropped columns and remove credit_posting_on.
|
||||
* Columns are restored as nullable so existing rows are unaffected.
|
||||
*/
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
const TABLE = 'activity_types';
|
||||
|
||||
// Remove the newly added column
|
||||
if (await columnExists(queryInterface, TABLE, 'credit_posting_on')) {
|
||||
await queryInterface.removeColumn(TABLE, 'credit_posting_on');
|
||||
}
|
||||
|
||||
// Restore dropped columns
|
||||
const columnsToRestore: Record<string, any> = {
|
||||
hsn_code: { type: DataTypes.STRING(20), allowNull: true, defaultValue: null },
|
||||
sac_code: { type: DataTypes.STRING(20), allowNull: true, defaultValue: null },
|
||||
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true, defaultValue: null },
|
||||
gl_code: { type: DataTypes.STRING(20), allowNull: true, defaultValue: null },
|
||||
credit_nature: { type: DataTypes.STRING(50), allowNull: true, defaultValue: null }
|
||||
};
|
||||
|
||||
for (const [col, spec] of Object.entries(columnsToRestore)) {
|
||||
if (!(await columnExists(queryInterface, TABLE, col))) {
|
||||
await queryInterface.addColumn(TABLE, col, spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/migrations/20260317100001-create-form16-sap-responses.ts
Normal file
85
src/migrations/20260317100001-create-form16-sap-responses.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
|
||||
26
src/migrations/20260317120001-add-form16-trns-uniq-no.ts
Normal file
26
src/migrations/20260317120001-add-form16-trns-uniq-no.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
41
src/migrations/20260318200001-add-sap-response-csv-fields.ts
Normal file
41
src/migrations/20260318200001-add-sap-response-csv-fields.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Add explicit CSV-column fields to both SAP response tables.
|
||||
* Previously everything was dumped into raw_row; now each well-known SAP CSV column
|
||||
* has its own DB column, and raw_row holds only unexpected/extra columns.
|
||||
*
|
||||
* New columns (both tables):
|
||||
* trns_uniq_no – TRNS_UNIQ_NO from SAP response (our unique ID echoed back)
|
||||
* tds_trns_id – TDS_TRNS_ID from SAP response (= credit note number we sent)
|
||||
* doc_date – DOC_DATE (SAP document date)
|
||||
* tds_amt – TDS_AMT (amount confirmed by SAP)
|
||||
*/
|
||||
module.exports = {
|
||||
up: async (queryInterface: QueryInterface) => {
|
||||
const commonColumns = [
|
||||
['trns_uniq_no', { type: DataTypes.STRING(200), allowNull: true }],
|
||||
['tds_trns_id', { type: DataTypes.STRING(200), allowNull: true }],
|
||||
['doc_date', { type: DataTypes.STRING(20), allowNull: true }],
|
||||
['tds_amt', { type: DataTypes.STRING(50), allowNull: true }],
|
||||
] as const;
|
||||
|
||||
for (const [col, def] of commonColumns) {
|
||||
await queryInterface.addColumn('form16_sap_responses', col, def).catch(() => {/* already exists */});
|
||||
await queryInterface.addColumn('form16_debit_note_sap_responses', col, def).catch(() => {/* already exists */});
|
||||
}
|
||||
|
||||
await queryInterface.addIndex('form16_sap_responses', ['trns_uniq_no'], { name: 'idx_f16_sap_resp_trns_uniq_no' }).catch(() => {});
|
||||
await queryInterface.addIndex('form16_sap_responses', ['tds_trns_id'], { name: 'idx_f16_sap_resp_tds_trns_id' }).catch(() => {});
|
||||
await queryInterface.addIndex('form16_debit_note_sap_responses', ['trns_uniq_no'], { name: 'idx_f16_dbt_sap_trns_uniq_no' }).catch(() => {});
|
||||
await queryInterface.addIndex('form16_debit_note_sap_responses', ['tds_trns_id'], { name: 'idx_f16_dbt_sap_tds_trns_id' }).catch(() => {});
|
||||
},
|
||||
|
||||
down: async (queryInterface: QueryInterface) => {
|
||||
for (const col of ['trns_uniq_no', 'tds_trns_id', 'doc_date', 'tds_amt']) {
|
||||
await queryInterface.removeColumn('form16_sap_responses', col).catch(() => {});
|
||||
await queryInterface.removeColumn('form16_debit_note_sap_responses', col).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -9,18 +9,14 @@ interface ActivityTypeAttributes {
|
||||
taxationType?: string;
|
||||
sapRefNo?: string;
|
||||
isActive: boolean;
|
||||
hsnCode?: string | null;
|
||||
sacCode?: string | null;
|
||||
gstRate?: number | null;
|
||||
glCode?: string | null;
|
||||
creditNature?: 'Commercial' | 'GST' | null;
|
||||
creditPostingOn?: string | null;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'creditPostingOn' | 'updatedBy' | 'createdAt' | 'updatedAt'> { }
|
||||
|
||||
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
|
||||
public activityTypeId!: string;
|
||||
@ -29,11 +25,7 @@ class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAtt
|
||||
public taxationType?: string;
|
||||
public sapRefNo?: string;
|
||||
public isActive!: boolean;
|
||||
public hsnCode?: string | null;
|
||||
public sacCode?: string | null;
|
||||
public gstRate?: number | null;
|
||||
public glCode?: string | null;
|
||||
public creditNature?: 'Commercial' | 'GST' | null;
|
||||
public creditPostingOn?: string | null;
|
||||
public createdBy!: string;
|
||||
public updatedBy?: string;
|
||||
public createdAt!: Date;
|
||||
@ -81,30 +73,12 @@ ActivityType.init(
|
||||
defaultValue: true,
|
||||
field: 'is_active'
|
||||
},
|
||||
hsnCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'hsn_code'
|
||||
},
|
||||
sacCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'sac_code'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
},
|
||||
glCode: {
|
||||
creditPostingOn: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'gl_code'
|
||||
},
|
||||
creditNature: {
|
||||
type: DataTypes.ENUM('Commercial', 'GST'),
|
||||
allowNull: true,
|
||||
field: 'credit_nature'
|
||||
defaultValue: null,
|
||||
field: 'credit_posting_on',
|
||||
comment: 'Indicates what the credit note is posted against (e.g. "Spares", "Vehicle")'
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.UUID,
|
||||
|
||||
86
src/models/Form1626asQuarterSnapshot.ts
Normal file
86
src/models/Form1626asQuarterSnapshot.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form1626asUploadLog } from './Form1626asUploadLog';
|
||||
|
||||
export interface Form1626asQuarterSnapshotAttributes {
|
||||
id: number;
|
||||
tanNumber: string;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
aggregatedAmount: number;
|
||||
uploadLogId?: number | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Form1626asQuarterSnapshotCreationAttributes
|
||||
extends Optional<Form1626asQuarterSnapshotAttributes, 'id' | 'uploadLogId' | 'createdAt'> {}
|
||||
|
||||
class Form1626asQuarterSnapshot
|
||||
extends Model<Form1626asQuarterSnapshotAttributes, Form1626asQuarterSnapshotCreationAttributes>
|
||||
implements Form1626asQuarterSnapshotAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public tanNumber!: string;
|
||||
public financialYear!: string;
|
||||
public quarter!: string;
|
||||
public aggregatedAmount!: number;
|
||||
public uploadLogId?: number | null;
|
||||
public createdAt!: Date;
|
||||
|
||||
public uploadLog?: Form1626asUploadLog;
|
||||
}
|
||||
|
||||
Form1626asQuarterSnapshot.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
tanNumber: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'tan_number',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
aggregatedAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
field: 'aggregated_amount',
|
||||
},
|
||||
uploadLogId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'upload_log_id',
|
||||
references: { model: 'form_16_26as_upload_log', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'created_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_26as_quarter_snapshots',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
}
|
||||
);
|
||||
|
||||
Form1626asQuarterSnapshot.belongsTo(Form1626asUploadLog, {
|
||||
as: 'uploadLog',
|
||||
foreignKey: 'uploadLogId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
export { Form1626asQuarterSnapshot };
|
||||
82
src/models/Form1626asUploadLog.ts
Normal file
82
src/models/Form1626asUploadLog.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { User } from './User';
|
||||
|
||||
export interface Form1626asUploadLogAttributes {
|
||||
id: number;
|
||||
uploadedAt: Date;
|
||||
uploadedBy: string;
|
||||
fileName?: string | null;
|
||||
recordsImported: number;
|
||||
errorsCount: number;
|
||||
}
|
||||
|
||||
interface Form1626asUploadLogCreationAttributes
|
||||
extends Optional<Form1626asUploadLogAttributes, 'id' | 'fileName'> {}
|
||||
|
||||
class Form1626asUploadLog
|
||||
extends Model<Form1626asUploadLogAttributes, Form1626asUploadLogCreationAttributes>
|
||||
implements Form1626asUploadLogAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public uploadedAt!: Date;
|
||||
public uploadedBy!: string;
|
||||
public fileName?: string | null;
|
||||
public recordsImported!: number;
|
||||
public errorsCount!: number;
|
||||
|
||||
public uploadedByUser?: User;
|
||||
}
|
||||
|
||||
Form1626asUploadLog.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
uploadedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'uploaded_at',
|
||||
},
|
||||
uploadedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'uploaded_by',
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'file_name',
|
||||
},
|
||||
recordsImported: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'records_imported',
|
||||
},
|
||||
errorsCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'errors_count',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_26as_upload_log',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
}
|
||||
);
|
||||
|
||||
Form1626asUploadLog.belongsTo(User, {
|
||||
as: 'uploadedByUser',
|
||||
foreignKey: 'uploadedBy',
|
||||
targetKey: 'userId',
|
||||
});
|
||||
|
||||
export { Form1626asUploadLog };
|
||||
147
src/models/Form16CreditNote.ts
Normal file
147
src/models/Form16CreditNote.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16aSubmission } from './Form16aSubmission';
|
||||
import { User } from './User';
|
||||
|
||||
export interface Form16CreditNoteAttributes {
|
||||
id: number;
|
||||
submissionId: number;
|
||||
creditNoteNumber: string;
|
||||
trnsUniqNo?: string;
|
||||
sapDocumentNumber?: string;
|
||||
amount: number;
|
||||
issueDate: Date;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
status: string;
|
||||
remarks?: string;
|
||||
issuedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16CreditNoteCreationAttributes
|
||||
extends Optional<
|
||||
Form16CreditNoteAttributes,
|
||||
'id' | 'sapDocumentNumber' | 'remarks' | 'issuedBy' | 'createdAt' | 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16CreditNote
|
||||
extends Model<Form16CreditNoteAttributes, Form16CreditNoteCreationAttributes>
|
||||
implements Form16CreditNoteAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public submissionId!: number;
|
||||
public creditNoteNumber!: string;
|
||||
public trnsUniqNo?: string;
|
||||
public sapDocumentNumber?: string;
|
||||
public amount!: number;
|
||||
public issueDate!: Date;
|
||||
public financialYear!: string;
|
||||
public quarter!: string;
|
||||
public status!: string;
|
||||
public remarks?: string;
|
||||
public issuedBy?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public submission?: Form16aSubmission;
|
||||
public issuedByUser?: User;
|
||||
}
|
||||
|
||||
Form16CreditNote.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
submissionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'submission_id',
|
||||
references: { model: 'form16a_submissions', key: 'id' },
|
||||
},
|
||||
creditNoteNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'credit_note_number',
|
||||
},
|
||||
trnsUniqNo: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'trns_uniq_no',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'sap_document_number',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
issueDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'issue_date',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
issuedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'issued_by',
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'created_at',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_credit_notes',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
Form16CreditNote.belongsTo(Form16aSubmission, {
|
||||
as: 'submission',
|
||||
foreignKey: 'submissionId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
Form16CreditNote.belongsTo(User, {
|
||||
as: 'issuedByUser',
|
||||
foreignKey: 'issuedBy',
|
||||
targetKey: 'userId',
|
||||
});
|
||||
|
||||
export { Form16CreditNote };
|
||||
136
src/models/Form16DebitNote.ts
Normal file
136
src/models/Form16DebitNote.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { User } from './User';
|
||||
|
||||
export interface Form16DebitNoteAttributes {
|
||||
id: number;
|
||||
creditNoteId: number;
|
||||
debitNoteNumber: string;
|
||||
trnsUniqNo?: string;
|
||||
sapDocumentNumber?: string;
|
||||
amount: number;
|
||||
issueDate: Date;
|
||||
status: string;
|
||||
reason?: string;
|
||||
createdBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16DebitNoteCreationAttributes
|
||||
extends Optional<
|
||||
Form16DebitNoteAttributes,
|
||||
'id' | 'sapDocumentNumber' | 'reason' | 'createdBy' | 'createdAt' | 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16DebitNote
|
||||
extends Model<Form16DebitNoteAttributes, Form16DebitNoteCreationAttributes>
|
||||
implements Form16DebitNoteAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public creditNoteId!: number;
|
||||
public debitNoteNumber!: string;
|
||||
public trnsUniqNo?: string;
|
||||
public sapDocumentNumber?: string;
|
||||
public amount!: number;
|
||||
public issueDate!: Date;
|
||||
public status!: string;
|
||||
public reason?: string;
|
||||
public createdBy?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public creditNote?: Form16CreditNote;
|
||||
public createdByUser?: User;
|
||||
}
|
||||
|
||||
Form16DebitNote.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
creditNoteId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'credit_note_id',
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
unique: true,
|
||||
},
|
||||
debitNoteNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'debit_note_number',
|
||||
},
|
||||
trnsUniqNo: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'trns_uniq_no',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'sap_document_number',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
issueDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'issue_date',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
reason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'created_by',
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'created_at',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_debit_notes',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
Form16DebitNote.belongsTo(Form16CreditNote, {
|
||||
as: 'creditNote',
|
||||
foreignKey: 'creditNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
Form16DebitNote.belongsTo(User, {
|
||||
as: 'createdByUser',
|
||||
foreignKey: 'createdBy',
|
||||
targetKey: 'userId',
|
||||
});
|
||||
|
||||
export { Form16DebitNote };
|
||||
100
src/models/Form16DebitNoteSapResponse.ts
Normal file
100
src/models/Form16DebitNoteSapResponse.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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;
|
||||
// Well-known SAP CSV columns stored as individual fields
|
||||
trnsUniqNo?: string | null; // TRNS_UNIQ_NO – our unique ID echoed back by SAP
|
||||
tdsTransId?: string | null; // TDS_TRNS_ID – credit note number we sent (primary match key)
|
||||
claimNumber?: string | null; // CLAIM_NUMBER
|
||||
sapDocumentNumber?: string | null;// DOC_NO – SAP-generated document number
|
||||
msgTyp?: string | null; // MSG_TYP
|
||||
message?: string | null; // MESSAGE
|
||||
docDate?: string | null; // DOC_DATE
|
||||
tdsAmt?: string | null; // TDS_AMT
|
||||
rawRow?: Record<string, unknown> | null; // any extra / unknown columns from the CSV
|
||||
storageUrl?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16DebitNoteSapResponseCreationAttributes
|
||||
extends Optional<
|
||||
Form16DebitNoteSapResponseAttributes,
|
||||
| 'id'
|
||||
| 'debitNoteId'
|
||||
| 'trnsUniqNo'
|
||||
| 'tdsTransId'
|
||||
| 'claimNumber'
|
||||
| 'sapDocumentNumber'
|
||||
| 'msgTyp'
|
||||
| 'message'
|
||||
| 'docDate'
|
||||
| 'tdsAmt'
|
||||
| 'rawRow'
|
||||
| 'storageUrl'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16DebitNoteSapResponse
|
||||
extends Model<Form16DebitNoteSapResponseAttributes, Form16DebitNoteSapResponseCreationAttributes>
|
||||
implements Form16DebitNoteSapResponseAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public fileName!: string;
|
||||
public debitNoteId?: number | null;
|
||||
public trnsUniqNo?: string | null;
|
||||
public tdsTransId?: string | null;
|
||||
public claimNumber?: string | null;
|
||||
public sapDocumentNumber?: string | null;
|
||||
public msgTyp?: string | null;
|
||||
public message?: string | null;
|
||||
public docDate?: string | null;
|
||||
public tdsAmt?: 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' },
|
||||
trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' },
|
||||
tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' },
|
||||
claimNumber: { type: DataTypes.STRING(200), 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 },
|
||||
docDate: { type: DataTypes.STRING(20), allowNull: true, field: 'doc_date' },
|
||||
tdsAmt: { type: DataTypes.STRING(50), allowNull: true, field: 'tds_amt' },
|
||||
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 };
|
||||
137
src/models/Form16LedgerEntry.ts
Normal file
137
src/models/Form16LedgerEntry.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
import { Form16aSubmission } from './Form16aSubmission';
|
||||
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
|
||||
|
||||
export type Form16LedgerEntryType = 'CREDIT' | 'DEBIT';
|
||||
|
||||
export interface Form16LedgerEntryAttributes {
|
||||
id: number;
|
||||
tanNumber: string;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
entryType: Form16LedgerEntryType;
|
||||
amount: number;
|
||||
creditNoteId?: number | null;
|
||||
debitNoteId?: number | null;
|
||||
form16SubmissionId?: number | null;
|
||||
snapshotId?: number | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Form16LedgerEntryCreationAttributes
|
||||
extends Optional<
|
||||
Form16LedgerEntryAttributes,
|
||||
'id' | 'creditNoteId' | 'debitNoteId' | 'form16SubmissionId' | 'snapshotId' | 'createdAt'
|
||||
> {}
|
||||
|
||||
class Form16LedgerEntry
|
||||
extends Model<Form16LedgerEntryAttributes, Form16LedgerEntryCreationAttributes>
|
||||
implements Form16LedgerEntryAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public tanNumber!: string;
|
||||
public financialYear!: string;
|
||||
public quarter!: string;
|
||||
public entryType!: Form16LedgerEntryType;
|
||||
public amount!: number;
|
||||
public creditNoteId?: number | null;
|
||||
public debitNoteId?: number | null;
|
||||
public form16SubmissionId?: number | null;
|
||||
public snapshotId?: number | null;
|
||||
public createdAt!: Date;
|
||||
|
||||
public creditNote?: Form16CreditNote;
|
||||
public debitNote?: Form16DebitNote;
|
||||
public form16Submission?: Form16aSubmission;
|
||||
public snapshot?: Form1626asQuarterSnapshot;
|
||||
}
|
||||
|
||||
Form16LedgerEntry.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
tanNumber: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'tan_number',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
entryType: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
field: 'entry_type',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
},
|
||||
creditNoteId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'credit_note_id',
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
debitNoteId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'debit_note_id',
|
||||
references: { model: 'form_16_debit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
form16SubmissionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'form16_submission_id',
|
||||
references: { model: 'form16a_submissions', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
snapshotId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'snapshot_id',
|
||||
references: { model: 'form_16_26as_quarter_snapshots', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'created_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_ledger_entries',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
}
|
||||
);
|
||||
|
||||
Form16LedgerEntry.belongsTo(Form16CreditNote, { as: 'creditNote', foreignKey: 'creditNoteId', targetKey: 'id' });
|
||||
Form16LedgerEntry.belongsTo(Form16DebitNote, { as: 'debitNote', foreignKey: 'debitNoteId', targetKey: 'id' });
|
||||
Form16LedgerEntry.belongsTo(Form16aSubmission, {
|
||||
as: 'form16Submission',
|
||||
foreignKey: 'form16SubmissionId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
Form16LedgerEntry.belongsTo(Form1626asQuarterSnapshot, {
|
||||
as: 'snapshot',
|
||||
foreignKey: 'snapshotId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
export { Form16LedgerEntry };
|
||||
72
src/models/Form16NonSubmittedNotification.ts
Normal file
72
src/models/Form16NonSubmittedNotification.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { User } from './User';
|
||||
|
||||
export interface Form16NonSubmittedNotificationAttributes {
|
||||
id: number;
|
||||
dealerCode: string;
|
||||
financialYear: string;
|
||||
notifiedAt: Date;
|
||||
notifiedBy: string;
|
||||
}
|
||||
|
||||
interface Form16NonSubmittedNotificationCreationAttributes
|
||||
extends Optional<Form16NonSubmittedNotificationAttributes, 'id' | 'notifiedAt'> {}
|
||||
|
||||
class Form16NonSubmittedNotification
|
||||
extends Model<Form16NonSubmittedNotificationAttributes, Form16NonSubmittedNotificationCreationAttributes>
|
||||
implements Form16NonSubmittedNotificationAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public dealerCode!: string;
|
||||
public financialYear!: string;
|
||||
public notifiedAt!: Date;
|
||||
public notifiedBy!: string;
|
||||
|
||||
public notifiedByUser?: User;
|
||||
}
|
||||
|
||||
Form16NonSubmittedNotification.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
dealerCode: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
field: 'dealer_code',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
notifiedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'notified_at',
|
||||
},
|
||||
notifiedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'notified_by',
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form16_non_submitted_notifications',
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
Form16NonSubmittedNotification.belongsTo(User, {
|
||||
as: 'notifiedByUser',
|
||||
foreignKey: 'notifiedBy',
|
||||
targetKey: 'userId',
|
||||
});
|
||||
|
||||
export { Form16NonSubmittedNotification };
|
||||
108
src/models/Form16QuarterStatus.ts
Normal file
108
src/models/Form16QuarterStatus.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
|
||||
export type Form16QuarterStatusValue = 'SETTLED' | 'DEBIT_ISSUED_PENDING_FORM16';
|
||||
|
||||
export interface Form16QuarterStatusAttributes {
|
||||
id: number;
|
||||
tanNumber: string;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
status: Form16QuarterStatusValue;
|
||||
lastCreditNoteId?: number | null;
|
||||
lastDebitNoteId?: number | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16QuarterStatusCreationAttributes
|
||||
extends Optional<
|
||||
Form16QuarterStatusAttributes,
|
||||
'id' | 'lastCreditNoteId' | 'lastDebitNoteId' | 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16QuarterStatus
|
||||
extends Model<Form16QuarterStatusAttributes, Form16QuarterStatusCreationAttributes>
|
||||
implements Form16QuarterStatusAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public tanNumber!: string;
|
||||
public financialYear!: string;
|
||||
public quarter!: string;
|
||||
public status!: Form16QuarterStatusValue;
|
||||
public lastCreditNoteId?: number | null;
|
||||
public lastDebitNoteId?: number | null;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public lastCreditNote?: Form16CreditNote;
|
||||
public lastDebitNote?: Form16DebitNote;
|
||||
}
|
||||
|
||||
Form16QuarterStatus.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
tanNumber: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'tan_number',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
},
|
||||
lastCreditNoteId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'last_credit_note_id',
|
||||
references: { model: 'form_16_credit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
lastDebitNoteId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'last_debit_note_id',
|
||||
references: { model: 'form_16_debit_notes', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form_16_quarter_status',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
updatedAt: 'updated_at',
|
||||
createdAt: false,
|
||||
}
|
||||
);
|
||||
|
||||
Form16QuarterStatus.belongsTo(Form16CreditNote, {
|
||||
as: 'lastCreditNote',
|
||||
foreignKey: 'lastCreditNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
Form16QuarterStatus.belongsTo(Form16DebitNote, {
|
||||
as: 'lastDebitNote',
|
||||
foreignKey: 'lastDebitNoteId',
|
||||
targetKey: 'id',
|
||||
});
|
||||
|
||||
export { Form16QuarterStatus };
|
||||
103
src/models/Form16SapResponse.ts
Normal file
103
src/models/Form16SapResponse.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
|
||||
export interface Form16SapResponseAttributes {
|
||||
id: number;
|
||||
type: 'credit';
|
||||
fileName: string;
|
||||
creditNoteId?: number | null;
|
||||
// Well-known SAP CSV columns stored as individual fields
|
||||
trnsUniqNo?: string | null; // TRNS_UNIQ_NO – our unique ID echoed back by SAP
|
||||
tdsTransId?: string | null; // TDS_TRNS_ID – credit note number echoed back (primary match key)
|
||||
claimNumber?: string | null; // CLAIM_NUMBER (alias / fallback)
|
||||
sapDocumentNumber?: string | null;// DOC_NO – SAP-generated document number
|
||||
msgTyp?: string | null; // MSG_TYP
|
||||
message?: string | null; // MESSAGE
|
||||
docDate?: string | null; // DOC_DATE
|
||||
tdsAmt?: string | null; // TDS_AMT
|
||||
rawRow?: Record<string, unknown> | null; // any extra / unknown columns from the CSV
|
||||
storageUrl?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16SapResponseCreationAttributes
|
||||
extends Optional<
|
||||
Form16SapResponseAttributes,
|
||||
| 'id'
|
||||
| 'creditNoteId'
|
||||
| 'trnsUniqNo'
|
||||
| 'tdsTransId'
|
||||
| 'claimNumber'
|
||||
| 'sapDocumentNumber'
|
||||
| 'msgTyp'
|
||||
| 'message'
|
||||
| 'docDate'
|
||||
| 'tdsAmt'
|
||||
| 'rawRow'
|
||||
| 'storageUrl'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16SapResponse
|
||||
extends Model<Form16SapResponseAttributes, Form16SapResponseCreationAttributes>
|
||||
implements Form16SapResponseAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public type!: 'credit';
|
||||
public fileName!: string;
|
||||
public creditNoteId?: number | null;
|
||||
public trnsUniqNo?: string | null;
|
||||
public tdsTransId?: string | null;
|
||||
public claimNumber?: string | null;
|
||||
public sapDocumentNumber?: string | null;
|
||||
public msgTyp?: string | null;
|
||||
public message?: string | null;
|
||||
public docDate?: string | null;
|
||||
public tdsAmt?: string | null;
|
||||
public rawRow?: Record<string, unknown> | null;
|
||||
public storageUrl?: string | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public creditNote?: Form16CreditNote;
|
||||
}
|
||||
|
||||
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' },
|
||||
trnsUniqNo: { type: DataTypes.STRING(200), allowNull: true, field: 'trns_uniq_no' },
|
||||
tdsTransId: { type: DataTypes.STRING(200), allowNull: true, field: 'tds_trns_id' },
|
||||
claimNumber: { type: DataTypes.STRING(200), 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 },
|
||||
docDate: { type: DataTypes.STRING(20), allowNull: true, field: 'doc_date' },
|
||||
tdsAmt: { type: DataTypes.STRING(50), allowNull: true, field: 'tds_amt' },
|
||||
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',
|
||||
});
|
||||
|
||||
export { Form16SapResponse };
|
||||
209
src/models/Form16aSubmission.ts
Normal file
209
src/models/Form16aSubmission.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
import { User } from './User';
|
||||
|
||||
export interface Form16aSubmissionAttributes {
|
||||
id: number;
|
||||
requestId: string;
|
||||
dealerCode: string;
|
||||
form16aNumber: string;
|
||||
financialYear: string;
|
||||
quarter: string;
|
||||
version: number;
|
||||
tdsAmount: number;
|
||||
totalAmount: number;
|
||||
tanNumber: string;
|
||||
deductorName: string;
|
||||
documentUrl?: string;
|
||||
ocrExtractedData?: Record<string, unknown> | null;
|
||||
status: string;
|
||||
validationStatus?: string | null;
|
||||
validationNotes?: string | null;
|
||||
submittedDate?: Date;
|
||||
processedDate?: Date;
|
||||
processedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Form16aSubmissionCreationAttributes
|
||||
extends Optional<
|
||||
Form16aSubmissionAttributes,
|
||||
| 'id'
|
||||
| 'documentUrl'
|
||||
| 'ocrExtractedData'
|
||||
| 'validationStatus'
|
||||
| 'validationNotes'
|
||||
| 'submittedDate'
|
||||
| 'processedDate'
|
||||
| 'processedBy'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Form16aSubmission
|
||||
extends Model<Form16aSubmissionAttributes, Form16aSubmissionCreationAttributes>
|
||||
implements Form16aSubmissionAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public requestId!: string;
|
||||
public dealerCode!: string;
|
||||
public form16aNumber!: string;
|
||||
public financialYear!: string;
|
||||
public quarter!: string;
|
||||
public version!: number;
|
||||
public tdsAmount!: number;
|
||||
public totalAmount!: number;
|
||||
public tanNumber!: string;
|
||||
public deductorName!: string;
|
||||
public documentUrl?: string;
|
||||
public ocrExtractedData?: Record<string, unknown> | null;
|
||||
public status!: string;
|
||||
public validationStatus?: string;
|
||||
public validationNotes?: string;
|
||||
public submittedDate?: Date;
|
||||
public processedDate?: Date;
|
||||
public processedBy?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
public workflowRequest?: WorkflowRequest;
|
||||
public processedByUser?: User;
|
||||
}
|
||||
|
||||
Form16aSubmission.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'request_id',
|
||||
references: { model: 'workflow_requests', key: 'request_id' },
|
||||
},
|
||||
dealerCode: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
field: 'dealer_code',
|
||||
},
|
||||
form16aNumber: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
field: 'form16a_number',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
tdsAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
field: 'tds_amount',
|
||||
},
|
||||
totalAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
field: 'total_amount',
|
||||
},
|
||||
tanNumber: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'tan_number',
|
||||
},
|
||||
deductorName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'deductor_name',
|
||||
},
|
||||
documentUrl: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'document_url',
|
||||
},
|
||||
ocrExtractedData: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
field: 'ocr_extracted_data',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
validationStatus: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'validation_status',
|
||||
},
|
||||
validationNotes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'validation_notes',
|
||||
},
|
||||
submittedDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'submitted_date',
|
||||
},
|
||||
processedDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'processed_date',
|
||||
},
|
||||
processedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'processed_by',
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'form16a_submissions',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
Form16aSubmission.belongsTo(WorkflowRequest, {
|
||||
as: 'workflowRequest',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId',
|
||||
});
|
||||
|
||||
Form16aSubmission.belongsTo(User, {
|
||||
as: 'processedByUser',
|
||||
foreignKey: 'processedBy',
|
||||
targetKey: 'userId',
|
||||
});
|
||||
|
||||
export { Form16aSubmission };
|
||||
174
src/models/Tds26asEntry.ts
Normal file
174
src/models/Tds26asEntry.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
|
||||
export interface Tds26asEntryAttributes {
|
||||
id: number;
|
||||
tanNumber: string;
|
||||
deductorName?: string;
|
||||
quarter: string;
|
||||
assessmentYear?: string;
|
||||
financialYear: string;
|
||||
sectionCode?: string;
|
||||
amountPaid?: number;
|
||||
taxDeducted: number;
|
||||
totalTdsDeposited?: number;
|
||||
natureOfPayment?: string;
|
||||
transactionDate?: string;
|
||||
dateOfBooking?: string;
|
||||
statusOltas?: string;
|
||||
remarks?: string;
|
||||
uploadLogId?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Tds26asEntryCreationAttributes
|
||||
extends Optional<
|
||||
Tds26asEntryAttributes,
|
||||
| 'id'
|
||||
| 'deductorName'
|
||||
| 'assessmentYear'
|
||||
| 'sectionCode'
|
||||
| 'amountPaid'
|
||||
| 'totalTdsDeposited'
|
||||
| 'natureOfPayment'
|
||||
| 'transactionDate'
|
||||
| 'dateOfBooking'
|
||||
| 'statusOltas'
|
||||
| 'remarks'
|
||||
| 'uploadLogId'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> {}
|
||||
|
||||
class Tds26asEntry
|
||||
extends Model<Tds26asEntryAttributes, Tds26asEntryCreationAttributes>
|
||||
implements Tds26asEntryAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public tanNumber!: string;
|
||||
public deductorName?: string;
|
||||
public quarter!: string;
|
||||
public assessmentYear?: string;
|
||||
public financialYear!: string;
|
||||
public sectionCode?: string;
|
||||
public amountPaid?: number;
|
||||
public taxDeducted!: number;
|
||||
public totalTdsDeposited?: number;
|
||||
public natureOfPayment?: string;
|
||||
public transactionDate?: string;
|
||||
public dateOfBooking?: string;
|
||||
public statusOltas?: string;
|
||||
public remarks?: string;
|
||||
public uploadLogId?: number | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
}
|
||||
|
||||
Tds26asEntry.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
tanNumber: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'tan_number',
|
||||
},
|
||||
deductorName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'deductor_name',
|
||||
},
|
||||
quarter: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
assessmentYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'assessment_year',
|
||||
},
|
||||
financialYear: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
field: 'financial_year',
|
||||
},
|
||||
sectionCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'section_code',
|
||||
},
|
||||
amountPaid: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'amount_paid',
|
||||
},
|
||||
taxDeducted: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'tax_deducted',
|
||||
},
|
||||
totalTdsDeposited: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_tds_deposited',
|
||||
},
|
||||
natureOfPayment: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'nature_of_payment',
|
||||
},
|
||||
transactionDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'transaction_date',
|
||||
},
|
||||
dateOfBooking: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'date_of_booking',
|
||||
},
|
||||
statusOltas: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'status_oltas',
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
uploadLogId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'upload_log_id',
|
||||
references: { model: 'form_16_26as_upload_log', key: 'id' },
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'tds_26as_entries',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
export { Tds26asEntry };
|
||||
@ -7,7 +7,7 @@ interface WorkflowRequestAttributes {
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
initiatorId: string;
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
|
||||
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
|
||||
templateId?: string; // Reference to workflow_templates if using admin template
|
||||
title: string;
|
||||
@ -39,7 +39,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
|
||||
public requestId!: string;
|
||||
public requestNumber!: string;
|
||||
public initiatorId!: string;
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
|
||||
public workflowType?: string;
|
||||
public templateId?: string;
|
||||
public title!: string;
|
||||
|
||||
@ -29,6 +29,17 @@ import { WorkflowTemplate } from './WorkflowTemplate';
|
||||
import { ClaimInvoice } from './ClaimInvoice';
|
||||
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
|
||||
import { ClaimCreditNote } from './ClaimCreditNote';
|
||||
import { Form16aSubmission } from './Form16aSubmission';
|
||||
import { Form16CreditNote } from './Form16CreditNote';
|
||||
import { Form16DebitNote } from './Form16DebitNote';
|
||||
import { Tds26asEntry } from './Tds26asEntry';
|
||||
import { Form1626asUploadLog } from './Form1626asUploadLog';
|
||||
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 = () => {
|
||||
@ -148,6 +159,25 @@ const defineAssociations = () => {
|
||||
sourceKey: 'requestId'
|
||||
});
|
||||
|
||||
// Form 16 associations
|
||||
WorkflowRequest.hasOne(Form16aSubmission, {
|
||||
as: 'form16Submission',
|
||||
foreignKey: 'requestId',
|
||||
sourceKey: 'requestId'
|
||||
});
|
||||
|
||||
Form16aSubmission.hasMany(Form16CreditNote, {
|
||||
as: 'creditNotes',
|
||||
foreignKey: 'submissionId',
|
||||
sourceKey: 'id'
|
||||
});
|
||||
|
||||
Form16CreditNote.hasOne(Form16DebitNote, {
|
||||
as: 'debitNote',
|
||||
foreignKey: 'creditNoteId',
|
||||
sourceKey: 'id'
|
||||
});
|
||||
|
||||
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
|
||||
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||
};
|
||||
@ -185,7 +215,18 @@ export {
|
||||
DealerClaimHistory,
|
||||
ClaimInvoice,
|
||||
ClaimInvoiceItem,
|
||||
ClaimCreditNote
|
||||
ClaimCreditNote,
|
||||
Form16aSubmission,
|
||||
Form16CreditNote,
|
||||
Form16DebitNote,
|
||||
Tds26asEntry,
|
||||
Form1626asUploadLog,
|
||||
Form16NonSubmittedNotification,
|
||||
Form1626asQuarterSnapshot,
|
||||
Form16QuarterStatus,
|
||||
Form16LedgerEntry,
|
||||
Form16SapResponse,
|
||||
Form16DebitNoteSapResponse
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -4,6 +4,9 @@ import logger from '@utils/logger';
|
||||
|
||||
let pauseResumeQueue: Queue | null = null;
|
||||
|
||||
const QUEUE_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
|
||||
let lastPauseResumeQueueErrorLog = 0;
|
||||
|
||||
try {
|
||||
// Use shared Redis connection for both Queue and Worker
|
||||
pauseResumeQueue = new Queue('pauseResumeQueue', {
|
||||
@ -23,7 +26,11 @@ try {
|
||||
});
|
||||
|
||||
pauseResumeQueue.on('error', (error) => {
|
||||
logger.error('[Pause Resume Queue] Queue error:', error);
|
||||
const now = Date.now();
|
||||
if (now - lastPauseResumeQueueErrorLog >= QUEUE_ERROR_LOG_INTERVAL_MS) {
|
||||
lastPauseResumeQueueErrorLog = now;
|
||||
logger.error('[Pause Resume Queue] Queue error (Redis may be down):', (error as Error)?.message || error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[Pause Resume Queue] ✅ Queue initialized');
|
||||
|
||||
@ -4,6 +4,8 @@ import { handlePauseResumeJob } from './pauseResumeProcessor';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
let pauseResumeWorker: Worker | null = null;
|
||||
const WORKER_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
|
||||
let lastPauseResumeWorkerErrorLog = 0;
|
||||
|
||||
try {
|
||||
pauseResumeWorker = new Worker('pauseResumeQueue', handlePauseResumeJob, {
|
||||
@ -34,14 +36,17 @@ try {
|
||||
});
|
||||
|
||||
pauseResumeWorker.on('error', (err) => {
|
||||
// Connection errors are common if Redis is unavailable - log as warning
|
||||
const errorCode = (err as any)?.code;
|
||||
const isConnectionError = err?.message?.includes('connect') ||
|
||||
err?.message?.includes('ECONNREFUSED') ||
|
||||
err?.message?.includes('Redis') ||
|
||||
errorCode === 'ECONNREFUSED';
|
||||
if (isConnectionError) {
|
||||
logger.warn('[Pause Resume Worker] Connection issue (Redis may be unavailable):', err?.message || errorCode || String(err));
|
||||
const now = Date.now();
|
||||
if (now - lastPauseResumeWorkerErrorLog >= WORKER_ERROR_LOG_INTERVAL_MS) {
|
||||
lastPauseResumeWorkerErrorLog = now;
|
||||
logger.warn('[Pause Resume Worker] Connection issue (Redis may be unavailable). Next log in 2 min.');
|
||||
}
|
||||
} else {
|
||||
// Log full error details for non-connection errors to diagnose issues
|
||||
logger.error('[Pause Resume Worker] Error:', {
|
||||
|
||||
@ -4,12 +4,20 @@ import logger from '@utils/logger';
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
const redisPassword = process.env.REDIS_PASSWORD || undefined;
|
||||
|
||||
// Throttle Redis error/close logs when Redis is down (avoid flooding terminal)
|
||||
let lastRedisErrorLog = 0;
|
||||
let lastRedisCloseLog = 0;
|
||||
const REDIS_LOG_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
const redisOptions: any = {
|
||||
maxRetriesPerRequest: null, // Required for BullMQ
|
||||
enableReadyCheck: false,
|
||||
retryStrategy: (times: number) => {
|
||||
if (times > 5) {
|
||||
logger.error('[Redis] Connection failed after 5 attempts');
|
||||
if (Date.now() - lastRedisErrorLog >= REDIS_LOG_INTERVAL_MS) {
|
||||
lastRedisErrorLog = Date.now();
|
||||
logger.error('[Redis] Connection failed after 5 attempts. TAT/Pause-Resume need Redis. Start Redis or ignore if not needed.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 2000, 10000);
|
||||
@ -40,11 +48,19 @@ export const getSharedRedisConnection = (): IORedis => {
|
||||
});
|
||||
|
||||
sharedConnection.on('error', (err) => {
|
||||
logger.error('[Redis] Connection error:', err.message);
|
||||
const now = Date.now();
|
||||
if (now - lastRedisErrorLog >= REDIS_LOG_INTERVAL_MS) {
|
||||
lastRedisErrorLog = now;
|
||||
logger.error('[Redis] Connection error: ' + err.message + ' (next log in 2 min if still down)');
|
||||
}
|
||||
});
|
||||
|
||||
sharedConnection.on('close', () => {
|
||||
logger.warn('[Redis] Connection closed');
|
||||
const now = Date.now();
|
||||
if (now - lastRedisCloseLog >= REDIS_LOG_INTERVAL_MS) {
|
||||
lastRedisCloseLog = now;
|
||||
logger.warn('[Redis] Connection closed (next log in 2 min if still down)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,9 @@ import logger from '@utils/logger';
|
||||
|
||||
let tatQueue: Queue | null = null;
|
||||
|
||||
const QUEUE_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
|
||||
let lastTatQueueErrorLog = 0;
|
||||
|
||||
try {
|
||||
// Use shared Redis connection for both Queue and Worker
|
||||
tatQueue = new Queue('tatQueue', {
|
||||
@ -20,7 +23,11 @@ try {
|
||||
});
|
||||
|
||||
tatQueue.on('error', (error) => {
|
||||
logger.error('[TAT Queue] Queue error:', error);
|
||||
const now = Date.now();
|
||||
if (now - lastTatQueueErrorLog >= QUEUE_ERROR_LOG_INTERVAL_MS) {
|
||||
lastTatQueueErrorLog = now;
|
||||
logger.error('[TAT Queue] Queue error (Redis may be down):', (error as Error)?.message || error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[TAT Queue] ✅ Queue initialized');
|
||||
|
||||
@ -4,6 +4,8 @@ import { handleTatJob } from './tatProcessor';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
let tatWorker: Worker | null = null;
|
||||
const WORKER_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
|
||||
let lastTatWorkerErrorLog = 0;
|
||||
|
||||
try {
|
||||
tatWorker = new Worker('tatQueue', handleTatJob, {
|
||||
@ -34,12 +36,15 @@ try {
|
||||
});
|
||||
|
||||
tatWorker.on('error', (err) => {
|
||||
// Connection errors are common if Redis is unavailable - log as warning
|
||||
const isConnectionError = err?.message?.includes('connect') ||
|
||||
err?.message?.includes('ECONNREFUSED') ||
|
||||
err?.message?.includes('Redis');
|
||||
if (isConnectionError) {
|
||||
logger.warn('[TAT Worker] Connection issue (Redis may be unavailable):', err?.message || err);
|
||||
const now = Date.now();
|
||||
if (now - lastTatWorkerErrorLog >= WORKER_ERROR_LOG_INTERVAL_MS) {
|
||||
lastTatWorkerErrorLog = now;
|
||||
logger.warn('[TAT Worker] Connection issue (Redis may be unavailable). Next log in 2 min.');
|
||||
}
|
||||
} else {
|
||||
logger.error('[TAT Worker] Error:', err?.message || err);
|
||||
}
|
||||
|
||||
@ -12,15 +12,15 @@ export function initSocket(httpServer: any) {
|
||||
|
||||
let origins: string[];
|
||||
|
||||
// FRONTEND_URL is required - no fallbacks
|
||||
if (!frontendUrl) {
|
||||
console.error('❌ ERROR: FRONTEND_URL environment variable is not set for Socket.io!');
|
||||
console.error(' Socket.io will block all origins. This will prevent WebSocket connections.');
|
||||
console.error(' To fix: Set FRONTEND_URL environment variable with your frontend URL(s)');
|
||||
console.error(' Example: FRONTEND_URL=https://your-frontend-domain.com');
|
||||
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
||||
console.error(' Development: FRONTEND_URL=http://localhost:3000');
|
||||
origins = []; // Block all origins if not configured
|
||||
if (isProduction) {
|
||||
console.error('❌ ERROR: FRONTEND_URL environment variable is not set for Socket.io!');
|
||||
console.error(' Socket.io will block all origins. This will prevent WebSocket connections.');
|
||||
origins = [];
|
||||
} else {
|
||||
console.warn('⚠️ Socket.io: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
||||
origins = ['http://localhost:3000'];
|
||||
}
|
||||
} else if (frontendUrl === '*') {
|
||||
if (isProduction) {
|
||||
console.warn('⚠️ WARNING: Socket.io FRONTEND_URL is set to allow ALL origins (*) in production. This is not recommended for security.');
|
||||
@ -28,11 +28,15 @@ export function initSocket(httpServer: any) {
|
||||
origins = ['*'] as any; // Allow all origins
|
||||
} else {
|
||||
origins = frontendUrl.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
|
||||
if (origins.length === 0) {
|
||||
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs for Socket.io!');
|
||||
origins = [];
|
||||
} else {
|
||||
// In development always allow localhost:3000 (Vite default) so WebSocket works when frontend is on 3000
|
||||
if (!isProduction && !origins.includes('http://localhost:3000')) {
|
||||
origins = ['http://localhost:3000', ...origins];
|
||||
}
|
||||
console.log(`✅ Socket.io: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
createActivityTypeSchema,
|
||||
updateActivityTypeSchema,
|
||||
activityTypeParamsSchema,
|
||||
updateForm16ConfigSchema,
|
||||
} from '../validators/admin.validator';
|
||||
import {
|
||||
getAllHolidays,
|
||||
@ -26,6 +27,8 @@ import {
|
||||
getAllConfigurations,
|
||||
updateConfiguration,
|
||||
resetConfiguration,
|
||||
getForm16Config,
|
||||
putForm16Config,
|
||||
updateUserRole,
|
||||
getUsersByRole,
|
||||
getRoleStatistics,
|
||||
@ -121,6 +124,21 @@ router.put('/configurations/:configKey', validateParams(configKeyParamsSchema),
|
||||
*/
|
||||
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
|
||||
|
||||
/**
|
||||
* @route GET /api/admin/form16-config
|
||||
* @desc Get Form 16 admin config (submission/26AS viewers, reminders)
|
||||
* @access Admin
|
||||
*/
|
||||
router.get('/form16-config', getForm16Config);
|
||||
|
||||
/**
|
||||
* @route PUT /api/admin/form16-config
|
||||
* @desc Update Form 16 admin config
|
||||
* @body { submissionViewerEmails?, twentySixAsViewerEmails?, reminderEnabled?, reminderDays? }
|
||||
* @access Admin
|
||||
*/
|
||||
router.put('/form16-config', validateBody(updateForm16ConfigSchema), putForm16Config);
|
||||
|
||||
// ==================== User Role Management Routes (RBAC) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -42,7 +42,8 @@ router.get('/activity-types',
|
||||
title: at.title,
|
||||
itemCode: at.itemCode,
|
||||
taxationType: at.taxationType,
|
||||
sapRefNo: at.sapRefNo
|
||||
sapRefNo: at.sapRefNo,
|
||||
creditPostingOn: at.creditPostingOn
|
||||
}))
|
||||
});
|
||||
return;
|
||||
|
||||
@ -107,6 +107,7 @@ router.post('/:requestId/wfm/retrigger', authenticateToken, validateParams(reque
|
||||
*/
|
||||
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
|
||||
router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
|
||||
router.get('/:requestId/credit-note-wfm', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.fetchCreditNoteWfm.bind(dealerClaimController)));
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
|
||||
|
||||
229
src/routes/form16.routes.ts
Normal file
229
src/routes/form16.routes.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
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';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// REform16 pattern: disk storage to uploads dir (path.join(__dirname, '../../uploads') → we use UPLOAD_DIR/form16-extract)
|
||||
const form16ExtractDir = path.join(UPLOAD_DIR, 'form16-extract');
|
||||
if (!fs.existsSync(form16ExtractDir)) {
|
||||
fs.mkdirSync(form16ExtractDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, form16ExtractDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
cb(null, `form16a-${uniqueSuffix}${path.extname(file.originalname || '.pdf')}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Same multer config as REform16: disk storage, 10MB, PDF only, field 'document'
|
||||
const uploadExtract = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
if (ext === '.pdf') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PDF files are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Memory storage for submission (needs buffer for GCS upload)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// 26AS upload: .txt only, 5MB, memory storage (parse then bulk insert)
|
||||
const upload26asTxt = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
const isTxt = ext === '.txt' || (file.mimetype && (file.mimetype === 'text/plain' || file.mimetype === 'application/octet-stream'));
|
||||
if (isTxt) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .txt files are allowed for 26AS upload'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// Permissions (API-driven from admin config; used by frontend to show/hide Form 16 and 26AS)
|
||||
router.get(
|
||||
'/permissions',
|
||||
asyncHandler(form16Controller.getPermissions.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Form 16 submission data (who can see: submissionViewerEmails from admin config; dealers always allowed)
|
||||
router.get(
|
||||
'/credit-notes',
|
||||
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,
|
||||
asyncHandler(form16Controller.getCreditNoteByRequest.bind(form16Controller))
|
||||
);
|
||||
// RE only: Form 16 request actions (cancel, resubmission needed)
|
||||
router.post(
|
||||
'/requests/:requestId/cancel-submission',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.cancelForm16Submission.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/requests/:requestId/resubmission-needed',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
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',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.sapSimulateCreditNote.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/sap-simulate/debit-note',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.sapSimulateDebitNote.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Dealer-only: pending submissions and pending quarters (Form 16 Pending Submissions page)
|
||||
router.get(
|
||||
'/dealer/submissions',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.listDealerSubmissions.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/dealer/pending-quarters',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.listDealerPendingQuarters.bind(form16Controller))
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/non-submitted-dealers',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.listNonSubmittedDealers.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/non-submitted-dealers/notify',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.notifyNonSubmittedDealer.bind(form16Controller))
|
||||
);
|
||||
|
||||
// 26AS (who can see: twentySixAsViewerEmails from admin config)
|
||||
router.get(
|
||||
'/26as',
|
||||
requireForm1626AsAccess,
|
||||
asyncHandler(form16Controller.list26as.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/26as',
|
||||
requireForm1626AsAccess,
|
||||
asyncHandler(form16Controller.create26as.bind(form16Controller))
|
||||
);
|
||||
router.put(
|
||||
'/26as/:id',
|
||||
requireForm1626AsAccess,
|
||||
asyncHandler(form16Controller.update26as.bind(form16Controller))
|
||||
);
|
||||
router.delete(
|
||||
'/26as/:id',
|
||||
requireForm1626AsAccess,
|
||||
asyncHandler(form16Controller.delete26as.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/26as/upload-history',
|
||||
requireForm1626AsAccess,
|
||||
asyncHandler(form16Controller.get26asUploadHistory.bind(form16Controller))
|
||||
);
|
||||
router.post(
|
||||
'/26as/upload',
|
||||
requireForm1626AsAccess,
|
||||
upload26asTxt.single('file'),
|
||||
asyncHandler(form16Controller.upload26as.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Extract – dealers/RE with Form 16 submission access
|
||||
router.post(
|
||||
'/extract',
|
||||
requireForm16SubmissionAccess,
|
||||
uploadExtract.single('document'),
|
||||
asyncHandler(form16Controller.extractOcr.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Create Form 16 submission – dealers/RE with Form 16 submission access
|
||||
router.post(
|
||||
'/submissions',
|
||||
requireForm16SubmissionAccess,
|
||||
upload.single('document'),
|
||||
asyncHandler(form16Controller.createSubmission.bind(form16Controller))
|
||||
);
|
||||
|
||||
// Dev only: send a Form 16 test notification (in-app + email via Ethereal) to current user
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
router.post(
|
||||
'/test-notification',
|
||||
asyncHandler(form16Controller.testNotification.bind(form16Controller))
|
||||
);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@ -1,4 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||
import {
|
||||
authLimiter,
|
||||
uploadLimiter,
|
||||
adminLimiter,
|
||||
aiLimiter,
|
||||
webhookLimiter,
|
||||
generalApiLimiter,
|
||||
} from '../middlewares/rateLimiter.middleware';
|
||||
|
||||
import authRoutes from './auth.routes';
|
||||
import workflowRoutes from './workflow.routes';
|
||||
import summaryRoutes from './summary.routes';
|
||||
@ -20,16 +33,7 @@ import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||
import apiTokenRoutes from './apiToken.routes';
|
||||
import antivirusRoutes from './antivirus.routes';
|
||||
import dealerExternalRoutes from './dealerExternal.routes';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||
import {
|
||||
authLimiter,
|
||||
uploadLimiter,
|
||||
adminLimiter,
|
||||
aiLimiter,
|
||||
webhookLimiter,
|
||||
generalApiLimiter,
|
||||
} from '../middlewares/rateLimiter.middleware';
|
||||
import form16Routes from './form16.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -42,6 +46,26 @@ router.get('/health', (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Database connectivity check (for ops/testing)
|
||||
router.get('/health/db', async (_req, res) => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
database: 'connected',
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(503).json({
|
||||
status: 'ERROR',
|
||||
database: 'disconnected',
|
||||
error: err?.message || 'Unknown error',
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API routes (with rate limiters)
|
||||
// ── Auth & Admin (strict limits) ──
|
||||
router.use('/auth', authLimiter, authRoutes); // 20 req/15min
|
||||
router.use('/admin', adminLimiter, adminRoutes); // 30 req/15min
|
||||
@ -73,6 +97,7 @@ router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 r
|
||||
router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min
|
||||
router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 req/15min
|
||||
router.use('/dealers-external', generalApiLimiter, dealerExternalRoutes); // 200 req/15min
|
||||
router.use('/form16', uploadLimiter, form16Routes); // 50 req/15min (file uploads: extract, submissions, 26as)
|
||||
router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
|
||||
|
||||
export default router;
|
||||
|
||||
@ -18,9 +18,8 @@ import { notificationService } from '../services/notification.service';
|
||||
import { Activity } from '@models/Activity';
|
||||
import { WorkflowService } from '../services/workflow.service';
|
||||
import { WorkNoteController } from '../controllers/worknote.controller';
|
||||
import { workNoteService } from '../services/worknote.service';
|
||||
import { documentController } from '../controllers/document.controller';
|
||||
import { pauseController } from '../controllers/pause.controller';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -232,505 +231,25 @@ router.post('/:id/work-notes',
|
||||
// Preview workflow document
|
||||
router.get('/documents/:documentId/preview',
|
||||
authenticateToken,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { documentId } = req.params;
|
||||
const { Document } = require('@models/Document');
|
||||
const { gcsStorageService } = require('../services/gcsStorage.service');
|
||||
const fs = require('fs');
|
||||
|
||||
const document = await Document.findOne({ where: { documentId } });
|
||||
if (!document) {
|
||||
res.status(404).json({ success: false, error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const storageUrl = (document as any).storageUrl || (document as any).storage_url;
|
||||
const filePath = (document as any).filePath || (document as any).file_path;
|
||||
const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName;
|
||||
const fileType = (document as any).mimeType || (document as any).mime_type;
|
||||
|
||||
// Check if it's a GCS URL
|
||||
const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://'));
|
||||
|
||||
if (isGcsUrl) {
|
||||
// Redirect to GCS public URL or use signed URL for private files
|
||||
res.redirect(storageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
|
||||
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
|
||||
try {
|
||||
// Use the existing GCS storage service instance
|
||||
if (!gcsStorageService.isConfigured()) {
|
||||
throw new Error('GCS not configured');
|
||||
}
|
||||
|
||||
// Access the storage instance from the service
|
||||
const { Storage } = require('@google-cloud/storage');
|
||||
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||
const bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||
const path = require('path');
|
||||
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||
? keyFilePath
|
||||
: path.resolve(process.cwd(), keyFilePath);
|
||||
|
||||
const storage = new Storage({
|
||||
projectId: process.env.GCP_PROJECT_ID || '',
|
||||
keyFilename: resolvedKeyPath,
|
||||
});
|
||||
|
||||
const bucket = storage.bucket(bucketName);
|
||||
const file = bucket.file(filePath);
|
||||
|
||||
// Check if file exists
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
res.status(404).json({ success: false, error: 'File not found in GCS' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file metadata for content type
|
||||
const [metadata] = await file.getMetadata();
|
||||
const contentType = metadata.contentType || fileType || 'application/octet-stream';
|
||||
|
||||
// Set CORS headers
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
res.setHeader('Content-Type', contentType);
|
||||
|
||||
// For images and PDFs, allow inline viewing
|
||||
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
|
||||
const disposition = isPreviewable ? 'inline' : 'attachment';
|
||||
res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName));
|
||||
|
||||
// Stream file from GCS to response
|
||||
file.createReadStream()
|
||||
.on('error', (streamError: Error) => {
|
||||
const logger = require('../utils/logger').default;
|
||||
logger.error('[Workflow] Failed to stream file from GCS', {
|
||||
documentId,
|
||||
filePath,
|
||||
error: streamError.message,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to stream file from storage'
|
||||
});
|
||||
}
|
||||
})
|
||||
.pipe(res);
|
||||
return;
|
||||
} catch (gcsError) {
|
||||
const logger = require('../utils/logger').default;
|
||||
logger.error('[Workflow] Failed to access GCS file for preview', {
|
||||
documentId,
|
||||
filePath,
|
||||
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to access file. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = storageUrl.replace(/^\/uploads\//, '');
|
||||
const absolutePath = path.join(UPLOAD_DIR, relativePath);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found on server' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers to allow blob URL creation when served from same origin
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set appropriate content type
|
||||
res.contentType(fileType || 'application/octet-stream');
|
||||
|
||||
// For images and PDFs, allow inline viewing
|
||||
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
|
||||
if (isPreviewable) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
}
|
||||
|
||||
res.sendFile(absolutePath, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const absolutePath = filePath && !path.isAbsolute(filePath)
|
||||
? path.join(UPLOAD_DIR, filePath)
|
||||
: filePath;
|
||||
|
||||
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found on server' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers to allow blob URL creation when served from same origin
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set appropriate content type
|
||||
res.contentType(fileType || 'application/octet-stream');
|
||||
|
||||
// For images and PDFs, allow inline viewing
|
||||
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
|
||||
if (isPreviewable) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
}
|
||||
|
||||
res.sendFile(absolutePath, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
})
|
||||
asyncHandler((req: any, res: Response) => documentController.getWorkflowDocument(req, res, 'preview'))
|
||||
);
|
||||
|
||||
// Download workflow document
|
||||
router.get('/documents/:documentId/download',
|
||||
authenticateToken,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { documentId } = req.params;
|
||||
const { Document } = require('@models/Document');
|
||||
const { gcsStorageService } = require('../services/gcsStorage.service');
|
||||
const fs = require('fs');
|
||||
|
||||
const document = await Document.findOne({ where: { documentId } });
|
||||
if (!document) {
|
||||
res.status(404).json({ success: false, error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const storageUrl = (document as any).storageUrl || (document as any).storage_url;
|
||||
const filePath = (document as any).filePath || (document as any).file_path;
|
||||
const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName;
|
||||
|
||||
// Check if it's a GCS URL
|
||||
const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://'));
|
||||
|
||||
if (isGcsUrl) {
|
||||
// Redirect to GCS public URL for download
|
||||
res.redirect(storageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
|
||||
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
|
||||
try {
|
||||
// Use the existing GCS storage service instance
|
||||
if (!gcsStorageService.isConfigured()) {
|
||||
throw new Error('GCS not configured');
|
||||
}
|
||||
|
||||
// Access the storage instance from the service
|
||||
const { Storage } = require('@google-cloud/storage');
|
||||
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||
const bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||
const path = require('path');
|
||||
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||
? keyFilePath
|
||||
: path.resolve(process.cwd(), keyFilePath);
|
||||
|
||||
const storage = new Storage({
|
||||
projectId: process.env.GCP_PROJECT_ID || '',
|
||||
keyFilename: resolvedKeyPath,
|
||||
});
|
||||
|
||||
const bucket = storage.bucket(bucketName);
|
||||
const file = bucket.file(filePath);
|
||||
|
||||
// Check if file exists
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
res.status(404).json({ success: false, error: 'File not found in GCS' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file metadata for content type
|
||||
const [metadata] = await file.getMetadata();
|
||||
const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream';
|
||||
|
||||
// Set CORS headers
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set headers for download
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName));
|
||||
|
||||
// Stream file from GCS to response
|
||||
file.createReadStream()
|
||||
.on('error', (streamError: Error) => {
|
||||
const logger = require('../utils/logger').default;
|
||||
logger.error('[Workflow] Failed to stream file from GCS for download', {
|
||||
documentId,
|
||||
filePath,
|
||||
error: streamError.message,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to stream file from storage'
|
||||
});
|
||||
}
|
||||
})
|
||||
.pipe(res);
|
||||
return;
|
||||
} catch (gcsError) {
|
||||
const logger = require('../utils/logger').default;
|
||||
logger.error('[Workflow] Failed to access GCS file for download', {
|
||||
documentId,
|
||||
filePath,
|
||||
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to access file. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = storageUrl.replace(/^\/uploads\//, '');
|
||||
const absolutePath = path.join(UPLOAD_DIR, relativePath);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found on server' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set headers for download
|
||||
const fileTypeForDownload = (document as any).mimeType || (document as any).mime_type || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', fileTypeForDownload);
|
||||
res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName));
|
||||
|
||||
res.download(absolutePath, fileName, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to download file' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const absolutePath = filePath && !path.isAbsolute(filePath)
|
||||
? path.join(UPLOAD_DIR, filePath)
|
||||
: filePath;
|
||||
|
||||
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found on server' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.download(absolutePath, fileName, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to download file' });
|
||||
}
|
||||
});
|
||||
})
|
||||
asyncHandler((req: any, res: Response) => documentController.getWorkflowDocument(req, res, 'download'))
|
||||
);
|
||||
|
||||
// Preview work note attachment (serves file for inline viewing)
|
||||
// Preview work note attachment
|
||||
router.get('/work-notes/attachments/:attachmentId/preview',
|
||||
authenticateToken,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { attachmentId } = req.params;
|
||||
const fileInfo = await workNoteService.downloadAttachment(attachmentId);
|
||||
const fs = require('fs');
|
||||
|
||||
// Check if it's a GCS URL
|
||||
if (fileInfo.isGcsUrl && fileInfo.storageUrl) {
|
||||
// Redirect to GCS public URL
|
||||
res.redirect(fileInfo.storageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, '');
|
||||
const absolutePath = path.join(UPLOAD_DIR, relativePath);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers to allow blob URL creation when served from same origin
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set appropriate content type
|
||||
res.contentType(fileInfo.fileType || 'application/octet-stream');
|
||||
|
||||
// For images and PDFs, allow inline viewing
|
||||
const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf'));
|
||||
if (isPreviewable) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`);
|
||||
}
|
||||
|
||||
res.sendFile(absolutePath, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
|
||||
? path.join(UPLOAD_DIR, fileInfo.filePath)
|
||||
: fileInfo.filePath;
|
||||
|
||||
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers to allow blob URL creation when served from same origin
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
// Set appropriate content type
|
||||
res.contentType(fileInfo.fileType || 'application/octet-stream');
|
||||
|
||||
// For images and PDFs, allow inline viewing
|
||||
const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf'));
|
||||
if (isPreviewable) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`);
|
||||
}
|
||||
|
||||
res.sendFile(absolutePath, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
})
|
||||
asyncHandler((req: any, res: Response) => documentController.getWorkNoteAttachment(req, res, 'preview'))
|
||||
);
|
||||
|
||||
// Download work note attachment
|
||||
router.get('/work-notes/attachments/:attachmentId/download',
|
||||
authenticateToken,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { attachmentId } = req.params;
|
||||
const fileInfo = await workNoteService.downloadAttachment(attachmentId);
|
||||
const fs = require('fs');
|
||||
|
||||
// Check if it's a GCS URL
|
||||
if (fileInfo.isGcsUrl && fileInfo.storageUrl) {
|
||||
// Redirect to GCS public URL for download
|
||||
res.redirect(fileInfo.storageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, '');
|
||||
const absolutePath = path.join(UPLOAD_DIR, relativePath);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set CORS headers
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||
|
||||
res.download(absolutePath, fileInfo.fileName, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to download file' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
|
||||
? path.join(UPLOAD_DIR, fileInfo.filePath)
|
||||
: fileInfo.filePath;
|
||||
|
||||
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
||||
res.status(404).json({ success: false, error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.download(absolutePath, fileInfo.fileName, (err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Failed to download file' });
|
||||
}
|
||||
});
|
||||
})
|
||||
asyncHandler((req: any, res: Response) => documentController.getWorkNoteAttachment(req, res, 'download'))
|
||||
);
|
||||
|
||||
// Add participant routes
|
||||
|
||||
@ -160,9 +160,26 @@ async function runMigrations(): Promise<void> {
|
||||
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
|
||||
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
|
||||
const m47 = require('../migrations/20260216-create-api-tokens');
|
||||
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
|
||||
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses');
|
||||
const m47a = require('../migrations/20260217-add-is-service-to-expenses');
|
||||
const m48a = require('../migrations/20260217-create-claim-invoice-items');
|
||||
const m49 = require('../migrations/20260302-refine-dealer-claim-schema');
|
||||
const m50 = require('../migrations/20260309-add-wfm-push-fields');
|
||||
const m50 = require('../migrations/20260220-create-form16-tables');
|
||||
const m51 = require('../migrations/20260220000001-add-form16-ocr-extracted-data');
|
||||
const m52 = require('../migrations/20260222000001-create-tds-26as-entries');
|
||||
const m53 = require('../migrations/20260223000001-create-form-16-debit-notes');
|
||||
const m54 = require('../migrations/20260224000001-create-form-16-26as-upload-log');
|
||||
const m55 = require('../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables');
|
||||
const m56 = require('../migrations/20260225000001-create-form16-non-submitted-notifications');
|
||||
const m57 = require('../migrations/20260225100001-add-form16-archived-at');
|
||||
const m58 = require('../migrations/20260303100001-drop-form16a-number-unique');
|
||||
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 m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -215,18 +232,36 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
|
||||
{ name: '20260210-add-raw-pwc-responses', module: m46 },
|
||||
{ name: '20260216-create-api-tokens', module: m47 },
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 },
|
||||
{ name: '20260217-add-is-service-to-expenses', module: m47a },
|
||||
{ name: '20260217-create-claim-invoice-items', module: m48a },
|
||||
{ name: '20260302-refine-dealer-claim-schema', module: m49 },
|
||||
{ name: '20260309-add-wfm-push-fields', module: m50 },
|
||||
{ name: '20260220-create-form16-tables', module: m50 },
|
||||
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m51 },
|
||||
{ name: '20260222000001-create-tds-26as-entries', module: m52 },
|
||||
{ name: '20260223000001-create-form-16-debit-notes', module: m53 },
|
||||
{ name: '20260224000001-create-form-16-26as-upload-log', module: m54 },
|
||||
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m55 },
|
||||
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m56 },
|
||||
{ name: '20260225100001-add-form16-archived-at', module: m57 },
|
||||
{ name: '20260303100001-drop-form16a-number-unique', module: m58 },
|
||||
{ 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 },
|
||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
const { sequelize } = require('../config/database');
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
// Ensure migrations tracking table exists
|
||||
// Ensure migrations tracking table exists (case-insensitive for PostgreSQL)
|
||||
const tables = await queryInterface.showAllTables();
|
||||
if (!tables.includes('migrations')) {
|
||||
const tableName = (t: string | { tableName?: string }) => (typeof t === 'string' ? t.toLowerCase() : (t && t.tableName ? String(t.tableName).toLowerCase() : ''));
|
||||
if (!tables.some((t: string | { tableName?: string }) => tableName(t) === 'migrations')) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
145
src/scripts/clear-form16-and-demo-data.ts
Normal file
145
src/scripts/clear-form16-and-demo-data.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Clear Form 16 and demo request data for a full live test.
|
||||
* Deletes only DATA (rows). Does NOT drop tables or columns. Does NOT touch users table.
|
||||
*
|
||||
* Removes:
|
||||
* - All Form 16 submissions, credit notes, debit notes, and their FORM_16 workflow requests + related rows
|
||||
* - All demo CUSTOM requests (title like '[Demo]%') and their related rows
|
||||
*
|
||||
* Usage: npm run clear:form16-and-demo
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
async function clearForm16AndDemoData(): Promise<void> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
logger.info('[Clear] Starting Form 16 and demo data cleanup (data only; users and schema untouched)...');
|
||||
|
||||
// 1) Get request IDs to clear: FORM_16 and [Demo] CUSTOM
|
||||
const form16Rows = await sequelize.query<{ request_id: string }>(
|
||||
`SELECT request_id FROM workflow_requests WHERE template_type = 'FORM_16'`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const demoRows = await sequelize.query<{ request_id: string }>(
|
||||
`SELECT request_id FROM workflow_requests WHERE title LIKE '[Demo]%'`,
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const form16Ids = form16Rows.map((r) => r.request_id);
|
||||
const demoIds = demoRows.map((r) => r.request_id);
|
||||
const allRequestIds = [...new Set([...form16Ids, ...demoIds])];
|
||||
|
||||
if (allRequestIds.length === 0) {
|
||||
logger.info('[Clear] No Form 16 or demo requests found. Nothing to delete.');
|
||||
await transaction.commit();
|
||||
return;
|
||||
}
|
||||
logger.info(`[Clear] Found ${form16Ids.length} Form 16 request(s) and ${demoIds.length} demo request(s) to clear.`);
|
||||
|
||||
const reqPlaceholders = allRequestIds.map(() => '?').join(',');
|
||||
|
||||
const deleteByRequestIds = async (table: string, col = 'request_id'): Promise<void> => {
|
||||
await sequelize.query(
|
||||
`DELETE FROM ${table} WHERE ${col} = ANY(ARRAY[${allRequestIds.map(() => '?').join(',')}]::uuid[])`,
|
||||
{
|
||||
replacements: allRequestIds,
|
||||
type: QueryTypes.DELETE,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 2) Form 16–specific tables (all rows; no request_id filter)
|
||||
logger.info('[Clear] Deleting from form_16_debit_notes...');
|
||||
await sequelize.query(`DELETE FROM form_16_debit_notes`, { type: QueryTypes.DELETE, transaction });
|
||||
|
||||
logger.info('[Clear] Deleting from form_16_credit_notes...');
|
||||
await sequelize.query(`DELETE FROM form_16_credit_notes`, { type: QueryTypes.DELETE, transaction });
|
||||
|
||||
logger.info('[Clear] Deleting from form16a_submissions...');
|
||||
await sequelize.query(`DELETE FROM form16a_submissions`, { type: QueryTypes.DELETE, transaction });
|
||||
|
||||
// 3) Workflow child tables: delete by request_id (Form 16 + demo)
|
||||
// shared_summaries: delete where summary_id in (select from request_summaries where request_id in ...)
|
||||
const summaryRows = await sequelize.query<{ summary_id: string }>(
|
||||
`SELECT summary_id FROM request_summaries WHERE request_id = ANY(ARRAY[${allRequestIds.map(() => '?').join(',')}]::uuid[])`,
|
||||
{ replacements: allRequestIds, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const sidList = summaryRows.map((r) => r.summary_id);
|
||||
if (sidList.length > 0) {
|
||||
logger.info('[Clear] Deleting from shared_summaries...');
|
||||
await sequelize.query(
|
||||
`DELETE FROM shared_summaries WHERE summary_id = ANY(ARRAY[${sidList.map(() => '?').join(',')}]::uuid[])`,
|
||||
{ replacements: sidList, type: QueryTypes.DELETE, transaction }
|
||||
);
|
||||
}
|
||||
|
||||
// work_note_attachments: note_id in (work_notes for these request_ids)
|
||||
const noteRows = await sequelize.query<{ note_id: string }>(
|
||||
`SELECT note_id FROM work_notes WHERE request_id = ANY(ARRAY[${allRequestIds.map(() => '?').join(',')}]::uuid[])`,
|
||||
{ replacements: allRequestIds, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const nidList = noteRows.map((r) => r.note_id);
|
||||
if (nidList.length > 0) {
|
||||
logger.info('[Clear] Deleting from work_note_attachments...');
|
||||
await sequelize.query(
|
||||
`DELETE FROM work_note_attachments WHERE note_id = ANY(ARRAY[${nidList.map(() => '?').join(',')}]::uuid[])`,
|
||||
{ replacements: nidList, type: QueryTypes.DELETE, transaction }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('[Clear] Deleting from work_notes...');
|
||||
await deleteByRequestIds('work_notes');
|
||||
|
||||
logger.info('[Clear] Deleting from documents...');
|
||||
await deleteByRequestIds('documents');
|
||||
|
||||
logger.info('[Clear] Deleting from activities...');
|
||||
await deleteByRequestIds('activities');
|
||||
|
||||
logger.info('[Clear] Deleting from participants...');
|
||||
await deleteByRequestIds('participants');
|
||||
|
||||
logger.info('[Clear] Deleting from approval_levels...');
|
||||
await deleteByRequestIds('approval_levels');
|
||||
|
||||
logger.info('[Clear] Deleting from notifications...');
|
||||
await deleteByRequestIds('notifications');
|
||||
|
||||
logger.info('[Clear] Deleting from request_summaries...');
|
||||
await deleteByRequestIds('request_summaries');
|
||||
|
||||
logger.info('[Clear] Deleting from conclusion_remarks...');
|
||||
await deleteByRequestIds('conclusion_remarks');
|
||||
|
||||
logger.info('[Clear] Deleting from tat_alerts...');
|
||||
await deleteByRequestIds('tat_alerts');
|
||||
|
||||
logger.info('[Clear] Deleting from workflow_requests...');
|
||||
await deleteByRequestIds('workflow_requests');
|
||||
|
||||
await transaction.commit();
|
||||
logger.info('[Clear] Done. Form 16 and demo request data cleared. Users table and all table structures unchanged.');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
logger.error('[Clear] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
clearForm16AndDemoData()
|
||||
.then(() => {
|
||||
logger.info('[Clear] Exiting successfully.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('[Clear] Exiting with error.', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { clearForm16AndDemoData };
|
||||
@ -48,26 +48,106 @@ import * as m42 from '../migrations/20250125-create-activity-types';
|
||||
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
|
||||
import * as m44 from '../migrations/20260123-fix-template-id-schema';
|
||||
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
|
||||
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
|
||||
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
|
||||
import * as m46 from '../migrations/20260210-add-raw-pwc-responses';
|
||||
import * as m47 from '../migrations/20260216-create-api-tokens';
|
||||
import * as m48 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||
import * as m47a from '../migrations/20260217-add-is-service-to-expenses';
|
||||
import * as m48a from '../migrations/20260217-create-claim-invoice-items';
|
||||
import * as m49 from '../migrations/20260302-refine-dealer-claim-schema';
|
||||
import * as m50 from '../migrations/20260309-add-wfm-push-fields';
|
||||
import * as m50 from '../migrations/20260220-create-form16-tables';
|
||||
import * as m51 from '../migrations/20260220000001-add-form16-ocr-extracted-data';
|
||||
import * as m52 from '../migrations/20260222000001-create-tds-26as-entries';
|
||||
import * as m53 from '../migrations/20260223000001-create-form-16-debit-notes';
|
||||
import * as m54 from '../migrations/20260224000001-create-form-16-26as-upload-log';
|
||||
import * as m55 from '../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables';
|
||||
import * as m56 from '../migrations/20260225000001-create-form16-non-submitted-notifications';
|
||||
import * as m57 from '../migrations/20260225100001-add-form16-archived-at';
|
||||
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';
|
||||
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
module: any;
|
||||
}
|
||||
|
||||
// Define all migrations in order
|
||||
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
|
||||
// Define ALL migrations in order. Required for fresh DB (e.g. UAT/production).
|
||||
// Order matters: base tables first, then tables that reference them.
|
||||
const migrations: Migration[] = [
|
||||
// ... existing migrations ...
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
|
||||
{ name: '20260217-add-is-service-to-expenses', module: m47 },
|
||||
{ name: '20260217-create-claim-invoice-items', module: m48 },
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
{ name: '2025103001-create-workflow-requests', module: m1 },
|
||||
{ name: '2025103002-create-approval-levels', module: m2 },
|
||||
{ name: '2025103003-create-participants', module: m3 },
|
||||
{ name: '2025103004-create-documents', module: m4 },
|
||||
{ name: '20251031_01_create_subscriptions', module: m5 },
|
||||
{ name: '20251031_02_create_activities', module: m6 },
|
||||
{ name: '20251031_03_create_work_notes', module: m7 },
|
||||
{ name: '20251031_04_create_work_note_attachments', module: m8 },
|
||||
{ name: '20251104-add-tat-alert-fields', module: m9 },
|
||||
{ name: '20251104-create-tat-alerts', module: m10 },
|
||||
{ name: '20251104-create-kpi-views', module: m11 },
|
||||
{ name: '20251104-create-holidays', module: m12 },
|
||||
{ name: '20251104-create-admin-config', module: m13 },
|
||||
{ name: '20251105-add-skip-fields-to-approval-levels', module: m14 },
|
||||
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
|
||||
{ name: '20251111-create-notifications', module: m16 },
|
||||
{ name: '20251111-create-conclusion-remarks', module: m17 },
|
||||
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
|
||||
{ name: '20251121-add-ai-model-configs', module: m19 },
|
||||
{ name: '20250122-create-request-summaries', module: m20 },
|
||||
{ name: '20250122-create-shared-summaries', module: m21 },
|
||||
{ name: '20250123-update-request-number-format', module: m22 },
|
||||
{ name: '20250126-add-paused-to-enum', module: m23 },
|
||||
{ name: '20250126-add-paused-to-workflow-status-enum', module: m24 },
|
||||
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
|
||||
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
|
||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
|
||||
{ name: '20251203-add-user-notification-preferences', module: m29 },
|
||||
{ name: '20251210-add-workflow-type-support', module: m30 },
|
||||
{ name: '20251210-enhance-workflow-templates', module: m31 },
|
||||
{ name: '20251210-add-template-id-foreign-key', module: m32 },
|
||||
{ name: '20251210-create-dealer-claim-tables', module: m33 },
|
||||
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
|
||||
{ name: '20251211-create-internal-orders-table', module: m35 },
|
||||
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
|
||||
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
|
||||
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
|
||||
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
|
||||
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
||||
{ name: '20250120-create-dealers-table', module: m41 },
|
||||
{ name: '20250125-create-activity-types', module: m42 },
|
||||
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
||||
{ name: '20260123-fix-template-id-schema', module: m44 },
|
||||
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
|
||||
{ name: '20260210-add-raw-pwc-responses', module: m46 },
|
||||
{ name: '20260216-create-api-tokens', module: m47 },
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 },
|
||||
{ name: '20260217-add-is-service-to-expenses', module: m47a },
|
||||
{ name: '20260217-create-claim-invoice-items', module: m48a },
|
||||
{ name: '20260302-refine-dealer-claim-schema', module: m49 },
|
||||
{ name: '20260309-add-wfm-push-fields', module: m50 }
|
||||
{ name: '20260220-create-form16-tables', module: m50 },
|
||||
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m51 },
|
||||
{ name: '20260222000001-create-tds-26as-entries', module: m52 },
|
||||
{ name: '20260223000001-create-form-16-debit-notes', module: m53 },
|
||||
{ name: '20260224000001-create-form-16-26as-upload-log', module: m54 },
|
||||
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m55 },
|
||||
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m56 },
|
||||
{ name: '20260225100001-add-form16-archived-at', module: m57 },
|
||||
{ name: '20260303100001-drop-form16a-number-unique', module: m58 },
|
||||
{ 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 },
|
||||
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
@ -77,9 +157,10 @@ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<vo
|
||||
try {
|
||||
const tables = await queryInterface.showAllTables();
|
||||
|
||||
if (!tables.includes('migrations')) {
|
||||
const tableName = (t: string) => (typeof t === 'string' ? t.toLowerCase() : (t as any));
|
||||
if (!tables.some((t: string) => tableName(t) === 'migrations')) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE migrations (
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
21
src/scripts/run-form16-archive.ts
Normal file
21
src/scripts/run-form16-archive.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Run Form 16 archive once (records older than 5 FY get archived_at set).
|
||||
* Use this for manual runs; the scheduler runs daily at 02:00.
|
||||
*
|
||||
* Usage: npx ts-node -r tsconfig-paths/register src/scripts/run-form16-archive.ts
|
||||
*/
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
async function main() {
|
||||
const { runForm16ArchiveOldRecords } = await import('../services/form16Archive.service');
|
||||
const results = await runForm16ArchiveOldRecords();
|
||||
console.log('Form 16 archive run completed:', results);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
91
src/scripts/runMigrations.ts
Normal file
91
src/scripts/runMigrations.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Run pending DB migrations. Used by server on startup and by npm run migrate.
|
||||
* Export runMigrations(sequelize) - does not load secrets or connect; caller must pass connected sequelize.
|
||||
*/
|
||||
|
||||
import { QueryInterface, QueryTypes } from 'sequelize';
|
||||
import type { Sequelize } from 'sequelize';
|
||||
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
|
||||
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
|
||||
import * as m49 from '../migrations/20260220-create-form16-tables';
|
||||
import * as m50 from '../migrations/20260220000001-add-form16-ocr-extracted-data';
|
||||
import * as m51 from '../migrations/20260222000001-create-tds-26as-entries';
|
||||
import * as m52 from '../migrations/20260223000001-create-form-16-debit-notes';
|
||||
import * as m53 from '../migrations/20260224000001-create-form-16-26as-upload-log';
|
||||
import * as m53a from '../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables';
|
||||
import * as m54 from '../migrations/20260225000001-create-form16-non-submitted-notifications';
|
||||
import * as m54a from '../migrations/20260225100001-add-form16-archived-at';
|
||||
import * as m55 from '../migrations/20260303100001-drop-form16a-number-unique';
|
||||
import * as m56 from '../migrations/20260302-refine-dealer-claim-schema';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
module: any;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
|
||||
{ name: '20260217-add-is-service-to-expenses', module: m47 },
|
||||
{ name: '20260217-create-claim-invoice-items', module: m48 },
|
||||
{ name: '20260220-create-form16-tables', module: m49 },
|
||||
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m50 },
|
||||
{ name: '20260222000001-create-tds-26as-entries', module: m51 },
|
||||
{ name: '20260223000001-create-form-16-debit-notes', module: m52 },
|
||||
{ name: '20260224000001-create-form-16-26as-upload-log', module: m53 },
|
||||
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m53a },
|
||||
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m54 },
|
||||
{ name: '20260225100001-add-form16-archived-at', module: m54a },
|
||||
{ name: '20260302-refine-dealer-claim-schema', module: m56 },
|
||||
{ name: '20260303100001-drop-form16a-number-unique', module: m55 },
|
||||
];
|
||||
|
||||
async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<void> {
|
||||
const tables = await queryInterface.showAllTables();
|
||||
const tableName = (t: string) => (typeof t === 'string' ? t.toLowerCase() : (t as any));
|
||||
if (!tables.some((t) => tableName(t) === 'migrations')) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getExecutedMigrations(sequelize: Sequelize): Promise<string[]> {
|
||||
try {
|
||||
const results = (await sequelize.query('SELECT name FROM migrations ORDER BY id', {
|
||||
type: QueryTypes.SELECT,
|
||||
})) as { name: string }[];
|
||||
return results.map((r) => r.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function markMigrationExecuted(sequelize: Sequelize, name: string): Promise<void> {
|
||||
await sequelize.query(
|
||||
'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING',
|
||||
{ replacements: { name }, type: QueryTypes.RAW }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations. Call after DB is connected (e.g. after sequelize.authenticate()).
|
||||
* @returns Number of migrations applied (0 if already up-to-date).
|
||||
*/
|
||||
export async function runMigrations(sequelize: Sequelize): Promise<number> {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
await ensureMigrationsTable(queryInterface);
|
||||
const executedMigrations = await getExecutedMigrations(sequelize);
|
||||
const pendingMigrations = migrations.filter((m) => !executedMigrations.includes(m.name));
|
||||
if (pendingMigrations.length === 0) return 0;
|
||||
for (const migration of pendingMigrations) {
|
||||
await migration.module.up(queryInterface);
|
||||
await markMigrationExecuted(sequelize, migration.name);
|
||||
console.log(`✅ Migration: ${migration.name}`);
|
||||
}
|
||||
return pendingMigrations.length;
|
||||
}
|
||||
101
src/scripts/seed-admin-user.ts
Normal file
101
src/scripts/seed-admin-user.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Demo RE Admin user for local login (no Okta).
|
||||
* Login: admin@example.com / Admin@123
|
||||
* User is created on server start if missing; password is verified in auth.service with a default hash.
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from '../models/User';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const ADMIN_EMAIL = 'admin@example.com';
|
||||
const ADMIN_OKTA_SUB = 'local-ADMIN';
|
||||
const ADMIN_PASSWORD = 'Admin@123';
|
||||
const ADMIN_DISPLAY_NAME = 'RE Admin';
|
||||
|
||||
/** Ensure demo admin user exists in DB (called on server start). */
|
||||
export async function ensureDemoAdminUser(): Promise<void> {
|
||||
try {
|
||||
let user = await User.findOne({ where: { email: ADMIN_EMAIL } });
|
||||
const userData = {
|
||||
email: ADMIN_EMAIL,
|
||||
oktaSub: ADMIN_OKTA_SUB,
|
||||
displayName: ADMIN_DISPLAY_NAME,
|
||||
firstName: 'RE',
|
||||
lastName: 'Admin',
|
||||
isActive: true,
|
||||
role: 'ADMIN' as const,
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
};
|
||||
if (user) {
|
||||
await user.update(userData);
|
||||
} else {
|
||||
await User.create(userData);
|
||||
logger.info('[Demo Admin] Created demo admin user (admin@example.com). Sign in with Admin@123');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Demo Admin] Could not ensure demo admin user:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdminUser(): Promise<void> {
|
||||
try {
|
||||
logger.info('[Seed Admin User] Starting...');
|
||||
|
||||
let user = await User.findOne({
|
||||
where: { email: ADMIN_EMAIL },
|
||||
});
|
||||
|
||||
const userData = {
|
||||
email: ADMIN_EMAIL,
|
||||
oktaSub: ADMIN_OKTA_SUB,
|
||||
displayName: ADMIN_DISPLAY_NAME,
|
||||
firstName: 'RE',
|
||||
lastName: 'Admin',
|
||||
isActive: true,
|
||||
role: 'ADMIN' as const,
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
await user.update(userData);
|
||||
logger.info('[Seed Admin User] ✅ Updated existing user:', user.userId);
|
||||
} else {
|
||||
user = await User.create(userData);
|
||||
logger.info('[Seed Admin User] ✅ Created admin user:', user.userId);
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
||||
console.log('\n---------- Add these to your .env ----------');
|
||||
console.log('ENABLE_LOCAL_ADMIN_LOGIN=true');
|
||||
console.log(`LOCAL_ADMIN_EMAIL=${ADMIN_EMAIL}`);
|
||||
console.log(`LOCAL_ADMIN_PASSWORD_HASH=${hash}`);
|
||||
console.log('---------------------------------------------\n');
|
||||
console.log('Login with:');
|
||||
console.log(` Email: ${ADMIN_EMAIL}`);
|
||||
console.log(` Password: ${ADMIN_PASSWORD}`);
|
||||
console.log('---------------------------------------------\n');
|
||||
logger.info('[Seed Admin User] ✅ Done. Use the credentials above to log in as RE Admin.');
|
||||
} catch (error) {
|
||||
logger.error('[Seed Admin User] ❌ Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
sequelize
|
||||
.authenticate()
|
||||
.then(() => seedAdminUser())
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { seedAdminUser };
|
||||
81
src/scripts/seed-dealer-user.ts
Normal file
81
src/scripts/seed-dealer-user.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Seed dealer user for local login (Form 16A testing).
|
||||
* Creates:
|
||||
* 1. User with email testreflow@example.com, oktaSub local-TESTREFLOW (for dealer lookup)
|
||||
* 2. Dealer with dealer_principal_email_id = testreflow@example.com (via seed-test-dealer)
|
||||
* 3. Prints bcrypt hash for password T3$tr1Fl0w@12 — add to .env as LOCAL_DEALER_PASSWORD_HASH
|
||||
*
|
||||
* Login: username = TESTREFLOW, password = T3$tr1Fl0w@12
|
||||
* Enable: ENABLE_LOCAL_DEALER_LOGIN=true in .env
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from '../models/User';
|
||||
import { seedTestDealer } from './seed-test-dealer';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const DEALER_EMAIL = 'testreflow@example.com';
|
||||
const DEALER_OKTA_SUB = 'local-TESTREFLOW';
|
||||
const DEALER_PASSWORD = 'T3$tr1Fl0w@12';
|
||||
|
||||
async function seedDealerUser(): Promise<void> {
|
||||
try {
|
||||
logger.info('[Seed Dealer User] Starting...');
|
||||
|
||||
// 1. Create or update User (so getDealerCodeForUser finds dealer by email)
|
||||
let user = await User.findOne({
|
||||
where: { email: DEALER_EMAIL },
|
||||
});
|
||||
|
||||
const userData = {
|
||||
email: DEALER_EMAIL,
|
||||
oktaSub: DEALER_OKTA_SUB,
|
||||
displayName: 'Test Reflow Dealer',
|
||||
firstName: 'Test',
|
||||
lastName: 'Reflow',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
await user.update(userData);
|
||||
logger.info('[Seed Dealer User] ✅ Updated user:', user.userId);
|
||||
} else {
|
||||
user = await User.create({
|
||||
...userData,
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
role: 'USER',
|
||||
});
|
||||
logger.info('[Seed Dealer User] ✅ Created user:', user.userId);
|
||||
}
|
||||
|
||||
// 2. Ensure dealer exists (dealer_principal_email_id = testreflow@example.com)
|
||||
await seedTestDealer();
|
||||
|
||||
// 3. Generate and print hash for .env
|
||||
const hash = await bcrypt.hash(DEALER_PASSWORD, 10);
|
||||
console.log('\n---------- Add these to your .env ----------');
|
||||
console.log('ENABLE_LOCAL_DEALER_LOGIN=true');
|
||||
console.log(`LOCAL_DEALER_PASSWORD_HASH=${hash}`);
|
||||
console.log('---------------------------------------------\n');
|
||||
logger.info('[Seed Dealer User] ✅ Done. Login with username=TESTREFLOW, password=<your password>');
|
||||
} catch (error) {
|
||||
logger.error('[Seed Dealer User] ❌ Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
sequelize
|
||||
.authenticate()
|
||||
.then(() => seedDealerUser())
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { seedDealerUser };
|
||||
24
src/scripts/seed-demo-dealers.ts
Normal file
24
src/scripts/seed-demo-dealers.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Seed only the demo dealers used for Form 16 non-submitted list.
|
||||
* Run this to populate the dealers table with DEMO-NOSUB-001, 002, 003 so the
|
||||
* "Non-submitted Dealers" page shows data (select FY 2024-25 and Q1).
|
||||
*
|
||||
* Usage: npm run seed:demo-dealers
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import { ensureDemoNonSubmittedDealers } from './seed-demo-requests';
|
||||
|
||||
async function main() {
|
||||
await sequelize.authenticate();
|
||||
await ensureDemoNonSubmittedDealers();
|
||||
logger.info('[Seed Demo Dealers] Done. Open Non-submitted Dealers, select FY 2024-25 and Q1 to see them.');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
logger.error('[Seed Demo Dealers] Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
247
src/scripts/seed-demo-requests.ts
Normal file
247
src/scripts/seed-demo-requests.ts
Normal file
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Seed demo data: Form 16 submissions and other workflow requests.
|
||||
* Data is inserted into database tables only (no hardcoded data in app code).
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Run seed:admin-user (for admin user)
|
||||
* - Run seed:dealer-user (for dealer user + dealer record for Form 16)
|
||||
*
|
||||
* Usage: npm run seed:demo-requests
|
||||
*/
|
||||
|
||||
import { Op } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
import {
|
||||
User,
|
||||
WorkflowRequest,
|
||||
Form16aSubmission,
|
||||
Form16CreditNote,
|
||||
Activity,
|
||||
Document,
|
||||
Dealer,
|
||||
} from '../models';
|
||||
import { Priority, WorkflowStatus } from '../types/common.types';
|
||||
import { generateRequestNumber } from '../utils/helpers';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const DEMO_MARKER_TITLE_PREFIX = 'Form 16A - 2024-25'; // Used to detect existing demo Form 16 data
|
||||
const DEMO_CUSTOM_TITLE = '[Demo] Sample workflow request';
|
||||
|
||||
async function getOrResolveUsers(): Promise<{ adminUser: User | null; dealerUser: User | null; dealerCode: string | null }> {
|
||||
const adminUser = await User.findOne({ where: { email: 'admin@example.com' } });
|
||||
const dealerUser = await User.findOne({ where: { email: 'testreflow@example.com' } });
|
||||
let dealerCode: string | null = null;
|
||||
if (dealerUser) {
|
||||
const dealer = await Dealer.findOne({
|
||||
where: { dealerPrincipalEmailId: 'testreflow@example.com', isActive: true },
|
||||
attributes: ['salesCode', 'dlrcode'],
|
||||
});
|
||||
dealerCode = dealer?.salesCode ?? dealer?.dlrcode ?? null;
|
||||
}
|
||||
return { adminUser, dealerUser, dealerCode };
|
||||
}
|
||||
|
||||
async function hasExistingDemoForm16(): Promise<boolean> {
|
||||
const count = await WorkflowRequest.count({
|
||||
where: {
|
||||
templateType: 'FORM_16',
|
||||
title: { [Op.like]: `${DEMO_MARKER_TITLE_PREFIX}%` },
|
||||
},
|
||||
});
|
||||
return count >= 2;
|
||||
}
|
||||
|
||||
/** Demo dealers used for "non-submitted dealers" list: active dealers with no Form 16 submission for the demo FY/quarter. */
|
||||
const DEMO_NON_SUBMITTED_DEALERS = [
|
||||
{ dlrcode: 'DEMO-NOSUB-001', dealership: 'Demo Motors Mumbai', dealerPrincipalName: 'Demo Mumbai', dealerPrincipalEmailId: 'demo.nosub.1@example.com', state: 'Maharashtra', city: 'Mumbai' },
|
||||
{ dlrcode: 'DEMO-NOSUB-002', dealership: 'Demo Enfield Delhi', dealerPrincipalName: 'Demo Delhi', dealerPrincipalEmailId: 'demo.nosub.2@example.com', state: 'Delhi', city: 'New Delhi' },
|
||||
{ dlrcode: 'DEMO-NOSUB-003', dealership: 'Demo Royal Bangalore', dealerPrincipalName: 'Demo Bangalore', dealerPrincipalEmailId: 'demo.nosub.3@example.com', state: 'Karnataka', city: 'Bengaluru' },
|
||||
];
|
||||
|
||||
async function ensureDemoNonSubmittedDealers(): Promise<void> {
|
||||
for (const data of DEMO_NON_SUBMITTED_DEALERS) {
|
||||
const [dealer] = await Dealer.findOrCreate({
|
||||
where: { dlrcode: data.dlrcode },
|
||||
defaults: {
|
||||
...data,
|
||||
salesCode: data.dlrcode,
|
||||
isActive: true,
|
||||
} as any,
|
||||
});
|
||||
if (dealer && !dealer.isActive) {
|
||||
await dealer.update({ isActive: true });
|
||||
}
|
||||
}
|
||||
logger.info('[Seed Demo] Demo non-submitted dealers ensured (they have no Form 16 submissions for 2024-25).');
|
||||
}
|
||||
|
||||
async function seedDemoRequests(): Promise<void> {
|
||||
// Always ensure demo dealers exist first (for both submission and non-submitted lists).
|
||||
// These dealers are in the dealers table; only the test dealer gets Form 16 submissions below.
|
||||
await ensureDemoNonSubmittedDealers();
|
||||
|
||||
const { adminUser, dealerUser, dealerCode } = await getOrResolveUsers();
|
||||
const initiatorId = adminUser?.userId ?? dealerUser?.userId;
|
||||
if (!initiatorId) {
|
||||
logger.warn('[Seed Demo] No admin or dealer user found. Run seed:admin-user and seed:dealer-user first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await hasExistingDemoForm16()) {
|
||||
logger.info('[Seed Demo] Demo Form 16 requests already present. Skipping to avoid duplicates.');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// ---- 1) Create a few CUSTOM (non–Form 16) requests so "other requests" are visible ----
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const requestNumber = await generateRequestNumber();
|
||||
await WorkflowRequest.create({
|
||||
requestNumber,
|
||||
initiatorId,
|
||||
templateType: 'CUSTOM',
|
||||
workflowType: 'NON_TEMPLATIZED',
|
||||
title: `${DEMO_CUSTOM_TITLE} ${i}`,
|
||||
description: `Demo workflow request for testing. Created by seed script.`,
|
||||
priority: Priority.STANDARD,
|
||||
status: i === 1 ? WorkflowStatus.PENDING : WorkflowStatus.PENDING,
|
||||
currentLevel: 1,
|
||||
totalLevels: 1,
|
||||
totalTatHours: 48,
|
||||
submissionDate: now,
|
||||
isDraft: false,
|
||||
isDeleted: false,
|
||||
isPaused: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 2) Create Form 16 requests + submissions (and optional credit notes / activity / docs) ----
|
||||
if (!dealerUser || !dealerCode) {
|
||||
logger.warn('[Seed Demo] Dealer user or dealer code missing. Form 16 demo requests skipped. Run seed:dealer-user.');
|
||||
} else {
|
||||
const form16InitiatorId = dealerUser.userId;
|
||||
const form16Demos = [
|
||||
{ fy: '2024-25', quarter: 'Q1', deductor: 'Royal Enfield Motors Ltd', tds: 15000, total: 150000, cnAmount: 15000, withCreditNote: true, withdrawn: false },
|
||||
{ fy: '2024-25', quarter: 'Q2', deductor: 'Royal Enfield Motors Ltd', tds: 18000, total: 180000, cnAmount: 18000, withCreditNote: true, withdrawn: true },
|
||||
{ fy: '2024-25', quarter: 'Q3', deductor: 'Royal Enfield Motors Ltd', tds: 12000, total: 120000, cnAmount: null, withCreditNote: false, withdrawn: false },
|
||||
];
|
||||
|
||||
for (const demo of form16Demos) {
|
||||
const requestNumber = await generateRequestNumber();
|
||||
const title = `Form 16A - ${demo.fy} ${demo.quarter}`;
|
||||
const description = `Form 16A TDS certificate submission. Deductor: ${demo.deductor}.`;
|
||||
|
||||
const workflow = await WorkflowRequest.create({
|
||||
requestNumber,
|
||||
initiatorId: form16InitiatorId,
|
||||
templateType: 'FORM_16',
|
||||
workflowType: 'FORM_16',
|
||||
title,
|
||||
description,
|
||||
priority: Priority.STANDARD,
|
||||
status: demo.withCreditNote && !demo.withdrawn ? WorkflowStatus.CLOSED : WorkflowStatus.PENDING,
|
||||
currentLevel: 1,
|
||||
totalLevels: 1,
|
||||
totalTatHours: 0,
|
||||
submissionDate: now,
|
||||
closureDate: demo.withCreditNote && !demo.withdrawn ? now : undefined,
|
||||
isDraft: false,
|
||||
isDeleted: false,
|
||||
isPaused: false,
|
||||
});
|
||||
|
||||
const requestId = (workflow as any).requestId;
|
||||
const form16aNumber = `DEMO-F16-${demo.quarter}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const submission = await Form16aSubmission.create({
|
||||
requestId,
|
||||
dealerCode,
|
||||
form16aNumber,
|
||||
financialYear: demo.fy,
|
||||
quarter: demo.quarter,
|
||||
version: 1,
|
||||
tdsAmount: demo.tds,
|
||||
totalAmount: demo.total,
|
||||
tanNumber: 'BLRE00001E',
|
||||
deductorName: demo.deductor,
|
||||
documentUrl: 'https://example.com/demo-form16a.pdf',
|
||||
status: 'pending',
|
||||
submittedDate: now,
|
||||
});
|
||||
|
||||
// Activity: dealer submitted Form 16
|
||||
await Activity.create({
|
||||
requestId,
|
||||
userId: form16InitiatorId,
|
||||
userName: (dealerUser as any).displayName || (dealerUser as any).firstName + ' ' + (dealerUser as any).lastName || 'Dealer',
|
||||
activityType: 'FORM_16_SUBMITTED',
|
||||
activityDescription: `Dealer submitted Form 16A for ${demo.fy} ${demo.quarter}. Certificate: ${form16aNumber}.`,
|
||||
activityCategory: 'submission',
|
||||
isSystemEvent: false,
|
||||
});
|
||||
|
||||
// Document: attached Form 16 (placeholder so Docs tab shows an entry)
|
||||
await Document.create({
|
||||
requestId,
|
||||
uploadedBy: form16InitiatorId,
|
||||
fileName: `form16a-${demo.quarter}.pdf`,
|
||||
originalFileName: `Form16A_${demo.fy}_${demo.quarter}.pdf`,
|
||||
fileType: 'application/pdf',
|
||||
fileExtension: 'pdf',
|
||||
fileSize: 1024,
|
||||
filePath: `form16-demo/${requestId}/form16a.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
checksum: 'demo-checksum-' + requestId.slice(0, 8),
|
||||
isGoogleDoc: false,
|
||||
category: 'SUPPORTING',
|
||||
version: 1,
|
||||
isDeleted: false,
|
||||
downloadCount: 0,
|
||||
uploadedAt: now,
|
||||
});
|
||||
|
||||
if (demo.withCreditNote && demo.cnAmount != null) {
|
||||
const creditNote = await Form16CreditNote.create({
|
||||
submissionId: submission.id,
|
||||
creditNoteNumber: `DEMO-CN-${demo.quarter}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
sapDocumentNumber: `SAP-${demo.quarter}-DEMO`,
|
||||
amount: demo.cnAmount,
|
||||
issueDate: now.toISOString().slice(0, 10) as unknown as Date,
|
||||
financialYear: demo.fy,
|
||||
quarter: demo.quarter,
|
||||
status: demo.withdrawn ? 'withdrawn' : 'issued',
|
||||
issuedBy: adminUser?.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await Activity.create({
|
||||
requestId,
|
||||
userId: adminUser?.userId ?? undefined,
|
||||
userName: 'RE Admin',
|
||||
activityType: demo.withdrawn ? 'CREDIT_NOTE_WITHDRAWN' : 'CREDIT_NOTE_ISSUED',
|
||||
activityDescription: demo.withdrawn
|
||||
? `Credit note ${creditNote.creditNoteNumber} was withdrawn by RE user.`
|
||||
: `Credit note ${creditNote.creditNoteNumber} generated for ${demo.fy} ${demo.quarter}.`,
|
||||
activityCategory: 'workflow',
|
||||
isSystemEvent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[Seed Demo] Demo requests, Form 16 submissions, and non-submitted dealers demo data created successfully.');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
sequelize
|
||||
.authenticate()
|
||||
.then(() => seedDemoRequests())
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
logger.error('[Seed Demo] Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { seedDemoRequests, ensureDemoNonSubmittedDealers };
|
||||
85
src/scripts/seed-rohit-user.ts
Normal file
85
src/scripts/seed-rohit-user.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Seed user: Rohit Mandiwal (dealer).
|
||||
* Creates user in users table and prints bcrypt hash for password login.
|
||||
*
|
||||
* User: email = rohitm_ext@royalenfield.com, name = Rohit Mandiwal, role = USER (dealer).
|
||||
* Password: Test@123
|
||||
*
|
||||
* After running, add to .env for password login:
|
||||
* LOCAL_DEALER_2_EMAIL=rohitm_ext@royalenfield.com
|
||||
* LOCAL_DEALER_2_PASSWORD_HASH=<hash printed below>
|
||||
* Login with username = rohitm_ext@royalenfield.com, password = Test@123
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from '../models/User';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const EMAIL = 'rohitm_ext@royalenfield.com';
|
||||
const OLD_EMAIL = 'rohit.m.ext@royalenfield.com';
|
||||
const OKTA_SUB = 'local-ROHIT-MANDIWAL';
|
||||
const PASSWORD = 'Test@123';
|
||||
const DISPLAY_NAME = 'Rohit Mandiwal';
|
||||
const FIRST_NAME = 'Rohit';
|
||||
const LAST_NAME = 'Mandiwal';
|
||||
|
||||
async function seedRohitUser(): Promise<void> {
|
||||
try {
|
||||
logger.info('[Seed Rohit User] Starting...');
|
||||
|
||||
let user = await User.findOne({ where: { email: EMAIL } });
|
||||
if (!user) {
|
||||
const oldUser = await User.findOne({ where: { email: OLD_EMAIL } });
|
||||
if (oldUser) {
|
||||
await oldUser.update({ email: EMAIL });
|
||||
user = oldUser;
|
||||
logger.info('[Seed Rohit User] Updated existing user email to:', EMAIL);
|
||||
}
|
||||
}
|
||||
|
||||
const userData = {
|
||||
email: EMAIL,
|
||||
oktaSub: OKTA_SUB,
|
||||
displayName: DISPLAY_NAME,
|
||||
firstName: FIRST_NAME,
|
||||
lastName: LAST_NAME,
|
||||
isActive: true,
|
||||
role: 'USER' as const,
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
await user.update(userData);
|
||||
logger.info('[Seed Rohit User] Updated user:', user.userId);
|
||||
} else {
|
||||
user = await User.create(userData);
|
||||
logger.info('[Seed Rohit User] Created user:', user.userId);
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(PASSWORD, 10);
|
||||
console.log('\n---------- Add these to your .env for password login ----------');
|
||||
console.log('LOCAL_DEALER_2_EMAIL=rohitm_ext@royalenfield.com');
|
||||
console.log(`LOCAL_DEALER_2_PASSWORD_HASH=${hash}`);
|
||||
console.log('----------------------------------------------------------------');
|
||||
console.log('Login: username = rohitm_ext@royalenfield.com, password = Test@123\n');
|
||||
} catch (error) {
|
||||
logger.error('[Seed Rohit User] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
sequelize
|
||||
.authenticate()
|
||||
.then(() => seedRohitUser())
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { seedRohitUser };
|
||||
65
src/scripts/test-wfm-archive.ts
Normal file
65
src/scripts/test-wfm-archive.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { wfmFileService } from '../services/wfmFile.service';
|
||||
import logger from '../utils/logger';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function testWfmArchiveFinal() {
|
||||
try {
|
||||
console.log('Starting WFM Final Verification Test...');
|
||||
|
||||
// 1. Test Claim CSV Archiving (GST)
|
||||
const claimData = [{
|
||||
TRNS_UNIQ_NO: 'TEST_FINAL_123',
|
||||
CLAIM_NUMBER: 'CLI_FINAL_001',
|
||||
DEALER_CODE: '6059',
|
||||
CLAIM_AMT: 1000.00
|
||||
}];
|
||||
|
||||
const claimFileName = `ARCHIVE_FINAL_CLAIM_${Date.now()}.csv`;
|
||||
console.log(`Testing Claim CSV: ${claimFileName}`);
|
||||
const claimFilePath = await wfmFileService.generateIncomingClaimCSV(claimData, claimFileName, false); // false = GST
|
||||
|
||||
console.log(`Main Claim CSV: ${claimFilePath}`);
|
||||
const expectedArchivePath = claimFilePath.replace('WFM_MAIN', 'WFM_ARACHIVE');
|
||||
console.log(`Archive Claim CSV expected at: ${expectedArchivePath}`);
|
||||
|
||||
if (fs.existsSync(claimFilePath) && fs.existsSync(expectedArchivePath)) {
|
||||
console.log('✅ Claim CSV and Archive verified!');
|
||||
} else {
|
||||
if (!fs.existsSync(claimFilePath)) console.error('❌ Main Claim CSV not found!');
|
||||
if (!fs.existsSync(expectedArchivePath)) console.error('❌ Archive Claim CSV not found!');
|
||||
}
|
||||
|
||||
// 2. Test Form 16 CSV Archiving (Credit)
|
||||
const form16Data = [{
|
||||
DEALER_CODE: '6059',
|
||||
QUARTER: 'Q1',
|
||||
YEAR: '2024-25',
|
||||
AMOUNT: 500.00
|
||||
}];
|
||||
|
||||
const form16FileName = `ARCHIVE_FINAL_FORM16_${Date.now()}.csv`;
|
||||
console.log(`\nTesting Form 16 CSV: ${form16FileName}`);
|
||||
const form16FilePath = await wfmFileService.generateForm16IncomingCSV(form16Data, form16FileName, 'credit');
|
||||
|
||||
console.log(`Main Form 16 CSV: ${form16FilePath}`);
|
||||
const expectedForm16ArchivePath = form16FilePath.replace('WFM_MAIN', 'WFM_ARACHIVE');
|
||||
console.log(`Archive Form 16 CSV expected at: ${expectedForm16ArchivePath}`);
|
||||
|
||||
if (fs.existsSync(form16FilePath) && fs.existsSync(expectedForm16ArchivePath)) {
|
||||
console.log('✅ Form 16 CSV and Archive verified!');
|
||||
} else {
|
||||
if (!fs.existsSync(form16FilePath)) console.error('❌ Main Form 16 CSV not found!');
|
||||
if (!fs.existsSync(expectedForm16ArchivePath)) console.error('❌ Archive Form 16 CSV not found!');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Verification failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testWfmArchiveFinal();
|
||||
@ -1,4 +1,5 @@
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
@ -9,6 +10,42 @@ dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
import { stopQueueMetrics } from './utils/queueMetrics';
|
||||
|
||||
const PORT: number = parseInt(process.env.PORT || '5000', 10);
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const MAX_PORT_ATTEMPTS = isDev ? 6 : 1; // In dev try PORT..PORT+5 if in use
|
||||
|
||||
/**
|
||||
* Check if a port is free (no one listening).
|
||||
*/
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const onError = () => {
|
||||
socket.destroy();
|
||||
resolve(true); // Error (e.g. ECONNREFUSED) means nothing listening → port free
|
||||
};
|
||||
socket.setTimeout(200);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.connect(port, '127.0.0.1', () => {
|
||||
socket.destroy();
|
||||
resolve(false); // Connected → something is listening → port in use
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first free port in [startPort, startPort + maxAttempts).
|
||||
*/
|
||||
async function findFreePort(startPort: number, maxAttempts: number): Promise<number> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const port = startPort + i;
|
||||
if (await isPortFree(port)) return port;
|
||||
}
|
||||
return startPort; // Fallback to original; listen will then fail with EADDRINUSE
|
||||
}
|
||||
|
||||
// Start server
|
||||
const startServer = async (): Promise<void> => {
|
||||
@ -61,6 +98,14 @@ const startServer = async (): Promise<void> => {
|
||||
console.error('⚠️ Activity type seeding error:', error);
|
||||
}
|
||||
|
||||
// Ensure demo admin user exists (admin@example.com / Admin@123)
|
||||
const { ensureDemoAdminUser } = require('./scripts/seed-admin-user');
|
||||
try {
|
||||
await ensureDemoAdminUser();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Demo admin user setup warning:', error);
|
||||
}
|
||||
|
||||
// Initialize holidays cache for TAT calculations
|
||||
try {
|
||||
await initializeHolidaysCache();
|
||||
@ -70,12 +115,43 @@ const startServer = async (): Promise<void> => {
|
||||
|
||||
// Start scheduled jobs
|
||||
startPauseResumeJob();
|
||||
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
|
||||
startForm16NotificationJobs();
|
||||
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
|
||||
startForm16ArchiveJob();
|
||||
|
||||
// Initialize queue metrics collection for Prometheus
|
||||
initializeQueueMetrics();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
|
||||
// In development, if default port is in use (e.g. previous run or another app), try next ports
|
||||
let portToUse = PORT;
|
||||
if (isDev) {
|
||||
const freePort = await findFreePort(PORT, MAX_PORT_ATTEMPTS);
|
||||
if (freePort !== PORT) {
|
||||
console.warn(`⚠️ Port ${PORT} is in use. Using port ${freePort} instead.`);
|
||||
console.warn(` Update frontend .env VITE_API_BASE_URL to http://localhost:${freePort}/api/v1 if needed.`);
|
||||
portToUse = freePort;
|
||||
}
|
||||
}
|
||||
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error('');
|
||||
console.error('❌ Port ' + portToUse + ' is already in use.');
|
||||
console.error(' Another process (often a previous backend instance) is using it.');
|
||||
console.error(' Windows: netstat -ano | findstr :' + portToUse);
|
||||
console.error(' Then: taskkill /PID <PID> /F');
|
||||
console.error(' Or run in a single terminal and avoid starting backend twice.');
|
||||
console.error('');
|
||||
} else {
|
||||
console.error('❌ Server error:', err);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(portToUse, () => {
|
||||
console.log(`🚀 Server running on port ${portToUse} | ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(` API base: http://localhost:${portToUse}/api/v1 (ensure frontend uses this and CORS allows your origin)`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Unable to start server:', error);
|
||||
|
||||
@ -60,14 +60,12 @@ export class ActivityTypeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new activity type
|
||||
*/
|
||||
async createActivityType(activityTypeData: {
|
||||
title: string;
|
||||
itemCode?: string;
|
||||
taxationType?: string;
|
||||
sapRefNo?: string;
|
||||
creditPostingOn?: string;
|
||||
createdBy: string;
|
||||
}): Promise<ActivityType> {
|
||||
try {
|
||||
@ -104,6 +102,7 @@ export class ActivityTypeService {
|
||||
itemCode?: string;
|
||||
taxationType?: string;
|
||||
sapRefNo?: string;
|
||||
creditPostingOn?: string;
|
||||
isActive?: boolean;
|
||||
}, updatedBy: string): Promise<ActivityType | null> {
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user