Compare commits

..

3 Commits

Author SHA1 Message Date
a67eb9da3c migration file conflict resolved 2026-03-10 15:21:27 +05:30
185d96e8b1 front end build added 2026-03-10 14:22:47 +05:30
56d9d9c169 form 16 code is added to the workflow 2026-03-10 14:06:27 +05:30
83 changed files with 9382 additions and 517 deletions

325
Jenkinsfile vendored
View File

@ -1,325 +0,0 @@
pipeline {
agent any
environment {
SSH_CREDENTIALS = 'cloudtopiaa'
REMOTE_SERVER = 'ubuntu@160.187.166.17'
PROJECT_NAME = 'Royal-Enfield-Backend'
DEPLOY_PATH = '/home/ubuntu/Royal-Enfield/Re_Backend'
GIT_CREDENTIALS = 'git-cred'
REPO_URL = 'https://git.tech4biz.wiki/laxmanhalaki/Re_Backend.git'
GIT_BRANCH = 'main'
NPM_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/npm'
NODE_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/node'
PM2_PATH = '/home/ubuntu/.nvm/versions/node/v22.21.1/bin/pm2'
PM2_APP_NAME = 'royal-enfield-backend'
APP_PORT = '5000'
EMAIL_RECIPIENT = 'laxman.halaki@tech4biz.org'
}
options {
timeout(time: 20, unit: 'MINUTES')
disableConcurrentBuilds()
timestamps()
buildDiscarder(logRotator(numToKeepStr: '10', daysToKeepStr: '30'))
}
stages {
stage('Pre-deployment Check') {
steps {
script {
echo "═══════════════════════════════════════════"
echo "🚀 Starting ${PROJECT_NAME} Deployment"
echo "═══════════════════════════════════════════"
echo "Server: ${REMOTE_SERVER}"
echo "Deploy Path: ${DEPLOY_PATH}"
echo "PM2 App: ${PM2_APP_NAME}"
echo "Build #: ${BUILD_NUMBER}"
echo "═══════════════════════════════════════════"
}
}
}
stage('Pull Latest Code') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
withCredentials([usernamePassword(credentialsId: GIT_CREDENTIALS, usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) {
sh """
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${REMOTE_SERVER} << 'ENDSSH'
set -e
echo "📦 Git Operations..."
if [ -d "${DEPLOY_PATH}/.git" ]; then
cd ${DEPLOY_PATH}
echo "Configuring git..."
git config --global --add safe.directory ${DEPLOY_PATH}
git config credential.helper store
echo "Fetching updates..."
git fetch https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/laxmanhalaki/Re_Backend.git ${GIT_BRANCH}
CURRENT_COMMIT=\$(git rev-parse HEAD)
LATEST_COMMIT=\$(git rev-parse FETCH_HEAD)
if [ "\$CURRENT_COMMIT" = "\$LATEST_COMMIT" ]; then
echo "⚠️ Already up to date. No changes to deploy."
echo "Current: \$CURRENT_COMMIT"
else
echo "Pulling new changes..."
git reset --hard FETCH_HEAD
git clean -fd
echo "✓ Updated from \${CURRENT_COMMIT:0:7} to \${LATEST_COMMIT:0:7}"
fi
else
echo "Cloning repository..."
rm -rf ${DEPLOY_PATH}
mkdir -p /home/ubuntu/Royal-Enfield
cd /home/ubuntu/Royal-Enfield
git clone https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/laxmanhalaki/Re_Backend.git Re_Backend
cd ${DEPLOY_PATH}
git checkout ${GIT_BRANCH}
git config --global --add safe.directory ${DEPLOY_PATH}
echo "✓ Repository cloned successfully"
fi
cd ${DEPLOY_PATH}
echo "Current commit: \$(git log -1 --oneline)"
ENDSSH
"""
}
}
}
}
stage('Install Dependencies') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
sh """
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
set -e
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
cd ${DEPLOY_PATH}
echo "🔧 Environment Check..."
echo "Node: \$(${NODE_PATH} -v)"
echo "NPM: \$(${NPM_PATH} -v)"
echo ""
echo "📥 Installing Dependencies..."
${NPM_PATH} install --prefer-offline --no-audit --progress=false
echo ""
echo "✅ Dependencies installed successfully!"
ENDSSH
"""
}
}
}
stage('Build Application') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
sh """
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
set -e
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
cd ${DEPLOY_PATH}
echo "🔨 Building application..."
${NPM_PATH} run build
echo "✅ Build completed successfully!"
ENDSSH
"""
}
}
}
stage('Stop PM2 Process') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
sh """
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
set -e
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
echo "🛑 Stopping existing PM2 process..."
if ${PM2_PATH} list | grep -q "${PM2_APP_NAME}"; then
echo "Stopping ${PM2_APP_NAME}..."
${PM2_PATH} stop ${PM2_APP_NAME} || true
${PM2_PATH} delete ${PM2_APP_NAME} || true
echo "✓ Process stopped"
else
echo "No existing process found"
fi
ENDSSH
"""
}
}
}
stage('Start with PM2') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
sh """
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
set -e
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
cd ${DEPLOY_PATH}
echo "🚀 Starting application with PM2..."
# Start with PM2
${PM2_PATH} start ${NPM_PATH} --name "${PM2_APP_NAME}" -- start
echo ""
echo "⏳ Waiting for application to start..."
sleep 5
# Save PM2 configuration
${PM2_PATH} save
# Show PM2 status
echo ""
echo "📊 PM2 Process Status:"
${PM2_PATH} list
# Show logs (last 20 lines)
echo ""
echo "📝 Application Logs:"
${PM2_PATH} logs ${PM2_APP_NAME} --lines 20 --nostream || true
echo ""
echo "✅ Application started successfully!"
ENDSSH
"""
}
}
}
stage('Health Check') {
steps {
sshagent(credentials: [SSH_CREDENTIALS]) {
sh """
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} << 'ENDSSH'
set -e
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
echo "🔍 Deployment Verification..."
# Check if PM2 process is running
if ${PM2_PATH} list | grep -q "${PM2_APP_NAME}.*online"; then
echo "✓ PM2 process is running"
else
echo "✗ PM2 process is NOT running!"
${PM2_PATH} logs ${PM2_APP_NAME} --lines 50 --nostream || true
exit 1
fi
# Check if port is listening
echo ""
echo "Checking if port ${APP_PORT} is listening..."
if ss -tuln | grep -q ":${APP_PORT} "; then
echo "✓ Application is listening on port ${APP_PORT}"
else
echo "⚠️ Port ${APP_PORT} not detected (may take a moment to start)"
fi
# Show process info
echo ""
echo "📊 Process Information:"
${PM2_PATH} info ${PM2_APP_NAME}
echo ""
echo "═══════════════════════════════════════════"
echo "✅ DEPLOYMENT SUCCESSFUL"
echo "═══════════════════════════════════════════"
ENDSSH
"""
}
}
}
}
post {
always {
cleanWs()
}
success {
script {
def duration = currentBuild.durationString.replace(' and counting', '')
mail to: "${EMAIL_RECIPIENT}",
subject: "✅ ${PROJECT_NAME} - Deployment Successful #${BUILD_NUMBER}",
body: """
Deployment completed successfully!
Project: ${PROJECT_NAME}
Build: #${BUILD_NUMBER}
Duration: ${duration}
Server: ${REMOTE_SERVER}
PM2 App: ${PM2_APP_NAME}
Port: ${APP_PORT}
Deployed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
Console: ${BUILD_URL}console
Commands to manage:
- View logs: pm2 logs ${PM2_APP_NAME}
- Restart: pm2 restart ${PM2_APP_NAME}
- Stop: pm2 stop ${PM2_APP_NAME}
"""
}
}
failure {
script {
sshagent(credentials: [SSH_CREDENTIALS]) {
try {
def logs = sh(
script: """ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
export PATH="/home/ubuntu/.nvm/versions/node/v22.21.1/bin:\$PATH"
${PM2_PATH} logs ${PM2_APP_NAME} --lines 50 --nostream || echo "No logs available"
'""",
returnStdout: true
).trim()
mail to: "${EMAIL_RECIPIENT}",
subject: "❌ ${PROJECT_NAME} - Deployment Failed #${BUILD_NUMBER}",
body: """
Deployment FAILED!
Project: ${PROJECT_NAME}
Build: #${BUILD_NUMBER}
Server: ${REMOTE_SERVER}
Failed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
Console Log: ${BUILD_URL}console
Recent PM2 Logs:
${logs}
Action required immediately!
"""
} catch (Exception e) {
mail to: "${EMAIL_RECIPIENT}",
subject: "❌ ${PROJECT_NAME} - Deployment Failed #${BUILD_NUMBER}",
body: """
Deployment FAILED!
Project: ${PROJECT_NAME}
Build: #${BUILD_NUMBER}
Server: ${REMOTE_SERVER}
Failed at: ${new Date().format('yyyy-MM-dd HH:mm:ss')}
Console Log: ${BUILD_URL}console
Could not retrieve PM2 logs. Please check manually.
"""
}
}
}
}
}
}

View File

@ -83,6 +83,41 @@ A comprehensive backend API for the Royal Enfield Workflow Management System bui
The API will be available at `http://localhost:5000`
### Redis (for TAT and Pause-Resume jobs)
The backend uses **Redis** for TAT (turnaround time) alerts and Pause-Resume workflow jobs. The app runs without Redis, but those features need Redis on `localhost:6379`.
**Option 1 Docker (easiest if you have Docker)**
```bash
# Start Redis in the background (port 6379)
npm run redis:start
# Stop when done
npm run redis:stop
```
**Option 2 Windows (Memurai or WSL)**
- **Memurai** (Redis-compatible, native Windows): Download from [memurai.com](https://www.memurai.com/) and install. Default port 6379. You can install as a Windows service.
- **WSL2**: Install Ubuntu from Microsoft Store, then:
```bash
sudo apt update && sudo apt install redis-server -y
redis-server --daemonize yes
```
**Option 3 macOS / Linux**
```bash
# macOS (Homebrew)
brew install redis && brew services start redis
# Ubuntu/Debian
sudo apt install redis-server -y && sudo systemctl start redis-server
```
**Verify:** `redis-cli ping` should return `PONG`. Then restart the backend so it connects to Redis.
### Docker Setup
```bash

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,31 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-CULgQ-8S.js"></script>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- 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>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CX5oLBI_.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CxsBWvVP.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-XBJXaMj2.css">
</head>
<body>
<div id="root"></div>
</body>
<link rel="modulepreload" crossorigin href="/assets/router-vendor-BATWUvr6.js">
<link rel="stylesheet" crossorigin href="/assets/index-Bue1DC_k.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
form 16 build (6 march).zip Normal file

Binary file not shown.

View File

@ -14,7 +14,7 @@ module.exports = {
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
moduleNameMapping: {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@controllers/(.*)$': '<rootDir>/src/controllers/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
@ -23,5 +23,6 @@ module.exports = {
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@types/(.*)$': '<rootDir>/src/types/$1',
'^@config/(.*)$': '<rootDir>/src/config/$1',
'^@validators/(.*)$': '<rootDir>/src/validators/$1',
},
};

227
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@google-cloud/secret-manager": "^6.1.1",
"@google-cloud/storage": "^7.18.0",
"@google-cloud/vertexai": "^1.10.0",
"@google/generative-ai": "^0.24.1",
"@types/nodemailer": "^7.0.4",
"@types/uuid": "^8.3.4",
"axios": "^1.7.9",
@ -34,6 +35,7 @@
"openai": "^6.8.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"pg": "^8.13.1",
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",
@ -1698,6 +1700,15 @@
"node": ">=18.0.0"
}
},
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
@ -2430,6 +2441,190 @@
"win32"
]
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/snappy-android-arm-eabi": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm-eabi/-/snappy-android-arm-eabi-7.3.3.tgz",
@ -10178,6 +10373,38 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",

View File

@ -19,12 +19,23 @@
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
"seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts",
"seed:test-dealer": "ts-node -r tsconfig-paths/register src/scripts/seed-test-dealer.ts",
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
"seed:dealer-user": "ts-node -r tsconfig-paths/register src/scripts/seed-dealer-user.ts",
"seed:rohit-user": "ts-node -r tsconfig-paths/register src/scripts/seed-rohit-user.ts",
"seed:admin-user": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-user.ts",
"seed:demo-requests": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-requests.ts",
"seed:demo-dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-dealers.ts",
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts",
"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"
},
"dependencies": {
"@google-cloud/secret-manager": "^6.1.1",
"@google-cloud/storage": "^7.18.0",
"@google-cloud/vertexai": "^1.10.0",
"@google/generative-ai": "^0.24.1",
"@types/nodemailer": "^7.0.4",
"@types/uuid": "^8.3.4",
"axios": "^1.7.9",
@ -48,6 +59,7 @@
"openai": "^6.8.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"pg": "^8.13.1",
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",

View File

@ -0,0 +1,120 @@
/**
* API smoke tests for UAT / production readiness.
* Tests health, routing, and auth validation without requiring full DB/Redis in CI.
*
* Run: npm test -- smoke
*/
import request from 'supertest';
// Load app without starting server (server.ts is not imported)
// Suppress DB config console logs in test
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
let app: import('express').Application;
beforeAll(() => {
app = require('../../app').default;
});
afterAll(() => {
process.env.NODE_ENV = originalEnv;
});
describe('API Smoke Health & Routing', () => {
it('SMK-01: GET /health returns 200 and status OK', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('status', 'OK');
expect(res.body).toHaveProperty('timestamp');
expect(res.body).toHaveProperty('uptime');
});
it('SMK-02: GET /api/v1/health returns 200 and service name', async () => {
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('status', 'OK');
expect(res.body).toHaveProperty('service', 're-workflow-backend');
});
it('SMK-03: GET /api/v1/health/db returns 200 (connected) or 503 (disconnected)', async () => {
const res = await request(app).get('/api/v1/health/db');
expect([200, 503]).toContain(res.status);
if (res.status === 200) {
expect(res.body).toHaveProperty('database', 'connected');
} else {
expect(res.body).toHaveProperty('database', 'disconnected');
}
});
it('SMK-04: Invalid API route returns 404 with JSON', async () => {
const res = await request(app).get('/api/v1/invalid-route-xyz');
expect(res.status).toBe(404);
expect(res.body).toHaveProperty('success', false);
expect(res.body).toHaveProperty('message');
});
});
describe('API Smoke Authentication', () => {
it('AUTH-01: POST /api/v1/auth/sso-callback with empty body returns 400', async () => {
const res = await request(app)
.post('/api/v1/auth/sso-callback')
.send({})
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('success', false);
// Auth route validator returns "Request body validation failed"; legacy app route returns "email and oktaSub required"
expect(res.body.message).toMatch(/email|oktaSub|required|validation failed/i);
});
it('AUTH-02: POST /api/v1/auth/sso-callback without oktaSub returns 400', async () => {
const res = await request(app)
.post('/api/v1/auth/sso-callback')
.send({ email: 'test@example.com' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('success', false);
});
it('AUTH-03: POST /api/v1/auth/sso-callback without email returns 400', async () => {
const res = await request(app)
.post('/api/v1/auth/sso-callback')
.send({ oktaSub: 'okta-123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('success', false);
});
it('AUTH-04: GET /api/v1/users without token returns 401', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(401);
});
it('AUTH-05: GET /api/v1/users with invalid token returns 401', async () => {
const res = await request(app)
.get('/api/v1/users')
.set('Authorization', 'Bearer invalid-token');
expect(res.status).toBe(401);
});
});
describe('API Smoke Security Headers (SEC-01, SEC-02, SEC-03)', () => {
it('SEC-01: Response includes Content-Security-Policy header', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.headers).toHaveProperty('content-security-policy');
expect(res.headers['content-security-policy']).toBeTruthy();
});
it('SEC-02: Response includes X-Frame-Options (SAMEORIGIN or deny)', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.headers).toHaveProperty('x-frame-options');
expect(res.headers['x-frame-options'].toUpperCase()).toMatch(/SAMEORIGIN|DENY/);
});
it('SEC-03: GET /metrics without admin auth returns 401', async () => {
const res = await request(app).get('/metrics');
expect(res.status).toBe(401);
});
});

View File

@ -0,0 +1,67 @@
/**
* Form 16 reconciliation tests.
* - Ledger: one CREDIT/DEBIT row per credit/debit note; no deletion (see docs/form16/LEDGER.md).
* - 26AS: only Section 194Q, Booking F/O; quarter aggregation; snapshot + auto-debit when total changes.
* - Form 16 match: latest 26AS aggregate only; reject mismatch/duplicate.
*
* Run: npm test -- form16-reconciliation
* Or: npm test -- --testPathPattern=form16
*
* Optional: FORM16_TEST_DB=1 to run integration tests (requires DB).
*/
import {
getLatest26asAggregatedForQuarter,
getLatest26asSnapshot,
getQuarterStatus,
process26asUploadAggregation,
upload26asFile,
parse26asTxtFile,
} from '../services/form16.service';
describe('Form 16 reconciliation', () => {
describe('parse26asTxtFile', () => {
it('parses 26AS official format (^ delimiter) and extracts sectionCode 194Q and statusOltas F', () => {
const header = '1^Deductor Name^TAN12345G^^^^^Total^Tax^TDS';
const line = '^1^194Q^30-Sep-2024^F^24-Oct-2024^-^1000^100^100';
const buffer = Buffer.from([header, line].join('\n'), 'utf8');
const { rows, errors } = parse26asTxtFile(buffer);
expect(errors).toEqual([]);
expect(rows.length).toBeGreaterThanOrEqual(1);
expect(rows[0].sectionCode).toBe('194Q');
expect(rows[0].statusOltas).toBe('F');
expect(rows[0].taxDeducted).toBe(100);
});
it('returns empty rows for empty buffer', () => {
const { rows, errors } = parse26asTxtFile(Buffer.from('', 'utf8'));
expect(rows).toEqual([]);
expect(errors).toEqual([]);
});
it('filters by section and booking status in aggregation (194Q, F/O only) documented behavior', () => {
expect(parse26asTxtFile(Buffer.from('x', 'utf8'))).toBeDefined();
});
});
describe('aggregation and snapshot helpers', () => {
it('getLatest26asAggregatedForQuarter is a function', () => {
expect(typeof getLatest26asAggregatedForQuarter).toBe('function');
});
it('getLatest26asSnapshot is a function', () => {
expect(typeof getLatest26asSnapshot).toBe('function');
});
it('getQuarterStatus is a function', () => {
expect(typeof getQuarterStatus).toBe('function');
});
});
describe('upload and process', () => {
it('upload26asFile accepts buffer and optional uploadLogId', () => {
expect(typeof upload26asFile).toBe('function');
});
it('process26asUploadAggregation returns snapshotsCreated and debitsCreated', () => {
expect(typeof process26asUploadAggregation).toBe('function');
});
});
});

View File

@ -0,0 +1,92 @@
/**
* Workflow validator (Zod schema) tests for UAT.
* Covers create and update workflow validation WF-01 to WF-04.
*
* Run: npm test -- workflow-validator
*/
import {
createWorkflowSchema,
updateWorkflowSchema,
validateCreateWorkflow,
validateUpdateWorkflow,
} from '../validators/workflow.validator';
const validApprovalLevel = {
email: 'approver@example.com',
tatHours: 24,
};
describe('Workflow validator', () => {
describe('createWorkflowSchema (WF-01 to WF-04)', () => {
it('WF-01: rejects missing title', () => {
const data = {
templateType: 'CUSTOM',
description: 'Test description',
priority: 'STANDARD',
approvalLevels: [validApprovalLevel],
};
expect(() => createWorkflowSchema.parse(data)).toThrow();
});
it('WF-02: rejects invalid priority', () => {
const data = {
templateType: 'CUSTOM',
title: 'Test',
description: 'Desc',
priority: 'INVALID_PRIORITY',
approvalLevels: [validApprovalLevel],
};
expect(() => createWorkflowSchema.parse(data)).toThrow();
});
it('WF-03: rejects empty approval levels', () => {
const data = {
templateType: 'CUSTOM',
title: 'Test',
description: 'Desc',
priority: 'STANDARD',
approvalLevels: [],
};
expect(() => createWorkflowSchema.parse(data)).toThrow();
});
it('WF-04: accepts valid minimal create payload', () => {
const data = {
templateType: 'CUSTOM',
title: 'Valid Title',
description: 'Valid description',
priority: 'STANDARD',
approvalLevels: [validApprovalLevel],
};
const result = validateCreateWorkflow(data);
expect(result.title).toBe('Valid Title');
expect(result.priority).toBe('STANDARD');
expect(result.approvalLevels).toHaveLength(1);
});
it('accepts EXPRESS priority', () => {
const data = {
templateType: 'CUSTOM',
title: 'Express',
description: 'Desc',
priority: 'EXPRESS',
approvalLevels: [validApprovalLevel],
};
const result = createWorkflowSchema.parse(data);
expect(result.priority).toBe('EXPRESS');
});
});
describe('updateWorkflowSchema', () => {
it('accepts partial update with valid status', () => {
const data = { status: 'APPROVED' };
const result = updateWorkflowSchema.parse(data);
expect(result.status).toBe('APPROVED');
});
it('rejects invalid status', () => {
expect(() => updateWorkflowSchema.parse({ status: 'INVALID' })).toThrow();
});
});
});

View File

@ -11,6 +11,7 @@ import { authenticateToken } from './middlewares/auth.middleware';
import { requireAdmin } from './middlewares/authorization.middleware';
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
import routes from './routes/index';
import form16Routes from './routes/form16.routes';
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
@ -112,6 +113,11 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
app.set('trust proxy', 1);
}
// Form 16 extract MUST be mounted BEFORE body parsers so multipart stream is not consumed
// (REform16 pattern: extract uses multer disk storage; mounting first guarantees raw stream for multer)
ensureUploadDir();
app.use('/api/v1/form16', form16Routes);
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
@ -141,7 +147,7 @@ app.get('/health', (_req: express.Request, res: express.Response) => {
});
});
// Mount API routes - MUST be before static file serving
// Mount API routes (form16 already mounted above before body parser)
app.use('/api/v1', routes);
// Serve uploaded files statically

View File

@ -512,6 +512,203 @@ export const resetConfiguration = async (req: Request, res: Response): Promise<v
}
};
/** Form 16 admin config stored in same admin_configurations table as other workflow/admin configs (config_key = FORM16_ADMIN_CONFIG, config_value = JSON). */
const FORM16_CONFIG_KEY = 'FORM16_ADMIN_CONFIG';
/** Normalize run-at time to HH:mm (e.g. "9:0" -> "09:00"). */
function normalizeRunAtTime(s: string): string {
const [h, m] = s.split(':').map((x) => parseInt(x, 10));
if (Number.isNaN(h) || Number.isNaN(m)) return '09:00';
const hh = Math.max(0, Math.min(23, h));
const mm = Math.max(0, Math.min(59, m));
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
const defaultNotificationItem = (template: string) => ({ enabled: true, template });
/** 26AS data added: separate message for RE users and for dealers */
const default26AsNotification = () => ({
enabled: true,
templateRe: '26AS data has been added. Please review and use for matching dealer Form 16 submissions.',
templateDealers: 'New 26AS data has been uploaded. You can now submit your Form 16 for the relevant quarter if you havent 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)

View File

@ -0,0 +1,743 @@
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();

View File

@ -14,11 +14,26 @@ 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 {
@ -449,6 +464,20 @@ export class WorkflowController {
return;
}
const templateType = (workflow as any).templateType || '';
if (templateType.toString().toUpperCase() === 'FORM_16') {
if (!req.user?.email || !req.user?.userId) {
ResponseHandler.forbidden(res, 'Authentication required to view Form 16 request');
return;
}
const role = (req.user as any).role;
const allowed = await canViewForm16Submission(req.user.email, req.user.userId, role);
if (!allowed) {
ResponseHandler.forbidden(res, 'You do not have permission to view this Form 16 request');
return;
}
}
ResponseHandler.success(res, workflow, 'Workflow retrieved successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -478,6 +507,15 @@ export class WorkflowController {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const templateType = (result as any).templateType || (result as any).workflow?.templateType || '';
if (templateType.toString().toUpperCase() === 'FORM_16') {
const role = (req.user as any).role;
const allowed = await canViewForm16Submission(req.user!.email, req.user!.userId, role);
if (!allowed) {
ResponseHandler.forbidden(res, 'You do not have permission to view this Form 16 request');
return;
}
}
ResponseHandler.success(res, result, 'Workflow details fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -490,12 +528,14 @@ export class WorkflowController {
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
// Extract filter parameters
// Extract filter parameters (financialYear, quarter for Form 16)
const filters = {
search: req.query.search as string | undefined,
status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined,
templateType: req.query.templateType as string | undefined,
financialYear: req.query.financialYear as string | undefined,
quarter: req.query.quarter as string | undefined,
department: req.query.department as string | undefined,
initiator: req.query.initiator as string | undefined,
approver: req.query.approver as string | undefined,
@ -506,7 +546,15 @@ export class WorkflowController {
endDate: req.query.endDate as string | undefined,
};
const result = await workflowService.listWorkflows(page, limit, filters);
let result = await workflowService.listWorkflows(page, limit, filters);
if (req.user?.email && req.user?.userId) {
const role = (req.user as any).role;
const allowed = await canViewForm16Submission(req.user.email, req.user.userId, role);
if (!allowed && result?.data?.length) {
const filtered = result.data.filter((w: any) => ((w.templateType || '').toString().toUpperCase() !== 'FORM_16'));
result = { ...result, data: filtered };
}
}
ResponseHandler.success(res, result, 'Workflows fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -535,7 +583,8 @@ export class WorkflowController {
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const result = await workflowService.listMyRequests(userId, page, limit, filters);
let result = await workflowService.listMyRequests(userId, page, limit, filters);
result = await filterForm16FromListIfNeeded(req, result);
ResponseHandler.success(res, result, 'My requests fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -566,10 +615,13 @@ export class WorkflowController {
const dateRange = req.query.dateRange as string | undefined;
const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined;
const financialYear = req.query.financialYear as string | undefined;
const quarter = req.query.quarter as string | undefined;
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const filters = { search, status, priority, templateType, financialYear, quarter, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
let result = await workflowService.listParticipantRequests(userId, page, limit, filters);
result = await filterForm16FromListIfNeeded(req, result);
ResponseHandler.success(res, result, 'Participant requests fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -599,7 +651,8 @@ export class WorkflowController {
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate };
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
let result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
result = await filterForm16FromListIfNeeded(req, result);
ResponseHandler.success(res, result, 'My initiated requests fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -613,19 +666,22 @@ export class WorkflowController {
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
// Extract filter parameters
// Extract filter parameters (Form 16: financialYear, quarter when templateType is FORM_16)
const filters = {
search: req.query.search as string | undefined,
status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined,
templateType: req.query.templateType as string | undefined
templateType: req.query.templateType as string | undefined,
financialYear: req.query.financialYear as string | undefined,
quarter: req.query.quarter as string | undefined,
};
// Extract sorting parameters
const sortBy = req.query.sortBy as string | undefined;
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
let result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder);
result = await filterForm16FromListIfNeeded(req, result);
ResponseHandler.success(res, result, 'Open requests for user fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -639,19 +695,22 @@ export class WorkflowController {
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
// Extract filter parameters
// Extract filter parameters (Form 16: financialYear, quarter when templateType is FORM_16)
const filters = {
search: req.query.search as string | undefined,
status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined,
templateType: req.query.templateType as string | undefined
templateType: req.query.templateType as string | undefined,
financialYear: req.query.financialYear as string | undefined,
quarter: req.query.quarter as string | undefined,
};
// Extract sorting parameters
const sortBy = req.query.sortBy as string | undefined;
const sortOrder = (req.query.sortOrder as string | undefined) || 'desc';
const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
let result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder);
result = await filterForm16FromListIfNeeded(req, result);
ResponseHandler.success(res, result, 'Closed requests by user fetched');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View File

@ -882,6 +882,11 @@ export function getTemplateTypeLabel(templateType?: string): string {
if (upper === 'DEALER CLAIM' || upper === 'DEALER_CLAIM' || upper === 'CLAIM-MANAGEMENT' || upper === 'CLAIM_MANAGEMENT') {
return 'Dealer Claim';
}
// Form 16 (Form 16A TDS)
if (upper === 'FORM_16' || upper === 'FORM16') {
return 'Form 16';
}
// Handle template type
if (upper === 'TEMPLATE') {

View File

@ -0,0 +1,71 @@
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})`);
}

View File

@ -31,13 +31,18 @@ const getAllowedOrigins = (): string[] | boolean => {
}
// Parse comma-separated URLs or use single URL
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
let origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
if (origins.length === 0) {
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
}
// In development always allow localhost:3000 (Vite default) so frontend works even if FRONTEND_URL is 3001
if (!isProduction && !origins.includes('http://localhost:3000')) {
origins = ['http://localhost:3000', ...origins];
}
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
return origins;
};

View File

@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger';
export const errorHandlerMiddleware = (
error: Error,
error: Error & { code?: string },
req: Request,
res: Response,
next: NextFunction
@ -15,6 +15,24 @@ export const errorHandlerMiddleware = (
ip: req.ip,
});
// Multer errors (e.g. LIMIT_FILE_SIZE, file filter) → 400
if (error.code === 'LIMIT_FILE_SIZE') {
res.status(400).json({
success: false,
message: 'File too large. Maximum size is 15MB.',
timestamp: new Date(),
});
return;
}
if (error.message === 'Only PDF files are allowed' || (error as any).code === 'LIMIT_UNEXPECTED_FILE') {
res.status(400).json({
success: false,
message: error.message || 'Invalid file type.',
timestamp: new Date(),
});
return;
}
res.status(500).json({
success: false,
message: 'Internal Server Error',

View File

@ -0,0 +1,95 @@
/**
* Form 16 permission middleware enforces API-driven config (submission viewers, 26AS viewers).
* Use after authenticateToken so req.user is set.
*/
import { Request, Response, NextFunction } from 'express';
import { ResponseHandler } from '../utils/responseHandler';
import { canViewForm16Submission, canView26As } from '../services/form16Permission.service';
import { getDealerCodeForUser } from '../services/form16.service';
/**
* Require RE user only (block dealers). Use for endpoints that are RE-only (e.g. withdraw credit note, non-submitted dealers).
* Call after authenticateToken; use before or with requireForm16SubmissionAccess as needed.
*/
export const requireForm16ReOnly = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const user = req.user;
if (!user?.userId || !user?.email) {
ResponseHandler.unauthorized(res, 'Authentication required');
return;
}
const dealerCode = await getDealerCodeForUser(user.userId);
if (dealerCode) {
ResponseHandler.forbidden(res, 'This action is only available to RE users, not dealers');
return;
}
next();
} catch (error) {
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
}
};
/**
* Require Form 16 submission data access.
* Admin has full access. Dealers are always allowed. RE users must be in submissionViewerEmails (or list empty).
*/
export const requireForm16SubmissionAccess = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const user = req.user;
if (!user?.userId || !user?.email) {
ResponseHandler.unauthorized(res, 'Authentication required');
return;
}
if ((user as any).role === 'ADMIN') {
next();
return;
}
const allowed = await canViewForm16Submission(user.email, user.userId, (user as any).role);
if (!allowed) {
ResponseHandler.forbidden(res, 'You do not have permission to view Form 16 submission data');
return;
}
next();
} catch (error) {
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
}
};
/**
* Require 26AS access (view/upload/manage 26AS data).
* Admin has full access. Otherwise user must be in twentySixAsViewerEmails (or list empty).
*/
export const requireForm1626AsAccess = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const user = req.user;
if (!user?.userId || !user?.email) {
ResponseHandler.unauthorized(res, 'Authentication required');
return;
}
if ((user as any).role === 'ADMIN') {
next();
return;
}
const allowed = await canView26As(user.email, (user as any).role);
if (!allowed) {
ResponseHandler.forbidden(res, 'You do not have permission to access 26AS data');
return;
}
next();
} catch (error) {
ResponseHandler.error(res, 'Permission check failed', 500, error instanceof Error ? error.message : 'Unknown error');
}
};

View File

@ -390,6 +390,10 @@ export function recordAIServiceCall(provider: string, operation: string, success
// QUEUE METRICS COLLECTION
// ============================================================================
// Throttle queue-metrics error logs when Redis is down (avoid flooding terminal)
const queueMetricsLastErrorLog = new Map<string, number>();
const QUEUE_METRICS_ERROR_LOG_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Update queue metrics for a specific queue
* Call this periodically or on queue events
@ -410,8 +414,13 @@ export async function updateQueueMetrics(queueName: string, queue: any): Promise
queueJobsFailed.set({ queue_name: queueName }, failed);
queueJobsDelayed.set({ queue_name: queueName }, delayed);
} catch (error) {
// Silently fail to avoid breaking metrics collection
console.error(`[Metrics] Failed to update queue metrics for ${queueName}:`, error);
// Log at most once per queue per 5 min when Redis is down so terminal stays readable
const now = Date.now();
const last = queueMetricsLastErrorLog.get(queueName) ?? 0;
if (now - last >= QUEUE_METRICS_ERROR_LOG_INTERVAL_MS) {
queueMetricsLastErrorLog.set(queueName, now);
console.warn(`[Metrics] Queue metrics unavailable for ${queueName} (Redis may be down). Next log in 5 min.`);
}
}
}

View File

@ -0,0 +1,179 @@
/**
* Form 16 integration: form16a_submissions (linked to workflow_requests) and form_16_credit_notes.
* Single workflow DB; Form16 submissions appear in Open/Closed requests via workflow_requests row.
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 1. form16a_submissions: one row per Form 16A submission; request_id links to workflow_requests
await queryInterface.createTable('form16a_submissions', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
unique: true,
},
dealer_code: {
type: DataTypes.STRING(50),
allowNull: false,
},
form16a_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
tds_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
total_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
},
deductor_name: {
type: DataTypes.STRING(255),
allowNull: false,
},
document_url: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
validation_status: {
type: DataTypes.STRING(20),
allowNull: true,
},
validation_notes: {
type: DataTypes.TEXT,
allowNull: true,
},
submitted_date: {
type: DataTypes.DATE,
allowNull: true,
},
processed_date: {
type: DataTypes.DATE,
allowNull: true,
},
processed_by: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'user_id' },
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form16a_submissions', ['request_id'], { name: 'idx_form16a_submissions_request_id' });
await queryInterface.addIndex('form16a_submissions', ['dealer_code'], { name: 'idx_form16a_submissions_dealer_code' });
await queryInterface.addIndex('form16a_submissions', ['status'], { name: 'idx_form16a_submissions_status' });
await queryInterface.addIndex('form16a_submissions', ['financial_year', 'quarter'], { name: 'idx_form16a_submissions_fy_quarter' });
// 2. form_16_credit_notes: credit notes generated (e.g. via SAP); linked to form16a_submissions
await queryInterface.createTable('form_16_credit_notes', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
submission_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'form16a_submissions', key: 'id' },
onDelete: 'CASCADE',
},
credit_note_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
},
sap_document_number: {
type: DataTypes.STRING(50),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
issue_date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
remarks: {
type: DataTypes.TEXT,
allowNull: true,
},
issued_by: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'user_id' },
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_credit_notes', ['submission_id'], { name: 'idx_form_16_credit_notes_submission_id' });
await queryInterface.addIndex('form_16_credit_notes', ['status'], { name: 'idx_form_16_credit_notes_status' });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('form_16_credit_notes');
await queryInterface.dropTable('form16a_submissions');
}

View File

@ -0,0 +1,228 @@
/**
* Add ocr_extracted_data (JSONB) to form16a_submissions for audit/support.
* Stores the raw OCR response when a submission is created from an extracted PDF.
*
* If form16a_submissions does not exist (e.g. 20260220-create-form16-tables was marked
* run but used the wrong module on a previous deploy), this migration creates the table
* and form_16_credit_notes first, then adds the column. Safe for UAT/fresh DBs.
*/
import { QueryInterface, DataTypes } from 'sequelize';
async function tableExists(queryInterface: QueryInterface, tableName: string): Promise<boolean> {
const [rows] = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = :tableName
) AS "exists"`,
{ replacements: { tableName } }
);
const val = (rows as { exists: boolean | string }[])?.[0]?.exists;
return val === true || val === 't';
}
async function columnExists(queryInterface: QueryInterface, tableName: string, columnName: string): Promise<boolean> {
const [rows] = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = :tableName AND column_name = :columnName
) AS "exists"`,
{ replacements: { tableName, columnName } }
);
const val = (rows as { exists: boolean | string }[])?.[0]?.exists;
return val === true || val === 't';
}
/** Create form16a_submissions and form_16_credit_notes if missing (same as 20260220-create-form16-tables). */
async function ensureForm16Tables(queryInterface: QueryInterface): Promise<void> {
const exists = await tableExists(queryInterface, 'form16a_submissions');
if (exists) return;
await queryInterface.createTable('form16a_submissions', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'workflow_requests', key: 'request_id' },
onDelete: 'CASCADE',
unique: true,
},
dealer_code: {
type: DataTypes.STRING(50),
allowNull: false,
},
form16a_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
tds_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
total_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
},
deductor_name: {
type: DataTypes.STRING(255),
allowNull: false,
},
document_url: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
validation_status: {
type: DataTypes.STRING(20),
allowNull: true,
},
validation_notes: {
type: DataTypes.TEXT,
allowNull: true,
},
submitted_date: {
type: DataTypes.DATE,
allowNull: true,
},
processed_date: {
type: DataTypes.DATE,
allowNull: true,
},
processed_by: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'user_id' },
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form16a_submissions', ['request_id'], { name: 'idx_form16a_submissions_request_id' });
await queryInterface.addIndex('form16a_submissions', ['dealer_code'], { name: 'idx_form16a_submissions_dealer_code' });
await queryInterface.addIndex('form16a_submissions', ['status'], { name: 'idx_form16a_submissions_status' });
await queryInterface.addIndex('form16a_submissions', ['financial_year', 'quarter'], { name: 'idx_form16a_submissions_fy_quarter' });
if (!(await tableExists(queryInterface, 'form_16_credit_notes'))) {
await queryInterface.createTable('form_16_credit_notes', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
submission_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'form16a_submissions', key: 'id' },
onDelete: 'CASCADE',
},
credit_note_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
},
sap_document_number: {
type: DataTypes.STRING(50),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
issue_date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
remarks: {
type: DataTypes.TEXT,
allowNull: true,
},
issued_by: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'user_id' },
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_credit_notes', ['submission_id'], { name: 'idx_form_16_credit_notes_submission_id' });
await queryInterface.addIndex('form_16_credit_notes', ['status'], { name: 'idx_form_16_credit_notes_status' });
}
}
export async function up(queryInterface: QueryInterface): Promise<void> {
await ensureForm16Tables(queryInterface);
const hasColumn = await columnExists(queryInterface, 'form16a_submissions', 'ocr_extracted_data');
if (!hasColumn) {
await queryInterface.addColumn(
'form16a_submissions',
'ocr_extracted_data',
{
type: DataTypes.JSONB,
allowNull: true,
}
);
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
const hasColumn = await columnExists(queryInterface, 'form16a_submissions', 'ocr_extracted_data');
if (hasColumn) {
await queryInterface.removeColumn('form16a_submissions', 'ocr_extracted_data');
}
}

View File

@ -0,0 +1,97 @@
/**
* Form 16 26AS TDS entries table for RE admin.
* Stores TDS credit data from 26AS (Income Tax portal) for validation against Form 16A submissions.
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('tds_26as_entries', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
comment: 'TAN of deductor',
},
deductor_name: {
type: DataTypes.STRING(255),
allowNull: true,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
comment: 'Q1, Q2, Q3, Q4',
},
assessment_year: {
type: DataTypes.STRING(20),
allowNull: true,
comment: 'e.g. 2024-25',
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
comment: 'e.g. 2024-25',
},
section_code: {
type: DataTypes.STRING(20),
allowNull: true,
comment: 'e.g. 194C, 194A',
},
amount_paid: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
tax_deducted: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
defaultValue: 0,
},
total_tds_deposited: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
nature_of_payment: {
type: DataTypes.STRING(255),
allowNull: true,
},
transaction_date: {
type: DataTypes.DATEONLY,
allowNull: true,
},
date_of_booking: {
type: DataTypes.DATEONLY,
allowNull: true,
},
status_oltas: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Status of matching with OLTAS',
},
remarks: {
type: DataTypes.TEXT,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('tds_26as_entries', ['tan_number'], { name: 'idx_tds_26as_tan' });
await queryInterface.addIndex('tds_26as_entries', ['financial_year', 'quarter'], { name: 'idx_tds_26as_fy_quarter' });
await queryInterface.addIndex('tds_26as_entries', ['financial_year'], { name: 'idx_tds_26as_fy' });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('tds_26as_entries');
}

View File

@ -0,0 +1,75 @@
/**
* Form 16 Debit Notes: issued when RE withdraws a credit note (e.g. duplicate/wrong).
* One debit note per withdrawn credit note. SAP document number filled when SAP API is integrated.
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('form_16_debit_notes', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
credit_note_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'CASCADE',
unique: true,
},
debit_note_number: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
},
sap_document_number: {
type: DataTypes.STRING(50),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
issue_date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
reason: {
type: DataTypes.TEXT,
allowNull: true,
},
created_by: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'user_id' },
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_debit_notes', ['credit_note_id'], {
name: 'idx_form_16_debit_notes_credit_note_id',
});
await queryInterface.addIndex('form_16_debit_notes', ['status'], {
name: 'idx_form_16_debit_notes_status',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('form_16_debit_notes');
}

View File

@ -0,0 +1,51 @@
/**
* Form 16 26AS upload audit log.
* Records each 26AS file upload: who uploaded, when, and how many records were imported.
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('form_16_26as_upload_log', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
uploaded_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
uploaded_by: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'users', key: 'user_id' },
},
file_name: {
type: DataTypes.STRING(255),
allowNull: true,
},
records_imported: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
errors_count: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
});
await queryInterface.addIndex('form_16_26as_upload_log', ['uploaded_at'], {
name: 'idx_form_16_26as_upload_log_uploaded_at',
});
await queryInterface.addIndex('form_16_26as_upload_log', ['uploaded_by'], {
name: 'idx_form_16_26as_upload_log_uploaded_by',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('form_16_26as_upload_log');
}

View File

@ -0,0 +1,180 @@
/**
* Form 16 critical changes:
* 1. Add upload_log_id to tds_26as_entries to link each record to an upload.
* 2. form_16_26as_quarter_snapshots: stores aggregated 26AS total per (tan, fy, quarter) per upload version.
* 3. form_16_quarter_status: current status per (tan, fy, quarter) - SETTLED | DEBIT_ISSUED_PENDING_FORM16.
* 4. form_16_ledger_entries: full audit trail of every credit/debit (no deletion).
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 1. Add upload_log_id to tds_26as_entries (nullable for existing rows)
await queryInterface.addColumn('tds_26as_entries', 'upload_log_id', {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_26as_upload_log', key: 'id' },
onDelete: 'SET NULL',
});
await queryInterface.addIndex('tds_26as_entries', ['upload_log_id'], { name: 'idx_tds_26as_upload_log_id' });
// 2. form_16_26as_quarter_snapshots: one row per "version" of 26AS aggregated total for (tan, fy, quarter)
await queryInterface.createTable('form_16_26as_quarter_snapshots', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
aggregated_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
comment: 'Sum of tax_deducted for this tan+fy+quarter (Section 194Q, Booking F/O only)',
},
upload_log_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_26as_upload_log', key: 'id' },
onDelete: 'SET NULL',
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_26as_quarter_snapshots', ['tan_number', 'financial_year', 'quarter'], {
name: 'idx_form16_26as_snap_tan_fy_qtr',
});
await queryInterface.addIndex('form_16_26as_quarter_snapshots', ['upload_log_id'], {
name: 'idx_form16_26as_snap_upload_log_id',
});
// 3. form_16_quarter_status: current status per (tan, fy, quarter) for reverse-first logic
await queryInterface.createTable('form_16_quarter_status', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
status: {
type: DataTypes.STRING(50),
allowNull: false,
comment: 'SETTLED | DEBIT_ISSUED_PENDING_FORM16',
},
last_credit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'SET NULL',
},
last_debit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_quarter_status', ['tan_number', 'financial_year', 'quarter'], {
name: 'idx_form16_quarter_status_tan_fy_qtr',
unique: true,
});
// 4. form_16_ledger_entries: full history of every credit and debit (no deletion)
await queryInterface.createTable('form_16_ledger_entries', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
tan_number: {
type: DataTypes.STRING(20),
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
entry_type: {
type: DataTypes.STRING(10),
allowNull: false,
comment: 'CREDIT | DEBIT',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
credit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'SET NULL',
},
debit_note_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
form16_submission_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form16a_submissions', key: 'id' },
onDelete: 'SET NULL',
},
snapshot_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: { model: 'form_16_26as_quarter_snapshots', key: 'id' },
onDelete: 'SET NULL',
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
await queryInterface.addIndex('form_16_ledger_entries', ['tan_number', 'financial_year', 'quarter'], {
name: 'idx_form16_ledger_tan_fy_qtr',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('form_16_ledger_entries');
await queryInterface.dropTable('form_16_quarter_status');
await queryInterface.dropTable('form_16_26as_quarter_snapshots');
await queryInterface.removeIndex('tds_26as_entries', 'idx_tds_26as_upload_log_id');
await queryInterface.removeColumn('tds_26as_entries', 'upload_log_id');
}

View File

@ -0,0 +1,45 @@
/**
* Form 16 non-submitted dealer notification log.
* Records each time an RE user sends a "submit Form 16" notification to a non-submitted dealer (per FY).
*/
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('form16_non_submitted_notifications', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
dealer_code: {
type: DataTypes.STRING(50),
allowNull: false,
},
financial_year: {
type: DataTypes.STRING(20),
allowNull: false,
},
notified_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
notified_by: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'users', key: 'user_id' },
},
});
await queryInterface.addIndex('form16_non_submitted_notifications', ['dealer_code', 'financial_year'], {
name: 'idx_form16_ns_notif_dealer_fy',
});
await queryInterface.addIndex('form16_non_submitted_notifications', ['notified_at'], {
name: 'idx_form16_ns_notif_notified_at',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('form16_non_submitted_notifications');
}

View File

@ -0,0 +1,36 @@
/**
* Form 16 / 26AS data retention: add archived_at to keep last 5 FY active.
* Records with financial_year older than 5 years get archived_at set by scheduler (no deletion).
*/
import { QueryInterface, DataTypes } from 'sequelize';
const TABLES_WITH_FY = [
'tds_26as_entries',
'form_16_26as_quarter_snapshots',
'form_16_quarter_status',
'form_16_ledger_entries',
'form_16_credit_notes',
'form16a_submissions',
] as const;
export async function up(queryInterface: QueryInterface): Promise<void> {
for (const table of TABLES_WITH_FY) {
await queryInterface.addColumn(table, 'archived_at', {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when record is older than 5 financial years; active when NULL',
});
}
await queryInterface.addColumn('form_16_debit_notes', 'archived_at', {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when linked credit_note is archived; active when NULL',
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
for (const table of [...TABLES_WITH_FY, 'form_16_debit_notes']) {
await queryInterface.removeColumn(table, 'archived_at');
}
}

View File

@ -0,0 +1,33 @@
/**
* Allow multiple Form 16A submissions with the same certificate number.
* Duplicate submission is only when a credit note already exists for that dealer/quarter/amount
* (enforced in run26asMatchAndCreditNote). Same certificate number without an existing
* credit note (e.g. resubmission after 26AS upload) is allowed.
*/
import { QueryInterface } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
const sequelize = queryInterface.sequelize;
// Drop unique constraint (PostgreSQL names it tablename_columnname_key when created via CREATE TABLE)
await sequelize.query(
`ALTER TABLE form16a_submissions DROP CONSTRAINT IF EXISTS form16a_submissions_form16a_number_key;`
);
// If a unique index exists on form16a_number (e.g. from Sequelize), drop it
const [indexRows] = (await sequelize.query(
`SELECT indexname FROM pg_indexes WHERE tablename = 'form16a_submissions' AND indexdef LIKE '%form16a_number%' AND indexdef LIKE '%UNIQUE%';`
)) as [{ indexname: string }[], unknown];
for (const row of indexRows || []) {
if (row?.indexname) {
await sequelize.query(`DROP INDEX IF EXISTS "${row.indexname}";`);
}
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addConstraint('form16a_submissions', {
fields: ['form16a_number'],
type: 'unique',
name: 'form16a_submissions_form16a_number_key',
});
}

View File

@ -0,0 +1,86 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { Form1626asUploadLog } from './Form1626asUploadLog';
export interface Form1626asQuarterSnapshotAttributes {
id: number;
tanNumber: string;
financialYear: string;
quarter: string;
aggregatedAmount: number;
uploadLogId?: number | null;
createdAt: Date;
}
interface Form1626asQuarterSnapshotCreationAttributes
extends Optional<Form1626asQuarterSnapshotAttributes, 'id' | 'uploadLogId' | 'createdAt'> {}
class Form1626asQuarterSnapshot
extends Model<Form1626asQuarterSnapshotAttributes, Form1626asQuarterSnapshotCreationAttributes>
implements Form1626asQuarterSnapshotAttributes
{
public id!: number;
public tanNumber!: string;
public financialYear!: string;
public quarter!: string;
public aggregatedAmount!: number;
public uploadLogId?: number | null;
public createdAt!: Date;
public uploadLog?: Form1626asUploadLog;
}
Form1626asQuarterSnapshot.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
tanNumber: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'tan_number',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
aggregatedAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'aggregated_amount',
},
uploadLogId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'upload_log_id',
references: { model: 'form_16_26as_upload_log', key: 'id' },
onDelete: 'SET NULL',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'created_at',
},
},
{
sequelize,
tableName: 'form_16_26as_quarter_snapshots',
timestamps: false,
underscored: true,
}
);
Form1626asQuarterSnapshot.belongsTo(Form1626asUploadLog, {
as: 'uploadLog',
foreignKey: 'uploadLogId',
targetKey: 'id',
});
export { Form1626asQuarterSnapshot };

View File

@ -0,0 +1,82 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { User } from './User';
export interface Form1626asUploadLogAttributes {
id: number;
uploadedAt: Date;
uploadedBy: string;
fileName?: string | null;
recordsImported: number;
errorsCount: number;
}
interface Form1626asUploadLogCreationAttributes
extends Optional<Form1626asUploadLogAttributes, 'id' | 'fileName'> {}
class Form1626asUploadLog
extends Model<Form1626asUploadLogAttributes, Form1626asUploadLogCreationAttributes>
implements Form1626asUploadLogAttributes
{
public id!: number;
public uploadedAt!: Date;
public uploadedBy!: string;
public fileName?: string | null;
public recordsImported!: number;
public errorsCount!: number;
public uploadedByUser?: User;
}
Form1626asUploadLog.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
uploadedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'uploaded_at',
},
uploadedBy: {
type: DataTypes.UUID,
allowNull: false,
field: 'uploaded_by',
references: { model: 'users', key: 'user_id' },
},
fileName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'file_name',
},
recordsImported: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'records_imported',
},
errorsCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'errors_count',
},
},
{
sequelize,
tableName: 'form_16_26as_upload_log',
timestamps: false,
underscored: true,
}
);
Form1626asUploadLog.belongsTo(User, {
as: 'uploadedByUser',
foreignKey: 'uploadedBy',
targetKey: 'userId',
});
export { Form1626asUploadLog };

View File

@ -0,0 +1,140 @@
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 };

View File

@ -0,0 +1,129 @@
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 };

View File

@ -0,0 +1,137 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { Form16CreditNote } from './Form16CreditNote';
import { Form16DebitNote } from './Form16DebitNote';
import { Form16aSubmission } from './Form16aSubmission';
import { Form1626asQuarterSnapshot } from './Form1626asQuarterSnapshot';
export type Form16LedgerEntryType = 'CREDIT' | 'DEBIT';
export interface Form16LedgerEntryAttributes {
id: number;
tanNumber: string;
financialYear: string;
quarter: string;
entryType: Form16LedgerEntryType;
amount: number;
creditNoteId?: number | null;
debitNoteId?: number | null;
form16SubmissionId?: number | null;
snapshotId?: number | null;
createdAt: Date;
}
interface Form16LedgerEntryCreationAttributes
extends Optional<
Form16LedgerEntryAttributes,
'id' | 'creditNoteId' | 'debitNoteId' | 'form16SubmissionId' | 'snapshotId' | 'createdAt'
> {}
class Form16LedgerEntry
extends Model<Form16LedgerEntryAttributes, Form16LedgerEntryCreationAttributes>
implements Form16LedgerEntryAttributes
{
public id!: number;
public tanNumber!: string;
public financialYear!: string;
public quarter!: string;
public entryType!: Form16LedgerEntryType;
public amount!: number;
public creditNoteId?: number | null;
public debitNoteId?: number | null;
public form16SubmissionId?: number | null;
public snapshotId?: number | null;
public createdAt!: Date;
public creditNote?: Form16CreditNote;
public debitNote?: Form16DebitNote;
public form16Submission?: Form16aSubmission;
public snapshot?: Form1626asQuarterSnapshot;
}
Form16LedgerEntry.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
tanNumber: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'tan_number',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
entryType: {
type: DataTypes.STRING(10),
allowNull: false,
field: 'entry_type',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
creditNoteId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'credit_note_id',
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'SET NULL',
},
debitNoteId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'debit_note_id',
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
form16SubmissionId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'form16_submission_id',
references: { model: 'form16a_submissions', key: 'id' },
onDelete: 'SET NULL',
},
snapshotId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'snapshot_id',
references: { model: 'form_16_26as_quarter_snapshots', key: 'id' },
onDelete: 'SET NULL',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'created_at',
},
},
{
sequelize,
tableName: 'form_16_ledger_entries',
timestamps: false,
underscored: true,
}
);
Form16LedgerEntry.belongsTo(Form16CreditNote, { as: 'creditNote', foreignKey: 'creditNoteId', targetKey: 'id' });
Form16LedgerEntry.belongsTo(Form16DebitNote, { as: 'debitNote', foreignKey: 'debitNoteId', targetKey: 'id' });
Form16LedgerEntry.belongsTo(Form16aSubmission, {
as: 'form16Submission',
foreignKey: 'form16SubmissionId',
targetKey: 'id',
});
Form16LedgerEntry.belongsTo(Form1626asQuarterSnapshot, {
as: 'snapshot',
foreignKey: 'snapshotId',
targetKey: 'id',
});
export { Form16LedgerEntry };

View File

@ -0,0 +1,72 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { User } from './User';
export interface Form16NonSubmittedNotificationAttributes {
id: number;
dealerCode: string;
financialYear: string;
notifiedAt: Date;
notifiedBy: string;
}
interface Form16NonSubmittedNotificationCreationAttributes
extends Optional<Form16NonSubmittedNotificationAttributes, 'id' | 'notifiedAt'> {}
class Form16NonSubmittedNotification
extends Model<Form16NonSubmittedNotificationAttributes, Form16NonSubmittedNotificationCreationAttributes>
implements Form16NonSubmittedNotificationAttributes
{
public id!: number;
public dealerCode!: string;
public financialYear!: string;
public notifiedAt!: Date;
public notifiedBy!: string;
public notifiedByUser?: User;
}
Form16NonSubmittedNotification.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
dealerCode: {
type: DataTypes.STRING(50),
allowNull: false,
field: 'dealer_code',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
notifiedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'notified_at',
},
notifiedBy: {
type: DataTypes.UUID,
allowNull: false,
field: 'notified_by',
references: { model: 'users', key: 'user_id' },
},
},
{
sequelize,
tableName: 'form16_non_submitted_notifications',
timestamps: false,
}
);
Form16NonSubmittedNotification.belongsTo(User, {
as: 'notifiedByUser',
foreignKey: 'notifiedBy',
targetKey: 'userId',
});
export { Form16NonSubmittedNotification };

View File

@ -0,0 +1,108 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { Form16CreditNote } from './Form16CreditNote';
import { Form16DebitNote } from './Form16DebitNote';
export type Form16QuarterStatusValue = 'SETTLED' | 'DEBIT_ISSUED_PENDING_FORM16';
export interface Form16QuarterStatusAttributes {
id: number;
tanNumber: string;
financialYear: string;
quarter: string;
status: Form16QuarterStatusValue;
lastCreditNoteId?: number | null;
lastDebitNoteId?: number | null;
updatedAt: Date;
}
interface Form16QuarterStatusCreationAttributes
extends Optional<
Form16QuarterStatusAttributes,
'id' | 'lastCreditNoteId' | 'lastDebitNoteId' | 'updatedAt'
> {}
class Form16QuarterStatus
extends Model<Form16QuarterStatusAttributes, Form16QuarterStatusCreationAttributes>
implements Form16QuarterStatusAttributes
{
public id!: number;
public tanNumber!: string;
public financialYear!: string;
public quarter!: string;
public status!: Form16QuarterStatusValue;
public lastCreditNoteId?: number | null;
public lastDebitNoteId?: number | null;
public updatedAt!: Date;
public lastCreditNote?: Form16CreditNote;
public lastDebitNote?: Form16DebitNote;
}
Form16QuarterStatus.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
tanNumber: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'tan_number',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
status: {
type: DataTypes.STRING(50),
allowNull: false,
},
lastCreditNoteId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'last_credit_note_id',
references: { model: 'form_16_credit_notes', key: 'id' },
onDelete: 'SET NULL',
},
lastDebitNoteId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'last_debit_note_id',
references: { model: 'form_16_debit_notes', key: 'id' },
onDelete: 'SET NULL',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'updated_at',
},
},
{
sequelize,
tableName: 'form_16_quarter_status',
timestamps: true,
underscored: true,
updatedAt: 'updated_at',
createdAt: false,
}
);
Form16QuarterStatus.belongsTo(Form16CreditNote, {
as: 'lastCreditNote',
foreignKey: 'lastCreditNoteId',
targetKey: 'id',
});
Form16QuarterStatus.belongsTo(Form16DebitNote, {
as: 'lastDebitNote',
foreignKey: 'lastDebitNoteId',
targetKey: 'id',
});
export { Form16QuarterStatus };

View File

@ -0,0 +1,209 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { User } from './User';
export interface Form16aSubmissionAttributes {
id: number;
requestId: string;
dealerCode: string;
form16aNumber: string;
financialYear: string;
quarter: string;
version: number;
tdsAmount: number;
totalAmount: number;
tanNumber: string;
deductorName: string;
documentUrl?: string;
ocrExtractedData?: Record<string, unknown> | null;
status: string;
validationStatus?: string | null;
validationNotes?: string | null;
submittedDate?: Date;
processedDate?: Date;
processedBy?: string;
createdAt: Date;
updatedAt: Date;
}
interface Form16aSubmissionCreationAttributes
extends Optional<
Form16aSubmissionAttributes,
| 'id'
| 'documentUrl'
| 'ocrExtractedData'
| 'validationStatus'
| 'validationNotes'
| 'submittedDate'
| 'processedDate'
| 'processedBy'
| 'createdAt'
| 'updatedAt'
> {}
class Form16aSubmission
extends Model<Form16aSubmissionAttributes, Form16aSubmissionCreationAttributes>
implements Form16aSubmissionAttributes
{
public id!: number;
public requestId!: string;
public dealerCode!: string;
public form16aNumber!: string;
public financialYear!: string;
public quarter!: string;
public version!: number;
public tdsAmount!: number;
public totalAmount!: number;
public tanNumber!: string;
public deductorName!: string;
public documentUrl?: string;
public ocrExtractedData?: Record<string, unknown> | null;
public status!: string;
public validationStatus?: string;
public validationNotes?: string;
public submittedDate?: Date;
public processedDate?: Date;
public processedBy?: string;
public createdAt!: Date;
public updatedAt!: Date;
public workflowRequest?: WorkflowRequest;
public processedByUser?: User;
}
Form16aSubmission.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
field: 'request_id',
references: { model: 'workflow_requests', key: 'request_id' },
},
dealerCode: {
type: DataTypes.STRING(50),
allowNull: false,
field: 'dealer_code',
},
form16aNumber: {
type: DataTypes.STRING(50),
allowNull: false,
field: 'form16a_number',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
tdsAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'tds_amount',
},
totalAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'total_amount',
},
tanNumber: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'tan_number',
},
deductorName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'deductor_name',
},
documentUrl: {
type: DataTypes.TEXT,
allowNull: true,
field: 'document_url',
},
ocrExtractedData: {
type: DataTypes.JSONB,
allowNull: true,
field: 'ocr_extracted_data',
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'pending',
},
validationStatus: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'validation_status',
},
validationNotes: {
type: DataTypes.TEXT,
allowNull: true,
field: 'validation_notes',
},
submittedDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'submitted_date',
},
processedDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'processed_date',
},
processedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'processed_by',
references: { model: 'users', key: 'user_id' },
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
tableName: 'form16a_submissions',
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
Form16aSubmission.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
Form16aSubmission.belongsTo(User, {
as: 'processedByUser',
foreignKey: 'processedBy',
targetKey: 'userId',
});
export { Form16aSubmission };

174
src/models/Tds26asEntry.ts Normal file
View File

@ -0,0 +1,174 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
export interface Tds26asEntryAttributes {
id: number;
tanNumber: string;
deductorName?: string;
quarter: string;
assessmentYear?: string;
financialYear: string;
sectionCode?: string;
amountPaid?: number;
taxDeducted: number;
totalTdsDeposited?: number;
natureOfPayment?: string;
transactionDate?: string;
dateOfBooking?: string;
statusOltas?: string;
remarks?: string;
uploadLogId?: number | null;
createdAt: Date;
updatedAt: Date;
}
interface Tds26asEntryCreationAttributes
extends Optional<
Tds26asEntryAttributes,
| 'id'
| 'deductorName'
| 'assessmentYear'
| 'sectionCode'
| 'amountPaid'
| 'totalTdsDeposited'
| 'natureOfPayment'
| 'transactionDate'
| 'dateOfBooking'
| 'statusOltas'
| 'remarks'
| 'uploadLogId'
| 'createdAt'
| 'updatedAt'
> {}
class Tds26asEntry
extends Model<Tds26asEntryAttributes, Tds26asEntryCreationAttributes>
implements Tds26asEntryAttributes
{
public id!: number;
public tanNumber!: string;
public deductorName?: string;
public quarter!: string;
public assessmentYear?: string;
public financialYear!: string;
public sectionCode?: string;
public amountPaid?: number;
public taxDeducted!: number;
public totalTdsDeposited?: number;
public natureOfPayment?: string;
public transactionDate?: string;
public dateOfBooking?: string;
public statusOltas?: string;
public remarks?: string;
public uploadLogId?: number | null;
public createdAt!: Date;
public updatedAt!: Date;
}
Tds26asEntry.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
tanNumber: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'tan_number',
},
deductorName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'deductor_name',
},
quarter: {
type: DataTypes.STRING(10),
allowNull: false,
},
assessmentYear: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'assessment_year',
},
financialYear: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'financial_year',
},
sectionCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'section_code',
},
amountPaid: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'amount_paid',
},
taxDeducted: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
defaultValue: 0,
field: 'tax_deducted',
},
totalTdsDeposited: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_tds_deposited',
},
natureOfPayment: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'nature_of_payment',
},
transactionDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'transaction_date',
},
dateOfBooking: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'date_of_booking',
},
statusOltas: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'status_oltas',
},
remarks: {
type: DataTypes.TEXT,
allowNull: true,
},
uploadLogId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'upload_log_id',
references: { model: 'form_16_26as_upload_log', key: 'id' },
onDelete: 'SET NULL',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
tableName: 'tds_26as_entries',
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
export { Tds26asEntry };

View File

@ -7,7 +7,7 @@ interface WorkflowRequestAttributes {
requestId: string;
requestNumber: string;
initiatorId: string;
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc.
templateId?: string; // Reference to workflow_templates if using admin template
title: string;
@ -39,7 +39,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
public requestId!: string;
public requestNumber!: string;
public initiatorId!: string;
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
public templateType!: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
public workflowType?: string;
public templateId?: string;
public title!: string;

View File

@ -29,6 +29,15 @@ 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 = () => {
@ -148,6 +157,25 @@ const defineAssociations = () => {
sourceKey: 'requestId'
});
// Form 16 associations
WorkflowRequest.hasOne(Form16aSubmission, {
as: 'form16Submission',
foreignKey: 'requestId',
sourceKey: 'requestId'
});
Form16aSubmission.hasMany(Form16CreditNote, {
as: 'creditNotes',
foreignKey: 'submissionId',
sourceKey: 'id'
});
Form16CreditNote.hasOne(Form16DebitNote, {
as: 'debitNote',
foreignKey: 'creditNoteId',
sourceKey: 'id'
});
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
};
@ -185,7 +213,16 @@ export {
DealerClaimHistory,
ClaimInvoice,
ClaimInvoiceItem,
ClaimCreditNote
ClaimCreditNote,
Form16aSubmission,
Form16CreditNote,
Form16DebitNote,
Tds26asEntry,
Form1626asUploadLog,
Form16NonSubmittedNotification,
Form1626asQuarterSnapshot,
Form16QuarterStatus,
Form16LedgerEntry
};
// Export default sequelize instance

View File

@ -4,6 +4,9 @@ import logger from '@utils/logger';
let pauseResumeQueue: Queue | null = null;
const QUEUE_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
let lastPauseResumeQueueErrorLog = 0;
try {
// Use shared Redis connection for both Queue and Worker
pauseResumeQueue = new Queue('pauseResumeQueue', {
@ -23,7 +26,11 @@ try {
});
pauseResumeQueue.on('error', (error) => {
logger.error('[Pause Resume Queue] Queue error:', error);
const now = Date.now();
if (now - lastPauseResumeQueueErrorLog >= QUEUE_ERROR_LOG_INTERVAL_MS) {
lastPauseResumeQueueErrorLog = now;
logger.error('[Pause Resume Queue] Queue error (Redis may be down):', (error as Error)?.message || error);
}
});
logger.info('[Pause Resume Queue] ✅ Queue initialized');

View File

@ -4,6 +4,8 @@ import { handlePauseResumeJob } from './pauseResumeProcessor';
import logger from '@utils/logger';
let pauseResumeWorker: Worker | null = null;
const WORKER_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
let lastPauseResumeWorkerErrorLog = 0;
try {
pauseResumeWorker = new Worker('pauseResumeQueue', handlePauseResumeJob, {
@ -34,14 +36,17 @@ try {
});
pauseResumeWorker.on('error', (err) => {
// Connection errors are common if Redis is unavailable - log as warning
const errorCode = (err as any)?.code;
const isConnectionError = err?.message?.includes('connect') ||
err?.message?.includes('ECONNREFUSED') ||
err?.message?.includes('Redis') ||
errorCode === 'ECONNREFUSED';
if (isConnectionError) {
logger.warn('[Pause Resume Worker] Connection issue (Redis may be unavailable):', err?.message || errorCode || String(err));
const now = Date.now();
if (now - lastPauseResumeWorkerErrorLog >= WORKER_ERROR_LOG_INTERVAL_MS) {
lastPauseResumeWorkerErrorLog = now;
logger.warn('[Pause Resume Worker] Connection issue (Redis may be unavailable). Next log in 2 min.');
}
} else {
// Log full error details for non-connection errors to diagnose issues
logger.error('[Pause Resume Worker] Error:', {

View File

@ -4,12 +4,20 @@ import logger from '@utils/logger';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
const redisPassword = process.env.REDIS_PASSWORD || undefined;
// Throttle Redis error/close logs when Redis is down (avoid flooding terminal)
let lastRedisErrorLog = 0;
let lastRedisCloseLog = 0;
const REDIS_LOG_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
const redisOptions: any = {
maxRetriesPerRequest: null, // Required for BullMQ
enableReadyCheck: false,
retryStrategy: (times: number) => {
if (times > 5) {
logger.error('[Redis] Connection failed after 5 attempts');
if (Date.now() - lastRedisErrorLog >= REDIS_LOG_INTERVAL_MS) {
lastRedisErrorLog = Date.now();
logger.error('[Redis] Connection failed after 5 attempts. TAT/Pause-Resume need Redis. Start Redis or ignore if not needed.');
}
return null;
}
return Math.min(times * 2000, 10000);
@ -40,11 +48,19 @@ export const getSharedRedisConnection = (): IORedis => {
});
sharedConnection.on('error', (err) => {
logger.error('[Redis] Connection error:', err.message);
const now = Date.now();
if (now - lastRedisErrorLog >= REDIS_LOG_INTERVAL_MS) {
lastRedisErrorLog = now;
logger.error('[Redis] Connection error: ' + err.message + ' (next log in 2 min if still down)');
}
});
sharedConnection.on('close', () => {
logger.warn('[Redis] Connection closed');
const now = Date.now();
if (now - lastRedisCloseLog >= REDIS_LOG_INTERVAL_MS) {
lastRedisCloseLog = now;
logger.warn('[Redis] Connection closed (next log in 2 min if still down)');
}
});
}

View File

@ -4,6 +4,9 @@ import logger from '@utils/logger';
let tatQueue: Queue | null = null;
const QUEUE_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
let lastTatQueueErrorLog = 0;
try {
// Use shared Redis connection for both Queue and Worker
tatQueue = new Queue('tatQueue', {
@ -20,7 +23,11 @@ try {
});
tatQueue.on('error', (error) => {
logger.error('[TAT Queue] Queue error:', error);
const now = Date.now();
if (now - lastTatQueueErrorLog >= QUEUE_ERROR_LOG_INTERVAL_MS) {
lastTatQueueErrorLog = now;
logger.error('[TAT Queue] Queue error (Redis may be down):', (error as Error)?.message || error);
}
});
logger.info('[TAT Queue] ✅ Queue initialized');

View File

@ -4,6 +4,8 @@ import { handleTatJob } from './tatProcessor';
import logger from '@utils/logger';
let tatWorker: Worker | null = null;
const WORKER_ERROR_LOG_INTERVAL_MS = 2 * 60 * 1000;
let lastTatWorkerErrorLog = 0;
try {
tatWorker = new Worker('tatQueue', handleTatJob, {
@ -34,12 +36,15 @@ try {
});
tatWorker.on('error', (err) => {
// Connection errors are common if Redis is unavailable - log as warning
const isConnectionError = err?.message?.includes('connect') ||
err?.message?.includes('ECONNREFUSED') ||
err?.message?.includes('Redis');
if (isConnectionError) {
logger.warn('[TAT Worker] Connection issue (Redis may be unavailable):', err?.message || err);
const now = Date.now();
if (now - lastTatWorkerErrorLog >= WORKER_ERROR_LOG_INTERVAL_MS) {
lastTatWorkerErrorLog = now;
logger.warn('[TAT Worker] Connection issue (Redis may be unavailable). Next log in 2 min.');
}
} else {
logger.error('[TAT Worker] Error:', err?.message || err);
}

View File

@ -12,15 +12,15 @@ export function initSocket(httpServer: any) {
let origins: string[];
// FRONTEND_URL is required - no fallbacks
if (!frontendUrl) {
console.error('❌ ERROR: FRONTEND_URL environment variable is not set for Socket.io!');
console.error(' Socket.io will block all origins. This will prevent WebSocket connections.');
console.error(' To fix: Set FRONTEND_URL environment variable with your frontend URL(s)');
console.error(' Example: FRONTEND_URL=https://your-frontend-domain.com');
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
console.error(' Development: FRONTEND_URL=http://localhost:3000');
origins = []; // Block all origins if not configured
if (isProduction) {
console.error('❌ ERROR: FRONTEND_URL environment variable is not set for Socket.io!');
console.error(' Socket.io will block all origins. This will prevent WebSocket connections.');
origins = [];
} else {
console.warn('⚠️ Socket.io: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
origins = ['http://localhost:3000'];
}
} else if (frontendUrl === '*') {
if (isProduction) {
console.warn('⚠️ WARNING: Socket.io FRONTEND_URL is set to allow ALL origins (*) in production. This is not recommended for security.');
@ -28,11 +28,15 @@ export function initSocket(httpServer: any) {
origins = ['*'] as any; // Allow all origins
} else {
origins = frontendUrl.split(',').map(s => s.trim()).filter(Boolean);
if (origins.length === 0) {
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs for Socket.io!');
origins = [];
} else {
// In development always allow localhost:3000 (Vite default) so WebSocket works when frontend is on 3000
if (!isProduction && !origins.includes('http://localhost:3000')) {
origins = ['http://localhost:3000', ...origins];
}
console.log(`✅ Socket.io: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
}
}

View File

@ -26,6 +26,8 @@ import {
getAllConfigurations,
updateConfiguration,
resetConfiguration,
getForm16Config,
putForm16Config,
updateUserRole,
getUsersByRole,
getRoleStatistics,
@ -121,6 +123,21 @@ router.put('/configurations/:configKey', validateParams(configKeyParamsSchema),
*/
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
/**
* @route GET /api/admin/form16-config
* @desc Get Form 16 admin config (submission/26AS viewers, reminders)
* @access Admin
*/
router.get('/form16-config', getForm16Config);
/**
* @route PUT /api/admin/form16-config
* @desc Update Form 16 admin config
* @body { submissionViewerEmails?, twentySixAsViewerEmails?, reminderEnabled?, reminderDays? }
* @access Admin
*/
router.put('/form16-config', putForm16Config);
// ==================== User Role Management Routes (RBAC) ====================
/**

210
src/routes/form16.routes.ts Normal file
View File

@ -0,0 +1,210 @@
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;

View File

@ -1,4 +1,17 @@
import { Router } from 'express';
import { sequelize } from '../config/database';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import {
authLimiter,
uploadLimiter,
adminLimiter,
aiLimiter,
webhookLimiter,
generalApiLimiter,
} from '../middlewares/rateLimiter.middleware';
import authRoutes from './auth.routes';
import workflowRoutes from './workflow.routes';
import summaryRoutes from './summary.routes';
@ -20,16 +33,7 @@ import dmsWebhookRoutes from './dmsWebhook.routes';
import apiTokenRoutes from './apiToken.routes';
import antivirusRoutes from './antivirus.routes';
import dealerExternalRoutes from './dealerExternal.routes';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import {
authLimiter,
uploadLimiter,
adminLimiter,
aiLimiter,
webhookLimiter,
generalApiLimiter,
} from '../middlewares/rateLimiter.middleware';
import form16Routes from './form16.routes';
const router = Router();
@ -42,6 +46,26 @@ router.get('/health', (_req, res) => {
});
});
// Database connectivity check (for ops/testing)
router.get('/health/db', async (_req, res) => {
try {
await sequelize.authenticate();
res.status(200).json({
status: 'OK',
database: 'connected',
timestamp: new Date()
});
} catch (err: any) {
res.status(503).json({
status: 'ERROR',
database: 'disconnected',
error: err?.message || 'Unknown error',
timestamp: new Date()
});
}
});
// API routes (with rate limiters)
// ── Auth & Admin (strict limits) ──
router.use('/auth', authLimiter, authRoutes); // 20 req/15min
router.use('/admin', adminLimiter, adminRoutes); // 30 req/15min
@ -73,6 +97,7 @@ router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 r
router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min
router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 req/15min
router.use('/dealers-external', generalApiLimiter, dealerExternalRoutes); // 200 req/15min
router.use('/form16', uploadLimiter, form16Routes); // 50 req/15min (file uploads: extract, submissions, 26as)
router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
export default router;

View File

@ -160,9 +160,20 @@ async function runMigrations(): Promise<void> {
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
const m47 = require('../migrations/20260216-create-api-tokens');
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses');
const m47a = require('../migrations/20260217-add-is-service-to-expenses');
const m48a = require('../migrations/20260217-create-claim-invoice-items');
const m49 = require('../migrations/20260302-refine-dealer-claim-schema');
const m50 = require('../migrations/20260309-add-wfm-push-fields');
const m50 = require('../migrations/20260220-create-form16-tables');
const m51 = require('../migrations/20260220000001-add-form16-ocr-extracted-data');
const m52 = require('../migrations/20260222000001-create-tds-26as-entries');
const m53 = require('../migrations/20260223000001-create-form-16-debit-notes');
const m54 = require('../migrations/20260224000001-create-form-16-26as-upload-log');
const m55 = require('../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables');
const m56 = require('../migrations/20260225000001-create-form16-non-submitted-notifications');
const m57 = require('../migrations/20260225100001-add-form16-archived-at');
const m58 = require('../migrations/20260303100001-drop-form16a-number-unique');
const m59 = require('../migrations/20260309-add-wfm-push-fields');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -215,18 +226,30 @@ async function runMigrations(): Promise<void> {
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
{ name: '20260210-add-raw-pwc-responses', module: m46 },
{ name: '20260216-create-api-tokens', module: m47 },
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 },
{ name: '20260217-add-is-service-to-expenses', module: m47a },
{ name: '20260217-create-claim-invoice-items', module: m48a },
{ name: '20260302-refine-dealer-claim-schema', module: m49 },
{ name: '20260309-add-wfm-push-fields', module: m50 },
{ name: '20260220-create-form16-tables', module: m50 },
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m51 },
{ name: '20260222000001-create-tds-26as-entries', module: m52 },
{ name: '20260223000001-create-form-16-debit-notes', module: m53 },
{ name: '20260224000001-create-form-16-26as-upload-log', module: m54 },
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m55 },
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m56 },
{ name: '20260225100001-add-form16-archived-at', module: m57 },
{ name: '20260303100001-drop-form16a-number-unique', module: m58 },
{ name: '20260309-add-wfm-push-fields', module: m59 },
];
// Dynamically import sequelize after secrets are loaded
const { sequelize } = require('../config/database');
const queryInterface = sequelize.getQueryInterface();
// Ensure migrations tracking table exists
// Ensure migrations tracking table exists (case-insensitive for PostgreSQL)
const tables = await queryInterface.showAllTables();
if (!tables.includes('migrations')) {
const tableName = (t: string | { tableName?: string }) => (typeof t === 'string' ? t.toLowerCase() : (t && t.tableName ? String(t.tableName).toLowerCase() : ''));
if (!tables.some((t: string | { tableName?: string }) => tableName(t) === 'migrations')) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,

View File

@ -0,0 +1,145 @@
/**
* Clear Form 16 and demo request data for a full live test.
* Deletes only DATA (rows). Does NOT drop tables or columns. Does NOT touch users table.
*
* Removes:
* - All Form 16 submissions, credit notes, debit notes, and their FORM_16 workflow requests + related rows
* - All demo CUSTOM requests (title like '[Demo]%') and their related rows
*
* Usage: npm run clear:form16-and-demo
*/
import { sequelize } from '../config/database';
import { QueryTypes } from 'sequelize';
import logger from '../utils/logger';
async function clearForm16AndDemoData(): Promise<void> {
const transaction = await sequelize.transaction();
try {
logger.info('[Clear] Starting Form 16 and demo data cleanup (data only; users and schema untouched)...');
// 1) Get request IDs to clear: FORM_16 and [Demo] CUSTOM
const form16Rows = await sequelize.query<{ request_id: string }>(
`SELECT request_id FROM workflow_requests WHERE template_type = 'FORM_16'`,
{ type: QueryTypes.SELECT, transaction }
);
const demoRows = await sequelize.query<{ request_id: string }>(
`SELECT request_id FROM workflow_requests WHERE title LIKE '[Demo]%'`,
{ type: QueryTypes.SELECT, transaction }
);
const form16Ids = form16Rows.map((r) => r.request_id);
const demoIds = demoRows.map((r) => r.request_id);
const allRequestIds = [...new Set([...form16Ids, ...demoIds])];
if (allRequestIds.length === 0) {
logger.info('[Clear] No Form 16 or demo requests found. Nothing to delete.');
await transaction.commit();
return;
}
logger.info(`[Clear] Found ${form16Ids.length} Form 16 request(s) and ${demoIds.length} demo request(s) to clear.`);
const reqPlaceholders = allRequestIds.map(() => '?').join(',');
const deleteByRequestIds = async (table: string, col = 'request_id'): Promise<void> => {
await sequelize.query(
`DELETE FROM ${table} WHERE ${col} = ANY(ARRAY[${allRequestIds.map(() => '?').join(',')}]::uuid[])`,
{
replacements: allRequestIds,
type: QueryTypes.DELETE,
transaction,
}
);
};
// 2) Form 16specific 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 };

View File

@ -48,26 +48,94 @@ import * as m42 from '../migrations/20250125-create-activity-types';
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
import * as m44 from '../migrations/20260123-fix-template-id-schema';
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
import * as m46 from '../migrations/20260210-add-raw-pwc-responses';
import * as m47 from '../migrations/20260216-create-api-tokens';
import * as m48 from '../migrations/20260216-add-qty-hsn-to-expenses';
import * as m47a from '../migrations/20260217-add-is-service-to-expenses';
import * as m48a from '../migrations/20260217-create-claim-invoice-items';
import * as m49 from '../migrations/20260302-refine-dealer-claim-schema';
import * as m50 from '../migrations/20260309-add-wfm-push-fields';
import * as m50 from '../migrations/20260220-create-form16-tables';
import * as m51 from '../migrations/20260220000001-add-form16-ocr-extracted-data';
import * as m52 from '../migrations/20260222000001-create-tds-26as-entries';
import * as m53 from '../migrations/20260223000001-create-form-16-debit-notes';
import * as m54 from '../migrations/20260224000001-create-form-16-26as-upload-log';
import * as m55 from '../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables';
import * as m56 from '../migrations/20260225000001-create-form16-non-submitted-notifications';
import * as m57 from '../migrations/20260225100001-add-form16-archived-at';
import * as m58 from '../migrations/20260303100001-drop-form16a-number-unique';
import * as m59 from '../migrations/20260309-add-wfm-push-fields';
interface Migration {
name: string;
module: any;
}
// Define all migrations in order
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
// Define ALL migrations in order. Required for fresh DB (e.g. UAT/production).
// Order matters: base tables first, then tables that reference them.
const migrations: Migration[] = [
// ... existing migrations ...
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
{ name: '20260217-add-is-service-to-expenses', module: m47 },
{ name: '20260217-create-claim-invoice-items', module: m48 },
{ name: '2025103000-create-users', module: m0 },
{ name: '2025103001-create-workflow-requests', module: m1 },
{ name: '2025103002-create-approval-levels', module: m2 },
{ name: '2025103003-create-participants', module: m3 },
{ name: '2025103004-create-documents', module: m4 },
{ name: '20251031_01_create_subscriptions', module: m5 },
{ name: '20251031_02_create_activities', module: m6 },
{ name: '20251031_03_create_work_notes', module: m7 },
{ name: '20251031_04_create_work_note_attachments', module: m8 },
{ name: '20251104-add-tat-alert-fields', module: m9 },
{ name: '20251104-create-tat-alerts', module: m10 },
{ name: '20251104-create-kpi-views', module: m11 },
{ name: '20251104-create-holidays', module: m12 },
{ name: '20251104-create-admin-config', module: m13 },
{ name: '20251105-add-skip-fields-to-approval-levels', module: m14 },
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
{ name: '20251121-add-ai-model-configs', module: m19 },
{ name: '20250122-create-request-summaries', module: m20 },
{ name: '20250122-create-shared-summaries', module: m21 },
{ name: '20250123-update-request-number-format', module: m22 },
{ name: '20250126-add-paused-to-enum', module: m23 },
{ name: '20250126-add-paused-to-workflow-status-enum', module: m24 },
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
{ name: '20251203-add-user-notification-preferences', module: m29 },
{ name: '20251210-add-workflow-type-support', module: m30 },
{ name: '20251210-enhance-workflow-templates', module: m31 },
{ name: '20251210-add-template-id-foreign-key', module: m32 },
{ name: '20251210-create-dealer-claim-tables', module: m33 },
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
{ name: '20251211-create-internal-orders-table', module: m35 },
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
{ name: '20260123-fix-template-id-schema', module: m44 },
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
{ name: '20260210-add-raw-pwc-responses', module: m46 },
{ name: '20260216-create-api-tokens', module: m47 },
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 },
{ name: '20260217-add-is-service-to-expenses', module: m47a },
{ name: '20260217-create-claim-invoice-items', module: m48a },
{ name: '20260302-refine-dealer-claim-schema', module: m49 },
{ name: '20260309-add-wfm-push-fields', module: m50 }
{ name: '20260220-create-form16-tables', module: m50 },
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m51 },
{ name: '20260222000001-create-tds-26as-entries', module: m52 },
{ name: '20260223000001-create-form-16-debit-notes', module: m53 },
{ name: '20260224000001-create-form-16-26as-upload-log', module: m54 },
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m55 },
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m56 },
{ name: '20260225100001-add-form16-archived-at', module: m57 },
{ name: '20260303100001-drop-form16a-number-unique', module: m58 },
{ name: '20260309-add-wfm-push-fields', module: m59 },
];
/**
@ -77,9 +145,10 @@ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<vo
try {
const tables = await queryInterface.showAllTables();
if (!tables.includes('migrations')) {
const tableName = (t: string) => (typeof t === 'string' ? t.toLowerCase() : (t as any));
if (!tables.some((t: string) => tableName(t) === 'migrations')) {
await queryInterface.sequelize.query(`
CREATE TABLE migrations (
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP

View File

@ -0,0 +1,21 @@
/**
* Run Form 16 archive once (records older than 5 FY get archived_at set).
* Use this for manual runs; the scheduler runs daily at 02:00.
*
* Usage: npx ts-node -r tsconfig-paths/register src/scripts/run-form16-archive.ts
*/
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
async function main() {
const { runForm16ArchiveOldRecords } = await import('../services/form16Archive.service');
const results = await runForm16ArchiveOldRecords();
console.log('Form 16 archive run completed:', results);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,91 @@
/**
* Run pending DB migrations. Used by server on startup and by npm run migrate.
* Export runMigrations(sequelize) - does not load secrets or connect; caller must pass connected sequelize.
*/
import { QueryInterface, QueryTypes } from 'sequelize';
import type { Sequelize } from 'sequelize';
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
import * as m49 from '../migrations/20260220-create-form16-tables';
import * as m50 from '../migrations/20260220000001-add-form16-ocr-extracted-data';
import * as m51 from '../migrations/20260222000001-create-tds-26as-entries';
import * as m52 from '../migrations/20260223000001-create-form-16-debit-notes';
import * as m53 from '../migrations/20260224000001-create-form-16-26as-upload-log';
import * as m53a from '../migrations/20260225000001-add-form16-26as-upload-log-id-and-tables';
import * as m54 from '../migrations/20260225000001-create-form16-non-submitted-notifications';
import * as m54a from '../migrations/20260225100001-add-form16-archived-at';
import * as m55 from '../migrations/20260303100001-drop-form16a-number-unique';
import * as m56 from '../migrations/20260302-refine-dealer-claim-schema';
interface Migration {
name: string;
module: any;
}
const migrations: Migration[] = [
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
{ name: '20260217-add-is-service-to-expenses', module: m47 },
{ name: '20260217-create-claim-invoice-items', module: m48 },
{ name: '20260220-create-form16-tables', module: m49 },
{ name: '20260220000001-add-form16-ocr-extracted-data', module: m50 },
{ name: '20260222000001-create-tds-26as-entries', module: m51 },
{ name: '20260223000001-create-form-16-debit-notes', module: m52 },
{ name: '20260224000001-create-form-16-26as-upload-log', module: m53 },
{ name: '20260225000001-add-form16-26as-upload-log-id-and-tables', module: m53a },
{ name: '20260225000001-create-form16-non-submitted-notifications', module: m54 },
{ name: '20260225100001-add-form16-archived-at', module: m54a },
{ name: '20260302-refine-dealer-claim-schema', module: m56 },
{ name: '20260303100001-drop-form16a-number-unique', module: m55 },
];
async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<void> {
const tables = await queryInterface.showAllTables();
const tableName = (t: string) => (typeof t === 'string' ? t.toLowerCase() : (t as any));
if (!tables.some((t) => tableName(t) === 'migrations')) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
}
}
async function getExecutedMigrations(sequelize: Sequelize): Promise<string[]> {
try {
const results = (await sequelize.query('SELECT name FROM migrations ORDER BY id', {
type: QueryTypes.SELECT,
})) as { name: string }[];
return results.map((r) => r.name);
} catch {
return [];
}
}
async function markMigrationExecuted(sequelize: Sequelize, name: string): Promise<void> {
await sequelize.query(
'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING',
{ replacements: { name }, type: QueryTypes.RAW }
);
}
/**
* Run all pending migrations. Call after DB is connected (e.g. after sequelize.authenticate()).
* @returns Number of migrations applied (0 if already up-to-date).
*/
export async function runMigrations(sequelize: Sequelize): Promise<number> {
const queryInterface = sequelize.getQueryInterface();
await ensureMigrationsTable(queryInterface);
const executedMigrations = await getExecutedMigrations(sequelize);
const pendingMigrations = migrations.filter((m) => !executedMigrations.includes(m.name));
if (pendingMigrations.length === 0) return 0;
for (const migration of pendingMigrations) {
await migration.module.up(queryInterface);
await markMigrationExecuted(sequelize, migration.name);
console.log(`✅ Migration: ${migration.name}`);
}
return pendingMigrations.length;
}

View File

@ -0,0 +1,101 @@
/**
* Demo RE Admin user for local login (no Okta).
* Login: admin@example.com / Admin@123
* User is created on server start if missing; password is verified in auth.service with a default hash.
*/
import bcrypt from 'bcryptjs';
import { sequelize } from '../config/database';
import { User } from '../models/User';
import logger from '../utils/logger';
const ADMIN_EMAIL = 'admin@example.com';
const ADMIN_OKTA_SUB = 'local-ADMIN';
const ADMIN_PASSWORD = 'Admin@123';
const ADMIN_DISPLAY_NAME = 'RE Admin';
/** Ensure demo admin user exists in DB (called on server start). */
export async function ensureDemoAdminUser(): Promise<void> {
try {
let user = await User.findOne({ where: { email: ADMIN_EMAIL } });
const userData = {
email: ADMIN_EMAIL,
oktaSub: ADMIN_OKTA_SUB,
displayName: ADMIN_DISPLAY_NAME,
firstName: 'RE',
lastName: 'Admin',
isActive: true,
role: 'ADMIN' as const,
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true,
};
if (user) {
await user.update(userData);
} else {
await User.create(userData);
logger.info('[Demo Admin] Created demo admin user (admin@example.com). Sign in with Admin@123');
}
} catch (err) {
logger.warn('[Demo Admin] Could not ensure demo admin user:', err);
}
}
async function seedAdminUser(): Promise<void> {
try {
logger.info('[Seed Admin User] Starting...');
let user = await User.findOne({
where: { email: ADMIN_EMAIL },
});
const userData = {
email: ADMIN_EMAIL,
oktaSub: ADMIN_OKTA_SUB,
displayName: ADMIN_DISPLAY_NAME,
firstName: 'RE',
lastName: 'Admin',
isActive: true,
role: 'ADMIN' as const,
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true,
};
if (user) {
await user.update(userData);
logger.info('[Seed Admin User] ✅ Updated existing user:', user.userId);
} else {
user = await User.create(userData);
logger.info('[Seed Admin User] ✅ Created admin user:', user.userId);
}
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
console.log('\n---------- Add these to your .env ----------');
console.log('ENABLE_LOCAL_ADMIN_LOGIN=true');
console.log(`LOCAL_ADMIN_EMAIL=${ADMIN_EMAIL}`);
console.log(`LOCAL_ADMIN_PASSWORD_HASH=${hash}`);
console.log('---------------------------------------------\n');
console.log('Login with:');
console.log(` Email: ${ADMIN_EMAIL}`);
console.log(` Password: ${ADMIN_PASSWORD}`);
console.log('---------------------------------------------\n');
logger.info('[Seed Admin User] ✅ Done. Use the credentials above to log in as RE Admin.');
} catch (error) {
logger.error('[Seed Admin User] ❌ Error:', error);
throw error;
}
}
if (require.main === module) {
sequelize
.authenticate()
.then(() => seedAdminUser())
.then(() => process.exit(0))
.catch((err) => {
logger.error(err);
process.exit(1);
});
}
export { seedAdminUser };

View File

@ -0,0 +1,81 @@
/**
* Seed dealer user for local login (Form 16A testing).
* Creates:
* 1. User with email testreflow@example.com, oktaSub local-TESTREFLOW (for dealer lookup)
* 2. Dealer with dealer_principal_email_id = testreflow@example.com (via seed-test-dealer)
* 3. Prints bcrypt hash for password T3$tr1Fl0w@12 add to .env as LOCAL_DEALER_PASSWORD_HASH
*
* Login: username = TESTREFLOW, password = T3$tr1Fl0w@12
* Enable: ENABLE_LOCAL_DEALER_LOGIN=true in .env
*/
import bcrypt from 'bcryptjs';
import { sequelize } from '../config/database';
import { User } from '../models/User';
import { seedTestDealer } from './seed-test-dealer';
import logger from '../utils/logger';
const DEALER_EMAIL = 'testreflow@example.com';
const DEALER_OKTA_SUB = 'local-TESTREFLOW';
const DEALER_PASSWORD = 'T3$tr1Fl0w@12';
async function seedDealerUser(): Promise<void> {
try {
logger.info('[Seed Dealer User] Starting...');
// 1. Create or update User (so getDealerCodeForUser finds dealer by email)
let user = await User.findOne({
where: { email: DEALER_EMAIL },
});
const userData = {
email: DEALER_EMAIL,
oktaSub: DEALER_OKTA_SUB,
displayName: 'Test Reflow Dealer',
firstName: 'Test',
lastName: 'Reflow',
isActive: true,
};
if (user) {
await user.update(userData);
logger.info('[Seed Dealer User] ✅ Updated user:', user.userId);
} else {
user = await User.create({
...userData,
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true,
role: 'USER',
});
logger.info('[Seed Dealer User] ✅ Created user:', user.userId);
}
// 2. Ensure dealer exists (dealer_principal_email_id = testreflow@example.com)
await seedTestDealer();
// 3. Generate and print hash for .env
const hash = await bcrypt.hash(DEALER_PASSWORD, 10);
console.log('\n---------- Add these to your .env ----------');
console.log('ENABLE_LOCAL_DEALER_LOGIN=true');
console.log(`LOCAL_DEALER_PASSWORD_HASH=${hash}`);
console.log('---------------------------------------------\n');
logger.info('[Seed Dealer User] ✅ Done. Login with username=TESTREFLOW, password=<your password>');
} catch (error) {
logger.error('[Seed Dealer User] ❌ Error:', error);
throw error;
}
}
if (require.main === module) {
sequelize
.authenticate()
.then(() => seedDealerUser())
.then(() => process.exit(0))
.catch((err) => {
logger.error(err);
process.exit(1);
});
}
export { seedDealerUser };

View File

@ -0,0 +1,24 @@
/**
* Seed only the demo dealers used for Form 16 non-submitted list.
* Run this to populate the dealers table with DEMO-NOSUB-001, 002, 003 so the
* "Non-submitted Dealers" page shows data (select FY 2024-25 and Q1).
*
* Usage: npm run seed:demo-dealers
*/
import { sequelize } from '../config/database';
import logger from '../utils/logger';
import { ensureDemoNonSubmittedDealers } from './seed-demo-requests';
async function main() {
await sequelize.authenticate();
await ensureDemoNonSubmittedDealers();
logger.info('[Seed Demo Dealers] Done. Open Non-submitted Dealers, select FY 2024-25 and Q1 to see them.');
}
main()
.then(() => process.exit(0))
.catch((err) => {
logger.error('[Seed Demo Dealers] Failed:', err);
process.exit(1);
});

View File

@ -0,0 +1,247 @@
/**
* Seed demo data: Form 16 submissions and other workflow requests.
* Data is inserted into database tables only (no hardcoded data in app code).
*
* Prerequisites:
* - Run seed:admin-user (for admin user)
* - Run seed:dealer-user (for dealer user + dealer record for Form 16)
*
* Usage: npm run seed:demo-requests
*/
import { Op } from 'sequelize';
import { sequelize } from '../config/database';
import {
User,
WorkflowRequest,
Form16aSubmission,
Form16CreditNote,
Activity,
Document,
Dealer,
} from '../models';
import { Priority, WorkflowStatus } from '../types/common.types';
import { generateRequestNumber } from '../utils/helpers';
import logger from '../utils/logger';
const DEMO_MARKER_TITLE_PREFIX = 'Form 16A - 2024-25'; // Used to detect existing demo Form 16 data
const DEMO_CUSTOM_TITLE = '[Demo] Sample workflow request';
async function getOrResolveUsers(): Promise<{ adminUser: User | null; dealerUser: User | null; dealerCode: string | null }> {
const adminUser = await User.findOne({ where: { email: 'admin@example.com' } });
const dealerUser = await User.findOne({ where: { email: 'testreflow@example.com' } });
let dealerCode: string | null = null;
if (dealerUser) {
const dealer = await Dealer.findOne({
where: { dealerPrincipalEmailId: 'testreflow@example.com', isActive: true },
attributes: ['salesCode', 'dlrcode'],
});
dealerCode = dealer?.salesCode ?? dealer?.dlrcode ?? null;
}
return { adminUser, dealerUser, dealerCode };
}
async function hasExistingDemoForm16(): Promise<boolean> {
const count = await WorkflowRequest.count({
where: {
templateType: 'FORM_16',
title: { [Op.like]: `${DEMO_MARKER_TITLE_PREFIX}%` },
},
});
return count >= 2;
}
/** Demo dealers used for "non-submitted dealers" list: active dealers with no Form 16 submission for the demo FY/quarter. */
const DEMO_NON_SUBMITTED_DEALERS = [
{ dlrcode: 'DEMO-NOSUB-001', dealership: 'Demo Motors Mumbai', dealerPrincipalName: 'Demo Mumbai', dealerPrincipalEmailId: 'demo.nosub.1@example.com', state: 'Maharashtra', city: 'Mumbai' },
{ dlrcode: 'DEMO-NOSUB-002', dealership: 'Demo Enfield Delhi', dealerPrincipalName: 'Demo Delhi', dealerPrincipalEmailId: 'demo.nosub.2@example.com', state: 'Delhi', city: 'New Delhi' },
{ dlrcode: 'DEMO-NOSUB-003', dealership: 'Demo Royal Bangalore', dealerPrincipalName: 'Demo Bangalore', dealerPrincipalEmailId: 'demo.nosub.3@example.com', state: 'Karnataka', city: 'Bengaluru' },
];
async function ensureDemoNonSubmittedDealers(): Promise<void> {
for (const data of DEMO_NON_SUBMITTED_DEALERS) {
const [dealer] = await Dealer.findOrCreate({
where: { dlrcode: data.dlrcode },
defaults: {
...data,
salesCode: data.dlrcode,
isActive: true,
} as any,
});
if (dealer && !dealer.isActive) {
await dealer.update({ isActive: true });
}
}
logger.info('[Seed Demo] Demo non-submitted dealers ensured (they have no Form 16 submissions for 2024-25).');
}
async function seedDemoRequests(): Promise<void> {
// Always ensure demo dealers exist first (for both submission and non-submitted lists).
// These dealers are in the dealers table; only the test dealer gets Form 16 submissions below.
await ensureDemoNonSubmittedDealers();
const { adminUser, dealerUser, dealerCode } = await getOrResolveUsers();
const initiatorId = adminUser?.userId ?? dealerUser?.userId;
if (!initiatorId) {
logger.warn('[Seed Demo] No admin or dealer user found. Run seed:admin-user and seed:dealer-user first.');
return;
}
if (await hasExistingDemoForm16()) {
logger.info('[Seed Demo] Demo Form 16 requests already present. Skipping to avoid duplicates.');
return;
}
const now = new Date();
// ---- 1) Create a few CUSTOM (nonForm 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 };

View File

@ -0,0 +1,85 @@
/**
* Seed user: Rohit Mandiwal (dealer).
* Creates user in users table and prints bcrypt hash for password login.
*
* User: email = rohitm_ext@royalenfield.com, name = Rohit Mandiwal, role = USER (dealer).
* Password: Test@123
*
* After running, add to .env for password login:
* LOCAL_DEALER_2_EMAIL=rohitm_ext@royalenfield.com
* LOCAL_DEALER_2_PASSWORD_HASH=<hash printed below>
* Login with username = rohitm_ext@royalenfield.com, password = Test@123
*/
import bcrypt from 'bcryptjs';
import { sequelize } from '../config/database';
import { User } from '../models/User';
import logger from '../utils/logger';
const EMAIL = 'rohitm_ext@royalenfield.com';
const OLD_EMAIL = 'rohit.m.ext@royalenfield.com';
const OKTA_SUB = 'local-ROHIT-MANDIWAL';
const PASSWORD = 'Test@123';
const DISPLAY_NAME = 'Rohit Mandiwal';
const FIRST_NAME = 'Rohit';
const LAST_NAME = 'Mandiwal';
async function seedRohitUser(): Promise<void> {
try {
logger.info('[Seed Rohit User] Starting...');
let user = await User.findOne({ where: { email: EMAIL } });
if (!user) {
const oldUser = await User.findOne({ where: { email: OLD_EMAIL } });
if (oldUser) {
await oldUser.update({ email: EMAIL });
user = oldUser;
logger.info('[Seed Rohit User] Updated existing user email to:', EMAIL);
}
}
const userData = {
email: EMAIL,
oktaSub: OKTA_SUB,
displayName: DISPLAY_NAME,
firstName: FIRST_NAME,
lastName: LAST_NAME,
isActive: true,
role: 'USER' as const,
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true,
};
if (user) {
await user.update(userData);
logger.info('[Seed Rohit User] Updated user:', user.userId);
} else {
user = await User.create(userData);
logger.info('[Seed Rohit User] Created user:', user.userId);
}
const hash = await bcrypt.hash(PASSWORD, 10);
console.log('\n---------- Add these to your .env for password login ----------');
console.log('LOCAL_DEALER_2_EMAIL=rohitm_ext@royalenfield.com');
console.log(`LOCAL_DEALER_2_PASSWORD_HASH=${hash}`);
console.log('----------------------------------------------------------------');
console.log('Login: username = rohitm_ext@royalenfield.com, password = Test@123\n');
} catch (error) {
logger.error('[Seed Rohit User] Error:', error);
throw error;
}
}
if (require.main === module) {
sequelize
.authenticate()
.then(() => seedRohitUser())
.then(() => process.exit(0))
.catch((err) => {
logger.error(err);
process.exit(1);
});
}
export { seedRohitUser };

View File

@ -1,4 +1,5 @@
import http from 'http';
import net from 'net';
import dotenv from 'dotenv';
import path from 'path';
@ -9,6 +10,42 @@ dotenv.config({ path: path.resolve(__dirname, '../.env') });
import { stopQueueMetrics } from './utils/queueMetrics';
const PORT: number = parseInt(process.env.PORT || '5000', 10);
const isDev = process.env.NODE_ENV !== 'production';
const MAX_PORT_ATTEMPTS = isDev ? 6 : 1; // In dev try PORT..PORT+5 if in use
/**
* Check if a port is free (no one listening).
*/
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const onError = () => {
socket.destroy();
resolve(true); // Error (e.g. ECONNREFUSED) means nothing listening → port free
};
socket.setTimeout(200);
socket.once('error', onError);
socket.once('timeout', () => {
socket.destroy();
resolve(true);
});
socket.connect(port, '127.0.0.1', () => {
socket.destroy();
resolve(false); // Connected → something is listening → port in use
});
});
}
/**
* Find first free port in [startPort, startPort + maxAttempts).
*/
async function findFreePort(startPort: number, maxAttempts: number): Promise<number> {
for (let i = 0; i < maxAttempts; i++) {
const port = startPort + i;
if (await isPortFree(port)) return port;
}
return startPort; // Fallback to original; listen will then fail with EADDRINUSE
}
// Start server
const startServer = async (): Promise<void> => {
@ -61,6 +98,14 @@ const startServer = async (): Promise<void> => {
console.error('⚠️ Activity type seeding error:', error);
}
// Ensure demo admin user exists (admin@example.com / Admin@123)
const { ensureDemoAdminUser } = require('./scripts/seed-admin-user');
try {
await ensureDemoAdminUser();
} catch (error) {
console.warn('⚠️ Demo admin user setup warning:', error);
}
// Initialize holidays cache for TAT calculations
try {
await initializeHolidaysCache();
@ -70,12 +115,43 @@ const startServer = async (): Promise<void> => {
// Start scheduled jobs
startPauseResumeJob();
const { startForm16NotificationJobs } = require('./jobs/form16NotificationJob');
startForm16NotificationJobs();
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
startForm16ArchiveJob();
// Initialize queue metrics collection for Prometheus
initializeQueueMetrics();
server.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
// In development, if default port is in use (e.g. previous run or another app), try next ports
let portToUse = PORT;
if (isDev) {
const freePort = await findFreePort(PORT, MAX_PORT_ATTEMPTS);
if (freePort !== PORT) {
console.warn(`⚠️ Port ${PORT} is in use. Using port ${freePort} instead.`);
console.warn(` Update frontend .env VITE_API_BASE_URL to http://localhost:${freePort}/api/v1 if needed.`);
portToUse = freePort;
}
}
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error('');
console.error('❌ Port ' + portToUse + ' is already in use.');
console.error(' Another process (often a previous backend instance) is using it.');
console.error(' Windows: netstat -ano | findstr :' + portToUse);
console.error(' Then: taskkill /PID <PID> /F');
console.error(' Or run in a single terminal and avoid starting backend twice.');
console.error('');
} else {
console.error('❌ Server error:', err);
}
process.exit(1);
});
server.listen(portToUse, () => {
console.log(`🚀 Server running on port ${portToUse} | ${process.env.NODE_ENV || 'development'}`);
console.log(` API base: http://localhost:${portToUse}/api/v1 (ensure frontend uses this and CORS allows your origin)`);
});
} catch (error) {
console.error('❌ Unable to start server:', error);

View File

@ -59,10 +59,9 @@ export async function seedDefaultActivityTypes(): Promise<void> {
systemUserId = (systemUser[0] as any).user_id;
}
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';
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.');
}
// Insert default activity types with proper handling
@ -79,22 +78,20 @@ export async function seedDefaultActivityTypes(): Promise<void> {
});
if (existing) {
// Identify fields to update
// Identify fields to update (only need systemUserId for updatedBy)
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 {
@ -103,14 +100,19 @@ export async function seedDefaultActivityTypes(): Promise<void> {
}
}
} else {
// Create new activity type with default fields
// 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;
}
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})`);

View File

@ -5,6 +5,7 @@ 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 {
/**
@ -497,9 +498,153 @@ 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`;
@ -612,6 +757,21 @@ 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)) {

View File

@ -579,6 +579,28 @@ 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

View File

@ -0,0 +1,115 @@
/**
* 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.`);
}

View File

@ -0,0 +1,111 @@
/**
* 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 havent 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;
}
}

View File

@ -0,0 +1,286 @@
/**
* 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);
}
}

View File

@ -0,0 +1,669 @@
/**
* 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);
}
}

View File

@ -0,0 +1,89 @@
/**
* 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);
}

View File

@ -0,0 +1,105 @@
/**
* 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)',
};
}

View File

@ -307,11 +307,44 @@ 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
'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,
};
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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

View File

@ -1,10 +1,9 @@
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';
// Ensure associations are initialized by importing models index
import { Form16aSubmission } from '@models/Form16aSubmission';
import '@models/index';
import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.types';
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
@ -20,6 +19,7 @@ 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; 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; financialYear?: string; quarter?: 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,6 +677,20 @@ 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({
@ -890,6 +904,59 @@ 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: {
@ -1128,6 +1195,10 @@ 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;
@ -1444,6 +1515,8 @@ export class WorkflowService {
status?: string;
priority?: string;
templateType?: string;
financialYear?: string;
quarter?: string;
department?: string;
initiator?: string;
approver?: string;
@ -1482,11 +1555,24 @@ export class WorkflowService {
});
const participantRequestIds = participants.map((p: any) => p.requestId);
// Combine ALL request IDs where user is involved (initiator + approver + spectator)
// [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)
const allRequestIds = Array.from(new Set([
...initiatorRequestIds,
...approverRequestIds,
...participantRequestIds
...participantRequestIds,
...form16RequestIds
]));
// Build where clause with filters
@ -1548,6 +1634,20 @@ 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({
@ -1947,7 +2047,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 }, sortBy?: string, sortOrder?: string) {
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) {
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
@ -2009,8 +2109,94 @@ export class WorkflowService {
});
const approvedInitiatorRequestIds = approvedAsInitiator.map((r: any) => r.requestId);
// Combine all request IDs (approver, spectator, and approved as initiator)
const allOpenRequestIds = Array.from(new Set([...allRequestIds, ...approvedInitiatorRequestIds]));
// 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]));
}
// Build base where conditions
const baseConditions: any[] = [];
@ -2204,7 +2390,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 }, sortBy?: string, sortOrder?: string) {
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) {
const offset = (page - 1) * limit;
// Get requests where user participated as approver
@ -2398,8 +2584,59 @@ export class WorkflowService {
whereConditions.push(initiatorCondition);
// Build where clause with OR conditions
const where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {};
// 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 order clause based on sortBy parameter
let order: any[] = [['createdAt', 'DESC']]; // Default order
@ -2675,6 +2912,16 @@ 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,
@ -3104,7 +3351,185 @@ export class WorkflowService {
sla: overallSLA || summary.sla
};
return { workflow, approvals: updatedApprovals, participants, documents, activities, summary: updatedSummary, tatAlerts };
// 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 };
} catch (error) {
logger.error(`Failed to get workflow details ${requestId}:`, error);
throw new Error('Failed to get workflow details');

View File

@ -4,7 +4,7 @@ export interface WorkflowRequest {
requestId: string;
requestNumber: string;
initiatorId: string;
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
workflowType?: string;
title: string;
description: string;
@ -24,7 +24,7 @@ export interface WorkflowRequest {
}
export interface CreateWorkflowRequest {
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM';
templateType: 'CUSTOM' | 'TEMPLATE' | 'DEALER CLAIM' | 'FORM_16';
workflowType?: string;
title: string;
description: string;

View File

@ -108,7 +108,8 @@ const transports: winston.transport[] = [
];
// ============ LOKI TRANSPORT (Grafana) ============
if (process.env.LOKI_HOST) {
// 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')) {
try {
const LokiTransport = require('winston-loki');
@ -122,7 +123,7 @@ if (process.env.LOKI_HOST) {
),
replaceTimestamp: true,
onConnectionError: (err: Error) => {
console.error('[Loki] Connection error:', err.message);
if (isProduction) console.error('[Loki] Connection error:', err.message);
},
batching: true,
interval: 5,

2
tests/setup.js Normal file
View File

@ -0,0 +1,2 @@
// Jest setup (optional). Increase timeout for integration tests if needed.
jest.setTimeout(30000);