Skip to content

Commit d3a895e

Browse files
Merge pull request #74 from pugsley76/feat/escrow-lifecycle-service
feat: Escrow Lifecycle Service Layer
2 parents 6955955 + daa97b6 commit d3a895e

12 files changed

Lines changed: 2245 additions & 0 deletions

File tree

app/api/escrow/create/route.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* POST /api/escrow/create
3+
*
4+
* Deploy a new escrow contract for a project.
5+
* The authenticated user must be the client (project owner).
6+
*
7+
* Body:
8+
* projectId string (UUID)
9+
* freelancerId string (UUID)
10+
* freelancerWalletAddress string
11+
* totalAmount string e.g. "500.00"
12+
* currency string e.g. "USDC"
13+
* terms? string
14+
* milestones? Array<{ title, amount, description?, dueDate? }>
15+
*/
16+
17+
import { NextRequest, NextResponse } from 'next/server'
18+
import { withAuth } from '@/lib/auth/middleware'
19+
import { sql } from '@/lib/db'
20+
import {
21+
escrowService,
22+
EscrowError,
23+
escrowErrorToHttpStatus,
24+
EscrowAlreadyExistsError,
25+
} from '@/lib/escrow'
26+
27+
export const POST = withAuth(async (request: NextRequest, auth) => {
28+
let body: Record<string, unknown>
29+
try {
30+
body = await request.json()
31+
} catch {
32+
return NextResponse.json(
33+
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
34+
{ status: 400 }
35+
)
36+
}
37+
38+
// --- Resolve authenticated wallet to a DB user ---
39+
const users = await sql`
40+
SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1
41+
`
42+
if (users.length === 0) {
43+
return NextResponse.json(
44+
{ error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' },
45+
{ status: 401 }
46+
)
47+
}
48+
const clientId = users[0].id as string
49+
50+
try {
51+
const result = await escrowService.createEscrow({
52+
projectId: body.projectId as string,
53+
clientId,
54+
freelancerId: body.freelancerId as string,
55+
clientWalletAddress: auth.walletAddress,
56+
freelancerWalletAddress: body.freelancerWalletAddress as string,
57+
totalAmount: body.totalAmount as string,
58+
currency: (body.currency as string) ?? 'USDC',
59+
terms: body.terms as string | undefined,
60+
milestones: body.milestones as Array<{
61+
title: string
62+
amount: string
63+
description?: string
64+
dueDate?: string
65+
}> | undefined,
66+
})
67+
68+
return NextResponse.json(
69+
{
70+
contractId: result.contract.id,
71+
projectId: result.contract.projectId,
72+
escrowAddress: result.contract.escrowAddress,
73+
deployTxHash: result.deployTxHash,
74+
status: result.contract.status,
75+
escrowStatus: result.contract.escrowStatus,
76+
totalAmount: result.contract.totalAmount,
77+
currency: result.contract.currency,
78+
milestonesCreated: result.milestonesCreated,
79+
createdAt: result.contract.createdAt,
80+
},
81+
{ status: 201 }
82+
)
83+
} catch (err) {
84+
if (err instanceof EscrowAlreadyExistsError) {
85+
return NextResponse.json(
86+
{
87+
error: err.message,
88+
code: err.code,
89+
existingContractId: err.existingContractId,
90+
},
91+
{ status: 409 }
92+
)
93+
}
94+
if (err instanceof EscrowError) {
95+
return NextResponse.json(
96+
{ error: err.message, code: err.code },
97+
{ status: escrowErrorToHttpStatus(err) }
98+
)
99+
}
100+
console.error('[escrow/create] Unexpected error:', err)
101+
return NextResponse.json(
102+
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
103+
{ status: 500 }
104+
)
105+
}
106+
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* POST /api/escrow/dispute/resolve
3+
*
4+
* Resolve an open dispute. Admin only.
5+
*
6+
* Body:
7+
* disputeId string (UUID)
8+
* outcome 'resolved_client' | 'resolved_freelancer' | 'resolved_split' | 'withdrawn'
9+
* resolutionNotes string
10+
* clientRefundAmount? string — required when outcome = 'resolved_split'
11+
* freelancerPayoutAmount? string — required when outcome = 'resolved_split'
12+
*/
13+
14+
import { NextRequest, NextResponse } from 'next/server'
15+
import { withAdmin, AdminContext } from '@/lib/auth/adminMiddleware'
16+
import {
17+
escrowService,
18+
EscrowError,
19+
EscrowDisputeNotFoundError,
20+
escrowErrorToHttpStatus,
21+
} from '@/lib/escrow'
22+
23+
const VALID_OUTCOMES = [
24+
'resolved_client',
25+
'resolved_freelancer',
26+
'resolved_split',
27+
'withdrawn',
28+
] as const
29+
30+
type Outcome = (typeof VALID_OUTCOMES)[number]
31+
32+
export async function POST(request: NextRequest) {
33+
return withAdmin(async (req: NextRequest, auth: AdminContext) => {
34+
let body: Record<string, unknown>
35+
try {
36+
body = await req.json()
37+
} catch {
38+
return Response.json(
39+
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
40+
{ status: 400 }
41+
)
42+
}
43+
44+
if (!body.outcome || !VALID_OUTCOMES.includes(body.outcome as Outcome)) {
45+
return Response.json(
46+
{
47+
error: `outcome must be one of: ${VALID_OUTCOMES.join(', ')}`,
48+
code: 'INVALID_OUTCOME',
49+
},
50+
{ status: 400 }
51+
)
52+
}
53+
54+
try {
55+
const result = await escrowService.resolveDispute({
56+
disputeId: body.disputeId as string,
57+
resolverUserId: auth.userId,
58+
outcome: body.outcome as Outcome,
59+
resolutionNotes: body.resolutionNotes as string,
60+
clientRefundAmount: body.clientRefundAmount as string | undefined,
61+
freelancerPayoutAmount: body.freelancerPayoutAmount as string | undefined,
62+
})
63+
64+
return Response.json({
65+
disputeId: result.dispute.id,
66+
disputeStatus: result.dispute.status,
67+
resolutionNotes: result.dispute.resolutionNotes,
68+
resolvedAt: result.dispute.resolvedAt,
69+
clientRefundAmount: result.dispute.clientRefundAmount,
70+
freelancerPayoutAmount: result.dispute.freelancerPayoutAmount,
71+
contractId: result.contract.id,
72+
contractStatus: result.contract.status,
73+
escrowStatus: result.contract.escrowStatus,
74+
})
75+
} catch (err) {
76+
if (err instanceof EscrowDisputeNotFoundError) {
77+
return Response.json(
78+
{ error: err.message, code: err.code },
79+
{ status: 404 }
80+
)
81+
}
82+
if (err instanceof EscrowError) {
83+
return Response.json(
84+
{ error: err.message, code: err.code },
85+
{ status: escrowErrorToHttpStatus(err) }
86+
)
87+
}
88+
console.error('[escrow/dispute/resolve] Unexpected error:', err)
89+
return Response.json(
90+
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
91+
{ status: 500 }
92+
)
93+
}
94+
})(request)
95+
}

app/api/escrow/dispute/route.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* POST /api/escrow/dispute
3+
*
4+
* Raise a dispute on an active contract.
5+
* Either the client or the freelancer can raise a dispute.
6+
*
7+
* Body:
8+
* contractId string (UUID)
9+
* milestoneId? string (UUID) — optional, scope dispute to a milestone
10+
* reason string
11+
* desiredOutcome? string
12+
* evidence? Array<{ type, url, label? }>
13+
* responseDeadline? string — ISO date string
14+
*/
15+
16+
import { NextRequest, NextResponse } from 'next/server'
17+
import { withAuth } from '@/lib/auth/middleware'
18+
import { sql } from '@/lib/db'
19+
import {
20+
escrowService,
21+
EscrowError,
22+
EscrowDisputeAlreadyActiveError,
23+
escrowErrorToHttpStatus,
24+
type DisputeRaisedBy,
25+
} from '@/lib/escrow'
26+
27+
export const POST = withAuth(async (request: NextRequest, auth) => {
28+
let body: Record<string, unknown>
29+
try {
30+
body = await request.json()
31+
} catch {
32+
return NextResponse.json(
33+
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
34+
{ status: 400 }
35+
)
36+
}
37+
38+
// --- Resolve authenticated wallet to a DB user and determine their role ---
39+
const users = await sql`
40+
SELECT id, role FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1
41+
`
42+
if (users.length === 0) {
43+
return NextResponse.json(
44+
{ error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' },
45+
{ status: 401 }
46+
)
47+
}
48+
const { id: userId, role } = users[0] as { id: string; role: string }
49+
50+
// Map DB role to dispute_raised_by enum
51+
const raisedBy: DisputeRaisedBy =
52+
role === 'admin' ? 'admin' : role === 'client' ? 'client' : 'freelancer'
53+
54+
try {
55+
const result = await escrowService.raiseDispute({
56+
contractId: body.contractId as string,
57+
milestoneId: body.milestoneId as string | undefined,
58+
raisedByUserId: userId,
59+
raisedBy,
60+
reason: body.reason as string,
61+
desiredOutcome: body.desiredOutcome as string | undefined,
62+
evidence: body.evidence as Array<{ type: string; url: string; label?: string }> | undefined,
63+
responseDeadline: body.responseDeadline as string | undefined,
64+
})
65+
66+
return NextResponse.json(
67+
{
68+
disputeId: result.dispute.id,
69+
contractId: result.contract.id,
70+
contractStatus: result.contract.status,
71+
disputeStatus: result.dispute.status,
72+
raisedBy: result.dispute.raisedBy,
73+
createdAt: result.dispute.createdAt,
74+
responseDeadline: result.dispute.responseDeadline,
75+
},
76+
{ status: 201 }
77+
)
78+
} catch (err) {
79+
if (err instanceof EscrowDisputeAlreadyActiveError) {
80+
return NextResponse.json(
81+
{
82+
error: err.message,
83+
code: err.code,
84+
existingDisputeId: err.existingDisputeId,
85+
},
86+
{ status: 409 }
87+
)
88+
}
89+
if (err instanceof EscrowError) {
90+
return NextResponse.json(
91+
{ error: err.message, code: err.code },
92+
{ status: escrowErrorToHttpStatus(err) }
93+
)
94+
}
95+
console.error('[escrow/dispute] Unexpected error:', err)
96+
return NextResponse.json(
97+
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
98+
{ status: 500 }
99+
)
100+
}
101+
})

app/api/escrow/fund/route.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* POST /api/escrow/fund
3+
*
4+
* Record that the client has funded the escrow contract on-chain.
5+
* Verifies the funding transaction before updating state.
6+
*
7+
* Body:
8+
* contractId string (UUID)
9+
* fundingTxHash string — on-chain transaction hash
10+
* amount string — amount funded, e.g. "500.00"
11+
*/
12+
13+
import { NextRequest, NextResponse } from 'next/server'
14+
import { withAuth } from '@/lib/auth/middleware'
15+
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
16+
17+
export const POST = withAuth(async (request: NextRequest, auth) => {
18+
let body: Record<string, unknown>
19+
try {
20+
body = await request.json()
21+
} catch {
22+
return NextResponse.json(
23+
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
24+
{ status: 400 }
25+
)
26+
}
27+
28+
try {
29+
const result = await escrowService.fundEscrow({
30+
contractId: body.contractId as string,
31+
callerWalletAddress: auth.walletAddress,
32+
fundingTxHash: body.fundingTxHash as string,
33+
amount: body.amount as string,
34+
})
35+
36+
return NextResponse.json({
37+
contractId: result.contract.id,
38+
escrowStatus: result.contract.escrowStatus,
39+
contractStatus: result.contract.status,
40+
fundedAt: result.fundedAt,
41+
fundingTxHash: result.contract.fundingTxHash,
42+
})
43+
} catch (err) {
44+
if (err instanceof EscrowError) {
45+
return NextResponse.json(
46+
{ error: err.message, code: err.code },
47+
{ status: escrowErrorToHttpStatus(err) }
48+
)
49+
}
50+
console.error('[escrow/fund] Unexpected error:', err)
51+
return NextResponse.json(
52+
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
53+
{ status: 500 }
54+
)
55+
}
56+
})

0 commit comments

Comments
 (0)