Merge origin/laxman_dev into laxman_dev.

Accept pulled backend updates and keep existing resolved functionality.

Made-with: Cursor
This commit is contained in:
Aaditya Jaiswal 2026-03-27 18:12:40 +05:30
commit 3c1c743df6
41 changed files with 1560 additions and 300 deletions

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -138,4 +138,4 @@ SAP_DISABLE_SSL_VERIFY=false
# WFM Archive configuration examples (if overrides are needed)
# WFM_ARCHIVE_GST_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\DLR_INC_CLAIMS_GST
# WFM_FORM16_CREDIT_ARCHIVE_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\FORM16_CRDT
#CREDIT_NOTE_SYNC_INTERVAL_MINUTES=1

View File

@ -23,7 +23,8 @@ export class AuthController {
// Validate request body
const validatedData = validateSSOCallback(req.body);
const result = await this.authService.handleSSOCallback(validatedData as any);
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
const result = await this.authService.handleSSOCallback(validatedData as any, userAgent);
// Log login activity
const requestMeta = getRequestMetadata(req);
@ -180,7 +181,8 @@ export class AuthController {
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Tanflow token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri);
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri, userAgent);
// Log login activity
const requestMeta = getRequestMetadata(req);
@ -395,6 +397,13 @@ export class AuthController {
// Clear all cookies using multiple methods
clearCookiesCompletely();
if (userId !== 'unknown') {
const user = await this.authService.getUserProfile(userId);
if (user) {
await this.authService.updateUserProfile(userId, { sessionToken: null, lastLoginDevice: null });
}
}
logger.info('User logout successful - cookies cleared', {
userId: req.user?.userId || 'unknown',
email: req.user?.email || 'unknown',
@ -452,7 +461,8 @@ export class AuthController {
const { username, password } = validatePasswordLogin(req.body);
const result = await this.authService.authenticateWithPassword(username, password);
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
const result = await this.authService.authenticateWithPassword(username, password, userAgent);
// Log login activity
const requestMeta = getRequestMetadata(req);
@ -535,7 +545,8 @@ export class AuthController {
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeCodeForTokens(code, redirectUri);
const userAgent = req.headers['user-agent'] || getRequestMetadata(req).userAgent;
const result = await this.authService.exchangeCodeForTokens(code, redirectUri, userAgent);
// Log login activity
const requestMeta = getRequestMetadata(req);

View File

@ -15,10 +15,12 @@ import crypto from 'crypto';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
import { ActivityType } from '../models/ActivityType';
import { Participant } from '../models/Participant';
import { sanitizeObject, sanitizePermissive } from '../utils/sanitizer';
import { padDealerCode } from '../utils/helpers';
import { buildWfmClaimCsvRow, padDealerCode, WFM_CLAIM_CSV_HEADERS } from '../utils/helpers';
import { costBreakupSchema, closedExpensesSchema, updateEInvoiceSchema, updateIOSchema } from '../validators/dealerClaim.validator';
export class DealerClaimController {
@ -1114,52 +1116,49 @@ export class DealerClaimController {
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// Construct CSV with pipe separator
const headers = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_TYPE',
'CLAIM_DATE',
'CLAIM_AMT'
];
if (!isNonGst) {
headers.push('GST_AMT', 'GST_PERCENTAGE');
}
const headers = isNonGst
? [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_TYPE',
'CLAIM_DATE',
'CLAIM_AMT'
]
: [...WFM_CLAIM_CSV_HEADERS];
const rows = items.map(item => {
// For Non-GST, we hide HSN (often stored in transactionCode)
const trnsUniqNo = item.transactionCode || '';
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = padDealerCode(claimDetails?.dealerCode || '');
const ioNumber = internalOrder?.ioNumber || '';
const claimDocTyp = sapRefNo;
const claimType = claimDetails?.activityType || '';
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
const rowItems = [
trnsUniqNo,
claimNumber,
invNumber,
dealerCode,
ioNumber,
claimDocTyp,
claimType,
claimDate,
claimAmt
];
if (!isNonGst) {
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
rowItems.push(totalTax.toFixed(2), item.gstRt || 0);
if (isNonGst) {
const d = new Date(invoice?.invoiceDate || invoice?.createdAt || new Date());
const claimDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
return [
item.transactionCode || '',
requestNumber,
invoice?.invoiceNumber || '',
padDealerCode(claimDetails?.dealerCode || ''),
internalOrder?.ioNumber || '',
sapRefNo,
claimDetails?.activityType || '',
claimDate,
item.assAmt
].join('|');
}
return rowItems.join('|');
const row = buildWfmClaimCsvRow({
item: item as any,
requestNumber,
invoiceNumber: invoice?.invoiceNumber || '',
invoiceDate: (invoice?.invoiceDate as Date) || (invoice?.createdAt as Date) || new Date(),
dealerCode: claimDetails?.dealerCode || '',
ioNumber: internalOrder?.ioNumber || '',
claimDocTyp: sapRefNo,
claimType: claimDetails?.activityType || '',
});
return headers.map((key) => String((row as any)[key] ?? '')).join('|');
});
const csvContent = [headers.join('|'), ...rows].join('\n');
@ -1232,9 +1231,67 @@ export class DealerClaimController {
}
const { wfmFileService } = await import('../services/wfmFile.service');
const creditNoteData = await wfmFileService.getCreditNoteDetails(claimDetails.dealerCode, requestNumber, isNonGst);
const existingCreditNote = await ClaimCreditNote.findOne({
where: { requestId },
include: [{
model: ClaimCreditNoteItem,
as: 'items',
attributes: ['transactionNo'],
order: [['slNo', 'ASC']]
}]
}) as any;
return ResponseHandler.success(res, creditNoteData, 'Credit note data fetched successfully');
if (existingCreditNote?.sapDocumentNumber || existingCreditNote?.creditNoteNumber) {
let displayTxn = existingCreditNote.transactionNo || '';
const items = existingCreditNote.items || [];
if (items.length > 1) {
const first = items[0].transactionNo;
const last = items[items.length - 1].transactionNo;
if (first && last && first !== last) {
displayTxn = `${first} - ${last}`;
}
}
const payload = [{
TRNS_UNIQ_NO: displayTxn,
CLAIM_NUMBER: requestNumber,
DOC_NO: existingCreditNote.sapDocumentNumber || existingCreditNote.creditNoteNumber || '',
MSG_TYP: existingCreditNote.status || '',
MESSAGE: existingCreditNote.errorMessage || ''
}];
return ResponseHandler.success(res, payload, 'Credit note data fetched successfully');
}
const { filePath, data: creditNoteData } = await wfmFileService.getCreditNoteDetailsWithPath(
claimDetails.dealerCode,
requestNumber,
isNonGst
);
if (!creditNoteData.length) {
return ResponseHandler.success(res, [], 'Credit note data fetched successfully');
}
// Process the file using the sync service (handles grouping and transactions)
const { creditNoteSyncService } = await import('../services/creditNoteSync.service');
await creditNoteSyncService.processFile(filePath);
// Return unified row with range if multiple rows exist for this claim
const claimRows = creditNoteData.filter(row => row.CLAIM_NUMBER === requestNumber);
if (claimRows.length === 0) {
return ResponseHandler.success(res, [], 'Credit note data fetched successfully');
}
const claimRow = { ...claimRows[0] };
if (claimRows.length > 1) {
const first = claimRows[0].TRNS_UNIQ_NO;
const last = claimRows[claimRows.length - 1].TRNS_UNIQ_NO;
if (first && last && first !== last) {
claimRow.TRNS_UNIQ_NO = `${first} - ${last}`;
}
}
return ResponseHandler.success(res, [claimRow], 'Credit note data fetched successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error fetching credit note WFM data:', error);

View File

@ -0,0 +1,133 @@
import { Request, Response } from 'express';
import { hsnSacCodeService } from '../services/hsnSacCode.service';
import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
export class HsnSacCodeController {
/**
* Get HSN/SAC codes with pagination and search
*/
async getAllCodes(req: Request, res: Response): Promise<void> {
try {
const onlyActive = req.query.active === 'true';
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const search = req.query.search as string;
const result = await hsnSacCodeService.getAllCodes(onlyActive, page, limit, search);
ResponseHandler.success(
res,
result.codes,
'HSN/SAC codes fetched successfully',
200,
result.pagination
);
} catch (error: any) {
logger.error('Error in getAllCodes controller:', error);
ResponseHandler.error(res, 'Failed to fetch HSN/SAC codes', 500, error.message);
}
}
/**
* Get code by ID
*/
async getCodeById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const code = await hsnSacCodeService.getCodeById(id);
if (!code) {
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
}
ResponseHandler.success(res, code, 'HSN/SAC code fetched successfully');
} catch (error: any) {
logger.error('Error in getCodeById controller:', error);
ResponseHandler.error(res, 'Failed to fetch HSN/SAC code', 500, error.message);
}
}
/**
* Create new code
*/
async createCode(req: Request, res: Response): Promise<void> {
try {
const { code, type, gstRate, description, isActive } = req.body;
if (!code || !type) {
return ResponseHandler.error(res, 'Code and type are required', 400);
}
const newCode = await hsnSacCodeService.createCode({
code,
type,
gstRate,
description,
isActive: isActive !== undefined ? isActive : true
});
ResponseHandler.success(res, newCode, 'HSN/SAC code created successfully', 201);
} catch (error: any) {
logger.error('Error in createCode controller:', error);
ResponseHandler.error(res, 'Failed to create HSN/SAC code', 500, error.message);
}
}
/**
* Update code
*/
async updateCode(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const updatedCode = await hsnSacCodeService.updateCode(id, req.body);
if (!updatedCode) {
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
}
ResponseHandler.success(res, updatedCode, 'HSN/SAC code updated successfully');
} catch (error: any) {
logger.error('Error in updateCode controller:', error);
ResponseHandler.error(res, 'Failed to update HSN/SAC code', 500, error.message);
}
}
/**
* Delete code
*/
async deleteCode(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const success = await hsnSacCodeService.deleteCode(id);
if (!success) {
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
}
ResponseHandler.success(res, null, 'HSN/SAC code deleted successfully');
} catch (error: any) {
logger.error('Error in deleteCode controller:', error);
ResponseHandler.error(res, 'Failed to delete HSN/SAC code', 500, error.message);
}
}
/**
* Toggle active status
*/
async toggleActive(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const updatedCode = await hsnSacCodeService.toggleActive(id);
if (!updatedCode) {
return ResponseHandler.error(res, 'HSN/SAC code not found', 404);
}
ResponseHandler.success(res, updatedCode, 'HSN/SAC code status toggled successfully');
} catch (error: any) {
logger.error('Error in toggleActive controller:', error);
ResponseHandler.error(res, 'Failed to toggle HSN/SAC code status', 500, error.message);
}
}
}
export const hsnSacCodeController = new HsnSacCodeController();

View File

@ -0,0 +1,25 @@
import { creditNoteSyncService } from '../services/creditNoteSync.service';
import logger from '../utils/logger';
/**
* Main sync function to process all outgoing files
* Delegates to creditNoteSyncService
*/
export async function syncCreditNotes(): Promise<void> {
await creditNoteSyncService.syncCreditNotes();
}
/**
* Starts the background sync job
*/
export function startCreditNoteSyncJob(): void {
const intervalMinutes = Number(process.env.CREDIT_NOTE_SYNC_INTERVAL_MINUTES) || 5;
logger.info(`[CreditNoteSyncJob] Background job initialized (Interval: ${intervalMinutes}m)`);
// Run once immediately on startup
syncCreditNotes().catch(err => logger.error('[CreditNoteSyncJob] Initial sync failed:', err));
setInterval(() => {
syncCreditNotes().catch(err => logger.error('[CreditNoteSyncJob] Periodic sync failed:', err));
}, intervalMinutes * 60 * 1000);
}

View File

@ -12,6 +12,7 @@ interface JwtPayload {
employeeId: string;
email: string;
role: string;
sessionToken: string;
iat: number;
exp: number;
}
@ -70,6 +71,15 @@ export const authenticateToken = async (
return;
}
if (!decoded.sessionToken || decoded.sessionToken !== user.sessionToken) {
res.status(401).json({
success: false,
errorCode: 'SESSION_SUPERSEDED',
message: `You have been logged out because an active session was detected from ${user.lastLoginDevice || 'another device'}.`
});
return;
}
// Attach user info to request object
req.user = {
userId: user.userId,
@ -117,12 +127,14 @@ export const optionalAuth = async (
const user = await User.findByPk(decoded.userId);
if (user && user.isActive) {
req.user = {
userId: user.userId,
email: user.email,
employeeId: user.employeeId || null, // Optional - schema not finalized
role: user.role // Keep uppercase: USER, MANAGEMENT, ADMIN
};
if (!decoded.sessionToken || decoded.sessionToken === user.sessionToken) {
req.user = {
userId: user.userId,
email: user.email,
employeeId: user.employeeId || null, // Optional - schema not finalized
role: user.role // Keep uppercase: USER, MANAGEMENT, ADMIN
};
}
}
}

View File

@ -0,0 +1,84 @@
import { QueryInterface, DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
// 1. Add sessionToken to users table
await queryInterface.addColumn('users', 'sessionToken', {
type: DataTypes.UUID,
allowNull: true,
comment: 'Unique token for active session to restrict concurrent logins'
});
// 2. Add lastLoginDevice to users table
await queryInterface.addColumn('users', 'lastLoginDevice', {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Browser/Device string from User-Agent of the active session'
});
// 3. Create hsn_sac_codes table
await queryInterface.createTable('hsn_sac_codes', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
code: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'The HSN or SAC code value'
},
type: {
type: DataTypes.ENUM('HSN', 'SAC'),
allowNull: false,
comment: 'Type of code: either HSN or SAC'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'gst_rate',
comment: 'Associated GST rate percentage'
},
description: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Description of the code'
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
allowNull: false,
field: 'is_active'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at'
}
});
// Add indexes to hsn_sac_codes
await queryInterface.addIndex('hsn_sac_codes', ['code']);
await queryInterface.addIndex('hsn_sac_codes', ['type']);
await queryInterface.addIndex('hsn_sac_codes', ['is_active']);
},
down: async (queryInterface: QueryInterface) => {
// Drop hsn_sac_codes table
await queryInterface.dropTable('hsn_sac_codes');
// Remove columns from users table
await queryInterface.removeColumn('users', 'lastLoginDevice');
await queryInterface.removeColumn('users', 'sessionToken');
// Also drop the ENUM type created for hsn_sac_codes type
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_hsn_sac_codes_type";');
}
};

View File

@ -0,0 +1,166 @@
import { QueryInterface, DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
// 1. Update claim_credit_notes table with idempotency checks
const tableDefinition = await queryInterface.describeTable('claim_credit_notes');
if (!tableDefinition.transaction_no) {
await queryInterface.addColumn('claim_credit_notes', 'transaction_no', {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Overall PWC transaction unique number'
});
}
if (!tableDefinition.tds_amount) {
await queryInterface.addColumn('claim_credit_notes', 'tds_amount', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
comment: 'TDS amount for the credit note'
});
}
if (!tableDefinition.credit_amount) {
await queryInterface.addColumn('claim_credit_notes', 'credit_amount', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
comment: 'Final credit amount after TDS'
});
}
// Drop redundant columns if they exist
const columnsToDrop = [
'gst_rate', 'gst_amt', 'cgst_rate', 'cgst_amt',
'sgst_rate', 'sgst_amt', 'igst_rate', 'igst_amt',
'utgst_rate', 'utgst_amt', 'cess_rate', 'cess_amt',
'total_amt'
];
for (const column of columnsToDrop) {
if (tableDefinition[column]) {
await queryInterface.removeColumn('claim_credit_notes', column);
}
}
// 2. Create claim_credit_note_items table (Refined & Unified)
const allTables = await queryInterface.showAllTables();
const tableExists = allTables.some(t => {
const name = typeof t === 'string' ? t : (t as any).tableName;
return name.toLowerCase() === 'claim_credit_note_items';
});
if (!tableExists) {
await queryInterface.createTable('claim_credit_note_items', {
item_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
credit_note_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'claim_credit_notes',
key: 'credit_note_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
sl_no: {
type: DataTypes.INTEGER,
allowNull: false,
},
transaction_no: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Per-item TRNS_UNIQ_NO'
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
hsn_cd: {
type: DataTypes.STRING(20),
allowNull: true,
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
defaultValue: 0,
},
claim_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
},
tds_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
},
credit_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
}
});
// Add index for performance
await queryInterface.addIndex('claim_credit_note_items', ['credit_note_id']);
await queryInterface.addIndex('claim_credit_note_items', ['transaction_no']);
}
},
down: async (queryInterface: QueryInterface) => {
// Drop the items table first
await queryInterface.dropTable('claim_credit_note_items');
// Re-add dropped columns to claim_credit_notes (if they were removed)
const tableDefinition = await queryInterface.describeTable('claim_credit_notes');
const columnsToReAdd = [
{ name: 'gst_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'gst_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'cgst_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'cgst_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'sgst_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'sgst_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'igst_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'igst_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'utgst_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'utgst_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'cess_rate', type: DataTypes.DECIMAL(5, 2) },
{ name: 'cess_amt', type: DataTypes.DECIMAL(15, 2) },
{ name: 'total_amt', type: DataTypes.DECIMAL(15, 2) }
];
for (const col of columnsToReAdd) {
if (!tableDefinition[col.name]) {
await queryInterface.addColumn('claim_credit_notes', col.name, {
type: col.type,
allowNull: true
});
}
}
// Remove added columns
const addedCols = ['credit_amount', 'tds_amount', 'transaction_no'];
for (const col of addedCols) {
if (tableDefinition[col]) {
await queryInterface.removeColumn('claim_credit_notes', col);
}
}
}
};

View File

@ -10,19 +10,9 @@ interface ClaimCreditNoteAttributes {
creditNoteNumber?: string;
creditNoteDate?: Date;
creditNoteAmount: number;
gstRate?: number;
gstAmt?: number;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
sgstAmt?: number;
igstRate?: number;
igstAmt?: number;
utgstRate?: number;
utgstAmt?: number;
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
transactionNo?: string;
tdsAmount?: number;
creditAmount?: number;
sapDocumentNumber?: string;
creditNoteFilePath?: string;
status?: string;
@ -35,7 +25,7 @@ interface ClaimCreditNoteAttributes {
updatedAt: Date;
}
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'transactionNo' | 'tdsAmount' | 'creditAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string;
@ -44,19 +34,9 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
public creditNoteNumber?: string;
public creditNoteDate?: Date;
public creditNoteAmount!: number;
public gstRate?: number;
public gstAmt?: number;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
public sgstAmt?: number;
public igstRate?: number;
public igstAmt?: number;
public utgstRate?: number;
public utgstAmt?: number;
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public transactionNo?: string;
public tdsAmount?: number;
public creditAmount?: number;
public sapDocumentNumber?: string;
public creditNoteFilePath?: string;
public status?: string;
@ -115,70 +95,22 @@ ClaimCreditNote.init(
allowNull: false,
field: 'credit_amount'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
transactionNo: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'gst_rate'
field: 'transaction_no',
},
gstAmt: {
tdsAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'gst_amt'
defaultValue: 0,
field: 'tds_amount',
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cgst_rate'
},
cgstAmt: {
creditAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cgst_amt'
},
sgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'sgst_rate'
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'sgst_amt'
},
igstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'igst_rate'
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'igst_amt'
},
utgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'utgst_rate'
},
utgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'utgst_amt'
},
cessRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cess_rate'
},
cessAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cess_amt'
},
totalAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_amt'
defaultValue: 0,
field: 'credit_amount',
},
sapDocumentNumber: {
type: DataTypes.STRING(100),

View File

@ -0,0 +1,83 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '@config/database';
export class ClaimCreditNoteItem extends Model {
public itemId!: string;
public creditNoteId!: string;
public slNo!: number;
public transactionNo!: string | null;
public description!: string | null;
public hsnCd!: string | null;
public amount!: number;
public claimAmount!: number | null;
public tdsAmount!: number | null;
public creditAmount!: number | null;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
ClaimCreditNoteItem.init(
{
itemId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'item_id',
},
creditNoteId: {
type: DataTypes.UUID,
allowNull: false,
field: 'credit_note_id',
},
slNo: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'sl_no',
},
transactionNo: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'transaction_no',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
field: 'description',
},
hsnCd: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_cd',
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
defaultValue: 0,
field: 'amount',
},
claimAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
field: 'claim_amount',
},
tdsAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
field: 'tds_amount',
},
creditAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
field: 'credit_amount',
},
},
{
sequelize,
tableName: 'claim_credit_note_items',
underscored: true,
timestamps: true,
}
);

95
src/models/HsnSacCode.ts Normal file
View File

@ -0,0 +1,95 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
export type CodeType = 'HSN' | 'SAC';
export interface HsnSacCodeAttributes {
id: string;
code: string;
type: CodeType;
gstRate?: number | null;
description?: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface HsnSacCodeCreationAttributes extends Optional<HsnSacCodeAttributes, 'id' | 'gstRate' | 'description' | 'isActive' | 'createdAt' | 'updatedAt'> { }
class HsnSacCode extends Model<HsnSacCodeAttributes, HsnSacCodeCreationAttributes> implements HsnSacCodeAttributes {
public id!: string;
public code!: string;
public type!: CodeType;
public gstRate?: number | null;
public description?: string | null;
public isActive!: boolean;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
HsnSacCode.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
code: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'The HSN or SAC code value'
},
type: {
type: DataTypes.ENUM('HSN', 'SAC'),
allowNull: false,
comment: 'Type of code: either HSN or SAC'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'gst_rate',
comment: 'Associated GST rate percentage'
},
description: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Description of the code'
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
allowNull: false,
field: 'is_active'
},
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,
modelName: 'HsnSacCode',
tableName: 'hsn_sac_codes',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
fields: ['code']
},
{
fields: ['type']
},
{
fields: ['is_active']
}
]
});
export { HsnSacCode };

View File

@ -51,11 +51,13 @@ interface UserAttributes {
isActive: boolean;
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
lastLogin?: Date;
sessionToken?: string | null;
lastLoginDevice?: string | null;
createdAt: Date;
updatedAt: Date;
}
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'sessionToken' | 'lastLoginDevice' | 'createdAt' | 'updatedAt'> { }
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public userId!: string;
@ -95,6 +97,8 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public isActive!: boolean;
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
public lastLogin?: Date;
public sessionToken?: string | null;
public lastLoginDevice?: string | null;
public createdAt!: Date;
public updatedAt!: Date;
@ -278,6 +282,18 @@ User.init(
allowNull: true,
field: 'last_login'
},
sessionToken: {
type: DataTypes.UUID,
allowNull: true,
field: 'sessionToken',
comment: 'Unique token for active session to restrict concurrent logins'
},
lastLoginDevice: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'lastLoginDevice',
comment: 'Browser/Device string from User-Agent of the active session'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -29,6 +29,7 @@ import { WorkflowTemplate } from './WorkflowTemplate';
import { ClaimInvoice } from './ClaimInvoice';
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
import { ClaimCreditNote } from './ClaimCreditNote';
import { ClaimCreditNoteItem } from './ClaimCreditNoteItem';
import { Form16aSubmission } from './Form16aSubmission';
import { Form16CreditNote } from './Form16CreditNote';
import { Form16DebitNote } from './Form16DebitNote';
@ -179,6 +180,13 @@ const defineAssociations = () => {
sourceKey: 'id'
});
// ClaimCreditNote associations
ClaimCreditNote.hasMany(ClaimCreditNoteItem, {
as: 'items',
foreignKey: 'creditNoteId',
sourceKey: 'creditNoteId'
});
// 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
};
@ -217,6 +225,7 @@ export {
ClaimInvoice,
ClaimInvoiceItem,
ClaimCreditNote,
ClaimCreditNoteItem,
Form16aSubmission,
Form16CreditNote,
Form16DebitNote,

View File

@ -0,0 +1,56 @@
import { Router } from 'express';
import { hsnSacCodeController } from '../controllers/hsnSacCode.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
const router = Router();
// All HSN/SAC routes require authentication
router.use(authenticateToken);
/**
* @route GET /api/v1/hsn-sac
* @desc Get all HSN/SAC codes
* @access Private
*/
router.get('/', (req, res) => hsnSacCodeController.getAllCodes(req, res));
/**
* @route GET /api/v1/hsn-sac/:id
* @desc Get code by ID
* @access Private
*/
router.get('/:id', (req, res) => hsnSacCodeController.getCodeById(req, res));
// Admin only routes for modification
router.use(requireAdmin);
/**
* @route POST /api/v1/hsn-sac
* @desc Create new HSN/SAC code
* @access Private/Admin
*/
router.post('/', (req, res) => hsnSacCodeController.createCode(req, res));
/**
* @route PATCH /api/v1/hsn-sac/:id
* @desc Update HSN/SAC code
* @access Private/Admin
*/
router.patch('/:id', (req, res) => hsnSacCodeController.updateCode(req, res));
/**
* @route DELETE /api/v1/hsn-sac/:id
* @desc Delete HSN/SAC code
* @access Private/Admin
*/
router.delete('/:id', (req, res) => hsnSacCodeController.deleteCode(req, res));
/**
* @route PATCH /api/v1/hsn-sac/:id/toggle-active
* @desc Toggle active status
* @access Private/Admin
*/
router.patch('/:id/toggle-active', (req, res) => hsnSacCodeController.toggleActive(req, res));
export default router;

View File

@ -34,6 +34,7 @@ import apiTokenRoutes from './apiToken.routes';
import antivirusRoutes from './antivirus.routes';
import dealerExternalRoutes from './dealerExternal.routes';
import form16Routes from './form16.routes';
import hsnSacCodeRoutes from './hsnSacCode.routes';
const router = Router();
@ -99,6 +100,7 @@ router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 r
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)
router.use('/hsn-sac', generalApiLimiter, hsnSacCodeRoutes); // 200 req/15min
export default router;

View File

@ -180,6 +180,11 @@ async function runMigrations(): Promise<void> {
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
const m66 = require('../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log');
const m67 = require('../migrations/20260324110001-add-pan-number-to-26as');
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -252,6 +257,11 @@ async function runMigrations(): Promise<void> {
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 },
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
];
// Dynamically import sequelize after secrets are loaded

View File

@ -73,6 +73,8 @@ import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
import * as m66 from '../migrations/20260324090001-refactor-form16-sap-response-and-add-read-log';
import * as m67 from '../migrations/20260324110001-add-pan-number-to-26as';
import * as m68 from '../migrations/20260325090001-ensure-pan-number-in-26as';
import * as m69 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
import * as m70 from '../migrations/20260325175000-update-credit-notes-and-add-items';
interface Migration {
name: string;
@ -153,7 +155,8 @@ const migrations: Migration[] = [
{ name: '20260324090001-refactor-form16-sap-response-and-add-read-log', module: m66 },
{ name: '20260324110001-add-pan-number-to-26as', module: m67 },
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
];
/**

View File

@ -134,6 +134,8 @@ const startServer = async (): Promise<void> => {
startForm16NotificationJobs();
const { startForm16ArchiveJob } = require('./services/form16Archive.service');
startForm16ArchiveJob();
const { startCreditNoteSyncJob } = require('./jobs/creditNoteSyncJob');
startCreditNoteSyncJob();
// Initialize queue metrics collection for Prometheus
initializeQueueMetrics();

View File

@ -9,19 +9,19 @@ import { ActivityType } from '@models/ActivityType';
*/
const DEFAULT_ACTIVITY_TYPES = [
{ title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM', creditPostingOn: 'Spares' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV', creditPostingOn: 'Vehicle' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV', creditPostingOn: 'Vehicles' },
{ title: 'Media Bike Service', itemCode: '3', taxationType: 'GST', sapRefNo: 'ZMBS', creditPostingOn: 'Spares' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML', creditPostingOn: 'Vehicle' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS', creditPostingOn: 'Vehicle' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML', creditPostingOn: 'Vehicles' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS', creditPostingOn: 'Vehicles' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6', taxationType: 'GST', sapRefNo: 'ZPPE', creditPostingOn: 'Spares' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB', creditPostingOn: 'Vehicle' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG', creditPostingOn: 'Vehicle' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM', creditPostingOn: 'Vehicle' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC', creditPostingOn: 'Vehicle' },
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR', creditPostingOn: 'Vehicle' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB', creditPostingOn: 'Vehicles' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG', creditPostingOn: 'Vehicles' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM', creditPostingOn: 'Vehicles' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC', creditPostingOn: 'Vehicles' },
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR', creditPostingOn: 'Vehicles' },
{ title: 'Service Camp Claims', itemCode: '12', taxationType: 'Non GST', sapRefNo: 'ZSCC', creditPostingOn: 'Spares' },
{ title: 'Corporate Claims Institutional Sales PD', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN', creditPostingOn: 'Vehicle' },
{ title: 'Corporate Claims Institutional Sales PD', itemCode: '14', taxationType: 'GST', sapRefNo: 'ZCCG', creditPostingOn: 'Vehicle' }
{ title: 'Corporate Claims Institutional Sales PD', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN', creditPostingOn: 'Vehicles' },
{ title: 'Corporate Claims Institutional Sales PD', itemCode: '14', taxationType: 'GST', sapRefNo: 'ZCCG', creditPostingOn: 'Vehicles' }
];
/**

View File

@ -6,6 +6,27 @@ import { LoginResponse } from '../types/auth.types';
import logger, { logAuthEvent } from '../utils/logger';
import axios from 'axios';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { emitToUser } from '../realtime/socket';
function parseDeviceFromUserAgent(ua?: string): string {
if (!ua) return 'Unknown Device';
let browser = 'Unknown Browser';
if (ua.includes('Firefox/')) browser = 'Firefox';
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
else if (ua.includes('Edg/')) browser = 'Edge';
let os = 'Unknown OS';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
return `${browser} on ${os}`;
}
export class AuthService {
/**
@ -240,7 +261,7 @@ export class AuthService {
* Handle SSO callback from frontend
* Creates new user or updates existing user based on employeeId
*/
async handleSSOCallback(userData: SSOUserData): Promise<LoginResponse> {
async handleSSOCallback(userData: SSOUserData, userAgent?: string): Promise<LoginResponse> {
try {
// Validate required fields - email and oktaSub are required
if (!userData.email || !userData.oktaSub) {
@ -272,11 +293,16 @@ export class AuthService {
displayName = userData.email.split('@')[0] || 'User';
}
const sessionToken = uuidv4();
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
// Prepare update/create data - always include required fields
const userUpdateData: any = {
email: userData.email,
oktaSub: userData.oktaSub,
lastLogin: new Date(),
sessionToken,
lastLoginDevice,
isActive: true,
};
@ -315,6 +341,14 @@ export class AuthService {
action: 'user_updated',
updatedFields: Object.keys(userUpdateData),
});
// Notify previous session via WebSocket to logout immediately
// This provides real-time "Last-In-Wins" enforcement
emitToUser(user.userId, 'SESSION_SUPERSEDED', {
reason: 'CONCURRENT_LOGIN',
device: lastLoginDevice,
timestamp: new Date().toISOString()
});
} else {
// Create new user with required fields (email and oktaSub)
user = await User.create({
@ -335,7 +369,9 @@ export class AuthService {
employeeNumber: userData.employeeNumber || userData.dealerCode || null,
isActive: true,
role: 'USER',
lastLogin: new Date()
lastLogin: new Date(),
sessionToken,
lastLoginDevice
});
logAuthEvent('sso_callback', user.userId, {
@ -390,7 +426,8 @@ export class AuthService {
userId: user.userId,
employeeId: user.employeeId,
email: user.email,
role: user.role // Keep uppercase: USER, MANAGEMENT, ADMIN
role: user.role, // Keep uppercase: USER, MANAGEMENT, ADMIN
sessionToken: user.sessionToken
};
const options: SignOptions = {
@ -410,7 +447,8 @@ export class AuthService {
const payload = {
userId: user.userId,
type: 'refresh'
type: 'refresh',
sessionToken: user.sessionToken
};
const options: SignOptions = {
@ -447,6 +485,10 @@ export class AuthService {
throw new Error('User not found or inactive');
}
if (decoded.sessionToken !== user.sessionToken) {
throw new Error('Session expired due to login from another device');
}
return this.generateAccessToken(user);
} catch (error) {
logAuthEvent('auth_failure', undefined, {
@ -497,7 +539,7 @@ export class AuthService {
* 4. Create/update user in our database if needed
* 5. Return our JWT tokens
*/
async authenticateWithPassword(username: string, password: string): Promise<LoginResponse> {
async authenticateWithPassword(username: string, password: string, userAgent?: 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"
@ -509,6 +551,9 @@ export class AuthService {
const passwordMatch = await bcrypt.compare(password, hash);
if (!passwordMatch) return null;
let user = await User.findOne({ where: { email: adminEmail } });
const sessionToken = uuidv4();
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
if (!user) {
user = await User.create({
email: adminEmail,
@ -521,10 +566,13 @@ export class AuthService {
emailNotificationsEnabled: true,
pushNotificationsEnabled: true,
inAppNotificationsEnabled: true,
sessionToken,
lastLoginDevice,
lastLogin: new Date()
});
logger.info('Demo admin user created on first login', { email: adminEmail });
} else {
await user.update({ lastLogin: new Date() });
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
}
logger.info('Demo admin login successful', { email: adminEmail });
const accessToken = this.generateAccessToken(user);
@ -563,7 +611,7 @@ export class AuthService {
displayName: 'Test Reflow Dealer',
firstName: 'Test',
lastName: 'Reflow',
});
}, userAgent);
};
// Fallback bcrypt hash for "Test@123" when .env hash is corrupted (dev only)
@ -608,7 +656,9 @@ export class AuthService {
logger.warn('Local dealer login by email: user not found', { email });
return null;
}
await user.update({ lastLogin: new Date() });
const sessionToken = uuidv4();
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
logger.info('Local dealer login by email successful', { email });
const accessToken = this.generateAccessToken(user);
const refreshToken = this.generateRefreshToken(user);
@ -735,7 +785,7 @@ export class AuthService {
});
// Step 4: Create/update user in our database
const result = await this.handleSSOCallback(userData);
const result = await this.handleSSOCallback(userData, userAgent);
logger.info('User authenticated successfully via password flow', {
userId: result.user.userId,
@ -791,7 +841,7 @@ export class AuthService {
* This is the FRONTEND callback URL (e.g., http://localhost:3000/login/callback),
* NOT the backend URL. Okta verifies this matches to prevent redirect URI attacks.
*/
async exchangeCodeForTokens(code: string, redirectUri: string): Promise<LoginResponse> {
async exchangeCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise<LoginResponse> {
try {
// Validate configuration
if (!ssoConfig.oktaClientId || ssoConfig.oktaClientId.trim() === '') {
@ -926,7 +976,7 @@ export class AuthService {
});
// Handle SSO callback to create/update user and generate our tokens
const result = await this.handleSSOCallback(userData);
const result = await this.handleSSOCallback(userData, userAgent);
// Return our JWT tokens along with Okta tokens (store Okta refresh token for future use)
return {
@ -970,7 +1020,7 @@ export class AuthService {
* Exchange Tanflow authorization code for tokens
* Similar to Okta flow but uses Tanflow IAM endpoints
*/
async exchangeTanflowCodeForTokens(code: string, redirectUri: string): Promise<LoginResponse> {
async exchangeTanflowCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise<LoginResponse> {
try {
// Validate configuration
if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') {
@ -1138,7 +1188,7 @@ export class AuthService {
});
// Handle SSO callback to create/update user and generate our tokens
const result = await this.handleSSOCallback(userData);
const result = await this.handleSSOCallback(userData, userAgent);
// Return our JWT tokens along with Tanflow tokens
return {

View File

@ -0,0 +1,161 @@
import fs from 'fs';
import { wfmFileService } from './wfmFile.service';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
import { sequelize } from '@config/database';
import logger from '../utils/logger';
export class CreditNoteSyncService {
/**
* Main sync function to process all outgoing files
*/
async syncCreditNotes(): Promise<void> {
try {
const gstFiles = wfmFileService.listOutgoingFiles(false);
const nonGstFiles = wfmFileService.listOutgoingFiles(true);
const allFiles = [
...gstFiles.map(f => ({ path: f, isNonGst: false })),
...nonGstFiles.map(f => ({ path: f, isNonGst: true }))
];
if (allFiles.length === 0) return;
logger.info(`[CreditNoteSyncService] Found ${allFiles.length} files to process`);
for (const fileInfo of allFiles) {
await this.processFile(fileInfo.path);
}
} catch (error) {
logger.error('[CreditNoteSyncService] Error during sync:', error);
}
}
/**
* Process a single CSV file
*/
async processFile(filePath: string): Promise<boolean> {
try {
if (!fs.existsSync(filePath)) return false;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const lines = fileContent.split(/\r?\n/).filter(l => l.trim() !== '');
if (lines.length <= 1) {
// Empty or only headers - delete it
fs.unlinkSync(filePath);
logger.info(`[CreditNoteSyncService] Deleted empty/header-only file: ${filePath}`);
return true;
}
const headers = lines[0].split('|').map(h => h.trim().toUpperCase());
const rows = lines.slice(1).map(line => {
const values = line.split('|');
const row: any = {};
headers.forEach((h, i) => { row[h] = values[i]?.trim() || ''; });
return row;
});
// Group rows by CLAIM_NUMBER
const groups: Record<string, any[]> = {};
rows.forEach(row => {
const claimNum = row.CLAIM_NUMBER;
if (!claimNum) return;
if (!groups[claimNum]) groups[claimNum] = [];
groups[claimNum].push(row);
});
// Process each group
let allProcessed = true;
for (const [claimNumber, rows] of Object.entries(groups)) {
const success = await this.processClaimGroup(claimNumber, rows, filePath);
if (!success) {
allProcessed = false;
logger.warn(`[CreditNoteSyncService] Failed to process claim group ${claimNumber} in file ${filePath}`);
}
}
if (allProcessed && rows.length > 0) {
fs.unlinkSync(filePath);
logger.info(`[CreditNoteSyncService] Successfully processed and deleted file: ${filePath}`);
return true;
}
return false;
} catch (error) {
logger.error(`[CreditNoteSyncService] Error processing file ${filePath}:`, error);
return false;
}
}
private async processClaimGroup(claimNumber: string, rows: any[], filePath: string): Promise<boolean> {
const t = await sequelize.transaction();
try {
// 1. Find the request by requestNumber (which is the CLAIM_NUMBER in CSV)
const request = await WorkflowRequest.findOne({ where: { requestNumber: claimNumber }, transaction: t });
if (!request) {
logger.warn(`[CreditNoteSyncService] WorkflowRequest not found for claim number: ${claimNumber}`);
await t.rollback();
// We return true here because we might still want to delete the file if other claims are processed
// or if this is a filtered/old claim we don't care about.
return true;
}
const requestId = request.requestId;
// 2. Calculate totals
let totalAmount = 0;
let totalTds = 0;
let totalCredit = 0;
rows.forEach(row => {
totalAmount += Number(row.CREDITED_TOTAL_AMT || row.CLAIM_AMT || row.CREDIT_AMT || 0);
totalTds += Number(row.TDS_AMT || 0);
totalCredit += Number(row.CREDITED_TOTAL_AMT || row.CREDIT_AMT || row.FINAL_AMT || 0);
});
const firstRow = rows[0];
// 3. Upsert Header
const [cnHeader] = await ClaimCreditNote.upsert({
requestId,
creditNoteNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || undefined,
sapDocumentNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || undefined,
status: firstRow.MSG_TYP || 'CONFIRMED',
errorMessage: firstRow.MESSAGE || undefined,
creditNoteFilePath: filePath,
creditNoteAmount: totalAmount,
transactionNo: firstRow.TRNS_UNIQ_NO || undefined,
tdsAmount: totalTds,
creditAmount: totalCredit,
confirmedAt: new Date()
}, { transaction: t, returning: true });
// 4. Update Line Items
// Clear existing items
await ClaimCreditNoteItem.destroy({ where: { creditNoteId: cnHeader.creditNoteId }, transaction: t });
// Bulk create new items
const itemsToCreate = rows.map((row, index) => ({
creditNoteId: cnHeader.creditNoteId,
slNo: index + 1,
transactionNo: row.TRNS_UNIQ_NO,
description: row.DESCRIPTION || row.MESSAGE || '',
hsnCd: row.HSN_CODE || row.SAC_CODE || '',
amount: Number(row.CREDITED_TOTAL_AMT || row.FINAL_AMT || row.CREDIT_AMT || 0),
claimAmount: Number(row.CREDITED_TOTAL_AMT || row.CLAIM_AMT || 0),
tdsAmount: Number(row.TDS_AMT || 0),
creditAmount: Number(row.CREDITED_TOTAL_AMT || row.FINAL_AMT || row.CREDIT_AMT || 0)
}));
await ClaimCreditNoteItem.bulkCreate(itemsToCreate, { transaction: t });
await t.commit();
return true;
} catch (error) {
if (t) await t.rollback();
logger.error(`[CreditNoteSyncService] Error processing claim ${claimNumber}:`, error);
return false;
}
}
}
export const creditNoteSyncService = new CreditNoteSyncService();

View File

@ -33,6 +33,7 @@ export interface DealerInfo {
gstin?: string | null;
pincode?: string | null;
itemGroup?: string | null;
parentCode?: string | null;
}
/**
@ -112,6 +113,7 @@ export async function getAllDealers(searchTerm?: string, limit: number = 10): Pr
gstin: dealer.gst || null,
pincode: dealer.showroomPincode || null,
itemGroup: null, // Local dealer table doesn't have item group yet
parentCode: null,
};
});
} catch (error) {
@ -138,9 +140,13 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
}
// 2. Try to find in User table directly (Validation logic)
// One dealer can have multiple codes but they share one parentCode.
// If externalData has a parentcode, we check if there's a user record for that parentcode.
const userLookupCode = externalData?.parentcode || dealerCode;
const user = await User.findOne({
where: {
employeeNumber: dealerCode,
employeeNumber: userLookupCode,
jobTitle: 'Dealer',
isActive: true
},
@ -149,11 +155,12 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
if (user) {
logger.info(`[DealerService] Dealer found in User table: ${dealerCode}`);
logger.info(`[DealerService] Dealer mapping found via code ${userLookupCode}: ${dealerCode}`);
return {
dealerId: user.userId,
userId: user.userId,
email: user.email,
dealerCode: user.employeeNumber || dealerCode,
dealerCode: dealerCode, // Return the originally requested code
dealerName: externalData?.['dealer name'] || user.displayName || '',
displayName: user.displayName || '',
phone: externalData?.['dealer phone'] || (user as any).mobilePhone || user.phone || undefined,
@ -165,6 +172,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
state: externalData?.['re state code'] || null,
pincode: externalData?.pincode || null,
itemGroup: externalData?.['item group'] || null,
parentCode: externalData?.parentcode || null,
};
}
@ -193,6 +201,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
state: externalData['re state code'],
pincode: externalData.pincode,
itemGroup: externalData['item group'] || null,
parentCode: externalData.parentcode || null,
};
}
logger.warn(`[DealerService] Dealer not found in any source: ${dealerCode}`);
@ -222,6 +231,7 @@ export async function getDealerByCode(dealerCode: string): Promise<DealerInfo |
gstin: externalData?.gstin || dealer.gst || null,
pincode: externalData?.pincode || dealer.showroomPincode || null,
itemGroup: externalData?.['item group'] || null,
parentCode: externalData?.parentcode || null,
};
} catch (error) {
logger.error('[DealerService] Error fetching dealer by code:', error);
@ -284,6 +294,7 @@ export async function getDealerByEmail(email: string): Promise<DealerInfo | null
gstin: dealer.gst || null,
pincode: dealer.showroomPincode || null,
itemGroup: null,
parentCode: null,
};
} catch (error) {
logger.error('[DealerService] Error fetching dealer by email:', error);

View File

@ -10,6 +10,7 @@ import { InternalOrder, IOStatus } from '../models/InternalOrder';
import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { ApprovalLevel } from '../models/ApprovalLevel';
@ -21,7 +22,7 @@ import { Document } from '../models/Document';
import { Dealer } from '../models/Dealer';
import { WorkflowService } from './workflow.service';
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
import { generateRequestNumber, padDealerCode } from '../utils/helpers';
import { buildWfmClaimCsvRow, generateRequestNumber, padDealerCode } from '../utils/helpers';
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
import { sapIntegrationService } from './sapIntegration.service';
import { pwcIntegrationService } from './pwcIntegration.service';
@ -125,8 +126,13 @@ export class DealerClaimService {
// 2. Map and validate dealer user
const dealerCode = claimData.dealerCode;
logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`);
const dealerUser = await validateDealerUser(claimData.dealerCode);
logger.info(`[DealerClaimService] Resolving dealer mapping for code: ${dealerCode}`);
// Fetch dealer info (this handles external lookup and resolves parentCode)
const dealerInfo = await findDealerLocally(dealerCode);
const userLookupCode = dealerInfo?.parentCode || dealerCode;
logger.info(`[DealerClaimService] Validating dealer user for lookup code: ${userLookupCode}`);
const dealerUser = await validateDealerUser(userLookupCode);
// Validate Dealer Item Group against Activity Credit Posting
const activityType = await ActivityType.findOne({ where: { title: claimData.activityType } });
@ -1249,9 +1255,10 @@ export class DealerClaimService {
where: { requestId }
});
// Fetch Credit Note details
// Fetch Credit Note details with items
const claimCreditNote = await ClaimCreditNote.findOne({
where: { requestId }
where: { requestId },
include: [{ model: ClaimCreditNoteItem, as: 'items' }]
});
// Fetch Completion Expenses (individual expense items)
@ -3625,16 +3632,9 @@ export class DealerClaimService {
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
}
const formatDate = (date: any) => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
const csvData = invoiceItems.map((item: any) => {
const row: any = {
if (isNonGst) {
return {
TRNS_UNIQ_NO: item.transactionCode || '',
CLAIM_NUMBER: requestNumber,
INV_NUMBER: invoice.invoiceNumber || '',
@ -3642,17 +3642,24 @@ export class DealerClaimService {
IO_NUMBER: internalOrder?.ioNumber || '',
CLAIM_DOC_TYP: sapRefNo,
CLAIM_TYPE: claimDetails.activityType,
CLAIM_DATE: formatDate(invoice.invoiceDate || new Date()),
CLAIM_DATE: (() => {
const d = new Date(invoice.invoiceDate || new Date());
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
})(),
CLAIM_AMT: item.assAmt
};
if (!isNonGst) {
const totalTax = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0);
row.GST_AMT = totalTax.toFixed(2);
row.GST_PERCENTAGE = item.gstRt;
};
}
return row;
return buildWfmClaimCsvRow({
item,
requestNumber,
invoiceNumber: invoice.invoiceNumber || '',
invoiceDate: (invoice.invoiceDate as Date) || new Date(),
dealerCode: claimDetails.dealerCode,
ioNumber: internalOrder?.ioNumber || '',
claimDocTyp: sapRefNo,
claimType: claimDetails.activityType || '',
});
});
await wfmFileService.generateIncomingClaimCSV(csvData, `CN_${padDealerCode(claimDetails.dealerCode)}_${requestNumber}.csv`, isNonGst);

View File

@ -16,6 +16,7 @@ export interface ExternalDealerResponse {
pincode: string | null;
'dealer email': string | null;
'dealer phone': string | null;
parentcode?: string | null;
}
export class DealerExternalService {
@ -76,7 +77,8 @@ export class DealerExternalService {
'store address': data['store address'] || null,
pincode: data.pincode || null,
'dealer email': data['dealer email'] || null,
'dealer phone': data['dealer phone'] || null
'dealer phone': data['dealer phone'] || null,
parentcode: data.parentcode || null
};
} catch (error) {

View File

@ -0,0 +1,158 @@
import { Op } from 'sequelize';
import { HsnSacCode, HsnSacCodeAttributes, HsnSacCodeCreationAttributes } from '../models/HsnSacCode';
import logger from '../utils/logger';
export interface PaginatedHsnSacCodes {
codes: HsnSacCode[];
pagination: {
totalRecords: number;
totalPages: number;
currentPage: number;
limit: number;
};
}
export class HsnSacCodeService {
/**
* Get HSN/SAC codes with pagination and search
*/
async getAllCodes(
onlyActive: boolean = false,
page: number = 1,
limit: number = 10,
search?: string
): Promise<PaginatedHsnSacCodes> {
try {
const where: any = {};
if (onlyActive) {
where.isActive = true;
}
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
where[Op.or] = [
{ code: { [Op.like]: searchTerm } },
{ description: { [Op.like]: searchTerm } }
];
}
const offset = (page - 1) * limit;
const { count, rows } = await HsnSacCode.findAndCountAll({
where,
order: [['type', 'ASC'], ['code', 'ASC']],
limit,
offset
});
return {
codes: rows,
pagination: {
totalRecords: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit
}
};
} catch (error) {
logger.error('Error fetching HSN/SAC codes:', error);
throw error;
}
}
/**
* Get a single code by ID
*/
async getCodeById(id: string): Promise<HsnSacCode | null> {
try {
return await HsnSacCode.findByPk(id);
} catch (error) {
logger.error(`Error fetching HSN/SAC code with id ${id}:`, error);
throw error;
}
}
/**
* Create a new HSN/SAC code with duplicate check
*/
async createCode(data: HsnSacCodeCreationAttributes): Promise<HsnSacCode> {
try {
// Check for duplicates
const existing = await HsnSacCode.findOne({
where: {
code: data.code,
type: data.type
}
});
if (existing) {
throw new Error(`${data.type} code "${data.code}" already exists`);
}
return await HsnSacCode.create(data);
} catch (error) {
logger.error('Error creating HSN/SAC code:', error);
throw error;
}
}
/**
* Update an existing HSN/SAC code with duplicate check
*/
async updateCode(id: string, data: Partial<HsnSacCodeAttributes>): Promise<HsnSacCode | null> {
try {
const code = await HsnSacCode.findByPk(id);
if (!code) return null;
// If code or type is being updated, check for duplicates
if (data.code || data.type) {
const existing = await HsnSacCode.findOne({
where: {
code: data.code || code.code,
type: data.type || code.type,
id: { [Op.ne]: id }
}
});
if (existing) {
throw new Error(`${data.type || code.type} code "${data.code || code.code}" already exists`);
}
}
return await code.update(data);
} catch (error) {
logger.error(`Error updating HSN/SAC code with id ${id}:`, error);
throw error;
}
}
/**
* Delete an HSN/SAC code
*/
async deleteCode(id: string): Promise<boolean> {
try {
const result = await HsnSacCode.destroy({ where: { id } });
return result > 0;
} catch (error) {
logger.error(`Error deleting HSN/SAC code with id ${id}:`, error);
throw error;
}
}
/**
* Toggle active status
*/
async toggleActive(id: string): Promise<HsnSacCode | null> {
try {
const code = await HsnSacCode.findByPk(id);
if (!code) return null;
return await code.update({ isActive: !code.isActive });
} catch (error) {
logger.error(`Error toggling active status for HSN/SAC code with id ${id}:`, error);
throw error;
}
}
}
export const hsnSacCodeService = new HsnSacCodeService();

View File

@ -168,21 +168,79 @@ export class WFMFileService {
return path.join(this.basePath, targetPath, fileName);
}
private getOutgoingClaimsDir(isNonGst: boolean = false): string {
const targetPath = isNonGst ? this.outgoingNonGstClaimsPath : this.outgoingGstClaimsPath;
return path.join(this.basePath, targetPath);
}
/**
* List all outgoing CSV files in the folder.
*/
listOutgoingFiles(isNonGst: boolean = false): string[] {
const outgoingDir = this.getOutgoingClaimsDir(isNonGst);
if (!fs.existsSync(outgoingDir)) return [];
return fs.readdirSync(outgoingDir)
.filter(file => file.toLowerCase().endsWith('.csv'))
.map(file => path.join(outgoingDir, file));
}
/**
* Build outgoing credit note file path for a dealer + request.
*/
getCreditNoteOutgoingFilePath(dealerCode: string, requestNumber: string, isNonGst: boolean = false): { fileName: string; filePath: string } {
const dealer = String(dealerCode || '').trim();
const paddedDealer = dealer.padStart(6, '0');
const exactCandidates = [
`CN_${paddedDealer}_${requestNumber}.csv`,
`CN_${dealer}_${requestNumber}.csv`,
];
for (const candidate of exactCandidates) {
const candidatePath = this.getOutgoingPath(candidate, isNonGst);
if (fs.existsSync(candidatePath)) {
return { fileName: candidate, filePath: candidatePath };
}
}
// Last fallback: pick any CN_*_<requestNumber>.csv from target outgoing folder.
const outgoingDir = this.getOutgoingClaimsDir(isNonGst);
if (fs.existsSync(outgoingDir)) {
const escapedReq = requestNumber.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const reqFileRegex = new RegExp(`^CN_.*_${escapedReq}\\.csv$`, 'i');
const matched = fs.readdirSync(outgoingDir).find((name) => reqFileRegex.test(name));
if (matched) {
return { fileName: matched, filePath: path.join(outgoingDir, matched) };
}
}
// Keep deterministic default for callers when no file exists yet.
const fileName = exactCandidates[0];
return { fileName, filePath: this.getOutgoingPath(fileName, isNonGst) };
}
/**
* Get credit note details from outgoing CSV
*/
async getCreditNoteDetails(dealerCode: string, requestNumber: string, isNonGst: boolean = false): Promise<any[]> {
const fileName = `CN_${String(dealerCode).padStart(6, '0')}_${requestNumber}.csv`;
const filePath = this.getOutgoingPath(fileName, isNonGst);
const { data } = await this.getCreditNoteDetailsWithPath(dealerCode, requestNumber, isNonGst);
return data;
}
/**
* Get credit note details and resolved file path from outgoing CSV.
*/
async getCreditNoteDetailsWithPath(dealerCode: string, requestNumber: string, isNonGst: boolean = false): Promise<{ fileName: string; filePath: string; data: any[] }> {
const { fileName, filePath } = this.getCreditNoteOutgoingFilePath(dealerCode, requestNumber, isNonGst);
try {
if (!fs.existsSync(filePath)) {
return [];
return { fileName, filePath, data: [] };
}
const fileContent = fs.readFileSync(filePath, 'utf-8');
const lines = fileContent.split('\n').filter(line => line.trim() !== '');
if (lines.length <= 1) return []; // Only headers or empty
if (lines.length <= 1) return { fileName, filePath, data: [] }; // Only headers or empty
const headers = lines[0].split('|');
const data = lines.slice(1).map(line => {
@ -194,10 +252,25 @@ export class WFMFileService {
return row;
});
return data;
return { fileName, filePath, data };
} catch (error) {
logger.error(`[WFMFileService] Error reading credit note CSV: ${fileName}`, error);
return [];
return { fileName, filePath, data: [] };
}
}
/**
* Delete an outgoing credit note file once it has been persisted.
*/
deleteCreditNoteOutgoingFileByPath(filePath: string): void {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
logger.info(`[WFMFileService] Deleted processed credit note CSV: ${filePath}`);
}
} catch (error) {
logger.error('[WFMFileService] Error deleting processed credit note CSV:', filePath, error);
throw error;
}
}

View File

@ -39,6 +39,12 @@ export interface ApiResponse<T = any> {
message: string;
data?: T;
error?: string;
pagination?: {
totalRecords: number;
totalPages: number;
currentPage: number;
limit: number;
};
timestamp: Date;
}

View File

@ -115,3 +115,93 @@ export const padDealerCode = (dealerCode: string | number): string => {
if (dealerCode === null || dealerCode === undefined) return '';
return String(dealerCode).padStart(6, '0');
};
/**
* WFM incoming claim CSV headers in required fixed order.
*/
export const WFM_CLAIM_CSV_HEADERS = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'INV_DATE',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_TYPE',
'CLAIM_DATE',
'HSN_CODE',
'SAC_CODE',
'PART_AMT',
'LABOUR_AMT',
'GST_AMT',
'GST_PERCENTAGE',
] as const;
type WfmInvoiceItemLike = {
transactionCode?: string;
hsnCd?: string;
assAmt?: number | string;
gstRt?: number | string;
igstAmt?: number | string;
cgstAmt?: number | string;
sgstAmt?: number | string;
utgstAmt?: number | string;
isServc?: string;
};
type BuildWfmRowInput = {
item: WfmInvoiceItemLike;
requestNumber: string;
invoiceNumber: string;
invoiceDate: Date;
dealerCode: string;
ioNumber: string;
claimDocTyp: string;
claimType: string;
};
/**
* Build one WFM incoming claim CSV row.
* Business rule:
* - Service line -> SAC_CODE + LABOUR_AMT, keep HSN_CODE + PART_AMT empty
* - Material line -> HSN_CODE + PART_AMT, keep SAC_CODE + LABOUR_AMT empty
*/
export const buildWfmClaimCsvRow = ({
item,
requestNumber,
invoiceNumber,
invoiceDate,
dealerCode,
ioNumber,
claimDocTyp,
claimType,
}: BuildWfmRowInput): Record<(typeof WFM_CLAIM_CSV_HEADERS)[number], string | number> => {
const d = new Date(invoiceDate);
const invDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
const normalizedIsService = String(item.isServc || '').toUpperCase() === 'Y';
const hsnOrSac = item.hsnCd || '';
const assesableAmount = Number(item.assAmt || 0);
const totalTax =
Number(item.igstAmt || 0) +
Number(item.cgstAmt || 0) +
Number(item.sgstAmt || 0) +
Number(item.utgstAmt || 0);
return {
TRNS_UNIQ_NO: item.transactionCode || '',
CLAIM_NUMBER: requestNumber || '',
INV_NUMBER: invoiceNumber || '',
INV_DATE: invDate,
DEALER_CODE: padDealerCode(dealerCode || ''),
IO_NUMBER: ioNumber || '',
CLAIM_DOC_TYP: claimDocTyp || '',
CLAIM_TYPE: claimType || '',
CLAIM_DATE: invDate,
HSN_CODE: normalizedIsService ? '' : hsnOrSac,
SAC_CODE: normalizedIsService ? hsnOrSac : '',
PART_AMT: normalizedIsService ? '' : assesableAmount.toFixed(2),
LABOUR_AMT: normalizedIsService ? assesableAmount.toFixed(2) : '',
GST_AMT: totalTax.toFixed(2),
GST_PERCENTAGE: item.gstRt ?? '',
};
};

View File

@ -7,16 +7,22 @@ export class ResponseHandler {
res: Response,
data: T,
message: string = 'Success',
statusCode: number = 200
statusCode: number = 200,
pagination?: {
totalRecords: number;
totalPages: number;
currentPage: number;
limit: number;
}
): void {
const response: ApiResponse<T> = {
success: true,
message,
data,
pagination,
timestamp: new Date(),
};
res.status(statusCode).json(response);
}

View File

@ -58,8 +58,8 @@ export const createActivityTypeSchema = z.object({
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'),
creditPostingOn: z.enum(['Spares', 'Vehicle', 'GMA', 'Apparel'], {
errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, GMA or Apparel' }),
creditPostingOn: z.enum(['Spares', 'Vehicle', 'Vehicles', 'GMA', 'Apparel'], {
errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, Vehicles, GMA or Apparel' }),
}),
});