web hooks created for the dms team
This commit is contained in:
parent
8dc7fd3307
commit
1ac169dc7f
574
docs/DMS_WEBHOOK_API.md
Normal file
574
docs/DMS_WEBHOOK_API.md
Normal 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
|
||||
|
||||
669
docs/GCP_PRODUCTION_SETUP.md
Normal file
669
docs/GCP_PRODUCTION_SETUP.md
Normal 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
|
||||
|
||||
@ -813,5 +813,42 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send credit note to dealer and auto-approve Step 8
|
||||
* POST /api/v1/dealer-claims/:requestId/credit-note/send
|
||||
* Accepts either UUID or requestNumber
|
||||
*/
|
||||
async sendCreditNoteToDealer(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
117
src/controllers/dmsWebhook.controller.ts
Normal file
117
src/controllers/dmsWebhook.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,18 +23,30 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
},
|
||||
invoice_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
},
|
||||
dms_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
invoice_file_path: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
generation_status: {
|
||||
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
|
||||
allowNull: true,
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
generated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
@ -54,6 +66,7 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.addIndex('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true });
|
||||
await queryInterface.addIndex('claim_invoices', ['invoice_number'], { name: 'idx_claim_invoices_invoice_number' });
|
||||
await queryInterface.addIndex('claim_invoices', ['dms_number'], { name: 'idx_claim_invoices_dms_number' });
|
||||
await queryInterface.addIndex('claim_invoices', ['generation_status'], { name: 'idx_claim_invoices_status' });
|
||||
|
||||
await queryInterface.createTable('claim_credit_notes', {
|
||||
credit_note_id: {
|
||||
@ -69,6 +82,13 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
invoice_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'claim_invoices', key: 'invoice_id' },
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
credit_note_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
@ -77,12 +97,35 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
},
|
||||
credit_note_amount: {
|
||||
credit_amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED
|
||||
sap_document_number: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
},
|
||||
credit_note_file_path: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
},
|
||||
confirmation_status: {
|
||||
type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, CONFIRMED, FAILED, CANCELLED
|
||||
allowNull: true,
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
confirmed_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
confirmed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
reason: {
|
||||
@ -106,7 +149,10 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true });
|
||||
await queryInterface.addIndex('claim_credit_notes', ['invoice_id'], { name: 'idx_claim_credit_notes_invoice_id' });
|
||||
await queryInterface.addIndex('claim_credit_notes', ['credit_note_number'], { name: 'idx_claim_credit_notes_number' });
|
||||
await queryInterface.addIndex('claim_credit_notes', ['sap_document_number'], { name: 'idx_claim_credit_notes_sap_doc' });
|
||||
await queryInterface.addIndex('claim_credit_notes', ['confirmation_status'], { name: 'idx_claim_credit_notes_status' });
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
|
||||
240
src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts
Normal file
240
src/migrations/20251218-fix-claim-invoice-credit-note-columns.ts
Normal 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');
|
||||
}
|
||||
|
||||
@ -1,29 +1,42 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
import { ClaimInvoice } from './ClaimInvoice';
|
||||
|
||||
interface ClaimCreditNoteAttributes {
|
||||
creditNoteId: string;
|
||||
requestId: string;
|
||||
invoiceId?: string;
|
||||
creditNoteNumber?: string;
|
||||
creditNoteDate?: Date;
|
||||
creditNoteAmount?: number;
|
||||
sapDocumentNumber?: string;
|
||||
creditNoteFilePath?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
confirmedBy?: string;
|
||||
confirmedAt?: Date;
|
||||
reason?: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimCreditNoteCreationAttributes extends Optional<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 {
|
||||
public creditNoteId!: string;
|
||||
public requestId!: string;
|
||||
public invoiceId?: string;
|
||||
public creditNoteNumber?: string;
|
||||
public creditNoteDate?: Date;
|
||||
public creditNoteAmount?: number;
|
||||
public sapDocumentNumber?: string;
|
||||
public creditNoteFilePath?: string;
|
||||
public status?: string;
|
||||
public errorMessage?: string;
|
||||
public confirmedBy?: string;
|
||||
public confirmedAt?: Date;
|
||||
public reason?: string;
|
||||
public description?: string;
|
||||
public createdAt!: Date;
|
||||
@ -50,6 +63,17 @@ ClaimCreditNote.init(
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
invoiceId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'invoice_id',
|
||||
references: {
|
||||
model: 'claim_invoices',
|
||||
key: 'invoice_id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
creditNoteNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
@ -63,12 +87,43 @@ ClaimCreditNote.init(
|
||||
creditNoteAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'credit_note_amount',
|
||||
field: 'credit_amount',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'sap_document_number',
|
||||
},
|
||||
creditNoteFilePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'credit_note_file_path',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'status',
|
||||
field: 'confirmation_status',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
confirmedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'confirmed_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
confirmedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'confirmed_at',
|
||||
},
|
||||
reason: {
|
||||
type: DataTypes.TEXT,
|
||||
@ -102,7 +157,10 @@ ClaimCreditNote.init(
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{ unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' },
|
||||
{ fields: ['invoice_id'], name: 'idx_claim_credit_notes_invoice_id' },
|
||||
{ fields: ['credit_note_number'], name: 'idx_claim_credit_notes_number' },
|
||||
{ fields: ['sap_document_number'], name: 'idx_claim_credit_notes_sap_doc' },
|
||||
{ fields: ['confirmation_status'], name: 'idx_claim_credit_notes_status' },
|
||||
],
|
||||
}
|
||||
);
|
||||
@ -119,5 +177,17 @@ ClaimCreditNote.belongsTo(WorkflowRequest, {
|
||||
targetKey: 'requestId',
|
||||
});
|
||||
|
||||
ClaimCreditNote.belongsTo(ClaimInvoice, {
|
||||
as: 'claimInvoice',
|
||||
foreignKey: 'invoiceId',
|
||||
targetKey: 'invoiceId',
|
||||
});
|
||||
|
||||
ClaimInvoice.hasMany(ClaimCreditNote, {
|
||||
as: 'creditNotes',
|
||||
foreignKey: 'invoiceId',
|
||||
sourceKey: 'invoiceId',
|
||||
});
|
||||
|
||||
export { ClaimCreditNote };
|
||||
|
||||
|
||||
@ -7,24 +7,30 @@ interface ClaimInvoiceAttributes {
|
||||
requestId: string;
|
||||
invoiceNumber?: string;
|
||||
invoiceDate?: Date;
|
||||
dmsNumber?: string;
|
||||
amount?: number;
|
||||
dmsNumber?: string;
|
||||
invoiceFilePath?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
generatedAt?: Date;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimInvoiceCreationAttributes extends Optional<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 {
|
||||
public invoiceId!: string;
|
||||
public requestId!: string;
|
||||
public invoiceNumber?: string;
|
||||
public invoiceDate?: Date;
|
||||
public dmsNumber?: string;
|
||||
public amount?: number;
|
||||
public dmsNumber?: string;
|
||||
public invoiceFilePath?: string;
|
||||
public status?: string;
|
||||
public errorMessage?: string;
|
||||
public generatedAt?: Date;
|
||||
public description?: string;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
@ -60,20 +66,35 @@ ClaimInvoice.init(
|
||||
allowNull: true,
|
||||
field: 'invoice_date',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'invoice_amount',
|
||||
},
|
||||
dmsNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'dms_number',
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
invoiceFilePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'amount',
|
||||
field: 'invoice_file_path',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'status',
|
||||
field: 'generation_status',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
generatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'generated_at',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
@ -104,6 +125,7 @@ ClaimInvoice.init(
|
||||
{ unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' },
|
||||
{ fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' },
|
||||
{ fields: ['dms_number'], name: 'idx_claim_invoices_dms_number' },
|
||||
{ fields: ['generation_status'], name: 'idx_claim_invoices_status' },
|
||||
],
|
||||
}
|
||||
);
|
||||
@ -120,5 +142,8 @@ ClaimInvoice.belongsTo(WorkflowRequest, {
|
||||
targetKey: 'requestId',
|
||||
});
|
||||
|
||||
// Note: hasMany association with ClaimCreditNote is defined in ClaimCreditNote.ts
|
||||
// to avoid circular dependency issues
|
||||
|
||||
export { ClaimInvoice };
|
||||
|
||||
|
||||
@ -86,5 +86,12 @@ router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimC
|
||||
*/
|
||||
router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
|
||||
* @desc Send credit note to dealer and auto-approve Step 8
|
||||
* @access Private
|
||||
*/
|
||||
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
47
src/routes/dmsWebhook.routes.ts
Normal file
47
src/routes/dmsWebhook.routes.ts
Normal 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;
|
||||
|
||||
@ -16,6 +16,7 @@ import aiRoutes from './ai.routes';
|
||||
import dealerClaimRoutes from './dealerClaim.routes';
|
||||
import templateRoutes from './template.routes';
|
||||
import dealerRoutes from './dealer.routes';
|
||||
import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -46,6 +47,7 @@ router.use('/summaries', summaryRoutes);
|
||||
router.use('/dealer-claims', dealerClaimRoutes);
|
||||
router.use('/templates', templateRoutes);
|
||||
router.use('/dealers', dealerRoutes);
|
||||
router.use('/webhooks/dms', dmsWebhookRoutes);
|
||||
|
||||
// TODO: Add other route modules as they are implemented
|
||||
// router.use('/approvals', approvalRoutes);
|
||||
|
||||
@ -132,6 +132,7 @@ async function runMigrations(): Promise<void> {
|
||||
const m37 = require('../migrations/20251213-drop-claim-details-invoice-columns');
|
||||
const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables');
|
||||
const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
|
||||
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -176,6 +177,7 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
|
||||
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
|
||||
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
|
||||
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
||||
];
|
||||
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
@ -42,6 +42,7 @@ import * as m36 from '../migrations/20251211-create-claim-budget-tracking-table'
|
||||
import * as m37 from '../migrations/20251213-drop-claim-details-invoice-columns';
|
||||
import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables';
|
||||
import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
|
||||
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@ -98,6 +99,7 @@ const migrations: Migration[] = [
|
||||
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
|
||||
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
|
||||
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
|
||||
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -447,19 +447,14 @@ export class ApprovalService {
|
||||
// Don't fail the Step 3 approval if Step 4 processing fails - log and continue
|
||||
}
|
||||
} else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
|
||||
// Step 7 is an auto-step - process it automatically after Step 6 approval
|
||||
logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`);
|
||||
try {
|
||||
const dealerClaimService = new DealerClaimService();
|
||||
await dealerClaimService.processEInvoiceGeneration(level.requestId);
|
||||
logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`);
|
||||
// Skip notification for system auto-processed step
|
||||
return updatedLevel;
|
||||
} catch (step7Error) {
|
||||
logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error);
|
||||
// Don't fail the Step 6 approval if Step 7 processing fails - log and continue
|
||||
}
|
||||
} else if (wf && nextLevel) {
|
||||
// Step 7 is an auto-step - activate it but don't process invoice generation
|
||||
// Invoice generation will be handled by DMS webhook when invoice is created
|
||||
logger.info(`[Approval] Step 6 approved for claim management workflow. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
|
||||
// Step 7 will remain in IN_PROGRESS until webhook creates invoice and auto-approves it
|
||||
// Continue with normal flow to activate Step 7
|
||||
}
|
||||
|
||||
if (wf && nextLevel) {
|
||||
// Normal flow - notify next approver (skip for auto-steps)
|
||||
// Check if it's an auto-step by checking approverEmail or levelName
|
||||
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|
||||
|
||||
@ -1544,6 +1544,7 @@ export class DealerClaimService {
|
||||
dmsNumber: invoiceResult.dmsNumber,
|
||||
amount: invoiceAmount,
|
||||
status: 'GENERATED',
|
||||
generatedAt: new Date(),
|
||||
description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`,
|
||||
});
|
||||
|
||||
@ -1560,6 +1561,7 @@ export class DealerClaimService {
|
||||
dmsNumber: invoiceData.dmsNumber,
|
||||
amount: invoiceData.amount,
|
||||
status: 'UPDATED',
|
||||
generatedAt: new Date(),
|
||||
description: invoiceData.description,
|
||||
});
|
||||
|
||||
@ -1573,19 +1575,18 @@ export class DealerClaimService {
|
||||
|
||||
if (step6Level && step6Level.status !== ApprovalStatus.APPROVED) {
|
||||
logger.info(`[DealerClaimService] Step 6 not approved yet. Auto-approving Step 6 for request ${requestId}`);
|
||||
// Auto-approve Step 6 - this will trigger Step 7 auto-processing in approval service
|
||||
// Auto-approve Step 6 - Step 7 will be activated but invoice generation will happen via webhook
|
||||
await this.approvalService.approveLevel(
|
||||
step6Level.levelId,
|
||||
{ action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generated.' },
|
||||
{ action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. Waiting for DMS webhook to generate invoice.' },
|
||||
'system',
|
||||
{ ipAddress: null, userAgent: 'System Auto-Process' }
|
||||
);
|
||||
// Note: Step 7 auto-processing will be handled by approval service when Step 6 is approved
|
||||
// But we also call it directly here to ensure it runs (in case approval service logic doesn't trigger)
|
||||
await this.processEInvoiceGeneration(requestId);
|
||||
// Note: Step 7 invoice generation will be handled by DMS webhook, not here
|
||||
logger.info(`[DealerClaimService] Step 6 approved. Step 7 activated. Waiting for DMS webhook to generate invoice.`);
|
||||
} else {
|
||||
// Step 6 already approved - directly process Step 7
|
||||
await this.processEInvoiceGeneration(requestId);
|
||||
// Step 6 already approved - Step 7 should be active, waiting for webhook
|
||||
logger.info(`[DealerClaimService] Step 6 already approved. Step 7 should be active. Invoice generation will happen via DMS webhook.`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[DealerClaimService] Error updating e-invoice details:', error);
|
||||
@ -1594,8 +1595,9 @@ export class DealerClaimService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Step 7: E-Invoice Generation (Auto-processed after Step 6 approval or when pushing to DMS)
|
||||
* Generates e-invoice via DMS and auto-approves Step 7
|
||||
* Process Step 7: E-Invoice Generation (Deprecated - now handled by DMS webhook)
|
||||
* This method is kept for backward compatibility but invoice generation is now triggered only via DMS webhook
|
||||
* @deprecated Use DMS webhook to trigger invoice generation instead
|
||||
*/
|
||||
async processEInvoiceGeneration(requestId: string): Promise<void> {
|
||||
try {
|
||||
@ -1707,10 +1709,12 @@ export class DealerClaimService {
|
||||
|
||||
await ClaimCreditNote.upsert({
|
||||
requestId,
|
||||
invoiceId: claimInvoice.invoiceId,
|
||||
creditNoteNumber: creditNoteResult.creditNoteNumber,
|
||||
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
|
||||
creditNoteAmount: creditNoteResult.creditNoteAmount,
|
||||
status: 'GENERATED',
|
||||
confirmedAt: new Date(),
|
||||
reason: creditNoteData?.reason || 'Claim settlement',
|
||||
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
|
||||
});
|
||||
@ -1723,10 +1727,12 @@ export class DealerClaimService {
|
||||
// Manual entry - just update the fields
|
||||
await ClaimCreditNote.upsert({
|
||||
requestId,
|
||||
invoiceId: claimInvoice.invoiceId,
|
||||
creditNoteNumber: creditNoteData.creditNoteNumber,
|
||||
creditNoteDate: creditNoteData.creditNoteDate || new Date(),
|
||||
creditNoteAmount: creditNoteData.creditNoteAmount,
|
||||
status: 'UPDATED',
|
||||
confirmedAt: new Date(),
|
||||
reason: creditNoteData?.reason,
|
||||
description: creditNoteData?.description,
|
||||
});
|
||||
@ -1739,6 +1745,107 @@ export class DealerClaimService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send credit note to dealer and auto-approve Step 8
|
||||
* This method sends the credit note to the dealer via email/notification and auto-approves Step 8
|
||||
*/
|
||||
async sendCreditNoteToDealer(requestId: string, userId: string): Promise<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)
|
||||
* Creates activity confirmation and sends emails to dealer, requestor, and department lead
|
||||
|
||||
408
src/services/dmsWebhook.service.ts
Normal file
408
src/services/dmsWebhook.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user