non-gdty flow modified and docker compose updated

This commit is contained in:
laxmanhalaki 2026-02-26 16:10:15 +05:30
parent aaa249c341
commit 55804671e5
14 changed files with 316 additions and 434 deletions

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,7 @@
<!-- 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-bnlsiWxc.js"></script>
<script type="module" crossorigin src="/assets/index-BCZm9H2Q.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">
@ -21,7 +21,7 @@
<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-tioiXSPh.css">
<link rel="stylesheet" crossorigin href="/assets/index-DjE6S9VF.css">
</head>
<body>

View File

@ -1,39 +1,8 @@
# =============================================================================
# RE Workflow - Full Stack Docker Compose
# Includes: Application + Database + Monitoring Stack
# =============================================================================
# Usage:
# docker-compose -f docker-compose.full.yml up -d
# =============================================================================
# docker-compose.full.yml
# Synced with streamlined infrastructure
version: '3.8'
services:
# ===========================================================================
# APPLICATION SERVICES
# ===========================================================================
postgres:
image: postgres:16-alpine
container_name: re_workflow_db
environment:
POSTGRES_USER: ${DB_USER:-laxman}
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: re_workflow_redis
@ -69,73 +38,6 @@ services:
networks:
- re_workflow_network
backend:
build:
context: .
dockerfile: Dockerfile
container_name: re_workflow_backend
environment:
NODE_ENV: development
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-laxman}
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
CLAMD_HOST: clamav
CLAMD_PORT: 3310
PORT: 5000
# Loki for logging
LOKI_HOST: http://loki:3100
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ===========================================================================
# MONITORING SERVICES
# ===========================================================================
prometheus:
image: prom/prometheus:v2.47.2
container_name: re_prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
loki:
image: grafana/loki:2.9.2
container_name: re_loki
@ -177,15 +79,12 @@ services:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
- GF_USERS_ALLOW_SIGN_UP=false
- GF_FEATURE_TOGGLES_ENABLE=publicDashboards
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource,grafana-piechart-panel
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
- loki
networks:
- re_workflow_network
@ -196,56 +95,13 @@ services:
timeout: 10s
retries: 3
node-exporter:
image: prom/node-exporter:v1.6.1
container_name: re_node_exporter
ports:
- "9100:9100"
networks:
- re_workflow_network
restart: unless-stopped
volumes:
redis_data:
clamav_data:
loki_data:
promtail_data:
grafana_data:
alertmanager:
image: prom/alertmanager:v0.26.0
container_name: re_alertmanager
ports:
- "9093:9093"
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
networks:
- re_workflow_network
restart: unless-stopped
# ===========================================================================
# NETWORKS
# ===========================================================================
networks:
re_workflow_network:
driver: bridge
name: re_workflow_network
# ===========================================================================
# VOLUMES
# ===========================================================================
volumes:
postgres_data:
name: re_postgres_data
redis_data:
name: re_redis_data
clamav_data:
name: re_clamav_data
prometheus_data:
name: re_prometheus_data
loki_data:
name: re_loki_data
promtail_data:
name: re_promtail_data
grafana_data:
name: re_grafana_data
alertmanager_data:
name: re_alertmanager_data

View File

@ -1,28 +1,8 @@
# docker-compose.yml
# Streamlined infrastructure for local development
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: re_workflow_db
environment:
POSTGRES_USER: ${DB_USER:-laxman}
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: re_workflow_redis
@ -58,46 +38,69 @@ services:
networks:
- re_workflow_network
backend:
build:
context: .
dockerfile: Dockerfile
container_name: re_workflow_backend
environment:
NODE_ENV: development
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-laxman}
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
CLAMD_HOST: clamav
CLAMD_PORT: 3310
PORT: 5000
loki:
image: grafana/loki:2.9.2
container_name: re_loki
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
- "3100:3100"
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
- ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 5
promtail:
image: grafana/promtail:2.9.2
container_name: re_promtail
volumes:
- ./monitoring/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- ./logs:/var/log/app:ro
- promtail_data:/tmp/promtail
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
networks:
- re_workflow_network
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.2
container_name: re_grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- loki
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:
redis_data:
clamav_data:
loki_data:
promtail_data:
grafana_data:
networks:
re_workflow_network:

View File

@ -791,31 +791,28 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Invoice record not found', 404);
}
// Automatically regenerate PDF to ensure latest template/data is used (useful during testing/fixes)
// Generate PDF on the fly
try {
const { pdfService } = await import('../services/pdf.service');
await pdfService.generateInvoicePdf(requestId);
// Re-fetch invoice to get the new filePath
invoice = await ClaimInvoice.findOne({ where: { requestId } });
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
const requestNumber = workflow.requestNumber || 'invoice';
const fileName = `Invoice_${requestNumber}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
res.setHeader('Content-Length', pdfBuffer.length);
// Convert Buffer to stream
const { Readable } = await import('stream');
const stream = new Readable();
stream.push(pdfBuffer);
stream.push(null);
stream.pipe(res);
} catch (pdfError) {
logger.error(`[DealerClaimController] Failed to auto-regenerate PDF:`, pdfError);
// Continue with existing file if regeneration fails
logger.error(`[DealerClaimController] Failed to generate PDF:`, pdfError);
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
}
if (!invoice || !invoice.filePath) {
return ResponseHandler.error(res, 'Invoice PDF not found', 404);
}
const filePath = path.join(process.cwd(), 'storage', 'invoices', invoice.filePath);
if (!fs.existsSync(filePath)) {
return ResponseHandler.error(res, 'Invoice file not found on server', 404);
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${invoice.filePath}"`);
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice PDF:', error);
@ -1020,9 +1017,11 @@ export class DealerClaimController {
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
let sapRefNo = '';
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activityType = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activityType?.sapRefNo || '';
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
// Construct CSV
@ -1040,7 +1039,10 @@ export class DealerClaimController {
];
const rows = items.map(item => {
const trnsUniqNo = item.transactionCode || '';
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = claimDetails?.dealerCode || '';
@ -1048,8 +1050,10 @@ export class DealerClaimController {
const claimDocTyp = sapRefNo;
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
const gstPercentag = item.gstRt;
// Zero out tax for Non-GST
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
return [
trnsUniqNo,

View File

@ -168,6 +168,11 @@ async function performScan(
console.log(`[MalwareScan] ⚠️ File validation warnings for "${file.originalname}": ${validation.warnings.join('; ')}`);
}
// 🟢 SANITIZATION: Update originalname with the sanitized version
if (validation.sanitizedFilename) {
file.originalname = validation.sanitizedFilename;
}
// Check if we have a buffer (memory storage) or path (disk storage)
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);
@ -326,6 +331,11 @@ async function performMultiScan(
return;
}
// 🟢 SANITIZATION: Update originalname with the sanitized version for each file in the array
if (validation.sanitizedFilename) {
file.originalname = validation.sanitizedFilename;
}
// Write to temp if memory storage
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);

View File

@ -1084,16 +1084,45 @@ export class DealerClaimService {
if (claimDetails) {
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
// Add default GST rate from ActivityType
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
if (activity) {
serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18;
serializedClaimDetails.taxationType = activity.taxationType || null;
} else {
serializedClaimDetails.defaultGstRate = 18; // Fallback
serializedClaimDetails.taxationType = null;
// Fetch default GST rate and taxation type from ActivityType table
try {
const activityTypeTitle = (claimDetails.activityType || '').trim();
logger.info(`[DealerClaimService] Resolving taxationType for activity: "${activityTypeTitle}"`);
let activity = await ActivityType.findOne({
where: { title: activityTypeTitle }
});
// Fallback 1: Try normalized title (handling en-dash vs hyphen)
if (!activity && activityTypeTitle) {
const normalizedTitle = activityTypeTitle.replace(//g, '-');
if (normalizedTitle !== activityTypeTitle) {
activity = await ActivityType.findOne({
where: { title: normalizedTitle }
});
}
}
// Fallback 2: Handle cases where activity is found but taxationType is missing, or activity not found
if (activity && activity.taxationType) {
serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18;
serializedClaimDetails.taxationType = activity.taxationType;
logger.info(`[DealerClaimService] Resolved from ActivityType record: ${activity.taxationType}`);
} else {
// Infer from title if record is missing or incomplete
const isNonGst = activityTypeTitle.toLowerCase().includes('non');
serializedClaimDetails.taxationType = isNonGst ? 'Non GST' : 'GST';
serializedClaimDetails.defaultGstRate = isNonGst ? 0 : (activity ? (Number(activity.gstRate) || 18) : 18);
logger.info(`[DealerClaimService] Inferred taxationType from title: ${serializedClaimDetails.taxationType} (Activity record ${activity ? 'found but missing taxationType' : 'not found'})`);
}
} catch (error) {
logger.warn(`[DealerClaimService] Error fetching activity type for ${claimDetails.activityType}:`, error);
serializedClaimDetails.defaultGstRate = 18;
serializedClaimDetails.taxationType = 'GST'; // Safe default
}
// Fetch dealer GSTIN from dealers table
try {
const dealer = await Dealer.findOne({
@ -1303,47 +1332,38 @@ export class DealerClaimService {
throw new Error('Failed to get proposal ID after saving proposal details');
}
// Save cost items to separate table (preferred approach)
if (proposalData.costBreakup && proposalData.costBreakup.length > 0) {
// Validate HSN/SAC codes before saving
for (const item of proposalData.costBreakup) {
// Pass item.isService explicitly; if undefined/null, validation will use default HSN/SAC logic
this.validateHSNSAC(item.hsnCode, item.isService);
}
// Clear existing cost items for this proposal (in case of update)
await DealerProposalCostItem.destroy({
where: { proposalId }
});
// Delete existing cost items for this proposal (in case of update)
await DealerProposalCostItem.destroy({
where: { proposalId }
});
// Insert new cost items
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
proposalId,
requestId,
itemDescription: item.description || item.itemDescription || '',
amount: Number(item.amount) || 0,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
isService: !!item.isService,
itemOrder: index
}));
// Insert new cost items
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
proposalId,
requestId,
itemDescription: item.description || item.itemDescription || '',
amount: Number(item.amount) || 0,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
isService: !!item.isService,
itemOrder: index
}));
await DealerProposalCostItem.bulkCreate(costItems);
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
}
await DealerProposalCostItem.bulkCreate(costItems);
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
// Update budget tracking with proposal estimate
await ClaimBudgetTracking.upsert({
@ -1482,9 +1502,12 @@ export class DealerClaimService {
const completionId = (completionDetails as any)?.completionId;
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
// Validate HSN/SAC codes before saving
for (const item of completionData.closedExpenses) {
this.validateHSNSAC(item.hsnCode, !!item.isService);
// Determine taxation type for fallback logic
let isNonGst = false;
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
}
// Clear existing expenses for this request to avoid duplicates
@ -1497,8 +1520,8 @@ export class DealerClaimService {
// Use provided tax details or calculate if missing/zero
let gstRate = Number(item.gstRate);
if (isNaN(gstRate) || gstRate === 0) {
// Fallback to activity GST rate if available
gstRate = 18; // Default fallback
// Fallback to activity GST rate ONLY for GST claims
gstRate = isNonGst ? 0 : 18;
}
const hasUtgst = (Number(item.utgstRate) > 0 || Number(item.utgstAmt) > 0);
@ -3533,44 +3556,5 @@ export class DealerClaimService {
return plain;
});
}
/**
* Validates HSN or SAC code based on GST rules
* @param hsnCode HSN/SAC code string
* @param isService Boolean indicating if it's a Service (SAC) or Goods (HSN).
* If undefined, it will attempt to infer from the code (internal safety).
*/
private validateHSNSAC(hsnCode: string | undefined, isService?: boolean): void {
if (!hsnCode) return;
const cleanCode = String(hsnCode).trim();
if (!cleanCode) return;
if (!/^\d+$/.test(cleanCode)) {
throw new Error(`Invalid HSN/SAC code: ${hsnCode}. Code must contain only digits.`);
}
// Infer isService if not provided (safety fallback)
const effectiveIsService = isService !== undefined ? !!isService : cleanCode.startsWith('99');
if (effectiveIsService) {
// SAC (Services Accounting Code)
if (!cleanCode.startsWith('99')) {
throw new Error(`Invalid SAC code: ${hsnCode}. Service codes must start with 99.`);
}
if (cleanCode.length !== 6) {
throw new Error(`Invalid SAC code: ${hsnCode}. SAC codes must be exactly 6 digits.`);
}
} else {
// HSN (Harmonized System of Nomenclature) for Goods
// Valid lengths in India: 4, 6, 8
const validHSNLengths = [4, 6, 8];
if (!validHSNLengths.includes(cleanCode.length)) {
throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes must be 4, 6, or 8 digits.`);
}
if (cleanCode.startsWith('99')) {
throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes should not start with 99 (reserved for SAC).`);
}
}
}
}

View File

@ -96,6 +96,11 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
pattern: /\.\w+\.(exe|bat|cmd|com|msi|scr|pif|vbs|vbe|js|jse|wsf|wsh|ps1|sh|bash)$/i,
reason: 'Suspicious double extension'
},
// XSS Patterns in filenames
{
pattern: /<script|javascript:|onerror=|onload=|onclick=|alert\(|eval\(|document\./i,
reason: 'Potential XSS payload in filename'
},
];
@ -218,8 +223,10 @@ export function sanitizeFilename(original: string): string {
let name = original.replace(/^.*[\\\/]/, '');
// Remove null bytes
name = name.replace(/\x00/g, '');
// Replace dangerous characters
// Replace dangerous characters including XSS-prone characters
name = name.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_');
// More aggressive XSS sanitization (replace suspicious keywords)
name = name.replace(/(onerror|onload|onclick|onmouseover|onfocus|alert|eval|javascript|vbscript|script|expression|document)/gi, 'safe');
// Collapse multiple dots
name = name.replace(/\.{2,}/g, '.');
// Trim leading/trailing dots and spaces

View File

@ -5,6 +5,7 @@ import { DealerClaimDetails } from '@models/DealerClaimDetails';
import { DealerProposalDetails } from '@models/DealerProposalDetails';
import { DealerCompletionExpense } from '@models/DealerCompletionExpense';
import { ClaimInvoiceItem } from '@models/ClaimInvoiceItem';
import { ActivityType } from '@models/ActivityType';
import { amountToWords } from '@utils/currencyUtils';
import { findDealerLocally } from './dealer.service';
import path from 'path';
@ -13,15 +14,9 @@ import logger from '@utils/logger';
import dayjs from 'dayjs';
export class PdfService {
private storagePath = path.join(process.cwd(), 'storage', 'invoices');
constructor() { }
constructor() {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
}
async generateInvoicePdf(requestId: string): Promise<string> {
async generateInvoicePdf(requestId: string): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
@ -44,6 +39,13 @@ export class PdfService {
});
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
// Resolve taxationType
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails?.activityType } });
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
if (!request || !invoice) {
throw new Error('Request or Invoice not found');
}
@ -55,24 +57,19 @@ export class PdfService {
proposalDetails,
completionExpenses,
invoiceItems,
dealer
dealer,
taxationType
});
await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
const fileName = `invoice_${requestId}_${Date.now()}.pdf`;
const filePath = path.join(this.storagePath, fileName);
await page.pdf({
path: filePath,
const pdfBuffer = Buffer.from(await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
});
}));
await invoice.update({ filePath: fileName });
return fileName;
return pdfBuffer;
} catch (error) {
logger.error(`[PdfService] Error generating PDF for request ${requestId}:`, error);
throw error;
@ -82,9 +79,10 @@ export class PdfService {
}
private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [] } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [], taxationType } = data;
const qrImage = invoice.qrImage ? `data:image/png,base64,${invoice.qrImage}` : '';
const logoUrl = `{{LOGO_URL}}`;
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
let tableRows = '';
@ -94,18 +92,20 @@ export class PdfService {
<tr>
<td>${item.slNo}</td>
<td>${item.description}</td>
<td>${item.hsnCd}</td>
${!isNonGst ? `<td>${item.hsnCd}</td>` : ''}
<td>${Number(item.assAmt).toFixed(2)}</td>
<td>0.00</td>
<td>${item.unit}</td>
<td>${Number(item.assAmt).toFixed(2)}</td>
${!isNonGst ? `<td>${Number(item.assAmt).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstAmt) > 0 ? Number(item.gstRt).toFixed(2) : '0.00'}</td>
<td>${Number(item.igstAmt).toFixed(2)}</td>
<td>${Number(item.cgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.cgstAmt).toFixed(2)}</td>
<td>${Number(item.sgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.sgstAmt).toFixed(2)}</td>
<td>${Number(item.totItemVal).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.assAmt : item.totItemVal).toFixed(2)}</td>
</tr>
`).join('');
} else if (completionExpenses.length > 0) {
@ -114,7 +114,7 @@ export class PdfService {
completionExpenses.forEach((item: any) => {
const hsn = item.hsnCode || 'N/A';
const rate = Number(item.gstRate || 0);
const key = `${hsn}_${rate}`;
const key = isNonGst ? 'NON_GST' : `${hsn}_${rate}`;
if (!grouped[key]) {
grouped[key] = {
@ -143,18 +143,20 @@ export class PdfService {
<tr>
<td>EXPENSE</td>
<td>${item.description}</td>
<td>${item.hsn}</td>
${!isNonGst ? `<td>${item.hsn}</td>` : ''}
<td>${Number(item.amount).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(item.amount).toFixed(2)}</td>
${!isNonGst ? `<td>${Number(item.amount).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstRate || 0).toFixed(2)}</td>
<td>${Number(item.igstAmt || 0).toFixed(2)}</td>
<td>${Number(item.cgstRate || 0).toFixed(2)}</td>
<td>${Number(item.cgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.sgstRate || 0).toFixed(2)}</td>
<td>${Number(item.sgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.totalAmt || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.amount : item.totalAmt).toFixed(2)}</td>
</tr>
`).join('');
} else {
@ -162,18 +164,20 @@ export class PdfService {
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
<td>998881</td>
${!isNonGst ? '<td>998881</td>' : ''}
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
${!isNonGst ? `<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${invoice.igstTotal > 0 ? (Number(invoice.igstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.igstTotal || 0).toFixed(2)}</td>
<td>${invoice.cgstTotal > 0 ? (Number(invoice.cgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.cgstTotal || 0).toFixed(2)}</td>
<td>${invoice.sgstTotal > 0 ? (Number(invoice.sgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.sgstTotal || 0).toFixed(2)}</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? (invoice.taxableValue || invoice.amount) : invoice.amount || 0).toFixed(2)}</td>
</tr>
`;
}
@ -185,24 +189,34 @@ export class PdfService {
if (invoiceItems && invoiceItems.length > 0) {
invoiceItems.forEach((item: any) => {
totalTaxable += Number(item.assAmt || 0);
totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
grandTotal += Number(item.totItemVal || 0);
if (!isNonGst) {
totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
grandTotal += Number(item.totItemVal || 0);
} else {
grandTotal += Number(item.assAmt || 0);
}
});
} else if (completionExpenses.length > 0) {
// Fallback for completion expenses calculation
completionExpenses.forEach((item: any) => {
const qty = Number(item.quantity || 1);
const baseAmt = Number(item.amount || 0) * qty;
const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
totalTaxable += baseAmt;
totalTax += taxAmt;
grandTotal += (baseAmt + taxAmt);
if (!isNonGst) {
const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
totalTax += taxAmt;
grandTotal += (baseAmt + taxAmt);
} else {
grandTotal += baseAmt;
}
});
} else {
// Fallback for invoice record
totalTaxable = Number(invoice.taxableValue || invoice.amount || 0);
totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0);
grandTotal = Number(invoice.amount || 0);
if (!isNonGst) {
totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0);
grandTotal = Number(invoice.amount || 0);
} else {
grandTotal = totalTaxable;
}
}
const totalValueInWords = amountToWords(grandTotal);
@ -249,7 +263,7 @@ export class PdfService {
${qrImage ? `<img src="${qrImage}" class="qr-code" />` : ''}
</div>
<div class="title">WARRANTY CLAIM TAX INVOICE</div>
<div class="title">${isNonGst ? 'CLAIM INVOICE' : 'CLAIM TAX INVOICE'}</div>
<div class="info-grid">
<div class="info-section">
@ -275,17 +289,19 @@ export class PdfService {
<tr>
<th>Part</th>
<th>Description</th>
<th>HSN/SAC</th>
<th>Base Amount</th>
${!isNonGst ? '<th>HSN/SAC</th>' : ''}
<th>${isNonGst ? 'Amount' : 'Base Amount'}</th>
<th>Disc</th>
<th>UOM</th>
<th>Taxable Value</th>
${!isNonGst ? '<th>Taxable Value</th>' : ''}
${!isNonGst ? `
<th>IGST %</th>
<th>IGST</th>
<th>CGST %</th>
<th>CGST</th>
<th>SGST %</th>
<th>SGST</th>
` : ''}
<th>Total</th>
</tr>
</thead>
@ -295,14 +311,14 @@ export class PdfService {
</table>
<div class="totals">
<div class="totals-row"><span>TAXABLE TOTAL:</span><span>${totalTaxable.toFixed(2)}</span></div>
<div class="totals-row"><span>TOTAL TAX:</span><span>${totalTax.toFixed(2)}</span></div>
<div class="totals-row"><span>${isNonGst ? 'TOTAL AMOUNT' : 'TAXABLE TOTAL'}:</span><span>${totalTaxable.toFixed(2)}</span></div>
${!isNonGst ? `<div class="totals-row"><span>TOTAL TAX:</span><span>${totalTax.toFixed(2)}</span></div>` : ''}
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${grandTotal.toFixed(2)}</span></div>
</div>
<div class="words">
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${totalValueInWords}</div>
<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>
${!isNonGst ? `<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>` : ''}
</div>
<div class="footer">

View File

@ -54,8 +54,10 @@ export const userIdParamsSchema = z.object({
export const createActivityTypeSchema = z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title too long'),
itemCode: z.string().max(50, 'Item code too long').optional(),
taxationType: z.string().max(50, 'Taxation type too long').optional(),
sapRefNo: z.string().max(50, 'SAP ref number too long').optional(),
taxationType: z.enum(['GST', 'Non GST'], {
errorMap: () => ({ message: 'Taxation type must be GST or Non GST' }),
}),
sapRefNo: z.string().min(1, 'SAP ref number (Claim Document Type) is required').max(50, 'SAP ref number too long'),
});
export const updateActivityTypeSchema = createActivityTypeSchema.partial();