toke generation from profile and enhanced cost item to support hsn

This commit is contained in:
laxmanhalaki 2026-02-16 20:02:07 +05:30
parent 60c5d4b475
commit ff20bb7ef8
31 changed files with 777 additions and 70 deletions

View File

@ -52,6 +52,8 @@ scrape_configs:
metrics_path: /metrics
scrape_interval: 10s
scrape_timeout: 5s
authorization:
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
# ============================================
# Node Exporter - Host Metrics

View File

@ -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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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;

View File

@ -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<boolean> {
try {
const tableDescription = await queryInterface.describeTable(tableName);
return columnName in tableDescription;
} catch (error) {
return false;
}
}
export async function up(queryInterface: QueryInterface): Promise<void> {
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<void> {
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);
});
}
}
}

View File

@ -0,0 +1,70 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
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<void> {
await queryInterface.dropTable('api_tokens');
}

105
src/models/ApiToken.ts Normal file
View File

@ -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<ApiTokenAttributes, 'id' | 'lastUsedAt' | 'expiresAt' | 'isActive' | 'createdAt' | 'updatedAt'> { }
class ApiToken extends Model<ApiTokenAttributes, ApiTokenCreationAttributes> 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 };

View File

@ -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<DealerCompletionExpenseAttributes, D
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
@ -101,6 +105,17 @@ DealerCompletionExpense.init(
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,

View File

@ -11,6 +11,8 @@ interface DealerProposalCostItemAttributes {
amount: number;
gstRate?: number;
gstAmt?: number;
quantity?: number;
hsnCode?: string;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
@ -37,6 +39,8 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
@ -102,6 +106,17 @@ DealerProposalCostItem.init(
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,

View File

@ -1,5 +1,8 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
// ApiToken association is defined in ApiToken.ts to avoid circular dependency issues
// but we can declare the mixin type here if needed.
/**
* User Role Enum
@ -52,7 +55,7 @@ interface UserAttributes {
updatedAt: Date;
}
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public userId!: string;

View File

@ -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;

View File

@ -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);

View File

@ -159,6 +159,8 @@ async function runMigrations(): Promise<void> {
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<void> {
{ 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

View File

@ -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 }
];
/**

View File

@ -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();

View File

@ -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<ApiToken[]> {
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<boolean> {
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<User | null> {
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;
}
}
}

View File

@ -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<string> {
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

View File

@ -119,13 +119,8 @@ export class PdfService {
<div class="info-grid">
<div class="info-section">
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">` + `{{BUYER_GSTIN}}` + `</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">33AAACE3882D1ZZ</div></div>
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
<br/>
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Chassis No.</div><div class="info-value">N/A</div></div>
</div>
<div class="info-section">
<div class="info-row"><div class="info-label">Dealer</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>

View File

@ -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)
}
}
];