diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml index b4dddc4..baa7bf5 100644 --- a/monitoring/prometheus/prometheus.yml +++ b/monitoring/prometheus/prometheus.yml @@ -52,6 +52,8 @@ scrape_configs: metrics_path: /metrics scrape_interval: 10s scrape_timeout: 5s + authorization: + credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d' # ============================================ # Node Exporter - Host Metrics diff --git a/src/controllers/apiToken.controller.ts b/src/controllers/apiToken.controller.ts new file mode 100644 index 0000000..c60a5c7 --- /dev/null +++ b/src/controllers/apiToken.controller.ts @@ -0,0 +1,79 @@ +import { Request, Response } from 'express'; +import { ApiTokenService } from '../services/apiToken.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import { AuthenticatedRequest } from '../types/express'; +import { z } from 'zod'; + +const createTokenSchema = z.object({ + name: z.string().min(1).max(100), + expiresInDays: z.number().int().positive().optional(), +}); + +export class ApiTokenController { + private apiTokenService: ApiTokenService; + + constructor() { + this.apiTokenService = new ApiTokenService(); + } + + /** + * Create a new API Token + */ + async create(req: AuthenticatedRequest, res: Response): Promise { + try { + const validation = createTokenSchema.safeParse(req.body); + if (!validation.success) { + ResponseHandler.error(res, 'Validation error', 400, validation.error.message); + return; + } + + const { name, expiresInDays } = validation.data; + const userId = req.user.userId; + + const result = await this.apiTokenService.createToken(userId, name, expiresInDays); + + ResponseHandler.success(res, { + token: result.token, + apiToken: result.apiToken + }, 'API Token created successfully. Please copy the token now, you will not be able to see it again.'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + ResponseHandler.error(res, 'Failed to create API token', 500, errorMessage); + } + } + + /** + * List user's API Tokens + */ + async list(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user.userId; + const tokens = await this.apiTokenService.listTokens(userId); + ResponseHandler.success(res, { tokens }, 'API Tokens retrieved successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + ResponseHandler.error(res, 'Failed to list API tokens', 500, errorMessage); + } + } + + /** + * Revoke an API Token + */ + async revoke(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user.userId; + const { id } = req.params; + + const success = await this.apiTokenService.revokeToken(userId, id); + + if (success) { + ResponseHandler.success(res, null, 'API Token revoked successfully'); + } else { + ResponseHandler.notFound(res, 'Token not found or already revoked'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + ResponseHandler.error(res, 'Failed to revoke API token', 500, errorMessage); + } + } +} diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 43debd5..5f864a5 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -3,6 +3,9 @@ import jwt from 'jsonwebtoken'; import { User } from '../models/User'; import { ssoConfig } from '../config/sso'; import { ResponseHandler } from '../utils/responseHandler'; +import { ApiTokenService } from '../services/apiToken.service'; + +const apiTokenService = new ApiTokenService(); interface JwtPayload { userId: string; @@ -23,6 +26,29 @@ export const authenticateToken = async ( const authHeader = req.headers.authorization; let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + // Check if it's an API Token (starts with re_) + if (token && token.startsWith('re_')) { + const user = await apiTokenService.verifyToken(token); + + if (!user || !user.isActive) { + ResponseHandler.unauthorized(res, 'Invalid or expired API token'); + return; + } + + // Attach user info to request object + req.user = { + userId: user.userId, + email: user.email, + employeeId: user.employeeId || null, + role: user.role + }; + + next(); + return; + } + + // Fallback to cookie if available (requires cookie-parser middleware) + // Fallback to cookie if available (requires cookie-parser middleware) if (!token && req.cookies?.accessToken) { token = req.cookies.accessToken; @@ -35,10 +61,10 @@ export const authenticateToken = async ( // Verify JWT token const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload; - + // Fetch user from database to ensure they still exist and are active const user = await User.findByPk(decoded.userId); - + if (!user || !user.isActive) { ResponseHandler.unauthorized(res, 'User not found or inactive'); return; @@ -89,7 +115,7 @@ export const optionalAuth = async ( if (token) { const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload; const user = await User.findByPk(decoded.userId); - + if (user && user.isActive) { req.user = { userId: user.userId, @@ -99,7 +125,7 @@ export const optionalAuth = async ( }; } } - + next(); } catch (error) { // For optional auth, we don't throw errors, just continue without user diff --git a/src/migrations/20260216-add-qty-hsn-to-expenses.ts b/src/migrations/20260216-add-qty-hsn-to-expenses.ts new file mode 100644 index 0000000..750e87b --- /dev/null +++ b/src/migrations/20260216-add-qty-hsn-to-expenses.ts @@ -0,0 +1,50 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +/** + * Helper function to check if a column exists in a table + */ +async function columnExists( + queryInterface: QueryInterface, + tableName: string, + columnName: string +): Promise { + try { + const tableDescription = await queryInterface.describeTable(tableName); + return columnName in tableDescription; + } catch (error) { + return false; + } +} + +export async function up(queryInterface: QueryInterface): Promise { + const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses']; + + const newColumns = { + quantity: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 }, + hsn_code: { type: DataTypes.STRING(20), allowNull: true } + }; + + for (const table of tables) { + for (const [colName, colSpec] of Object.entries(newColumns)) { + if (!(await columnExists(queryInterface, table, colName))) { + await queryInterface.addColumn(table, colName, colSpec); + console.log(`Added column ${colName} to ${table}`); + } else { + console.log(`Column ${colName} already exists in ${table}`); + } + } + } +} + +export async function down(queryInterface: QueryInterface): Promise { + const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses']; + const columns = ['quantity', 'hsn_code']; + + for (const table of tables) { + for (const col of columns) { + await queryInterface.removeColumn(table, col).catch((err) => { + console.warn(`Failed to remove column ${col} from ${table}:`, err.message); + }); + } + } +} diff --git a/src/migrations/20260216-create-api-tokens.ts b/src/migrations/20260216-create-api-tokens.ts new file mode 100644 index 0000000..0c353e0 --- /dev/null +++ b/src/migrations/20260216-create-api-tokens.ts @@ -0,0 +1,70 @@ + +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable('api_tokens', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'user_id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'User-friendly name for the token', + }, + prefix: { + type: DataTypes.STRING(10), + allowNull: false, + comment: 'First few characters of token for identification (e.g., re_1234)', + }, + token_hash: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Bcrypt hash of the full token', + }, + last_used_at: { + type: DataTypes.DATE, + allowNull: true, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'Optional expiration date', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + // Indexes + await queryInterface.addIndex('api_tokens', ['user_id']); + await queryInterface.addIndex('api_tokens', ['prefix']); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('api_tokens'); +} diff --git a/src/models/ApiToken.ts b/src/models/ApiToken.ts new file mode 100644 index 0000000..b97e019 --- /dev/null +++ b/src/models/ApiToken.ts @@ -0,0 +1,105 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '../config/database'; +import { User } from './User'; + +interface ApiTokenAttributes { + id: string; + userId: string; + name: string; + prefix: string; + tokenHash: string; + lastUsedAt?: Date | null; + expiresAt?: Date | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +interface ApiTokenCreationAttributes extends Optional { } + +class ApiToken extends Model implements ApiTokenAttributes { + public id!: string; + public userId!: string; + public name!: string; + public prefix!: string; + public tokenHash!: string; + public lastUsedAt?: Date | null; + public expiresAt?: Date | null; + public isActive!: boolean; + public createdAt!: Date; + public updatedAt!: Date; +} + +ApiToken.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + field: 'user_id', + references: { + model: 'users', + key: 'user_id', + }, + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + }, + prefix: { + type: DataTypes.STRING(10), + allowNull: false, + }, + tokenHash: { + type: DataTypes.STRING, + allowNull: false, + field: 'token_hash', + }, + lastUsedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_used_at', + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'expires_at', + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: 'is_active', + allowNull: false, + }, + 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: 'ApiToken', + tableName: 'api_tokens', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } +); + +// Define associations +ApiToken.belongsTo(User, { foreignKey: 'userId', as: 'user' }); +User.hasMany(ApiToken, { foreignKey: 'userId', as: 'apiTokens' }); + +export { ApiToken }; diff --git a/src/models/DealerCompletionExpense.ts b/src/models/DealerCompletionExpense.ts index d92012a..1d47138 100644 --- a/src/models/DealerCompletionExpense.ts +++ b/src/models/DealerCompletionExpense.ts @@ -11,6 +11,8 @@ interface DealerCompletionExpenseAttributes { amount: number; gstRate?: number; gstAmt?: number; + quantity?: number; + hsnCode?: string; cgstRate?: number; cgstAmt?: number; sgstRate?: number; @@ -37,6 +39,8 @@ class DealerCompletionExpense extends Model {} +interface UserCreationAttributes extends Optional { } class User extends Model implements UserAttributes { public userId!: string; @@ -65,7 +68,7 @@ class User extends Model implements User public department?: string; public designation?: string; public phone?: string; - + // Extended fields from SSO/Okta (All Optional) public manager?: string | null; public secondEmail?: string | null; @@ -74,7 +77,7 @@ class User extends Model implements User public postalAddress?: string | null; public mobilePhone?: string | null; public adGroups?: string[] | null; - + // Location Information (JSON object) public location?: { city?: string; @@ -83,12 +86,12 @@ class User extends Model implements User office?: string; timezone?: string; }; - + // Notification Preferences public emailNotificationsEnabled!: boolean; public pushNotificationsEnabled!: boolean; public inAppNotificationsEnabled!: boolean; - + public isActive!: boolean; public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN public lastLogin?: Date; @@ -96,26 +99,26 @@ class User extends Model implements User public updatedAt!: Date; // Associations - + /** * Helper Methods for Role Checking */ public isUserRole(): boolean { return this.role === 'USER'; } - + public isManagementRole(): boolean { return this.role === 'MANAGEMENT'; } - + public isAdminRole(): boolean { return this.role === 'ADMIN'; } - + public hasManagementAccess(): boolean { return this.role === 'MANAGEMENT' || this.role === 'ADMIN'; } - + public hasAdminAccess(): boolean { return this.role === 'ADMIN'; } @@ -181,7 +184,7 @@ User.init( type: DataTypes.STRING(20), allowNull: true }, - + // ============ Extended SSO/Okta Fields (All Optional) ============ manager: { type: DataTypes.STRING(200), @@ -227,14 +230,14 @@ User.init( field: 'ad_groups', comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array' }, - + // Location Information (JSON object) location: { type: DataTypes.JSONB, // Use JSONB for PostgreSQL allowNull: true, comment: 'JSON object containing location details (city, state, country, office, timezone)' }, - + // Notification Preferences emailNotificationsEnabled: { type: DataTypes.BOOLEAN, @@ -257,7 +260,7 @@ User.init( field: 'in_app_notifications_enabled', comment: 'User preference for receiving in-app notifications' }, - + isActive: { type: DataTypes.BOOLEAN, defaultValue: true, diff --git a/src/routes/apiToken.routes.ts b/src/routes/apiToken.routes.ts new file mode 100644 index 0000000..81e5b36 --- /dev/null +++ b/src/routes/apiToken.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { ApiTokenController } from '../controllers/apiToken.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; + +const router = Router(); +const apiTokenController = new ApiTokenController(); + +// All routes require authentication +router.use(authenticateToken); + +router.post('/', asyncHandler(apiTokenController.create.bind(apiTokenController))); +router.get('/', asyncHandler(apiTokenController.list.bind(apiTokenController))); +router.delete('/:id', asyncHandler(apiTokenController.revoke.bind(apiTokenController))); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 5235037..2fc2ad8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -17,6 +17,7 @@ import dealerClaimRoutes from './dealerClaim.routes'; import templateRoutes from './template.routes'; import dealerRoutes from './dealer.routes'; import dmsWebhookRoutes from './dmsWebhook.routes'; +import apiTokenRoutes from './apiToken.routes'; import { authenticateToken } from '../middlewares/auth.middleware'; import { requireAdmin } from '../middlewares/authorization.middleware'; @@ -50,6 +51,7 @@ router.use('/dealer-claims', dealerClaimRoutes); router.use('/templates', templateRoutes); router.use('/dealers', dealerRoutes); router.use('/webhooks/dms', dmsWebhookRoutes); +router.use('/api-tokens', apiTokenRoutes); // Add other route modules as they are implemented // router.use('/approvals', approvalRoutes); diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index b5844a6..ac04cfb 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -159,6 +159,8 @@ async function runMigrations(): Promise { const m44 = require('../migrations/20260123-fix-template-id-schema'); const m45 = require('../migrations/20260209-add-gst-and-pwc-fields'); const m46 = require('../migrations/20260210-add-raw-pwc-responses'); + const m47 = require('../migrations/20260216-create-api-tokens'); + const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -210,6 +212,8 @@ async function runMigrations(): Promise { { name: '20260123-fix-template-id-schema', module: m44 }, { name: '20260209-add-gst-and-pwc-fields', module: m45 }, { name: '20260210-add-raw-pwc-responses', module: m46 }, + { name: '20260216-create-api-tokens', module: m47 }, + { name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array ]; // Dynamically import sequelize after secrets are loaded diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index c2c3626..5eee9f4 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -48,6 +48,7 @@ import * as m42 from '../migrations/20250125-create-activity-types'; import * as m43 from '../migrations/20260113-redesign-dealer-claim-history'; import * as m44 from '../migrations/20260123-fix-template-id-schema'; import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields'; +import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses'; interface Migration { name: string; @@ -109,7 +110,8 @@ const migrations: Migration[] = [ { name: '20250125-create-activity-types', module: m42 }, { name: '20260113-redesign-dealer-claim-history', module: m43 }, { name: '20260123-fix-template-id-schema', module: m44 }, - { name: '20260209-add-gst-and-pwc-fields', module: m45 } + { name: '20260209-add-gst-and-pwc-fields', module: m45 }, + { name: '20260216-add-qty-hsn-to-expenses', module: m46 } ]; /** diff --git a/src/scripts/test-api-tokens.ts b/src/scripts/test-api-tokens.ts new file mode 100644 index 0000000..2fdd650 --- /dev/null +++ b/src/scripts/test-api-tokens.ts @@ -0,0 +1,74 @@ +import { sequelize } from '../config/database'; +import { User } from '../models/User'; +import { ApiTokenService } from '../services/apiToken.service'; + +async function testApiTokens() { + try { + console.log('🔌 Connecting to database...'); + await sequelize.authenticate(); + console.log('✅ Database connected'); + + const apiTokenService = new ApiTokenService(); + + // 1. Find an admin user + const adminUser = await User.findOne({ where: { role: 'ADMIN' } }); + if (!adminUser) { + console.error('❌ No admin user found. Please seed the database first.'); + process.exit(1); + } + console.log(`👤 Found Admin User: ${adminUser.email}`); + + // 2. Create a Token + console.log('🔑 Creating API Token...'); + const tokenName = 'Test Token ' + Date.now(); + const { token, apiToken } = await apiTokenService.createToken(adminUser.userId, tokenName, 30); + console.log(`✅ Token Created: ${token}`); + console.log(` ID: ${apiToken.id}`); + console.log(` Prefix: ${apiToken.prefix}`); + + // 3. Verify Token + console.log('🔍 Verifying Token...'); + const verifiedUser = await apiTokenService.verifyToken(token); + if (verifiedUser && verifiedUser.userId === adminUser.userId) { + console.log('✅ Token Verification Successful'); + } else { + console.error('❌ Token Verification Failed'); + } + + // 4. List Tokens + console.log('📋 Listing Tokens...'); + const tokens = await apiTokenService.listTokens(adminUser.userId); + console.log(`✅ Found ${tokens.length} tokens`); + const createdToken = tokens.find(t => t.id === apiToken.id); + if (createdToken) { + console.log('✅ Created token found in list'); + } else { + console.error('❌ Created token NOT found in list'); + } + + // 5. Revoke Token + console.log('🚫 Revoking Token...'); + const revoked = await apiTokenService.revokeToken(adminUser.userId, apiToken.id); + if (revoked) { + console.log('✅ Token Revoked Successfully'); + } else { + console.error('❌ Token Revocation Failed'); + } + + // 6. Verify Revocation + console.log('🔍 Verifying Revoked Token...'); + const revokedUser = await apiTokenService.verifyToken(token); + if (!revokedUser) { + console.log('✅ Revoked Token Verification Successful (Access Denied)'); + } else { + console.error('❌ Revoked Token Verification Failed (Access Granted)'); + } + + } catch (error) { + console.error('❌ Test Failed:', error); + } finally { + await sequelize.close(); + } +} + +testApiTokens(); diff --git a/src/services/apiToken.service.ts b/src/services/apiToken.service.ts new file mode 100644 index 0000000..6c23013 --- /dev/null +++ b/src/services/apiToken.service.ts @@ -0,0 +1,119 @@ +import crypto from 'crypto'; +import bcrypt from 'bcryptjs'; +import { ApiToken } from '../models/ApiToken'; +import { User } from '../models/User'; +import logger from '../utils/logger'; + +export class ApiTokenService { + private static readonly TOKEN_PREFIX = 're_'; + private static readonly TOKEN_LENGTH = 32; + + /** + * Generate a new API token for a user + */ + async createToken(userId: string, name: string, expiresInDays?: number): Promise<{ token: string; apiToken: ApiToken }> { + try { + // 1. Generate secure random token + const randomBytes = crypto.randomBytes(ApiTokenService.TOKEN_LENGTH); + const randomString = randomBytes.toString('hex'); + const token = `${ApiTokenService.TOKEN_PREFIX}${randomString}`; + + // 2. Hash the token + const tokenHash = await bcrypt.hash(token, 10); + + // 3. Calculate expiry + let expiresAt: Date | undefined; + if (expiresInDays) { + expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + } + + // 4. Save to DB + const apiToken = await ApiToken.create({ + userId, + name, + prefix: token.substring(0, 8), // Store first 8 chars (prefix + 5 chars) for lookup + tokenHash, + expiresAt, + isActive: true, + }); + + logger.info(`API Token created for user ${userId}: ${name}`); + + // Return raw token ONLY here + return { token, apiToken }; + } catch (error) { + logger.error('Error creating API token:', error); + throw new Error('Failed to create API token'); + } + } + + /** + * List all active tokens for a user + */ + async listTokens(userId: string): Promise { + return await ApiToken.findAll({ + where: { userId, isActive: true }, + order: [['createdAt', 'DESC']], + attributes: { exclude: ['tokenHash'] } // Never return hash + }); + } + + /** + * Revoke (delete) a token + */ + async revokeToken(userId: string, tokenId: string): Promise { + const result = await ApiToken.destroy({ + where: { id: tokenId, userId } + }); + return result > 0; + } + + /** + * Verify an API token and return the associated user + */ + async verifyToken(rawToken: string): Promise { + try { + if (!rawToken.startsWith(ApiTokenService.TOKEN_PREFIX)) { + return null; + } + + // 1. Find potential tokens by prefix (optimization) + const prefix = rawToken.substring(0, 8); + const tokens = await ApiToken.findAll({ + where: { + prefix, + isActive: true + }, + include: [{ + model: User, + as: 'user', + where: { isActive: true } // Ensure user is also active + }] + }); + + // 2. check hash for each candidate + for (const tokenRecord of tokens) { + const isValid = await bcrypt.compare(rawToken, tokenRecord.tokenHash); + if (isValid) { + // 3. Check expiry + if (tokenRecord.expiresAt && new Date() > tokenRecord.expiresAt) { + logger.warn(`Expired API token used: ${tokenRecord.id}`); + return null; + } + + // 4. Update last used + await tokenRecord.update({ lastUsedAt: new Date() }); + + // Return the user (casted as any because include types can be tricky) + return (tokenRecord as any).user; + } + } + + return null; + } catch (error) { + logger.error('Error verifying API token:', error); + return null; + } + } +} diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 701d082..0617a39 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -1,3 +1,6 @@ +import { Op } from 'sequelize'; +import { sequelize } from '../config/database'; +import logger from '../utils/logger'; import { WorkflowRequest } from '../models/WorkflowRequest'; import { DealerClaimDetails } from '../models/DealerClaimDetails'; import { DealerProposalDetails } from '../models/DealerProposalDetails'; @@ -24,7 +27,7 @@ import { activityService } from './activity.service'; import { UserService } from './user.service'; import { dmsIntegrationService } from './dmsIntegration.service'; import { findDealerLocally } from './dealer.service'; -import logger from '../utils/logger'; + const appDomain = process.env.APP_DOMAIN || 'royalenfield.com'; @@ -171,7 +174,7 @@ export class DealerClaimService { levelNumber: a.level, levelName: levelName, approverId: approverUserId || '', // Fallback to empty string if still not resolved - approverEmail: `system@${appDomain}`, + approverEmail: a.email, approverName: a.name || a.email, tatHours: tatHours, // New 5-step flow: Level 5 is the final approver (Requestor Claim Approval) @@ -1090,7 +1093,15 @@ export class DealerClaimService { // Use cost items from separate table costBreakup = proposalData.costItems.map((item: any) => ({ description: item.itemDescription || item.description, - amount: Number(item.amount) || 0 + amount: Number(item.amount) || 0, + quantity: Number(item.quantity) || 1, + hsnCode: item.hsnCode || '', + gstRate: Number(item.gstRate) || 0, + gstAmt: Number(item.gstAmt) || 0, + cgstAmt: Number(item.cgstAmt) || 0, + sgstAmt: Number(item.sgstAmt) || 0, + igstAmt: Number(item.igstAmt) || 0, + totalAmt: Number(item.totalAmt) || 0 })); } // Note: costBreakup JSONB field has been removed - only using separate table now @@ -1156,7 +1167,14 @@ export class DealerClaimService { const expenseData = expense.toJSON ? expense.toJSON() : expense; return { description: expenseData.description || '', - amount: Number(expenseData.amount) || 0 + amount: Number(expenseData.amount) || 0, + gstRate: Number(expenseData.gstRate) || 0, + gstAmt: Number(expenseData.gstAmt) || 0, + cgstAmt: Number(expenseData.cgstAmt) || 0, + sgstAmt: Number(expenseData.sgstAmt) || 0, + igstAmt: Number(expenseData.igstAmt) || 0, + totalAmt: Number(expenseData.totalAmt) || 0, + expenseDate: expenseData.expenseDate }; }); @@ -1271,6 +1289,8 @@ export class DealerClaimService { requestId, itemDescription: item.description || item.itemDescription || '', amount: Number(item.amount) || 0, + quantity: Number(item.quantity) || 1, + hsnCode: item.hsnCode || '', gstRate: Number(item.gstRate) || 0, gstAmt: Number(item.gstAmt) || 0, cgstRate: Number(item.cgstRate) || 0, @@ -1423,6 +1443,8 @@ export class DealerClaimService { completionId, description: item.description, amount: Number(item.amount) || 0, + quantity: Number(item.quantity) || 1, + hsnCode: item.hsnCode || '', gstRate: Number(item.gstRate) || 0, gstAmt: Number(item.gstAmt) || 0, cgstRate: Number(item.cgstRate) || 0, @@ -1914,7 +1936,15 @@ export class DealerClaimService { || budgetTracking?.initialEstimatedBudget || 0; - const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount); + // Generate custom invoice number based on specific format: INDC + DealerCode + AB + Sequence + // Format: INDC[DealerCode]AB[Sequence] (e.g., INDC004597AB0001) + logger.info(`[DealerClaimService] Generating custom invoice number for dealer: ${claimDetails.dealerCode}`); + const customInvoiceNumber = await this.generateCustomInvoiceNumber(claimDetails.dealerCode); + logger.info(`[DealerClaimService] Generated custom invoice number: ${customInvoiceNumber} for request: ${requestId}`); + + const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount, customInvoiceNumber); + + logger.info(`[DealerClaimService] PWC Generation Result: Success=${invoiceResult.success}, AckNo=${invoiceResult.ackNo}, SignedInv present=${!!invoiceResult.signedInvoice}`); if (!invoiceResult.success) { throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`); @@ -1922,7 +1952,7 @@ export class DealerClaimService { await ClaimInvoice.upsert({ requestId, - invoiceNumber: invoiceResult.ackNo, // Using Ack No as primary identifier for now + invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()), irn: invoiceResult.irn, ackNo: invoiceResult.ackNo, @@ -2160,6 +2190,48 @@ export class DealerClaimService { } } + /** + * Genetate Custom Invoice Number + * Format: INDC - DEALER CODE - AB (For FY) - sequence nos. + * Sample: INDC004597AB0001 + */ + private async generateCustomInvoiceNumber(dealerCode: string): Promise { + const fyCode = 'AB'; // Hardcoded FY code as per requirement + // Ensure dealer code is padded/truncated if needed to fit length constraints, but requirement says "004597" which is 6 digits. + // Assuming dealerCode is already correct length or we use it as is. + const cleanDealerCode = (dealerCode || '000000').trim(); + + const prefix = `INDC${cleanDealerCode}${fyCode}`; + + // Find last invoice with this prefix + const lastInvoice = await ClaimInvoice.findOne({ + where: { + invoiceNumber: { + [Op.like]: `${prefix}%` + } + }, + order: [ + [sequelize.fn('LENGTH', sequelize.col('invoice_number')), 'DESC'], + ['invoice_number', 'DESC'] + ] + }); + + let sequence = 1; + if (lastInvoice && lastInvoice.invoiceNumber) { + // Extract the sequence part (last 4 digits) + const lastSeqStr = lastInvoice.invoiceNumber.replace(prefix, ''); + const lastSeq = parseInt(lastSeqStr, 10); + if (!isNaN(lastSeq)) { + sequence = lastSeq + 1; + } + } + + // Pad sequence to 4 digits + const sequenceStr = sequence.toString().padStart(4, '0'); + + return `${prefix}${sequenceStr}`; + } + /** * Send credit note to dealer and auto-approve Step 8 * This method sends the credit note to the dealer via email/notification and auto-approves Step 8 diff --git a/src/services/pdf.service.ts b/src/services/pdf.service.ts index d5e1165..15c23e4 100644 --- a/src/services/pdf.service.ts +++ b/src/services/pdf.service.ts @@ -119,13 +119,8 @@ export class PdfService {
Customer Name
Royal Enfield
-
Customer GSTIN
` + `{{BUYER_GSTIN}}` + `
+
Customer GSTIN
33AAACE3882D1ZZ
Customer Address
State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604
-
-
Vehicle Owner
N/A
-
Invoice No.
${invoice.invoiceNumber || 'N/A'}
-
Invoice Date
${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : 'N/A'}
-
Chassis No.
N/A
Dealer
${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}
diff --git a/src/services/pwcIntegration.service.ts b/src/services/pwcIntegration.service.ts index 91757d8..55b960e 100644 --- a/src/services/pwcIntegration.service.ts +++ b/src/services/pwcIntegration.service.ts @@ -7,6 +7,8 @@ import { ClaimInvoice } from '../models/ClaimInvoice'; import { InternalOrder } from '../models/InternalOrder'; import { User } from '../models/User'; import { DealerClaimDetails } from '../models/DealerClaimDetails'; +import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; +import { DealerCompletionDetails } from '../models/DealerCompletionDetails'; /** * PWC E-Invoice Integration Service @@ -47,7 +49,7 @@ export class PWCIntegrationService { /** * Generate Signed Invoice via PWC API */ - async generateSignedInvoice(requestId: string, amount?: number): Promise<{ + async generateSignedInvoice(requestId: string, amount?: number, customInvoiceNumber?: string): Promise<{ success: boolean; irn?: string; ackNo?: string; @@ -105,16 +107,90 @@ export class PWCIntegrationService { dealerStateCode = (dealer as any).stateCode; } - // Calculate tax amounts - const gstRate = Number(activity.gstRate || 18); + // Fetch expenses if available + const expenses = await DealerCompletionExpense.findAll({ where: { requestId } }); + + let itemList: any[] = []; + let totalAssAmt = 0; + let totalIgstAmt = 0; + let totalCgstAmt = 0; + let totalSgstAmt = 0; + let totalInvVal = 0; + const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST - const assAmt = finalAmount; - const igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0; - const cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0; - const sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0; - const totalTax = igstAmt + cgstAmt + sgstAmt; - const totalItemVal = finalAmount + totalTax; + if (expenses && expenses.length > 0) { + itemList = expenses.map((expense: any, index: number) => { + const qty = expense.quantity || 1; + const rate = Number(expense.amount) || 0; + const gstRate = Number(expense.gstRate || 18); + + // Calculate per item tax + const baseAmt = rate * qty; + const igst = isIGST ? (baseAmt * (gstRate / 100)) : 0; + const cgst = !isIGST ? (baseAmt * (gstRate / 200)) : 0; + const sgst = !isIGST ? (baseAmt * (gstRate / 200)) : 0; + const itemTotal = baseAmt + igst + cgst + sgst; + + // Accumulate totals + totalAssAmt += baseAmt; + totalIgstAmt += igst; + totalCgstAmt += cgst; + totalSgstAmt += sgst; + totalInvVal += itemTotal; + + return { + SlNo: String(index + 1), + PrdNm: expense.description || activity.title, + PrdDesc: expense.description || activity.title, + HsnCd: expense.hsnCode || activity.hsnCode || activity.sacCode || "9983", + IsServc: "Y", + Qty: qty, + Unit: "OTH", + UnitPrice: formatAmount(rate), + TotAmt: formatAmount(baseAmt), + AssAmt: formatAmount(baseAmt), + GstRt: gstRate, + IgstAmt: formatAmount(igst), + CgstAmt: formatAmount(cgst), + SgstAmt: formatAmount(sgst), + TotItemVal: formatAmount(itemTotal) + }; + }); + } else { + // Fallback to single line item if no expenses found + const gstRate = Number(activity.gstRate || 18); + const assAmt = finalAmount; + const igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0; + const cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0; + const sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0; + const totalTax = igstAmt + cgstAmt + sgstAmt; + const totalItemVal = finalAmount + totalTax; + + totalAssAmt = assAmt; + totalIgstAmt = igstAmt; + totalCgstAmt = cgstAmt; + totalSgstAmt = sgstAmt; + totalInvVal = totalItemVal; + + itemList = [{ + SlNo: "1", + PrdNm: activity.title, + PrdDesc: activity.title, + HsnCd: activity.hsnCode || activity.sacCode || "9983", + IsServc: "Y", + Qty: 1, + Unit: "OTH", + UnitPrice: formatAmount(finalAmount), + TotAmt: formatAmount(finalAmount), + AssAmt: formatAmount(assAmt), + GstRt: gstRate, + IgstAmt: formatAmount(igstAmt), + CgstAmt: formatAmount(cgstAmt), + SgstAmt: formatAmount(sgstAmt), + TotItemVal: formatAmount(totalItemVal) + }]; + } // Construct PWC Payload - Aligned with sample format provided by user const payload = [ @@ -140,7 +216,7 @@ export class PWCIntegrationService { }, DocDtls: { Typ: "Inv", - No: (request as any).requestNumber || `INV-${Date.now()}`, + No: customInvoiceNumber || (request as any).requestNumber || `INV-${Date.now()}`, Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY }, SellerDtls: { @@ -157,7 +233,7 @@ export class PWCIntegrationService { Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com" }, BuyerDtls: { - Gstin: "{{BUYER_GSTIN}}", // Royal Enfield GST + Gstin: "33AAACE3882D1ZZ", // Royal Enfield GST (Tamil Nadu) LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)", TrdNm: "ROYAL ENFIELD", Addr1: "No. 2, Thiruvottiyur High Road", @@ -166,31 +242,13 @@ export class PWCIntegrationService { Stcd: "33", Pos: "33" }, - ItemList: [ - { - SlNo: "1", - PrdNm: activity.title, - PrdDesc: activity.title, - HsnCd: activity.hsnCode || activity.sacCode || "9983", - IsServc: "Y", - Qty: 1, - Unit: "OTH", - UnitPrice: formatAmount(finalAmount), // Ensure number - TotAmt: formatAmount(finalAmount), // Ensure number - AssAmt: formatAmount(assAmt), // Ensure number - GstRt: gstRate, - IgstAmt: formatAmount(igstAmt), - CgstAmt: formatAmount(cgstAmt), - SgstAmt: formatAmount(sgstAmt), - TotItemVal: formatAmount(totalItemVal) - } - ], + ItemList: itemList, ValDtls: { - AssVal: formatAmount(assAmt), - IgstVal: formatAmount(igstAmt), - CgstVal: formatAmount(cgstAmt), - SgstVal: formatAmount(sgstAmt), - TotInvVal: formatAmount(totalItemVal) + AssVal: formatAmount(totalAssAmt), + IgstVal: formatAmount(totalIgstAmt), + CgstVal: formatAmount(totalCgstAmt), + SgstVal: formatAmount(totalSgstAmt), + TotInvVal: formatAmount(totalInvVal) } } ]; diff --git a/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245128733.pdf b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245128733.pdf new file mode 100644 index 0000000..0ce1d92 Binary files /dev/null and b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245128733.pdf differ diff --git a/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245134092.pdf b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245134092.pdf new file mode 100644 index 0000000..c270abe Binary files /dev/null and b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245134092.pdf differ diff --git a/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245215029.pdf b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245215029.pdf new file mode 100644 index 0000000..db316f9 Binary files /dev/null and b/storage/invoices/invoice_11a48145-d9d5-40ae-b76d-62b6c60a2ba5_1771245215029.pdf differ diff --git a/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771241944655.pdf b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771241944655.pdf new file mode 100644 index 0000000..fdf41ea Binary files /dev/null and b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771241944655.pdf differ diff --git a/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242540690.pdf b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242540690.pdf new file mode 100644 index 0000000..0a0b54d Binary files /dev/null and b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242540690.pdf differ diff --git a/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242743568.pdf b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242743568.pdf new file mode 100644 index 0000000..08f4108 Binary files /dev/null and b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242743568.pdf differ diff --git a/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242988444.pdf b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242988444.pdf new file mode 100644 index 0000000..47c53c9 Binary files /dev/null and b/storage/invoices/invoice_468d45f9-2ea3-4b4d-82fc-6721b60cf8bb_1771242988444.pdf differ diff --git a/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243600786.pdf b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243600786.pdf new file mode 100644 index 0000000..9449d13 Binary files /dev/null and b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243600786.pdf differ diff --git a/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243606916.pdf b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243606916.pdf new file mode 100644 index 0000000..594ecb4 Binary files /dev/null and b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243606916.pdf differ diff --git a/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243829536.pdf b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243829536.pdf new file mode 100644 index 0000000..6681fa6 Binary files /dev/null and b/storage/invoices/invoice_49308f5c-0082-42dc-ac64-caeccedb8cd9_1771243829536.pdf differ diff --git a/storage/invoices/invoice_76e20959-51e0-4c68-8683-392d8a3065c9_1771242502655.pdf b/storage/invoices/invoice_76e20959-51e0-4c68-8683-392d8a3065c9_1771242502655.pdf new file mode 100644 index 0000000..f6118fe Binary files /dev/null and b/storage/invoices/invoice_76e20959-51e0-4c68-8683-392d8a3065c9_1771242502655.pdf differ diff --git a/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247318222.pdf b/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247318222.pdf new file mode 100644 index 0000000..ddb6841 Binary files /dev/null and b/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247318222.pdf differ diff --git a/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247323749.pdf b/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247323749.pdf new file mode 100644 index 0000000..8c2f84f Binary files /dev/null and b/storage/invoices/invoice_df0e094c-8878-4a94-a703-01193d6e9fd6_1771247323749.pdf differ