diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index c2f84f61..a2112e30 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -167,6 +167,49 @@ curl -X POST http://localhost:3000/api/commitments/abc123/settle \ --- +## `GET /api/commitments/[id]/settle/preview` + +Returns a preview of whether a commitment is eligible for settlement and an estimated settlement amount. Reuses the maturity and status checks from the settlement logic without mutating chain state. + +- **Path parameter**: `id` (string) — The commitment ID to preview settlement for. +- **Query parameters**: none. +- **Response**: + - `200 OK`: Settlement preview completed. Returns the eligibility status and estimated settlement value. + - `404 Not Found`: Commitment does not exist. + - `429 Too Many Requests`: Rate limit exceeded. + +### Example + +```bash +curl -X GET http://localhost:3000/api/commitments/abc123/settle/preview +``` + +```json +{ + "success": true, + "data": { + "eligible": true, + "reason": null, + "estimatedSettlement": "1000.50" + } +} +``` + +If the commitment is not eligible: + +```json +{ + "success": true, + "data": { + "eligible": false, + "reason": "Commitment has not matured yet and cannot be settled.", + "estimatedSettlement": "1000.50" + } +} +``` + +--- + ## `POST /api/commitments/[id]/fund` Funds an existing commitment that was previously created but not yet funded. The route validates ownership, enforces `CREATED` state, and submits the on-chain `fund_escrow` transaction. diff --git a/openapi.yaml b/openapi.yaml index dd50a4f2..85851519 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -359,6 +359,42 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/commitments/{id}/settle/preview: + get: + summary: Settlement Preview + description: Returns a preview of whether a commitment is eligible for settlement and an estimated settlement amount. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The commitment ID to preview settlement for. + responses: + '200': + description: Settlement preview result. + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessEnvelope' + - type: object + properties: + data: + type: object + properties: + eligible: + type: boolean + reason: + type: string + nullable: true + estimatedSettlement: + type: string + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /api/commitments/{id}/early-exit: post: summary: Early Exit diff --git a/package-lock.json b/package-lock.json index 58f2a9bb..0d7f3256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "eslint": "^8.0.0", "eslint-config-next": "^14.0.0", "happy-dom": "^20.7.0", + "ioredis": "^5.11.0", "node-fetch": "^3.3.2", "tsx": "^4.21.0", "typescript": "^5.0.0", @@ -720,6 +721,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3334,6 +3342,16 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3675,6 +3693,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5237,6 +5265,29 @@ "node": ">=12" } }, + "node_modules/ioredis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", + "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7064,6 +7115,29 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -7626,6 +7700,13 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "dev": true, + "license": "MIT" + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", diff --git a/src/app/api/commitments/[id]/settle/preview/route.ts b/src/app/api/commitments/[id]/settle/preview/route.ts new file mode 100644 index 00000000..4b964fcf --- /dev/null +++ b/src/app/api/commitments/[id]/settle/preview/route.ts @@ -0,0 +1,83 @@ +import { NextRequest } from 'next/server'; +import { ok } from '@/lib/backend/apiResponse'; +import { ApiError, BackendError, NotFoundError, TooManyRequestsError } from '@/lib/backend/errors'; +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; +import { getClientIp } from '@/lib/backend/getClientIp'; +import { getCommitmentFromChain } from '@/lib/backend/services/contracts'; + +/** + * GET /api/commitments/[id]/settle/preview + * + * Returns a preview of whether a commitment is eligible for settlement and an estimated settlement amount. + * Reuses the maturity and status checks from the settlement logic without mutating chain state. + */ +export const GET = withApiHandler(async (req: NextRequest, { params }, correlationId) => { + const ip = getClientIp(req); + if (!(await checkRateLimit(ip, 'api/commitments/settle/preview'))) { + throw new TooManyRequestsError( + 'Too many requests. Please try again later.', + undefined, + getRateLimitWindowSeconds('api/commitments/settle/preview'), + ); + } + + const commitmentId = params.id; + if (!commitmentId?.trim()) { + throw new NotFoundError('Commitment'); + } + + let commitment; + try { + commitment = await getCommitmentFromChain(commitmentId, { requestId: correlationId }); + } catch (error) { + if (error instanceof BackendError) { + throw new ApiError(error.message, error.code, error.status, error.details); + } + throw error; + } + + if (!commitment) { + throw new NotFoundError('Commitment', { commitmentId }); + } + + let eligible = true; + let reason: string | null = null; + + if (commitment.status === 'SETTLED') { + eligible = false; + reason = 'Commitment has already been settled.'; + } else if (commitment.status === 'VIOLATED') { + eligible = false; + reason = 'Commitment has been violated and cannot be settled.'; + } else if (commitment.status === 'EARLY_EXIT') { + eligible = false; + reason = 'Commitment has already been exited early.'; + } else if (commitment.status === 'CREATED') { + eligible = false; + reason = 'Commitment must be active to be settled.'; + } else if (commitment.status === 'DISPUTED') { + eligible = false; + reason = 'Commitment is currently in dispute and cannot be settled.'; + } else if (commitment.status === 'ACTIVE') { + if (commitment.expiresAt) { + const expiryTime = new Date(commitment.expiresAt).getTime(); + const now = Date.now(); + if (now < expiryTime) { + eligible = false; + reason = 'Commitment has not matured yet and cannot be settled.'; + } + } + } else { + eligible = false; + reason = 'Commitment is in an ineligible state for settlement.'; + } + + const responseData = { + eligible, + reason, + estimatedSettlement: commitment.currentValue, + }; + + return ok(responseData, undefined, 200, correlationId); +}); diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index c39965fa..9fd19879 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -101,11 +101,19 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat txHash: settlementResult.txHash, reference: settlementResult.reference, settledAt: new Date().toISOString(), - }, { requestId: correlationId }, - undefined, - 200, - correlationId, - ); + }; + + if (idempotencyKey) { + await idempotencyService.complete(idempotencyKey, responseData, 200); + } + + return ok(responseData, undefined, 200, correlationId); + } catch (error) { + if (idempotencyKey) { + await idempotencyService.fail(idempotencyKey); + } + throw error; + } }, { cors: COMMITMENT_SETTLE_CORS_POLICY }); const _405 = methodNotAllowed(['POST']); diff --git a/src/lib/backend/apiResponse.ts b/src/lib/backend/apiResponse.ts index b2507ea8..1e1cee3d 100644 --- a/src/lib/backend/apiResponse.ts +++ b/src/lib/backend/apiResponse.ts @@ -140,9 +140,5 @@ export function fail( response.headers.set("x-request-id", correlationId); } - return NextResponse.json(body, { - status, - headers: Object.keys(headers).length > 0 ? headers : undefined, - }); return response; } diff --git a/tests/api/settle-preview.test.ts b/tests/api/settle-preview.test.ts new file mode 100644 index 00000000..d894a75e --- /dev/null +++ b/tests/api/settle-preview.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createMockRequest, + parseResponse, + createMockRouteContext, +} from './helpers'; + +// Mock dependencies BEFORE importing the route +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), + getRateLimitWindowSeconds: vi.fn(() => 60), +})); + +vi.mock('@/lib/backend/services/contracts', () => ({ + getCommitmentFromChain: vi.fn(), +})); + +// NOW import the route and dependencies +import { GET as getHandler } from '@/app/api/commitments/[id]/settle/preview/route'; +import type { NextRequest } from 'next/server'; +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { getCommitmentFromChain } from '@/lib/backend/services/contracts'; +import { BackendError } from '@/lib/backend/errors'; + +const mockedCheckRateLimit = vi.mocked(checkRateLimit); +const mockedGetCommitmentFromChain = vi.mocked(getCommitmentFromChain); + +// Cast handler to correct signature +const GET = getHandler as ( + req: NextRequest, + context: { params: Record }, +) => Promise; + +const COMMITMENT_ID = 'cm_123456'; + +describe('GET /api/commitments/[id]/settle/preview', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCheckRateLimit.mockResolvedValue(true); + }); + + it('returns 200 and eligible true for expired active commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + ownerAddress: 'GOWNER', + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 100, + currentValue: '1050', + feeEarned: '50', + violationCount: 0, + expiresAt: new Date(Date.now() - 3600 * 1000).toISOString(), // 1 hour ago + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data).toEqual({ + eligible: true, + reason: null, + estimatedSettlement: '1050', + }); + }); + + it('returns 200 and eligible false for non-expired active commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + ownerAddress: 'GOWNER', + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 100, + currentValue: '1050', + feeEarned: '50', + violationCount: 0, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour in future + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment has not matured yet and cannot be settled.', + estimatedSettlement: '1050', + }); + }); + + it('returns 200 and eligible false for already settled commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + ownerAddress: 'GOWNER', + asset: 'USDC', + amount: '1000', + status: 'SETTLED', + complianceScore: 100, + currentValue: '1050', + feeEarned: '50', + violationCount: 0, + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment has already been settled.', + estimatedSettlement: '1050', + }); + }); + + it('returns 200 and eligible false for violated commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'VIOLATED', + currentValue: '1000', + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment has been violated and cannot be settled.', + estimatedSettlement: '1000', + }); + }); + + it('returns 200 and eligible false for early exited commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'EARLY_EXIT', + currentValue: '1000', + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment has already been exited early.', + estimatedSettlement: '1000', + }); + }); + + it('returns 200 and eligible false for created commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'CREATED', + currentValue: '1000', + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment must be active to be settled.', + estimatedSettlement: '1000', + }); + }); + + it('returns 200 and eligible false for disputed commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'DISPUTED', + currentValue: '1000', + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment is currently in dispute and cannot be settled.', + estimatedSettlement: '1000', + }); + }); + + it('returns 404 if commitment is missing or not found', async () => { + mockedGetCommitmentFromChain.mockResolvedValue(null as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(404); + expect(result.data.success).toBe(false); + expect(result.data.error.code).toBe('NOT_FOUND'); + }); + + it('returns 404 if getCommitmentFromChain throws a 404 BackendError', async () => { + mockedGetCommitmentFromChain.mockRejectedValue( + new BackendError({ + code: 'NOT_FOUND', + message: 'Commitment not found', + status: 404, + }), + ); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(404); + expect(result.data.error.code).toBe('NOT_FOUND'); + }); + + it('returns 429 when rate limited', async () => { + mockedCheckRateLimit.mockResolvedValue(false); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(429); + expect(result.data.success).toBe(false); + expect(result.data.error.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('returns 404 if commitment ID is empty', async () => { + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/ /settle/preview`), + createMockRouteContext({ id: ' ' }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(404); + expect(result.data.error.code).toBe('NOT_FOUND'); + }); + + it('returns 500 if getCommitmentFromChain throws a generic Error', async () => { + mockedGetCommitmentFromChain.mockRejectedValue(new Error('Generic database failure')); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(500); + expect(result.data.success).toBe(false); + expect(result.data.error.code).toBe('INTERNAL_ERROR'); + }); + + it('returns eligible false for commitment with unknown status', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'UNKNOWN_STATUS_ABC', + currentValue: '1000', + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: false, + reason: 'Commitment is in an ineligible state for settlement.', + estimatedSettlement: '1000', + }); + }); + + it('returns eligible true for active commitment without expiresAt', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + status: 'ACTIVE', + currentValue: '1000', + expiresAt: undefined, + } as any); + + const response = await GET( + createMockRequest(`http://localhost:3000/api/commitments/${COMMITMENT_ID}/settle/preview`), + createMockRouteContext({ id: COMMITMENT_ID }), + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.data).toEqual({ + eligible: true, + reason: null, + estimatedSettlement: '1000', + }); + }); +});