web hooks created for the dms team

This commit is contained in:
laxmanhalaki 2025-12-18 21:23:37 +05:30
parent 8dc7fd3307
commit 1ac169dc7f
16 changed files with 2386 additions and 38 deletions

574
docs/DMS_WEBHOOK_API.md Normal file
View File

@ -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: <HMAC-SHA256-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: <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: <HMAC-SHA256-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: <HMAC-SHA256-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: <calculated-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: <calculated-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: <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: <error>`
- **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

View File

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

View File

@ -813,5 +813,42 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage); 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<void> {
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);
}
}
} }

View File

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

View File

@ -23,18 +23,30 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
allowNull: true, allowNull: true,
}, },
invoice_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
},
dms_number: { dms_number: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: true, allowNull: true,
}, },
amount: { invoice_file_path: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.STRING(500),
allowNull: true, allowNull: true,
}, },
status: { generation_status: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
allowNull: true, allowNull: true,
}, },
error_message: {
type: DataTypes.TEXT,
allowNull: true,
},
generated_at: {
type: DataTypes.DATE,
allowNull: true,
},
description: { description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: true, allowNull: true,
@ -54,6 +66,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addIndex('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true }); 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', ['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', ['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', { await queryInterface.createTable('claim_credit_notes', {
credit_note_id: { credit_note_id: {
@ -69,6 +82,13 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
onDelete: 'CASCADE', onDelete: 'CASCADE',
onUpdate: '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: { credit_note_number: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: true, allowNull: true,
@ -77,12 +97,35 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
allowNull: true, allowNull: true,
}, },
credit_note_amount: { credit_amount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, allowNull: true,
}, },
status: { sap_document_number: {
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED 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, allowNull: true,
}, },
reason: { reason: {
@ -106,7 +149,10 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
}); });
await queryInterface.addIndex('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true }); 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', ['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<void> { export async function down(queryInterface: QueryInterface): Promise<void> {

View File

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

View File

@ -1,29 +1,42 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database'; import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest'; import { WorkflowRequest } from './WorkflowRequest';
import { ClaimInvoice } from './ClaimInvoice';
interface ClaimCreditNoteAttributes { interface ClaimCreditNoteAttributes {
creditNoteId: string; creditNoteId: string;
requestId: string; requestId: string;
invoiceId?: string;
creditNoteNumber?: string; creditNoteNumber?: string;
creditNoteDate?: Date; creditNoteDate?: Date;
creditNoteAmount?: number; creditNoteAmount?: number;
sapDocumentNumber?: string;
creditNoteFilePath?: string;
status?: string; status?: string;
errorMessage?: string;
confirmedBy?: string;
confirmedAt?: Date;
reason?: string; reason?: string;
description?: string; description?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'status' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {} interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes { class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string; public creditNoteId!: string;
public requestId!: string; public requestId!: string;
public invoiceId?: string;
public creditNoteNumber?: string; public creditNoteNumber?: string;
public creditNoteDate?: Date; public creditNoteDate?: Date;
public creditNoteAmount?: number; public creditNoteAmount?: number;
public sapDocumentNumber?: string;
public creditNoteFilePath?: string;
public status?: string; public status?: string;
public errorMessage?: string;
public confirmedBy?: string;
public confirmedAt?: Date;
public reason?: string; public reason?: string;
public description?: string; public description?: string;
public createdAt!: Date; public createdAt!: Date;
@ -50,6 +63,17 @@ ClaimCreditNote.init(
onDelete: 'CASCADE', onDelete: 'CASCADE',
onUpdate: '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: { creditNoteNumber: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: true, allowNull: true,
@ -63,12 +87,43 @@ ClaimCreditNote.init(
creditNoteAmount: { creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, 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: { status: {
type: DataTypes.STRING(50), type: DataTypes.STRING(50),
allowNull: true, 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: { reason: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
@ -102,7 +157,10 @@ ClaimCreditNote.init(
updatedAt: 'updated_at', updatedAt: 'updated_at',
indexes: [ indexes: [
{ unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' }, { 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: ['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', targetKey: 'requestId',
}); });
ClaimCreditNote.belongsTo(ClaimInvoice, {
as: 'claimInvoice',
foreignKey: 'invoiceId',
targetKey: 'invoiceId',
});
ClaimInvoice.hasMany(ClaimCreditNote, {
as: 'creditNotes',
foreignKey: 'invoiceId',
sourceKey: 'invoiceId',
});
export { ClaimCreditNote }; export { ClaimCreditNote };

View File

@ -7,24 +7,30 @@ interface ClaimInvoiceAttributes {
requestId: string; requestId: string;
invoiceNumber?: string; invoiceNumber?: string;
invoiceDate?: Date; invoiceDate?: Date;
dmsNumber?: string;
amount?: number; amount?: number;
dmsNumber?: string;
invoiceFilePath?: string;
status?: string; status?: string;
errorMessage?: string;
generatedAt?: Date;
description?: string; description?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'dmsNumber' | 'amount' | 'status' | 'description' | 'createdAt' | 'updatedAt'> {} interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes { class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
public invoiceId!: string; public invoiceId!: string;
public requestId!: string; public requestId!: string;
public invoiceNumber?: string; public invoiceNumber?: string;
public invoiceDate?: Date; public invoiceDate?: Date;
public dmsNumber?: string;
public amount?: number; public amount?: number;
public dmsNumber?: string;
public invoiceFilePath?: string;
public status?: string; public status?: string;
public errorMessage?: string;
public generatedAt?: Date;
public description?: string; public description?: string;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
@ -60,20 +66,35 @@ ClaimInvoice.init(
allowNull: true, allowNull: true,
field: 'invoice_date', field: 'invoice_date',
}, },
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'invoice_amount',
},
dmsNumber: { dmsNumber: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: true, allowNull: true,
field: 'dms_number', field: 'dms_number',
}, },
amount: { invoiceFilePath: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.STRING(500),
allowNull: true, allowNull: true,
field: 'amount', field: 'invoice_file_path',
}, },
status: { status: {
type: DataTypes.STRING(50), type: DataTypes.STRING(50),
allowNull: true, 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: { description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
@ -104,6 +125,7 @@ ClaimInvoice.init(
{ unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' }, { unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' },
{ fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' }, { fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' },
{ fields: ['dms_number'], name: 'idx_claim_invoices_dms_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', targetKey: 'requestId',
}); });
// Note: hasMany association with ClaimCreditNote is defined in ClaimCreditNote.ts
// to avoid circular dependency issues
export { ClaimInvoice }; export { ClaimInvoice };

View File

@ -86,5 +86,12 @@ router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimC
*/ */
router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); 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; export default router;

View File

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

View File

@ -16,6 +16,7 @@ import aiRoutes from './ai.routes';
import dealerClaimRoutes from './dealerClaim.routes'; import dealerClaimRoutes from './dealerClaim.routes';
import templateRoutes from './template.routes'; import templateRoutes from './template.routes';
import dealerRoutes from './dealer.routes'; import dealerRoutes from './dealer.routes';
import dmsWebhookRoutes from './dmsWebhook.routes';
const router = Router(); const router = Router();
@ -46,6 +47,7 @@ router.use('/summaries', summaryRoutes);
router.use('/dealer-claims', dealerClaimRoutes); router.use('/dealer-claims', dealerClaimRoutes);
router.use('/templates', templateRoutes); router.use('/templates', templateRoutes);
router.use('/dealers', dealerRoutes); router.use('/dealers', dealerRoutes);
router.use('/webhooks/dms', dmsWebhookRoutes);
// TODO: Add other route modules as they are implemented // TODO: Add other route modules as they are implemented
// router.use('/approvals', approvalRoutes); // router.use('/approvals', approvalRoutes);

View File

@ -132,6 +132,7 @@ async function runMigrations(): Promise<void> {
const m37 = require('../migrations/20251213-drop-claim-details-invoice-columns'); const m37 = require('../migrations/20251213-drop-claim-details-invoice-columns');
const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables'); const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables');
const m39 = require('../migrations/20251214-create-dealer-completion-expenses'); const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -176,6 +177,7 @@ async function runMigrations(): Promise<void> {
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 }, { name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 }, { name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
]; ];
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();

View File

@ -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 m37 from '../migrations/20251213-drop-claim-details-invoice-columns';
import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables'; import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables';
import * as m39 from '../migrations/20251214-create-dealer-completion-expenses'; import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
interface Migration { interface Migration {
name: string; name: string;
@ -98,6 +99,7 @@ const migrations: Migration[] = [
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 }, { name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 }, { name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
]; ];
/** /**

View File

@ -447,19 +447,14 @@ export class ApprovalService {
// Don't fail the Step 3 approval if Step 4 processing fails - log and continue // 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') { } else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
// Step 7 is an auto-step - process it automatically after Step 6 approval // Step 7 is an auto-step - activate it but don't process invoice generation
logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`); // Invoice generation will be handled by DMS webhook when invoice is created
try { logger.info(`[Approval] Step 6 approved for claim management workflow. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
const dealerClaimService = new DealerClaimService(); // Step 7 will remain in IN_PROGRESS until webhook creates invoice and auto-approves it
await dealerClaimService.processEInvoiceGeneration(level.requestId); // Continue with normal flow to activate Step 7
logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`); }
// Skip notification for system auto-processed step
return updatedLevel; if (wf && nextLevel) {
} 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) {
// Normal flow - notify next approver (skip for auto-steps) // Normal flow - notify next approver (skip for auto-steps)
// Check if it's an auto-step by checking approverEmail or levelName // Check if it's an auto-step by checking approverEmail or levelName
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'

View File

@ -1544,6 +1544,7 @@ export class DealerClaimService {
dmsNumber: invoiceResult.dmsNumber, dmsNumber: invoiceResult.dmsNumber,
amount: invoiceAmount, amount: invoiceAmount,
status: 'GENERATED', status: 'GENERATED',
generatedAt: new Date(),
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
}); });
@ -1560,6 +1561,7 @@ export class DealerClaimService {
dmsNumber: invoiceData.dmsNumber, dmsNumber: invoiceData.dmsNumber,
amount: invoiceData.amount, amount: invoiceData.amount,
status: 'UPDATED', status: 'UPDATED',
generatedAt: new Date(),
description: invoiceData.description, description: invoiceData.description,
}); });
@ -1573,19 +1575,18 @@ export class DealerClaimService {
if (step6Level && step6Level.status !== ApprovalStatus.APPROVED) { if (step6Level && step6Level.status !== ApprovalStatus.APPROVED) {
logger.info(`[DealerClaimService] Step 6 not approved yet. Auto-approving Step 6 for request ${requestId}`); 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( await this.approvalService.approveLevel(
step6Level.levelId, 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', 'system',
{ ipAddress: null, userAgent: 'System Auto-Process' } { ipAddress: null, userAgent: 'System Auto-Process' }
); );
// Note: Step 7 auto-processing will be handled by approval service when Step 6 is approved // Note: Step 7 invoice generation will be handled by DMS webhook, not here
// But we also call it directly here to ensure it runs (in case approval service logic doesn't trigger) logger.info(`[DealerClaimService] Step 6 approved. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
await this.processEInvoiceGeneration(requestId);
} else { } else {
// Step 6 already approved - directly process Step 7 // Step 6 already approved - Step 7 should be active, waiting for webhook
await this.processEInvoiceGeneration(requestId); logger.info(`[DealerClaimService] Step 6 already approved. Step 7 should be active. Invoice generation will happen via DMS webhook.`);
} }
} catch (error) { } catch (error) {
logger.error('[DealerClaimService] Error updating e-invoice details:', 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) * Process Step 7: E-Invoice Generation (Deprecated - now handled by DMS webhook)
* Generates e-invoice via DMS and auto-approves Step 7 * 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<void> { async processEInvoiceGeneration(requestId: string): Promise<void> {
try { try {
@ -1707,10 +1709,12 @@ export class DealerClaimService {
await ClaimCreditNote.upsert({ await ClaimCreditNote.upsert({
requestId, requestId,
invoiceId: claimInvoice.invoiceId,
creditNoteNumber: creditNoteResult.creditNoteNumber, creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteDate: creditNoteResult.creditNoteDate || new Date(), creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
creditNoteAmount: creditNoteResult.creditNoteAmount, creditNoteAmount: creditNoteResult.creditNoteAmount,
status: 'GENERATED', status: 'GENERATED',
confirmedAt: new Date(),
reason: creditNoteData?.reason || 'Claim settlement', reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
}); });
@ -1723,10 +1727,12 @@ export class DealerClaimService {
// Manual entry - just update the fields // Manual entry - just update the fields
await ClaimCreditNote.upsert({ await ClaimCreditNote.upsert({
requestId, requestId,
invoiceId: claimInvoice.invoiceId,
creditNoteNumber: creditNoteData.creditNoteNumber, creditNoteNumber: creditNoteData.creditNoteNumber,
creditNoteDate: creditNoteData.creditNoteDate || new Date(), creditNoteDate: creditNoteData.creditNoteDate || new Date(),
creditNoteAmount: creditNoteData.creditNoteAmount, creditNoteAmount: creditNoteData.creditNoteAmount,
status: 'UPDATED', status: 'UPDATED',
confirmedAt: new Date(),
reason: creditNoteData?.reason, reason: creditNoteData?.reason,
description: creditNoteData?.description, 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<void> {
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) * Process Step 4: Activity Creation (Auto-processed after Step 3 approval)
* Creates activity confirmation and sends emails to dealer, requestor, and department lead * Creates activity confirmation and sends emails to dealer, requestor, and department lead

View File

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