536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
import { Request } from 'express';
|
|
import { ClaimInvoice } from '../models/ClaimInvoice';
|
|
import { ClaimCreditNote } from '../models/ClaimCreditNote';
|
|
import { WorkflowRequest } from '../models/WorkflowRequest';
|
|
import { ApprovalLevel } from '../models/ApprovalLevel';
|
|
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
|
import { User } from '../models/User';
|
|
import { ApprovalService } from './approval.service';
|
|
import logger from '../utils/logger';
|
|
import crypto from 'crypto';
|
|
import { activityService } from './activity.service';
|
|
import { notificationService } from './notification.service';
|
|
|
|
/**
|
|
* DMS Webhook Service
|
|
* Handles processing of webhook callbacks from DMS system
|
|
*/
|
|
export class DMSWebhookService {
|
|
private webhookSecret: string;
|
|
private approvalService: ApprovalService;
|
|
|
|
constructor() {
|
|
this.webhookSecret = process.env.DMS_WEBHOOK_SECRET || '';
|
|
this.approvalService = new ApprovalService();
|
|
}
|
|
|
|
/**
|
|
* Validate webhook signature for security
|
|
* DMS should send a signature in the header that we can verify
|
|
*/
|
|
async validateWebhookSignature(req: Request): Promise<boolean> {
|
|
// If webhook secret is not configured, skip validation (for development)
|
|
if (!this.webhookSecret) {
|
|
logger.warn('[DMSWebhook] Webhook secret not configured, skipping signature validation');
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const signature = req.headers['x-dms-signature'] as string;
|
|
if (!signature) {
|
|
logger.warn('[DMSWebhook] Missing webhook signature in header');
|
|
return false;
|
|
}
|
|
|
|
// Create HMAC hash of the request body
|
|
const body = JSON.stringify(req.body);
|
|
const expectedSignature = crypto
|
|
.createHmac('sha256', this.webhookSecret)
|
|
.update(body)
|
|
.digest('hex');
|
|
|
|
// Compare signatures (use constant-time comparison to prevent timing attacks)
|
|
const isValid = crypto.timingSafeEqual(
|
|
Buffer.from(signature),
|
|
Buffer.from(expectedSignature)
|
|
);
|
|
|
|
if (!isValid) {
|
|
logger.warn('[DMSWebhook] Invalid webhook signature');
|
|
}
|
|
|
|
return isValid;
|
|
} catch (error) {
|
|
logger.error('[DMSWebhook] Error validating webhook signature:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process invoice generation webhook from DMS
|
|
*/
|
|
async processInvoiceWebhook(payload: any): Promise<{
|
|
success: boolean;
|
|
invoiceNumber?: string;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
// Validate required fields
|
|
const requiredFields = ['request_number', 'document_no', 'document_type'];
|
|
for (const field of requiredFields) {
|
|
if (!payload[field]) {
|
|
return {
|
|
success: false,
|
|
error: `Missing required field: ${field}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Find workflow request by request number
|
|
const request = await WorkflowRequest.findOne({
|
|
where: {
|
|
requestNumber: payload.request_number,
|
|
},
|
|
});
|
|
|
|
if (!request) {
|
|
return {
|
|
success: false,
|
|
error: `Request not found: ${payload.request_number}`,
|
|
};
|
|
}
|
|
|
|
// Find or create invoice record
|
|
let invoice = await ClaimInvoice.findOne({
|
|
where: { requestId: request.requestId },
|
|
});
|
|
|
|
// Create invoice if it doesn't exist (new flow: webhook creates invoice)
|
|
if (!invoice) {
|
|
logger.info('[DMSWebhook] Invoice record not found, creating new invoice from webhook', {
|
|
requestNumber: payload.request_number,
|
|
});
|
|
|
|
invoice = await ClaimInvoice.create({
|
|
requestId: request.requestId,
|
|
invoiceNumber: payload.document_no,
|
|
dmsNumber: payload.document_no,
|
|
invoiceDate: payload.document_date ? new Date(payload.document_date) : new Date(),
|
|
amount: payload.total_amount || payload.claim_amount,
|
|
status: 'GENERATED',
|
|
generatedAt: new Date(),
|
|
invoiceFilePath: payload.invoice_file_path || null,
|
|
errorMessage: payload.error_message || null,
|
|
description: this.buildInvoiceDescription(payload),
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Invoice created successfully from webhook', {
|
|
requestNumber: payload.request_number,
|
|
invoiceNumber: payload.document_no,
|
|
});
|
|
} else {
|
|
// Update existing invoice with DMS response data
|
|
await invoice.update({
|
|
invoiceNumber: payload.document_no,
|
|
dmsNumber: payload.document_no, // DMS document number
|
|
invoiceDate: payload.document_date ? new Date(payload.document_date) : new Date(),
|
|
amount: payload.total_amount || payload.claim_amount,
|
|
status: 'GENERATED',
|
|
generatedAt: new Date(),
|
|
invoiceFilePath: payload.invoice_file_path || null,
|
|
errorMessage: payload.error_message || null,
|
|
// Store additional DMS data in description or separate fields if needed
|
|
description: this.buildInvoiceDescription(payload),
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Invoice updated successfully', {
|
|
requestNumber: payload.request_number,
|
|
invoiceNumber: payload.document_no,
|
|
irnNo: payload.irn_no,
|
|
});
|
|
}
|
|
|
|
// Auto-approve Step 7 and move to Step 8
|
|
await this.logEInvoiceGenerationActivity(request.requestId, payload.request_number);
|
|
|
|
return {
|
|
success: true,
|
|
invoiceNumber: payload.document_no,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
logger.error('[DMSWebhook] Error processing invoice webhook:', error);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process credit note generation webhook from DMS
|
|
*/
|
|
async processCreditNoteWebhook(payload: any): Promise<{
|
|
success: boolean;
|
|
creditNoteNumber?: string;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
// Validate required fields
|
|
const requiredFields = ['request_number', 'document_no', 'document_type'];
|
|
for (const field of requiredFields) {
|
|
if (!payload[field]) {
|
|
return {
|
|
success: false,
|
|
error: `Missing required field: ${field}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Find workflow request by request number
|
|
const request = await WorkflowRequest.findOne({
|
|
where: {
|
|
requestNumber: payload.request_number,
|
|
},
|
|
});
|
|
|
|
if (!request) {
|
|
return {
|
|
success: false,
|
|
error: `Request not found: ${payload.request_number}`,
|
|
};
|
|
}
|
|
|
|
// Find invoice to link credit note (optional - credit note can exist without invoice)
|
|
const invoice = await ClaimInvoice.findOne({
|
|
where: { requestId: request.requestId },
|
|
});
|
|
|
|
// Find or create credit note record
|
|
let creditNote = await ClaimCreditNote.findOne({
|
|
where: { requestId: request.requestId },
|
|
});
|
|
|
|
// Create credit note if it doesn't exist (new flow: webhook creates credit note)
|
|
if (!creditNote) {
|
|
logger.info('[DMSWebhook] Credit note record not found, creating new credit note from webhook', {
|
|
requestNumber: payload.request_number,
|
|
hasInvoice: !!invoice,
|
|
});
|
|
|
|
creditNote = await ClaimCreditNote.create({
|
|
requestId: request.requestId,
|
|
invoiceId: invoice?.invoiceId || undefined, // Allow undefined if no invoice exists
|
|
creditNoteNumber: payload.document_no,
|
|
creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(),
|
|
creditNoteAmount: payload.total_amount || payload.credit_amount,
|
|
sapDocumentNumber: payload.sap_credit_note_no || null,
|
|
status: 'CONFIRMED',
|
|
confirmedAt: new Date(),
|
|
creditNoteFilePath: payload.credit_note_file_path || null,
|
|
errorMessage: payload.error_message || null,
|
|
description: this.buildCreditNoteDescription(payload),
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Credit note created successfully from webhook', {
|
|
requestNumber: payload.request_number,
|
|
creditNoteNumber: payload.document_no,
|
|
hasInvoice: !!invoice,
|
|
});
|
|
|
|
// Log activity and notify initiator
|
|
await this.logCreditNoteCreationActivity(
|
|
request.requestId,
|
|
payload.request_number,
|
|
payload.document_no,
|
|
creditNote.creditNoteAmount || payload.total_amount || payload.credit_amount
|
|
);
|
|
} else {
|
|
// Update existing credit note with DMS response data
|
|
await creditNote.update({
|
|
invoiceId: invoice?.invoiceId || creditNote.invoiceId, // Preserve existing invoiceId if no invoice found
|
|
creditNoteNumber: payload.document_no,
|
|
creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(),
|
|
creditNoteAmount: payload.total_amount || payload.credit_amount,
|
|
sapDocumentNumber: payload.sap_credit_note_no || null,
|
|
status: 'CONFIRMED',
|
|
confirmedAt: new Date(),
|
|
creditNoteFilePath: payload.credit_note_file_path || null,
|
|
errorMessage: payload.error_message || null,
|
|
description: this.buildCreditNoteDescription(payload),
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Credit note updated successfully', {
|
|
requestNumber: payload.request_number,
|
|
creditNoteNumber: payload.document_no,
|
|
sapCreditNoteNo: payload.sap_credit_note_no,
|
|
irnNo: payload.irn_no,
|
|
hasInvoice: !!invoice,
|
|
});
|
|
|
|
// Log activity and notify initiator for updated credit note
|
|
await this.logCreditNoteCreationActivity(
|
|
request.requestId,
|
|
payload.request_number,
|
|
payload.document_no,
|
|
creditNote.creditNoteAmount || payload.total_amount || payload.credit_amount
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
creditNoteNumber: payload.document_no,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
logger.error('[DMSWebhook] Error processing credit note webhook:', error);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build invoice description from DMS payload
|
|
*/
|
|
private buildInvoiceDescription(payload: any): string {
|
|
const parts: string[] = [];
|
|
|
|
if (payload.irn_no) {
|
|
parts.push(`IRN: ${payload.irn_no}`);
|
|
}
|
|
if (payload.item_code_no) {
|
|
parts.push(`Item Code: ${payload.item_code_no}`);
|
|
}
|
|
if (payload.hsn_sac_code) {
|
|
parts.push(`HSN/SAC: ${payload.hsn_sac_code}`);
|
|
}
|
|
if (payload.cgst_amount || payload.sgst_amount || payload.igst_amount) {
|
|
parts.push(`GST - CGST: ${payload.cgst_amount || 0}, SGST: ${payload.sgst_amount || 0}, IGST: ${payload.igst_amount || 0}`);
|
|
}
|
|
|
|
return parts.length > 0 ? parts.join(' | ') : '';
|
|
}
|
|
|
|
/**
|
|
* Build credit note description from DMS payload
|
|
*/
|
|
private buildCreditNoteDescription(payload: any): string {
|
|
const parts: string[] = [];
|
|
|
|
if (payload.irn_no) {
|
|
parts.push(`IRN: ${payload.irn_no}`);
|
|
}
|
|
if (payload.sap_credit_note_no) {
|
|
parts.push(`SAP CN: ${payload.sap_credit_note_no}`);
|
|
}
|
|
if (payload.credit_type) {
|
|
parts.push(`Credit Type: ${payload.credit_type}`);
|
|
}
|
|
if (payload.item_code_no) {
|
|
parts.push(`Item Code: ${payload.item_code_no}`);
|
|
}
|
|
if (payload.hsn_sac_code) {
|
|
parts.push(`HSN/SAC: ${payload.hsn_sac_code}`);
|
|
}
|
|
if (payload.cgst_amount || payload.sgst_amount || payload.igst_amount) {
|
|
parts.push(`GST - CGST: ${payload.cgst_amount || 0}, SGST: ${payload.sgst_amount || 0}, IGST: ${payload.igst_amount || 0}`);
|
|
}
|
|
|
|
return parts.length > 0 ? parts.join(' | ') : '';
|
|
}
|
|
|
|
/**
|
|
* Log Credit Note Creation as activity and notify initiator
|
|
* This is called after credit note is created/updated from DMS webhook
|
|
*/
|
|
private async logCreditNoteCreationActivity(
|
|
requestId: string,
|
|
requestNumber: string,
|
|
creditNoteNumber: string,
|
|
creditNoteAmount: number
|
|
): Promise<void> {
|
|
try {
|
|
// Check if this is a claim management workflow
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
logger.warn('[DMSWebhook] Request not found for credit note activity logging', { requestId });
|
|
return;
|
|
}
|
|
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
logger.info('[DMSWebhook] Not a claim management workflow, skipping credit note activity logging', {
|
|
requestId,
|
|
workflowType,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const initiatorId = (request as any).initiatorId;
|
|
if (!initiatorId) {
|
|
logger.warn('[DMSWebhook] Initiator ID not found for credit note notification', { requestId });
|
|
return;
|
|
}
|
|
|
|
// Log activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: undefined, // System event (no user means it's a system event)
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Credit Note Generated',
|
|
details: `Credit note generated from DMS. Credit Note Number: ${creditNoteNumber}. Credit Note Amount: ₹${creditNoteAmount || 0}. Request: ${requestNumber}`,
|
|
category: 'credit_note',
|
|
severity: 'INFO',
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Credit note activity logged successfully', {
|
|
requestId,
|
|
requestNumber,
|
|
creditNoteNumber,
|
|
});
|
|
|
|
// Get dealer information from claim details
|
|
const claimDetails = await DealerClaimDetails.findOne({
|
|
where: { requestId }
|
|
});
|
|
|
|
let dealerUserId: string | null = null;
|
|
if (claimDetails?.dealerEmail) {
|
|
const dealerUser = await User.findOne({
|
|
where: { email: claimDetails.dealerEmail.toLowerCase() },
|
|
attributes: ['userId'],
|
|
});
|
|
dealerUserId = dealerUser?.userId || null;
|
|
|
|
if (dealerUserId) {
|
|
logger.info('[DMSWebhook] Found dealer user for notification', {
|
|
requestId,
|
|
dealerEmail: claimDetails.dealerEmail,
|
|
dealerUserId,
|
|
});
|
|
} else {
|
|
logger.warn('[DMSWebhook] Dealer email found but user not found in system', {
|
|
requestId,
|
|
dealerEmail: claimDetails.dealerEmail,
|
|
});
|
|
}
|
|
} else {
|
|
logger.info('[DMSWebhook] No dealer email found in claim details', { requestId });
|
|
}
|
|
|
|
// Send notification to initiator
|
|
await notificationService.sendToUsers([initiatorId], {
|
|
title: 'Credit Note Generated',
|
|
body: `Credit note ${creditNoteNumber} has been generated for request ${requestNumber}. Amount: ₹${creditNoteAmount || 0}`,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'status_change',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false,
|
|
metadata: {
|
|
creditNoteNumber,
|
|
creditNoteAmount,
|
|
source: 'dms_webhook',
|
|
},
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Credit note notification sent to initiator', {
|
|
requestId,
|
|
requestNumber,
|
|
initiatorId,
|
|
creditNoteNumber,
|
|
});
|
|
|
|
// Send notification to dealer if dealer user exists
|
|
if (dealerUserId) {
|
|
await notificationService.sendToUsers([dealerUserId], {
|
|
title: 'Credit Note Generated',
|
|
body: `Credit note ${creditNoteNumber} has been generated for your claim request ${requestNumber}. Amount: ₹${creditNoteAmount || 0}`,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'status_change',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false,
|
|
metadata: {
|
|
creditNoteNumber,
|
|
creditNoteAmount,
|
|
source: 'dms_webhook',
|
|
recipient: 'dealer',
|
|
},
|
|
});
|
|
|
|
logger.info('[DMSWebhook] Credit note notification sent to dealer', {
|
|
requestId,
|
|
requestNumber,
|
|
dealerUserId,
|
|
dealerEmail: claimDetails?.dealerEmail,
|
|
creditNoteNumber,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
logger.error('[DMSWebhook] Error logging credit note activity:', {
|
|
requestId,
|
|
requestNumber,
|
|
error: errorMessage,
|
|
});
|
|
// Don't throw error - webhook processing should continue even if activity/notification fails
|
|
// The credit note is already created/updated, which is the primary goal
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log E-Invoice Generation as activity (no longer an approval step)
|
|
* This is called after invoice is created/updated from DMS webhook
|
|
*/
|
|
private async logEInvoiceGenerationActivity(requestId: string, requestNumber: string): Promise<void> {
|
|
try {
|
|
// Check if this is a claim management workflow
|
|
const request = await WorkflowRequest.findByPk(requestId);
|
|
if (!request) {
|
|
logger.warn('[DMSWebhook] Request not found for Step 7 auto-approval', { requestId });
|
|
return;
|
|
}
|
|
|
|
const workflowType = (request as any).workflowType;
|
|
if (workflowType !== 'CLAIM_MANAGEMENT') {
|
|
logger.info('[DMSWebhook] Not a claim management workflow, skipping Step 7 auto-approval', {
|
|
requestId,
|
|
workflowType,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// E-Invoice Generation is now an activity log only, not an approval step
|
|
// Log the activity using the dealerClaimService
|
|
const { DealerClaimService } = await import('./dealerClaim.service');
|
|
const dealerClaimService = new DealerClaimService();
|
|
const invoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
const invoiceNumber = invoice?.invoiceNumber || 'N/A';
|
|
|
|
await dealerClaimService.logEInvoiceGenerationActivity(requestId, invoiceNumber);
|
|
|
|
logger.info('[DMSWebhook] E-Invoice Generation activity logged successfully', {
|
|
requestId,
|
|
requestNumber,
|
|
invoiceNumber,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
logger.error('[DMSWebhook] Error logging E-Invoice Generation activity:', {
|
|
requestId,
|
|
requestNumber,
|
|
error: errorMessage,
|
|
});
|
|
// Don't throw error - webhook processing should continue even if activity logging fails
|
|
// The invoice is already created/updated, which is the primary goal
|
|
}
|
|
}
|
|
}
|
|
|