From f0435c47e462a9cc697b93b7d38d8481ed775193 Mon Sep 17 00:00:00 2001 From: laxman h Date: Wed, 25 Mar 2026 19:24:54 +0530 Subject: [PATCH] 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 --- env.example | 2 +- src/controllers/auth.controller.ts | 19 +- src/controllers/dealerClaim.controller.ts | 59 +++++-- src/controllers/hsnSacCode.controller.ts | 133 ++++++++++++++ src/jobs/creditNoteSyncJob.ts | 25 +++ src/middlewares/auth.middleware.ts | 24 ++- ...4500-add-user-session-and-hsn-sac-codes.ts | 84 +++++++++ ...75000-update-credit-notes-and-add-items.ts | 166 ++++++++++++++++++ src/models/ClaimCreditNote.ts | 100 ++--------- src/models/ClaimCreditNoteItem.ts | 83 +++++++++ src/models/HsnSacCode.ts | 95 ++++++++++ src/models/User.ts | 18 +- src/models/index.ts | 9 + src/routes/hsnSacCode.routes.ts | 56 ++++++ src/routes/index.ts | 2 + src/scripts/auto-setup.ts | 4 + src/scripts/migrate.ts | 3 +- src/server.ts | 2 + src/services/activityTypeSeed.service.ts | 20 +-- src/services/auth.service.ts | 76 ++++++-- src/services/creditNoteSync.service.ts | 161 +++++++++++++++++ src/services/dealerClaim.service.ts | 6 +- src/services/hsnSacCode.service.ts | 158 +++++++++++++++++ src/services/pwcIntegration.service.ts | 8 +- src/services/wfmFile.service.ts | 12 ++ src/types/common.types.ts | 6 + src/utils/responseHandler.ts | 10 +- src/validators/admin.validator.ts | 4 +- 28 files changed, 1198 insertions(+), 147 deletions(-) create mode 100644 src/controllers/hsnSacCode.controller.ts create mode 100644 src/jobs/creditNoteSyncJob.ts create mode 100644 src/migrations/20260325094500-add-user-session-and-hsn-sac-codes.ts create mode 100644 src/migrations/20260325175000-update-credit-notes-and-add-items.ts create mode 100644 src/models/ClaimCreditNoteItem.ts create mode 100644 src/models/HsnSacCode.ts create mode 100644 src/routes/hsnSacCode.routes.ts create mode 100644 src/services/creditNoteSync.service.ts create mode 100644 src/services/hsnSacCode.service.ts diff --git a/env.example b/env.example index daa321a..f884c88 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 1cde6a0..7ed7b49 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -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); diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index 7d9e797..b4c2797 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -16,6 +16,7 @@ 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'; @@ -1230,10 +1231,29 @@ export class DealerClaimController { } 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) { + 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: '', + TRNS_UNIQ_NO: displayTxn, CLAIM_NUMBER: requestNumber, DOC_NO: existingCreditNote.sapDocumentNumber || existingCreditNote.creditNoteNumber || '', MSG_TYP: existingCreditNote.status || '', @@ -1247,26 +1267,31 @@ export class DealerClaimController { requestNumber, isNonGst ); + if (!creditNoteData.length) { return ResponseHandler.success(res, [], 'Credit note data fetched successfully'); } - // Current requirement: process/store a single credit note per request. - const firstRow = creditNoteData[0] || {}; - const existingAmount = existingCreditNote?.creditNoteAmount ?? 0; - 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); + // Process the file using the sync service (handles grouping and transactions) + const { creditNoteSyncService } = await import('../services/creditNoteSync.service'); + await creditNoteSyncService.processFile(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) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('[DealerClaimController] Error fetching credit note WFM data:', error); diff --git a/src/controllers/hsnSacCode.controller.ts b/src/controllers/hsnSacCode.controller.ts new file mode 100644 index 0000000..6b515d9 --- /dev/null +++ b/src/controllers/hsnSacCode.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/jobs/creditNoteSyncJob.ts b/src/jobs/creditNoteSyncJob.ts new file mode 100644 index 0000000..28ad186 --- /dev/null +++ b/src/jobs/creditNoteSyncJob.ts @@ -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 { + 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); +} diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 5f864a5..0695679 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -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 + }; + } } } diff --git a/src/migrations/20260325094500-add-user-session-and-hsn-sac-codes.ts b/src/migrations/20260325094500-add-user-session-and-hsn-sac-codes.ts new file mode 100644 index 0000000..51867ae --- /dev/null +++ b/src/migrations/20260325094500-add-user-session-and-hsn-sac-codes.ts @@ -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";'); + } +}; diff --git a/src/migrations/20260325175000-update-credit-notes-and-add-items.ts b/src/migrations/20260325175000-update-credit-notes-and-add-items.ts new file mode 100644 index 0000000..692ffa8 --- /dev/null +++ b/src/migrations/20260325175000-update-credit-notes-and-add-items.ts @@ -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); + } + } + } +}; diff --git a/src/models/ClaimCreditNote.ts b/src/models/ClaimCreditNote.ts index 22128f0..91fa66c 100644 --- a/src/models/ClaimCreditNote.ts +++ b/src/models/ClaimCreditNote.ts @@ -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 { } +interface ClaimCreditNoteCreationAttributes extends Optional { } class ClaimCreditNote extends Model implements ClaimCreditNoteAttributes { public creditNoteId!: string; @@ -44,19 +34,9 @@ class ClaimCreditNote extends Model { } + +class HsnSacCode extends Model 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 }; diff --git a/src/models/User.ts b/src/models/User.ts index 59db741..8cd5578 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -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 { } +interface UserCreationAttributes extends Optional { } class User extends Model implements UserAttributes { public userId!: string; @@ -95,6 +97,8 @@ class User extends Model 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, diff --git a/src/models/index.ts b/src/models/index.ts index 51d21de..ff5814f 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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'; @@ -178,6 +179,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 }; @@ -216,6 +224,7 @@ export { ClaimInvoice, ClaimInvoiceItem, ClaimCreditNote, + ClaimCreditNoteItem, Form16aSubmission, Form16CreditNote, Form16DebitNote, diff --git a/src/routes/hsnSacCode.routes.ts b/src/routes/hsnSacCode.routes.ts new file mode 100644 index 0000000..41a425b --- /dev/null +++ b/src/routes/hsnSacCode.routes.ts @@ -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; diff --git a/src/routes/index.ts b/src/routes/index.ts index 6cfef11..511e15c 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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; diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index 94cb546..35a302f 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -180,6 +180,8 @@ async function runMigrations(): Promise { 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/20260325094500-add-user-session-and-hsn-sac-codes'); + const m67 = require('../migrations/20260325175000-update-credit-notes-and-add-items'); const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -252,6 +254,8 @@ async function runMigrations(): Promise { { 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: '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 diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 7fba546..c890786 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -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 m64 from '../migrations/20260318100001-create-form16-debit-note-sap-responses'; 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 { name: string; @@ -147,7 +148,7 @@ const migrations: Migration[] = [ { 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: '20260325094500-add-user-session-and-hsn-sac-codes', module: m66 }, ]; /** diff --git a/src/server.ts b/src/server.ts index e963e52..f42b260 100644 --- a/src/server.ts +++ b/src/server.ts @@ -119,6 +119,8 @@ const startServer = async (): Promise => { startForm16NotificationJobs(); const { startForm16ArchiveJob } = require('./services/form16Archive.service'); startForm16ArchiveJob(); + const { startCreditNoteSyncJob } = require('./jobs/creditNoteSyncJob'); + startCreditNoteSyncJob(); // Initialize queue metrics collection for Prometheus initializeQueueMetrics(); diff --git a/src/services/activityTypeSeed.service.ts b/src/services/activityTypeSeed.service.ts index 9a5a4dc..ce99906 100644 --- a/src/services/activityTypeSeed.service.ts +++ b/src/services/activityTypeSeed.service.ts @@ -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' } ]; /** diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index ab822ec..c24df92 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -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 { + async handleSSOCallback(userData: SSOUserData, userAgent?: string): Promise { 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 { + async authenticateWithPassword(username: string, password: string, userAgent?: string): Promise { // 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 { + async exchangeCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise { 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 { + async exchangeTanflowCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise { 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 { diff --git a/src/services/creditNoteSync.service.ts b/src/services/creditNoteSync.service.ts new file mode 100644 index 0000000..40c3687 --- /dev/null +++ b/src/services/creditNoteSync.service.ts @@ -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 { + 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 { + 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 = {}; + 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 { + 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(); diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 2e906d2..78416d5 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -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'; @@ -1249,9 +1250,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) diff --git a/src/services/hsnSacCode.service.ts b/src/services/hsnSacCode.service.ts new file mode 100644 index 0000000..ce0a852 --- /dev/null +++ b/src/services/hsnSacCode.service.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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(); diff --git a/src/services/pwcIntegration.service.ts b/src/services/pwcIntegration.service.ts index 2c7a2a6..f74c01b 100644 --- a/src/services/pwcIntegration.service.ts +++ b/src/services/pwcIntegration.service.ts @@ -392,7 +392,7 @@ export class PWCIntegrationService { 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", IRN: "", SourceSystem: "RE_WORKFLOW", @@ -422,13 +422,13 @@ export class PWCIntegrationService { Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY }, SellerDtls: { - Gstin: dealerGst, // Actual dealer GST from local table + Gstin: '27AAAPI3182M002', // Actual dealer GST from local table LglNm: dealer?.dealerName || 'Dealer', TrdNm: dealer?.dealerName || 'Dealer', Addr1: dealer?.city || "Address Line 1", Loc: dealer?.city || "Location", - Pin: Number(dealer?.pincode || 600001), - Stcd: dealerStateCode, + Pin: 400001, + Stcd: '27', Ph: dealer?.phone || "9998887776", Em: dealer?.email || "Supplier@inv.com" }, diff --git a/src/services/wfmFile.service.ts b/src/services/wfmFile.service.ts index 4a49480..d5ee17c 100644 --- a/src/services/wfmFile.service.ts +++ b/src/services/wfmFile.service.ts @@ -159,6 +159,18 @@ export class WFMFileService { 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. */ diff --git a/src/types/common.types.ts b/src/types/common.types.ts index f935645..11e78bd 100644 --- a/src/types/common.types.ts +++ b/src/types/common.types.ts @@ -39,6 +39,12 @@ export interface ApiResponse { message: string; data?: T; error?: string; + pagination?: { + totalRecords: number; + totalPages: number; + currentPage: number; + limit: number; + }; timestamp: Date; } diff --git a/src/utils/responseHandler.ts b/src/utils/responseHandler.ts index 09bae3e..6e218cb 100644 --- a/src/utils/responseHandler.ts +++ b/src/utils/responseHandler.ts @@ -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 = { success: true, message, data, + pagination, timestamp: new Date(), }; - res.status(statusCode).json(response); } diff --git a/src/validators/admin.validator.ts b/src/validators/admin.validator.ts index 633137b..046b30d 100644 --- a/src/validators/admin.validator.ts +++ b/src/validators/admin.validator.ts @@ -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' }), }), });