Compare commits
No commits in common. "a67eb9da3c02a38076f736848f3335f70899dd80" and "b6ca3c7f9ffd8563fe0663986a114e1e132b82e1" have entirely different histories.
a67eb9da3c
...
b6ca3c7f9f
325
Jenkinsfile
vendored
Normal file
325
Jenkinsfile
vendored
Normal file
@ -0,0 +1,325 @@
|
||||
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,41 +83,6 @@ 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-CYKuaJ7k.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CxsBWvVP.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-BATWUvr6.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
import{a as s}from"./index-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};
|
||||
File diff suppressed because one or more lines are too long
64
build/assets/index-CULgQ-8S.js
Normal file
64
build/assets/index-CULgQ-8S.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
1
build/assets/index-XBJXaMj2.css
Normal file
1
build/assets/index-XBJXaMj2.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
2
build/assets/ui-vendor-CX5oLBI_.js
Normal file
2
build/assets/ui-vendor-CX5oLBI_.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-CYKuaJ7k.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CULgQ-8S.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CxsBWvVP.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CX5oLBI_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bue1DC_k.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XBJXaMj2.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Binary file not shown.
@ -14,7 +14,7 @@ module.exports = {
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
moduleNameMapper: {
|
||||
moduleNameMapping: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@controllers/(.*)$': '<rootDir>/src/controllers/$1',
|
||||
'^@services/(.*)$': '<rootDir>/src/services/$1',
|
||||
@ -23,6 +23,5 @@ 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,7 +11,6 @@
|
||||
"@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",
|
||||
@ -35,7 +34,6 @@
|
||||
"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",
|
||||
@ -1700,15 +1698,6 @@
|
||||
"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",
|
||||
@ -2441,190 +2430,6 @@
|
||||
"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",
|
||||
@ -10373,38 +10178,6 @@
|
||||
"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",
|
||||
|
||||
14
package.json
14
package.json
@ -19,23 +19,12 @@
|
||||
"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",
|
||||
"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",
|
||||
"clear:form16-and-demo": "ts-node -r tsconfig-paths/register src/scripts/clear-form16-and-demo-data.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"
|
||||
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
|
||||
},
|
||||
"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",
|
||||
@ -59,7 +48,6 @@
|
||||
"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",
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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,7 +11,6 @@ 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';
|
||||
@ -113,11 +112,6 @@ 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' }));
|
||||
@ -147,7 +141,7 @@ app.get('/health', (_req: express.Request, res: express.Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Mount API routes (form16 already mounted above before body parser)
|
||||
// Mount API routes - MUST be before static file serving
|
||||
app.use('/api/v1', routes);
|
||||
|
||||
// Serve uploaded files statically
|
||||
|
||||
@ -512,203 +512,6 @@ 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 = 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)
|
||||
|
||||
@ -1,743 +0,0 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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/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/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/requests/:requestId/generate-credit-note
|
||||
* RE only. Manually generate credit note (e.g. when OCR was partial). Body: { amount: number }.
|
||||
*/
|
||||
async generateForm16CreditNote(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 body = (req.body || {}) as { amount?: number };
|
||||
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
|
||||
const result = await form16Service.generateForm16CreditNoteManually(requestId, userId, amount);
|
||||
const { triggerForm16ManualCreditNoteNotification } = await import('../services/form16Notification.service');
|
||||
const cnNumber = (result.creditNote as any)?.creditNoteNumber;
|
||||
if (cnNumber) {
|
||||
triggerForm16ManualCreditNoteNotification(requestId, cnNumber).catch((err) =>
|
||||
logger.error('[Form16Controller] Manual credit note notification failed:', err)
|
||||
);
|
||||
}
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ creditNote: result.creditNote, submission: result.submission },
|
||||
'Credit note generated (manually approved)'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] generateForm16CreditNote error:', error);
|
||||
return ResponseHandler.error(res, errorMessage, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/credit-notes/:id/generate-debit-note
|
||||
* RE only. Generate debit note for a credit note (dealer + credit note number + amount → SAP simulation → save debit note).
|
||||
*/
|
||||
async generateForm16DebitNote(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).user?.userId;
|
||||
if (!userId) return ResponseHandler.unauthorized(res, 'Authentication required');
|
||||
const creditNoteId = parseInt((req.params as { id: string }).id, 10);
|
||||
if (Number.isNaN(creditNoteId) || creditNoteId <= 0) {
|
||||
return ResponseHandler.error(res, 'Valid credit note id is required', 400);
|
||||
}
|
||||
const body = (req.body || {}) as { amount?: number };
|
||||
const amount = typeof body.amount === 'number' ? body.amount : parseFloat(String(body.amount || 0));
|
||||
const result = await form16Service.generateForm16DebitNoteForCreditNote(creditNoteId, userId, amount);
|
||||
return ResponseHandler.success(
|
||||
res,
|
||||
{ debitNote: result.debitNote, creditNote: result.creditNote },
|
||||
'Debit note generated'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[Form16Controller] generateForm16DebitNote error:', error);
|
||||
return ResponseHandler.error(res, errorMessage, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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',
|
||||
{
|
||||
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')) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const form16Controller = new Form16Controller();
|
||||
@ -14,26 +14,11 @@ 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 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 {
|
||||
@ -464,20 +449,6 @@ 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';
|
||||
@ -507,15 +478,6 @@ 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';
|
||||
@ -528,14 +490,12 @@ 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 (financialYear, quarter for Form 16)
|
||||
// Extract filter parameters
|
||||
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,
|
||||
@ -546,15 +506,7 @@ export class WorkflowController {
|
||||
endDate: req.query.endDate as string | undefined,
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
const result = await workflowService.listWorkflows(page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'Workflows fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -583,8 +535,7 @@ export class WorkflowController {
|
||||
|
||||
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
let result = await workflowService.listMyRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
const result = await workflowService.listMyRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'My requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -615,13 +566,10 @@ 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, financialYear, quarter, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
let result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'Participant requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -651,8 +599,7 @@ export class WorkflowController {
|
||||
|
||||
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
let result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -666,22 +613,19 @@ 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 (Form 16: financialYear, quarter when templateType is FORM_16)
|
||||
// Extract filter parameters
|
||||
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,
|
||||
templateType: req.query.templateType as string | undefined
|
||||
};
|
||||
|
||||
// Extract sorting parameters
|
||||
const sortBy = req.query.sortBy as string | undefined;
|
||||
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
|
||||
|
||||
let result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
ResponseHandler.success(res, result, 'Open requests for user fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -695,22 +639,19 @@ 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 (Form 16: financialYear, quarter when templateType is FORM_16)
|
||||
// Extract filter parameters
|
||||
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,
|
||||
templateType: req.query.templateType as string | undefined
|
||||
};
|
||||
|
||||
// Extract sorting parameters
|
||||
const sortBy = req.query.sortBy as string | undefined;
|
||||
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
|
||||
|
||||
let result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
result = await filterForm16FromListIfNeeded(req, result);
|
||||
const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
|
||||
ResponseHandler.success(res, result, 'Closed requests by user fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@ -883,11 +883,6 @@ export function getTemplateTypeLabel(templateType?: string): string {
|
||||
return 'Dealer Claim';
|
||||
}
|
||||
|
||||
// Form 16 (Form 16A TDS)
|
||||
if (upper === 'FORM_16' || upper === 'FORM16') {
|
||||
return 'Form 16';
|
||||
}
|
||||
|
||||
// Handle template type
|
||||
if (upper === 'TEMPLATE') {
|
||||
return 'Template';
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
import { getForm16Config } from '../services/form16Config.service';
|
||||
import { runForm16AlertSubmitJob, runForm16ReminderJob } from '../services/form16Notification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const TZ = process.env.TZ || 'Asia/Kolkata';
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
} 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})`);
|
||||
}
|
||||
@ -31,18 +31,13 @@ const getAllowedOrigins = (): string[] | boolean => {
|
||||
}
|
||||
|
||||
// Parse comma-separated URLs or use single URL
|
||||
let origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
||||
const 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 & { code?: string },
|
||||
error: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@ -15,24 +15,6 @@ 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',
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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,10 +390,6 @@ 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
|
||||
@ -414,13 +410,8 @@ export async function updateQueueMetrics(queueName: string, queue: any): Promise
|
||||
queueJobsFailed.set({ queue_name: queueName }, failed);
|
||||
queueJobsDelayed.set({ queue_name: queueName }, delayed);
|
||||
} catch (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.`);
|
||||
}
|
||||
// Silently fail to avoid breaking metrics collection
|
||||
console.error(`[Metrics] Failed to update queue metrics for ${queueName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
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 };
|
||||
@ -1,82 +0,0 @@
|
||||
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 };
|
||||
@ -1,140 +0,0 @@
|
||||
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;
|
||||
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 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',
|
||||
},
|
||||
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 };
|
||||
@ -1,129 +0,0 @@
|
||||
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;
|
||||
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 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',
|
||||
},
|
||||
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 };
|
||||
@ -1,137 +0,0 @@
|
||||
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 };
|
||||
@ -1,72 +0,0 @@
|
||||
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 };
|
||||
@ -1,108 +0,0 @@
|
||||
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 };
|
||||
@ -1,209 +0,0 @@
|
||||
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 };
|
||||
@ -1,174 +0,0 @@
|
||||
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' | 'FORM_16';
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
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' | 'FORM_16';
|
||||
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
public workflowType?: string;
|
||||
public templateId?: string;
|
||||
public title!: string;
|
||||
|
||||
@ -29,15 +29,6 @@ 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';
|
||||
|
||||
// Define associations
|
||||
const defineAssociations = () => {
|
||||
@ -157,25 +148,6 @@ 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
|
||||
};
|
||||
@ -213,16 +185,7 @@ export {
|
||||
DealerClaimHistory,
|
||||
ClaimInvoice,
|
||||
ClaimInvoiceItem,
|
||||
ClaimCreditNote,
|
||||
Form16aSubmission,
|
||||
Form16CreditNote,
|
||||
Form16DebitNote,
|
||||
Tds26asEntry,
|
||||
Form1626asUploadLog,
|
||||
Form16NonSubmittedNotification,
|
||||
Form1626asQuarterSnapshot,
|
||||
Form16QuarterStatus,
|
||||
Form16LedgerEntry
|
||||
ClaimCreditNote
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -4,9 +4,6 @@ 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', {
|
||||
@ -26,11 +23,7 @@ try {
|
||||
});
|
||||
|
||||
pauseResumeQueue.on('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.error('[Pause Resume Queue] Queue error:', error);
|
||||
});
|
||||
|
||||
logger.info('[Pause Resume Queue] ✅ Queue initialized');
|
||||
|
||||
@ -4,8 +4,6 @@ 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, {
|
||||
@ -36,17 +34,14 @@ 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) {
|
||||
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.');
|
||||
}
|
||||
logger.warn('[Pause Resume Worker] Connection issue (Redis may be unavailable):', err?.message || errorCode || String(err));
|
||||
} else {
|
||||
// Log full error details for non-connection errors to diagnose issues
|
||||
logger.error('[Pause Resume Worker] Error:', {
|
||||
|
||||
@ -4,20 +4,12 @@ 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) {
|
||||
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.');
|
||||
}
|
||||
logger.error('[Redis] Connection failed after 5 attempts');
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 2000, 10000);
|
||||
@ -48,19 +40,11 @@ export const getSharedRedisConnection = (): IORedis => {
|
||||
});
|
||||
|
||||
sharedConnection.on('error', (err) => {
|
||||
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)');
|
||||
}
|
||||
logger.error('[Redis] Connection error:', err.message);
|
||||
});
|
||||
|
||||
sharedConnection.on('close', () => {
|
||||
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)');
|
||||
}
|
||||
logger.warn('[Redis] Connection closed');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -4,9 +4,6 @@ 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', {
|
||||
@ -23,11 +20,7 @@ try {
|
||||
});
|
||||
|
||||
tatQueue.on('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.error('[TAT Queue] Queue error:', error);
|
||||
});
|
||||
|
||||
logger.info('[TAT Queue] ✅ Queue initialized');
|
||||
|
||||
@ -4,8 +4,6 @@ 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, {
|
||||
@ -36,15 +34,12 @@ 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) {
|
||||
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.');
|
||||
}
|
||||
logger.warn('[TAT Worker] Connection issue (Redis may be unavailable):', err?.message || err);
|
||||
} 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) {
|
||||
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'];
|
||||
}
|
||||
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
|
||||
} 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.');
|
||||
@ -33,10 +33,6 @@ export function initSocket(httpServer: any) {
|
||||
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(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,8 +26,6 @@ import {
|
||||
getAllConfigurations,
|
||||
updateConfiguration,
|
||||
resetConfiguration,
|
||||
getForm16Config,
|
||||
putForm16Config,
|
||||
updateUserRole,
|
||||
getUsersByRole,
|
||||
getRoleStatistics,
|
||||
@ -123,21 +121,6 @@ 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', putForm16Config);
|
||||
|
||||
// ==================== User Role Management Routes (RBAC) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
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 { 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))
|
||||
);
|
||||
router.get(
|
||||
'/credit-notes/:id',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.getCreditNoteById.bind(form16Controller))
|
||||
);
|
||||
router.get(
|
||||
'/requests/:requestId/credit-note',
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.getCreditNoteByRequest.bind(form16Controller))
|
||||
);
|
||||
// RE only: Form 16 request actions (cancel, resubmission needed, manual credit note)
|
||||
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))
|
||||
);
|
||||
router.post(
|
||||
'/requests/:requestId/generate-credit-note',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.generateForm16CreditNote.bind(form16Controller))
|
||||
);
|
||||
|
||||
// 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))
|
||||
);
|
||||
|
||||
// RE only: generate debit note for a credit note (hits SAP simulation; replace with real SAP later).
|
||||
router.post(
|
||||
'/credit-notes/:id/generate-debit-note',
|
||||
requireForm16ReOnly,
|
||||
requireForm16SubmissionAccess,
|
||||
asyncHandler(form16Controller.generateForm16DebitNote.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,17 +1,4 @@
|
||||
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';
|
||||
@ -33,7 +20,16 @@ import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||
import apiTokenRoutes from './apiToken.routes';
|
||||
import antivirusRoutes from './antivirus.routes';
|
||||
import dealerExternalRoutes from './dealerExternal.routes';
|
||||
import form16Routes from './form16.routes';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||
import {
|
||||
authLimiter,
|
||||
uploadLimiter,
|
||||
adminLimiter,
|
||||
aiLimiter,
|
||||
webhookLimiter,
|
||||
generalApiLimiter,
|
||||
} from '../middlewares/rateLimiter.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -46,26 +42,6 @@ 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
|
||||
@ -97,7 +73,6 @@ 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;
|
||||
|
||||
@ -160,20 +160,9 @@ 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');
|
||||
const m47a = require('../migrations/20260217-add-is-service-to-expenses');
|
||||
const m48a = require('../migrations/20260217-create-claim-invoice-items');
|
||||
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
|
||||
const m49 = require('../migrations/20260302-refine-dealer-claim-schema');
|
||||
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 m50 = require('../migrations/20260309-add-wfm-push-fields');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -226,30 +215,18 @@ 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 },
|
||||
{ name: '20260217-add-is-service-to-expenses', module: m47a },
|
||||
{ name: '20260217-create-claim-invoice-items', module: m48a },
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
|
||||
{ name: '20260302-refine-dealer-claim-schema', module: m49 },
|
||||
{ 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: '20260309-add-wfm-push-fields', module: m50 },
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
const { sequelize } = require('../config/database');
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
// Ensure migrations tracking table exists (case-insensitive for PostgreSQL)
|
||||
// Ensure migrations tracking table exists
|
||||
const tables = await queryInterface.showAllTables();
|
||||
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')) {
|
||||
if (!tables.includes('migrations')) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
/**
|
||||
* 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,94 +48,26 @@ 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/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 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/20260302-refine-dealer-claim-schema';
|
||||
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 m50 from '../migrations/20260309-add-wfm-push-fields';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
module: any;
|
||||
}
|
||||
|
||||
// Define ALL migrations in order. Required for fresh DB (e.g. UAT/production).
|
||||
// Order matters: base tables first, then tables that reference them.
|
||||
// Define all migrations in order
|
||||
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
|
||||
const migrations: Migration[] = [
|
||||
{ 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 },
|
||||
// ... 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: '20260302-refine-dealer-claim-schema', module: m49 },
|
||||
{ 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: '20260309-add-wfm-push-fields', module: m50 }
|
||||
];
|
||||
|
||||
/**
|
||||
@ -145,10 +77,9 @@ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<vo
|
||||
try {
|
||||
const tables = await queryInterface.showAllTables();
|
||||
|
||||
const tableName = (t: string) => (typeof t === 'string' ? t.toLowerCase() : (t as any));
|
||||
if (!tables.some((t: string) => tableName(t) === 'migrations')) {
|
||||
if (!tables.includes('migrations')) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
CREATE TABLE migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,5 +1,4 @@
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
@ -10,42 +9,6 @@ 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> => {
|
||||
@ -98,14 +61,6 @@ 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();
|
||||
@ -115,43 +70,12 @@ 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();
|
||||
|
||||
// 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)`);
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Unable to start server:', error);
|
||||
|
||||
@ -59,9 +59,10 @@ export async function seedDefaultActivityTypes(): Promise<void> {
|
||||
systemUserId = (systemUser[0] as any).user_id;
|
||||
}
|
||||
|
||||
const hasAdminUser = !!systemUserId;
|
||||
if (!hasAdminUser) {
|
||||
logger.warn('[ActivityType Seed] No admin user found. New activity types will be skipped (created_by is required). Create an admin user and restart to seed them.');
|
||||
if (!systemUserId) {
|
||||
logger.warn('[ActivityType Seed] No admin user found. Activity types will be created without created_by reference.');
|
||||
// Use a placeholder UUID - this should not happen in production
|
||||
systemUserId = '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
// Insert default activity types with proper handling
|
||||
@ -78,20 +79,22 @@ export async function seedDefaultActivityTypes(): Promise<void> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Identify fields to update (only need systemUserId for updatedBy)
|
||||
// Identify fields to update
|
||||
const updates: any = {};
|
||||
if (!existing.itemCode && itemCode) updates.itemCode = itemCode;
|
||||
if (!existing.taxationType && taxationType) updates.taxationType = taxationType;
|
||||
if (!existing.sapRefNo && sapRefNo) updates.sapRefNo = sapRefNo;
|
||||
if (systemUserId) updates.updatedBy = systemUserId;
|
||||
|
||||
if (!existing.isActive) {
|
||||
updates.isActive = true;
|
||||
updates.updatedBy = systemUserId;
|
||||
await existing.update(updates);
|
||||
updatedCount++;
|
||||
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
|
||||
} else {
|
||||
// Already exists and active
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedBy = systemUserId;
|
||||
await existing.update(updates);
|
||||
logger.debug(`[ActivityType Seed] Updated fields for existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
|
||||
} else {
|
||||
@ -100,19 +103,14 @@ export async function seedDefaultActivityTypes(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new only when we have an admin user (created_by FK requires valid user_id)
|
||||
if (!hasAdminUser) {
|
||||
skippedCount++;
|
||||
logger.debug(`[ActivityType Seed] Skipped creating ${title} (no admin user for created_by)`);
|
||||
continue;
|
||||
}
|
||||
// Create new activity type with default fields
|
||||
await ActivityType.create({
|
||||
title,
|
||||
itemCode: itemCode,
|
||||
taxationType: taxationType,
|
||||
sapRefNo: sapRefNo,
|
||||
isActive: true,
|
||||
createdBy: systemUserId!
|
||||
createdBy: systemUserId
|
||||
} as any);
|
||||
createdCount++;
|
||||
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode}, sap_ref: ${sapRefNo})`);
|
||||
|
||||
@ -5,7 +5,6 @@ import type { StringValue } from 'ms';
|
||||
import { LoginResponse } from '../types/auth.types';
|
||||
import logger, { logAuthEvent } from '../utils/logger';
|
||||
import axios from 'axios';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
@ -498,153 +497,9 @@ export class AuthService {
|
||||
* 5. Return our JWT tokens
|
||||
*/
|
||||
async authenticateWithPassword(username: string, password: string): Promise<LoginResponse> {
|
||||
// Demo admin: admin@example.com / Admin@123 (works with or without .env; for dev/demo only)
|
||||
const DEMO_ADMIN_EMAIL = 'admin@example.com';
|
||||
const DEFAULT_DEMO_ADMIN_HASH = '$2a$10$H4ikTC.HDZPM0iFxjBy2C./WlkbGbidipIiZlXIJx6QpcBazdf12K'; // bcrypt of "Admin@123"
|
||||
const tryLocalAdminLogin = async (): Promise<LoginResponse | null> => {
|
||||
const normalizedInput = username?.trim?.()?.toLowerCase?.() ?? '';
|
||||
const adminEmail = process.env.LOCAL_ADMIN_EMAIL?.trim() || DEMO_ADMIN_EMAIL;
|
||||
if (normalizedInput !== adminEmail.toLowerCase()) return null;
|
||||
const hash = process.env.LOCAL_ADMIN_PASSWORD_HASH?.trim() || DEFAULT_DEMO_ADMIN_HASH;
|
||||
const passwordMatch = await bcrypt.compare(password, hash);
|
||||
if (!passwordMatch) return null;
|
||||
let user = await User.findOne({ where: { email: adminEmail } });
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
email: adminEmail,
|
||||
oktaSub: 'local-ADMIN',
|
||||
displayName: 'RE Admin',
|
||||
firstName: 'RE',
|
||||
lastName: 'Admin',
|
||||
isActive: true,
|
||||
role: 'ADMIN',
|
||||
emailNotificationsEnabled: true,
|
||||
pushNotificationsEnabled: true,
|
||||
inAppNotificationsEnabled: true,
|
||||
});
|
||||
logger.info('Demo admin user created on first login', { email: adminEmail });
|
||||
} else {
|
||||
await user.update({ lastLogin: new Date() });
|
||||
}
|
||||
logger.info('Demo admin login successful', { email: adminEmail });
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
const refreshToken = this.generateRefreshToken(user);
|
||||
return {
|
||||
user: {
|
||||
userId: user.userId,
|
||||
employeeId: user.employeeId ?? null,
|
||||
email: user.email,
|
||||
firstName: user.firstName ?? null,
|
||||
lastName: user.lastName ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
department: user.department ?? null,
|
||||
designation: user.designation ?? null,
|
||||
jobTitle: user.jobTitle ?? null,
|
||||
role: user.role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper: try local dealer login (TESTREFLOW) when ENABLE_LOCAL_DEALER_LOGIN is set (in scope for try and catch)
|
||||
const tryLocalDealerLogin = async (): Promise<LoginResponse | null> => {
|
||||
const enabled = process.env.ENABLE_LOCAL_DEALER_LOGIN?.toLowerCase()?.trim() === 'true';
|
||||
const hash = process.env.LOCAL_DEALER_PASSWORD_HASH?.trim();
|
||||
const localUsername = 'TESTREFLOW';
|
||||
const normalizedUsername = username?.trim?.()?.toUpperCase?.() ?? '';
|
||||
if (!enabled || !hash || normalizedUsername !== localUsername) return null;
|
||||
const passwordMatch = await bcrypt.compare(password, hash);
|
||||
if (!passwordMatch) return null;
|
||||
logger.info('Local dealer login successful', { username: localUsername });
|
||||
return this.handleSSOCallback({
|
||||
oktaSub: 'local-TESTREFLOW',
|
||||
email: 'testreflow@example.com',
|
||||
displayName: 'Test Reflow Dealer',
|
||||
firstName: 'Test',
|
||||
lastName: 'Reflow',
|
||||
});
|
||||
};
|
||||
|
||||
// Fallback bcrypt hash for "Test@123" when .env hash is corrupted (dev only)
|
||||
const ROHIT_DEALER_EMAIL = 'rohitm_ext@royalenfield.com';
|
||||
const FALLBACK_HASH_TEST123 = '$2a$10$gQ34/Jt9rOFDBWJqVur2W.ZWlN0vqAzt2I/6HKBKOtggowY/R8W/C';
|
||||
|
||||
// Helper: try local login by email (e.g. rohitm_ext@royalenfield.com) when LOCAL_DEALER_2_* is set or known dealer
|
||||
const tryLocalDealerLoginByEmail = async (): Promise<LoginResponse | null> => {
|
||||
const envEmail = process.env.LOCAL_DEALER_2_EMAIL?.trim()?.toLowerCase();
|
||||
const rawHash = process.env.LOCAL_DEALER_2_PASSWORD_HASH;
|
||||
let hash = (typeof rawHash === 'string' ? rawHash.trim() : '') || '';
|
||||
if (hash.length >= 2 && ((hash.startsWith('"') && hash.endsWith('"')) || (hash.startsWith("'") && hash.endsWith("'")))) hash = hash.slice(1, -1);
|
||||
const normalizedInput = username?.trim?.()?.toLowerCase?.() ?? '';
|
||||
const isRohitEmail = normalizedInput === ROHIT_DEALER_EMAIL;
|
||||
const email = envEmail || (isRohitEmail ? ROHIT_DEALER_EMAIL : null);
|
||||
const inputMatches = !!email && normalizedInput === email;
|
||||
if (!inputMatches) {
|
||||
logger.info('[Auth] Local dealer by email skip', {
|
||||
hasEmail: !!envEmail,
|
||||
hasHash: !!hash,
|
||||
hashLen: hash.length,
|
||||
inputMatch: inputMatches,
|
||||
normalizedInput: normalizedInput ? `${normalizedInput.slice(0, 5)}...` : '',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let passwordMatch = false;
|
||||
if (hash.length >= 50) {
|
||||
passwordMatch = await bcrypt.compare(password, hash);
|
||||
}
|
||||
if (!passwordMatch && isRohitEmail) {
|
||||
passwordMatch = await bcrypt.compare(password, FALLBACK_HASH_TEST123);
|
||||
if (passwordMatch) logger.info('[Auth] Local dealer login used fallback hash for', { email: ROHIT_DEALER_EMAIL });
|
||||
}
|
||||
if (!passwordMatch) {
|
||||
logger.warn('[Auth] Local dealer by email: password mismatch', { email });
|
||||
return null;
|
||||
}
|
||||
const { Op } = await import('sequelize');
|
||||
const user = await User.findOne({ where: { email: { [Op.iLike]: email } } });
|
||||
if (!user) {
|
||||
logger.warn('Local dealer login by email: user not found', { email });
|
||||
return null;
|
||||
}
|
||||
await user.update({ lastLogin: new Date() });
|
||||
logger.info('Local dealer login by email successful', { email });
|
||||
const accessToken = this.generateAccessToken(user);
|
||||
const refreshToken = this.generateRefreshToken(user);
|
||||
return {
|
||||
user: {
|
||||
userId: user.userId,
|
||||
employeeId: user.employeeId ?? null,
|
||||
email: user.email,
|
||||
firstName: user.firstName ?? null,
|
||||
lastName: user.lastName ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
department: user.department ?? null,
|
||||
designation: user.designation ?? null,
|
||||
jobTitle: user.jobTitle ?? null,
|
||||
role: user.role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info('Authenticating user with username/password', { username });
|
||||
|
||||
// Demo admin (admin@example.com / Admin@123) and optional env-based local admin
|
||||
const adminResult = await tryLocalAdminLogin();
|
||||
if (adminResult) return adminResult;
|
||||
|
||||
// Development-only: try local dealer login when enabled
|
||||
const localResult = await tryLocalDealerLogin();
|
||||
if (localResult) return localResult;
|
||||
|
||||
// Optional: local login by email (e.g. rohit.m.ext@royalenfield.com) when LOCAL_DEALER_2_* set
|
||||
const localEmailResult = await tryLocalDealerLoginByEmail();
|
||||
if (localEmailResult) return localEmailResult;
|
||||
|
||||
// Step 1: Authenticate with Okta using Resource Owner Password flow
|
||||
// Note: This requires Okta to have Resource Owner Password grant type enabled
|
||||
const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`;
|
||||
@ -757,21 +612,6 @@ export class AuthService {
|
||||
oktaError: error.response?.data,
|
||||
});
|
||||
|
||||
// When Okta does not allow password grant (e.g. only authorization_code), fall back to local logins
|
||||
const msg = (error.message || '').toLowerCase();
|
||||
if (msg.includes('grant type') || msg.includes('not authorized to use the provided grant type')) {
|
||||
const adminFallback = await tryLocalAdminLogin();
|
||||
if (adminFallback) {
|
||||
logger.info('Local admin login used after Okta grant-type rejection');
|
||||
return adminFallback;
|
||||
}
|
||||
const localResult = await tryLocalDealerLogin();
|
||||
if (localResult) {
|
||||
logger.info('Local dealer login used after Okta grant-type rejection');
|
||||
return localResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.data) {
|
||||
const errorData = error.response.data;
|
||||
if (typeof errorData === 'object' && !Array.isArray(errorData)) {
|
||||
|
||||
@ -579,28 +579,6 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
NULL,
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
-- Form 16 admin (submission viewers, 26AS viewers, notifications) – same admin_configurations table
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'FORM16_ADMIN_CONFIG',
|
||||
'SYSTEM_SETTINGS',
|
||||
'{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7,"notification26AsDataAdded":{"enabled":true,"template":"26AS data has been added. Please review."},"notificationForm16SuccessCreditNote":{"enabled":true,"template":"Form 16 submitted successfully. Credit note: [CreditNoteRef]."},"notificationForm16Unsuccessful":{"enabled":true,"template":"Form 16 submission was unsuccessful. Issue: [Issue]."},"alertSubmitForm16Enabled":true,"alertSubmitForm16FrequencyDays":0,"alertSubmitForm16FrequencyHours":24,"alertSubmitForm16Template":"Please submit your Form 16 at your earliest. [Name], due date: [DueDate].","reminderNotificationEnabled":true,"reminderFrequencyDays":0,"reminderFrequencyHours":12,"reminderNotificationTemplate":"Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.","debitNoteNotification":{"enabled":true,"template":"Debit note issued: [DebitNoteRef]. Please review."}}',
|
||||
'JSON',
|
||||
'Form 16 Admin Config',
|
||||
'Form 16 visibility (submission data viewers, 26AS viewers), reminders and notification settings',
|
||||
'{"submissionViewerEmails":[],"twentySixAsViewerEmails":[],"reminderEnabled":true,"reminderDays":7}',
|
||||
true,
|
||||
false,
|
||||
'{}'::jsonb,
|
||||
NULL,
|
||||
NULL,
|
||||
62,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (config_key) DO NOTHING
|
||||
`, { type: QueryTypes.INSERT });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Form 16 archive job: move records older than 5 financial years to archive (set archived_at).
|
||||
* No deletion; full data retained for audit. Scheduler runs daily at 02:00.
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const TZ = process.env.TZ || 'Asia/Kolkata';
|
||||
|
||||
/** Current financial year start (e.g. 2025 if we are in Apr 2025). */
|
||||
function getCurrentFYStartYear(): number {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
if (month >= 4) return year;
|
||||
return year - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive Form 16 / 26AS records older than 5 financial years.
|
||||
* Sets archived_at = NOW() where financial_year start <= (currentFYStart - 5).
|
||||
* No rows are deleted.
|
||||
*/
|
||||
export async function runForm16ArchiveOldRecords(): Promise<{
|
||||
tds26as: number;
|
||||
snapshots: number;
|
||||
quarterStatus: number;
|
||||
ledger: number;
|
||||
creditNotes: number;
|
||||
debitNotes: number;
|
||||
submissions: number;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const currentStart = getCurrentFYStartYear();
|
||||
const cutoffStart = currentStart - 5;
|
||||
const results = {
|
||||
tds26as: 0,
|
||||
snapshots: 0,
|
||||
quarterStatus: 0,
|
||||
ledger: 0,
|
||||
creditNotes: 0,
|
||||
debitNotes: 0,
|
||||
submissions: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
await sequelize.transaction(async (tx) => {
|
||||
const tables: Array<{ table: string; fyColumn: string; resultKey: keyof typeof results }> = [
|
||||
{ table: 'tds_26as_entries', fyColumn: 'financial_year', resultKey: 'tds26as' },
|
||||
{ table: 'form_16_26as_quarter_snapshots', fyColumn: 'financial_year', resultKey: 'snapshots' },
|
||||
{ table: 'form_16_quarter_status', fyColumn: 'financial_year', resultKey: 'quarterStatus' },
|
||||
{ table: 'form_16_ledger_entries', fyColumn: 'financial_year', resultKey: 'ledger' },
|
||||
{ table: 'form_16_credit_notes', fyColumn: 'financial_year', resultKey: 'creditNotes' },
|
||||
{ table: 'form16a_submissions', fyColumn: 'financial_year', resultKey: 'submissions' },
|
||||
];
|
||||
|
||||
for (const { table, fyColumn, resultKey } of tables) {
|
||||
const [res] = await sequelize.query(
|
||||
`UPDATE ${table} SET archived_at = :now
|
||||
WHERE archived_at IS NULL
|
||||
AND TRIM(${fyColumn}) ~ '^[0-9]{4}-'
|
||||
AND (SUBSTRING(TRIM(${fyColumn}) FROM 1 FOR 4))::int <= :cutoff`,
|
||||
{
|
||||
replacements: { now, cutoff: cutoffStart },
|
||||
type: QueryTypes.UPDATE,
|
||||
transaction: tx,
|
||||
}
|
||||
);
|
||||
const count = typeof res === 'number' ? res : (res as any)?.rowCount ?? (res as any) ?? 0;
|
||||
(results as any)[resultKey] = count;
|
||||
}
|
||||
|
||||
await sequelize.query(
|
||||
`UPDATE form_16_debit_notes dn
|
||||
SET archived_at = :now
|
||||
FROM form_16_credit_notes cn
|
||||
WHERE dn.credit_note_id = cn.id
|
||||
AND dn.archived_at IS NULL
|
||||
AND cn.archived_at IS NOT NULL`,
|
||||
{
|
||||
replacements: { now },
|
||||
type: QueryTypes.UPDATE,
|
||||
transaction: tx,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const total = Object.values(results).reduce((a, b) => a + b, 0);
|
||||
if (total > 0) {
|
||||
logger.info(`[Form16 Archive] Archived ${total} record(s) older than FY ${cutoffStart}-${String(cutoffStart + 1).slice(-2)}`, results);
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
logger.error('[Form16 Archive] Error archiving old records', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Form 16 archive scheduler: run daily at 02:00 (configurable TZ).
|
||||
*/
|
||||
export function startForm16ArchiveJob(): void {
|
||||
const cron = require('node-cron');
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
try {
|
||||
await runForm16ArchiveOldRecords();
|
||||
} catch (e) {
|
||||
logger.error('[Form16 Archive Job] Failed', e);
|
||||
}
|
||||
}, { timezone: TZ });
|
||||
|
||||
logger.info(`[Form16 Archive Job] Daily archive job scheduled at 02:00 (TZ: ${TZ}). Keeps last 5 FY active.`);
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Form 16 admin config reader for server-side use (notifications, cron).
|
||||
* Reads FORM16_ADMIN_CONFIG from admin_configurations and returns merged config with defaults.
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
const FORM16_CONFIG_KEY = 'FORM16_ADMIN_CONFIG';
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
export interface Form16Notification26AsConfig {
|
||||
enabled: boolean;
|
||||
templateRe: string;
|
||||
templateDealers: string;
|
||||
}
|
||||
|
||||
export interface Form16Config {
|
||||
notification26AsDataAdded: Form16Notification26AsConfig;
|
||||
notificationForm16SuccessCreditNote: { enabled: boolean; template: string };
|
||||
notificationForm16Unsuccessful: { enabled: boolean; template: string };
|
||||
reminderNotificationEnabled: boolean;
|
||||
reminderNotificationTemplate: string;
|
||||
/** When to run the reminder job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
|
||||
reminderRunAtTime: string;
|
||||
alertSubmitForm16Enabled: boolean;
|
||||
alertSubmitForm16Template: string;
|
||||
/** When to run the alert job daily (HH:mm, 24h, server timezone). Empty = no scheduled run. */
|
||||
alertSubmitForm16RunAtTime: string;
|
||||
twentySixAsViewerEmails: string[];
|
||||
}
|
||||
|
||||
const default26As = (): Form16Notification26AsConfig => ({
|
||||
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 defaults: Form16Config = {
|
||||
notification26AsDataAdded: default26As(),
|
||||
notificationForm16SuccessCreditNote: { enabled: true, template: 'Form 16 submitted successfully. Credit note: [CreditNoteRef].' },
|
||||
notificationForm16Unsuccessful: { enabled: true, template: 'Form 16 submission was unsuccessful. Issue: [Issue].' },
|
||||
reminderNotificationEnabled: true,
|
||||
reminderNotificationTemplate: 'Reminder: Form 16 submission is pending. [Name], [Request ID]. Please review.',
|
||||
reminderRunAtTime: '10:00',
|
||||
alertSubmitForm16Enabled: true,
|
||||
alertSubmitForm16Template: 'Please submit your Form 16 at your earliest. [Name], due date: [DueDate].',
|
||||
alertSubmitForm16RunAtTime: '09:00',
|
||||
twentySixAsViewerEmails: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Form 16 config from admin_configurations for server-side use.
|
||||
*/
|
||||
export async function getForm16Config(): Promise<Form16Config> {
|
||||
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?.length || !result[0].config_value) {
|
||||
return defaults;
|
||||
}
|
||||
const parsed = JSON.parse(result[0].config_value) as Record<string, unknown>;
|
||||
|
||||
const merge26As = (): Form16Notification26AsConfig => {
|
||||
const v = parsed.notification26AsDataAdded;
|
||||
if (v && typeof v === 'object' && typeof (v as any).enabled === 'boolean') {
|
||||
const val = v as any;
|
||||
return {
|
||||
enabled: val.enabled,
|
||||
templateRe: typeof val.templateRe === 'string' ? val.templateRe : (typeof val.template === 'string' ? val.template : defaults.notification26AsDataAdded.templateRe),
|
||||
templateDealers: typeof val.templateDealers === 'string' ? val.templateDealers : defaults.notification26AsDataAdded.templateDealers,
|
||||
};
|
||||
}
|
||||
return defaults.notification26AsDataAdded;
|
||||
};
|
||||
|
||||
return {
|
||||
notification26AsDataAdded: merge26As(),
|
||||
notificationForm16SuccessCreditNote:
|
||||
parsed.notificationForm16SuccessCreditNote && typeof (parsed.notificationForm16SuccessCreditNote as any).template === 'string'
|
||||
? { enabled: (parsed.notificationForm16SuccessCreditNote as any).enabled !== false, template: (parsed.notificationForm16SuccessCreditNote as any).template }
|
||||
: defaults.notificationForm16SuccessCreditNote,
|
||||
notificationForm16Unsuccessful:
|
||||
parsed.notificationForm16Unsuccessful && typeof (parsed.notificationForm16Unsuccessful as any).template === 'string'
|
||||
? { enabled: (parsed.notificationForm16Unsuccessful as any).enabled !== false, template: (parsed.notificationForm16Unsuccessful as any).template }
|
||||
: defaults.notificationForm16Unsuccessful,
|
||||
reminderNotificationEnabled: typeof parsed.reminderNotificationEnabled === 'boolean' ? parsed.reminderNotificationEnabled : defaults.reminderNotificationEnabled,
|
||||
reminderNotificationTemplate: typeof parsed.reminderNotificationTemplate === 'string' ? parsed.reminderNotificationTemplate : defaults.reminderNotificationTemplate,
|
||||
reminderRunAtTime: typeof parsed.reminderRunAtTime === 'string' && parsed.reminderRunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.reminderRunAtTime.trim()) ? normalizeRunAtTime(parsed.reminderRunAtTime.trim()) : defaults.reminderRunAtTime) : '',
|
||||
alertSubmitForm16Enabled: typeof parsed.alertSubmitForm16Enabled === 'boolean' ? parsed.alertSubmitForm16Enabled : defaults.alertSubmitForm16Enabled,
|
||||
alertSubmitForm16Template: typeof parsed.alertSubmitForm16Template === 'string' ? parsed.alertSubmitForm16Template : defaults.alertSubmitForm16Template,
|
||||
alertSubmitForm16RunAtTime: typeof parsed.alertSubmitForm16RunAtTime === 'string' && parsed.alertSubmitForm16RunAtTime.trim() ? (/^\d{1,2}:\d{2}$/.test(parsed.alertSubmitForm16RunAtTime.trim()) ? normalizeRunAtTime(parsed.alertSubmitForm16RunAtTime.trim()) : defaults.alertSubmitForm16RunAtTime) : '',
|
||||
twentySixAsViewerEmails: Array.isArray(parsed.twentySixAsViewerEmails)
|
||||
? (parsed.twentySixAsViewerEmails as string[]).map((e) => String(e).trim().toLowerCase()).filter(Boolean)
|
||||
: defaults.twentySixAsViewerEmails,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.warn('[Form16Config] Failed to load config, using defaults:', e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
@ -1,286 +0,0 @@
|
||||
/**
|
||||
* Form 16 notification triggers: 26AS added, success/unsuccess, alerts, reminders.
|
||||
* Uses Form 16 admin config templates and notificationService.sendToUsers.
|
||||
*/
|
||||
|
||||
import { Op } from 'sequelize';
|
||||
import { User } from '@models/User';
|
||||
import { Dealer } from '@models/Dealer';
|
||||
import { Form16CreditNote } from '@models/Form16CreditNote';
|
||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||
import { getForm16Config } from './form16Config.service';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
/** Get user IDs for dealers (principal email linked to a dealer). */
|
||||
export async function getDealerUserIds(): Promise<string[]> {
|
||||
const dealers = await Dealer.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: ['dealerPrincipalEmailId'],
|
||||
raw: true,
|
||||
});
|
||||
const emails = [...new Set((dealers.map((d) => (d as any).dealerPrincipalEmailId).filter(Boolean) as string[]).map((e) => e.trim().toLowerCase()))];
|
||||
if (emails.length === 0) return [];
|
||||
const users = await User.findAll({
|
||||
where: userWhereEmailIn(emails),
|
||||
attributes: ['userId'],
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => (u as any).userId);
|
||||
}
|
||||
|
||||
function userWhereEmailIn(emails: string[]) {
|
||||
if (emails.length === 0) return { email: { [Op.eq]: '__no_match__' } } as any;
|
||||
return { [Op.or]: emails.map((e) => ({ email: { [Op.iLike]: e } })) };
|
||||
}
|
||||
|
||||
/** Get user IDs who should receive 26AS-added notification (RE side): config list or all non-dealer users. */
|
||||
export async function getReUserIdsFor26As(): Promise<string[]> {
|
||||
const config = await getForm16Config();
|
||||
const viewerEmails = config.twentySixAsViewerEmails || [];
|
||||
if (viewerEmails.length > 0) {
|
||||
const users = await User.findAll({
|
||||
where: userWhereEmailIn(viewerEmails),
|
||||
attributes: ['userId'],
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => (u as any).userId);
|
||||
}
|
||||
const dealerIds = await getDealerUserIds();
|
||||
const allUsers = await User.findAll({ attributes: ['userId'], raw: true });
|
||||
const allIds = allUsers.map((u) => (u as any).userId);
|
||||
if (dealerIds.length === 0) return allIds;
|
||||
const dealerSet = new Set(dealerIds);
|
||||
return allIds.filter((id) => !dealerSet.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger notifications when 26AS data is uploaded: RE users get templateRe, dealers get templateDealers.
|
||||
* Called after successful 26AS upload (fire-and-forget or await in controller).
|
||||
*/
|
||||
export async function trigger26AsDataAddedNotification(): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
const n = config.notification26AsDataAdded;
|
||||
if (!n?.enabled) {
|
||||
logger.info('[Form16Notification] 26AS notification disabled in config, skipping');
|
||||
return;
|
||||
}
|
||||
const { notificationService } = await import('./notification.service');
|
||||
const reUserIds = await getReUserIdsFor26As();
|
||||
const dealerIds = await getDealerUserIds();
|
||||
|
||||
const title = 'Form 16 – 26AS data updated';
|
||||
if (reUserIds.length > 0 && n.templateRe) {
|
||||
await notificationService.sendToUsers(reUserIds, {
|
||||
title,
|
||||
body: n.templateRe,
|
||||
type: 'form16_26as_added',
|
||||
});
|
||||
logger.info(`[Form16Notification] 26AS notification sent to ${reUserIds.length} RE user(s)`);
|
||||
}
|
||||
if (dealerIds.length > 0 && n.templateDealers) {
|
||||
await notificationService.sendToUsers(dealerIds, {
|
||||
title,
|
||||
body: n.templateDealers,
|
||||
type: 'form16_26as_added',
|
||||
});
|
||||
logger.info(`[Form16Notification] 26AS notification sent to ${dealerIds.length} dealer user(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] trigger26AsDataAddedNotification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace [CreditNoteRef] / [Issue] in template. */
|
||||
function replacePlaceholders(template: string, replacements: Record<string, string>): string {
|
||||
let out = template;
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
out = out.replace(new RegExp(`\\[${key}\\]`, 'gi'), value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the dealer (initiator) after Form 16 submission result: success (credit note) or unsuccessful.
|
||||
*/
|
||||
export async function triggerForm16SubmissionResultNotification(
|
||||
initiatorUserId: string,
|
||||
validationStatus: string | undefined,
|
||||
opts: { creditNoteNumber?: string | null; requestId?: string; validationNotes?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
const { notificationService } = await import('./notification.service');
|
||||
const isSuccess =
|
||||
validationStatus === 'success' || validationStatus === 'manually_approved';
|
||||
if (isSuccess && config.notificationForm16SuccessCreditNote?.enabled && config.notificationForm16SuccessCreditNote.template) {
|
||||
const body = replacePlaceholders(config.notificationForm16SuccessCreditNote.template, {
|
||||
CreditNoteRef: opts.creditNoteNumber || '—',
|
||||
});
|
||||
await notificationService.sendToUsers([initiatorUserId], {
|
||||
title: 'Form 16 – Credit note issued',
|
||||
body,
|
||||
type: 'form16_success_credit_note',
|
||||
requestId: opts.requestId,
|
||||
});
|
||||
logger.info(`[Form16Notification] Success notification sent to initiator ${initiatorUserId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isSuccess &&
|
||||
(validationStatus === 'failed' || validationStatus === 'duplicate' || validationStatus === 'resubmission_needed') &&
|
||||
config.notificationForm16Unsuccessful?.enabled &&
|
||||
config.notificationForm16Unsuccessful.template
|
||||
) {
|
||||
const issue = opts.validationNotes || 'Submission could not be processed. Please check the request and resubmit if needed.';
|
||||
const body = replacePlaceholders(config.notificationForm16Unsuccessful.template, { Issue: issue });
|
||||
await notificationService.sendToUsers([initiatorUserId], {
|
||||
title: 'Form 16 – Submission unsuccessful',
|
||||
body,
|
||||
type: 'form16_unsuccessful',
|
||||
requestId: opts.requestId,
|
||||
});
|
||||
logger.info(`[Form16Notification] Unsuccessful notification sent to initiator ${initiatorUserId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16SubmissionResultNotification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify dealer when RE manually generates a credit note (so they see success).
|
||||
*/
|
||||
export async function triggerForm16ManualCreditNoteNotification(requestId: string, creditNoteNumber: string): Promise<void> {
|
||||
try {
|
||||
const req = await WorkflowRequest.findByPk(requestId, { attributes: ['initiatorId'], raw: true });
|
||||
const initiatorId = (req as any)?.initiatorId;
|
||||
if (!initiatorId) return;
|
||||
await triggerForm16SubmissionResultNotification(initiatorId, 'manually_approved', {
|
||||
creditNoteNumber,
|
||||
requestId,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16ManualCreditNoteNotification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify dealer when RE marks submission as resubmission needed or cancels (unsuccessful outcome).
|
||||
*/
|
||||
export async function triggerForm16UnsuccessfulByRequestId(requestId: string, issueMessage: string): Promise<void> {
|
||||
try {
|
||||
const req = await WorkflowRequest.findByPk(requestId, { attributes: ['initiatorId'], raw: true });
|
||||
const initiatorId = (req as any)?.initiatorId;
|
||||
if (!initiatorId) return;
|
||||
await triggerForm16SubmissionResultNotification(initiatorId, 'resubmission_needed', {
|
||||
requestId,
|
||||
validationNotes: issueMessage,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16UnsuccessfulByRequestId failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send "submit Form 16" alert to dealers (e.g. those who haven't submitted for a quarter).
|
||||
* Call from a scheduled job using config alertSubmitForm16FrequencyDays/Hours.
|
||||
*/
|
||||
export async function triggerForm16AlertSubmit(dealerUserIds: string[], placeholders?: { name?: string; dueDate?: string }): Promise<void> {
|
||||
if (dealerUserIds.length === 0) return;
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.alertSubmitForm16Enabled || !config.alertSubmitForm16Template) return;
|
||||
const body = replacePlaceholders(config.alertSubmitForm16Template, {
|
||||
Name: placeholders?.name ?? 'Dealer',
|
||||
DueDate: placeholders?.dueDate ?? '—',
|
||||
});
|
||||
const { notificationService } = await import('./notification.service');
|
||||
await notificationService.sendToUsers(dealerUserIds, {
|
||||
title: 'Form 16 – Submit required',
|
||||
body,
|
||||
type: 'form16_alert_submit',
|
||||
});
|
||||
logger.info(`[Form16Notification] Alert submit sent to ${dealerUserIds.length} dealer(s)`);
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16AlertSubmit failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send pending Form 16 reminder to dealers (e.g. those with pending submission).
|
||||
* Call from a scheduled job using config reminderFrequencyDays/Hours.
|
||||
*/
|
||||
export async function triggerForm16Reminder(dealerUserIds: string[], placeholders?: { name?: string; requestId?: string }): Promise<void> {
|
||||
if (dealerUserIds.length === 0) return;
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.reminderNotificationEnabled || !config.reminderNotificationTemplate) return;
|
||||
const body = replacePlaceholders(config.reminderNotificationTemplate, {
|
||||
Name: placeholders?.name ?? 'Dealer',
|
||||
'Request ID': placeholders?.requestId ?? '—',
|
||||
});
|
||||
const { notificationService } = await import('./notification.service');
|
||||
await notificationService.sendToUsers(dealerUserIds, {
|
||||
title: 'Form 16 – Pending submission reminder',
|
||||
body,
|
||||
type: 'form16_reminder',
|
||||
});
|
||||
logger.info(`[Form16Notification] Reminder sent to ${dealerUserIds.length} dealer(s)`);
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] triggerForm16Reminder failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled job: send "submit Form 16" alert to all non-submitted dealers (current FY).
|
||||
* Call from cron (e.g. daily at 9 AM).
|
||||
*/
|
||||
export async function runForm16AlertSubmitJob(): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.alertSubmitForm16Enabled) {
|
||||
logger.info('[Form16Notification] Alert submit disabled in config, skipping job');
|
||||
return;
|
||||
}
|
||||
const { getDealerUserIdsFromNonSubmittedDealers } = await import('./form16.service');
|
||||
const dealerUserIds = await getDealerUserIdsFromNonSubmittedDealers();
|
||||
if (dealerUserIds.length === 0) {
|
||||
logger.info('[Form16Notification] No non-submitted dealers for alert, skipping');
|
||||
return;
|
||||
}
|
||||
const y = new Date().getFullYear();
|
||||
const fy = `${y}-${(y + 1).toString().slice(-2)}`;
|
||||
const dueDate = `FY ${fy} (as per policy)`;
|
||||
await triggerForm16AlertSubmit(dealerUserIds, { name: 'Dealer', dueDate });
|
||||
logger.info(`[Form16Notification] Alert submit job completed: notified ${dealerUserIds.length} dealer(s)`);
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] runForm16AlertSubmitJob failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled job: send "pending Form 16" reminder to dealers who have open submissions without credit note.
|
||||
* Call from cron (e.g. daily at 10 AM).
|
||||
*/
|
||||
export async function runForm16ReminderJob(): Promise<void> {
|
||||
try {
|
||||
const config = await getForm16Config();
|
||||
if (!config.reminderNotificationEnabled) {
|
||||
logger.info('[Form16Notification] Reminder disabled in config, skipping job');
|
||||
return;
|
||||
}
|
||||
const { getDealersWithPendingForm16Submissions } = await import('./form16.service');
|
||||
const pending = await getDealersWithPendingForm16Submissions();
|
||||
if (pending.length === 0) {
|
||||
logger.info('[Form16Notification] No pending Form 16 submissions for reminder, skipping');
|
||||
return;
|
||||
}
|
||||
for (const { userId, requestId } of pending) {
|
||||
await triggerForm16Reminder([userId], { name: 'Dealer', requestId });
|
||||
}
|
||||
logger.info(`[Form16Notification] Reminder job completed: sent ${pending.length} reminder(s)`);
|
||||
} catch (e) {
|
||||
logger.error('[Form16Notification] runForm16ReminderJob failed:', e);
|
||||
}
|
||||
}
|
||||
@ -1,669 +0,0 @@
|
||||
/**
|
||||
* Form 16A OCR: Google Gemini (primary) with regex fallback.
|
||||
* Supports (1) Gemini API key, (2) Vertex AI with service account, (3) regex fallback.
|
||||
*/
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { VertexAI } from '@google-cloud/vertexai';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// Ensure .env is loaded (backend may run from different cwd)
|
||||
const backendDir = path.join(__dirname, '../..');
|
||||
dotenv.config({ path: path.join(backendDir, '.env') });
|
||||
|
||||
function getForm16VertexKeyPath(): string | null {
|
||||
const keyFile = process.env.GCP_KEY_FILE?.trim();
|
||||
const creds = process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim();
|
||||
if (keyFile) return path.isAbsolute(keyFile) ? keyFile : path.resolve(backendDir, keyFile);
|
||||
if (creds) return path.isAbsolute(creds) ? creds : path.resolve(backendDir, creds);
|
||||
const defaultPath = path.join(backendDir, 'credentials', 're-platform-workflow-dealer-3d5738fcc1f9.json');
|
||||
return fs.existsSync(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
export interface Form16AExtractedData {
|
||||
nameAndAddressOfDeductor?: string | null;
|
||||
deductorName?: string | null;
|
||||
deductorAddress?: string | null;
|
||||
deductorPhone?: string | null;
|
||||
deductorEmail?: string | null;
|
||||
totalAmountPaid?: number | null;
|
||||
totalTaxDeducted?: number | null;
|
||||
totalTdsDeposited?: number | null;
|
||||
tanOfDeductor?: string | null;
|
||||
natureOfPayment?: string | null;
|
||||
transactionDate?: string | null;
|
||||
statusOfMatchingOltas?: string | null;
|
||||
dateOfBooking?: string | null;
|
||||
assessmentYear?: string | null;
|
||||
quarter?: string | null;
|
||||
form16aNumber?: string | null;
|
||||
financialYear?: string | null;
|
||||
certificateDate?: string | null;
|
||||
tanNumber?: string | null;
|
||||
tdsAmount?: number | null;
|
||||
totalAmount?: number | null;
|
||||
}
|
||||
|
||||
export interface Form16OcrResult {
|
||||
success: boolean;
|
||||
data?: Form16AExtractedData;
|
||||
method?: 'gemini' | 'fallback';
|
||||
ocrProvider?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const GEMINI_PROMPT = `You are an expert at extracting data from Indian Tax Form 16A certificates (TDS certificate under Section 203).
|
||||
|
||||
STEP 1 - Read the ENTIRE document: every table, every section, and every line. Form 16A has multiple parts: deductor details, deductee details, and one or more TABLES with payment/TDS figures.
|
||||
|
||||
STEP 2 - Extract these fields. For amounts, look in TABLES: find rows or columns with these labels and take the NUMBER in the same row/column (ignore ₹, Rs, commas):
|
||||
|
||||
1. nameAndAddressOfDeductor - "Name and address of the deductor". Full block in one string. Also extract: deductorName (person/entity name only), deductorAddress (street, city, state, PIN), deductorPhone, deductorEmail.
|
||||
|
||||
2. totalAmountPaid - In "Summary of payment" find "Total(Rs)" or "Amount paid/credited". The LARGE amount (e.g. 181968556.36). Not the TDS amount.
|
||||
|
||||
3. totalTaxDeducted - "Amount of Tax Deducted in respect of Deductee" in tax summary table. The TDS amount (e.g. 181969.00). Must be hundreds or more, NOT a single digit like 3.
|
||||
|
||||
4. totalTdsDeposited - "Amount of Tax Deposited / Remitted in respect of Deductee". Same as totalTaxDeducted if one total (e.g. 181969.00). NOT a page number.
|
||||
|
||||
5. tanOfDeductor - "TAN of the deductor" or "TAN". Must be exactly 10 characters: 4 uppercase letters + 5 digits + 1 letter (e.g. BLRH07660C). No spaces.
|
||||
|
||||
6. natureOfPayment - "Nature of payment" or "Section" or "Nature of Payment". Value is usually a section code like 194Q, 194A, 194I, or a short description. Extract that code or text.
|
||||
|
||||
7. transactionDate - "Transaction date" or "Date of payment" or "Period" end date. Format DD-MM-YYYY or DD/MM/YYYY.
|
||||
|
||||
8. statusOfMatchingOltas - "Status of matching with OLTAS" or "OLTAS". Single letter (F, O, M) or word like "Matched". Extract as shown.
|
||||
|
||||
9. dateOfBooking - "Date of booking" or "Date of deposit". DD-MM-YYYY or DD/MM/YYYY.
|
||||
|
||||
10. assessmentYear - "Assessment Year" or "AY" from the form header. Format YYYY-YY (e.g. 2025-26). This is the Form 16A assessment year.
|
||||
|
||||
11. quarter - "Quarter". Must be Q1, Q2, Q3, or Q4. If you see "Apr-Jun","Jul-Sep","Oct-Dec","Jan-Mar" or "Quarter 1" etc., convert to Q1, Q2, Q3, Q4.
|
||||
|
||||
12. form16aNumber - "Certificate Number" or "Certificate No" - the alphanumeric code (e.g. LTZKJZA, 12345). Do NOT return "Last" or "updated" or "on" (from "Last updated on"). Only the certificate ID. If unclear, return null.
|
||||
|
||||
13. financialYear - "Financial Year" or "FY". Format YYYY-YY. Can derive from Assessment Year (AY 2025-26 => FY 2024-25).
|
||||
|
||||
14. certificateDate - "Date of certificate" or "Last updated on". DD-MM-YYYY. Optional.
|
||||
|
||||
RULES:
|
||||
- Scan every table in the document for amount and TDS figures. The totals are usually in the last row or a row labeled "Total".
|
||||
- For amounts: output only a number (e.g. 128234.00), no currency symbol or commas.
|
||||
- For form16aNumber: if the value is a single English word (e.g. contains, certificate, number, nil), return null.
|
||||
- If a field is truly not in the document, set it to null.
|
||||
- Return ONLY a single JSON object, no markdown, no \`\`\`, no explanation.
|
||||
|
||||
JSON format (use this exact structure):
|
||||
{
|
||||
"nameAndAddressOfDeductor": "string or null",
|
||||
"deductorName": "string or null",
|
||||
"deductorAddress": "string or null",
|
||||
"deductorPhone": "string or null",
|
||||
"deductorEmail": "string or null",
|
||||
"totalAmountPaid": number or null,
|
||||
"totalTaxDeducted": number or null,
|
||||
"totalTdsDeposited": number or null,
|
||||
"tanOfDeductor": "string or null",
|
||||
"natureOfPayment": "string or null",
|
||||
"transactionDate": "string or null",
|
||||
"statusOfMatchingOltas": "string or null",
|
||||
"dateOfBooking": "string or null",
|
||||
"assessmentYear": "string or null",
|
||||
"quarter": "string or null",
|
||||
"form16aNumber": "string or null",
|
||||
"financialYear": "string or null",
|
||||
"certificateDate": "string or null"
|
||||
}`;
|
||||
|
||||
// ----- Helpers (aligned with REform16) -----
|
||||
function getNum(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||
const s = String(v).replace(/,/g, '').replace(/₹|Rs\.?|INR/gi, '').trim();
|
||||
const n = parseFloat(s);
|
||||
return !Number.isNaN(n) ? n : null;
|
||||
}
|
||||
|
||||
function getStr(v: unknown): string | null {
|
||||
if (v != null && String(v).trim() !== '') return String(v).trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDeductorBlock(block: string | null): { name: string | null; address: string | null; phone: string | null; email: string | null } {
|
||||
const result = { name: null as string | null, address: null as string | null, phone: null as string | null, email: null as string | null };
|
||||
if (!block || typeof block !== 'string') return result;
|
||||
const parts = block.split(/[,]+/).map((p) => p.trim()).filter(Boolean);
|
||||
const emailPart = parts.find((p) => /@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(p));
|
||||
const phonePart = parts.find((p) => /^[\+]?[(]?[0-9\s\-()]{10,}$/.test(p) || /^\+?91[\s\-]?\d{10}$/.test(p));
|
||||
if (emailPart) {
|
||||
result.email = emailPart;
|
||||
parts.splice(parts.indexOf(emailPart), 1);
|
||||
}
|
||||
if (phonePart) {
|
||||
result.phone = phonePart;
|
||||
parts.splice(parts.indexOf(phonePart), 1);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
result.name = parts[0];
|
||||
if (parts.length > 1) result.address = parts.slice(1).join(', ');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isValidTdsAmount(n: number | null): boolean {
|
||||
if (n == null || Number.isNaN(n) || n < 0) return false;
|
||||
if (n >= 100) return true;
|
||||
if (Number.isInteger(n) && n < 100) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractTotalAmountPaidForm16A(text: string): number | null {
|
||||
const m = text.match(/Summary\s+of\s+payment[\s\S]*?Total\s*\(Rs\.?\)\s*([0-9,]+\.?[0-9]*)/i);
|
||||
if (m?.[1]) {
|
||||
const n = parseFloat(m[1].replace(/,/g, ''));
|
||||
if (!Number.isNaN(n) && n > 0) return n;
|
||||
}
|
||||
const m2 = text.match(/Total\s*\(Rs\.?\)\s*([0-9,]+\.?[0-9]*)/i);
|
||||
if (m2?.[1]) {
|
||||
const n = parseFloat(m2[1].replace(/,/g, ''));
|
||||
if (!Number.isNaN(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTDSAmountsForm16A(text: string): { taxDeducted: number | null; taxDeposited: number | null; totalRs: number | null } {
|
||||
const result = { taxDeducted: null as number | null, taxDeposited: null as number | null, totalRs: null as number | null };
|
||||
const quarterLine = text.match(/Q[1-4][A-Z0-9]*([0-9,]+\.\d{2})([0-9,]+\.\d{2})/);
|
||||
if (quarterLine?.[1] && quarterLine?.[2]) {
|
||||
const a1 = parseFloat(quarterLine[1].replace(/,/g, ''));
|
||||
const a2 = parseFloat(quarterLine[2].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(a1)) result.taxDeducted = a1;
|
||||
if (isValidTdsAmount(a2)) result.taxDeposited = a2;
|
||||
}
|
||||
const taxDeductedM = text.match(/Amount\s+of\s+Tax\s+Deducted[\s\S]*?([0-9,]{3,}\.?[0-9]*|[0-9,]+\.\d{2})\s*(?:Amount|Deductee|$)/i);
|
||||
if (taxDeductedM?.[1] && !result.taxDeducted) {
|
||||
const n = parseFloat(taxDeductedM[1].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(n)) result.taxDeducted = n;
|
||||
}
|
||||
const taxDepositedM = text.match(/Amount\s+of\s+Tax\s+Deposited[\s\S]*?([0-9,]{3,}\.?[0-9]*|[0-9,]+\.\d{2})/i);
|
||||
if (taxDepositedM?.[1] && !result.taxDeposited) {
|
||||
const n = parseFloat(taxDepositedM[1].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(n)) result.taxDeposited = n;
|
||||
}
|
||||
const totalRsM = text.match(/Status\s+of\s+matching[\s\S]*?Total\s*\(Rs\.?\)\s*([0-9,]+\.?[0-9]*)/i)
|
||||
|| text.match(/OLTAS[\s\S]*?Total\s*\(Rs\.?\)\s*([0-9,]+\.?[0-9]*)/i);
|
||||
if (totalRsM?.[1]) {
|
||||
const n = parseFloat(totalRsM[1].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(n)) result.totalRs = n;
|
||||
}
|
||||
const rsDeductedM = text.match(/sum\s+of\s+Rs\.?\s*([0-9,]+\.?[0-9]*)\s*\[?Rs\.?\s*[Oo]ne\s+[Ll]akh/i);
|
||||
if (rsDeductedM?.[1] && !result.taxDeducted) {
|
||||
const n = parseFloat(rsDeductedM[1].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(n)) result.taxDeducted = n;
|
||||
}
|
||||
const rsDepositedM = text.match(/deposited\s+[a-z\s]+Rs\.?\s*([0-9,]+\.?[0-9]*)\s*\[?Rs\.?\s*[Oo]ne/i);
|
||||
if (rsDepositedM?.[1] && !result.taxDeposited) {
|
||||
const n = parseFloat(rsDepositedM[1].replace(/,/g, ''));
|
||||
if (isValidTdsAmount(n)) result.taxDeposited = n;
|
||||
}
|
||||
if (result.taxDeducted != null && !isValidTdsAmount(result.taxDeducted)) result.taxDeducted = null;
|
||||
if (result.taxDeposited != null && !isValidTdsAmount(result.taxDeposited)) result.taxDeposited = null;
|
||||
if (result.totalRs != null && !isValidTdsAmount(result.totalRs)) result.totalRs = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractQuarterForm16A(text: string): string | null {
|
||||
const m = text.match(/\bQuarter\s*[:\s]*\n?\s*(Q[1-4])/i) || text.match(/\b(Q[1-4])[A-Z0-9]*\s*[0-9]/);
|
||||
if (m?.[1]) return m[1].toUpperCase();
|
||||
return extractQuarter(text);
|
||||
}
|
||||
|
||||
function extractQuarter(text: string): string | null {
|
||||
const patterns = [
|
||||
/Quarter[:\s]*([1-4])/i,
|
||||
/Q[:\s]*([1-4])/i,
|
||||
/([1-4])\s*Quarter/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const m = text.match(pattern);
|
||||
if (m?.[1]) return `Q${m[1]}`;
|
||||
}
|
||||
const dateMatch = text.match(/(Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|September|Oct|October|Nov|November|Dec|December|Jan|January|Feb|February|Mar|March)/i);
|
||||
if (dateMatch) {
|
||||
const month = dateMatch[1].toLowerCase();
|
||||
if (['apr', 'april', 'may', 'jun', 'june'].includes(month)) return 'Q1';
|
||||
if (['jul', 'july', 'aug', 'august', 'sep', 'september'].includes(month)) return 'Q2';
|
||||
if (['oct', 'october', 'nov', 'november', 'dec', 'december'].includes(month)) return 'Q3';
|
||||
if (['jan', 'january', 'feb', 'february', 'mar', 'march'].includes(month)) return 'Q4';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNatureOfPayment(text: string): string | null {
|
||||
const m = text.match(/\.(\d{2})\s*(19[4-9][A-Z]?|20[0-6][A-Z]?)\s*\d{2}-/);
|
||||
if (m?.[2]) return m[2];
|
||||
const m2 = text.match(/Nature\s+of\s+payment[\s\S]*?(19[4-9][A-Z]?|20[0-6][A-Z]?)/i);
|
||||
if (m2?.[1]) return m2[1];
|
||||
const m3 = text.match(/\b(19[4-9][A-Z]|20[0-6][A-Z])\b/);
|
||||
return m3?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractTransactionDate(text: string): string | null {
|
||||
const m = text.match(/Period\s*From\s*(\d{1,2}-[A-Za-z]{3}-\d{4})\s*To\s*(\d{1,2}-[A-Za-z]{3}-\d{4})/i);
|
||||
if (m?.[2]) return m[2];
|
||||
const d = text.match(/(\d{1,2}-\d{1,2}-\d{4})/g);
|
||||
return d?.[0] ?? null;
|
||||
}
|
||||
|
||||
function extractOltasStatus(text: string): string | null {
|
||||
const m = text.match(/Status\s+of\s+matching\s+with\s+OLTAS[\s\S]*?(\d{2}-\d{2}-\d{4})\s*(\d+)\s*([FOMUP])/i)
|
||||
|| text.match(/([FOMUP])\s*Final\s*|([FOMUP])\s*Unmatched/i)
|
||||
|| text.match(/\d{2}-\d{2}-\d{4}\s*\d+\s*([FOMUP])/);
|
||||
if (m) return (m[3] || m[1] || m[2] || '').toUpperCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractDateOfBooking(text: string): string | null {
|
||||
const m = text.match(/Date\s+on\s+which\s+tax\s+deposited[\s\S]*?(\d{1,2}-\d{1,2}-\d{4})/i)
|
||||
|| text.match(/Challan[\s\S]*?(\d{1,2}-\d{1,2}-\d{4})/i);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractForm16ANumber(text: string): string | null {
|
||||
const invalidWords = ['contains', 'certificate', 'number', 'nil', 'na', 'n/a', 'none', 'last'];
|
||||
const patterns = [
|
||||
/Certificate\s*No\.?[:\s]*([A-Z0-9][A-Z0-9\-]{2,30})/i,
|
||||
/Form\s*16A\s*No\.?[:\s]*([A-Z0-9][A-Z0-9\-]{2,30})/i,
|
||||
/Form\s*16A[:\s]*([A-Z0-9][A-Z0-9\-]{2,30})/i,
|
||||
/Certificate\s*number[:\s]*([A-Z0-9][A-Z0-9\-]{2,30})/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) {
|
||||
const val = match[1].trim();
|
||||
if (invalidWords.includes(val.toLowerCase()) || val.length < 3) continue;
|
||||
if (/\d/.test(val)) return val;
|
||||
if (/^[A-Z0-9\-]{3,30}$/i.test(val)) return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTAN(text: string): string | null {
|
||||
const patterns = [
|
||||
/TAN[:\s]*([A-Z]{4}[0-9]{5}[A-Z]{1})/i,
|
||||
/Tax\s*Deduction\s*Account\s*Number[:\s]*([A-Z]{4}[0-9]{5}[A-Z]{1})/i,
|
||||
/([A-Z]{4}[0-9]{5}[A-Z]{1})/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1].trim().toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractDeductorName(text: string): string | null {
|
||||
const patterns = [
|
||||
/Deductor[:\s]*([A-Z][A-Za-z\s&.,]+)/i,
|
||||
/Name\s*of\s*Deductor[:\s]*([A-Z][A-Za-z\s&.,]+)/i,
|
||||
/Company\s*Name[:\s]*([A-Z][A-Za-z\s&.,]+)/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFinancialYear(text: string): string | null {
|
||||
const patterns = [
|
||||
/Financial\s*Year[:\s]*([0-9]{4}[-/][0-9]{2,4})/i,
|
||||
/FY[:\s]*([0-9]{4}[-/][0-9]{2,4})/i,
|
||||
/([0-9]{4}[-/][0-9]{2,4})/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAssessmentYear(text: string): string | null {
|
||||
const fyMatch = extractFinancialYear(text);
|
||||
if (fyMatch) {
|
||||
const parts = fyMatch.split(/[-/]/);
|
||||
if (parts.length === 2) {
|
||||
const startYear = parseInt(parts[0], 10);
|
||||
return `${startYear + 1}-${(startYear + 2).toString().slice(-2)}`;
|
||||
}
|
||||
}
|
||||
const patterns = [
|
||||
/Assessment\s*Year[:\s]*([0-9]{4}[-/][0-9]{2,4})/i,
|
||||
/AY[:\s]*([0-9]{4}[-/][0-9]{2,4})/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCertificateDate(text: string): string | null {
|
||||
const patterns = [
|
||||
/Certificate\s*Date[:\s]*([0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{4})/i,
|
||||
/Date[:\s]*([0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{4})/i,
|
||||
/Issued\s*on[:\s]*([0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{4})/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse Form 16A raw text (REform16-aligned). */
|
||||
function parseForm16ARawText(text: string): Form16AExtractedData {
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
const fullText = lines.join('\n');
|
||||
|
||||
let nameAndAddressOfDeductor: string | null = null;
|
||||
const deductorStart = fullText.search(/Name\s+and\s+address\s+of\s+the\s+deductor/i);
|
||||
const deductorEnd = fullText.search(/Name\s+and\s+address\s+of\s+the\s+deductee|PAN\s+of\s+the\s+deductor/i);
|
||||
if (deductorStart !== -1 && deductorEnd !== -1 && deductorEnd > deductorStart) {
|
||||
const block = fullText.slice(deductorStart, deductorEnd);
|
||||
const afterLabel = block.replace(/Name\s+and\s+address\s+of\s+the\s+deductor\s*/i, '').trim();
|
||||
nameAndAddressOfDeductor = afterLabel.split(/\n/).map((l) => l.trim()).filter(Boolean).join(', ') || null;
|
||||
}
|
||||
if (!nameAndAddressOfDeductor) nameAndAddressOfDeductor = extractDeductorName(fullText);
|
||||
|
||||
const tanOfDeductor = extractTAN(fullText);
|
||||
const totalAmountPaid = extractTotalAmountPaidForm16A(fullText);
|
||||
const tdsAmounts = extractTDSAmountsForm16A(fullText);
|
||||
const totalTaxDeducted = tdsAmounts.taxDeducted ?? tdsAmounts.totalRs ?? null;
|
||||
const totalTdsDeposited = tdsAmounts.taxDeposited ?? tdsAmounts.totalRs ?? totalTaxDeducted ?? null;
|
||||
const form16aNumber = extractForm16ANumber(fullText);
|
||||
const assessmentYear = extractAssessmentYear(fullText);
|
||||
const quarter = extractQuarterForm16A(fullText);
|
||||
const natureOfPayment = extractNatureOfPayment(fullText);
|
||||
const transactionDate = extractTransactionDate(fullText);
|
||||
const statusOfMatchingOltas = extractOltasStatus(fullText);
|
||||
const certificateDate = extractCertificateDate(fullText);
|
||||
const dateOfBooking = extractDateOfBooking(fullText);
|
||||
let financialYear = extractFinancialYear(fullText);
|
||||
if (!financialYear && assessmentYear) {
|
||||
const parts = assessmentYear.split(/[-/]/).map((p) => parseInt(p, 10));
|
||||
if (parts.length === 2 && !Number.isNaN(parts[1])) {
|
||||
financialYear = `${parts[0] - 1}-${String(parts[1] - 1).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedDeductor = parseDeductorBlock(nameAndAddressOfDeductor || '');
|
||||
return {
|
||||
nameAndAddressOfDeductor,
|
||||
deductorName: parsedDeductor.name || nameAndAddressOfDeductor,
|
||||
deductorAddress: parsedDeductor.address ?? null,
|
||||
deductorPhone: parsedDeductor.phone ?? null,
|
||||
deductorEmail: parsedDeductor.email ?? null,
|
||||
totalAmountPaid: totalAmountPaid ?? null,
|
||||
totalTaxDeducted: totalTaxDeducted ?? null,
|
||||
totalTdsDeposited: totalTdsDeposited ?? null,
|
||||
tanOfDeductor,
|
||||
natureOfPayment: natureOfPayment ?? null,
|
||||
transactionDate: transactionDate ?? null,
|
||||
statusOfMatchingOltas: statusOfMatchingOltas ?? null,
|
||||
dateOfBooking: dateOfBooking ?? null,
|
||||
assessmentYear: assessmentYear ?? null,
|
||||
quarter: quarter ?? null,
|
||||
form16aNumber,
|
||||
financialYear: financialYear ?? null,
|
||||
certificateDate,
|
||||
tanNumber: tanOfDeductor,
|
||||
tdsAmount: totalTaxDeducted ?? null,
|
||||
totalAmount: totalAmountPaid ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fallback: pdf-parse (v2 PDFParse API) + parseForm16ARawText (REform16-aligned). */
|
||||
async function fallbackExtraction(filePath: string): Promise<Form16OcrResult> {
|
||||
try {
|
||||
const dataBuffer = fs.readFileSync(filePath);
|
||||
const { PDFParse } = await import('pdf-parse');
|
||||
const parser = new PDFParse({ data: new Uint8Array(dataBuffer) });
|
||||
const textResult = await parser.getText();
|
||||
const text = textResult?.text ?? '';
|
||||
await parser.destroy();
|
||||
if (!text || typeof text !== 'string') {
|
||||
logger.warn('[Form16 OCR] Fallback: no text extracted from PDF');
|
||||
return { success: false, message: 'No text could be extracted from PDF', error: 'Empty PDF text' };
|
||||
}
|
||||
const extracted = parseForm16ARawText(text);
|
||||
if (extracted.tanOfDeductor && !/^[A-Z]{4}[0-9]{5}[A-Z]{1}$/.test(extracted.tanOfDeductor)) {
|
||||
extracted.tanOfDeductor = null;
|
||||
extracted.tanNumber = null;
|
||||
}
|
||||
if (extracted.quarter) {
|
||||
const q = extracted.quarter.toUpperCase().trim();
|
||||
const qMatch = q.match(/[Q]?([1-4])/);
|
||||
extracted.quarter = /^Q[1-4]$/.test(q) ? q : (qMatch ? `Q${qMatch[1]}` : null);
|
||||
}
|
||||
logger.info('[Form16 OCR] Fallback extraction completed');
|
||||
return {
|
||||
success: true,
|
||||
data: extracted,
|
||||
method: 'fallback',
|
||||
ocrProvider: 'Regex fallback',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('[Form16 OCR] Fallback extraction error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: errMsg,
|
||||
message: 'Failed to extract data from PDF',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAndCleanGeminiData(extracted: Record<string, unknown>): Form16AExtractedData {
|
||||
const invalidCertWords = ['contains', 'certificate', 'number', 'nil', 'na', 'n/a', 'none', 'not', 'mentioned', 'see', 'above', 'below', 'refer', 'document', 'form', 'the', 'a', 'an', 'last'];
|
||||
const isInvalidCertNumber = (s: string | null): boolean => {
|
||||
if (!s || s.length > 50) return true;
|
||||
const lower = s.toLowerCase();
|
||||
if (invalidCertWords.some((w) => lower === w || lower.startsWith(w + ' ') || lower.endsWith(' ' + w))) return true;
|
||||
if (/\d/.test(s)) return false;
|
||||
if (/^[A-Z0-9\-]{3,30}$/i.test(s)) return false;
|
||||
return true;
|
||||
};
|
||||
const rawCertNo = getStr(extracted.form16aNumber);
|
||||
const form16aNumber = rawCertNo && !isInvalidCertNumber(rawCertNo) ? rawCertNo : null;
|
||||
|
||||
const sanitizeTds = (n: number | null): number | null => {
|
||||
if (n == null || n < 0) return null;
|
||||
if (n >= 100) return n;
|
||||
if (Number.isInteger(n) && n < 100) return null;
|
||||
return n;
|
||||
};
|
||||
const rawTdsDeducted = getNum(extracted.totalTaxDeducted ?? extracted.tdsAmount);
|
||||
const rawTdsDeposited = getNum(extracted.totalTdsDeposited ?? extracted.tdsAmount);
|
||||
const safeTdsDeducted = sanitizeTds(rawTdsDeducted);
|
||||
const safeTdsDeposited = sanitizeTds(rawTdsDeposited);
|
||||
|
||||
const deductorBlock = getStr(extracted.nameAndAddressOfDeductor ?? extracted.deductorName);
|
||||
const parsedDeductor = parseDeductorBlock(deductorBlock);
|
||||
const tanStr = getStr(extracted.tanOfDeductor ?? extracted.tanNumber);
|
||||
let tanUpper: string | null = tanStr ? tanStr.toUpperCase().trim() : null;
|
||||
if (tanUpper && !/^[A-Z]{4}[0-9]{5}[A-Z]{1}$/.test(tanUpper)) {
|
||||
tanUpper = null;
|
||||
}
|
||||
const quarterRaw = getStr(extracted.quarter);
|
||||
let quarter: string | null = null;
|
||||
if (quarterRaw) {
|
||||
const q = quarterRaw.toUpperCase().trim();
|
||||
if (/^Q[1-4]$/.test(q)) quarter = q;
|
||||
else {
|
||||
const m = q.match(/[Q]?([1-4])/);
|
||||
if (m) quarter = `Q${m[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nameAndAddressOfDeductor: deductorBlock,
|
||||
deductorName: getStr(extracted.deductorName ?? parsedDeductor.name) || deductorBlock,
|
||||
deductorAddress: getStr(extracted.deductorAddress ?? parsedDeductor.address),
|
||||
deductorPhone: getStr(extracted.deductorPhone ?? parsedDeductor.phone),
|
||||
deductorEmail: getStr(extracted.deductorEmail ?? parsedDeductor.email),
|
||||
totalAmountPaid: getNum(extracted.totalAmountPaid ?? extracted.totalAmount),
|
||||
totalTaxDeducted: safeTdsDeducted,
|
||||
totalTdsDeposited: safeTdsDeposited,
|
||||
tanOfDeductor: tanUpper,
|
||||
natureOfPayment: getStr(extracted.natureOfPayment),
|
||||
transactionDate: getStr(extracted.transactionDate),
|
||||
statusOfMatchingOltas: getStr(extracted.statusOfMatchingOltas),
|
||||
dateOfBooking: getStr(extracted.dateOfBooking),
|
||||
assessmentYear: getStr(extracted.assessmentYear),
|
||||
quarter,
|
||||
form16aNumber,
|
||||
financialYear: getStr(extracted.financialYear),
|
||||
certificateDate: getStr(extracted.certificateDate),
|
||||
tanNumber: tanUpper,
|
||||
tdsAmount: safeTdsDeducted,
|
||||
totalAmount: getNum(extracted.totalAmountPaid ?? extracted.totalAmount),
|
||||
};
|
||||
}
|
||||
|
||||
/** Run Form 16A extraction via Vertex AI (service account). */
|
||||
async function extractWithVertexAI(filePath: string, fileBase64: string, mimeType: string): Promise<Form16OcrResult> {
|
||||
const projectId = process.env.GCP_PROJECT_ID?.trim() || 're-platform-workflow-dealer';
|
||||
const location = process.env.FORM16_VERTEX_LOCATION?.trim() || process.env.VERTEX_AI_LOCATION?.trim() || 'us-central1';
|
||||
const modelId = process.env.GEMINI_MODEL?.trim() || 'gemini-2.0-flash-lite';
|
||||
const keyPath = getForm16VertexKeyPath();
|
||||
if (!keyPath || !fs.existsSync(keyPath)) {
|
||||
logger.warn('[Form16 OCR] Vertex: no service account key file found. Set GCP_KEY_FILE or GOOGLE_APPLICATION_CREDENTIALS.');
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
const vertexAI = new VertexAI({
|
||||
project: projectId,
|
||||
location,
|
||||
googleAuthOptions: { keyFilename: keyPath },
|
||||
});
|
||||
const generativeModel = vertexAI.getGenerativeModel({
|
||||
model: modelId,
|
||||
generationConfig: { temperature: 0.1, topP: 0.95, topK: 40, maxOutputTokens: 8192 },
|
||||
});
|
||||
logger.info(`[Form16 OCR] Using Vertex AI (${modelId}, ${location}) for ${path.basename(filePath)}`);
|
||||
const request = {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: GEMINI_PROMPT },
|
||||
{ inlineData: { mimeType, data: fileBase64 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = await generativeModel.generateContent(request);
|
||||
const candidate = response.response?.candidates?.[0];
|
||||
const textPart = candidate?.content?.parts?.[0];
|
||||
const text = textPart && 'text' in textPart ? (textPart as { text: string }).text : '';
|
||||
if (!text || !text.trim()) {
|
||||
logger.warn('[Form16 OCR] Vertex AI returned no text, using fallback');
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
let extractedData: Record<string, unknown>;
|
||||
try {
|
||||
const cleaned = text.trim().replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) extractedData = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
else extractedData = JSON.parse(cleaned) as Record<string, unknown>;
|
||||
} catch (parseErr) {
|
||||
logger.warn('[Form16 OCR] Failed to parse Vertex AI JSON, using fallback:', parseErr);
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
const data = sanitizeAndCleanGeminiData(extractedData);
|
||||
logger.info('[Form16 OCR] Vertex AI extraction completed successfully');
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
method: 'gemini',
|
||||
ocrProvider: 'Vertex AI (Gemini)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Form 16A details from PDF: (1) Gemini API key, (2) Vertex AI with service account, (3) regex fallback.
|
||||
*/
|
||||
export async function extractForm16ADetails(filePath: string): Promise<Form16OcrResult> {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileBase64 = fileBuffer.toString('base64');
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeType = ext === '.pdf' ? 'application/pdf' : 'image/png';
|
||||
|
||||
try {
|
||||
const geminiKey = process.env.GEMINI_API_KEY?.trim();
|
||||
if (geminiKey) {
|
||||
const genAI = new GoogleGenerativeAI(geminiKey);
|
||||
const modelId = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelId,
|
||||
generationConfig: { temperature: 0.1, topP: 0.95, topK: 40 },
|
||||
});
|
||||
logger.info(`[Form16 OCR] Using Gemini API (${modelId}) for ${path.basename(filePath)}`);
|
||||
const imagePart = { inlineData: { data: fileBase64, mimeType } };
|
||||
const result = await model.generateContent([GEMINI_PROMPT, imagePart]);
|
||||
const response = result.response;
|
||||
if (!response) {
|
||||
logger.warn('[Form16 OCR] Gemini API returned no response, trying Vertex AI or fallback');
|
||||
const vertexResult = await extractWithVertexAI(filePath, fileBase64, mimeType);
|
||||
if (vertexResult.success) return vertexResult;
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = response.text();
|
||||
} catch (textErr) {
|
||||
logger.warn('[Form16 OCR] Gemini API response.text() failed, trying Vertex AI or fallback:', textErr);
|
||||
const vertexResult = await extractWithVertexAI(filePath, fileBase64, mimeType);
|
||||
if (vertexResult.success) return vertexResult;
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
const vertexResult = await extractWithVertexAI(filePath, fileBase64, mimeType);
|
||||
if (vertexResult.success) return vertexResult;
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
let extractedData: Record<string, unknown>;
|
||||
try {
|
||||
const cleaned = text.trim().replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) extractedData = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
else extractedData = JSON.parse(cleaned) as Record<string, unknown>;
|
||||
} catch (parseErr) {
|
||||
logger.warn('[Form16 OCR] Failed to parse Gemini API JSON, using fallback:', parseErr);
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
const data = sanitizeAndCleanGeminiData(extractedData);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
method: 'gemini',
|
||||
ocrProvider: 'Google Gemini API',
|
||||
};
|
||||
}
|
||||
|
||||
// No API key: use Vertex AI with service account
|
||||
const vertexResult = await extractWithVertexAI(filePath, fileBase64, mimeType);
|
||||
if (vertexResult.success) return vertexResult;
|
||||
logger.warn('[Form16 OCR] Vertex AI failed or unavailable, using regex fallback');
|
||||
return await fallbackExtraction(filePath);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[Form16 OCR] Gemini/Vertex extraction error:', error);
|
||||
logger.info('[Form16 OCR] Falling back to regex-based extraction');
|
||||
return await fallbackExtraction(filePath);
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Form 16 permission service – API-driven access based on admin configuration.
|
||||
* Reads submissionViewerEmails and twentySixAsViewerEmails from FORM16_ADMIN_CONFIG.
|
||||
* No hardcoded emails or roles; all driven by stored config.
|
||||
*/
|
||||
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import { getDealerCodeForUser } from './form16.service';
|
||||
|
||||
const FORM16_CONFIG_KEY = 'FORM16_ADMIN_CONFIG';
|
||||
|
||||
export interface Form16ViewerConfig {
|
||||
submissionViewerEmails: string[];
|
||||
twentySixAsViewerEmails: string[];
|
||||
}
|
||||
|
||||
const emptyConfig: Form16ViewerConfig = {
|
||||
submissionViewerEmails: [],
|
||||
twentySixAsViewerEmails: [],
|
||||
};
|
||||
|
||||
/** Normalize email for comparison (lowercase, trim). */
|
||||
function normalizeEmail(email: string): string {
|
||||
return (email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Form 16 viewer config from admin_configurations (API-driven).
|
||||
* Returns empty arrays if no config or parse error (empty = allow all).
|
||||
*/
|
||||
export async function getForm16ViewerConfig(): Promise<Form16ViewerConfig> {
|
||||
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?.length || !result[0].config_value) {
|
||||
return emptyConfig;
|
||||
}
|
||||
const parsed = JSON.parse(result[0].config_value);
|
||||
const submission = Array.isArray(parsed.submissionViewerEmails)
|
||||
? parsed.submissionViewerEmails.map((e: unknown) => normalizeEmail(String(e ?? ''))).filter(Boolean)
|
||||
: [];
|
||||
const twentySixAs = Array.isArray(parsed.twentySixAsViewerEmails)
|
||||
? parsed.twentySixAsViewerEmails.map((e: unknown) => normalizeEmail(String(e ?? ''))).filter(Boolean)
|
||||
: [];
|
||||
return { submissionViewerEmails: submission, twentySixAsViewerEmails: twentySixAs };
|
||||
} catch {
|
||||
return emptyConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view Form 16 submission data (Credit Notes, Non-submitted Dealers, etc.).
|
||||
* - Admin: always allowed (full access to everything).
|
||||
* - Dealers: always allowed (they see their own submissions).
|
||||
* - RE users: allowed if submissionViewerEmails is empty, or user email is in submissionViewerEmails,
|
||||
* or user email is in twentySixAsViewerEmails (26AS access implies submission access so sidebar shows both).
|
||||
*/
|
||||
export async function canViewForm16Submission(
|
||||
userEmail: string,
|
||||
userId: string,
|
||||
role?: string
|
||||
): Promise<boolean> {
|
||||
if (role === 'ADMIN') return true;
|
||||
const isDealer = (await getDealerCodeForUser(userId)) !== null;
|
||||
if (isDealer) return true;
|
||||
const config = await getForm16ViewerConfig();
|
||||
const email = normalizeEmail(userEmail);
|
||||
if (!email) return false;
|
||||
if (config.submissionViewerEmails.length === 0 && config.twentySixAsViewerEmails.length === 0) return true;
|
||||
if (config.submissionViewerEmails.includes(email)) return true;
|
||||
if (config.twentySixAsViewerEmails.includes(email)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view 26AS page and 26AS data.
|
||||
* - Admin: always allowed (full access to everything).
|
||||
* - Otherwise: allowed if twentySixAsViewerEmails is empty, or user email is in the list.
|
||||
*/
|
||||
export async function canView26As(userEmail: string, role?: string): Promise<boolean> {
|
||||
if (role === 'ADMIN') return true;
|
||||
const config = await getForm16ViewerConfig();
|
||||
const email = normalizeEmail(userEmail);
|
||||
if (config.twentySixAsViewerEmails.length === 0) return true;
|
||||
return config.twentySixAsViewerEmails.includes(email);
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Form 16 – SAP simulation for Credit Note and Debit Note generation.
|
||||
* When real SAP APIs are integrated, replace calls to this service with actual SAP API calls
|
||||
* and keep the same request/response shapes below.
|
||||
*/
|
||||
|
||||
/** Dealer details sent to SAP for credit note generation */
|
||||
export interface SapCreditNoteDealerDetails {
|
||||
dealerCode: string;
|
||||
dealerName?: string | null;
|
||||
dealerEmail?: string | null;
|
||||
dealerContact?: string | null;
|
||||
/** Optional: address or other identifiers */
|
||||
[key: string]: string | number | null | undefined;
|
||||
}
|
||||
|
||||
/** Simulated SAP response for credit note generation (JSON response from SAP) */
|
||||
export interface SapCreditNoteResponse {
|
||||
success: boolean;
|
||||
creditNoteNumber: string;
|
||||
sapDocumentNumber: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
financialYear?: string;
|
||||
quarter?: string;
|
||||
status: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Dealer info sent to SAP for debit note generation */
|
||||
export interface SapDebitNoteDealerInfo {
|
||||
dealerCode: string;
|
||||
dealerName?: string | null;
|
||||
dealerEmail?: string | null;
|
||||
dealerContact?: string | null;
|
||||
[key: string]: string | number | null | undefined;
|
||||
}
|
||||
|
||||
/** Simulated SAP response for debit note generation (JSON response from SAP) */
|
||||
export interface SapDebitNoteResponse {
|
||||
success: boolean;
|
||||
debitNoteNumber: string;
|
||||
sapDocumentNumber: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
status: string;
|
||||
creditNoteNumber: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate SAP credit note generation.
|
||||
* Input: dealer details + amount. Output: credit note JSON (as from SAP).
|
||||
* Replace this with real SAP API call when integrating.
|
||||
*/
|
||||
export function simulateCreditNoteFromSap(
|
||||
dealerDetails: SapCreditNoteDealerDetails,
|
||||
amount: number
|
||||
): SapCreditNoteResponse {
|
||||
const now = new Date();
|
||||
const ts = now.getTime().toString(36).toUpperCase();
|
||||
const code = (dealerDetails.dealerCode || 'XX').replace(/\s/g, '').slice(0, 8);
|
||||
const creditNoteNumber = `CN-SAP-${now.getFullYear()}-${code}-${ts}`;
|
||||
const sapDocumentNumber = `SAP-CN-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${ts.slice(-6)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditNoteNumber,
|
||||
sapDocumentNumber,
|
||||
amount: Number(amount),
|
||||
issueDate: now.toISOString().split('T')[0],
|
||||
status: 'issued',
|
||||
message: 'Simulated SAP credit note (replace with real SAP integration)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate SAP debit note generation.
|
||||
* Input: dealer code, dealer info, credit note number, amount. Output: debit note JSON (as from SAP).
|
||||
* Replace this with real SAP API call when integrating.
|
||||
*/
|
||||
export function simulateDebitNoteFromSap(params: {
|
||||
dealerCode: string;
|
||||
dealerInfo: SapDebitNoteDealerInfo;
|
||||
creditNoteNumber: string;
|
||||
amount: number;
|
||||
}): SapDebitNoteResponse {
|
||||
const { dealerCode, dealerInfo, creditNoteNumber, amount } = params;
|
||||
const now = new Date();
|
||||
const ts = now.getTime().toString(36).toUpperCase();
|
||||
const code = (dealerCode || 'XX').replace(/\s/g, '').slice(0, 8);
|
||||
const debitNoteNumber = `DN-SAP-${now.getFullYear()}-${code}-${ts}`;
|
||||
const sapDocumentNumber = `SAP-DN-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${ts.slice(-6)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
debitNoteNumber,
|
||||
sapDocumentNumber,
|
||||
amount: Number(amount),
|
||||
issueDate: now.toISOString().split('T')[0],
|
||||
status: 'issued',
|
||||
creditNoteNumber: String(creditNoteNumber),
|
||||
message: 'Simulated SAP debit note (replace with real SAP integration)',
|
||||
};
|
||||
}
|
||||
@ -307,44 +307,11 @@ class NotificationService {
|
||||
'einvoice_generated': EmailNotificationType.EINVOICE_GENERATED,
|
||||
'credit_note_sent': EmailNotificationType.CREDIT_NOTE_SENT,
|
||||
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
|
||||
'pause_retriggered': null,
|
||||
// Form 16 – email sent in block above via same transport (Ethereal/SMTP); map kept null
|
||||
'form16_26as_added': null,
|
||||
'form16_success_credit_note': null,
|
||||
'form16_unsuccessful': null,
|
||||
'form16_debit_note': null,
|
||||
'form16_alert_submit': null,
|
||||
'form16_reminder': null,
|
||||
'pause_retriggered': null
|
||||
};
|
||||
|
||||
const emailType = emailTypeMap[payload.type || ''];
|
||||
|
||||
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); templates come from payload
|
||||
if (payload.type && payload.type.startsWith('form16_') && user?.email) {
|
||||
if (user.emailNotificationsEnabled === false) {
|
||||
logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { emailService } = await import('./email.service');
|
||||
const escaped = (payload.body || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
const html = `<!DOCTYPE html><html><body><p>${escaped}</p></body></html>`;
|
||||
await emailService.sendEmail({
|
||||
to: user.email,
|
||||
subject: payload.title || 'Form 16 Notification',
|
||||
html,
|
||||
});
|
||||
logger.info(`[Email] Form 16 email sent to ${user.email} (type: ${payload.type})`);
|
||||
} catch (err) {
|
||||
logger.error(`[Email] Form 16 email failed for user ${userId}:`, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailType) {
|
||||
// This notification type doesn't warrant email
|
||||
// Note: 'document_added' emails are handled separately via emailNotificationService
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { WorkflowRequest } from '@models/WorkflowRequest';
|
||||
// duplicate import removed
|
||||
import { User } from '@models/User';
|
||||
import { ApprovalLevel } from '@models/ApprovalLevel';
|
||||
import { Participant } from '@models/Participant';
|
||||
import { Document } from '@models/Document';
|
||||
import { Form16aSubmission } from '@models/Form16aSubmission';
|
||||
// Ensure associations are initialized by importing models index
|
||||
import '@models/index';
|
||||
import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.types';
|
||||
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
|
||||
@ -19,7 +20,6 @@ import { activityService } from './activity.service';
|
||||
import { tatSchedulerService } from './tatScheduler.service';
|
||||
import { emitToRequestRoom } from '../realtime/socket';
|
||||
import { sanitizeHtml } from '@utils/sanitizer';
|
||||
import { canViewForm16Submission } from './form16Permission.service';
|
||||
|
||||
export class WorkflowService {
|
||||
/**
|
||||
@ -616,7 +616,7 @@ export class WorkflowService {
|
||||
* Shows ALL requests in the organization, including where admin is initiator
|
||||
* Used by: "All Requests" page for admin users
|
||||
*/
|
||||
async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) {
|
||||
async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause with filters
|
||||
@ -677,20 +677,6 @@ export class WorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
// Form 16: filter by financial year and/or quarter (form16a_submissions)
|
||||
if (filters?.templateType?.toUpperCase() === 'FORM_16' && (filters.financialYear || filters.quarter)) {
|
||||
const subWhere: any = {};
|
||||
if (filters.financialYear) subWhere.financialYear = filters.financialYear;
|
||||
if (filters.quarter) subWhere.quarter = filters.quarter;
|
||||
const form16Rows = await Form16aSubmission.findAll({ where: subWhere, attributes: ['requestId'] });
|
||||
const form16RequestIds = form16Rows.map((r: any) => r.requestId);
|
||||
if (form16RequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: form16RequestIds } });
|
||||
} else {
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter (title, description, or requestNumber)
|
||||
if (filters?.search && filters.search.trim()) {
|
||||
whereConditions.push({
|
||||
@ -904,59 +890,6 @@ export class WorkflowService {
|
||||
}
|
||||
|
||||
private async enrichForCards(rows: WorkflowRequest[]) {
|
||||
// Batch-fetch Form 16 submission data for FORM_16 requests (incl. totalAmount, creditNoteNumber, displayStatus)
|
||||
const form16RequestIds = rows
|
||||
.filter((wf) => ((wf as any).templateType || '').toString().toUpperCase() === 'FORM_16')
|
||||
.map((wf) => (wf as any).requestId);
|
||||
const form16ByRequestId = new Map<string, any>();
|
||||
if (form16RequestIds.length > 0) {
|
||||
const { Form16CreditNote } = await import('@models/Form16CreditNote');
|
||||
const form16Rows = await Form16aSubmission.findAll({
|
||||
where: { requestId: { [Op.in]: form16RequestIds } },
|
||||
attributes: ['id', 'requestId', 'dealerCode', 'form16aNumber', 'financialYear', 'quarter', 'status', 'validationStatus', 'validationNotes', 'submittedDate', 'totalAmount'],
|
||||
});
|
||||
const submissionIds = form16Rows.map((r: any) => r.id);
|
||||
const creditNotes = submissionIds.length
|
||||
? await Form16CreditNote.findAll({
|
||||
where: { submissionId: submissionIds },
|
||||
attributes: ['submissionId', 'creditNoteNumber'],
|
||||
})
|
||||
: [];
|
||||
const cnBySubId = new Map<number, string>();
|
||||
for (const c of creditNotes as any[]) {
|
||||
if (c.submissionId && c.creditNoteNumber) cnBySubId.set(c.submissionId, c.creditNoteNumber);
|
||||
}
|
||||
const toDisplayStatus = (hasCn: boolean, valStatus: string | null, st: string, notes: string | null): string => {
|
||||
if (hasCn) return 'Completed';
|
||||
const v = (valStatus || '').toLowerCase();
|
||||
const n = (notes || '').toLowerCase();
|
||||
if (v === 'resubmission_needed') return 'Resubmission needed';
|
||||
if (v === 'duplicate') return 'Duplicate';
|
||||
if (v === 'manually_approved') return 'Completed';
|
||||
if (v === 'failed' || st === 'failed') {
|
||||
if (n.includes('mismatch') || n.includes('26as') || n.includes('value')) return 'Balance mismatch';
|
||||
return 'Failed';
|
||||
}
|
||||
return 'Under review';
|
||||
};
|
||||
for (const row of form16Rows as any[]) {
|
||||
const hasCn = cnBySubId.has(row.id);
|
||||
const creditNoteNumber = cnBySubId.get(row.id) ?? null;
|
||||
const displayStatus = toDisplayStatus(hasCn, row.validationStatus ?? null, row.status || '', row.validationNotes ?? null);
|
||||
form16ByRequestId.set(row.requestId, {
|
||||
dealerCode: row.dealerCode,
|
||||
form16aNumber: row.form16aNumber,
|
||||
financialYear: row.financialYear,
|
||||
quarter: row.quarter,
|
||||
status: row.status,
|
||||
submittedDate: row.submittedDate,
|
||||
totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null,
|
||||
creditNoteNumber,
|
||||
displayStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const data = await Promise.all(rows.map(async (wf) => {
|
||||
const currentLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
@ -1195,10 +1128,6 @@ export class WorkflowService {
|
||||
status: 'on_track'
|
||||
}, // ← Overall request SLA (all levels combined)
|
||||
currentLevelSLA: currentLevelSLA, // ← Also provide at root level for easy access
|
||||
// Form 16: include submission columns for All Requests / Closed Requests
|
||||
form16Submission: ((wf as any).templateType || '').toString().toUpperCase() === 'FORM_16'
|
||||
? form16ByRequestId.get((wf as any).requestId) || null
|
||||
: null,
|
||||
};
|
||||
}));
|
||||
return data;
|
||||
@ -1515,8 +1444,6 @@ export class WorkflowService {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
templateType?: string;
|
||||
financialYear?: string;
|
||||
quarter?: string;
|
||||
department?: string;
|
||||
initiator?: string;
|
||||
approver?: string;
|
||||
@ -1555,24 +1482,11 @@ export class WorkflowService {
|
||||
});
|
||||
const participantRequestIds = participants.map((p: any) => p.requestId);
|
||||
|
||||
// [Form 16 only] If user has Form 16 submission view permission (admin config), include all FORM_16 request IDs so they appear on All Requests.
|
||||
// Does not affect users without Form 16 access: form16RequestIds stays [] and list remains initiator + approver + spectator only.
|
||||
let form16RequestIds: string[] = [];
|
||||
const userForForm16 = await User.findByPk(userId, { attributes: ['email', 'role'] });
|
||||
if (userForForm16?.email && await canViewForm16Submission((userForForm16 as any).email, userId, (userForForm16 as any).role)) {
|
||||
const form16Workflows = await WorkflowRequest.findAll({
|
||||
where: { templateType: 'FORM_16', isDraft: false },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
form16RequestIds = form16Workflows.map((r: any) => r.requestId);
|
||||
}
|
||||
|
||||
// Combine ALL request IDs where user is involved (initiator + approver + spectator + Form 16 if permitted)
|
||||
// Combine ALL request IDs where user is involved (initiator + approver + spectator)
|
||||
const allRequestIds = Array.from(new Set([
|
||||
...initiatorRequestIds,
|
||||
...approverRequestIds,
|
||||
...participantRequestIds,
|
||||
...form16RequestIds
|
||||
...participantRequestIds
|
||||
]));
|
||||
|
||||
// Build where clause with filters
|
||||
@ -1634,20 +1548,6 @@ export class WorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
// Form 16: filter by financial year and/or quarter (form16a_submissions)
|
||||
if (filters?.templateType?.toUpperCase() === 'FORM_16' && (filters.financialYear || filters.quarter)) {
|
||||
const subWhere: any = {};
|
||||
if (filters.financialYear) subWhere.financialYear = filters.financialYear;
|
||||
if (filters.quarter) subWhere.quarter = filters.quarter;
|
||||
const form16Rows = await Form16aSubmission.findAll({ where: subWhere, attributes: ['requestId'] });
|
||||
const form16RequestIds = form16Rows.map((r: any) => r.requestId);
|
||||
if (form16RequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: form16RequestIds } });
|
||||
} else {
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter (title, description, or requestNumber)
|
||||
if (filters?.search && filters.search.trim()) {
|
||||
whereConditions.push({
|
||||
@ -2047,7 +1947,7 @@ export class WorkflowService {
|
||||
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
||||
}
|
||||
|
||||
async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string }, sortBy?: string, sortOrder?: string) {
|
||||
async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string }, sortBy?: string, sortOrder?: string) {
|
||||
const offset = (page - 1) * limit;
|
||||
// Find all pending/in-progress/paused approval levels across requests ordered by levelNumber
|
||||
// Include PAUSED status so paused requests where user is the current approver are shown
|
||||
@ -2109,94 +2009,8 @@ export class WorkflowService {
|
||||
});
|
||||
const approvedInitiatorRequestIds = approvedAsInitiator.map((r: any) => r.requestId);
|
||||
|
||||
// Also include PENDING/PAUSED requests where the user is the initiator (e.g. Form 16 submissions with no approval level yet)
|
||||
const pendingAsInitiator = await WorkflowRequest.findAll({
|
||||
where: {
|
||||
initiatorId: userId,
|
||||
status: { [Op.in]: [WorkflowStatus.PENDING as any, WorkflowStatus.PAUSED as any, 'PENDING', 'PAUSED'] as any },
|
||||
},
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const pendingInitiatorRequestIds = pendingAsInitiator.map((r: any) => r.requestId);
|
||||
|
||||
// Combine all request IDs (approver, spectator, approved as initiator, pending as initiator)
|
||||
let allOpenRequestIds = Array.from(new Set([...allRequestIds, ...approvedInitiatorRequestIds, ...pendingInitiatorRequestIds]));
|
||||
|
||||
let reForm16OpenUsed = false;
|
||||
// RE user with FORM_16 filter: show all Form 16 open requests (org-wide), not just user's
|
||||
if (filters?.templateType?.toUpperCase() === 'FORM_16') {
|
||||
const { getDealerCodeForUser } = await import('./form16.service');
|
||||
const dealerCode = await getDealerCodeForUser(userId);
|
||||
if (!dealerCode) {
|
||||
reForm16OpenUsed = true;
|
||||
const openStatuses = [
|
||||
WorkflowStatus.PENDING as any,
|
||||
WorkflowStatus.APPROVED as any,
|
||||
WorkflowStatus.PAUSED as any,
|
||||
'PENDING',
|
||||
'IN_PROGRESS',
|
||||
'APPROVED',
|
||||
'PAUSED'
|
||||
] as any;
|
||||
const form16Where: any = { templateType: 'FORM_16', status: { [Op.in]: openStatuses } };
|
||||
const allForm16 = await WorkflowRequest.findAll({ where: form16Where, attributes: ['requestId'] });
|
||||
let reForm16Ids = allForm16.map((r: any) => r.requestId);
|
||||
if (filters.financialYear || filters.quarter) {
|
||||
const subWhere: any = {};
|
||||
if (filters.financialYear) subWhere.financialYear = filters.financialYear;
|
||||
if (filters.quarter) subWhere.quarter = filters.quarter;
|
||||
const subRows = await Form16aSubmission.findAll({ where: subWhere, attributes: ['requestId'] });
|
||||
const subIds = subRows.map((r: any) => r.requestId);
|
||||
reForm16Ids = reForm16Ids.filter((id) => subIds.includes(id));
|
||||
}
|
||||
allOpenRequestIds = reForm16Ids.length > 0 ? reForm16Ids : ['00000000-0000-0000-0000-000000000000'];
|
||||
}
|
||||
}
|
||||
|
||||
// When filtering by Form 16 with FY/quarter (for dealer only), restrict to request IDs that have matching form16a_submissions
|
||||
if (!reForm16OpenUsed && filters?.templateType?.toUpperCase() === 'FORM_16' && (filters.financialYear || filters.quarter)) {
|
||||
const form16Where: any = {};
|
||||
if (filters.financialYear) form16Where.financialYear = filters.financialYear;
|
||||
if (filters.quarter) form16Where.quarter = filters.quarter;
|
||||
const form16Rows = await Form16aSubmission.findAll({ where: form16Where, attributes: ['requestId'] });
|
||||
const form16RequestIds = form16Rows.map((r: any) => r.requestId);
|
||||
if (form16RequestIds.length > 0) {
|
||||
allOpenRequestIds = allOpenRequestIds.filter((id) => form16RequestIds.includes(id));
|
||||
} else {
|
||||
allOpenRequestIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Form 16 only: for dealers, exclude Form 16 from Open Requests (they see Form 16 on All Requests only)
|
||||
const { getDealerCodeForUser } = await import('./form16.service');
|
||||
const dealerCodeOpen = await getDealerCodeForUser(userId);
|
||||
if (dealerCodeOpen && allOpenRequestIds.length > 0 && (!filters?.templateType || (filters.templateType || '').toUpperCase() !== 'FORM_16')) {
|
||||
const form16Open = await WorkflowRequest.findAll({
|
||||
where: { requestId: { [Op.in]: allOpenRequestIds }, templateType: 'FORM_16' },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const form16OpenIds = new Set(form16Open.map((r: any) => r.requestId));
|
||||
allOpenRequestIds = allOpenRequestIds.filter((id) => !form16OpenIds.has(id));
|
||||
}
|
||||
|
||||
// Form 16 only: for RE users (no dealer code), include all open Form 16 requests so they appear in Open Requests without needing to filter by Form 16
|
||||
if (!dealerCodeOpen && allOpenRequestIds.length >= 0) {
|
||||
const form16OpenStatuses = [
|
||||
WorkflowStatus.PENDING as any,
|
||||
WorkflowStatus.APPROVED as any,
|
||||
WorkflowStatus.PAUSED as any,
|
||||
'PENDING',
|
||||
'IN_PROGRESS',
|
||||
'APPROVED',
|
||||
'PAUSED'
|
||||
] as any;
|
||||
const form16Open = await WorkflowRequest.findAll({
|
||||
where: { templateType: 'FORM_16', status: { [Op.in]: form16OpenStatuses } },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const form16OpenIds = form16Open.map((r: any) => r.requestId);
|
||||
allOpenRequestIds = Array.from(new Set([...allOpenRequestIds, ...form16OpenIds]));
|
||||
}
|
||||
// Combine all request IDs (approver, spectator, and approved as initiator)
|
||||
const allOpenRequestIds = Array.from(new Set([...allRequestIds, ...approvedInitiatorRequestIds]));
|
||||
|
||||
// Build base where conditions
|
||||
const baseConditions: any[] = [];
|
||||
@ -2390,7 +2204,7 @@ export class WorkflowService {
|
||||
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
||||
}
|
||||
|
||||
async listClosedByMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string; financialYear?: string; quarter?: string }, sortBy?: string, sortOrder?: string) {
|
||||
async listClosedByMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string }, sortBy?: string, sortOrder?: string) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get requests where user participated as approver
|
||||
@ -2584,59 +2398,8 @@ export class WorkflowService {
|
||||
|
||||
whereConditions.push(initiatorCondition);
|
||||
|
||||
// When filtering by Form 16 with FY/quarter, restrict to request IDs that have matching form16a_submissions
|
||||
let where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {};
|
||||
let reForm16ClosedUsed = false;
|
||||
// RE user with FORM_16 filter: show all closed Form 16 requests (org-wide)
|
||||
if (filters?.templateType?.toUpperCase() === 'FORM_16') {
|
||||
const { getDealerCodeForUser } = await import('./form16.service');
|
||||
const dealerCode = await getDealerCodeForUser(userId);
|
||||
if (!dealerCode) {
|
||||
reForm16ClosedUsed = true;
|
||||
const closedStatuses = [(WorkflowStatus as any).CLOSED ?? 'CLOSED', 'CLOSED'] as any;
|
||||
where = { templateType: 'FORM_16', status: { [Op.in]: closedStatuses } };
|
||||
if (filters.financialYear || filters.quarter) {
|
||||
const form16Where: any = {};
|
||||
if (filters.financialYear) form16Where.financialYear = filters.financialYear;
|
||||
if (filters.quarter) form16Where.quarter = filters.quarter;
|
||||
const form16Rows = await Form16aSubmission.findAll({ where: form16Where, attributes: ['requestId'] });
|
||||
const form16RequestIds = form16Rows.map((r: any) => r.requestId);
|
||||
if (form16RequestIds.length > 0) {
|
||||
where = { [Op.and]: [where, { requestId: { [Op.in]: form16RequestIds } }] };
|
||||
} else {
|
||||
where = { [Op.and]: [where, { requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }] };
|
||||
}
|
||||
}
|
||||
if (filters?.status && filters?.status !== 'all') {
|
||||
const s = filters.status.toLowerCase();
|
||||
if (s === 'rejected') {
|
||||
where = { [Op.and]: [where, literal(`EXISTS (SELECT 1 FROM approval_levels al WHERE al.request_id = "WorkflowRequest"."request_id" AND al.status = 'REJECTED')`)] };
|
||||
} else if (s === 'approved') {
|
||||
where = { [Op.and]: [where, literal(`NOT EXISTS (SELECT 1 FROM approval_levels al WHERE al.request_id = "WorkflowRequest"."request_id" AND al.status = 'REJECTED')`)] };
|
||||
}
|
||||
}
|
||||
if (filters?.priority && filters?.priority !== 'all') {
|
||||
where = { [Op.and]: [where, { priority: filters.priority.toUpperCase() }] };
|
||||
}
|
||||
if (filters?.search && filters?.search?.trim()) {
|
||||
where = { [Op.and]: [where, { [Op.or]: [{ title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }] }] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reForm16ClosedUsed && filters?.templateType?.toUpperCase() === 'FORM_16' && (filters.financialYear || filters.quarter)) {
|
||||
const form16Where: any = {};
|
||||
if (filters.financialYear) form16Where.financialYear = filters.financialYear;
|
||||
if (filters.quarter) form16Where.quarter = filters.quarter;
|
||||
const form16Rows = await Form16aSubmission.findAll({ where: form16Where, attributes: ['requestId'] });
|
||||
const form16RequestIds = form16Rows.map((r: any) => r.requestId);
|
||||
if (form16RequestIds.length > 0) {
|
||||
where = { [Op.and]: [where, { requestId: { [Op.in]: form16RequestIds } }] };
|
||||
} else {
|
||||
where = { requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } };
|
||||
}
|
||||
}
|
||||
|
||||
// Build where clause with OR conditions (already built above)
|
||||
// Build where clause with OR conditions
|
||||
const where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {};
|
||||
|
||||
// Build order clause based on sortBy parameter
|
||||
let order: any[] = [['createdAt', 'DESC']]; // Default order
|
||||
@ -2912,16 +2675,6 @@ export class WorkflowService {
|
||||
return { hasAccess: true };
|
||||
}
|
||||
|
||||
// Check 5: [Form 16 only] For FORM_16 requests, allow if user has Form 16 submission view permission (admin config).
|
||||
// Does not affect other workflow types: runs only when templateType is FORM_16.
|
||||
const templateType = (workflowBase as any).templateType ?? (workflowBase as any).template_type;
|
||||
if (templateType && String(templateType).toUpperCase() === 'FORM_16') {
|
||||
const u = user ?? await User.findByPk(userId, { attributes: ['email', 'role'] });
|
||||
if (u?.email && await canViewForm16Submission((u as any).email, userId, (u as any).role)) {
|
||||
return { hasAccess: true };
|
||||
}
|
||||
}
|
||||
|
||||
// No access
|
||||
return {
|
||||
hasAccess: false,
|
||||
@ -3351,185 +3104,7 @@ export class WorkflowService {
|
||||
sla: overallSLA || summary.sla
|
||||
};
|
||||
|
||||
// For Form 16 requests, attach form16 submission (for detail Overview) and ensure Documents tab has the uploaded Form 16
|
||||
let form16Submission: any = null;
|
||||
let documentsForReturn = documents;
|
||||
if (((workflow as any).templateType || '').toString().toUpperCase() === 'FORM_16') {
|
||||
const form16Row = await Form16aSubmission.findOne({
|
||||
where: { requestId: actualRequestId },
|
||||
attributes: ['id', 'dealerCode', 'form16aNumber', 'financialYear', 'quarter', 'version', 'status', 'validationStatus', 'validationNotes', 'tanNumber', 'deductorName', 'tdsAmount', 'totalAmount', 'ocrExtractedData', 'submittedDate', 'documentUrl'],
|
||||
});
|
||||
if (form16Row) {
|
||||
const row = form16Row as any;
|
||||
const { Form16CreditNote } = await import('@models/Form16CreditNote');
|
||||
const creditNote = await Form16CreditNote.findOne({
|
||||
where: { submissionId: row.id },
|
||||
attributes: ['creditNoteNumber'],
|
||||
});
|
||||
const hasCreditNote = !!creditNote && !!(creditNote as any).creditNoteNumber;
|
||||
const creditNoteNumber = (creditNote as any)?.creditNoteNumber ?? null;
|
||||
const toDisplayStatus = (): string => {
|
||||
if (hasCreditNote) return 'Completed';
|
||||
const v = (row.validationStatus || '').toLowerCase();
|
||||
const notes = (row.validationNotes || '').toLowerCase();
|
||||
if (v === 'resubmission_needed') return 'Resubmission needed';
|
||||
if (v === 'duplicate') return 'Duplicate';
|
||||
if (v === 'manually_approved') return 'Completed';
|
||||
if (v === 'failed' || row.status === 'failed') {
|
||||
if (notes.includes('mismatch') || notes.includes('26as') || notes.includes('value')) return 'Balance mismatch';
|
||||
if (notes.includes('partial')) return 'Partial extracted data';
|
||||
return 'Failed';
|
||||
}
|
||||
return 'Under review';
|
||||
};
|
||||
// Ensure Form 16 uploaded file appears in Documents tab (for dealer, RE, admin). Backfill if no document row exists.
|
||||
if (row.documentUrl && documents.length === 0) {
|
||||
try {
|
||||
await Document.create({
|
||||
requestId: actualRequestId,
|
||||
uploadedBy: (workflow as any).initiatorId,
|
||||
fileName: 'form16a.pdf',
|
||||
originalFileName: 'Form 16A.pdf',
|
||||
fileType: 'application/pdf',
|
||||
fileExtension: 'pdf',
|
||||
fileSize: 0,
|
||||
filePath: row.documentUrl.slice(0, 500),
|
||||
storageUrl: row.documentUrl.length <= 500 ? row.documentUrl : undefined,
|
||||
mimeType: 'application/pdf',
|
||||
checksum: 'form16-legacy-' + actualRequestId.slice(0, 8),
|
||||
isGoogleDoc: false,
|
||||
category: 'SUPPORTING',
|
||||
version: 1,
|
||||
isDeleted: false,
|
||||
downloadCount: 0,
|
||||
uploadedAt: (workflow as any).submissionDate || (workflow as any).createdAt || new Date(),
|
||||
});
|
||||
const docsRefetched = await Document.findAll({
|
||||
where: { requestId: actualRequestId, isDeleted: false },
|
||||
}) as any[];
|
||||
documentsForReturn = docsRefetched;
|
||||
} catch (backfillErr) {
|
||||
logger.warn('[Workflow] Form 16 document backfill failed for request ' + actualRequestId, backfillErr);
|
||||
}
|
||||
}
|
||||
const ocr = row.ocrExtractedData || {};
|
||||
form16Submission = {
|
||||
dealerCode: row.dealerCode,
|
||||
form16aNumber: row.form16aNumber,
|
||||
financialYear: row.financialYear,
|
||||
quarter: row.quarter,
|
||||
version: row.version ?? 1,
|
||||
status: row.status,
|
||||
validationStatus: row.validationStatus ?? null,
|
||||
validationNotes: row.validationNotes ?? null,
|
||||
displayStatus: toDisplayStatus(),
|
||||
creditNoteNumber,
|
||||
tanNumber: row.tanNumber,
|
||||
deductorName: row.deductorName,
|
||||
tdsAmount: row.tdsAmount,
|
||||
totalAmount: row.totalAmount,
|
||||
submittedDate: row.submittedDate,
|
||||
acknowledgementNumber: ocr.acknowledgementNumber ?? ocr.acknowledgement_number,
|
||||
dateOfIssue: ocr.dateOfIssue ?? ocr.date_of_issue,
|
||||
section: ocr.section ?? ocr.sectionCode,
|
||||
amountPaid: ocr.amountPaid ?? ocr.amount_paid,
|
||||
dateOfPayment: ocr.dateOfPayment ?? ocr.date_of_payment,
|
||||
bsrCode: ocr.bsrCode ?? ocr.bsr_code,
|
||||
challanSerialNo: ocr.challanSerialNo ?? ocr.challan_serial_no,
|
||||
dateOfDeposit: ocr.dateOfDeposit ?? ocr.date_of_deposit,
|
||||
deduceeName: ocr.deduceeName ?? ocr.deducee_name,
|
||||
deduceePan: ocr.deduceePan ?? ocr.deducee_pan,
|
||||
deduceeAddress: ocr.deduceeAddress ?? ocr.deducee_address,
|
||||
ocrExtractedData: row.ocrExtractedData ?? null,
|
||||
};
|
||||
// Previous submissions for same dealer+FY+quarter only (Documents tab shows only this FY+quarter for this request)
|
||||
const currentFy = row.financialYear ?? row.financial_year;
|
||||
const currentQuarter = row.quarter;
|
||||
const currentDealerCode = row.dealerCode ?? row.dealer_code;
|
||||
const previousRows = await Form16aSubmission.findAll({
|
||||
where: {
|
||||
dealerCode: currentDealerCode,
|
||||
financialYear: currentFy,
|
||||
quarter: currentQuarter,
|
||||
requestId: { [Op.ne]: actualRequestId },
|
||||
},
|
||||
attributes: ['id', 'requestId', 'submittedDate', 'validationStatus', 'documentUrl'],
|
||||
order: [['submittedDate', 'ASC']],
|
||||
raw: true,
|
||||
}) as any[];
|
||||
// raw: true may return request_id (snake_case); support both
|
||||
const previousRequestIds = previousRows.map((r) => r.requestId ?? r.request_id).filter(Boolean) as string[];
|
||||
const previousRequestIdSet = new Set(previousRequestIds);
|
||||
const previousWorkflows = previousRequestIds.length > 0
|
||||
? await WorkflowRequest.findAll({
|
||||
where: { requestId: { [Op.in]: previousRequestIds } },
|
||||
attributes: ['requestId', 'requestNumber'],
|
||||
raw: true,
|
||||
}) as any[]
|
||||
: [];
|
||||
const requestNumberByRequestId = new Map<string, string>();
|
||||
for (const w of previousWorkflows) {
|
||||
const wid = (w as any).requestId ?? (w as any).request_id;
|
||||
const wnum = (w as any).requestNumber ?? (w as any).request_number;
|
||||
if (wid && wnum) requestNumberByRequestId.set(wid, wnum);
|
||||
}
|
||||
const submissionIdsPrev = previousRows.map((r) => r.id);
|
||||
const creditNotesPrev = submissionIdsPrev.length > 0
|
||||
? await Form16CreditNote.findAll({
|
||||
where: { submissionId: submissionIdsPrev },
|
||||
attributes: ['submissionId', 'creditNoteNumber', 'issueDate'],
|
||||
raw: true,
|
||||
}) as any[]
|
||||
: [];
|
||||
const cnBySubId = new Map<number, { creditNoteNumber: string; issueDate?: string }>();
|
||||
for (const c of creditNotesPrev) {
|
||||
if (c.submissionId) cnBySubId.set(c.submissionId, { creditNoteNumber: c.creditNoteNumber || '', issueDate: c.issueDate });
|
||||
}
|
||||
const form16PreviousSubmissions = previousRows.map((r) => {
|
||||
const rReqId = r.requestId ?? r.request_id;
|
||||
const reqNum = requestNumberByRequestId.get(rReqId) || rReqId;
|
||||
const cn = cnBySubId.get(r.id);
|
||||
const hasCn = !!cn && !!cn.creditNoteNumber;
|
||||
const displayStatus = hasCn ? 'Completed' : (r.validationStatus === 'duplicate' ? 'Duplicate' : (r.validationStatus || 'Under review'));
|
||||
return {
|
||||
requestId: rReqId,
|
||||
requestNumber: reqNum,
|
||||
submittedDate: r.submittedDate ?? r.submitted_date,
|
||||
displayStatus,
|
||||
creditNoteNumber: cn?.creditNoteNumber ?? null,
|
||||
creditNoteIssueDate: cn?.issueDate ?? null,
|
||||
};
|
||||
});
|
||||
let form16PreviousDocuments: any[] = [];
|
||||
if (previousRequestIds.length > 0) {
|
||||
const prevDocs = await Document.findAll({
|
||||
where: { requestId: { [Op.in]: previousRequestIds }, isDeleted: false },
|
||||
raw: true,
|
||||
}) as any[];
|
||||
const reqNumByRequestId = Object.fromEntries([...requestNumberByRequestId]);
|
||||
// Only include documents whose request belongs to same FY+quarter (defensive filter)
|
||||
form16PreviousDocuments = prevDocs
|
||||
.filter((d) => previousRequestIdSet.has(d.requestId ?? d.request_id))
|
||||
.map((d) => {
|
||||
const reqId = d.requestId ?? d.request_id;
|
||||
return {
|
||||
...d,
|
||||
requestId: reqId,
|
||||
requestNumber: reqNumByRequestId[reqId] || reqId,
|
||||
};
|
||||
});
|
||||
}
|
||||
(form16Submission as any).previousSubmissions = form16PreviousSubmissions;
|
||||
(form16Submission as any).previousDocuments = form16PreviousDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure documents array contains only this request's documents (no cross-request leakage)
|
||||
const safeDocuments = Array.isArray(documentsForReturn)
|
||||
? documentsForReturn.filter((d: any) => (d.requestId ?? d.request_id) === actualRequestId)
|
||||
: documentsForReturn;
|
||||
|
||||
return { workflow, approvals: updatedApprovals, participants, documents: safeDocuments, activities, summary: updatedSummary, tatAlerts, form16Submission };
|
||||
return { workflow, approvals: updatedApprovals, participants, documents, activities, summary: updatedSummary, tatAlerts };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get workflow details ${requestId}:`, error);
|
||||
throw new Error('Failed to get workflow details');
|
||||
|
||||
@ -4,7 +4,7 @@ export interface WorkflowRequest {
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
initiatorId: string;
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
workflowType?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@ -24,7 +24,7 @@ export interface WorkflowRequest {
|
||||
}
|
||||
|
||||
export interface CreateWorkflowRequest {
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
|
||||
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
|
||||
workflowType?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@ -108,8 +108,7 @@ const transports: winston.transport[] = [
|
||||
];
|
||||
|
||||
// ============ LOKI TRANSPORT (Grafana) ============
|
||||
// Only enable Loki in production (or LOKI_FORCE=true); in dev, connection errors are noisy when Loki isn't running
|
||||
if (process.env.LOKI_HOST && (isProduction || process.env.LOKI_FORCE === 'true')) {
|
||||
if (process.env.LOKI_HOST) {
|
||||
try {
|
||||
const LokiTransport = require('winston-loki');
|
||||
|
||||
@ -123,7 +122,7 @@ if (process.env.LOKI_HOST && (isProduction || process.env.LOKI_FORCE === 'true')
|
||||
),
|
||||
replaceTimestamp: true,
|
||||
onConnectionError: (err: Error) => {
|
||||
if (isProduction) console.error('[Loki] Connection error:', err.message);
|
||||
console.error('[Loki] Connection error:', err.message);
|
||||
},
|
||||
batching: true,
|
||||
interval: 5,
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
// Jest setup (optional). Increase timeout for integration tests if needed.
|
||||
jest.setTimeout(30000);
|
||||
Loading…
Reference in New Issue
Block a user