diff --git a/backend/src/cron/__tests__/loanCheckCron.test.ts b/backend/src/cron/__tests__/loanCheckCron.test.ts new file mode 100644 index 00000000..645322a7 --- /dev/null +++ b/backend/src/cron/__tests__/loanCheckCron.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +type CronCallback = () => Promise; +let capturedCallback: CronCallback | null = null; +const mockScheduleStop = jest.fn(); + +const mockQuery = jest.fn<() => Promise<{ rows: any[] }>>(); +const mockCreateNotification = jest.fn<() => Promise>(); +const mockRecordSuccess = jest.fn(); +const mockRecordFailure = jest.fn(); + +jest.unstable_mockModule('node-cron', () => ({ + default: { + schedule: (_pattern: string, cb: CronCallback) => { + capturedCallback = cb; + return { stop: mockScheduleStop }; + }, + }, +})); + +jest.unstable_mockModule('../../db/connection.js', () => ({ + query: mockQuery, +})); + +jest.unstable_mockModule('../../services/notificationService.js', () => ({ + notificationService: { + createNotification: mockCreateNotification, + }, +})); + +jest.unstable_mockModule('../../services/jobMetricsService.js', () => ({ + jobMetricsService: { + recordSuccess: mockRecordSuccess, + recordFailure: mockRecordFailure, + }, +})); + +const { startLoanDueCheckCron, stopLoanDueCheckCron } = await import('../loanCheckCron.js'); + +describe('loanDueCheckCron (#2028)', () => { + beforeEach(() => { + jest.clearAllMocks(); + stopLoanDueCheckCron(); + capturedCallback = null; + }); + + it('queries for loans due soon with exclusions and formats amount in non-stroop units', async () => { + startLoanDueCheckCron(); + expect(capturedCallback).toBeDefined(); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + loan_id: 42, + address: 'GBORROWER123', + amount: '50000000', // 50 million stroops = 5 units + }, + ], + }); + mockCreateNotification.mockResolvedValueOnce(undefined); + + await capturedCallback!(); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const querySql = (mockQuery.mock.calls[0] as unknown[])[0] as string; + + // Must exclude defaulted / liquidated loans + expect(querySql).toContain('LoanDefaulted'); + expect(querySql).toContain('LoanLiquidated'); + expect(querySql).toContain('CollateralLiquidated'); + + // Must deduplicate notifications within 24 hours + expect(querySql).toContain("n.type = 'repayment_due'"); + expect(querySql).toContain("INTERVAL '24 hours'"); + + // Notification must format stroops as loan amount (divided by 10_000_000) + expect(mockCreateNotification).toHaveBeenCalledWith({ + userId: 'GBORROWER123', + type: 'repayment_due', + title: 'Repayment Due Soon', + message: 'Your repayment for loan #42 of 5 is due.', + loanId: 42, + }); + expect(mockRecordSuccess).toHaveBeenCalledWith('loanDueCheckCron', expect.any(Number)); + }); +}); diff --git a/backend/src/cron/loanCheckCron.ts b/backend/src/cron/loanCheckCron.ts index add2e582..65c11d2a 100644 --- a/backend/src/cron/loanCheckCron.ts +++ b/backend/src/cron/loanCheckCron.ts @@ -39,15 +39,24 @@ export function startLoanDueCheckCron() { SELECT 1 FROM contract_events re WHERE re.loan_id = le.loan_id AND re.event_type = 'LoanRepaid' ) + AND NOT EXISTS ( + SELECT 1 FROM contract_events de + WHERE de.loan_id = le.loan_id AND de.event_type IN ('LoanDefaulted', 'LoanLiquidated', 'CollateralLiquidated') + ) + AND NOT EXISTS ( + SELECT 1 FROM notifications n + WHERE n.loan_id = le.loan_id AND n.type = 'repayment_due' AND n.created_at > NOW() - INTERVAL '24 hours' + ) AND le.ledger_closed_at < NOW() - INTERVAL '30 days' -- Simplified due logic `); for (const loan of result.rows) { + const formattedAmount = Number(loan.amount) / 10_000_000; await notificationService.createNotification({ userId: loan.address, type: 'repayment_due', title: 'Repayment Due Soon', - message: `Your repayment for loan #${loan.loan_id} of ${loan.amount} is due.`, + message: `Your repayment for loan #${loan.loan_id} of ${formattedAmount} is due.`, loanId: loan.loan_id, }); } diff --git a/backend/src/services/__tests__/rateLimitService.test.ts b/backend/src/services/__tests__/rateLimitService.test.ts index 424be723..df05d91a 100644 --- a/backend/src/services/__tests__/rateLimitService.test.ts +++ b/backend/src/services/__tests__/rateLimitService.test.ts @@ -56,7 +56,7 @@ describe('rateLimitService', () => { expect(script).toContain('TTL'); }); - it('blocks requests once the atomic counter reaches or exceeds the limit', async () => { + it('allows the Nth request when the atomic counter reaches the limit', async () => { mockEval.mockResolvedValueOnce([5, 60]); const result = await rateLimitService.checkRateLimit('user123', { @@ -64,12 +64,25 @@ describe('rateLimitService', () => { windowSeconds: 60, }); - expect(result.allowed).toBe(false); + expect(result.allowed).toBe(true); expect(result.remaining).toBe(0); expect(result.currentCount).toBe(5); }); - it('admits requests strictly below maxRequests under concurrent requests', async () => { + it('blocks requests once the atomic counter exceeds the limit', async () => { + mockEval.mockResolvedValueOnce([6, 60]); + + const result = await rateLimitService.checkRateLimit('user123', { + maxRequests: 5, + windowSeconds: 60, + }); + + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.currentCount).toBe(6); + }); + + it('admits requests up to maxRequests under concurrent requests', async () => { let counter = 0; mockEval.mockImplementation(async () => { counter += 1; @@ -85,8 +98,8 @@ describe('rateLimitService', () => { ), ); - expect(results.filter((result) => result.allowed)).toHaveLength(4); - expect(results.filter((result) => !result.allowed)).toHaveLength(6); + expect(results.filter((result) => result.allowed)).toHaveLength(5); + expect(results.filter((result) => !result.allowed)).toHaveLength(5); expect(mockEval).toHaveBeenCalledTimes(10); }); diff --git a/backend/src/services/rateLimitService.ts b/backend/src/services/rateLimitService.ts index b275bd51..a1c03294 100644 --- a/backend/src/services/rateLimitService.ts +++ b/backend/src/services/rateLimitService.ts @@ -85,7 +85,7 @@ return {currentCount, ttl} const resetTime = new Date( Date.now() + (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000, ); - const allowed = currentCount < config.maxRequests; + const allowed = currentCount <= config.maxRequests; const remaining = Math.max(0, config.maxRequests - currentCount); return { diff --git a/docs/webhooks.md b/docs/webhooks.md index 2cfdbecb..bf15b6d2 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -20,29 +20,31 @@ subscribe, receive, and verify webhook deliveries. ## Creating a Subscription -**Endpoint:** `POST /api/webhooks/subscriptions` +**Endpoint:** `POST /api/indexer/webhooks` *(also available at `POST /api/v1/indexer/webhooks` and `POST /api/admin/webhooks`)* **Headers:** ``` Content-Type: application/json -Authorization: Bearer +x-api-key: ``` +*(Requires `admin:webhooks` API key scope)* + **Request body:** ```json { - "url": "https://your-service.com/webhooks/remitlend", - "events": ["LoanApproved", "LoanRepaid", "LoanDefaulted"], - "description": "My loan tracking service (optional)" + "callbackUrl": "https://your-service.com/webhooks/remitlend", + "eventTypes": ["LoanApproved", "LoanRepaid", "LoanDefaulted"], + "secret": "your-hmac-secret-key" } ``` -| Field | Type | Description | -|-------------|----------|------------------------------------------------------| -| `url` | string | HTTPS endpoint that will receive POST requests | -| `events` | string[] | Array of [event types](#supported-event-types) | -| `description` | string | Optional human-readable label | +| Field | Type | Required | Description | +|---------------|----------|----------|----------------------------------------------------------------------------------| +| `callbackUrl` | string | Yes | HTTP or HTTPS endpoint that will receive POST deliveries (private IPs rejected) | +| `eventTypes` | string[] | Yes | Array of [event types](#supported-event-types) to subscribe to | +| `secret` | string | No | Optional secret key used to sign webhook payloads using HMAC-SHA256 | **Response (201):** @@ -50,11 +52,14 @@ Authorization: Bearer { "success": true, "data": { - "id": "sub_abc123", - "url": "https://your-service.com/webhooks/remitlend", - "events": ["LoanApproved", "LoanRepaid", "LoanDefaulted"], - "active": true, - "createdAt": "2026-05-28T12:00:00.000Z" + "subscription": { + "id": 1, + "callbackUrl": "https://your-service.com/webhooks/remitlend", + "eventTypes": ["LoanApproved", "LoanRepaid", "LoanDefaulted"], + "isActive": true, + "createdAt": "2026-05-28T12:00:00.000Z", + "updatedAt": "2026-05-28T12:00:00.000Z" + } } } ``` @@ -64,12 +69,13 @@ is required. ### Managing Subscriptions -| Method | Endpoint | Description | -|--------|---------------------------------------|------------------------| -| GET | `/api/webhooks/subscriptions` | List all subscriptions | -| GET | `/api/webhooks/subscriptions/:id` | Get a single subscription | -| PUT | `/api/webhooks/subscriptions/:id` | Update events / URL | -| DELETE | `/api/webhooks/subscriptions/:id` | Delete a subscription | +| Method | Endpoint | Description | +|--------|-----------------------------------------|-------------------------------------------------| +| GET | `/api/indexer/webhooks` | List all webhook subscriptions | +| POST | `/api/indexer/webhooks` | Register a new webhook subscription | +| DELETE | `/api/indexer/webhooks/:subscriptionId` | Delete a webhook subscription | +| GET | `/api/admin/webhooks/:id/deliveries` | View delivery history for a subscription | +| GET | `/api/admin/webhooks/retry-status` | View failed webhooks and retry queue status | --- @@ -164,14 +170,23 @@ current event names listed above where an equivalent exists. ## Payload Examples -Every delivery is a JSON POST with the following envelope: +Every delivery is a JSON POST with a flat structure containing event metadata and indexed contract attributes: ```json { - "event": "", - "id": "", - "timestamp": "2026-05-28T12:00:00.000Z", - "data": { } + "eventId": "", + "eventType": "", + "loanId": 42, + "address": "", + "amount": "", + "interestRateBps": 500, + "termLedgers": 17280, + "ledger": 12345, + "ledgerClosedAt": "2026-05-28T12:00:00.000Z", + "txHash": "", + "contractId": "", + "topics": ["", ""], + "value": "" } ``` @@ -179,15 +194,19 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "LoanApproved", - "id": "evt_loan_42", - "timestamp": "2026-05-28T12:00:00.000Z", - "data": { - "loanId": 42, - "borrower": "GABCDEF...", - "amount": "5000", - "termMonths": 12 - } + "eventId": "12345:0", + "eventType": "LoanApproved", + "loanId": 42, + "address": "GABCDEF...", + "amount": "5000000000", + "interestRateBps": 500, + "termLedgers": 17280, + "ledger": 12345, + "ledgerClosedAt": "2026-05-28T12:00:00.000Z", + "txHash": "a1b2c3d4...", + "contractId": "CA...", + "topics": ["LoanApproved", "42"], + "value": "..." } ``` @@ -195,15 +214,17 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "LoanRepaid", - "id": "evt_repay_99", - "timestamp": "2026-05-28T12:05:00.000Z", - "data": { - "loanId": 42, - "borrower": "GABCDEF...", - "amount": "450", - "txHash": "a1b2c3d4..." - } + "eventId": "12346:1", + "eventType": "LoanRepaid", + "loanId": 42, + "address": "GABCDEF...", + "amount": "450000000", + "ledger": 12346, + "ledgerClosedAt": "2026-05-28T12:05:00.000Z", + "txHash": "b2c3d4e5...", + "contractId": "CA...", + "topics": ["LoanRepaid", "42"], + "value": "..." } ``` @@ -211,14 +232,17 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "LoanDefaulted", - "id": "evt_default_7", - "timestamp": "2026-05-28T12:10:00.000Z", - "data": { - "loanId": 42, - "borrower": "GABCDEF...", - "outstandingAmount": "3200" - } + "eventId": "12347:0", + "eventType": "LoanDefaulted", + "loanId": 42, + "address": "GABCDEF...", + "amount": "3200000000", + "ledger": 12347, + "ledgerClosedAt": "2026-05-28T12:10:00.000Z", + "txHash": "c3d4e5f6...", + "contractId": "CA...", + "topics": ["LoanDefaulted", "42"], + "value": "..." } ``` @@ -226,15 +250,17 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "CollateralLiquidated", - "id": "evt_liq_3", - "timestamp": "2026-05-28T12:15:00.000Z", - "data": { - "loanId": 42, - "borrower": "GABCDEF...", - "collateralSeized": true, - "borrowerRefund": "150" - } + "eventId": "12348:2", + "eventType": "CollateralLiquidated", + "loanId": 42, + "address": "GABCDEF...", + "amount": "1500000000", + "ledger": 12348, + "ledgerClosedAt": "2026-05-28T12:15:00.000Z", + "txHash": "d4e5f6a7...", + "contractId": "CA...", + "topics": ["CollateralLiquidated", "42"], + "value": "..." } ``` @@ -242,15 +268,17 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "LateFeeCharged", - "id": "evt_due_21", - "timestamp": "2026-05-28T12:00:00.000Z", - "data": { - "loanId": 42, - "borrower": "GABCDEF...", - "dueDate": "2026-06-01", - "amount": "450" - } + "eventId": "12349:0", + "eventType": "LateFeeCharged", + "loanId": 42, + "address": "GABCDEF...", + "amount": "45000000", + "ledger": 12349, + "ledgerClosedAt": "2026-05-28T12:20:00.000Z", + "txHash": "e5f6a7b8...", + "contractId": "CA...", + "topics": ["LateFeeCharged", "42"], + "value": "..." } ``` @@ -258,15 +286,16 @@ Every delivery is a JSON POST with the following envelope: ```json { - "event": "ScoreUpdated", - "id": "evt_score_15", - "timestamp": "2026-05-28T12:00:00.000Z", - "data": { - "userId": "GABCDEF...", - "previousScore": 650, - "newScore": 665, - "reason": "on-time repayment" - } + "eventId": "12350:1", + "eventType": "ScoreUpdated", + "address": "GABCDEF...", + "amount": "665", + "ledger": 12350, + "ledgerClosedAt": "2026-05-28T12:25:00.000Z", + "txHash": "f6a7b8c9...", + "contractId": "CA...", + "topics": ["ScoreUpdated", "GABCDEF..."], + "value": "..." } ``` @@ -274,20 +303,15 @@ Every delivery is a JSON POST with the following envelope: ## Delivery & Retry Semantics -1. **Delivery method:** HTTP POST to the subscriber URL. -2. **Timeout:** The endpoint must respond within **10 seconds**. -3. **Retry policy:** Deliveries are retried with exponential backoff: - - Retry 1: 10 seconds - - Retry 2: 30 seconds - - Retry 3: 1 minute - - Retry 4: 5 minutes - - Retry 5: 15 minutes - - Retry 6: 30 minutes - - Retry 7: 1 hour -4. **Max attempts:** 8 total (1 initial + 7 retries). -5. **Delivery window:** Events older than **24 hours** are not retried. -6. **Ordering:** Webhooks are delivered on a **best-effort** basis and may not - arrive in the exact order events occurred. +1. **Delivery method:** HTTP POST to the subscriber `callbackUrl` with `Content-Type: application/json`. +2. **Timeout:** The endpoint must respond within **30 seconds** (`WEBHOOK_REQUEST_TIMEOUT_MS`, default 30s). +3. **Retry policy:** Deliveries that fail (network error, timeout, or HTTP non-2xx status) are queued in `webhook_deliveries` and retried by the background retry processor with the following schedule: + - Initial attempt: immediate + - Retry 1: ~5 minutes after failure (`RETRY_DELAYS_MS[0]`) + - Retry 2: ~15 minutes after failure (`RETRY_DELAYS_MS[1]`) + - Retry 3: ~45 minutes after failure (`RETRY_DELAYS_MS[2]`) +4. **Max attempts:** 4 total attempts (`MAX_RETRY_ATTEMPTS = 4`). After attempt 4, the delivery is marked as permanently failed. +5. **Ordering:** Webhooks are delivered on a **best-effort** basis and may not arrive in the exact order events occurred. --- @@ -299,9 +323,8 @@ wasting resources. While deactivated: - No further events are sent to the subscriber. -- The subscription status changes to `deactivated`. -- You can **re-activate** the subscription by calling - `PUT /api/webhooks/subscriptions/:id` with `{ "active": true }`. +- The subscription status changes to `deactivated` (`isActive: false`). +- You can register a new subscription or contact the administrator to re-enable it. --- @@ -355,7 +378,7 @@ per-subscription secret returned when registering the webhook. See | 5xx | Server error — will be retried | | Timeout | Treated as a failure — will be retried | -- **Respond within 10 seconds.** Slow responses are counted as failures. +- **Respond within 30 seconds.** Slow responses exceeding the timeout are counted as failures. - Returning any 2xx status (200, 201, 202, 204) acknowledges delivery. --- diff --git a/frontend/src/app/utils/transactionErrors.ts b/frontend/src/app/utils/transactionErrors.ts index 947cb5fd..1016e566 100644 --- a/frontend/src/app/utils/transactionErrors.ts +++ b/frontend/src/app/utils/transactionErrors.ts @@ -56,6 +56,7 @@ export const ERROR_CODE_MESSAGES: Record = { DATABASE_ERROR: "Database error occurred", EXTERNAL_SERVICE_ERROR: "External service error occurred", BLOCKCHAIN_ERROR: "Blockchain operation failed", + SERVICE_UNAVAILABLE: "The service is temporarily unavailable. Please try again later", BORROWER_MISMATCH: "Borrower wallet mismatch", INSUFFICIENT_BALANCE: "Insufficient account balance", LOAN_ALREADY_REPAID: "Loan has already been repaid", diff --git a/scripts/check-error-code-mappings.mjs b/scripts/check-error-code-mappings.mjs index a71e0819..78940480 100644 --- a/scripts/check-error-code-mappings.mjs +++ b/scripts/check-error-code-mappings.mjs @@ -27,7 +27,7 @@ if (!enumMatch) { const enumBody = enumMatch[1]; const errorCodeKeys = []; -const keyRegex = /^\s*([A_Z0-9_]+)\s*=/gm; +const keyRegex = /^\s*([A-Z0-9_]+)\s*=/gm; let match; while ((match = keyRegex.exec(enumBody)) !== null) { @@ -36,12 +36,32 @@ while ((match = keyRegex.exec(enumBody)) !== null) { console.log(`Found ${errorCodeKeys.length} backend ErrorCode definitions.`); -// Verify each ErrorCode exists in frontend ERROR_CODE_MESSAGES or transactionErrors mapping +// Extract ERROR_CODE_MESSAGES mapping object body from frontendContent +const messagesMatch = frontendContent.match( + /export\s+const\s+ERROR_CODE_MESSAGES\s*(?::\s*Record<[^>]+>)?\s*=\s*\{([\s\S]*?)\};/, +); + +if (!messagesMatch) { + console.error('::error::Could not parse ERROR_CODE_MESSAGES in frontend/src/app/utils/transactionErrors.ts'); + process.exit(1); +} + +const messagesBody = messagesMatch[1]; +const frontendCodeKeys = new Set(); +const frontendKeyRegex = /^\s*([A-Z0-9_]+)\s*:/gm; +let fMatch; + +while ((fMatch = frontendKeyRegex.exec(messagesBody)) !== null) { + frontendCodeKeys.add(fMatch[1]); +} + +console.log(`Found ${frontendCodeKeys.size} frontend ERROR_CODE_MESSAGES mappings.`); + +// Verify each ErrorCode exists specifically in frontend ERROR_CODE_MESSAGES const missingCodes = []; for (const code of errorCodeKeys) { - const codePattern = new RegExp(`\\b${code}\\b`); - if (!codePattern.test(frontendContent)) { + if (!frontendCodeKeys.has(code)) { missingCodes.push(code); } }