modified cost item and cost expens based on the new changes aksed similar hsn items will be clubbed and enhanced the activity type to support documnt type and gst type

This commit is contained in:
laxmanhalaki 2026-02-17 20:36:21 +05:30
parent ff20bb7ef8
commit 896b345e02
12 changed files with 1022 additions and 197 deletions

View File

@ -0,0 +1,43 @@
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 colName = 'is_service';
const colSpec = { type: DataTypes.BOOLEAN, allowNull: true, defaultValue: false };
for (const table of tables) {
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 col = 'is_service';
for (const table of tables) {
await queryInterface.removeColumn(table, col).catch((err) => {
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
});
}
}

View File

@ -0,0 +1,109 @@
import { QueryInterface, DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('claim_invoice_items', {
item_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoice_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
transaction_code: {
type: DataTypes.STRING(100),
allowNull: true,
},
sl_no: {
type: DataTypes.INTEGER,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: false,
},
hsn_cd: {
type: DataTypes.STRING(20),
allowNull: false,
},
qty: {
type: DataTypes.DECIMAL(15, 3),
allowNull: false,
},
unit: {
type: DataTypes.STRING(20),
allowNull: false,
},
unit_price: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
ass_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
gst_rt: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
},
igst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
cgst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
sgst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
tot_item_val: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
is_servc: {
type: DataTypes.STRING(1),
allowNull: false,
},
expense_ids: {
type: DataTypes.JSONB,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
// Add indexes
await queryInterface.addIndex('claim_invoice_items', ['request_id'], {
name: 'idx_claim_invoice_items_request_id',
});
await queryInterface.addIndex('claim_invoice_items', ['invoice_number'], {
name: 'idx_claim_invoice_items_invoice_number',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('claim_invoice_items');
},
};

View File

@ -0,0 +1,191 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimInvoiceItemAttributes {
itemId: string;
requestId: string;
invoiceNumber?: string;
transactionCode?: string;
slNo: number;
description: string;
hsnCd: string;
qty: number;
unit: string;
unitPrice: number;
assAmt: number;
gstRt: number;
igstAmt: number;
cgstAmt: number;
sgstAmt: number;
totItemVal: number;
isServc: string;
expenseIds?: string[];
createdAt: Date;
updatedAt: Date;
}
interface ClaimInvoiceItemCreationAttributes extends Optional<ClaimInvoiceItemAttributes, 'itemId' | 'invoiceNumber' | 'transactionCode' | 'expenseIds' | 'createdAt' | 'updatedAt'> { }
class ClaimInvoiceItem extends Model<ClaimInvoiceItemAttributes, ClaimInvoiceItemCreationAttributes> implements ClaimInvoiceItemAttributes {
public itemId!: string;
public requestId!: string;
public invoiceNumber?: string;
public transactionCode?: string;
public slNo!: number;
public description!: string;
public hsnCd!: string;
public qty!: number;
public unit!: string;
public unitPrice!: number;
public assAmt!: number;
public gstRt!: number;
public igstAmt!: number;
public cgstAmt!: number;
public sgstAmt!: number;
public totItemVal!: number;
public isServc!: string;
public expenseIds?: string[];
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimInvoiceItem.init(
{
itemId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'item_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoiceNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'invoice_number',
},
transactionCode: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'transaction_code',
},
slNo: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'sl_no',
},
description: {
type: DataTypes.TEXT,
allowNull: false,
field: 'description',
},
hsnCd: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'hsn_cd',
},
qty: {
type: DataTypes.DECIMAL(15, 3),
allowNull: false,
field: 'qty',
},
unit: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'unit',
},
unitPrice: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'unit_price',
},
assAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'ass_amt',
},
gstRt: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
field: 'gst_rt',
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'igst_amt',
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'cgst_amt',
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'sgst_amt',
},
totItemVal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'tot_item_val',
},
isServc: {
type: DataTypes.STRING(1),
allowNull: false,
field: 'is_servc',
},
expenseIds: {
type: DataTypes.JSONB,
allowNull: true,
field: 'expense_ids',
},
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: 'ClaimInvoiceItem',
tableName: 'claim_invoice_items',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['request_id'], name: 'idx_claim_invoice_items_request_id' },
{ fields: ['invoice_number'], name: 'idx_claim_invoice_items_invoice_number' },
],
}
);
WorkflowRequest.hasMany(ClaimInvoiceItem, {
as: 'invoiceItems',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimInvoiceItem.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimInvoiceItem };

View File

@ -24,6 +24,7 @@ interface DealerCompletionExpenseAttributes {
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
expenseDate: Date;
createdAt: Date;
updatedAt: Date;
@ -52,6 +53,7 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public expenseDate!: Date;
public createdAt!: Date;
public updatedAt!: Date;
@ -171,6 +173,12 @@ DealerCompletionExpense.init(
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
expenseDate: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -24,6 +24,7 @@ interface DealerProposalCostItemAttributes {
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
itemOrder: number;
createdAt: Date;
updatedAt: Date;
@ -52,6 +53,7 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public itemOrder!: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -172,6 +174,12 @@ DealerProposalCostItem.init(
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
itemOrder: {
type: DataTypes.INTEGER,
allowNull: false,

View File

@ -26,6 +26,9 @@ import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
import { DealerClaimHistory } from './DealerClaimHistory';
import { WorkflowTemplate } from './WorkflowTemplate';
import { ClaimInvoice } from './ClaimInvoice';
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
import { ClaimCreditNote } from './ClaimCreditNote';
// Define associations
const defineAssociations = () => {
@ -179,7 +182,10 @@ export {
ClaimBudgetTracking,
Dealer,
ActivityType,
DealerClaimHistory
DealerClaimHistory,
ClaimInvoice,
ClaimInvoiceItem,
ClaimCreditNote
};
// Export default sequelize instance

View File

@ -49,6 +49,8 @@ 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';
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
interface Migration {
name: string;
@ -58,60 +60,10 @@ interface Migration {
// Define all migrations in order
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
const migrations: Migration[] = [
// 1. FIRST: Create base tables with no dependencies
{ name: '2025103000-create-users', module: m0 }, // ← MUST BE FIRST
// 2. Tables that depend on users
{ name: '2025103001-create-workflow-requests', module: m1 },
{ name: '2025103002-create-approval-levels', module: m2 },
{ name: '2025103003-create-participants', module: m3 },
{ name: '2025103004-create-documents', module: m4 },
{ name: '20251031_01_create_subscriptions', module: m5 },
{ name: '20251031_02_create_activities', module: m6 },
{ name: '20251031_03_create_work_notes', module: m7 },
{ name: '20251031_04_create_work_note_attachments', module: m8 },
// 3. Table modifications and additional features
{ name: '20251104-add-tat-alert-fields', module: m9 },
{ name: '20251104-create-tat-alerts', module: m10 },
{ name: '20251104-create-kpi-views', module: m11 },
{ name: '20251104-create-holidays', module: m12 },
{ name: '20251104-create-admin-config', module: m13 },
{ name: '20251105-add-skip-fields-to-approval-levels', module: m14 },
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
{ name: '20251121-add-ai-model-configs', module: m19 },
{ name: '20250122-create-request-summaries', module: m20 },
{ name: '20250122-create-shared-summaries', module: m21 },
{ name: '20250123-update-request-number-format', module: m22 },
{ name: '20250126-add-paused-to-enum', module: m23 },
{ name: '20250126-add-paused-to-workflow-status-enum', module: m24 },
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
// Base branch migrations (m28-m29)
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
{ name: '20251203-add-user-notification-preferences', module: m29 },
// Dealer claim branch migrations (m30-m39)
{ name: '20251210-add-workflow-type-support', module: m30 },
{ name: '20251210-enhance-workflow-templates', module: m31 },
{ name: '20251210-add-template-id-foreign-key', module: m32 },
{ name: '20251210-create-dealer-claim-tables', module: m33 },
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
{ name: '20251211-create-internal-orders-table', module: m35 },
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ 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: '20260216-add-qty-hsn-to-expenses', module: m46 }
// ... existing migrations ...
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
{ name: '20260217-add-is-service-to-expenses', module: m47 },
{ name: '20260217-create-claim-invoice-items', module: m48 }
];
/**

View File

@ -8,19 +8,19 @@ import { ActivityType } from '@models/ActivityType';
* These will be seeded into the database with default item_code values (1-13)
*/
const DEFAULT_ACTIVITY_TYPES = [
{ title: 'Riders Mania Claims', itemCode: '1' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2' },
{ title: 'Media Bike Service', itemCode: '3' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10' },
{ title: 'Legal Claims Reimbursement', itemCode: '11' },
{ title: 'Service Camp Claims', itemCode: '12' },
{ title: 'Corporate Claims Institutional Sales PDI', itemCode: '13' }
{ title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV' },
{ title: 'Media Bike Service', itemCode: '3', taxationType: 'GST', sapRefNo: 'ZMBS' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6', taxationType: 'GST', sapRefNo: 'ZPPE' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC' },
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR' },
{ title: 'Service Camp Claims', itemCode: '12', taxationType: 'Non GST', sapRefNo: 'ZSCC' },
{ title: 'Corporate Claims Institutional Sales PDI', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN' }
];
/**
@ -40,7 +40,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
);
const exists = tableExists && tableExists.length > 0 && (tableExists[0] as any).exists;
if (!exists) {
logger.warn('[ActivityType Seed] ⚠️ activity_types table does not exist. Please run migrations first (npm run migrate). Skipping seed.');
return;
@ -71,7 +71,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
let skippedCount = 0;
for (const activityType of DEFAULT_ACTIVITY_TYPES) {
const { title, itemCode } = activityType;
const { title, itemCode, taxationType, sapRefNo } = activityType;
try {
// Check if activity type already exists (active or inactive)
const existing = await ActivityType.findOne({
@ -79,45 +79,41 @@ export async function seedDefaultActivityTypes(): Promise<void> {
});
if (existing) {
// If exists but inactive, reactivate it
// Identify fields to update
const updates: any = {};
if (!existing.itemCode && itemCode) updates.itemCode = itemCode;
if (!existing.taxationType && taxationType) updates.taxationType = taxationType;
if (!existing.sapRefNo && sapRefNo) updates.sapRefNo = sapRefNo;
if (!existing.isActive) {
// Update item_code if it's null (preserve if user has already set it)
const updateData: any = {
isActive: true,
updatedBy: systemUserId
};
// Only set item_code if it's currently null (don't overwrite user edits)
if (!existing.itemCode) {
updateData.itemCode = itemCode;
}
await existing.update(updateData);
updates.isActive = true;
updates.updatedBy = systemUserId;
await existing.update(updates);
updatedCount++;
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title}${!existing.itemCode ? ` (set item_code: ${itemCode})` : ''}`);
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
} else {
// Already exists and active
// Update item_code if it's null (preserve if user has already set it)
if (!existing.itemCode) {
await existing.update({
itemCode: itemCode,
updatedBy: systemUserId
} as any);
logger.debug(`[ActivityType Seed] Updated item_code for existing activity type: ${title} (${itemCode})`);
if (Object.keys(updates).length > 0) {
updates.updatedBy = systemUserId;
await existing.update(updates);
logger.debug(`[ActivityType Seed] Updated fields for existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
} else {
skippedCount++;
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
}
skippedCount++;
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
}
} else {
// Create new activity type with default item_code
// Create new activity type with default fields
await ActivityType.create({
title,
itemCode: itemCode,
taxationType: null,
sapRefNo: null,
taxationType: taxationType,
sapRefNo: sapRefNo,
isActive: true,
createdBy: systemUserId
} as any);
createdCount++;
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode})`);
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode}, sap_ref: ${sapRefNo})`);
}
} catch (error: any) {
// Log error but continue with other activity types
@ -132,7 +128,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
{ type: QueryTypes.SELECT }
);
const totalCount = result && (result[0] as any).count ? (result[0] as any).count : 0;
logger.info(`[ActivityType Seed] ✅ Activity type seeding complete. Created: ${createdCount}, Reactivated: ${updatedCount}, Skipped: ${skippedCount}, Total active: ${totalCount}`);
} catch (error: any) {
logger.error('[ActivityType Seed] ❌ Error seeding activity types:', {

View File

@ -15,6 +15,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant';
import { User } from '../models/User';
import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory';
import { ActivityType } from '../models/ActivityType';
import { Document } from '../models/Document';
import { WorkflowService } from './workflow.service';
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
@ -22,11 +23,12 @@ import { generateRequestNumber } from '../utils/helpers';
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
import { sapIntegrationService } from './sapIntegration.service';
import { pwcIntegrationService } from './pwcIntegration.service';
import { findDealerLocally } from './dealer.service';
import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { UserService } from './user.service';
import { dmsIntegrationService } from './dmsIntegration.service';
import { findDealerLocally } from './dealer.service';
// findDealerLocally removed (duplicate)
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
@ -1080,6 +1082,14 @@ export class DealerClaimService {
let serializedClaimDetails = null;
if (claimDetails) {
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
// Add default GST rate from ActivityType
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
if (activity) {
serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18;
} else {
serializedClaimDetails.defaultGstRate = 18; // Fallback
}
}
// Transform proposal details to include cost items as array
@ -1278,6 +1288,12 @@ export class DealerClaimService {
// Save cost items to separate table (preferred approach)
if (proposalData.costBreakup && proposalData.costBreakup.length > 0) {
// Validate HSN/SAC codes before saving
for (const item of proposalData.costBreakup) {
// Pass item.isService explicitly; if undefined/null, validation will use default HSN/SAC logic
this.validateHSNSAC(item.hsnCode, item.isService);
}
// Delete existing cost items for this proposal (in case of update)
await DealerProposalCostItem.destroy({
where: { proposalId }
@ -1304,6 +1320,7 @@ export class DealerClaimService {
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
isService: !!item.isService,
itemOrder: index
}));
@ -1434,32 +1451,72 @@ export class DealerClaimService {
});
// Persist individual closed expenses to dealer_completion_expenses
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
const buyerStateCode = "33";
let dealerStateCode = "33";
if (dealer?.gstin && dealer.gstin.length >= 2 && !isNaN(Number(dealer.gstin.substring(0, 2)))) {
dealerStateCode = dealer.gstin.substring(0, 2);
} else if (dealer?.state) {
if (dealer.state.toLowerCase().includes('tamil nadu')) dealerStateCode = "33";
else dealerStateCode = "00";
}
const isIGST = dealerStateCode !== buyerStateCode;
const completionId = (completionDetails as any)?.completionId;
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
// Validate HSN/SAC codes before saving
for (const item of completionData.closedExpenses) {
this.validateHSNSAC(item.hsnCode, !!item.isService);
}
// Clear existing expenses for this request to avoid duplicates
await DealerCompletionExpense.destroy({ where: { requestId } });
const expenseRows = completionData.closedExpenses.map((item: any) => ({
requestId,
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,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
}));
const expenseRows = completionData.closedExpenses.map((item: any) => {
const amount = Number(item.amount) || 0;
const quantity = Number(item.quantity) || 1;
const baseTotal = amount * quantity;
// Use provided tax details or calculate if missing/zero
let gstRate = Number(item.gstRate);
if (isNaN(gstRate) || gstRate === 0) {
// Fallback to activity GST rate if available
gstRate = 18; // Default fallback
}
const igstRate = isIGST ? gstRate : 0;
const cgstRate = !isIGST ? gstRate / 2 : 0;
const sgstRate = !isIGST ? gstRate / 2 : 0;
const igstAmt = isIGST ? (baseTotal * gstRate) / 100 : 0;
const cgstAmt = !isIGST ? (baseTotal * gstRate) / 200 : 0;
const sgstAmt = !isIGST ? (baseTotal * gstRate) / 200 : 0;
const gstAmt = igstAmt + cgstAmt + sgstAmt;
return {
requestId,
completionId,
description: item.description,
amount,
quantity,
hsnCode: item.hsnCode || '',
gstRate,
gstAmt: Number(item.gstAmt) || gstAmt,
cgstRate: Number(item.cgstRate) || cgstRate,
cgstAmt: Number(item.cgstAmt) || cgstAmt,
sgstRate: Number(item.sgstRate) || sgstRate,
sgstAmt: Number(item.sgstAmt) || sgstAmt,
igstRate: Number(item.igstRate) || igstRate,
igstAmt: Number(item.igstAmt) || igstAmt,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || (baseTotal + gstAmt),
isService: !!item.isService,
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
};
});
await DealerCompletionExpense.bulkCreate(expenseRows);
}
@ -1963,6 +2020,10 @@ export class DealerClaimService {
pwcResponse: invoiceResult.rawResponse,
irpResponse: invoiceResult.irpResponse,
amount: invoiceAmount,
taxableValue: invoiceResult.totalAssAmt,
igstTotal: invoiceResult.totalIgstAmt,
cgstTotal: invoiceResult.totalCgstAmt,
sgstTotal: invoiceResult.totalSgstAmt,
status: 'GENERATED',
generatedAt: new Date(),
description: invoiceData?.description || `PWC Signed Invoice for claim request ${requestNumber}`,
@ -3451,5 +3512,44 @@ export class DealerClaimService {
return plain;
});
}
/**
* Validates HSN or SAC code based on GST rules
* @param hsnCode HSN/SAC code string
* @param isService Boolean indicating if it's a Service (SAC) or Goods (HSN).
* If undefined, it will attempt to infer from the code (internal safety).
*/
private validateHSNSAC(hsnCode: string | undefined, isService?: boolean): void {
if (!hsnCode) return;
const cleanCode = String(hsnCode).trim();
if (!cleanCode) return;
if (!/^\d+$/.test(cleanCode)) {
throw new Error(`Invalid HSN/SAC code: ${hsnCode}. Code must contain only digits.`);
}
// Infer isService if not provided (safety fallback)
const effectiveIsService = isService !== undefined ? !!isService : cleanCode.startsWith('99');
if (effectiveIsService) {
// SAC (Services Accounting Code)
if (!cleanCode.startsWith('99')) {
throw new Error(`Invalid SAC code: ${hsnCode}. Service codes must start with 99.`);
}
if (cleanCode.length !== 6) {
throw new Error(`Invalid SAC code: ${hsnCode}. SAC codes must be exactly 6 digits.`);
}
} else {
// HSN (Harmonized System of Nomenclature) for Goods
// Valid lengths in India: 4, 6, 8
const validHSNLengths = [4, 6, 8];
if (!validHSNLengths.includes(cleanCode.length)) {
throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes must be 4, 6, or 8 digits.`);
}
if (cleanCode.startsWith('99')) {
throw new Error(`Invalid HSN code: ${hsnCode}. HSN codes should not start with 99 (reserved for SAC).`);
}
}
}
}

View File

@ -3,6 +3,9 @@ import { WorkflowRequest } from '@models/WorkflowRequest';
import { ClaimInvoice } from '@models/ClaimInvoice';
import { DealerClaimDetails } from '@models/DealerClaimDetails';
import { DealerProposalDetails } from '@models/DealerProposalDetails';
import { DealerCompletionExpense } from '@models/DealerCompletionExpense';
import { ClaimInvoiceItem } from '@models/ClaimInvoiceItem';
import { amountToWords } from '@utils/currencyUtils';
import { findDealerLocally } from './dealer.service';
import path from 'path';
import fs from 'fs';
@ -31,6 +34,14 @@ export class PdfService {
const invoice = await ClaimInvoice.findOne({ where: { requestId } });
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
const completionExpenses = await DealerCompletionExpense.findAll({
where: { requestId },
order: [['createdAt', 'ASC']]
});
const invoiceItems = await ClaimInvoiceItem.findAll({
where: { requestId },
order: [['slNo', 'ASC']]
});
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
if (!request || !invoice) {
@ -42,6 +53,8 @@ export class PdfService {
invoice,
claimDetails,
proposalDetails,
completionExpenses,
invoiceItems,
dealer
});
@ -69,10 +82,132 @@ export class PdfService {
}
private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails } = data;
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [] } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const logoUrl = `{{LOGO_URL}}`;
let tableRows = '';
if (invoiceItems && invoiceItems.length > 0) {
// Use persisted invoice items (matches PWC payload exactly)
tableRows = invoiceItems.map((item: any) => `
<tr>
<td>${item.slNo}</td>
<td>${item.description}</td>
<td>${item.hsnCd}</td>
<td>${Number(item.assAmt).toFixed(2)}</td>
<td>0.00</td>
<td>${item.unit}</td>
<td>${Number(item.assAmt).toFixed(2)}</td>
<td>${Number(item.igstAmt) > 0 ? Number(item.gstRt).toFixed(2) : '0.00'}</td>
<td>${Number(item.igstAmt).toFixed(2)}</td>
<td>${Number(item.cgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.cgstAmt).toFixed(2)}</td>
<td>${Number(item.sgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.sgstAmt).toFixed(2)}</td>
<td>${Number(item.totItemVal).toFixed(2)}</td>
</tr>
`).join('');
} else if (completionExpenses.length > 0) {
// Group expenses by HSN/SAC and GST Rate for clubbed display
const grouped: Record<string, any> = {};
completionExpenses.forEach((item: any) => {
const hsn = item.hsnCode || 'N/A';
const rate = Number(item.gstRate || 0);
const key = `${hsn}_${rate}`;
if (!grouped[key]) {
grouped[key] = {
hsn,
rate,
description: item.description || 'Expense',
amount: 0,
igstRate: item.igstRate || 0,
igstAmt: 0,
cgstRate: item.cgstRate || 0,
cgstAmt: 0,
sgstRate: item.sgstRate || 0,
sgstAmt: 0,
totalAmt: 0
};
}
const qty = Number(item.quantity || 1);
grouped[key].amount += Number(item.amount || 0) * qty;
grouped[key].igstAmt += Number(item.igstAmt || 0);
grouped[key].cgstAmt += Number(item.cgstAmt || 0);
grouped[key].sgstAmt += Number(item.sgstAmt || 0);
grouped[key].totalAmt += Number(item.totalAmt || 0);
});
tableRows = Object.values(grouped).map((item: any) => `
<tr>
<td>EXPENSE</td>
<td>${item.description}</td>
<td>${item.hsn}</td>
<td>${Number(item.amount).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(item.amount).toFixed(2)}</td>
<td>${Number(item.igstRate || 0).toFixed(2)}</td>
<td>${Number(item.igstAmt || 0).toFixed(2)}</td>
<td>${Number(item.cgstRate || 0).toFixed(2)}</td>
<td>${Number(item.cgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.sgstRate || 0).toFixed(2)}</td>
<td>${Number(item.sgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.totalAmt || 0).toFixed(2)}</td>
</tr>
`).join('');
} else {
tableRows = `
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
<td>998881</td>
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
<td>${invoice.igstTotal > 0 ? (Number(invoice.igstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.igstTotal || 0).toFixed(2)}</td>
<td>${invoice.cgstTotal > 0 ? (Number(invoice.cgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.cgstTotal || 0).toFixed(2)}</td>
<td>${invoice.sgstTotal > 0 ? (Number(invoice.sgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.sgstTotal || 0).toFixed(2)}</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
</tr>
`;
}
let totalTaxable = 0;
let totalTax = 0;
let grandTotal = 0;
if (invoiceItems && invoiceItems.length > 0) {
invoiceItems.forEach((item: any) => {
totalTaxable += Number(item.assAmt || 0);
totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
grandTotal += Number(item.totItemVal || 0);
});
} else if (completionExpenses.length > 0) {
// Fallback for completion expenses calculation
completionExpenses.forEach((item: any) => {
const qty = Number(item.quantity || 1);
const baseAmt = Number(item.amount || 0) * qty;
const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
totalTaxable += baseAmt;
totalTax += taxAmt;
grandTotal += (baseAmt + taxAmt);
});
} else {
// Fallback for invoice record
totalTaxable = Number(invoice.taxableValue || invoice.amount || 0);
totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0);
grandTotal = Number(invoice.amount || 0);
}
const totalValueInWords = amountToWords(grandTotal);
const totalTaxInWords = amountToWords(totalTax);
return `
<!DOCTYPE html>
<html>
@ -106,12 +241,12 @@ export class PdfService {
<div>
<img src="${logoUrl}" class="logo" />
<div class="irn-details">
<div><strong>IRN No :</strong> ${invoice.irn || 'N/A'}</div>
<div><strong>Ack No :</strong> ${invoice.ackNo || 'N/A'}</div>
<div><strong>Ack Date & Time :</strong> ${invoice.ackDate ? dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss') : 'N/A'}</div>
${invoice.irn ? `<div><strong>IRN No :</strong> ${invoice.irn}</div>` : ''}
${invoice.ackNo ? `<div><strong>Ack No :</strong> ${invoice.ackNo}</div>` : ''}
${invoice.ackDate ? `<div><strong>Ack Date & Time :</strong> ${dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss')}</div>` : ''}
</div>
</div>
<img src="${qrImage}" class="qr-code" />
${qrImage ? `<img src="${qrImage}" class="qr-code" />` : ''}
</div>
<div class="title">WARRANTY CLAIM TAX INVOICE</div>
@ -127,12 +262,11 @@ export class PdfService {
<div class="info-row"><div class="info-label">Store</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Supplier GSTIN</div><div class="info-value">${dealer?.gstin || 'N/A'}</div></div>
<br/>
<div class="info-row"><div class="info-label">POS</div><div class="info-value">${dealer?.state || dealer?.city || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice No.</div><div class="info-value">${request.requestNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice Date</div><div class="info-value">${dayjs().format('DD-MM-YYYY')}</div></div>
<div class="info-row"><div class="info-label">Job Card No.</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">KMS.Reading</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">Last Approval Date</div><div class="info-value">${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : 'N/A'}</div></div>
<div class="info-row"><div class="info-label">POS</div><div class="info-value">${invoice.placeOfSupply || dealer?.state || 'Test State'}</div></div>
<div class="info-row"><div class="info-label">Claim Request Number</div><div class="info-value">${request.requestNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
<div class="info-row"><div class="info-label">Last Approval Date</div><div class="info-value">${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
</div>
</div>
@ -142,9 +276,8 @@ export class PdfService {
<th>Part</th>
<th>Description</th>
<th>HSN/SAC</th>
<th>Qty</th>
<th>Rate</th>
<th>Discount</th>
<th>Base Amount</th>
<th>Disc</th>
<th>UOM</th>
<th>Taxable Value</th>
<th>IGST %</th>
@ -153,39 +286,23 @@ export class PdfService {
<th>CGST</th>
<th>SGST %</th>
<th>SGST</th>
<th>Amount</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
<td>998881</td>
<td>1.00</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
<td>18.00</td>
<td>${(Number(invoice.amount || 0) * 0.18).toFixed(2)}</td>
<td>0.00</td>
<td>0.00</td>
<td>0.00</td>
<td>0.00</td>
<td>${(Number(invoice.amount || 0) * 1.18).toFixed(2)}</td>
</tr>
${tableRows}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>TOTAL:</span><span>${Number(invoice.amount || 0).toFixed(2)}</span></div>
<div class="totals-row"><span>TOTAL TAX:</span><span>${(Number(invoice.amount || 0) * 0.18).toFixed(2)}</span></div>
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${(Number(invoice.amount || 0) * 1.18).toFixed(2)}</span></div>
<div class="totals-row"><span>TAXABLE TOTAL:</span><span>${totalTaxable.toFixed(2)}</span></div>
<div class="totals-row"><span>TOTAL TAX:</span><span>${totalTax.toFixed(2)}</span></div>
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${grandTotal.toFixed(2)}</span></div>
</div>
<div class="words">
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${invoice.totalValueInWords || 'N/A'}</div>
<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${invoice.taxValueInWords || 'N/A'}</div>
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${totalValueInWords}</div>
<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>
</div>
<div class="footer">

View File

@ -9,6 +9,7 @@ import { User } from '../models/User';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
/**
* PWC E-Invoice Integration Service
@ -46,6 +47,23 @@ export class PWCIntegrationService {
return '610000'; // Default placeholder
}
/**
* Determine if HSN code belongs to a service (starts with 99)
* @param hsnCode HSN/SAC code string
* @param isServiceFlag Explicit flag from database if available
*/
private isServiceHSN(hsnCode?: string, isServiceFlag?: boolean): "Y" | "N" {
const cleanCode = String(hsnCode || '').trim();
// Priority 1: SAC (Service Accounting Code) in India always starts with 99
if (cleanCode.startsWith('99')) return "Y";
// Priority 2: In Indian GST e-invoicing, any code not starting with 99 is treated as Goods (HSN)
// Even if the user checked "Is Service", if the HSN is for goods, IsServc must be "N"
// for valid NIC/IRP processing.
return "N";
}
/**
* Generate Signed Invoice via PWC API
*/
@ -60,6 +78,10 @@ export class PWCIntegrationService {
rawResponse?: any;
irpResponse?: any;
error?: string;
totalIgstAmt?: number;
totalCgstAmt?: number;
totalSgstAmt?: number;
totalAssAmt?: number;
}> {
try {
const request = await WorkflowRequest.findByPk(requestId, {
@ -79,9 +101,104 @@ export class PWCIntegrationService {
// Fallback for amount if not provided
const finalAmount = Number(amount || (request as any).amount || 0);
// Helper to format number to 2 decimal places
const formatAmount = (val: number) => Number(val.toFixed(2));
// Fetch expenses early to be available for both Non-GST and GST logic
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
// Helpers for precise formatting (return numbers as required by IRN schema)
const formatAmount = (val: number) => Number(val.toFixed(2));
const formatQty = (val: number) => Number(val.toFixed(3));
const formatRate = (val: number) => Number(val.toFixed(2));
// NEW LOGIC: Check for Non-GST Activity
const isNonGST = activity.taxationType === 'Non GST';
if (isNonGST) {
logger.info(`[PWC] Activity ${activity.title} is Non-GST. Skipping IRN generation and grossing up values.`);
let totalNonGstVal = 0;
let nonGstSlNo = 1;
// Clear existing items
await ClaimInvoiceItem.destroy({ where: { requestId } });
if (expenses && expenses.length > 0) {
for (const expense of expenses) {
// Gross Up Logic: Total Amount (Base + Tax) becomes the new Base Amount
// stored in 'totalAmt' or calculated from components
const grossAmount = Number(expense.totalAmt) || (Number(expense.amount) + Number(expense.gstAmt || 0) + Number(expense.cessAmt || 0));
// Transaction Code
const transactionCode = `${customInvoiceNumber}-${String(nonGstSlNo).padStart(2, '0')}`;
await ClaimInvoiceItem.create({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: nonGstSlNo,
description: expense.description || activity.title,
hsnCd: expense.hsnCode || activity.hsnCode || "998311",
qty: 1,
unit: "NOS",
unitPrice: formatAmount(grossAmount),
assAmt: formatAmount(grossAmount), // Taxable value is the gross amount
gstRt: 0, // 0% Tax
igstAmt: 0,
cgstAmt: 0,
sgstAmt: 0,
totItemVal: formatAmount(grossAmount),
isServc: "Y", // Non-GST reimbursements are generally treated as service/settlement
expenseIds: [expense.expenseId]
});
totalNonGstVal += grossAmount;
nonGstSlNo++;
}
} else {
// Fallback if no specific expenses (rare)
const fallbackAmount = finalAmount;
const transactionCode = `${customInvoiceNumber}-${String(nonGstSlNo).padStart(2, '0')}`;
await ClaimInvoiceItem.create({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: nonGstSlNo,
description: activity.title,
hsnCd: activity.hsnCode || "998311",
qty: 1,
unit: "NOS",
unitPrice: formatAmount(fallbackAmount),
assAmt: formatAmount(fallbackAmount),
gstRt: 0,
igstAmt: 0,
cgstAmt: 0,
sgstAmt: 0,
totItemVal: formatAmount(fallbackAmount),
isServc: "Y",
expenseIds: []
});
totalNonGstVal += fallbackAmount;
}
// Return internal success response (No IRN)
return {
success: true,
irn: undefined,
ackNo: undefined,
ackDate: new Date(),
signedInvoice: undefined,
qrCode: undefined, // No QR for Non-GST
qrImage: undefined,
totalIgstAmt: 0,
totalCgstAmt: 0,
totalSgstAmt: 0,
totalAssAmt: formatAmount(totalNonGstVal)
};
}
// --- END Non-GST Logic ---
// Existing GST Logic starts here...
// Extract State Code from Dealer GSTIN
let dealerGst = (dealer as any).gst;
@ -107,8 +224,8 @@ export class PWCIntegrationService {
dealerStateCode = (dealer as any).stateCode;
}
// Fetch expenses if available
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
// Fetch expenses if available (Moved to top)
// const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
let itemList: any[] = [];
let totalAssAmt = 0;
@ -120,41 +237,119 @@ export class PWCIntegrationService {
const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST
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);
// Group expenses by HSN/SAC and GST Rate
const groupedExpenses: Record<string, any> = {};
expenses.forEach((expense: any) => {
const hsnCd = expense.hsnCode || activity.hsnCode || activity.sacCode || "998311";
const gstRate = (expense.gstRate === undefined || expense.gstRate === null || Number(expense.gstRate) === 0)
? Number(activity.gstRate || 18)
: Number(expense.gstRate);
const groupKey = `${hsnCd}_${gstRate}`;
const qty = Number(expense.quantity) || 1;
const amount = Number(expense.amount) || 0;
const baseAmt = amount * qty;
// 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;
if (!groupedExpenses[groupKey]) {
groupedExpenses[groupKey] = {
hsnCd,
gstRate,
isService: this.isServiceHSN(hsnCd, expense.isService) === "Y",
baseAmt: 0,
igst: 0,
cgst: 0,
sgst: 0,
itemTotal: 0,
description: expense.description || activity.title,
expenseIds: [expense.expenseId]
};
} else {
const nextDesc = expense.description || activity.title;
if (nextDesc && !groupedExpenses[groupKey].description.includes(nextDesc)) {
const updatedDesc = `${groupedExpenses[groupKey].description}, ${nextDesc}`;
// Truncate if too long for PWC (usually 100 chars)
groupedExpenses[groupKey].description = updatedDesc.length > 100
? updatedDesc.substring(0, 97) + '...'
: updatedDesc;
}
groupedExpenses[groupKey].expenseIds.push(expense.expenseId);
}
groupedExpenses[groupKey].baseAmt += baseAmt;
groupedExpenses[groupKey].igst += igst;
groupedExpenses[groupKey].cgst += cgst;
groupedExpenses[groupKey].sgst += sgst;
groupedExpenses[groupKey].itemTotal += itemTotal;
});
// Persistence: Delete old items and insert new ones for this request
await ClaimInvoiceItem.destroy({ where: { requestId } });
itemList = Object.values(groupedExpenses).map((group: any, index: number) => {
// Accumulate overall totals
totalAssAmt += group.baseAmt;
totalIgstAmt += group.igst;
totalCgstAmt += group.cgst;
totalSgstAmt += group.sgst;
totalInvVal += group.itemTotal;
const slNo = index + 1;
// Save to DB
const transactionCode = `${customInvoiceNumber}-${String(slNo).padStart(2, '0')}`;
ClaimInvoiceItem.create({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: slNo,
description: group.description,
hsnCd: group.hsnCd, // Use actual HSN from group
qty: 1,
unit: "NOS",
unitPrice: formatAmount(group.baseAmt),
assAmt: formatAmount(group.baseAmt),
gstRt: formatRate(Number(group.gstRate)),
igstAmt: formatAmount(group.igst),
cgstAmt: formatAmount(group.cgst),
sgstAmt: formatAmount(group.sgst),
totItemVal: formatAmount(group.itemTotal),
isServc: group.isService ? "Y" : "N", // Use actual isService from group
expenseIds: group.expenseIds
}).catch((err: any) => logger.error(`[PWCIntegrationService] Error saving ClaimInvoiceItem:`, err));
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)
SlNo: String(slNo),
PrdNm: group.description,
PrdDesc: group.description,
HsnCd: "87141090", // Known working HSN from old invoice
IsServc: "N", // 87141090 is Goods
Qty: formatQty(1),
Unit: "NOS",
UnitPrice: formatAmount(group.baseAmt),
TotAmt: formatAmount(group.baseAmt),
Discount: 0,
PreTaxVal: formatAmount(group.baseAmt),
AssAmt: formatAmount(group.baseAmt),
GstRt: formatRate(Number(group.gstRate)),
IgstAmt: formatAmount(group.igst),
CgstAmt: formatAmount(group.cgst),
SgstAmt: formatAmount(group.sgst),
CesRt: 0,
CesAmt: 0,
CesNonAdValAmt: 0,
StateCesRt: 0,
StateCesAmt: 0,
StateCesNonAdValAmt: 0,
OthChrg: 0,
TotItemVal: formatAmount(group.itemTotal)
};
});
} else {
@ -173,21 +368,61 @@ export class PWCIntegrationService {
totalSgstAmt = sgstAmt;
totalInvVal = totalItemVal;
const slNo = 1;
// Persistence: Delete old items and insert single fallback item
await ClaimInvoiceItem.destroy({ where: { requestId } });
const fallbackHsn = activity.hsnCode || activity.sacCode || "998311";
const fallbackIsService = this.isServiceHSN(fallbackHsn) === "Y";
const transactionCode = `${customInvoiceNumber}-${String(slNo).padStart(2, '0')}`;
ClaimInvoiceItem.create({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: slNo,
description: activity.title,
hsnCd: fallbackHsn,
qty: 1,
unit: "NOS",
unitPrice: formatAmount(assAmt),
assAmt: formatAmount(assAmt),
gstRt: formatRate(gstRate),
igstAmt: formatAmount(igstAmt),
cgstAmt: formatAmount(cgstAmt),
sgstAmt: formatAmount(sgstAmt),
totItemVal: formatAmount(totalItemVal),
isServc: fallbackIsService ? "Y" : "N",
expenseIds: []
}).catch((err: any) => logger.error(`[PWCIntegrationService] Error saving ClaimInvoiceItem (fallback):`, err));
const hsnCd = "87141090"; // Force valid HSN for PWC Payload
itemList = [{
SlNo: "1",
SlNo: String(slNo),
PrdNm: activity.title,
PrdDesc: activity.title,
HsnCd: activity.hsnCode || activity.sacCode || "9983",
IsServc: "Y",
Qty: 1,
Unit: "OTH",
UnitPrice: formatAmount(finalAmount),
TotAmt: formatAmount(finalAmount),
HsnCd: hsnCd,
IsServc: "N",
Qty: formatQty(1),
Unit: "NOS",
UnitPrice: formatAmount(assAmt),
TotAmt: formatAmount(assAmt),
Discount: 0,
PreTaxVal: formatAmount(assAmt),
AssAmt: formatAmount(assAmt),
GstRt: gstRate,
GstRt: formatRate(gstRate),
IgstAmt: formatAmount(igstAmt),
CgstAmt: formatAmount(cgstAmt),
SgstAmt: formatAmount(sgstAmt),
CesRt: 0,
CesAmt: 0,
CesNonAdValAmt: 0,
StateCesRt: 0,
StateCesAmt: 0,
StateCesNonAdValAmt: 0,
OthChrg: 0,
TotItemVal: formatAmount(totalItemVal)
}];
}
@ -210,7 +445,7 @@ export class PWCIntegrationService {
RegRev: "N",
Typ: "REG",
DiffPercentage: "0",
Taxability: "Taxable",
Taxability: (totalIgstAmt + totalCgstAmt + totalSgstAmt) > 0 ? "Taxable" : "Exempted",
InterIntra: isIGST ? "Inter" : "Intra",
CancelFlag: "N"
},
@ -254,6 +489,7 @@ export class PWCIntegrationService {
];
logger.info(`[PWC] Sending e-invoice request for ${request.requestNumber}`);
logger.info(`[PWC] Payload for ${request.requestNumber}: ${JSON.stringify(payload)}`);
const response = await axios.post(this.apiUrl, payload, {
headers: {
@ -317,7 +553,11 @@ export class PWCIntegrationService {
qrCode,
qrImage: qrB64,
rawResponse: responseData?.pwc_response,
irpResponse: responseData?.irp_response
irpResponse: responseData?.irp_response,
totalIgstAmt: Number(totalIgstAmt.toFixed(2)),
totalCgstAmt: Number(totalCgstAmt.toFixed(2)),
totalSgstAmt: Number(totalSgstAmt.toFixed(2)),
totalAssAmt: Number(totalAssAmt.toFixed(2))
};
} catch (error) {

View File

@ -0,0 +1,55 @@
/**
* Converts numbers to words (Indian currency format).
* Note: This is a simplified version. For production use with all edge cases, consider a robust library or a more complete implementation.
*/
export const amountToWords = (amount: number): string => {
if (amount === 0) return "Zero Only";
// Round to 2 decimal places
amount = Math.round(Math.abs(amount) * 100) / 100;
const parts = amount.toString().split('.');
const integerPart = parseInt(parts[0]);
const decimalPart = parts.length > 1 ? parseInt(parts[1]) : 0;
const units = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten',
'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'];
function convertGroup(n: number): string {
if (n < 20) return units[n];
const digit = n % 10;
if (n < 100) return tens[Math.floor(n / 10)] + (digit ? " " + units[digit] : "");
if (n < 1000) return units[Math.floor(n / 100)] + " Hundred" + (n % 100 == 0 ? "" : " " + convertGroup(n % 100));
return convertGroup(n);
}
let words = "";
let residual = integerPart;
if (residual >= 10000000) {
words += convertGroup(Math.floor(residual / 10000000)) + " Crore ";
residual %= 10000000;
}
if (residual >= 100000) {
words += convertGroup(Math.floor(residual / 100000)) + " Lakh ";
residual %= 100000;
}
if (residual >= 1000) {
words += convertGroup(Math.floor(residual / 1000)) + " Thousand ";
residual %= 1000;
}
if (residual > 0) {
words += convertGroup(residual);
}
if (decimalPart > 0) {
words += " and " + convertGroup(decimalPart) + " Paise";
}
return (words.trim() + " Only").replace(/\s+/g, ' ');
};