sap implementsion for internal order and budget block

This commit is contained in:
laxmanhalaki 2025-12-16 19:50:16 +05:30
parent c97e5df689
commit 4de9cddb6b
9 changed files with 1313 additions and 168 deletions

View File

@ -0,0 +1,214 @@
# SAP Integration Testing Guide
## Postman Testing
### 1. Testing IO Validation API
**Endpoint:** `GET /api/v1/dealer-claims/:requestId/io`
**Method:** GET
**Headers:**
```
Authorization: Bearer <your_jwt_token>
Content-Type: application/json
```
**Note:** The CSRF error in Postman is likely coming from SAP, not our backend. Our backend doesn't have CSRF protection enabled.
### 2. Testing Budget Blocking API
**Endpoint:** `PUT /api/v1/dealer-claims/:requestId/io`
**Method:** PUT
**Headers:**
```
Authorization: Bearer <your_jwt_token>
Content-Type: application/json
```
**Body:**
```json
{
"ioNumber": "600060",
"ioRemark": "Test remark",
"availableBalance": 1000000,
"blockedAmount": 500,
"remainingBalance": 999500
}
```
### 3. Direct SAP API Testing in Postman
If you want to test SAP API directly (bypassing our backend):
#### IO Validation
- **URL:** `https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_CHECK_API_SRV/GetSenderDataSet?$filter=IONumber eq '600060'&$select=Sender,ResponseDate,GetIODetailsSet01&$expand=GetIODetailsSet01&$format=json`
- **Method:** GET
- **Authentication:** Basic Auth
- Username: Your SAP username
- Password: Your SAP password
- **Headers:**
- `Accept: application/json`
- `Content-Type: application/json`
#### Budget Blocking
- **URL:** `https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_BLOCK_API_SRV/RequesterInputSet`
- **Method:** POST
- **Authentication:** Basic Auth
- Username: Your SAP username
- Password: Your SAP password
- **Headers:**
- `Accept: application/json`
- `Content-Type: application/json`
- **Body:**
```json
{
"Request_Date_Time": "2025-08-29T10:51:00",
"Requester": "REFMS",
"lt_io_input": [
{
"IONumber": "600060",
"Amount": "500"
}
],
"lt_io_output": [],
"ls_response": []
}
```
## Common Errors and Solutions
### 1. CSRF Token Validation Error
**Error:** "CSRF token validation error"
**Possible Causes:**
- SAP API requires CSRF tokens for POST/PUT requests
- SAP might be checking for specific headers
**Solutions:**
1. **Get CSRF Token First:**
- Make a GET request to the SAP service root to get CSRF token
- Example: `GET https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_BLOCK_API_SRV/`
- Look for `x-csrf-token` header in response
- Add this token to subsequent POST/PUT requests as header: `X-CSRF-Token: <token>`
2. **Add Required Headers:**
```
X-CSRF-Token: Fetch
X-Requested-With: XMLHttpRequest
```
### 2. Authentication Failed
**Error:** "Authentication failed" or "401 Unauthorized"
**Possible Causes:**
1. Wrong username/password
2. Basic auth not being sent correctly
3. SSL certificate issues
4. SAP account locked or expired
**Solutions:**
1. **Verify Credentials:**
- Double-check `SAP_USERNAME` and `SAP_PASSWORD` in `.env`
- Ensure no extra spaces or special characters
- Test credentials in browser first
2. **Check SSL Certificate:**
- If using self-signed certificate, set `SAP_DISABLE_SSL_VERIFY=true` in `.env` (testing only!)
- For production, ensure proper SSL certificates are configured
3. **Test Basic Auth Manually:**
- Use Postman with Basic Auth enabled
- Verify the Authorization header format: `Basic <base64(username:password)>`
4. **Check SAP Account Status:**
- Verify account is active and not locked
- Check if password has expired
- Contact SAP administrator if needed
### 3. Connection Errors
**Error:** "ECONNREFUSED" or "ENOTFOUND"
**Solutions:**
1. Verify `SAP_BASE_URL` is correct
2. Check network connectivity to SAP server
3. Ensure firewall allows connections to port 1443
4. Verify Zscaler is configured correctly
### 4. Timeout Errors
**Error:** "Request timeout"
**Solutions:**
1. Increase `SAP_TIMEOUT_MS` in `.env` (default: 30000ms = 30 seconds)
2. Check SAP server response time
3. Verify network latency
## Debugging
### Enable Debug Logging
Set log level to debug in your `.env`:
```
LOG_LEVEL=debug
```
This will log:
- Request URLs
- Request payloads
- Response status codes
- Response data
- Error details
### Check Backend Logs
Look for `[SAP]` prefixed log messages:
```bash
# In development
npm run dev
# Check logs for SAP-related messages
```
### Test SAP Connection
You can test if SAP is reachable:
```bash
curl -u "username:password" \
"https://RENOIHND01.Eichergroup.com:1443/sap/opu/odata/sap/ZFI_BUDGET_CHECK_API_SRV/"
```
## Environment Variables Checklist
Ensure these are set in your `.env`:
```bash
# Required
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
SAP_USERNAME=your_username
SAP_PASSWORD=your_password
# Optional (with defaults)
SAP_TIMEOUT_MS=30000
SAP_SERVICE_NAME=ZFI_BUDGET_CHECK_API_SRV
SAP_BLOCK_SERVICE_NAME=ZFI_BUDGET_BLOCK_API_SRV
SAP_REQUESTER=REFMS
SAP_DISABLE_SSL_VERIFY=false # Only for testing
```
## Next Steps
If you're still getting errors:
1. **Check Backend Logs:** Look for detailed error messages
2. **Test Directly in Postman:** Bypass backend and test SAP API directly
3. **Verify SAP Credentials:** Test with SAP administrator
4. **Check Network:** Ensure server can reach SAP URL
5. **Review SAP Documentation:** Check if there are additional requirements

View File

@ -84,3 +84,18 @@ VAPID_CONTACT=mailto:you@example.com
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}} REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
TAT_TEST_MODE=false (on true it will consider 1 hour==1min) TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
# SAP Integration (OData Service via Zscaler)
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
SAP_USERNAME={{SAP_USERNAME}}
SAP_PASSWORD={{SAP_PASSWORD}}
SAP_TIMEOUT_MS=30000
# SAP OData Service Name for IO Validation (default: ZFI_BUDGET_CHECK_API_SRV)
SAP_SERVICE_NAME=ZFI_BUDGET_CHECK_API_SRV
# SAP OData Service Name for Budget Blocking (default: ZFI_BUDGET_BLOCK_API_SRV)
SAP_BLOCK_SERVICE_NAME=ZFI_BUDGET_BLOCK_API_SRV
# SAP Requester identifier for budget blocking API (default: REFMS)
SAP_REQUESTER=REFMS
# SAP SSL Verification (set to 'true' to disable SSL verification for testing with self-signed certs)
# WARNING: Only use in development/testing environments
SAP_DISABLE_SSL_VERIFY=false

57
package-lock.json generated
View File

@ -21,6 +21,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@ -793,18 +794,6 @@
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
} }
}, },
"node_modules/@aws-sdk/xml-builder/node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/@aws/lambda-invoke-store": { "node_modules/@aws/lambda-invoke-store": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
@ -1651,6 +1640,36 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@google-cloud/storage/node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@google-cloud/storage/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/@google-cloud/vertexai": { "node_modules/@google-cloud/vertexai": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz",
@ -6346,9 +6365,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "4.5.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", "integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -6357,7 +6376,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"strnum": "^1.1.1" "strnum": "^2.1.0"
}, },
"bin": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@ -10791,9 +10810,9 @@
} }
}, },
"node_modules/strnum": { "node_modules/strnum": {
"version": "1.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",

View File

@ -35,6 +35,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",

View File

@ -5,6 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service'; import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document'; import { Document } from '../models/Document';
import { InternalOrder } from '../models/InternalOrder';
import { constants } from '../config/constants'; import { constants } from '../config/constants';
import { sapIntegrationService } from '../services/sapIntegration.service'; import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs'; import fs from 'fs';
@ -653,6 +654,8 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400); return ResponseHandler.error(res, 'Available balance is required when blocking amount', 400);
} }
// Don't pass remainingBalance - let the service calculate it from SAP's response
// This ensures we always use the actual remaining balance from SAP after blocking
await this.dealerClaimService.updateIODetails( await this.dealerClaimService.updateIODetails(
requestId, requestId,
{ {
@ -660,23 +663,43 @@ export class DealerClaimController {
ioRemark: ioRemark || '', ioRemark: ioRemark || '',
availableBalance: parseFloat(availableBalance), availableBalance: parseFloat(availableBalance),
blockedAmount: blockAmount, blockedAmount: blockAmount,
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount, // remainingBalance will be calculated by the service from SAP's response
}, },
userId userId
); );
// Fetch and return the updated IO details from database
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
if (updatedIO) {
return ResponseHandler.success(res, {
message: 'IO blocked successfully in SAP',
ioDetails: {
ioNumber: updatedIO.ioNumber,
ioAvailableBalance: updatedIO.ioAvailableBalance,
ioBlockedAmount: updatedIO.ioBlockedAmount,
ioRemainingBalance: updatedIO.ioRemainingBalance,
ioRemark: updatedIO.ioRemark,
status: updatedIO.status,
}
}, 'IO blocked');
}
return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked'); return ResponseHandler.success(res, { message: 'IO blocked successfully in SAP' }, 'IO blocked');
} else if (ioNumber && ioRemark !== undefined) { } else if (ioNumber && ioRemark !== undefined) {
// Save IO details (ioNumber, ioRemark) even without blocking amount // Save IO details (ioNumber, ioRemark) even without blocking amount
// This is useful when Step 3 is approved but amount hasn't been blocked yet // This is useful when Step 3 is approved but amount hasn't been blocked yet
// IMPORTANT: Don't pass balance fields to preserve existing values from previous blocking
await this.dealerClaimService.updateIODetails( await this.dealerClaimService.updateIODetails(
requestId, requestId,
{ {
ioNumber, ioNumber,
ioRemark: ioRemark || '', ioRemark: ioRemark || '',
availableBalance: availableBalance ? parseFloat(availableBalance) : 0, // Don't pass balance fields - preserve existing values from previous blocking
// Only pass if explicitly provided and > 0 (for new records)
...(availableBalance && parseFloat(availableBalance) > 0 && { availableBalance: parseFloat(availableBalance) }),
blockedAmount: 0, blockedAmount: 0,
remainingBalance: remainingBalance ? parseFloat(remainingBalance) : 0, // Don't pass remainingBalance - preserve existing value from previous blocking
}, },
userId userId
); );

View File

@ -480,16 +480,30 @@ export class ApprovalService {
}); });
// Log assignment activity for next level (when it becomes active) // Log assignment activity for next level (when it becomes active)
// Skip notifications and assignment logging for system/auto-steps // IMPORTANT: Skip notifications and assignment logging for system/auto-steps
// System steps are: Activity Creation (Step 4), E-Invoice Generation (Step 7), and any step with system@royalenfield.com
// These steps are processed automatically and should NOT trigger notifications
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') { if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
// Additional check: ensure approverEmail is not a system email // Additional checks: ensure approverEmail and approverName are not system-related
// This prevents notifications to system accounts even if they pass other checks
const approverEmail = (nextLevel as any).approverEmail || ''; const approverEmail = (nextLevel as any).approverEmail || '';
const approverName = (nextLevel as any).approverName || '';
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com' const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|| approverEmail.toLowerCase().includes('system'); || approverEmail.toLowerCase().includes('system');
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|| approverName.toLowerCase().includes('system');
if (!isSystemEmail) { // EXCLUDE all system-related steps from notifications
// Only send notifications to real users, NOT system processes
if (!isSystemEmail && !isSystemName) {
// Send notification to next approver (only for real users, not system processes) // Send notification to next approver (only for real users, not system processes)
await notificationService.sendToUsers([ (nextLevel as any).approverId ], { // This will send both in-app and email notifications
const nextApproverId = (nextLevel as any).approverId;
const nextApproverName = (nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver';
logger.info(`[Approval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
await notificationService.sendToUsers([ nextApproverId ], {
title: `Action required: ${(wf as any).requestNumber}`, title: `Action required: ${(wf as any).requestNumber}`,
body: `${(wf as any).title}`, body: `${(wf as any).title}`,
requestNumber: (wf as any).requestNumber, requestNumber: (wf as any).requestNumber,
@ -500,6 +514,8 @@ export class ApprovalService {
actionRequired: true actionRequired: true
}); });
logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`);
// Log assignment activity for the next approver // Log assignment activity for the next approver
activityService.log({ activityService.log({
requestId: level.requestId, requestId: level.requestId,
@ -507,7 +523,7 @@ export class ApprovalService {
user: { userId: level.approverId, name: level.approverName }, user: { userId: level.approverId, name: level.approverName },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
action: 'Assigned to approver', action: 'Assigned to approver',
details: `Request assigned to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver'} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`, details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
ipAddress: requestMetadata?.ipAddress || undefined, ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined userAgent: requestMetadata?.userAgent || undefined
}); });
@ -517,6 +533,31 @@ export class ApprovalService {
} else { } else {
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`); logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
} }
// Notify initiator when dealer submits documents (Step 1 or Step 5 approval in claim management)
const workflowType = (wf as any)?.workflowType;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
const isStep1Approval = level.levelNumber === 1;
const isStep5Approval = level.levelNumber === 5;
if (isClaimManagement && (isStep1Approval || isStep5Approval) && (wf as any).initiatorId) {
const stepMessage = isStep1Approval
? 'Dealer proposal has been submitted and is now under review.'
: 'Dealer completion documents have been submitted and are now under review.';
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: isStep1Approval ? 'Proposal Submitted' : 'Completion Documents Submitted',
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
requestNumber: (wf as any).requestNumber,
requestId: (wf as any).requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval',
priority: 'MEDIUM',
actionRequired: false
});
logger.info(`[Approval] Sent notification to initiator for ${isStep1Approval ? 'Step 1' : 'Step 5'} approval in claim management workflow`);
}
} }
} else { } else {
// No next level found but not final approver - this shouldn't happen // No next level found but not final approver - this shouldn't happen

View File

@ -1273,15 +1273,24 @@ export class DealerClaimService {
if (!created) { if (!created) {
// Update existing IO record with new IO details // Update existing IO record with new IO details
// IMPORTANT: When updating existing record, preserve balance fields from previous blocking
// Only update ioNumber and ioRemark - don't overwrite balance values
await internalOrder.update({ await internalOrder.update({
ioNumber: ioData.ioNumber, ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '', ioRemark: ioData.ioRemark || '',
// Only update balance fields if provided // Don't update balance fields for existing records - preserve values from previous blocking
...(ioData.availableBalance !== undefined && { ioAvailableBalance: ioData.availableBalance }), // Only update organizedBy and organizedAt
...(ioData.remainingBalance !== undefined && { ioRemainingBalance: ioData.remainingBalance }),
organizedBy: organizedBy || internalOrder.organizedBy, organizedBy: organizedBy || internalOrder.organizedBy,
organizedAt: new Date(), organizedAt: new Date(),
}); });
logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, {
ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark,
preservedAvailableBalance: internalOrder.ioAvailableBalance,
preservedBlockedAmount: internalOrder.ioBlockedAmount,
preservedRemainingBalance: internalOrder.ioRemainingBalance,
});
} }
logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, { logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, {
@ -1318,41 +1327,101 @@ export class DealerClaimService {
} }
const finalBlockedAmount = blockResult.blockedAmount; const finalBlockedAmount = blockResult.blockedAmount;
const remainingBalance = blockResult.remainingBalance;
const availableBalance = ioData.availableBalance || ioValidation.availableBalance; const availableBalance = ioData.availableBalance || ioValidation.availableBalance;
// Calculate remaining balance: availableBalance - blockedAmount
// Use SAP's returned value if available and valid (> 0), otherwise calculate it
// This ensures we always have a valid remaining balance value
const sapRemainingBalance = blockResult.remainingBalance;
const calculatedRemainingBalance = availableBalance - finalBlockedAmount;
const remainingBalance = (sapRemainingBalance > 0 && sapRemainingBalance <= availableBalance)
? sapRemainingBalance
: calculatedRemainingBalance;
// Ensure remaining balance is not negative
const finalRemainingBalance = Math.max(0, remainingBalance);
logger.info(`[DealerClaimService] Budget blocking calculation:`, {
availableBalance,
blockedAmount: finalBlockedAmount,
sapRemainingBalance,
calculatedRemainingBalance,
finalRemainingBalance
});
// Get the user who is blocking the IO (current user) // Get the user who is blocking the IO (current user)
const organizedBy = organizedByUserId || null; const organizedBy = organizedByUserId || null;
// Create or update Internal Order record (only when blocking) // Create or update Internal Order record (only when blocking)
const [internalOrder, created] = await InternalOrder.findOrCreate({ const ioRecordData = {
where: { requestId },
defaults: {
requestId, requestId,
ioNumber: ioData.ioNumber, ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '', ioRemark: ioData.ioRemark || '',
ioAvailableBalance: availableBalance, ioAvailableBalance: availableBalance,
ioBlockedAmount: finalBlockedAmount, ioBlockedAmount: finalBlockedAmount,
ioRemainingBalance: remainingBalance, ioRemainingBalance: finalRemainingBalance,
organizedBy: organizedBy || undefined, organizedBy: organizedBy || undefined,
organizedAt: new Date(), organizedAt: new Date(),
status: IOStatus.BLOCKED, status: IOStatus.BLOCKED,
} };
logger.info(`[DealerClaimService] Storing IO details in database:`, {
ioNumber: ioData.ioNumber,
ioAvailableBalance: availableBalance,
ioBlockedAmount: finalBlockedAmount,
ioRemainingBalance: finalRemainingBalance,
requestId
});
const [internalOrder, created] = await InternalOrder.findOrCreate({
where: { requestId },
defaults: ioRecordData
}); });
if (!created) { if (!created) {
// Update existing IO record // Update existing IO record - explicitly update all fields including remainingBalance
await internalOrder.update({ logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`);
ioNumber: ioData.ioNumber, logger.info(`[DealerClaimService] Update data:`, {
ioRemark: ioData.ioRemark || '', ioRemainingBalance: ioRecordData.ioRemainingBalance,
ioAvailableBalance: availableBalance, ioBlockedAmount: ioRecordData.ioBlockedAmount,
ioBlockedAmount: finalBlockedAmount, ioAvailableBalance: ioRecordData.ioAvailableBalance
ioRemainingBalance: remainingBalance,
// Update to current user who is blocking
organizedBy: organizedBy || internalOrder.organizedBy,
organizedAt: new Date(),
status: IOStatus.BLOCKED,
}); });
// Explicitly update all fields to ensure remainingBalance is saved
const updateResult = await internalOrder.update({
ioNumber: ioRecordData.ioNumber,
ioRemark: ioRecordData.ioRemark,
ioAvailableBalance: ioRecordData.ioAvailableBalance,
ioBlockedAmount: ioRecordData.ioBlockedAmount,
ioRemainingBalance: ioRecordData.ioRemainingBalance, // Explicitly ensure this is updated
organizedBy: ioRecordData.organizedBy,
organizedAt: ioRecordData.organizedAt,
status: ioRecordData.status
});
logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed');
} else {
logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`);
}
// Verify what was actually saved - reload from database
await internalOrder.reload();
const savedRemainingBalance = internalOrder.ioRemainingBalance;
logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, {
ioId: internalOrder.ioId,
ioNumber: internalOrder.ioNumber,
ioAvailableBalance: internalOrder.ioAvailableBalance,
ioBlockedAmount: internalOrder.ioBlockedAmount,
ioRemainingBalance: savedRemainingBalance,
expectedRemainingBalance: finalRemainingBalance,
match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01,
status: internalOrder.status
});
// Warn if remaining balance doesn't match
if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) {
logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`);
} }
// Update budget tracking with blocked amount // Update budget tracking with blocked amount
@ -1367,7 +1436,8 @@ export class DealerClaimService {
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, { logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
ioNumber: ioData.ioNumber, ioNumber: ioData.ioNumber,
blockedAmount: finalBlockedAmount, blockedAmount: finalBlockedAmount,
remainingBalance availableBalance,
remainingBalance: finalRemainingBalance
}); });
} catch (error) { } catch (error) {
logger.error('[DealerClaimService] Error blocking IO:', error); logger.error('[DealerClaimService] Error blocking IO:', error);

View File

@ -143,8 +143,17 @@ class NotificationService {
// 1. Check admin + user preferences for in-app notifications // 1. Check admin + user preferences for in-app notifications
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general'); const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
logger.info(`[Notification] In-app notification check for user ${userId}:`, {
canSendInApp,
inAppNotificationsEnabled: user.inAppNotificationsEnabled,
notificationType: payload.type,
willCreate: canSendInApp && user.inAppNotificationsEnabled
});
let notification: any = null;
if (canSendInApp && user.inAppNotificationsEnabled) { if (canSendInApp && user.inAppNotificationsEnabled) {
const notification = await Notification.create({ try {
notification = await Notification.create({
userId, userId,
requestId: payload.requestId, requestId: payload.requestId,
notificationType: payload.type || 'general', notificationType: payload.type || 'general',
@ -165,7 +174,7 @@ class NotificationService {
} as any); } as any);
sentVia.push('IN_APP'); sentVia.push('IN_APP');
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`); logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title} (ID: ${(notification as any).notificationId})`);
// 2. Emit real-time socket event for immediate delivery // 2. Emit real-time socket event for immediate delivery
try { try {
@ -175,14 +184,20 @@ class NotificationService {
notification: notification.toJSON(), notification: notification.toJSON(),
...payload ...payload
}); });
logger.info(`[Notification] Emitted socket event to user ${userId}`); logger.info(`[Notification] ✅ Emitted socket event to user ${userId}`);
} else {
logger.warn(`[Notification] emitToUser function not available`);
} }
} catch (socketError) { } catch (socketError) {
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError); logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
} }
} catch (notificationError) {
logger.error(`[Notification] ❌ Failed to create in-app notification for user ${userId}:`, notificationError);
// Continue - don't block other notification channels
}
// 3. Send push notification (if enabled and user has subscriptions) // 3. Send push notification (if enabled and user has subscriptions)
if (user.pushNotificationsEnabled && canSendInApp) { if (user.pushNotificationsEnabled && canSendInApp && notification) {
let subs = this.userIdToSubscriptions.get(userId) || []; let subs = this.userIdToSubscriptions.get(userId) || [];
// Load from DB if memory empty // Load from DB if memory empty
if (subs.length === 0) { if (subs.length === 0) {
@ -290,18 +305,24 @@ class NotificationService {
} }
// Check if email should be sent (admin + user preferences) // Check if email should be sent (admin + user preferences)
// For assignment notifications, always attempt to send email (unless explicitly disabled by admin)
// This ensures next approvers always receive email notifications
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach' const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails ? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
: payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails : await shouldSendEmail(userId, emailType); // Regular emails
console.log(`[DEBUG Email] Should send email: ${shouldSend}`); console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`);
if (!shouldSend) { if (!shouldSend) {
console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`); console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`);
logger.info(`[Email] Skipped for user ${userId}, type: ${payload.type} (preferences)`); logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`);
return; return;
} }
logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`);
// Trigger email based on notification type // Trigger email based on notification type
// Email service will fetch additional data as needed // Email service will fetch additional data as needed
console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`); console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`);

View File

@ -1,30 +1,237 @@
import axios, { AxiosError } from 'axios';
import https from 'https';
import { XMLParser } from 'fast-xml-parser';
import logger from '../utils/logger'; import logger from '../utils/logger';
/** /**
* SAP Integration Service * SAP Integration Service
* Handles integration with SAP for IO validation and budget blocking * Handles integration with SAP for IO validation and budget blocking
* * Integrates with SAP OData service via Zscaler URL
* NOTE: This is a placeholder/stub implementation.
* Replace with actual SAP API integration based on your SAP system.
*/ */
export class SAPIntegrationService { export class SAPIntegrationService {
private sapBaseUrl: string; private sapBaseUrl: string;
private sapApiKey?: string;
private sapUsername?: string; private sapUsername?: string;
private sapPassword?: string; private sapPassword?: string;
private sapTimeout: number;
private sapServiceName: string; // OData service name for IO validation (e.g., ZFI_BUDGET_CHECK_API_SRV)
private sapBlockServiceName: string; // OData service name for budget blocking (e.g., ZFI_BUDGET_BLOCK_API_SRV)
private sapRequester: string; // Requester identifier for budget blocking
constructor() { constructor() {
this.sapBaseUrl = process.env.SAP_BASE_URL || ''; this.sapBaseUrl = process.env.SAP_BASE_URL || '';
this.sapApiKey = process.env.SAP_API_KEY;
this.sapUsername = process.env.SAP_USERNAME; this.sapUsername = process.env.SAP_USERNAME;
this.sapPassword = process.env.SAP_PASSWORD; this.sapPassword = process.env.SAP_PASSWORD;
this.sapTimeout = parseInt(process.env.SAP_TIMEOUT_MS || '30000', 10); // Default 30 seconds
// Service name can be configured per environment, defaults to budget check service
this.sapServiceName = process.env.SAP_SERVICE_NAME || 'ZFI_BUDGET_CHECK_API_SRV';
// Budget blocking service name (different from validation service)
this.sapBlockServiceName = process.env.SAP_BLOCK_SERVICE_NAME || 'ZFI_BUDGET_BLOCK_API_SRV';
// Requester identifier for budget blocking API
this.sapRequester = process.env.SAP_REQUESTER || 'REFMS';
} }
/** /**
* Check if SAP integration is configured * Check if SAP integration is configured
*/ */
private isConfigured(): boolean { private isConfigured(): boolean {
return !!this.sapBaseUrl && (!!this.sapApiKey || (!!this.sapUsername && !!this.sapPassword)); return !!this.sapBaseUrl && !!this.sapUsername && !!this.sapPassword;
}
/**
* Get CSRF token from SAP service (required for POST/PUT/DELETE operations)
* SAP OData services require CSRF tokens for state-changing operations
*
* Process:
* 1. Make GET request to service root with header "x-csrf-token: fetch"
* 2. Include query params: $format=json&sap-client=200
* 3. Extract CSRF token from response headers
* 4. Extract cookies from response (SAP session cookies)
* 5. Return both token and cookies for use in POST requests
*
* @returns Object with csrfToken and cookies, or null if failed
*/
private async getCsrfToken(serviceName: string): Promise<{ csrfToken: string; cookies: string } | null> {
try {
// Build service root URL with required query parameters
const serviceRootUrl = `/sap/opu/odata/sap/${serviceName}/`;
const queryParams = new URLSearchParams({
'$format': 'json',
'sap-client': '200'
});
const fullUrl = `${this.sapBaseUrl}${serviceRootUrl}?${queryParams.toString()}`;
logger.debug(`[SAP] Fetching CSRF token from service: ${serviceName}`);
logger.debug(`[SAP] CSRF token request URL: ${fullUrl}`);
// Use standalone axios request with Basic Auth in header
// We need to capture cookies from this response to use in POST request
const response = await axios.get(fullUrl, {
auth: {
username: this.sapUsername!,
password: this.sapPassword!
},
headers: {
'x-csrf-token': 'fetch', // Lowercase as per SAP requirement
'Accept': 'application/json',
'Content-Type': 'application/json'
},
timeout: this.sapTimeout,
httpsAgent: process.env.SAP_DISABLE_SSL_VERIFY === 'true' ? new https.Agent({
rejectUnauthorized: false
}) : undefined,
validateStatus: (status) => status < 500, // Don't throw on 4xx
// Enable cookie handling to capture SAP session cookies
withCredentials: true
});
// SAP returns CSRF token in response headers (check multiple case variations)
const csrfToken = response.headers['x-csrf-token'] ||
response.headers['X-CSRF-Token'] ||
response.headers['X-Csrf-Token'] ||
response.headers['x-csrf-token'];
// Extract cookies from response headers
// SAP sets cookies like: SAP_SESSIONID_DRE_200 and sap-usercontext
const setCookieHeaders = response.headers['set-cookie'] as string | string[] | undefined;
let cookies = '';
if (setCookieHeaders) {
if (Array.isArray(setCookieHeaders)) {
// Extract cookie values from Set-Cookie headers
cookies = setCookieHeaders.map((cookie: string) => {
// Extract cookie name=value (before semicolon)
return cookie.split(';')[0].trim();
}).join('; ');
} else {
// It's a string
cookies = setCookieHeaders.split(';')[0].trim();
}
}
// Log full GET response for debugging
logger.info(`[SAP] GET Response Status: ${response.status} ${response.statusText || ''}`);
logger.info(`[SAP] GET Response Headers:`, JSON.stringify(response.headers, null, 2));
logger.info(`[SAP] GET Response Data:`, JSON.stringify(response.data, null, 2));
if (csrfToken && typeof csrfToken === 'string' && csrfToken !== 'fetch') {
logger.info(`[SAP] CSRF token obtained successfully (length: ${csrfToken.length})`);
logger.debug(`[SAP] CSRF token preview: ${csrfToken.substring(0, 20)}...`);
if (cookies) {
logger.debug(`[SAP] Session cookies captured: ${cookies.substring(0, 50)}...`);
} else {
logger.warn('[SAP] No cookies found in CSRF token response - POST may fail');
}
return { csrfToken, cookies };
}
logger.warn('[SAP] CSRF token not found in response headers or invalid');
logger.debug('[SAP] Response status:', response.status);
logger.debug('[SAP] Available headers:', Object.keys(response.headers).filter(h => h.toLowerCase().includes('csrf')));
return null;
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
logger.error(`[SAP] Failed to get CSRF token: ${axiosError.response.status} ${axiosError.response.statusText}`);
logger.error(`[SAP] Response data:`, axiosError.response.data);
if (axiosError.response.status === 401 || axiosError.response.status === 403) {
logger.error('[SAP] Authentication failed while fetching CSRF token - check SAP credentials');
} else if (axiosError.response.status === 404) {
logger.error('[SAP] Service not found - check SAP_SERVICE_NAME configuration');
}
} else if (axiosError.request) {
logger.error('[SAP] No response received while fetching CSRF token:', axiosError.message);
if (axiosError.code === 'ECONNREFUSED') {
logger.error('[SAP] Connection refused - check if SAP server is reachable');
} else if (axiosError.code === 'ENOTFOUND') {
logger.error('[SAP] Host not found - check SAP_BASE_URL');
}
} else {
logger.error('[SAP] Error setting up CSRF token request:', error instanceof Error ? error.message : 'Unknown error');
}
return null;
}
}
/**
* Create axios instance with basic auth for SAP API calls
*/
private createSapClient() {
// Check if SSL verification should be disabled (for testing with self-signed certs)
const disableSSLVerification = process.env.SAP_DISABLE_SSL_VERIFY === 'true';
const client = axios.create({
baseURL: this.sapBaseUrl,
timeout: this.sapTimeout,
auth: {
username: this.sapUsername!,
password: this.sapPassword!
},
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
// SSL/TLS configuration
httpsAgent: disableSSLVerification ? new https.Agent({
rejectUnauthorized: false
}) : undefined
});
// Add request interceptor for debugging
client.interceptors.request.use(
(config) => {
logger.debug(`[SAP] Making ${config.method?.toUpperCase()} request to: ${config.baseURL}${config.url}`);
logger.debug(`[SAP] Auth username: ${this.sapUsername}`);
// Don't log password for security
return config;
},
(error) => {
logger.error('[SAP] Request interceptor error:', error);
return Promise.reject(error);
}
);
// Add response interceptor for debugging
client.interceptors.response.use(
(response) => {
logger.debug(`[SAP] Response status: ${response.status}`);
return response;
},
(error) => {
if (error.response) {
logger.error(`[SAP] Response error: ${error.response.status} ${error.response.statusText}`);
logger.error(`[SAP] Response data:`, error.response.data);
} else if (error.request) {
logger.error('[SAP] No response received:', error.message);
if (error.code === 'ECONNREFUSED') {
logger.error('[SAP] Connection refused - check if SAP server is reachable');
} else if (error.code === 'ENOTFOUND') {
logger.error('[SAP] Host not found - check SAP_BASE_URL');
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
logger.error('[SAP] SSL certificate error - set SAP_DISABLE_SSL_VERIFY=true to bypass (testing only)');
}
}
return Promise.reject(error);
}
);
return client;
}
/**
* Build SAP OData endpoint URL
* @param entitySet - OData entity set name (e.g., GetSenderDataSet)
* @param serviceName - Optional service name override (defaults to configured service)
* @returns Full OData endpoint path
*/
private buildODataEndpoint(entitySet: string, serviceName?: string): string {
const service = serviceName || this.sapServiceName;
return `/sap/opu/odata/sap/${service}/${entitySet}`;
} }
/** /**
@ -57,37 +264,191 @@ export class SAPIntegrationService {
}; };
} }
// TODO: Implement actual SAP API call const sapClient = this.createSapClient();
// Example:
// const response = await axios.get(`${this.sapBaseUrl}/api/io/${ioNumber}`, { // SAP OData endpoint: GetSenderDataSet with filter on IONumber
// headers: { // Service name is configurable via SAP_SERVICE_NAME env variable
// 'Authorization': `Bearer ${this.sapApiKey}`, const endpoint = this.buildODataEndpoint('GetSenderDataSet');
// 'Content-Type': 'application/json'
// } // Build OData query parameters matching the working URL format
// }); // $filter: Filter by IO number
// // $select: Select specific fields (Sender, ResponseDate, GetIODetailsSet01)
// return { // $expand: Expand the nested GetIODetailsSet01 entity set to get IO details
// isValid: response.data.valid, // $format: Explicitly request JSON format
// ioNumber: response.data.io_number, const queryParams = new URLSearchParams({
// availableBalance: response.data.available_balance, '$filter': `IONumber eq '${ioNumber}'`,
// blockedAmount: response.data.blocked_amount, '$select': 'Sender,ResponseDate,GetIODetailsSet01',
// remainingBalance: response.data.remaining_balance, '$expand': 'GetIODetailsSet01',
// currency: response.data.currency, '$format': 'json'
// description: response.data.description });
// };
const fullUrl = `${endpoint}?${queryParams.toString()}`;
logger.info(`[SAP] Validating IO number: ${ioNumber} using service: ${this.sapServiceName}`);
logger.debug(`[SAP] Request URL: ${this.sapBaseUrl}${fullUrl}`);
const response = await sapClient.get(fullUrl);
if (response.status === 200 && response.data) {
// SAP OData response format: { d: { results: [...] } }
const results = response.data.d?.results || response.data.results || [];
if (results.length === 0) {
logger.warn(`[SAP] IO number ${ioNumber} not found in SAP`);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: 'IO number not found in SAP system'
};
}
// Get first result (should be only one for a specific IO number)
const senderData = results[0];
// IO details are in the expanded GetIODetailsSet01 entity set
// Structure: senderData.GetIODetailsSet01.results[0]
const ioDetailsSet = senderData.GetIODetailsSet01;
if (!ioDetailsSet || !ioDetailsSet.results || !Array.isArray(ioDetailsSet.results) || ioDetailsSet.results.length === 0) {
logger.warn(`[SAP] No IO details found in expanded GetIODetailsSet01 for IO ${ioNumber}`);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: 'IO details not found in SAP response'
};
}
// Get the first IO detail from the results array
const ioDetails = ioDetailsSet.results[0];
// Map SAP response fields to our format based on actual response structure:
// - AvailableAmount: string with trailing space (e.g., "14333415.00 ")
// - IODescription: description text
// - IONumber: IO number
// - BlockedAmount: may not be present in response, default to 0
// - Currency: may not be present, default to INR
// Parse AvailableAmount - it's a string that may have trailing spaces
const availableAmountStr = (ioDetails.AvailableAmount || '0').toString().trim();
const availableBalance = parseFloat(availableAmountStr) || 0;
// BlockedAmount may not be in the response, default to 0
// If it exists, it might also be a string with trailing space
const blockedAmountStr = (ioDetails.BlockedAmount || ioDetails.Blocked || '0').toString().trim();
const blockedAmount = parseFloat(blockedAmountStr) || 0;
const remainingBalance = availableBalance - blockedAmount;
// Currency may not be in response, default to INR
const currency = (ioDetails.Currency || ioDetails.CurrencyCode || ioDetails.Curr || 'INR').toString().trim();
// Description from IODescription field
const description = ioDetails.IODescription || ioDetails.Description || ioDetails.Text || ioDetails.ShortText || undefined;
// IO Number from the IO details
const validatedIONumber = ioDetails.IONumber || ioDetails.InternalOrder || ioNumber;
logger.info(`[SAP] IO ${validatedIONumber} validated successfully. Available: ${availableBalance}, Blocked: ${blockedAmount}, Remaining: ${remainingBalance} ${currency}`);
logger.warn('[SAP] SAP API integration not implemented, returning mock data');
return { return {
isValid: true, isValid: true,
ioNumber, ioNumber: validatedIONumber,
availableBalance: 1000000, availableBalance,
blockedAmount: 0, blockedAmount,
remainingBalance: 1000000, remainingBalance,
currency: 'INR', currency,
description: 'Mock IO Data (SAP API not implemented)' description
}; };
} else if (response.status === 401 || response.status === 403) {
logger.error('[SAP] Authentication failed - check SAP credentials');
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: 'SAP authentication failed - invalid credentials'
};
} else if (response.status === 404) {
logger.warn(`[SAP] IO number ${ioNumber} not found (404)`);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: 'IO number not found in SAP system'
};
} else if (response.status === 501) {
logger.error(`[SAP] Not Implemented (501) - Check URL format and OData query parameters`);
logger.error(`[SAP] Request URL was: ${this.sapBaseUrl}${fullUrl}`);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: 'SAP API returned 501 Not Implemented - check OData query format'
};
} else {
logger.error(`[SAP] Unexpected response status: ${response.status}`);
logger.error(`[SAP] Response data:`, response.data);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: `SAP API returned status ${response.status}`
};
}
} catch (error) { } catch (error) {
logger.error('[SAP] Error validating IO number:', error); const axiosError = error as AxiosError;
if (axiosError.response) {
// SAP returned an error response
logger.error(`[SAP] Error validating IO number ${ioNumber}:`, {
status: axiosError.response.status,
statusText: axiosError.response.statusText,
data: axiosError.response.data
});
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: `SAP API error: ${axiosError.response.status} ${axiosError.response.statusText}`
};
} else if (axiosError.request) {
// Request was made but no response received
logger.error(`[SAP] No response from SAP API for IO ${ioNumber}:`, axiosError.message);
return {
isValid: false,
ioNumber,
availableBalance: 0,
blockedAmount: 0,
remainingBalance: 0,
currency: 'INR',
error: `SAP API connection failed: ${axiosError.message}`
};
} else {
// Error setting up request
logger.error('[SAP] Error setting up SAP API request:', error);
return { return {
isValid: false, isValid: false,
ioNumber, ioNumber,
@ -99,6 +460,7 @@ export class SAPIntegrationService {
}; };
} }
} }
}
/** /**
* Block budget in SAP for a claim request * Block budget in SAP for a claim request
@ -131,36 +493,414 @@ export class SAPIntegrationService {
}; };
} }
// TODO: Implement actual SAP API call to block budget const sapClient = this.createSapClient();
// Example:
// const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/block`, {
// amount,
// reference: requestNumber,
// description: description || `Budget block for request ${requestNumber}`
// }, {
// headers: {
// 'Authorization': `Bearer ${this.sapApiKey}`,
// 'Content-Type': 'application/json'
// }
// });
//
// return {
// success: response.data.success,
// blockId: response.data.block_id,
// blockedAmount: response.data.blocked_amount,
// remainingBalance: response.data.remaining_balance
// };
logger.warn('[SAP] SAP budget blocking not implemented, simulating block'); // SAP OData endpoint for budget blocking
// Service: ZFI_BUDGET_BLOCK_API_SRV
// Entity Set: RequesterInputSet
const endpoint = `/sap/opu/odata/sap/${this.sapBlockServiceName}/RequesterInputSet`;
// Format current date/time in ISO format: "2025-08-29T10:51:00"
const now = new Date();
const requestDateTime = now.toISOString().replace(/\.\d{3}Z$/, ''); // Remove milliseconds and Z
// Build request payload matching SAP API structure
const requestPayload = {
Request_Date_Time: requestDateTime,
Requester: this.sapRequester,
lt_io_input: [
{
IONumber: ioNumber,
Amount: amount.toString() // Amount as string
}
],
lt_io_output: [],
ls_response: []
};
logger.info(`[SAP] Blocking budget for IO ${ioNumber}, Amount: ${amount}, Request: ${requestNumber}`);
logger.debug(`[SAP] Budget block request payload:`, JSON.stringify(requestPayload, null, 2));
// Get CSRF token and cookies for POST request (SAP OData requires both)
// SAP sets session cookies during CSRF token fetch that must be included in POST
const csrfData = await this.getCsrfToken(this.sapBlockServiceName);
if (!csrfData || !csrfData.csrfToken) {
logger.warn('[SAP] CSRF token not available, request may fail with CSRF validation error');
logger.warn('[SAP] This is expected if SAP requires CSRF tokens for POST requests');
}
// Build headers with CSRF token, cookies, and other required headers
// Force JSON format via Accept header (SAP returns XML by default for POST)
const headers: Record<string, string> = {
'Accept': 'application/json, application/atom+xml;q=0.9', // Prefer JSON, fallback to XML
'Content-Type': 'application/json'
};
// Add CSRF token if available (required by SAP for POST/PUT/DELETE)
// Use lowercase 'x-csrf-token' as per SAP requirement
if (csrfData?.csrfToken) {
headers['x-csrf-token'] = csrfData.csrfToken;
logger.debug(`[SAP] CSRF token added to request headers`);
} else {
logger.warn('[SAP] CSRF token not available - request may fail with CSRF validation error');
}
// Add cookies if available (SAP session cookies required for POST)
// Cookies like: SAP_SESSIONID_DRE_200 and sap-usercontext
if (csrfData?.cookies) {
headers['Cookie'] = csrfData.cookies;
logger.debug(`[SAP] Session cookies added to request headers`);
} else {
logger.warn('[SAP] No session cookies available - request may fail');
}
// Some SAP systems also require these headers
headers['X-Requested-With'] = 'XMLHttpRequest';
// NOTE: Do NOT add query parameters ($format, sap-client) to POST requests
// SAP OData does not allow SystemQueryOptions in POST requests
// Query parameters are only allowed for GET requests
// Use the endpoint directly without query parameters
const urlWithParams = endpoint;
logger.debug(`[SAP] POST request URL: ${urlWithParams}`);
logger.debug(`[SAP] Request headers (CSRF token and cookies masked):`, {
...headers,
'x-csrf-token': csrfData?.csrfToken ? `${csrfData.csrfToken.substring(0, 10)}...` : 'not set',
'Cookie': csrfData?.cookies ? `${csrfData.cookies.substring(0, 30)}...` : 'not set'
});
logger.debug(`[SAP] Using username: ${this.sapUsername}`);
// Ensure auth is explicitly included in POST request config
// The axios instance has auth configured, but we'll include it explicitly to be safe
// This ensures auth is sent even if the instance config is overridden
const postConfig = {
headers,
auth: {
username: this.sapUsername!,
password: this.sapPassword!
},
timeout: this.sapTimeout,
httpsAgent: process.env.SAP_DISABLE_SSL_VERIFY === 'true' ? new https.Agent({
rejectUnauthorized: false
}) : undefined,
validateStatus: (status: number) => status < 500 // Don't throw on 4xx
};
logger.debug(`[SAP] POST request config prepared (auth included)`);
const response = await sapClient.post(urlWithParams, requestPayload, postConfig);
// Log full response for debugging
logger.info(`[SAP] POST Response Status: ${response.status} ${response.statusText || ''}`);
logger.info(`[SAP] POST Response Headers:`, JSON.stringify(response.headers, null, 2));
// Check if response is XML (SAP returns XML/Atom by default for POST)
const contentType = response.headers['content-type'] || '';
const isXML = contentType.includes('xml') || contentType.includes('atom') ||
(typeof response.data === 'string' && response.data.trim().startsWith('<'));
let responseData: any = response.data;
// Parse XML if needed
if (isXML && typeof response.data === 'string') {
logger.info(`[SAP] Response is XML, parsing to JSON...`);
try {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
parseAttributeValue: true,
trimValues: true
});
responseData = parser.parse(response.data);
logger.info(`[SAP] XML parsed successfully`);
} catch (xmlError) {
logger.error(`[SAP] Failed to parse XML response:`, xmlError);
// Continue with original data
}
}
// Log response data - handle different formats
if (responseData) {
try {
logger.info(`[SAP] POST Response Data:`, JSON.stringify(responseData, null, 2));
} catch (e) {
logger.info(`[SAP] POST Response Data (raw):`, responseData);
}
} else {
logger.info(`[SAP] POST Response Data: (empty or null)`);
}
// Also log the request that was sent
logger.info(`[SAP] POST Request URL: ${urlWithParams}`);
logger.info(`[SAP] POST Request Payload:`, JSON.stringify(requestPayload, null, 2));
if (response.status === 200 || response.status === 201) {
// Parse SAP response
// Response structure may vary, but typically contains:
// - Success indicator
// - Blocked amount confirmation
// - Remaining balance (in lt_io_output[0].Available_Amount for XML)
// - Block ID or reference number
// Helper function to extract remaining balance from various field names
// For XML: Available_Amount in lt_io_output[0] (may be prefixed with 'd:' namespace)
// For JSON: RemainingBalance, Remaining, Available_Amount, etc.
const extractRemainingBalance = (obj: any): number => {
if (!obj) return 0;
// Try various field name variations (both JSON and XML formats)
// XML namespace prefixes: 'd:Available_Amount', 'd:RemainingBalance', etc.
const value = obj['d:Available_Amount'] || // XML format with namespace prefix
obj.Available_Amount || // XML format without prefix
obj.RemainingBalance ||
obj['d:RemainingBalance'] ||
obj.Remaining ||
obj.RemainingAmount ||
obj.AvailableBalance ||
obj.Balance ||
obj.Available ||
0;
const parsed = parseFloat(value?.toString() || '0');
return isNaN(parsed) ? 0 : parsed;
};
// Helper function to extract blocked amount
const extractBlockedAmount = (obj: any): number => {
if (!obj) return amount;
const value = obj.BlockedAmount ||
obj.Amount ||
obj.Blocked ||
amount.toString();
const parsed = parseFloat(value?.toString() || amount.toString());
return isNaN(parsed) ? amount : parsed;
};
// Handle different possible response structures
let success = false;
let blockedAmount = amount;
let remainingBalance = 0;
let blockId: string | undefined;
// Parse XML structure: entry -> link[@rel='lt_io_output'] -> inline -> feed -> entry -> content -> properties
// Or JSON structure: { d: {...} } or { lt_io_output: [...] }
// Check for XML structure first (parsed XML from fast-xml-parser)
let ioOutputData: any = null;
let message = '';
// XML structure: entry.link (array) -> find link with @rel='lt_io_output' -> inline.feed.entry
if (responseData.entry) {
const entry = responseData.entry;
// Find lt_io_output link
const links = Array.isArray(entry.link) ? entry.link : (entry.link ? [entry.link] : []);
const ioOutputLink = links.find((link: any) =>
link['@_rel']?.includes('lt_io_output') ||
link['@_title'] === 'IOOutputSet' ||
link.title === 'IOOutputSet'
);
if (ioOutputLink?.inline?.feed?.entry) {
const ioEntry = Array.isArray(ioOutputLink.inline.feed.entry)
? ioOutputLink.inline.feed.entry[0]
: ioOutputLink.inline.feed.entry;
// Handle both namespace-prefixed and non-prefixed property names
const content = ioEntry.content || {};
const properties = content['m:properties'] || content.properties || content['@_type'] === 'application/xml' ? content : null;
if (properties) {
ioOutputData = properties;
// Try both namespace-prefixed and non-prefixed field names
message = ioOutputData['d:Message'] || ioOutputData.Message || ioOutputData['#text'] || '';
logger.info(`[SAP] Found XML structure - lt_io_output data extracted`);
logger.debug(`[SAP] XML properties keys:`, Object.keys(ioOutputData));
}
}
}
// Check for JSON OData format
if (responseData.d) {
// OData format: { d: { ... } }
const data = responseData.d;
success = data.Success !== false && data.Message !== 'Error';
blockedAmount = extractBlockedAmount(data);
remainingBalance = extractRemainingBalance(data);
blockId = data.BlockId || data.Reference || data.RequestId || data.DocumentNumber || data.Sap_Reference_no || undefined;
} else if (ioOutputData) {
// XML parsed structure - extract from lt_io_output properties
// Available_Amount is the remaining balance after blocking
success = message.includes('Successful') || message.includes('Success') || !message.includes('Error');
blockedAmount = amount; // Use the amount we sent (from lt_io_input)
remainingBalance = extractRemainingBalance(ioOutputData); // Available_Amount from XML
// Try both namespace-prefixed and non-prefixed field names
blockId = ioOutputData['d:Sap_Reference_no'] ||
ioOutputData.Sap_Reference_no ||
ioOutputData['d:Reference'] ||
ioOutputData.Reference ||
ioOutputData['d:BlockId'] ||
ioOutputData.BlockId ||
undefined;
logger.info(`[SAP] Extracted from XML lt_io_output:`, {
message,
availableAmount: remainingBalance,
sapReference: blockId,
allKeys: Object.keys(ioOutputData)
});
} else if (responseData.ls_response && Array.isArray(responseData.ls_response) && responseData.ls_response.length > 0) {
// Response in ls_response array
const responseItem = responseData.ls_response[0];
success = responseItem.Success !== false && responseItem.Message !== 'Error';
blockedAmount = extractBlockedAmount(responseItem);
remainingBalance = extractRemainingBalance(responseItem);
blockId = responseItem.BlockId || responseItem.Reference || responseItem.DocumentNumber || undefined;
} else if (responseData.lt_io_output && Array.isArray(responseData.lt_io_output) && responseData.lt_io_output.length > 0) {
// Response in lt_io_output array (JSON format)
const outputItem = responseData.lt_io_output[0];
success = outputItem.Success !== false && outputItem.Message !== 'Error';
blockedAmount = extractBlockedAmount(outputItem);
remainingBalance = extractRemainingBalance(outputItem);
blockId = outputItem.BlockId || outputItem.Reference || outputItem.DocumentNumber || outputItem.Sap_Reference_no || undefined;
} else if (responseData.Success !== undefined) {
// Direct success field
success = responseData.Success === true || responseData.Success === 'true';
blockedAmount = extractBlockedAmount(responseData);
remainingBalance = extractRemainingBalance(responseData);
blockId = responseData.BlockId || responseData.Reference || responseData.DocumentNumber || undefined;
} else {
// If no clear success indicator, assume success if status is 200/201
success = true;
blockedAmount = amount;
remainingBalance = 0; // Will be calculated in dealerClaim.service
logger.warn('[SAP] Budget block response structure unclear, assuming success');
logger.warn('[SAP] Response data keys:', Object.keys(responseData || {}));
}
// Log what we extracted
logger.info(`[SAP] Extracted from response:`, {
success,
blockedAmount,
remainingBalance,
blockId,
note: remainingBalance === 0 ? 'Remaining balance will be calculated from availableBalance - blockedAmount' : 'Remaining balance from SAP response'
});
if (success) {
logger.info(`[SAP] Budget blocked successfully for IO ${ioNumber}. Blocked: ${blockedAmount}, Remaining: ${remainingBalance}`);
return { return {
success: true, success: true,
blockId: `BLOCK-${Date.now()}`, blockId: blockId || `BLOCK-${Date.now()}`,
blockedAmount: amount, blockedAmount,
remainingBalance: 1000000 - amount, remainingBalance
error: 'SAP API not implemented - budget blocking simulated'
}; };
} else {
const errorMessage = responseData.Message || responseData.Error || 'Budget blocking failed';
logger.error(`[SAP] Budget blocking failed for IO ${ioNumber}: ${errorMessage}`);
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: errorMessage
};
}
} else if (response.status === 401 || response.status === 403) {
logger.error(`[SAP] Authentication failed during budget blocking (Status: ${response.status}) - check SAP credentials`);
logger.error(`[SAP] Response data:`, response.data);
logger.error(`[SAP] Response headers:`, response.headers);
// Check if it's actually a CSRF error disguised as auth error
const responseText = JSON.stringify(response.data || {});
if (responseText.includes('CSRF') || responseText.includes('csrf') || responseText.includes('token')) {
logger.error('[SAP] This might be a CSRF token validation error, not authentication');
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: 'SAP CSRF token validation failed - token may have expired or be invalid'
};
}
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: `SAP authentication failed (${response.status}) - check SAP credentials`
};
} else {
// Handle 400 Bad Request - usually means invalid request format
let errorMessage = `SAP API returned status ${response.status}`;
if (response.status === 400) {
errorMessage = 'SAP API returned 400 Bad Request - check request payload format';
// Try to extract error message from response
if (response.data) {
try {
const errorData = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
// Check for common SAP error fields
if (errorData.error) {
errorMessage = errorData.error.message?.value || errorData.error.message || errorMessage;
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.Message) {
errorMessage = errorData.Message;
} else if (errorData.d?.error) {
errorMessage = errorData.d.error.message?.value || errorData.d.error.message || errorMessage;
}
logger.error(`[SAP] SAP Error Details:`, JSON.stringify(errorData, null, 2));
} catch (e) {
logger.error(`[SAP] Error parsing response data:`, response.data);
}
}
}
logger.error(`[SAP] Unexpected response status during budget blocking: ${response.status}`);
logger.error(`[SAP] Response data:`, response.data);
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: errorMessage
};
}
} catch (error) { } catch (error) {
logger.error('[SAP] Error blocking budget:', error); const axiosError = error as AxiosError;
if (axiosError.response) {
// SAP returned an error response
logger.error(`[SAP] Error blocking budget for IO ${ioNumber}:`, {
status: axiosError.response.status,
statusText: axiosError.response.statusText,
data: axiosError.response.data
});
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: `SAP API error: ${axiosError.response.status} ${axiosError.response.statusText}`
};
} else if (axiosError.request) {
// Request was made but no response received
logger.error(`[SAP] No response from SAP API during budget blocking for IO ${ioNumber}:`, axiosError.message);
return {
success: false,
blockedAmount: 0,
remainingBalance: 0,
error: `SAP API connection failed: ${axiosError.message}`
};
} else {
// Error setting up request
logger.error('[SAP] Error setting up budget blocking request:', error);
return { return {
success: false, success: false,
blockedAmount: 0, blockedAmount: 0,
@ -169,6 +909,7 @@ export class SAPIntegrationService {
}; };
} }
} }
}
/** /**
* Release blocked budget in SAP * Release blocked budget in SAP