multiple device login restricted and

in admin hsn sac code cofiguration added and csv file read approach changed to read at interval of 5 minutes with mutiple cred
it note in single csv file
This commit is contained in:
laxman h 2026-03-25 19:24:54 +05:30
parent abba8aefdd
commit f0435c47e4
28 changed files with 1198 additions and 147 deletions

View File

@ -138,4 +138,4 @@ SAP_DISABLE_SSL_VERIFY=false
# WFM Archive configuration examples (if overrides are needed) # WFM Archive configuration examples (if overrides are needed)
# WFM_ARCHIVE_GST_CLAIMS_PATH=WFM-QRE\INCOMING\WFM_ARACHIVE\DLR_INC_CLAIMS_GST # 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 # 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 // Validate request body
const validatedData = validateSSOCallback(req.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 // Log login activity
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);
@ -180,7 +181,8 @@ export class AuthController {
const { code, redirectUri } = validateTokenExchange(req.body); const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Tanflow token exchange validation passed', { redirectUri }); 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 // Log login activity
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);
@ -395,6 +397,13 @@ export class AuthController {
// Clear all cookies using multiple methods // Clear all cookies using multiple methods
clearCookiesCompletely(); 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', { logger.info('User logout successful - cookies cleared', {
userId: req.user?.userId || 'unknown', userId: req.user?.userId || 'unknown',
email: req.user?.email || 'unknown', email: req.user?.email || 'unknown',
@ -452,7 +461,8 @@ export class AuthController {
const { username, password } = validatePasswordLogin(req.body); 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 // Log login activity
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);
@ -535,7 +545,8 @@ export class AuthController {
const { code, redirectUri } = validateTokenExchange(req.body); const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Token exchange validation passed', { redirectUri }); 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 // Log login activity
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);

View File

@ -16,6 +16,7 @@ import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { ClaimInvoice } from '../models/ClaimInvoice'; import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem'; import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { ClaimCreditNote } from '../models/ClaimCreditNote'; import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
import { ActivityType } from '../models/ActivityType'; import { ActivityType } from '../models/ActivityType';
import { Participant } from '../models/Participant'; import { Participant } from '../models/Participant';
import { sanitizeObject, sanitizePermissive } from '../utils/sanitizer'; import { sanitizeObject, sanitizePermissive } from '../utils/sanitizer';
@ -1230,10 +1231,29 @@ export class DealerClaimController {
} }
const { wfmFileService } = await import('../services/wfmFile.service'); const { wfmFileService } = await import('../services/wfmFile.service');
const existingCreditNote = await ClaimCreditNote.findOne({ where: { requestId } }); const existingCreditNote = await ClaimCreditNote.findOne({
where: { requestId },
include: [{
model: ClaimCreditNoteItem,
as: 'items',
attributes: ['transactionNo'],
order: [['slNo', 'ASC']]
}]
}) as any;
if (existingCreditNote?.sapDocumentNumber || existingCreditNote?.creditNoteNumber) { 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 = [{ const payload = [{
TRNS_UNIQ_NO: '', TRNS_UNIQ_NO: displayTxn,
CLAIM_NUMBER: requestNumber, CLAIM_NUMBER: requestNumber,
DOC_NO: existingCreditNote.sapDocumentNumber || existingCreditNote.creditNoteNumber || '', DOC_NO: existingCreditNote.sapDocumentNumber || existingCreditNote.creditNoteNumber || '',
MSG_TYP: existingCreditNote.status || '', MSG_TYP: existingCreditNote.status || '',
@ -1247,26 +1267,31 @@ export class DealerClaimController {
requestNumber, requestNumber,
isNonGst isNonGst
); );
if (!creditNoteData.length) { if (!creditNoteData.length) {
return ResponseHandler.success(res, [], 'Credit note data fetched successfully'); return ResponseHandler.success(res, [], 'Credit note data fetched successfully');
} }
// Current requirement: process/store a single credit note per request. // Process the file using the sync service (handles grouping and transactions)
const firstRow = creditNoteData[0] || {}; const { creditNoteSyncService } = await import('../services/creditNoteSync.service');
const existingAmount = existingCreditNote?.creditNoteAmount ?? 0; await creditNoteSyncService.processFile(filePath);
await ClaimCreditNote.upsert({
requestId,
creditNoteNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || existingCreditNote?.creditNoteNumber || undefined,
sapDocumentNumber: firstRow.DOC_NO || firstRow.CREDIT_NOTE_NUMBER || existingCreditNote?.sapDocumentNumber || undefined,
status: firstRow.MSG_TYP || existingCreditNote?.status || undefined,
errorMessage: firstRow.MESSAGE || existingCreditNote?.errorMessage || undefined,
creditNoteFilePath: filePath,
creditNoteAmount: Number(firstRow.CLAIM_AMT || firstRow.CREDIT_AMT || existingAmount || 0),
confirmedAt: new Date()
});
wfmFileService.deleteCreditNoteOutgoingFileByPath(filePath);
return ResponseHandler.success(res, [firstRow], 'Credit note data fetched successfully'); // 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error fetching credit note WFM data:', 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; employeeId: string;
email: string; email: string;
role: string; role: string;
sessionToken: string;
iat: number; iat: number;
exp: number; exp: number;
} }
@ -70,6 +71,15 @@ export const authenticateToken = async (
return; 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 // Attach user info to request object
req.user = { req.user = {
userId: user.userId, userId: user.userId,
@ -117,6 +127,7 @@ export const optionalAuth = async (
const user = await User.findByPk(decoded.userId); const user = await User.findByPk(decoded.userId);
if (user && user.isActive) { if (user && user.isActive) {
if (!decoded.sessionToken || decoded.sessionToken === user.sessionToken) {
req.user = { req.user = {
userId: user.userId, userId: user.userId,
email: user.email, email: user.email,
@ -125,6 +136,7 @@ export const optionalAuth = async (
}; };
} }
} }
}
next(); next();
} catch (error) { } catch (error) {

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; creditNoteNumber?: string;
creditNoteDate?: Date; creditNoteDate?: Date;
creditNoteAmount: number; creditNoteAmount: number;
gstRate?: number; transactionNo?: string;
gstAmt?: number; tdsAmount?: number;
cgstRate?: number; creditAmount?: number;
cgstAmt?: number;
sgstRate?: number;
sgstAmt?: number;
igstRate?: number;
igstAmt?: number;
utgstRate?: number;
utgstAmt?: number;
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
sapDocumentNumber?: string; sapDocumentNumber?: string;
creditNoteFilePath?: string; creditNoteFilePath?: string;
status?: string; status?: string;
@ -35,7 +25,7 @@ interface ClaimCreditNoteAttributes {
updatedAt: Date; 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 { class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string; public creditNoteId!: string;
@ -44,19 +34,9 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
public creditNoteNumber?: string; public creditNoteNumber?: string;
public creditNoteDate?: Date; public creditNoteDate?: Date;
public creditNoteAmount!: number; public creditNoteAmount!: number;
public gstRate?: number; public transactionNo?: string;
public gstAmt?: number; public tdsAmount?: number;
public cgstRate?: number; public creditAmount?: 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 sapDocumentNumber?: string; public sapDocumentNumber?: string;
public creditNoteFilePath?: string; public creditNoteFilePath?: string;
public status?: string; public status?: string;
@ -115,70 +95,22 @@ ClaimCreditNote.init(
allowNull: false, allowNull: false,
field: 'credit_amount' field: 'credit_amount'
}, },
gstRate: { transactionNo: {
type: DataTypes.DECIMAL(5, 2), type: DataTypes.STRING(100),
allowNull: true, allowNull: true,
field: 'gst_rate' field: 'transaction_no',
}, },
gstAmt: { tdsAmount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, allowNull: true,
field: 'gst_amt' defaultValue: 0,
field: 'tds_amount',
}, },
cgstRate: { creditAmount: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cgst_rate'
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, allowNull: true,
field: 'cgst_amt' defaultValue: 0,
}, field: 'credit_amount',
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'
}, },
sapDocumentNumber: { sapDocumentNumber: {
type: DataTypes.STRING(100), 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; isActive: boolean;
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
lastLogin?: Date; lastLogin?: Date;
sessionToken?: string | null;
lastLoginDevice?: string | null;
createdAt: Date; createdAt: Date;
updatedAt: 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 { class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public userId!: string; public userId!: string;
@ -95,6 +97,8 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public isActive!: boolean; public isActive!: boolean;
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
public lastLogin?: Date; public lastLogin?: Date;
public sessionToken?: string | null;
public lastLoginDevice?: string | null;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
@ -278,6 +282,18 @@ User.init(
allowNull: true, allowNull: true,
field: 'last_login' 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: { createdAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@ -29,6 +29,7 @@ import { WorkflowTemplate } from './WorkflowTemplate';
import { ClaimInvoice } from './ClaimInvoice'; import { ClaimInvoice } from './ClaimInvoice';
import { ClaimInvoiceItem } from './ClaimInvoiceItem'; import { ClaimInvoiceItem } from './ClaimInvoiceItem';
import { ClaimCreditNote } from './ClaimCreditNote'; import { ClaimCreditNote } from './ClaimCreditNote';
import { ClaimCreditNoteItem } from './ClaimCreditNoteItem';
import { Form16aSubmission } from './Form16aSubmission'; import { Form16aSubmission } from './Form16aSubmission';
import { Form16CreditNote } from './Form16CreditNote'; import { Form16CreditNote } from './Form16CreditNote';
import { Form16DebitNote } from './Form16DebitNote'; import { Form16DebitNote } from './Form16DebitNote';
@ -178,6 +179,13 @@ const defineAssociations = () => {
sourceKey: 'id' 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 // 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 // Only hasMany associations from WorkflowRequest are defined here since they're one-way
}; };
@ -216,6 +224,7 @@ export {
ClaimInvoice, ClaimInvoice,
ClaimInvoiceItem, ClaimInvoiceItem,
ClaimCreditNote, ClaimCreditNote,
ClaimCreditNoteItem,
Form16aSubmission, Form16aSubmission,
Form16CreditNote, Form16CreditNote,
Form16DebitNote, 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 antivirusRoutes from './antivirus.routes';
import dealerExternalRoutes from './dealerExternal.routes'; import dealerExternalRoutes from './dealerExternal.routes';
import form16Routes from './form16.routes'; import form16Routes from './form16.routes';
import hsnSacCodeRoutes from './hsnSacCode.routes';
const router = Router(); 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('/dealers-external', generalApiLimiter, dealerExternalRoutes); // 200 req/15min
router.use('/form16', uploadLimiter, form16Routes); // 50 req/15min (file uploads: extract, submissions, 26as) 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('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
router.use('/hsn-sac', generalApiLimiter, hsnSacCodeRoutes); // 200 req/15min
export default router; export default router;

View File

@ -180,6 +180,8 @@ async function runMigrations(): Promise<void> {
const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no'); const m63 = require('../migrations/20260317120001-add-form16-trns-uniq-no');
const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses'); const m64 = require('../migrations/20260318100001-create-form16-debit-note-sap-responses');
const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields'); const m65 = require('../migrations/20260318200001-add-sap-response-csv-fields');
const m66 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
const m67 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -252,6 +254,8 @@ async function runMigrations(): Promise<void> {
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 }, { name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 }, { name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 }, { name: '20260318200001-add-sap-response-csv-fields', module: m65 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m67 },
]; ];
// Dynamically import sequelize after secrets are loaded // Dynamically import sequelize after secrets are loaded

View File

@ -70,6 +70,7 @@ import * as m62 from '../migrations/20260317100001-create-form16-sap-responses';
import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no'; import * as m63 from '../migrations/20260317120001-add-form16-trns-uniq-no';
import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses'; import * as m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses';
import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields'; import * as m65 from '../migrations/20260318200001-add-sap-response-csv-fields';
import * as m66 from '../migrations/20260325094500-add-user-session-and-hsn-sac-codes';
interface Migration { interface Migration {
name: string; name: string;
@ -147,7 +148,7 @@ const migrations: Migration[] = [
{ name: '20260317120001-add-form16-trns-uniq-no', module: m63 }, { name: '20260317120001-add-form16-trns-uniq-no', module: m63 },
{ name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 }, { name: '20260318100001-create-form16-debit-note-sap-responses', module: m64 },
{ name: '20260318200001-add-sap-response-csv-fields', module: m65 }, { name: '20260318200001-add-sap-response-csv-fields', module: m65 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 },
]; ];
/** /**

View File

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

View File

@ -9,19 +9,19 @@ import { ActivityType } from '@models/ActivityType';
*/ */
const DEFAULT_ACTIVITY_TYPES = [ const DEFAULT_ACTIVITY_TYPES = [
{ title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM', creditPostingOn: 'Spares' }, { 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: '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 Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML', creditPostingOn: 'Vehicles' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS', creditPostingOn: 'Vehicle' }, { 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: '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: '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: 'Vehicle' }, { 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: 'Vehicle' }, { 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: 'Vehicle' }, { 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: 'Vehicle' }, { 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: '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: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN', creditPostingOn: 'Vehicles' },
{ title: 'Corporate Claims Institutional Sales PD', itemCode: '14', taxationType: 'GST', sapRefNo: 'ZCCG', creditPostingOn: 'Vehicle' } { 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 logger, { logAuthEvent } from '../utils/logger';
import axios from 'axios'; import axios from 'axios';
import bcrypt from 'bcryptjs'; 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 { export class AuthService {
/** /**
@ -240,7 +261,7 @@ export class AuthService {
* Handle SSO callback from frontend * Handle SSO callback from frontend
* Creates new user or updates existing user based on employeeId * 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 { try {
// Validate required fields - email and oktaSub are required // Validate required fields - email and oktaSub are required
if (!userData.email || !userData.oktaSub) { if (!userData.email || !userData.oktaSub) {
@ -272,11 +293,16 @@ export class AuthService {
displayName = userData.email.split('@')[0] || 'User'; displayName = userData.email.split('@')[0] || 'User';
} }
const sessionToken = uuidv4();
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
// Prepare update/create data - always include required fields // Prepare update/create data - always include required fields
const userUpdateData: any = { const userUpdateData: any = {
email: userData.email, email: userData.email,
oktaSub: userData.oktaSub, oktaSub: userData.oktaSub,
lastLogin: new Date(), lastLogin: new Date(),
sessionToken,
lastLoginDevice,
isActive: true, isActive: true,
}; };
@ -315,6 +341,14 @@ export class AuthService {
action: 'user_updated', action: 'user_updated',
updatedFields: Object.keys(userUpdateData), 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 { } else {
// Create new user with required fields (email and oktaSub) // Create new user with required fields (email and oktaSub)
user = await User.create({ user = await User.create({
@ -335,7 +369,9 @@ export class AuthService {
employeeNumber: userData.employeeNumber || userData.dealerCode || null, employeeNumber: userData.employeeNumber || userData.dealerCode || null,
isActive: true, isActive: true,
role: 'USER', role: 'USER',
lastLogin: new Date() lastLogin: new Date(),
sessionToken,
lastLoginDevice
}); });
logAuthEvent('sso_callback', user.userId, { logAuthEvent('sso_callback', user.userId, {
@ -390,7 +426,8 @@ export class AuthService {
userId: user.userId, userId: user.userId,
employeeId: user.employeeId, employeeId: user.employeeId,
email: user.email, 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 = { const options: SignOptions = {
@ -410,7 +447,8 @@ export class AuthService {
const payload = { const payload = {
userId: user.userId, userId: user.userId,
type: 'refresh' type: 'refresh',
sessionToken: user.sessionToken
}; };
const options: SignOptions = { const options: SignOptions = {
@ -447,6 +485,10 @@ export class AuthService {
throw new Error('User not found or inactive'); 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); return this.generateAccessToken(user);
} catch (error) { } catch (error) {
logAuthEvent('auth_failure', undefined, { logAuthEvent('auth_failure', undefined, {
@ -497,7 +539,7 @@ export class AuthService {
* 4. Create/update user in our database if needed * 4. Create/update user in our database if needed
* 5. Return our JWT tokens * 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) // Demo admin: admin@example.com / Admin@123 (works with or without .env; for dev/demo only)
const DEMO_ADMIN_EMAIL = 'admin@example.com'; const DEMO_ADMIN_EMAIL = 'admin@example.com';
const DEFAULT_DEMO_ADMIN_HASH = '$2a$10$H4ikTC.HDZPM0iFxjBy2C./WlkbGbidipIiZlXIJx6QpcBazdf12K'; // bcrypt of "Admin@123" 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); const passwordMatch = await bcrypt.compare(password, hash);
if (!passwordMatch) return null; if (!passwordMatch) return null;
let user = await User.findOne({ where: { email: adminEmail } }); let user = await User.findOne({ where: { email: adminEmail } });
const sessionToken = uuidv4();
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
if (!user) { if (!user) {
user = await User.create({ user = await User.create({
email: adminEmail, email: adminEmail,
@ -521,10 +566,13 @@ export class AuthService {
emailNotificationsEnabled: true, emailNotificationsEnabled: true,
pushNotificationsEnabled: true, pushNotificationsEnabled: true,
inAppNotificationsEnabled: true, inAppNotificationsEnabled: true,
sessionToken,
lastLoginDevice,
lastLogin: new Date()
}); });
logger.info('Demo admin user created on first login', { email: adminEmail }); logger.info('Demo admin user created on first login', { email: adminEmail });
} else { } else {
await user.update({ lastLogin: new Date() }); await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
} }
logger.info('Demo admin login successful', { email: adminEmail }); logger.info('Demo admin login successful', { email: adminEmail });
const accessToken = this.generateAccessToken(user); const accessToken = this.generateAccessToken(user);
@ -563,7 +611,7 @@ export class AuthService {
displayName: 'Test Reflow Dealer', displayName: 'Test Reflow Dealer',
firstName: 'Test', firstName: 'Test',
lastName: 'Reflow', lastName: 'Reflow',
}); }, userAgent);
}; };
// Fallback bcrypt hash for "Test@123" when .env hash is corrupted (dev only) // 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 }); logger.warn('Local dealer login by email: user not found', { email });
return null; 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 }); logger.info('Local dealer login by email successful', { email });
const accessToken = this.generateAccessToken(user); const accessToken = this.generateAccessToken(user);
const refreshToken = this.generateRefreshToken(user); const refreshToken = this.generateRefreshToken(user);
@ -735,7 +785,7 @@ export class AuthService {
}); });
// Step 4: Create/update user in our database // 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', { logger.info('User authenticated successfully via password flow', {
userId: result.user.userId, userId: result.user.userId,
@ -791,7 +841,7 @@ export class AuthService {
* This is the FRONTEND callback URL (e.g., http://localhost:3000/login/callback), * 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. * 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 { try {
// Validate configuration // Validate configuration
if (!ssoConfig.oktaClientId || ssoConfig.oktaClientId.trim() === '') { if (!ssoConfig.oktaClientId || ssoConfig.oktaClientId.trim() === '') {
@ -926,7 +976,7 @@ export class AuthService {
}); });
// Handle SSO callback to create/update user and generate our tokens // 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 our JWT tokens along with Okta tokens (store Okta refresh token for future use)
return { return {
@ -970,7 +1020,7 @@ export class AuthService {
* Exchange Tanflow authorization code for tokens * Exchange Tanflow authorization code for tokens
* Similar to Okta flow but uses Tanflow IAM endpoints * 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 { try {
// Validate configuration // Validate configuration
if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') { if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') {
@ -1138,7 +1188,7 @@ export class AuthService {
}); });
// Handle SSO callback to create/update user and generate our tokens // 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 our JWT tokens along with Tanflow tokens
return { 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.CLAIM_AMT || row.CREDIT_AMT || 0);
totalTds += Number(row.TDS_AMT || 0);
totalCredit += Number(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.FINAL_AMT || row.CREDIT_AMT || 0),
claimAmount: Number(row.CLAIM_AMT || 0),
tdsAmount: Number(row.TDS_AMT || 0),
creditAmount: Number(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

@ -10,6 +10,7 @@ import { InternalOrder, IOStatus } from '../models/InternalOrder';
import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking'; import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking';
import { ClaimInvoice } from '../models/ClaimInvoice'; import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimCreditNote } from '../models/ClaimCreditNote'; import { ClaimCreditNote } from '../models/ClaimCreditNote';
import { ClaimCreditNoteItem } from '../models/ClaimCreditNoteItem';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem'; import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { ApprovalLevel } from '../models/ApprovalLevel'; import { ApprovalLevel } from '../models/ApprovalLevel';
@ -1249,9 +1250,10 @@ export class DealerClaimService {
where: { requestId } where: { requestId }
}); });
// Fetch Credit Note details // Fetch Credit Note details with items
const claimCreditNote = await ClaimCreditNote.findOne({ const claimCreditNote = await ClaimCreditNote.findOne({
where: { requestId } where: { requestId },
include: [{ model: ClaimCreditNoteItem, as: 'items' }]
}); });
// Fetch Completion Expenses (individual expense items) // Fetch Completion Expenses (individual expense items)

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

@ -392,7 +392,7 @@ export class PWCIntegrationService {
const payload = [ const payload = [
{ {
User_GSTIN: dealerGst, // Portal User ID (Dealer's own GST) User_GSTIN: '27AAAPI3182M002', // Portal User ID (Dealer's own GST)
Version: "1.01", Version: "1.01",
IRN: "", IRN: "",
SourceSystem: "RE_WORKFLOW", SourceSystem: "RE_WORKFLOW",
@ -422,13 +422,13 @@ export class PWCIntegrationService {
Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY
}, },
SellerDtls: { SellerDtls: {
Gstin: dealerGst, // Actual dealer GST from local table Gstin: '27AAAPI3182M002', // Actual dealer GST from local table
LglNm: dealer?.dealerName || 'Dealer', LglNm: dealer?.dealerName || 'Dealer',
TrdNm: dealer?.dealerName || 'Dealer', TrdNm: dealer?.dealerName || 'Dealer',
Addr1: dealer?.city || "Address Line 1", Addr1: dealer?.city || "Address Line 1",
Loc: dealer?.city || "Location", Loc: dealer?.city || "Location",
Pin: Number(dealer?.pincode || 600001), Pin: 400001,
Stcd: dealerStateCode, Stcd: '27',
Ph: dealer?.phone || "9998887776", Ph: dealer?.phone || "9998887776",
Em: dealer?.email || "Supplier@inv.com" Em: dealer?.email || "Supplier@inv.com"
}, },

View File

@ -159,6 +159,18 @@ export class WFMFileService {
return path.join(this.basePath, targetPath); 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. * Build outgoing credit note file path for a dealer + request.
*/ */

View File

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

View File

@ -7,16 +7,22 @@ export class ResponseHandler {
res: Response, res: Response,
data: T, data: T,
message: string = 'Success', message: string = 'Success',
statusCode: number = 200 statusCode: number = 200,
pagination?: {
totalRecords: number;
totalPages: number;
currentPage: number;
limit: number;
}
): void { ): void {
const response: ApiResponse<T> = { const response: ApiResponse<T> = {
success: true, success: true,
message, message,
data, data,
pagination,
timestamp: new Date(), timestamp: new Date(),
}; };
res.status(statusCode).json(response); 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' }), 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'), 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'], { creditPostingOn: z.enum(['Spares', 'Vehicle', 'Vehicles', 'GMA', 'Apparel'], {
errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, GMA or Apparel' }), errorMap: () => ({ message: 'Credit posting on must be Spares, Vehicle, Vehicles, GMA or Apparel' }),
}), }),
}); });