From 1ac169dc7f42ce6fdbe0291f6cfa2aa231f32c4c Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Thu, 18 Dec 2025 21:23:37 +0530 Subject: [PATCH] web hooks created for the dms team --- docs/DMS_WEBHOOK_API.md | 574 +++++++++++++++ docs/GCP_PRODUCTION_SETUP.md | 669 ++++++++++++++++++ src/controllers/dealerClaim.controller.ts | 37 + src/controllers/dmsWebhook.controller.ts | 117 +++ ...create-claim-invoice-credit-note-tables.ts | 58 +- ...8-fix-claim-invoice-credit-note-columns.ts | 240 +++++++ src/models/ClaimCreditNote.ts | 76 +- src/models/ClaimInvoice.ts | 39 +- src/routes/dealerClaim.routes.ts | 7 + src/routes/dmsWebhook.routes.ts | 47 ++ src/routes/index.ts | 2 + src/scripts/auto-setup.ts | 2 + src/scripts/migrate.ts | 2 + src/services/approval.service.ts | 21 +- src/services/dealerClaim.service.ts | 125 +++- src/services/dmsWebhook.service.ts | 408 +++++++++++ 16 files changed, 2386 insertions(+), 38 deletions(-) create mode 100644 docs/DMS_WEBHOOK_API.md create mode 100644 docs/GCP_PRODUCTION_SETUP.md create mode 100644 src/controllers/dmsWebhook.controller.ts create mode 100644 src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts create mode 100644 src/routes/dmsWebhook.routes.ts create mode 100644 src/services/dmsWebhook.service.ts diff --git a/docs/DMS_WEBHOOK_API.md b/docs/DMS_WEBHOOK_API.md new file mode 100644 index 0000000..83aa0a2 --- /dev/null +++ b/docs/DMS_WEBHOOK_API.md @@ -0,0 +1,574 @@ +# DMS Webhook API Documentation + +## Overview + +This document describes the webhook endpoints that DMS (Document Management System) will call to notify the RE Workflow System after processing invoice and credit note generation requests. + +--- + +## Table of Contents + +1. [Webhook Overview](#1-webhook-overview) +2. [Authentication](#2-authentication) +3. [Invoice Webhook](#3-invoice-webhook) +4. [Credit Note Webhook](#4-credit-note-webhook) +5. [Payload Specifications](#5-payload-specifications) +6. [Error Handling](#6-error-handling) +7. [Testing](#7-testing) + +--- + +## 1. Webhook Overview + +### 1.1 Purpose + +After RE Workflow System pushes invoice/credit note generation requests to DMS, DMS processes them and sends webhook callbacks with the generated document details, tax information, and other metadata. + +### 1.2 Webhook Flow + +``` +┌─────────────────┐ ┌─────────────────┐ +│ RE Workflow │ │ DMS System │ +│ System │ │ │ +└────────┬────────┘ └────────┬────────┘ + │ │ + │ POST /api/invoices/generate │ + │ { request_number, dealer_code, ... }│ + ├─────────────────────────────────────►│ + │ │ + │ │ Process Invoice + │ │ Generate Document + │ │ Calculate GST + │ │ + │ │ POST /api/v1/webhooks/dms/invoice + │ │ { document_no, irn_no, ... } + │◄─────────────────────────────────────┤ + │ │ + │ Update Invoice Record │ + │ Store IRN, GST Details, etc. │ + │ │ +``` + +--- + +## 2. Authentication + +### 2.1 Webhook Signature + +DMS must include a signature in the request header for security validation: + +**Header:** +``` +X-DMS-Signature: +``` + +**Signature Generation:** +1. Create HMAC-SHA256 hash of the request body (JSON string) +2. Use the shared secret key (`DMS_WEBHOOK_SECRET`) +3. Send the hex-encoded signature in the `X-DMS-Signature` header + +**Example:** +```javascript +const crypto = require('crypto'); +const body = JSON.stringify(payload); +const signature = crypto + .createHmac('sha256', DMS_WEBHOOK_SECRET) + .update(body) + .digest('hex'); +// Send in header: X-DMS-Signature: +``` + +### 2.2 Environment Variable + +Configure the webhook secret in RE Workflow System: + +```env +DMS_WEBHOOK_SECRET=your_shared_secret_key_here +``` + +**Note:** If `DMS_WEBHOOK_SECRET` is not configured, signature validation is skipped (development mode only). + +--- + +## 3. Invoice Webhook + +### 3.1 Endpoint + +**URL:** `POST /api/v1/webhooks/dms/invoice` + +**Base URL Examples:** +- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice` +- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` +- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice` + +### 3.2 Request Headers + +```http +Content-Type: application/json +X-DMS-Signature: +User-Agent: DMS-Webhook-Client/1.0 +``` + +### 3.3 Request Payload + +```json +{ + "request_number": "REQ-2025-12-0001", + "document_no": "EINV-2025-001234", + "document_type": "E-INVOICE", + "document_date": "2025-12-17T10:30:00.000Z", + "dealer_code": "DLR001", + "dealer_name": "ABC Motors", + "activity_name": "Marketing Campaign", + "activity_description": "Q4 Marketing Campaign for Royal Enfield", + "claim_amount": 150000.00, + "io_number": "IO-2025-001", + "item_code_no": "ITEM-001", + "hsn_sac_code": "998314", + "cgst_percentage": 9.0, + "sgst_percentage": 9.0, + "igst_percentage": 0.0, + "cgst_amount": 13500.00, + "sgst_amount": 13500.00, + "igst_amount": 0.00, + "total_amount": 177000.00, + "irn_no": "IRN123456789012345678901234567890123456789012345678901234567890", + "invoice_file_path": "https://dms.example.com/invoices/EINV-2025-001234.pdf", + "error_message": null, + "timestamp": "2025-12-17T10:30:00.000Z" +} +``` + +### 3.4 Payload Field Descriptions + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `request_number` | string | ✅ Yes | Original request number from RE Workflow System (e.g., "REQ-2025-12-0001") | +| `document_no` | string | ✅ Yes | Generated invoice/document number from DMS | +| `document_type` | string | ✅ Yes | Type of document: "E-INVOICE" or "INVOICE" | +| `document_date` | string (ISO 8601) | ✅ Yes | Date when invoice was generated | +| `dealer_code` | string | ✅ Yes | Dealer code (should match original request) | +| `dealer_name` | string | ✅ Yes | Dealer name (should match original request) | +| `activity_name` | string | ✅ Yes | Activity name from original request | +| `activity_description` | string | ✅ Yes | Activity description from original request | +| `claim_amount` | number | ✅ Yes | Original claim amount (before tax) | +| `io_number` | string | No | Internal Order number (if provided in original request) | +| `item_code_no` | string | ✅ Yes | Item code number (provided by DMS team based on activity list) | +| `hsn_sac_code` | string | ✅ Yes | HSN/SAC code for the invoice | +| `cgst_percentage` | number | ✅ Yes | CGST percentage (e.g., 9.0 for 9%) | +| `sgst_percentage` | number | ✅ Yes | SGST percentage (e.g., 9.0 for 9%) | +| `igst_percentage` | number | ✅ Yes | IGST percentage (0.0 for intra-state, >0 for inter-state) | +| `cgst_amount` | number | ✅ Yes | CGST amount in INR | +| `sgst_amount` | number | ✅ Yes | SGST amount in INR | +| `igst_amount` | number | ✅ Yes | IGST amount in INR | +| `total_amount` | number | ✅ Yes | Total invoice amount (claim_amount + all taxes) | +| `irn_no` | string | No | Invoice Reference Number (IRN) from GST portal (if generated) | +| `invoice_file_path` | string | ✅ Yes | URL or path to the generated invoice PDF/document file | +| `error_message` | string | No | Error message if invoice generation failed | +| `timestamp` | string (ISO 8601) | ✅ Yes | Timestamp when webhook is sent | + +### 3.5 Success Response + +**Status Code:** `200 OK` + +```json +{ + "success": true, + "message": "Invoice webhook processed successfully", + "data": { + "message": "Invoice webhook processed successfully", + "invoiceNumber": "EINV-2025-001234", + "requestNumber": "REQ-2025-12-0001" + } +} +``` + +### 3.6 Error Response + +**Status Code:** `400 Bad Request` or `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to process invoice webhook", + "error": "Request not found: REQ-2025-12-0001" +} +``` + +--- + +## 4. Credit Note Webhook + +### 4.1 Endpoint + +**URL:** `POST /api/v1/webhooks/dms/credit-note` + +**Base URL Examples:** +- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note` +- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` +- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note` + +### 4.2 Request Headers + +```http +Content-Type: application/json +X-DMS-Signature: +User-Agent: DMS-Webhook-Client/1.0 +``` + +### 4.3 Request Payload + +```json +{ + "request_number": "REQ-2025-12-0001", + "document_no": "CN-2025-001234", + "document_type": "CREDIT_NOTE", + "document_date": "2025-12-17T11:00:00.000Z", + "dealer_code": "DLR001", + "dealer_name": "ABC Motors", + "activity_name": "Marketing Campaign", + "activity_description": "Q4 Marketing Campaign for Royal Enfield", + "claim_amount": 150000.00, + "io_number": "IO-2025-001", + "item_code_no": "ITEM-001", + "hsn_sac_code": "998314", + "cgst_percentage": 9.0, + "sgst_percentage": 9.0, + "igst_percentage": 0.0, + "cgst_amount": 13500.00, + "sgst_amount": 13500.00, + "igst_amount": 0.00, + "total_amount": 177000.00, + "credit_type": "GST", + "irn_no": "IRN987654321098765432109876543210987654321098765432109876543210", + "sap_credit_note_no": "SAP-CN-2025-001234", + "credit_note_file_path": "https://dms.example.com/credit-notes/CN-2025-001234.pdf", + "error_message": null, + "timestamp": "2025-12-17T11:00:00.000Z" +} +``` + +### 4.4 Payload Field Descriptions + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `request_number` | string | ✅ Yes | Original request number from RE Workflow System | +| `document_no` | string | ✅ Yes | Generated credit note number from DMS | +| `document_type` | string | ✅ Yes | Type of document: "CREDIT_NOTE" | +| `document_date` | string (ISO 8601) | ✅ Yes | Date when credit note was generated | +| `dealer_code` | string | ✅ Yes | Dealer code (should match original request) | +| `dealer_name` | string | ✅ Yes | Dealer name (should match original request) | +| `activity_name` | string | ✅ Yes | Activity name from original request | +| `activity_description` | string | ✅ Yes | Activity description from original request | +| `claim_amount` | string | ✅ Yes | Original claim amount (before tax) | +| `io_number` | string | No | Internal Order number (if provided) | +| `item_code_no` | string | ✅ Yes | Item code number (provided by DMS team) | +| `hsn_sac_code` | string | ✅ Yes | HSN/SAC code for the credit note | +| `cgst_percentage` | number | ✅ Yes | CGST percentage | +| `sgst_percentage` | number | ✅ Yes | SGST percentage | +| `igst_percentage` | number | ✅ Yes | IGST percentage | +| `cgst_amount` | number | ✅ Yes | CGST amount in INR | +| `sgst_amount` | number | ✅ Yes | SGST amount in INR | +| `igst_amount` | number | ✅ Yes | IGST amount in INR | +| `total_amount` | number | ✅ Yes | Total credit note amount (claim_amount + all taxes) | +| `credit_type` | string | ✅ Yes | Type of credit: "GST" or "Commercial Credit" | +| `irn_no` | string | No | Invoice Reference Number (IRN) for credit note (if generated) | +| `sap_credit_note_no` | string | ✅ Yes | SAP Credit Note Number (generated by SAP system) | +| `credit_note_file_path` | string | ✅ Yes | URL or path to the generated credit note PDF/document file | +| `error_message` | string | No | Error message if credit note generation failed | +| `timestamp` | string (ISO 8601) | ✅ Yes | Timestamp when webhook is sent | + +### 4.5 Success Response + +**Status Code:** `200 OK` + +```json +{ + "success": true, + "message": "Credit note webhook processed successfully", + "data": { + "message": "Credit note webhook processed successfully", + "creditNoteNumber": "CN-2025-001234", + "requestNumber": "REQ-2025-12-0001" + } +} +``` + +### 4.6 Error Response + +**Status Code:** `400 Bad Request` or `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to process credit note webhook", + "error": "Credit note record not found for request: REQ-2025-12-0001" +} +``` + +--- + +## 5. Payload Specifications + +### 5.1 Data Mapping: RE Workflow → DMS + +When RE Workflow System sends data to DMS, it includes: + +| RE Workflow Field | DMS Receives | Notes | +|-------------------|--------------|-------| +| `requestNumber` | `request_number` | Direct mapping | +| `dealerCode` | `dealer_code` | Direct mapping | +| `dealerName` | `dealer_name` | Direct mapping | +| `activityName` | `activity_name` | From claim details | +| `activityDescription` | `activity_description` | From claim details | +| `claimAmount` | `claim_amount` | Total claim amount | +| `ioNumber` | `io_number` | If available | + +### 5.2 Data Mapping: DMS → RE Workflow + +When DMS sends webhook, RE Workflow System stores: + +| DMS Webhook Field | RE Workflow Database Field | Table | +|-------------------|---------------------------|-------| +| `document_no` | `invoice_number` / `credit_note_number` | `claim_invoices` / `claim_credit_notes` | +| `document_date` | `invoice_date` / `credit_note_date` | `claim_invoices` / `claim_credit_notes` | +| `total_amount` | `invoice_amount` / `credit_note_amount` | `claim_invoices` / `claim_credit_notes` | +| `invoice_file_path` | `invoice_file_path` | `claim_invoices` | +| `credit_note_file_path` | `credit_note_file_path` | `claim_credit_notes` | +| `irn_no` | Stored in `description` field | Both tables | +| `sap_credit_note_no` | `sap_document_number` | `claim_credit_notes` | +| `item_code_no` | Stored in `description` field | Both tables | +| `hsn_sac_code` | Stored in `description` field | Both tables | +| GST amounts | Stored in `description` field | Both tables | +| `credit_type` | Stored in `description` field | `claim_credit_notes` | + +### 5.3 GST Calculation Logic + +**Intra-State (Same State):** +- CGST: Applied (e.g., 9%) +- SGST: Applied (e.g., 9%) +- IGST: 0% + +**Inter-State (Different State):** +- CGST: 0% +- SGST: 0% +- IGST: Applied (e.g., 18%) + +**Total Amount Calculation:** +``` +total_amount = claim_amount + cgst_amount + sgst_amount + igst_amount +``` + +--- + +## 6. Error Handling + +### 6.1 Common Error Scenarios + +| Error | Status Code | Description | Solution | +|-------|-------------|-------------|----------| +| Invalid Signature | 401 | Webhook signature validation failed | Check `DMS_WEBHOOK_SECRET` and signature generation | +| Missing Required Field | 400 | Required field is missing in payload | Ensure all required fields are included | +| Request Not Found | 400 | Request number doesn't exist in system | Verify request number matches original request | +| Invoice Not Found | 400 | Invoice record not found for request | Ensure invoice was created before webhook | +| Credit Note Not Found | 400 | Credit note record not found for request | Ensure credit note was created before webhook | +| Database Error | 500 | Internal database error | Check database connection and logs | + +### 6.2 Retry Logic + +DMS should implement retry logic for failed webhook deliveries: + +- **Initial Retry:** After 1 minute +- **Second Retry:** After 5 minutes +- **Third Retry:** After 15 minutes +- **Final Retry:** After 1 hour + +**Maximum Retries:** 4 attempts + +**Retry Conditions:** +- HTTP 5xx errors (server errors) +- Network timeouts +- Connection failures + +**Do NOT Retry:** +- HTTP 400 errors (client errors - invalid payload) +- HTTP 401 errors (authentication errors) + +### 6.3 Idempotency + +Webhooks should be idempotent. If DMS sends the same webhook multiple times: +- RE Workflow System will update the record with the latest data +- No duplicate records will be created +- Status will be updated to reflect the latest state + +--- + +## 7. Testing + +### 7.1 Test Invoice Webhook + +```bash +curl -X POST "http://localhost:5000/api/v1/webhooks/dms/invoice" \ + -H "Content-Type: application/json" \ + -H "X-DMS-Signature: " \ + -d '{ + "request_number": "REQ-2025-12-0001", + "document_no": "EINV-TEST-001", + "document_type": "E-INVOICE", + "document_date": "2025-12-17T10:30:00.000Z", + "dealer_code": "DLR001", + "dealer_name": "Test Dealer", + "activity_name": "Test Activity", + "activity_description": "Test Description", + "claim_amount": 100000.00, + "io_number": "IO-TEST-001", + "item_code_no": "ITEM-001", + "hsn_sac_code": "998314", + "cgst_percentage": 9.0, + "sgst_percentage": 9.0, + "igst_percentage": 0.0, + "cgst_amount": 9000.00, + "sgst_amount": 9000.00, + "igst_amount": 0.00, + "total_amount": 118000.00, + "irn_no": "IRN123456789012345678901234567890123456789012345678901234567890", + "invoice_file_path": "https://dms.example.com/invoices/EINV-TEST-001.pdf", + "timestamp": "2025-12-17T10:30:00.000Z" + }' +``` + +### 7.2 Test Credit Note Webhook + +```bash +curl -X POST "http://localhost:5000/api/v1/webhooks/dms/credit-note" \ + -H "Content-Type: application/json" \ + -H "X-DMS-Signature: " \ + -d '{ + "request_number": "REQ-2025-12-0001", + "document_no": "CN-TEST-001", + "document_type": "CREDIT_NOTE", + "document_date": "2025-12-17T11:00:00.000Z", + "dealer_code": "DLR001", + "dealer_name": "Test Dealer", + "activity_name": "Test Activity", + "activity_description": "Test Description", + "claim_amount": 100000.00, + "io_number": "IO-TEST-001", + "item_code_no": "ITEM-001", + "hsn_sac_code": "998314", + "cgst_percentage": 9.0, + "sgst_percentage": 9.0, + "igst_percentage": 0.0, + "cgst_amount": 9000.00, + "sgst_amount": 9000.00, + "igst_amount": 0.00, + "total_amount": 118000.00, + "credit_type": "GST", + "irn_no": "IRN987654321098765432109876543210987654321098765432109876543210", + "sap_credit_note_no": "SAP-CN-TEST-001", + "credit_note_file_path": "https://dms.example.com/credit-notes/CN-TEST-001.pdf", + "timestamp": "2025-12-17T11:00:00.000Z" + }' +``` + +### 7.3 Signature Calculation (Node.js Example) + +```javascript +const crypto = require('crypto'); + +function calculateSignature(payload, secret) { + const body = JSON.stringify(payload); + return crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex'); +} + +const payload = { /* webhook payload */ }; +const secret = process.env.DMS_WEBHOOK_SECRET; +const signature = calculateSignature(payload, secret); + +// Use in header: X-DMS-Signature: +``` + +--- + +## 8. Integration Checklist + +### 8.1 DMS Team Checklist + +- [ ] Configure webhook URLs in DMS system +- [ ] Set up `DMS_WEBHOOK_SECRET` (shared secret) +- [ ] Implement signature generation (HMAC-SHA256) +- [ ] Test webhook delivery to RE Workflow endpoints +- [ ] Implement retry logic for failed deliveries +- [ ] Set up monitoring/alerting for webhook failures +- [ ] Document webhook payload structure +- [ ] Coordinate with RE Workflow team for testing + +### 8.2 RE Workflow Team Checklist + +- [ ] Configure `DMS_WEBHOOK_SECRET` in environment variables +- [ ] Deploy webhook endpoints to UAT/Production +- [ ] Test webhook endpoints with sample payloads +- [ ] Verify database updates after webhook processing +- [ ] Set up monitoring/alerting for webhook failures +- [ ] Document webhook endpoints for DMS team +- [ ] Coordinate with DMS team for integration testing + +--- + +## 9. Support & Troubleshooting + +### 9.1 Logs + +RE Workflow System logs webhook processing: + +- **Success:** `[DMSWebhook] Invoice webhook processed successfully` +- **Error:** `[DMSWebhook] Error processing invoice webhook: ` +- **Validation:** `[DMSWebhook] Invalid webhook signature` + +### 9.2 Common Issues + +**Issue: Webhook signature validation fails** +- Verify `DMS_WEBHOOK_SECRET` matches in both systems +- Check signature calculation method (HMAC-SHA256) +- Ensure request body is JSON stringified correctly + +**Issue: Request not found** +- Verify `request_number` matches the original request +- Check if request exists in RE Workflow database +- Ensure request was created before webhook is sent + +**Issue: Invoice/Credit Note record not found** +- Verify invoice/credit note was created in RE Workflow +- Check if webhook is sent before record creation +- Review workflow step sequence + +--- + +## 10. Environment Configuration + +### 10.1 Environment Variables + +Add to RE Workflow System `.env` file: + +```env +# DMS Webhook Configuration +DMS_WEBHOOK_SECRET=your_shared_secret_key_here +``` + +### 10.2 Webhook URLs by Environment + +| Environment | Invoice Webhook URL | Credit Note Webhook URL | +|-------------|---------------------|-------------------------| +| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` | +| UAT | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` | +| Production | `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note` | + +--- + +**Document Version:** 1.0 +**Last Updated:** December 2024 +**Maintained By:** RE Workflow Development Team + diff --git a/docs/GCP_PRODUCTION_SETUP.md b/docs/GCP_PRODUCTION_SETUP.md new file mode 100644 index 0000000..6311e4d --- /dev/null +++ b/docs/GCP_PRODUCTION_SETUP.md @@ -0,0 +1,669 @@ +# GCP Cloud Storage - Production Setup Guide + +## Overview + +This guide provides step-by-step instructions for setting up Google Cloud Storage (GCS) for the **Royal Enfield Workflow System** in **Production** environment. This document focuses specifically on production deployment requirements, folder structure, and environment configuration. + +--- + +## Table of Contents + +1. [Production Requirements](#1-production-requirements) +2. [GCP Bucket Configuration](#2-gcp-bucket-configuration) +3. [Service Account Setup](#3-service-account-setup) +4. [Environment Variables Configuration](#4-environment-variables-configuration) +5. [Folder Structure in GCS](#5-folder-structure-in-gcs) +6. [Security & Access Control](#6-security--access-control) +7. [CORS Configuration](#7-cors-configuration) +8. [Lifecycle Management](#8-lifecycle-management) +9. [Monitoring & Alerts](#9-monitoring--alerts) +10. [Verification & Testing](#10-verification--testing) + +--- + +## 1. Production Requirements + +### 1.1 Application Details + +| Item | Production Value | +|------|------------------| +| **Application** | Royal Enfield Workflow System | +| **Environment** | Production | +| **Domain** | `https://reflow.royalenfield.com` | +| **Purpose** | Store workflow documents, attachments, invoices, and credit notes | +| **Storage Type** | Google Cloud Storage (GCS) | +| **Region** | `asia-south1` (Mumbai) | + +### 1.2 Storage Requirements + +The application stores: +- **Workflow Documents**: Initial documents uploaded during request creation +- **Work Note Attachments**: Files attached during approval workflow +- **Invoice Files**: Generated e-invoice PDFs +- **Credit Note Files**: Generated credit note PDFs +- **Dealer Claim Documents**: Proposal documents, completion documents + +--- + +## 2. GCP Bucket Configuration + +### 2.1 Production Bucket Settings + +| Setting | Production Value | +|---------|------------------| +| **Bucket Name** | `reflow-documents-prod` | +| **Location Type** | Region | +| **Region** | `asia-south1` (Mumbai) | +| **Storage Class** | Standard (for active files) | +| **Access Control** | Uniform bucket-level access | +| **Public Access Prevention** | Enforced (Block all public access) | +| **Versioning** | Enabled (for recovery) | +| **Lifecycle Rules** | Configured (see section 8) | + +### 2.2 Create Production Bucket + +```bash +# Create production bucket +gcloud storage buckets create gs://reflow-documents-prod \ + --project=re-platform-workflow-dealer \ + --location=asia-south1 \ + --uniform-bucket-level-access \ + --public-access-prevention + +# Enable versioning +gcloud storage buckets update gs://reflow-documents-prod \ + --versioning + +# Verify bucket creation +gcloud storage buckets describe gs://reflow-documents-prod +``` + +### 2.3 Bucket Naming Convention + +| Environment | Bucket Name | Purpose | +|-------------|-------------|---------| +| Development | `reflow-documents-dev` | Development testing | +| UAT | `reflow-documents-uat` | User acceptance testing | +| Production | `reflow-documents-prod` | Live production data | + +--- + +## 3. Service Account Setup + +### 3.1 Create Production Service Account + +```bash +# Create service account for production +gcloud iam service-accounts create reflow-storage-prod-sa \ + --display-name="RE Workflow Production Storage Service Account" \ + --description="Service account for production file storage operations" \ + --project=re-platform-workflow-dealer +``` + +### 3.2 Assign Required Roles + +The service account needs the following IAM roles: + +| Role | Purpose | Required For | +|------|---------|--------------| +| `roles/storage.objectAdmin` | Full control over objects | Upload, delete, update files | +| `roles/storage.objectViewer` | Read objects | Download and preview files | +| `roles/storage.legacyBucketReader` | Read bucket metadata | List files and check bucket status | + +```bash +# Grant Storage Object Admin role +gcloud projects add-iam-policy-binding re-platform-workflow-dealer \ + --member="serviceAccount:reflow-storage-prod-sa@re-platform-workflow-dealer.iam.gserviceaccount.com" \ + --role="roles/storage.objectAdmin" + +# Grant Storage Object Viewer role (for read operations) +gcloud projects add-iam-policy-binding re-platform-workflow-dealer \ + --member="serviceAccount:reflow-storage-prod-sa@re-platform-workflow-dealer.iam.gserviceaccount.com" \ + --role="roles/storage.objectViewer" +``` + +### 3.3 Generate Service Account Key + +```bash +# Generate JSON key file for production +gcloud iam service-accounts keys create ./config/gcp-key-prod.json \ + --iam-account=reflow-storage-prod-sa@re-platform-workflow-dealer.iam.gserviceaccount.com \ + --project=re-platform-workflow-dealer +``` + +⚠️ **Security Warning:** +- Store the key file securely (not in Git) +- Use secure file transfer methods +- Rotate keys periodically (every 90 days recommended) +- Restrict file permissions: `chmod 600 ./config/gcp-key-prod.json` + +--- + +## 4. Environment Variables Configuration + +### 4.1 Required Environment Variables + +Add the following environment variables to your production `.env` file: + +```env +# ============================================ +# Google Cloud Storage (GCP) Configuration +# ============================================ +# GCP Project ID - Must match the project_id in your service account key file +GCP_PROJECT_ID=re-platform-workflow-dealer + +# GCP Bucket Name - Production bucket name +GCP_BUCKET_NAME=reflow-documents-prod + +# GCP Service Account Key File Path +# Can be relative to project root or absolute path +# Example: ./config/gcp-key-prod.json +# Example: /etc/reflow/config/gcp-key-prod.json +GCP_KEY_FILE=./config/gcp-key-prod.json +``` + +### 4.2 Environment Variable Details + +| Variable | Description | Example Value | Required | +|----------|-------------|---------------|----------| +| `GCP_PROJECT_ID` | Your GCP project ID. Must match the `project_id` field in the service account JSON key file. | `re-platform-workflow-dealer` | ✅ Yes | +| `GCP_BUCKET_NAME` | Name of the GCS bucket where files will be stored. Must exist in your GCP project. | `reflow-documents-prod` | ✅ Yes | +| `GCP_KEY_FILE` | Path to the service account JSON key file. Can be relative (from project root) or absolute path. | `./config/gcp-key-prod.json` | ✅ Yes | + +### 4.3 File Path Configuration + +**Relative Path (Recommended for Development):** +```env +GCP_KEY_FILE=./config/gcp-key-prod.json +``` + +**Absolute Path (Recommended for Production):** +```env +GCP_KEY_FILE=/etc/reflow/config/gcp-key-prod.json +``` + +### 4.4 Verification + +After setting environment variables, verify the configuration: + +```bash +# Check if variables are set +echo $GCP_PROJECT_ID +echo $GCP_BUCKET_NAME +echo $GCP_KEY_FILE + +# Verify key file exists +ls -la $GCP_KEY_FILE + +# Verify key file permissions (should be 600) +stat -c "%a %n" $GCP_KEY_FILE +``` + +--- + +## 5. Folder Structure in GCS + +### 5.1 Production Bucket Structure + +``` +reflow-documents-prod/ +│ +├── requests/ # All workflow-related files +│ ├── REQ-2025-12-0001/ # Request-specific folder +│ │ ├── documents/ # Initial request documents +│ │ │ ├── 1701234567890-abc123-proposal.pdf +│ │ │ ├── 1701234567891-def456-specification.docx +│ │ │ └── 1701234567892-ghi789-budget.xlsx +│ │ │ +│ │ ├── attachments/ # Work note attachments +│ │ │ ├── 1701234567893-jkl012-approval_note.pdf +│ │ │ ├── 1701234567894-mno345-signature.png +│ │ │ └── 1701234567895-pqr678-supporting_doc.pdf +│ │ │ +│ │ ├── invoices/ # Generated invoice files +│ │ │ └── 1701234567896-stu901-invoice_REQ-2025-12-0001.pdf +│ │ │ +│ │ └── credit-notes/ # Generated credit note files +│ │ └── 1701234567897-vwx234-credit_note_REQ-2025-12-0001.pdf +│ │ +│ ├── REQ-2025-12-0002/ +│ │ ├── documents/ +│ │ ├── attachments/ +│ │ ├── invoices/ +│ │ └── credit-notes/ +│ │ +│ └── REQ-2025-12-0003/ +│ └── ... +│ +└── temp/ # Temporary uploads (auto-deleted after 24h) + └── (temporary files before processing) +``` + +### 5.2 File Path Patterns + +| File Type | Path Pattern | Example | +|-----------|--------------|---------| +| **Documents** | `requests/{requestNumber}/documents/{timestamp}-{hash}-{filename}` | `requests/REQ-2025-12-0001/documents/1701234567890-abc123-proposal.pdf` | +| **Attachments** | `requests/{requestNumber}/attachments/{timestamp}-{hash}-{filename}` | `requests/REQ-2025-12-0001/attachments/1701234567893-jkl012-approval_note.pdf` | +| **Invoices** | `requests/{requestNumber}/invoices/{timestamp}-{hash}-{filename}` | `requests/REQ-2025-12-0001/invoices/1701234567896-stu901-invoice_REQ-2025-12-0001.pdf` | +| **Credit Notes** | `requests/{requestNumber}/credit-notes/{timestamp}-{hash}-{filename}` | `requests/REQ-2025-12-0001/credit-notes/1701234567897-vwx234-credit_note_REQ-2025-12-0001.pdf` | + +### 5.3 File Naming Convention + +Files are automatically renamed with the following pattern: +``` +{timestamp}-{randomHash}-{sanitizedOriginalName} +``` + +**Example:** +- Original: `My Proposal Document (Final).pdf` +- Stored: `1701234567890-abc123-My_Proposal_Document__Final_.pdf` + +**Benefits:** +- Prevents filename conflicts +- Maintains original filename for reference +- Ensures unique file identifiers +- Safe for URL encoding + +--- + +## 6. Security & Access Control + +### 6.1 Bucket Security Settings + +```bash +# Enforce public access prevention +gcloud storage buckets update gs://reflow-documents-prod \ + --public-access-prevention + +# Enable uniform bucket-level access +gcloud storage buckets update gs://reflow-documents-prod \ + --uniform-bucket-level-access +``` + +### 6.2 Access Control Strategy + +**Production Approach:** +- **Private Bucket**: All files are private by default +- **Signed URLs**: Generate time-limited signed URLs for file access (recommended) +- **Service Account**: Only service account has direct access +- **IAM Policies**: Restrict access to specific service accounts only + +### 6.3 Signed URL Configuration (Recommended) + +For production, use signed URLs instead of public URLs: + +```typescript +// Example: Generate signed URL (valid for 1 hour) +const [url] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + 60 * 60 * 1000, // 1 hour +}); +``` + +### 6.4 Security Checklist + +- [ ] Public access prevention enabled +- [ ] Uniform bucket-level access enabled +- [ ] Service account has minimal required permissions +- [ ] JSON key file stored securely (not in Git) +- [ ] Key file permissions set to 600 +- [ ] CORS configured for specific domains only +- [ ] Bucket versioning enabled +- [ ] Access logging enabled +- [ ] Signed URLs used for file access (if applicable) + +--- + +## 7. CORS Configuration + +### 7.1 Production CORS Policy + +Create `cors-config-prod.json`: + +```json +[ + { + "origin": [ + "https://reflow.royalenfield.com", + "https://www.royalenfield.com" + ], + "method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"], + "responseHeader": [ + "Content-Type", + "Content-Disposition", + "Content-Length", + "Cache-Control", + "x-goog-meta-*" + ], + "maxAgeSeconds": 3600 + } +] +``` + +### 7.2 Apply CORS Configuration + +```bash +gcloud storage buckets update gs://reflow-documents-prod \ + --cors-file=cors-config-prod.json +``` + +### 7.3 Verify CORS + +```bash +# Check CORS configuration +gcloud storage buckets describe gs://reflow-documents-prod \ + --format="value(cors)" +``` + +--- + +## 8. Lifecycle Management + +### 8.1 Lifecycle Rules Configuration + +Create `lifecycle-config-prod.json`: + +```json +{ + "lifecycle": { + "rule": [ + { + "action": { "type": "Delete" }, + "condition": { + "age": 1, + "matchesPrefix": ["temp/"] + }, + "description": "Delete temporary files after 24 hours" + }, + { + "action": { "type": "SetStorageClass", "storageClass": "NEARLINE" }, + "condition": { + "age": 90, + "matchesPrefix": ["requests/"] + }, + "description": "Move old files to Nearline storage after 90 days" + }, + { + "action": { "type": "SetStorageClass", "storageClass": "COLDLINE" }, + "condition": { + "age": 365, + "matchesPrefix": ["requests/"] + }, + "description": "Move archived files to Coldline storage after 1 year" + } + ] + } +} +``` + +### 8.2 Apply Lifecycle Rules + +```bash +gcloud storage buckets update gs://reflow-documents-prod \ + --lifecycle-file=lifecycle-config-prod.json +``` + +### 8.3 Lifecycle Rule Benefits + +| Rule | Purpose | Cost Savings | +|------|---------|--------------| +| Delete temp files | Remove temporary uploads after 24h | Prevents storage bloat | +| Move to Nearline | Archive files older than 90 days | ~50% cost reduction | +| Move to Coldline | Archive files older than 1 year | ~70% cost reduction | + +--- + +## 9. Monitoring & Alerts + +### 9.1 Enable Access Logging + +```bash +# Create logging bucket (if not exists) +gcloud storage buckets create gs://reflow-logs-prod \ + --project=re-platform-workflow-dealer \ + --location=asia-south1 + +# Enable access logging +gcloud storage buckets update gs://reflow-documents-prod \ + --log-bucket=gs://reflow-logs-prod \ + --log-object-prefix=reflow-storage-logs/ +``` + +### 9.2 Set Up Monitoring Alerts + +**Recommended Alerts:** + +1. **Storage Quota Alert** + - Trigger: Storage exceeds 80% of quota + - Action: Notify DevOps team + +2. **Unusual Access Patterns** + - Trigger: Unusual download patterns detected + - Action: Security team notification + +3. **Failed Access Attempts** + - Trigger: Multiple failed authentication attempts + - Action: Immediate security alert + +4. **High Upload Volume** + - Trigger: Upload volume exceeds normal threshold + - Action: Performance team notification + +### 9.3 Cost Monitoring + +Monitor storage costs via: +- GCP Console → Billing → Reports +- Set up budget alerts at 50%, 75%, 90% of monthly budget +- Review storage class usage (Standard vs Nearline vs Coldline) + +--- + +## 10. Verification & Testing + +### 10.1 Pre-Deployment Verification + +```bash +# 1. Verify bucket exists +gcloud storage buckets describe gs://reflow-documents-prod + +# 2. Verify service account has access +gcloud storage ls gs://reflow-documents-prod \ + --impersonate-service-account=reflow-storage-prod-sa@re-platform-workflow-dealer.iam.gserviceaccount.com + +# 3. Test file upload +echo "test file" > test-upload.txt +gcloud storage cp test-upload.txt gs://reflow-documents-prod/temp/test-upload.txt + +# 4. Test file download +gcloud storage cp gs://reflow-documents-prod/temp/test-upload.txt ./test-download.txt + +# 5. Test file delete +gcloud storage rm gs://reflow-documents-prod/temp/test-upload.txt + +# 6. Clean up +rm test-upload.txt test-download.txt +``` + +### 10.2 Application-Level Testing + +1. **Upload Test:** + - Upload a document via API + - Verify file appears in GCS bucket + - Check database `storage_url` field contains GCS URL + +2. **Download Test:** + - Download file via API + - Verify file is accessible + - Check response headers + +3. **Delete Test:** + - Delete file via API + - Verify file is removed from GCS + - Check database record is updated + +### 10.3 Production Readiness Checklist + +- [ ] Bucket created and configured +- [ ] Service account created with correct permissions +- [ ] JSON key file generated and stored securely +- [ ] Environment variables configured in `.env` +- [ ] CORS policy applied +- [ ] Lifecycle rules configured +- [ ] Versioning enabled +- [ ] Access logging enabled +- [ ] Monitoring alerts configured +- [ ] Upload/download/delete operations tested +- [ ] Backup and recovery procedures documented + +--- + +## 11. Troubleshooting + +### 11.1 Common Issues + +**Issue: Files not uploading to GCS** +- ✅ Check `.env` configuration matches credentials +- ✅ Verify service account has correct permissions +- ✅ Check bucket name exists and is accessible +- ✅ Review application logs for GCS errors +- ✅ Verify key file path is correct + +**Issue: Files uploading but not accessible** +- ✅ Verify bucket permissions (private vs public) +- ✅ Check CORS configuration if accessing from browser +- ✅ Ensure `storage_url` is being saved correctly in database +- ✅ Verify signed URL generation (if using private bucket) + +**Issue: Permission denied errors** +- ✅ Verify service account has `roles/storage.objectAdmin` +- ✅ Check bucket IAM policies +- ✅ Verify key file is valid and not expired + +### 11.2 Log Analysis + +Check application logs for GCS-related messages: +```bash +# Search for GCS initialization +grep "GCS.*Initialized" logs/app.log + +# Search for GCS errors +grep "GCS.*Error" logs/app.log + +# Search for upload failures +grep "GCS.*upload.*failed" logs/app.log +``` + +--- + +## 12. Production Deployment Steps + +### 12.1 Deployment Checklist + +1. **Pre-Deployment:** + - [ ] Create production bucket + - [ ] Create production service account + - [ ] Generate and secure key file + - [ ] Configure environment variables + - [ ] Test upload/download operations + +2. **Deployment:** + - [ ] Deploy application with new environment variables + - [ ] Verify GCS initialization in logs + - [ ] Test file upload functionality + - [ ] Monitor for errors + +3. **Post-Deployment:** + - [ ] Verify files are being stored in GCS + - [ ] Check database `storage_url` fields + - [ ] Monitor storage costs + - [ ] Review access logs + +--- + +## 13. Cost Estimation (Production) + +| Item | Monthly Estimate | Notes | +|------|------------------|-------| +| **Storage (500GB)** | ~$10.00 | Standard storage class | +| **Operations (100K)** | ~$0.50 | Upload/download operations | +| **Network Egress** | Variable | Depends on download volume | +| **Nearline Storage** | ~$5.00 | Files older than 90 days | +| **Coldline Storage** | ~$2.00 | Files older than 1 year | + +**Total Estimated Monthly Cost:** ~$17.50 (excluding network egress) + +--- + +## 14. Support & Contacts + +| Role | Responsibility | Contact | +|------|----------------|---------| +| **DevOps Team** | GCP infrastructure setup | [DevOps Email] | +| **Application Team** | Application configuration | [App Team Email] | +| **Security Team** | Access control and permissions | [Security Email] | + +--- + +## 15. Quick Reference + +### 15.1 Essential Commands + +```bash +# Create bucket +gcloud storage buckets create gs://reflow-documents-prod \ + --project=re-platform-workflow-dealer \ + --location=asia-south1 \ + --uniform-bucket-level-access \ + --public-access-prevention + +# Create service account +gcloud iam service-accounts create reflow-storage-prod-sa \ + --display-name="RE Workflow Production Storage" \ + --project=re-platform-workflow-dealer + +# Generate key +gcloud iam service-accounts keys create ./config/gcp-key-prod.json \ + --iam-account=reflow-storage-prod-sa@re-platform-workflow-dealer.iam.gserviceaccount.com + +# Set CORS +gcloud storage buckets update gs://reflow-documents-prod \ + --cors-file=cors-config-prod.json + +# Enable versioning +gcloud storage buckets update gs://reflow-documents-prod \ + --versioning +``` + +### 15.2 Environment Variables Template + +```env +# Production GCP Configuration +GCP_PROJECT_ID=re-platform-workflow-dealer +GCP_BUCKET_NAME=reflow-documents-prod +GCP_KEY_FILE=./config/gcp-key-prod.json +``` + +--- + +## Appendix: File Structure Reference + +### Database Storage Fields + +The application stores file information in the database: + +| Table | Field | Description | +|-------|-------|-------------| +| `documents` | `file_path` | GCS path: `requests/{requestNumber}/documents/{filename}` | +| `documents` | `storage_url` | Full GCS URL: `https://storage.googleapis.com/bucket/path` | +| `work_note_attachments` | `file_path` | GCS path: `requests/{requestNumber}/attachments/{filename}` | +| `work_note_attachments` | `storage_url` | Full GCS URL | +| `claim_invoices` | `invoice_file_path` | GCS path: `requests/{requestNumber}/invoices/{filename}` | +| `claim_credit_notes` | `credit_note_file_path` | GCS path: `requests/{requestNumber}/credit-notes/{filename}` | + +--- + +**Document Version:** 1.0 +**Last Updated:** December 2024 +**Maintained By:** RE Workflow Development Team + diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index 04367d3..fb4d699 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -813,5 +813,42 @@ export class DealerClaimController { return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage); } } + + /** + * Send credit note to dealer and auto-approve Step 8 + * POST /api/v1/dealer-claims/:requestId/credit-note/send + * Accepts either UUID or requestNumber + */ + async sendCreditNoteToDealer( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const userId = req.user?.userId; + if (!userId) { + return ResponseHandler.error(res, 'Unauthorized', 401); + } + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + await this.dealerClaimService.sendCreditNoteToDealer(requestId, userId); + + return ResponseHandler.success(res, { message: 'Credit note sent to dealer and Step 8 approved successfully' }, 'Credit note sent'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error sending credit note to dealer:', error); + return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage); + } + } } diff --git a/src/controllers/dmsWebhook.controller.ts b/src/controllers/dmsWebhook.controller.ts new file mode 100644 index 0000000..41fd8c9 --- /dev/null +++ b/src/controllers/dmsWebhook.controller.ts @@ -0,0 +1,117 @@ +import { Request, Response } from 'express'; +import { DMSWebhookService } from '../services/dmsWebhook.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import logger from '../utils/logger'; + +/** + * DMS Webhook Controller + * Handles webhook callbacks from DMS system for invoice and credit note generation + */ +export class DMSWebhookController { + private webhookService = new DMSWebhookService(); + + /** + * Handle invoice generation webhook from DMS + * POST /api/v1/webhooks/dms/invoice + */ + async handleInvoiceWebhook(req: Request, res: Response): Promise { + try { + const payload = req.body; + + logger.info('[DMSWebhook] Invoice webhook received', { + requestNumber: payload.request_number, + documentNo: payload.document_no, + }); + + // Validate webhook signature if configured + const isValid = await this.webhookService.validateWebhookSignature(req); + if (!isValid) { + logger.warn('[DMSWebhook] Invalid webhook signature'); + return ResponseHandler.error(res, 'Invalid webhook signature', 401); + } + + // Process invoice webhook + const result = await this.webhookService.processInvoiceWebhook(payload); + + if (!result.success) { + logger.error('[DMSWebhook] Invoice webhook processing failed', { + error: result.error, + requestNumber: payload.request_number, + }); + return ResponseHandler.error(res, result.error || 'Failed to process invoice webhook', 400); + } + + logger.info('[DMSWebhook] Invoice webhook processed successfully', { + requestNumber: payload.request_number, + invoiceNumber: result.invoiceNumber, + }); + + return ResponseHandler.success( + res, + { + message: 'Invoice webhook processed successfully', + invoiceNumber: result.invoiceNumber, + requestNumber: payload.request_number, + }, + 'Webhook processed' + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DMSWebhook] Error processing invoice webhook:', error); + return ResponseHandler.error(res, 'Failed to process invoice webhook', 500, errorMessage); + } + } + + /** + * Handle credit note generation webhook from DMS + * POST /api/v1/webhooks/dms/credit-note + */ + async handleCreditNoteWebhook(req: Request, res: Response): Promise { + try { + const payload = req.body; + + logger.info('[DMSWebhook] Credit note webhook received', { + requestNumber: payload.request_number, + documentNo: payload.document_no, + }); + + // Validate webhook signature if configured + const isValid = await this.webhookService.validateWebhookSignature(req); + if (!isValid) { + logger.warn('[DMSWebhook] Invalid webhook signature'); + return ResponseHandler.error(res, 'Invalid webhook signature', 401); + } + + // Process credit note webhook + const result = await this.webhookService.processCreditNoteWebhook(payload); + + if (!result.success) { + logger.error('[DMSWebhook] Credit note webhook processing failed', { + error: result.error, + requestNumber: payload.request_number, + }); + return ResponseHandler.error(res, result.error || 'Failed to process credit note webhook', 400); + } + + logger.info('[DMSWebhook] Credit note webhook processed successfully', { + requestNumber: payload.request_number, + creditNoteNumber: result.creditNoteNumber, + }); + + return ResponseHandler.success( + res, + { + message: 'Credit note webhook processed successfully', + creditNoteNumber: result.creditNoteNumber, + requestNumber: payload.request_number, + }, + 'Webhook processed' + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DMSWebhook] Error processing credit note webhook:', error); + return ResponseHandler.error(res, 'Failed to process credit note webhook', 500, errorMessage); + } + } +} + diff --git a/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts b/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts index 4e2da5d..55ac5a0 100644 --- a/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts +++ b/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts @@ -23,18 +23,30 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.DATEONLY, allowNull: true, }, + invoice_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }, dms_number: { type: DataTypes.STRING(100), allowNull: true, }, - amount: { - type: DataTypes.DECIMAL(15, 2), + invoice_file_path: { + type: DataTypes.STRING(500), allowNull: true, }, - status: { + generation_status: { type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED allowNull: true, }, + error_message: { + type: DataTypes.TEXT, + allowNull: true, + }, + generated_at: { + type: DataTypes.DATE, + allowNull: true, + }, description: { type: DataTypes.TEXT, allowNull: true, @@ -54,6 +66,7 @@ export async function up(queryInterface: QueryInterface): Promise { await queryInterface.addIndex('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true }); await queryInterface.addIndex('claim_invoices', ['invoice_number'], { name: 'idx_claim_invoices_invoice_number' }); await queryInterface.addIndex('claim_invoices', ['dms_number'], { name: 'idx_claim_invoices_dms_number' }); + await queryInterface.addIndex('claim_invoices', ['generation_status'], { name: 'idx_claim_invoices_status' }); await queryInterface.createTable('claim_credit_notes', { credit_note_id: { @@ -69,6 +82,13 @@ export async function up(queryInterface: QueryInterface): Promise { onDelete: 'CASCADE', onUpdate: 'CASCADE', }, + invoice_id: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'claim_invoices', key: 'invoice_id' }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, credit_note_number: { type: DataTypes.STRING(100), allowNull: true, @@ -77,12 +97,35 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.DATEONLY, allowNull: true, }, - credit_note_amount: { + credit_amount: { type: DataTypes.DECIMAL(15, 2), allowNull: true, }, - status: { - type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED + sap_document_number: { + type: DataTypes.STRING(100), + allowNull: true, + }, + credit_note_file_path: { + type: DataTypes.STRING(500), + allowNull: true, + }, + confirmation_status: { + type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, CONFIRMED, FAILED, CANCELLED + allowNull: true, + }, + error_message: { + type: DataTypes.TEXT, + allowNull: true, + }, + confirmed_by: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'user_id' }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + confirmed_at: { + type: DataTypes.DATE, allowNull: true, }, reason: { @@ -106,7 +149,10 @@ export async function up(queryInterface: QueryInterface): Promise { }); await queryInterface.addIndex('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true }); + await queryInterface.addIndex('claim_credit_notes', ['invoice_id'], { name: 'idx_claim_credit_notes_invoice_id' }); await queryInterface.addIndex('claim_credit_notes', ['credit_note_number'], { name: 'idx_claim_credit_notes_number' }); + await queryInterface.addIndex('claim_credit_notes', ['sap_document_number'], { name: 'idx_claim_credit_notes_sap_doc' }); + await queryInterface.addIndex('claim_credit_notes', ['confirmation_status'], { name: 'idx_claim_credit_notes_status' }); } export async function down(queryInterface: QueryInterface): Promise { diff --git a/src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts b/src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts new file mode 100644 index 0000000..18b7cf7 --- /dev/null +++ b/src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts @@ -0,0 +1,240 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +/** + * Helper function to check if a column exists in a table + */ +async function columnExists( + queryInterface: QueryInterface, + tableName: string, + columnName: string +): Promise { + try { + const tableDescription = await queryInterface.describeTable(tableName); + return columnName in tableDescription; + } catch (error) { + return false; + } +} + +/** + * Migration: Fix column names in claim_invoices and claim_credit_notes tables + * + * This migration handles the case where tables were created with old column names + * and need to be updated to match the new schema. + */ +export async function up(queryInterface: QueryInterface): Promise { + try { + // Check if claim_invoices table exists + const [invoiceTables] = await queryInterface.sequelize.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'claim_invoices'; + `); + + if (invoiceTables.length > 0) { + // Fix claim_invoices table + const hasOldAmount = await columnExists(queryInterface, 'claim_invoices', 'amount'); + const hasNewAmount = await columnExists(queryInterface, 'claim_invoices', 'invoice_amount'); + + if (hasOldAmount && !hasNewAmount) { + // Rename amount to invoice_amount + await queryInterface.renameColumn('claim_invoices', 'amount', 'invoice_amount'); + console.log('✅ Renamed claim_invoices.amount to invoice_amount'); + } else if (!hasOldAmount && !hasNewAmount) { + // Add invoice_amount if neither exists + await queryInterface.addColumn('claim_invoices', 'invoice_amount', { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }); + console.log('✅ Added invoice_amount column to claim_invoices'); + } else if (hasNewAmount) { + console.log('✅ invoice_amount column already exists in claim_invoices'); + } + + // Check for status vs generation_status + const hasStatus = await columnExists(queryInterface, 'claim_invoices', 'status'); + const hasGenerationStatus = await columnExists(queryInterface, 'claim_invoices', 'generation_status'); + + if (hasStatus && !hasGenerationStatus) { + // Rename status to generation_status + await queryInterface.renameColumn('claim_invoices', 'status', 'generation_status'); + console.log('✅ Renamed claim_invoices.status to generation_status'); + } else if (!hasStatus && !hasGenerationStatus) { + // Add generation_status if neither exists + await queryInterface.addColumn('claim_invoices', 'generation_status', { + type: DataTypes.STRING(50), + allowNull: true, + }); + console.log('✅ Added generation_status column to claim_invoices'); + } else if (hasGenerationStatus) { + console.log('✅ generation_status column already exists in claim_invoices'); + } + } + + // Check if claim_credit_notes table exists + const [creditNoteTables] = await queryInterface.sequelize.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'claim_credit_notes'; + `); + + if (creditNoteTables.length > 0) { + // Fix claim_credit_notes table + const hasOldAmount = await columnExists(queryInterface, 'claim_credit_notes', 'credit_note_amount'); + const hasNewAmount = await columnExists(queryInterface, 'claim_credit_notes', 'credit_amount'); + + if (hasOldAmount && !hasNewAmount) { + // Rename credit_note_amount to credit_amount + await queryInterface.renameColumn('claim_credit_notes', 'credit_note_amount', 'credit_amount'); + console.log('✅ Renamed claim_credit_notes.credit_note_amount to credit_amount'); + } else if (!hasOldAmount && !hasNewAmount) { + // Add credit_amount if neither exists + await queryInterface.addColumn('claim_credit_notes', 'credit_amount', { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }); + console.log('✅ Added credit_amount column to claim_credit_notes'); + } else if (hasNewAmount) { + console.log('✅ credit_amount column already exists in claim_credit_notes'); + } + + // Check for status vs confirmation_status + const hasStatus = await columnExists(queryInterface, 'claim_credit_notes', 'status'); + const hasConfirmationStatus = await columnExists(queryInterface, 'claim_credit_notes', 'confirmation_status'); + + if (hasStatus && !hasConfirmationStatus) { + // Rename status to confirmation_status + await queryInterface.renameColumn('claim_credit_notes', 'status', 'confirmation_status'); + console.log('✅ Renamed claim_credit_notes.status to confirmation_status'); + } else if (!hasStatus && !hasConfirmationStatus) { + // Add confirmation_status if neither exists + await queryInterface.addColumn('claim_credit_notes', 'confirmation_status', { + type: DataTypes.STRING(50), + allowNull: true, + }); + console.log('✅ Added confirmation_status column to claim_credit_notes'); + } else if (hasConfirmationStatus) { + console.log('✅ confirmation_status column already exists in claim_credit_notes'); + } + + // Ensure invoice_id column exists + const hasInvoiceId = await columnExists(queryInterface, 'claim_credit_notes', 'invoice_id'); + if (!hasInvoiceId) { + await queryInterface.addColumn('claim_credit_notes', 'invoice_id', { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'claim_invoices', + key: 'invoice_id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + console.log('✅ Added invoice_id column to claim_credit_notes'); + } + + // Ensure sap_document_number column exists + const hasSapDoc = await columnExists(queryInterface, 'claim_credit_notes', 'sap_document_number'); + if (!hasSapDoc) { + await queryInterface.addColumn('claim_credit_notes', 'sap_document_number', { + type: DataTypes.STRING(100), + allowNull: true, + }); + console.log('✅ Added sap_document_number column to claim_credit_notes'); + } + + // Ensure credit_note_file_path column exists + const hasFilePath = await columnExists(queryInterface, 'claim_credit_notes', 'credit_note_file_path'); + if (!hasFilePath) { + await queryInterface.addColumn('claim_credit_notes', 'credit_note_file_path', { + type: DataTypes.STRING(500), + allowNull: true, + }); + console.log('✅ Added credit_note_file_path column to claim_credit_notes'); + } + + // Ensure confirmed_by column exists + const hasConfirmedBy = await columnExists(queryInterface, 'claim_credit_notes', 'confirmed_by'); + if (!hasConfirmedBy) { + await queryInterface.addColumn('claim_credit_notes', 'confirmed_by', { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + console.log('✅ Added confirmed_by column to claim_credit_notes'); + } + + // Ensure confirmed_at column exists + const hasConfirmedAt = await columnExists(queryInterface, 'claim_credit_notes', 'confirmed_at'); + if (!hasConfirmedAt) { + await queryInterface.addColumn('claim_credit_notes', 'confirmed_at', { + type: DataTypes.DATE, + allowNull: true, + }); + console.log('✅ Added confirmed_at column to claim_credit_notes'); + } + } + + // Ensure invoice_file_path exists in claim_invoices + if (invoiceTables.length > 0) { + const hasFilePath = await columnExists(queryInterface, 'claim_invoices', 'invoice_file_path'); + if (!hasFilePath) { + await queryInterface.addColumn('claim_invoices', 'invoice_file_path', { + type: DataTypes.STRING(500), + allowNull: true, + }); + console.log('✅ Added invoice_file_path column to claim_invoices'); + } + + // Ensure error_message exists + const hasErrorMessage = await columnExists(queryInterface, 'claim_invoices', 'error_message'); + if (!hasErrorMessage) { + await queryInterface.addColumn('claim_invoices', 'error_message', { + type: DataTypes.TEXT, + allowNull: true, + }); + console.log('✅ Added error_message column to claim_invoices'); + } + + // Ensure generated_at exists + const hasGeneratedAt = await columnExists(queryInterface, 'claim_invoices', 'generated_at'); + if (!hasGeneratedAt) { + await queryInterface.addColumn('claim_invoices', 'generated_at', { + type: DataTypes.DATE, + allowNull: true, + }); + console.log('✅ Added generated_at column to claim_invoices'); + } + } + + // Ensure error_message exists in claim_credit_notes + if (creditNoteTables.length > 0) { + const hasErrorMessage = await columnExists(queryInterface, 'claim_credit_notes', 'error_message'); + if (!hasErrorMessage) { + await queryInterface.addColumn('claim_credit_notes', 'error_message', { + type: DataTypes.TEXT, + allowNull: true, + }); + console.log('✅ Added error_message column to claim_credit_notes'); + } + } + + } catch (error: any) { + console.error('Migration error:', error.message); + throw error; + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // This migration is idempotent and safe to run multiple times + // The down migration would reverse the changes, but it's safer to keep the new schema + console.log('Note: Down migration not implemented - keeping new column names'); +} + diff --git a/src/models/ClaimCreditNote.ts b/src/models/ClaimCreditNote.ts index 8b43b7a..9427a3a 100644 --- a/src/models/ClaimCreditNote.ts +++ b/src/models/ClaimCreditNote.ts @@ -1,29 +1,42 @@ import { DataTypes, Model, Optional } from 'sequelize'; import { sequelize } from '@config/database'; import { WorkflowRequest } from './WorkflowRequest'; +import { ClaimInvoice } from './ClaimInvoice'; interface ClaimCreditNoteAttributes { creditNoteId: string; requestId: string; + invoiceId?: string; creditNoteNumber?: string; creditNoteDate?: Date; creditNoteAmount?: number; + sapDocumentNumber?: string; + creditNoteFilePath?: string; status?: string; + errorMessage?: string; + confirmedBy?: string; + confirmedAt?: Date; reason?: string; description?: string; createdAt: Date; updatedAt: Date; } -interface ClaimCreditNoteCreationAttributes extends Optional {} +interface ClaimCreditNoteCreationAttributes extends Optional {} class ClaimCreditNote extends Model implements ClaimCreditNoteAttributes { public creditNoteId!: string; public requestId!: string; + public invoiceId?: string; public creditNoteNumber?: string; public creditNoteDate?: Date; public creditNoteAmount?: number; + public sapDocumentNumber?: string; + public creditNoteFilePath?: string; public status?: string; + public errorMessage?: string; + public confirmedBy?: string; + public confirmedAt?: Date; public reason?: string; public description?: string; public createdAt!: Date; @@ -50,6 +63,17 @@ ClaimCreditNote.init( onDelete: 'CASCADE', onUpdate: 'CASCADE', }, + invoiceId: { + type: DataTypes.UUID, + allowNull: true, + field: 'invoice_id', + references: { + model: 'claim_invoices', + key: 'invoice_id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, creditNoteNumber: { type: DataTypes.STRING(100), allowNull: true, @@ -63,12 +87,43 @@ ClaimCreditNote.init( creditNoteAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: true, - field: 'credit_note_amount', + field: 'credit_amount', + }, + sapDocumentNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'sap_document_number', + }, + creditNoteFilePath: { + type: DataTypes.STRING(500), + allowNull: true, + field: 'credit_note_file_path', }, status: { type: DataTypes.STRING(50), allowNull: true, - field: 'status', + field: 'confirmation_status', + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true, + field: 'error_message', + }, + confirmedBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'confirmed_by', + references: { + model: 'users', + key: 'user_id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + confirmedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'confirmed_at', }, reason: { type: DataTypes.TEXT, @@ -102,7 +157,10 @@ ClaimCreditNote.init( updatedAt: 'updated_at', indexes: [ { unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' }, + { fields: ['invoice_id'], name: 'idx_claim_credit_notes_invoice_id' }, { fields: ['credit_note_number'], name: 'idx_claim_credit_notes_number' }, + { fields: ['sap_document_number'], name: 'idx_claim_credit_notes_sap_doc' }, + { fields: ['confirmation_status'], name: 'idx_claim_credit_notes_status' }, ], } ); @@ -119,5 +177,17 @@ ClaimCreditNote.belongsTo(WorkflowRequest, { targetKey: 'requestId', }); +ClaimCreditNote.belongsTo(ClaimInvoice, { + as: 'claimInvoice', + foreignKey: 'invoiceId', + targetKey: 'invoiceId', +}); + +ClaimInvoice.hasMany(ClaimCreditNote, { + as: 'creditNotes', + foreignKey: 'invoiceId', + sourceKey: 'invoiceId', +}); + export { ClaimCreditNote }; diff --git a/src/models/ClaimInvoice.ts b/src/models/ClaimInvoice.ts index a400619..118464b 100644 --- a/src/models/ClaimInvoice.ts +++ b/src/models/ClaimInvoice.ts @@ -7,24 +7,30 @@ interface ClaimInvoiceAttributes { requestId: string; invoiceNumber?: string; invoiceDate?: Date; - dmsNumber?: string; amount?: number; + dmsNumber?: string; + invoiceFilePath?: string; status?: string; + errorMessage?: string; + generatedAt?: Date; description?: string; createdAt: Date; updatedAt: Date; } -interface ClaimInvoiceCreationAttributes extends Optional {} +interface ClaimInvoiceCreationAttributes extends Optional {} class ClaimInvoice extends Model implements ClaimInvoiceAttributes { public invoiceId!: string; public requestId!: string; public invoiceNumber?: string; public invoiceDate?: Date; - public dmsNumber?: string; public amount?: number; + public dmsNumber?: string; + public invoiceFilePath?: string; public status?: string; + public errorMessage?: string; + public generatedAt?: Date; public description?: string; public createdAt!: Date; public updatedAt!: Date; @@ -60,20 +66,35 @@ ClaimInvoice.init( allowNull: true, field: 'invoice_date', }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'invoice_amount', + }, dmsNumber: { type: DataTypes.STRING(100), allowNull: true, field: 'dms_number', }, - amount: { - type: DataTypes.DECIMAL(15, 2), + invoiceFilePath: { + type: DataTypes.STRING(500), allowNull: true, - field: 'amount', + field: 'invoice_file_path', }, status: { type: DataTypes.STRING(50), allowNull: true, - field: 'status', + field: 'generation_status', + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true, + field: 'error_message', + }, + generatedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'generated_at', }, description: { type: DataTypes.TEXT, @@ -104,6 +125,7 @@ ClaimInvoice.init( { unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' }, { fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' }, { fields: ['dms_number'], name: 'idx_claim_invoices_dms_number' }, + { fields: ['generation_status'], name: 'idx_claim_invoices_status' }, ], } ); @@ -120,5 +142,8 @@ ClaimInvoice.belongsTo(WorkflowRequest, { targetKey: 'requestId', }); +// Note: hasMany association with ClaimCreditNote is defined in ClaimCreditNote.ts +// to avoid circular dependency issues + export { ClaimInvoice }; diff --git a/src/routes/dealerClaim.routes.ts b/src/routes/dealerClaim.routes.ts index ac3fd30..848f926 100644 --- a/src/routes/dealerClaim.routes.ts +++ b/src/routes/dealerClaim.routes.ts @@ -86,5 +86,12 @@ router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimC */ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); +/** + * @route POST /api/v1/dealer-claims/:requestId/credit-note/send + * @desc Send credit note to dealer and auto-approve Step 8 + * @access Private + */ +router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController))); + export default router; diff --git a/src/routes/dmsWebhook.routes.ts b/src/routes/dmsWebhook.routes.ts new file mode 100644 index 0000000..5301b62 --- /dev/null +++ b/src/routes/dmsWebhook.routes.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { DMSWebhookController } from '../controllers/dmsWebhook.controller'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; + +const router = Router(); +const webhookController = new DMSWebhookController(); + +/** + * @route GET /api/v1/webhooks/dms/health + * @desc Health check endpoint for webhook routes (for testing) + * @access Public + */ +router.get('/health', (_req, res) => { + res.status(200).json({ + status: 'OK', + message: 'DMS Webhook routes are active', + endpoints: { + invoice: 'POST /api/v1/webhooks/dms/invoice', + creditNote: 'POST /api/v1/webhooks/dms/credit-note' + } + }); +}); + +/** + * @route POST /api/v1/webhooks/dms/invoice + * @desc Webhook endpoint for DMS invoice generation callbacks + * @access Public (authenticated via webhook signature) + * @note This endpoint is called by DMS system after invoice generation + */ +router.post( + '/invoice', + asyncHandler(webhookController.handleInvoiceWebhook.bind(webhookController)) +); + +/** + * @route POST /api/v1/webhooks/dms/credit-note + * @desc Webhook endpoint for DMS credit note generation callbacks + * @access Public (authenticated via webhook signature) + * @note This endpoint is called by DMS system after credit note generation + */ +router.post( + '/credit-note', + asyncHandler(webhookController.handleCreditNoteWebhook.bind(webhookController)) +); + +export default router; + diff --git a/src/routes/index.ts b/src/routes/index.ts index 3bb0071..60e2cc3 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -16,6 +16,7 @@ import aiRoutes from './ai.routes'; import dealerClaimRoutes from './dealerClaim.routes'; import templateRoutes from './template.routes'; import dealerRoutes from './dealer.routes'; +import dmsWebhookRoutes from './dmsWebhook.routes'; const router = Router(); @@ -46,6 +47,7 @@ router.use('/summaries', summaryRoutes); router.use('/dealer-claims', dealerClaimRoutes); router.use('/templates', templateRoutes); router.use('/dealers', dealerRoutes); +router.use('/webhooks/dms', dmsWebhookRoutes); // TODO: Add other route modules as they are implemented // router.use('/approvals', approvalRoutes); diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index 254ee7b..d4685e5 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -132,6 +132,7 @@ async function runMigrations(): Promise { const m37 = require('../migrations/20251213-drop-claim-details-invoice-columns'); const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables'); const m39 = require('../migrations/20251214-create-dealer-completion-expenses'); + const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns'); const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -176,6 +177,7 @@ async function runMigrations(): Promise { { 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 }, ]; const queryInterface = sequelize.getQueryInterface(); diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 516e6b4..60d6795 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -42,6 +42,7 @@ import * as m36 from '../migrations/20251211-create-claim-budget-tracking-table' import * as m37 from '../migrations/20251213-drop-claim-details-invoice-columns'; import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables'; import * as m39 from '../migrations/20251214-create-dealer-completion-expenses'; +import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns'; interface Migration { name: string; @@ -98,6 +99,7 @@ const migrations: Migration[] = [ { 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 }, ]; /** diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 66cc6c1..b54eb28 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -447,19 +447,14 @@ export class ApprovalService { // Don't fail the Step 3 approval if Step 4 processing fails - log and continue } } else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') { - // Step 7 is an auto-step - process it automatically after Step 6 approval - logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`); - try { - const dealerClaimService = new DealerClaimService(); - await dealerClaimService.processEInvoiceGeneration(level.requestId); - logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`); - // Skip notification for system auto-processed step - return updatedLevel; - } catch (step7Error) { - logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error); - // Don't fail the Step 6 approval if Step 7 processing fails - log and continue - } - } else if (wf && nextLevel) { + // Step 7 is an auto-step - activate it but don't process invoice generation + // Invoice generation will be handled by DMS webhook when invoice is created + logger.info(`[Approval] Step 6 approved for claim management workflow. Step 7 activated. Waiting for DMS webhook to generate invoice.`); + // Step 7 will remain in IN_PROGRESS until webhook creates invoice and auto-approves it + // Continue with normal flow to activate Step 7 + } + + if (wf && nextLevel) { // Normal flow - notify next approver (skip for auto-steps) // Check if it's an auto-step by checking approverEmail or levelName const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index d0b2ffa..da012e9 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -1544,6 +1544,7 @@ export class DealerClaimService { dmsNumber: invoiceResult.dmsNumber, amount: invoiceAmount, status: 'GENERATED', + generatedAt: new Date(), description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, }); @@ -1560,6 +1561,7 @@ export class DealerClaimService { dmsNumber: invoiceData.dmsNumber, amount: invoiceData.amount, status: 'UPDATED', + generatedAt: new Date(), description: invoiceData.description, }); @@ -1573,19 +1575,18 @@ export class DealerClaimService { if (step6Level && step6Level.status !== ApprovalStatus.APPROVED) { logger.info(`[DealerClaimService] Step 6 not approved yet. Auto-approving Step 6 for request ${requestId}`); - // Auto-approve Step 6 - this will trigger Step 7 auto-processing in approval service + // Auto-approve Step 6 - Step 7 will be activated but invoice generation will happen via webhook await this.approvalService.approveLevel( step6Level.levelId, - { action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generated.' }, + { action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. Waiting for DMS webhook to generate invoice.' }, 'system', { ipAddress: null, userAgent: 'System Auto-Process' } ); - // Note: Step 7 auto-processing will be handled by approval service when Step 6 is approved - // But we also call it directly here to ensure it runs (in case approval service logic doesn't trigger) - await this.processEInvoiceGeneration(requestId); + // Note: Step 7 invoice generation will be handled by DMS webhook, not here + logger.info(`[DealerClaimService] Step 6 approved. Step 7 activated. Waiting for DMS webhook to generate invoice.`); } else { - // Step 6 already approved - directly process Step 7 - await this.processEInvoiceGeneration(requestId); + // Step 6 already approved - Step 7 should be active, waiting for webhook + logger.info(`[DealerClaimService] Step 6 already approved. Step 7 should be active. Invoice generation will happen via DMS webhook.`); } } catch (error) { logger.error('[DealerClaimService] Error updating e-invoice details:', error); @@ -1594,8 +1595,9 @@ export class DealerClaimService { } /** - * Process Step 7: E-Invoice Generation (Auto-processed after Step 6 approval or when pushing to DMS) - * Generates e-invoice via DMS and auto-approves Step 7 + * Process Step 7: E-Invoice Generation (Deprecated - now handled by DMS webhook) + * This method is kept for backward compatibility but invoice generation is now triggered only via DMS webhook + * @deprecated Use DMS webhook to trigger invoice generation instead */ async processEInvoiceGeneration(requestId: string): Promise { try { @@ -1707,10 +1709,12 @@ export class DealerClaimService { await ClaimCreditNote.upsert({ requestId, + invoiceId: claimInvoice.invoiceId, creditNoteNumber: creditNoteResult.creditNoteNumber, creditNoteDate: creditNoteResult.creditNoteDate || new Date(), creditNoteAmount: creditNoteResult.creditNoteAmount, status: 'GENERATED', + confirmedAt: new Date(), reason: creditNoteData?.reason || 'Claim settlement', description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, }); @@ -1723,10 +1727,12 @@ export class DealerClaimService { // Manual entry - just update the fields await ClaimCreditNote.upsert({ requestId, + invoiceId: claimInvoice.invoiceId, creditNoteNumber: creditNoteData.creditNoteNumber, creditNoteDate: creditNoteData.creditNoteDate || new Date(), creditNoteAmount: creditNoteData.creditNoteAmount, status: 'UPDATED', + confirmedAt: new Date(), reason: creditNoteData?.reason, description: creditNoteData?.description, }); @@ -1739,6 +1745,107 @@ export class DealerClaimService { } } + /** + * 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 + */ + async sendCreditNoteToDealer(requestId: string, userId: string): Promise { + try { + logger.info(`[DealerClaimService] Sending credit note to dealer for request ${requestId}`); + + // Get credit note details + const creditNote = await ClaimCreditNote.findOne({ + where: { requestId } + }); + + if (!creditNote) { + throw new Error('Credit note not found. Please ensure credit note is generated before sending to dealer.'); + } + + // Get claim details for dealer information + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (!claimDetails) { + throw new Error('Claim details not found'); + } + + // Get workflow request + const request = await WorkflowRequest.findByPk(requestId); + if (!request) { + throw new Error('Workflow request not found'); + } + + const workflowType = (request as any).workflowType; + if (workflowType !== 'CLAIM_MANAGEMENT') { + throw new Error('This operation is only available for claim management workflows'); + } + + // Get Step 8 approval level + const step8Level = await ApprovalLevel.findOne({ + where: { + requestId, + levelNumber: 8 + } + }); + + if (!step8Level) { + throw new Error(`Step 8 approval level not found for request ${requestId}`); + } + + // Check if Step 8 is already approved + if (step8Level.status === 'APPROVED') { + logger.info(`[DealerClaimService] Step 8 already approved for request ${requestId}`); + // Still send notification to dealer if credit note wasn't sent before + // You can add a flag to track if credit note was already sent + } else { + // Auto-approve Step 8 + logger.info(`[DealerClaimService] Auto-approving Step 8 for request ${requestId}`); + await this.approvalService.approveLevel( + step8Level.levelId, + { + action: 'APPROVE', + comments: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Step 8 auto-approved.`, + }, + userId, + { + ipAddress: null, + userAgent: 'System Auto-Process', + } + ); + + logger.info(`[DealerClaimService] Step 8 auto-approved successfully for request ${requestId}`); + } + + // Update credit note status to SENT + await creditNote.update({ + status: 'CONFIRMED', // Or 'SENT' if you have that status + confirmedAt: new Date(), + confirmedBy: userId, + }); + + // Send notification to dealer (you can implement email service here) + // For now, we'll just log it + logger.info(`[DealerClaimService] Credit note sent to dealer`, { + requestId, + creditNoteNumber: creditNote.creditNoteNumber, + dealerEmail: claimDetails.dealerEmail, + dealerName: claimDetails.dealerName, + }); + + // TODO: Implement email service to send credit note to dealer + // await emailService.sendCreditNoteToDealer({ + // dealerEmail: claimDetails.dealerEmail, + // dealerName: claimDetails.dealerName, + // creditNoteNumber: creditNote.creditNoteNumber, + // creditNoteAmount: creditNote.creditNoteAmount, + // requestNumber: (request as any).requestNumber, + // }); + + } catch (error) { + logger.error('[DealerClaimService] Error sending credit note to dealer:', error); + throw error; + } + } + /** * Process Step 4: Activity Creation (Auto-processed after Step 3 approval) * Creates activity confirmation and sends emails to dealer, requestor, and department lead diff --git a/src/services/dmsWebhook.service.ts b/src/services/dmsWebhook.service.ts new file mode 100644 index 0000000..efb9bbf --- /dev/null +++ b/src/services/dmsWebhook.service.ts @@ -0,0 +1,408 @@ +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 { ApprovalService } from './approval.service'; +import logger from '../utils/logger'; +import crypto from 'crypto'; + +/** + * 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 { + // 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.autoApproveStep7(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 + const invoice = await ClaimInvoice.findOne({ + where: { requestId: request.requestId }, + }); + + if (!invoice) { + return { + success: false, + error: `Invoice not found for request: ${payload.request_number}`, + }; + } + + // 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, + }); + + creditNote = await ClaimCreditNote.create({ + requestId: request.requestId, + invoiceId: invoice.invoiceId, + 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, + }); + } else { + // Update existing credit note with DMS response data + await creditNote.update({ + invoiceId: invoice.invoiceId, + 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, + }); + } + + 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(' | ') : ''; + } + + /** + * Auto-approve Step 7 (E-Invoice Generation) and move to Step 8 + * This is called after invoice is created/updated from DMS webhook + */ + private async autoApproveStep7(requestId: string, requestNumber: string): Promise { + 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; + } + + // Get Step 7 approval level + const step7Level = await ApprovalLevel.findOne({ + where: { + requestId, + levelNumber: 7, + }, + }); + + if (!step7Level) { + logger.warn('[DMSWebhook] Step 7 approval level not found', { requestId, requestNumber }); + return; + } + + // Check if Step 7 is already approved + if (step7Level.status === 'APPROVED') { + logger.info('[DMSWebhook] Step 7 already approved, skipping auto-approval', { + requestId, + requestNumber, + }); + return; + } + + // Auto-approve Step 7 + logger.info('[DMSWebhook] Auto-approving Step 7 (E-Invoice Generation)', { + requestId, + requestNumber, + levelId: step7Level.levelId, + }); + + await this.approvalService.approveLevel( + step7Level.levelId, + { + action: 'APPROVE', + comments: `E-Invoice generated via DMS webhook. Invoice Number: ${(await ClaimInvoice.findOne({ where: { requestId } }))?.invoiceNumber || 'N/A'}. Step 7 auto-approved.`, + }, + 'system', // System user for auto-approval + { + ipAddress: null, + userAgent: 'DMS-Webhook-System', + } + ); + + logger.info('[DMSWebhook] Step 7 auto-approved successfully. Workflow moved to Step 8', { + requestId, + requestNumber, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DMSWebhook] Error auto-approving Step 7:', { + requestId, + requestNumber, + error: errorMessage, + }); + // Don't throw error - webhook processing should continue even if Step 7 approval fails + // The invoice is already created/updated, which is the primary goal + } + } +} +