Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/backend-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions src/app/api/commitments/[id]/settle/preview/route.ts
Original file line number Diff line number Diff line change
@@ -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);
});
18 changes: 13 additions & 5 deletions src/app/api/commitments/[id]/settle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
4 changes: 0 additions & 4 deletions src/lib/backend/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading