From 4de9cddb6b2054dd5d94ead5e31c95777f075dd7 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 16 Dec 2025 19:50:16 +0530 Subject: [PATCH] sap implementsion for internal order and budget block --- docs/SAP_INTEGRATION_TESTING.md | 214 ++++++ env.example | 15 + package-lock.json | 57 +- package.json | 1 + src/controllers/dealerClaim.controller.ts | 29 +- src/services/approval.service.ts | 51 +- src/services/dealerClaim.service.ts | 124 ++- src/services/notification.service.ts | 93 ++- src/services/sapIntegration.service.ts | 897 ++++++++++++++++++++-- 9 files changed, 1313 insertions(+), 168 deletions(-) create mode 100644 docs/SAP_INTEGRATION_TESTING.md diff --git a/docs/SAP_INTEGRATION_TESTING.md b/docs/SAP_INTEGRATION_TESTING.md new file mode 100644 index 0000000..860afd9 --- /dev/null +++ b/docs/SAP_INTEGRATION_TESTING.md @@ -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 +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 +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: ` + +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 ` + +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 + diff --git a/env.example b/env.example index 06fea52..364789a 100644 --- a/env.example +++ b/env.example @@ -84,3 +84,18 @@ VAPID_CONTACT=mailto:you@example.com 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) +# 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 + diff --git a/package-lock.json b/package-lock.json index e94c366..5a7731b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^7.5.0", + "fast-xml-parser": "^5.3.3", "helmet": "^8.0.0", "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", @@ -793,18 +794,6 @@ "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": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", @@ -1651,6 +1640,36 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz", @@ -6346,9 +6365,9 @@ "license": "MIT" }, "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==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz", + "integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==", "funding": [ { "type": "github", @@ -6357,7 +6376,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -10791,9 +10810,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 9001838..959d65a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^7.5.0", + "fast-xml-parser": "^5.3.3", "helmet": "^8.0.0", "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index 569a51b..04367d3 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -5,6 +5,7 @@ import { ResponseHandler } from '../utils/responseHandler'; import logger from '../utils/logger'; import { gcsStorageService } from '../services/gcsStorage.service'; import { Document } from '../models/Document'; +import { InternalOrder } from '../models/InternalOrder'; import { constants } from '../config/constants'; import { sapIntegrationService } from '../services/sapIntegration.service'; import fs from 'fs'; @@ -653,6 +654,8 @@ export class DealerClaimController { 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( requestId, { @@ -660,23 +663,43 @@ export class DealerClaimController { ioRemark: ioRemark || '', availableBalance: parseFloat(availableBalance), blockedAmount: blockAmount, - remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - blockAmount, + // remainingBalance will be calculated by the service from SAP's response }, 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'); } else if (ioNumber && ioRemark !== undefined) { // Save IO details (ioNumber, ioRemark) even without blocking amount // 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( requestId, { ioNumber, 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, - remainingBalance: remainingBalance ? parseFloat(remainingBalance) : 0, + // Don't pass remainingBalance - preserve existing value from previous blocking }, userId ); diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 24e4fb3..66cc6c1 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -480,16 +480,30 @@ export class ApprovalService { }); // 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') { - // 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 approverName = (nextLevel as any).approverName || ''; const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com' || 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) - 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}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, @@ -500,6 +514,8 @@ export class ApprovalService { actionRequired: true }); + logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`); + // Log assignment activity for the next approver activityService.log({ requestId: level.requestId, @@ -507,7 +523,7 @@ export class ApprovalService { user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), 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, userAgent: requestMetadata?.userAgent || undefined }); @@ -517,6 +533,31 @@ export class ApprovalService { } else { 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 { // No next level found but not final approver - this shouldn't happen diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 71350b5..a7c43a3 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -1273,15 +1273,24 @@ export class DealerClaimService { if (!created) { // 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({ ioNumber: ioData.ioNumber, ioRemark: ioData.ioRemark || '', - // Only update balance fields if provided - ...(ioData.availableBalance !== undefined && { ioAvailableBalance: ioData.availableBalance }), - ...(ioData.remainingBalance !== undefined && { ioRemainingBalance: ioData.remainingBalance }), + // Don't update balance fields for existing records - preserve values from previous blocking + // Only update organizedBy and organizedAt organizedBy: organizedBy || internalOrder.organizedBy, 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}`, { @@ -1318,41 +1327,101 @@ export class DealerClaimService { } const finalBlockedAmount = blockResult.blockedAmount; - const remainingBalance = blockResult.remainingBalance; 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) const organizedBy = organizedByUserId || null; // Create or update Internal Order record (only when blocking) + const ioRecordData = { + requestId, + ioNumber: ioData.ioNumber, + ioRemark: ioData.ioRemark || '', + ioAvailableBalance: availableBalance, + ioBlockedAmount: finalBlockedAmount, + ioRemainingBalance: finalRemainingBalance, + organizedBy: organizedBy || undefined, + organizedAt: new Date(), + 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: { - requestId, - ioNumber: ioData.ioNumber, - ioRemark: ioData.ioRemark || '', - ioAvailableBalance: availableBalance, - ioBlockedAmount: finalBlockedAmount, - ioRemainingBalance: remainingBalance, - organizedBy: organizedBy || undefined, - organizedAt: new Date(), - status: IOStatus.BLOCKED, - } + defaults: ioRecordData }); if (!created) { - // Update existing IO record - await internalOrder.update({ - ioNumber: ioData.ioNumber, - ioRemark: ioData.ioRemark || '', - ioAvailableBalance: availableBalance, - ioBlockedAmount: finalBlockedAmount, - ioRemainingBalance: remainingBalance, - // Update to current user who is blocking - organizedBy: organizedBy || internalOrder.organizedBy, - organizedAt: new Date(), - status: IOStatus.BLOCKED, + // Update existing IO record - explicitly update all fields including remainingBalance + logger.info(`[DealerClaimService] Updating existing IO record for request: ${requestId}`); + logger.info(`[DealerClaimService] Update data:`, { + ioRemainingBalance: ioRecordData.ioRemainingBalance, + ioBlockedAmount: ioRecordData.ioBlockedAmount, + ioAvailableBalance: ioRecordData.ioAvailableBalance }); + + // 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 @@ -1367,7 +1436,8 @@ export class DealerClaimService { logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, { ioNumber: ioData.ioNumber, blockedAmount: finalBlockedAmount, - remainingBalance + availableBalance, + remainingBalance: finalRemainingBalance }); } catch (error) { logger.error('[DealerClaimService] Error blocking IO:', error); diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 89c98be..806d8d1 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -143,46 +143,61 @@ class NotificationService { // 1. Check admin + user preferences for in-app notifications 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) { - const notification = await Notification.create({ - userId, - requestId: payload.requestId, - notificationType: payload.type || 'general', - title: payload.title, - message: payload.body, - isRead: false, - priority: payload.priority || 'MEDIUM', - actionUrl: payload.url, - actionRequired: payload.actionRequired || false, - metadata: { - requestNumber: payload.requestNumber, - ...payload.metadata - }, - sentVia: ['IN_APP'], - emailSent: false, - smsSent: false, - pushSent: false - } as any); - - sentVia.push('IN_APP'); - logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`); - - // 2. Emit real-time socket event for immediate delivery try { - const { emitToUser } = require('../realtime/socket'); - if (emitToUser) { - emitToUser(userId, 'notification:new', { - notification: notification.toJSON(), - ...payload - }); - logger.info(`[Notification] Emitted socket event to user ${userId}`); + notification = await Notification.create({ + userId, + requestId: payload.requestId, + notificationType: payload.type || 'general', + title: payload.title, + message: payload.body, + isRead: false, + priority: payload.priority || 'MEDIUM', + actionUrl: payload.url, + actionRequired: payload.actionRequired || false, + metadata: { + requestNumber: payload.requestNumber, + ...payload.metadata + }, + sentVia: ['IN_APP'], + emailSent: false, + smsSent: false, + pushSent: false + } as any); + + sentVia.push('IN_APP'); + 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 + try { + const { emitToUser } = require('../realtime/socket'); + if (emitToUser) { + emitToUser(userId, 'notification:new', { + notification: notification.toJSON(), + ...payload + }); + logger.info(`[Notification] ✅ Emitted socket event to user ${userId}`); + } else { + logger.warn(`[Notification] emitToUser function not available`); + } + } catch (socketError) { + logger.warn(`[Notification] Socket emit failed (not critical):`, socketError); } - } catch (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) - if (user.pushNotificationsEnabled && canSendInApp) { + if (user.pushNotificationsEnabled && canSendInApp && notification) { let subs = this.userIdToSubscriptions.get(userId) || []; // Load from DB if memory empty if (subs.length === 0) { @@ -290,17 +305,23 @@ class NotificationService { } // 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' ? 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 - console.log(`[DEBUG Email] Should send email: ${shouldSend}`); + console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`); if (!shouldSend) { 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; } + + logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`); // Trigger email based on notification type // Email service will fetch additional data as needed diff --git a/src/services/sapIntegration.service.ts b/src/services/sapIntegration.service.ts index c4c539b..d9a85ed 100644 --- a/src/services/sapIntegration.service.ts +++ b/src/services/sapIntegration.service.ts @@ -1,30 +1,237 @@ +import axios, { AxiosError } from 'axios'; +import https from 'https'; +import { XMLParser } from 'fast-xml-parser'; import logger from '../utils/logger'; /** * SAP Integration Service * Handles integration with SAP for IO validation and budget blocking - * - * NOTE: This is a placeholder/stub implementation. - * Replace with actual SAP API integration based on your SAP system. + * Integrates with SAP OData service via Zscaler URL */ export class SAPIntegrationService { private sapBaseUrl: string; - private sapApiKey?: string; private sapUsername?: 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() { this.sapBaseUrl = process.env.SAP_BASE_URL || ''; - this.sapApiKey = process.env.SAP_API_KEY; this.sapUsername = process.env.SAP_USERNAME; 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 */ 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,46 +264,201 @@ export class SAPIntegrationService { }; } - // TODO: Implement actual SAP API call - // Example: - // const response = await axios.get(`${this.sapBaseUrl}/api/io/${ioNumber}`, { - // headers: { - // 'Authorization': `Bearer ${this.sapApiKey}`, - // 'Content-Type': 'application/json' - // } - // }); - // - // return { - // isValid: response.data.valid, - // ioNumber: response.data.io_number, - // availableBalance: response.data.available_balance, - // blockedAmount: response.data.blocked_amount, - // remainingBalance: response.data.remaining_balance, - // currency: response.data.currency, - // description: response.data.description - // }; + const sapClient = this.createSapClient(); + + // SAP OData endpoint: GetSenderDataSet with filter on IONumber + // Service name is configurable via SAP_SERVICE_NAME env variable + const endpoint = this.buildODataEndpoint('GetSenderDataSet'); + + // Build OData query parameters matching the working URL format + // $filter: Filter by IO number + // $select: Select specific fields (Sender, ResponseDate, GetIODetailsSet01) + // $expand: Expand the nested GetIODetailsSet01 entity set to get IO details + // $format: Explicitly request JSON format + const queryParams = new URLSearchParams({ + '$filter': `IONumber eq '${ioNumber}'`, + '$select': 'Sender,ResponseDate,GetIODetailsSet01', + '$expand': 'GetIODetailsSet01', + '$format': 'json' + }); + + 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); - logger.warn('[SAP] SAP API integration not implemented, returning mock data'); - return { - isValid: true, - ioNumber, - availableBalance: 1000000, - blockedAmount: 0, - remainingBalance: 1000000, - currency: 'INR', - description: 'Mock IO Data (SAP API not implemented)' - }; + 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}`); + + return { + isValid: true, + ioNumber: validatedIONumber, + availableBalance, + blockedAmount, + remainingBalance, + currency, + 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) { - logger.error('[SAP] Error validating IO number:', error); - return { - isValid: false, - ioNumber, - availableBalance: 0, - blockedAmount: 0, - remainingBalance: 0, - currency: 'INR', - error: error instanceof Error ? error.message : 'Unknown 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 { + isValid: false, + ioNumber, + availableBalance: 0, + blockedAmount: 0, + remainingBalance: 0, + currency: 'INR', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } } } @@ -131,42 +493,421 @@ export class SAPIntegrationService { }; } - // TODO: Implement actual SAP API call to block budget - // 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 - // }; + const sapClient = this.createSapClient(); + + // 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 = { + '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); - logger.warn('[SAP] SAP budget blocking not implemented, simulating block'); - return { - success: true, - blockId: `BLOCK-${Date.now()}`, - blockedAmount: amount, - remainingBalance: 1000000 - amount, - error: 'SAP API not implemented - budget blocking simulated' - }; + // 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 { + success: true, + blockId: blockId || `BLOCK-${Date.now()}`, + blockedAmount, + remainingBalance + }; + } 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) { - logger.error('[SAP] Error blocking budget:', error); - return { - success: false, - blockedAmount: 0, - remainingBalance: 0, - error: error instanceof Error ? error.message : 'Unknown 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 { + success: false, + blockedAmount: 0, + remainingBalance: 0, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } } }