Skip to content
268 changes: 268 additions & 0 deletions __tests__/proposals/accept-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { ProposalAcceptanceService } from '@/lib/proposals/service'
import type {
AcceptProposalResult,
ContractRecord,
IProposalAcceptanceRepository,
JobRecord,
ProposalMilestoneBreakdown,
ProposalRecord,
} from '@/lib/proposals/types'
import {
ProposalAlreadyAcceptedError,
ContractAlreadyExistsError,
JobNotAcceptingError,
ProposalForbiddenError,
ProposalNotPendingError,
ProposalValidationError,
ProposalTransactionError,
} from '@/lib/proposals/errors'

function makeProposal(overrides: Partial<ProposalRecord> = {}): ProposalRecord {
return {
id: 10,
jobId: 1,
freelancerId: 200,
coverLetter: 'I can deliver this well',
proposedBudget: '100.00',
estimatedDuration: '2 weeks',
status: 'pending',
milestoneBreakdown: [
{ title: 'Design', amount: '40.00' },
{ title: 'Build', amount: '60.00' },
],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
}
}

function makeJob(overrides: Partial<JobRecord> = {}): JobRecord {
return {
id: 1,
clientId: 100,
freelancerId: null,
title: 'Build TaskChain feature',
status: 'open',
budget: '100.00',
currency: 'XLM',
escrowContractId: null,
escrowStatus: null,
...overrides,
}
}

class FakeRepo implements IProposalAcceptanceRepository {
proposal: ProposalRecord | null = makeProposal()
job: JobRecord | null = makeJob()
accepted: ProposalRecord | null = null
contract: ContractRecord | null = null
userType: string | null = 'client'
failCommit = false
lastCommitMilestones: ProposalMilestoneBreakdown[] | null = null

async getProposalById(id: number) {
return this.proposal && this.proposal.id === id ? this.proposal : null
}
async getJobById(id: number) {
return this.job && this.job.id === id ? this.job : null
}
async getAcceptedProposalForJob(_jobId: number) {
return this.accepted
}
async getContractByJobId(_jobId: number) {
return this.contract
}
async getUserType(_userId: number) {
return this.userType
}
async commitAcceptance(params: {
proposal: ProposalRecord
job: JobRecord
actorUserId: number
terms: string | null
milestones: ProposalMilestoneBreakdown[]
contractStatus: 'pending' | 'draft' | 'pending_funding'
}): Promise<AcceptProposalResult> {
if (this.failCommit) {
throw new ProposalTransactionError('simulated rollback')
}
this.lastCommitMilestones = params.milestones
const now = new Date().toISOString()
const contract: ContractRecord = {
id: 55,
jobId: params.job.id,
proposalId: params.proposal.id,
clientId: params.job.clientId,
freelancerId: params.proposal.freelancerId,
totalAmount: params.proposal.proposedBudget,
currency: params.job.currency,
terms: params.terms,
status: params.contractStatus,
createdAt: now,
updatedAt: now,
}
return {
proposal: { ...params.proposal, status: 'accepted', updatedAt: now },
job: {
...params.job,
freelancerId: params.proposal.freelancerId,
status: 'assigned',
escrowStatus: 'pending',
},
contract,
milestones: params.milestones.map((m, i) => ({
id: i + 1,
jobId: params.job.id,
contractId: contract.id,
title: m.title,
description: m.description ?? null,
amount: m.amount,
status: 'pending',
dueDate: m.dueDate ?? null,
})),
escrowStatus: 'pending',
auditEvents: [
{
event: 'proposal_accepted',
actorUserId: params.actorUserId,
proposalId: params.proposal.id,
jobId: params.job.id,
contractId: contract.id,
createdAt: now,
},
{
event: 'contract_created',
actorUserId: params.actorUserId,
proposalId: params.proposal.id,
jobId: params.job.id,
contractId: contract.id,
createdAt: now,
},
{
event: 'milestones_created',
actorUserId: params.actorUserId,
proposalId: params.proposal.id,
jobId: params.job.id,
contractId: contract.id,
metadata: { count: params.milestones.length },
createdAt: now,
},
{
event: 'escrow_initialized',
actorUserId: params.actorUserId,
proposalId: params.proposal.id,
jobId: params.job.id,
contractId: contract.id,
metadata: { escrowStatus: 'pending' },
createdAt: now,
},
],
}
}
}

describe('ProposalAcceptanceService', () => {
let repo: FakeRepo
let service: ProposalAcceptanceService

beforeEach(() => {
repo = new FakeRepo()
service = new ProposalAcceptanceService(repo)
})

it('accepts a pending proposal and creates contract + milestones + escrow init', async () => {
const result = await service.acceptProposal({ proposalId: 10, actorUserId: 100 })

expect(result.proposal.status).toBe('accepted')
expect(result.contract.proposalId).toBe(10)
expect(result.contract.status).toBe('pending')
expect(result.job.status).toBe('assigned')
expect(result.job.escrowStatus).toBe('pending')
expect(result.milestones).toHaveLength(2)
expect(result.auditEvents.map((e) => e.event)).toEqual([
'proposal_accepted',
'contract_created',
'milestones_created',
'escrow_initialized',
])
})

it('rejects freelancers from accepting proposals', async () => {
repo.userType = 'freelancer'
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(ProposalForbiddenError)
})

it('rejects non-client actors who do not own the job', async () => {
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 999 })
).rejects.toBeInstanceOf(ProposalForbiddenError)
})

it('prevents accepting a non-pending proposal', async () => {
repo.proposal = makeProposal({ status: 'rejected' })
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(ProposalNotPendingError)
})

it('prevents acceptance when the job is not open', async () => {
repo.job = makeJob({ status: 'completed' })
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(JobNotAcceptingError)
})

it('enforces one accepted proposal per project/job', async () => {
repo.accepted = makeProposal({ id: 99, status: 'accepted' })
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(ProposalAlreadyAcceptedError)
})

it('enforces one contract per job', async () => {
repo.contract = {
id: 7,
jobId: 1,
proposalId: 3,
clientId: 100,
freelancerId: 200,
totalAmount: '50',
currency: 'XLM',
terms: null,
status: 'pending',
createdAt: '',
updatedAt: '',
}
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(ContractAlreadyExistsError)
})

it('validates milestone amounts equal the proposed budget', async () => {
await expect(
service.acceptProposal({
proposalId: 10,
actorUserId: 100,
milestones: [{ title: 'Only', amount: '10.00' }],
})
).rejects.toBeInstanceOf(ProposalValidationError)
})

it('defaults to a single full-budget milestone when none are provided', async () => {
repo.proposal = makeProposal({ milestoneBreakdown: [] })
const result = await service.acceptProposal({ proposalId: 10, actorUserId: 100 })
expect(result.milestones).toHaveLength(1)
expect(result.milestones[0].amount).toBe('100.00')
expect(repo.lastCommitMilestones?.[0].title).toBe('Project delivery')
})

it('surfaces transaction failures so callers know the state was rolled back', async () => {
repo.failCommit = true
await expect(
service.acceptProposal({ proposalId: 10, actorUserId: 100 })
).rejects.toBeInstanceOf(ProposalTransactionError)
})
})
97 changes: 97 additions & 0 deletions app/api/proposals/[id]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* POST /api/proposals/[id]/accept
*
* Client accepts a freelancer proposal. Atomically:
* - marks the proposal Accepted (rejects siblings)
* - creates a Draft / Pending Funding contract linked to the proposal
* - seeds milestones from the proposal breakdown (or request body)
* - initializes job escrow_status to pending (Pending Funding)
*
* Only the job's client may call this endpoint. Freelancers are rejected.
*/

import { NextRequest, NextResponse } from 'next/server'
import { withAuthCtx, resolveUserIdByWallet } from '@/lib/auth/middleware'
import {
proposalAcceptanceService,
ProposalAcceptanceError,
proposalErrorToHttpStatus,
type ProposalMilestoneBreakdown,
} from '@/lib/proposals'

type RouteCtx = { params: Promise<{ id: string }> }

export const POST = withAuthCtx(async (request: NextRequest, auth, context: RouteCtx) => {
const { id: idParam } = await context.params
const proposalId = Number(idParam)
if (!Number.isInteger(proposalId) || proposalId <= 0) {
return NextResponse.json(
{ error: 'Invalid proposal id', code: 'VALIDATION' },
{ status: 400 }
)
}

const actorUserId = await resolveUserIdByWallet(auth.walletAddress)
if (actorUserId == null) {
return NextResponse.json(
{ error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' },
{ status: 401 }
)
}

let body: Record<string, unknown> = {}
try {
const text = await request.text()
if (text.trim()) {
body = JSON.parse(text) as Record<string, unknown>
}
} catch {
return NextResponse.json(
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
{ status: 400 }
)
}

try {
const result = await proposalAcceptanceService.acceptProposal({
proposalId,
actorUserId,
terms: typeof body.terms === 'string' ? body.terms : undefined,
milestones: body.milestones as ProposalMilestoneBreakdown[] | undefined,
})

return NextResponse.json(
{
proposalId: result.proposal.id,
proposalStatus: result.proposal.status,
jobId: result.job.id,
jobStatus: result.job.status,
contractId: result.contract.id,
contractStatus: result.contract.status,
proposalLinked: result.contract.proposalId === result.proposal.id,
escrowStatus: result.escrowStatus,
milestonesCreated: result.milestones.length,
milestones: result.milestones.map((m) => ({
id: m.id,
title: m.title,
amount: m.amount,
status: m.status,
})),
auditEvents: result.auditEvents.map((e) => e.event),
},
{ status: 201 }
)
} catch (err) {
if (err instanceof ProposalAcceptanceError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: proposalErrorToHttpStatus(err) }
)
}
console.error('[proposals/accept] Unexpected error:', err)
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
)
}
})
Loading
Loading