diff --git a/app/api/routes-d/escrow/release/route.ts b/app/api/routes-d/escrow/release/route.ts index af2a9f6..9ce89d9 100644 --- a/app/api/routes-d/escrow/release/route.ts +++ b/app/api/routes-d/escrow/release/route.ts @@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { EscrowReleaseSchema, getAuthContext, releaseEscrowFunds } from '@/app/api/routes-d/escrow/_shared' import { sendEscrowReleasedEmail } from '@/lib/email' +import { processWaterfallPayments } from '@/lib/waterfall' +import { sendStellarPayment } from '@/lib/stellar' +import { Keypair } from '@stellar/stellar-sdk' import { logger } from '@/lib/logger' export async function POST(request: NextRequest) { @@ -20,31 +23,35 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'clientEmail must match authenticated user email' }, { status: 403 }) } + // Update query to include collaborators and user wallet const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId }, - include: { user: { select: { email: true, name: true } } }, + include: { + user: { include: { wallet: true } }, + collaborators: true, + }, }) + if (!invoice) return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) if (invoice.clientEmail.toLowerCase() !== clientEmail.toLowerCase()) { return NextResponse.json({ error: 'Not authorized (client email mismatch)' }, { status: 403 }) } - if (!(invoice as any).escrowEnabled) return NextResponse.json({ error: 'Escrow is not enabled for this invoice' }, { status: 400 }) - if ((invoice as any).escrowStatus !== 'held') return NextResponse.json({ error: `Invalid escrow status: ${(invoice as any).escrowStatus}` }, { status: 400 }) + if (!invoice.escrowEnabled) return NextResponse.json({ error: 'Escrow is not enabled for this invoice' }, { status: 400 }) + if (invoice.escrowStatus !== 'held') return NextResponse.json({ error: `Invalid escrow status: ${invoice.escrowStatus}` }, { status: 400 }) - // On-chain Release - if ((invoice as any).escrowContractId) { - try { - await releaseEscrowFunds((invoice as any).escrowContractId) - } catch (err) { - logger.error({ err: err }, 'On-chain escrow release failed:') - return NextResponse.json({ error: 'Failed to release escrow on-chain. Please ensure you have sufficient XLM for gas.' }, { status: 500 }) - } - } + const fundingSecret = process.env.STELLAR_FUNDING_WALLET_SECRET; + if (!fundingSecret) throw new Error('Funding secret not configured'); + const fundingKp = Keypair.fromSecret(fundingSecret); + const fundingPublicKey = fundingKp.publicKey(); + + const hasCollaborators = invoice.collaborators.length > 0; + const amountUsdc = Number(invoice.amount); const now = new Date() - const updated = await prisma.$transaction(async (tx: any) => { + const result = await prisma.$transaction(async (tx) => { + // 1. Escrow status update and invoice status update const updateResult = await tx.invoice.updateMany({ where: { id: invoice.id, @@ -54,6 +61,7 @@ export async function POST(request: NextRequest) { }, data: { escrowStatus: 'released', + status: 'paid', escrowReleasedAt: now, }, }) @@ -62,6 +70,17 @@ export async function POST(request: NextRequest) { throw new Error('ESCROW_RELEASE_CONFLICT') } + // 2. On-chain Release (Soroban) + if (invoice.escrowContractId) { + try { + await releaseEscrowFunds(invoice.escrowContractId) + } catch (err) { + logger.error({ err }, 'On-chain escrow release failed:') + throw new Error('Failed to release escrow on-chain. Please ensure you have sufficient XLM for gas.') + } + } + + // 3. Create Escrow Event await tx.escrowEvent.create({ data: { invoiceId: invoice.id, @@ -72,15 +91,59 @@ export async function POST(request: NextRequest) { }, }) - return tx.invoice.findUnique({ - where: { id: invoice.id }, - select: { id: true, escrowStatus: true, escrowReleasedAt: true }, - }) - }) + let distributions = []; + + // 4. Stellar Payments + if (!hasCollaborators) { + // Scenario A — No collaborators + const freelancerWallet = invoice.user.wallet?.address; + if (!freelancerWallet) throw new Error('Freelancer wallet not found'); + + await sendStellarPayment( + fundingPublicKey, + fundingSecret, + freelancerWallet, + amountUsdc.toString(), + `Escrow payout: ${invoice.invoiceNumber}` + ) + } else { + // Scenario B — Collaborators exist + const waterfallResult = await processWaterfallPayments(invoice.id, amountUsdc, 'escrow'); + distributions = waterfallResult.distributions; + + const freelancerWallet = invoice.user.wallet?.address; + if (!freelancerWallet) throw new Error('Freelancer wallet not found'); + + // Send lead share to freelancer + await sendStellarPayment( + fundingPublicKey, + fundingSecret, + freelancerWallet, + waterfallResult.leadShare.toString(), + `Escrow lead share: ${invoice.invoiceNumber}` + ) + + // Loop distributions — send completed payments + for (const dist of waterfallResult.distributions) { + if (dist.status === 'completed' && dist.walletAddress) { + try { + await sendStellarPayment( + fundingPublicKey, + fundingSecret, + dist.walletAddress, + dist.amount.toString(), + `Revenue split: ${invoice.invoiceNumber}` + ) + } catch (err) { + // Failed distributions are skipped in the payment loop — no throw + logger.error({ err, collaboratorEmail: dist.email }, `Failed to send payment to collaborator ${dist.email}:`); + } + } + } + } - if (!updated) { - return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) - } + return { distributions }; + }) if (invoice.user.email) { await sendEscrowReleasedEmail({ @@ -93,19 +156,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, - message: 'Escrow released to freelancer', - invoice: { - id: updated.id, - escrowStatus: 'released', - escrowReleasedAt: updated.escrowReleasedAt ? updated.escrowReleasedAt.toISOString() : now.toISOString(), - }, + message: hasCollaborators + ? `Escrow released with waterfall to ${invoice.collaborators.length} collaborators` + : 'Escrow released to freelancer', + distributions: result.distributions }) } catch (error) { if (error instanceof Error && error.message === 'ESCROW_RELEASE_CONFLICT') { return NextResponse.json({ error: 'Escrow status changed. Please refresh and retry.' }, { status: 409 }) } logger.error({ err: error }, 'Escrow release error:') - return NextResponse.json({ error: 'Failed to release escrow' }, { status: 500 }) + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to release escrow' }, { status: 500 }) } } - diff --git a/lib/stellar.ts b/lib/stellar.ts index a33ea63..5917474 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -871,3 +871,5 @@ export async function sendPathPayment( throw { type: "payment_failed", message } as StellarError; } } + +export const sendStellarPayment = sendUSDCPayment; diff --git a/lib/waterfall.ts b/lib/waterfall.ts index a4296a9..a1e66c3 100644 --- a/lib/waterfall.ts +++ b/lib/waterfall.ts @@ -7,6 +7,7 @@ interface WaterfallResult { distributions: Array<{ subContractorId: string email: string + walletAddress: string sharePercentage: number amount: number status: 'completed' | 'failed' @@ -47,6 +48,7 @@ export async function validateCollaboratorPercentages( export async function processWaterfallPayments( invoiceId: string, invoiceAmount: number, + source: 'payment' | 'escrow' = 'payment', tx?: any ): Promise { const db = tx ?? prisma @@ -87,6 +89,7 @@ export async function processWaterfallPayments( data: { payoutStatus: 'completed', internalTxId, + paymentSource: source, paidAt: new Date(), }, }) @@ -95,12 +98,14 @@ export async function processWaterfallPayments( distributions.push({ subContractorId: collaborator.subContractorId, email: collaborator.subContractor.email, + walletAddress: collaborator.subContractor.wallet?.address || '', sharePercentage: Number(collaborator.sharePercentage), amount: shareAmount, status: 'completed', internalTxId, }) } catch (error) { + // On failure, update payoutStatus but do not rethrow await db.invoiceCollaborator.update({ where: { id: collaborator.id }, data: { payoutStatus: 'failed' }, @@ -109,6 +114,7 @@ export async function processWaterfallPayments( distributions.push({ subContractorId: collaborator.subContractorId, email: collaborator.subContractor.email, + walletAddress: collaborator.subContractor.wallet?.address || '', sharePercentage: Number(collaborator.sharePercentage), amount: shareAmount, status: 'failed', diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 225cd87..d300d20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -579,6 +579,7 @@ model InvoiceCollaborator { payoutStatus String @default("pending") @db.VarChar(20) internalTxId String? @db.VarChar(255) paidAt DateTime? + paymentSource String? // 'payment' | 'escrow' | 'advance' createdAt DateTime @default(now()) invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) diff --git a/tests/escrow-waterfall.test.ts b/tests/escrow-waterfall.test.ts new file mode 100644 index 0000000..3b68e52 --- /dev/null +++ b/tests/escrow-waterfall.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST as releaseRoute } from '@/app/api/routes-d/escrow/release/route'; +import { prisma } from '@/lib/db'; +import * as shared from '@/app/api/routes-d/escrow/_shared'; +import * as waterfallLib from '@/lib/waterfall'; +import * as stellarLib from '@/lib/stellar'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/db', () => ({ + prisma: { + invoice: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + escrowEvent: { + create: vi.fn(), + }, + $transaction: vi.fn((cb) => cb(prisma)), + }, +})); + +vi.mock('@/app/api/routes-d/escrow/_shared', () => ({ + EscrowReleaseSchema: { + safeParse: vi.fn(), + }, + getAuthContext: vi.fn(), + releaseEscrowFunds: vi.fn(), +})); + +vi.mock('@/lib/waterfall', () => ({ + processWaterfallPayments: vi.fn(), +})); + +vi.mock('@/lib/stellar', () => ({ + sendStellarPayment: vi.fn(), +})); + +vi.mock('@/lib/email', () => ({ + sendEscrowReleasedEmail: vi.fn(), +})); + +describe('Escrow Release with Collaborators', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.STELLAR_FUNDING_WALLET_SECRET = 'SBFES63435GQ64ZKPIGI332O2KIZ2YMXGTG4VRRC6GNXE2WUCMF3GXU7'; + }); + + const mockRequest = (body: any) => { + return { + json: vi.fn().mockResolvedValue(body), + headers: new Headers({ authorization: 'Bearer token' }), + } as unknown as NextRequest; + }; + + it('should distribute funds via waterfall when collaborators exist', async () => { + // Setup + const invoiceId = 'inv_123'; + const freelancerWallet = 'G_FREELANCER'; + const collaboratorWallet = 'G_COLLABORATOR'; + + vi.mocked(shared.getAuthContext).mockResolvedValue({ email: 'client@example.com', user: {} } as any); + vi.mocked(shared.EscrowReleaseSchema.safeParse).mockReturnValue({ + success: true, + data: { invoiceId, clientEmail: 'client@example.com' } + } as any); + + const mockInvoice = { + id: invoiceId, + amount: 1000, + clientEmail: 'client@example.com', + escrowEnabled: true, + escrowStatus: 'held', + invoiceNumber: 'INV-001', + user: { wallet: { address: freelancerWallet }, email: 'free@example.com' }, + collaborators: [{ id: 'col_1' }], + }; + + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as any); + vi.mocked(prisma.invoice.updateMany).mockResolvedValue({ count: 1 } as any); + + vi.mocked(waterfallLib.processWaterfallPayments).mockResolvedValue({ + processed: true, + leadShare: 700, + distributions: [ + { + subContractorId: 'sub_1', + email: 'sub@example.com', + walletAddress: collaboratorWallet, + sharePercentage: 30, + amount: 300, + status: 'completed', + } + ] + } as any); + + // Execute + const res = await releaseRoute(mockRequest({ invoiceId, clientEmail: 'client@example.com' })); + const data = await res.json(); + + // Verify + expect(res.status).toBe(200); + expect(stellarLib.sendStellarPayment).toHaveBeenCalledTimes(2); + // Freelancer gets 700 (70%) + expect(stellarLib.sendStellarPayment).toHaveBeenCalledWith( + expect.any(String), expect.any(String), freelancerWallet, "700", expect.any(String) + ); + // Collaborator gets 300 (30%) + expect(stellarLib.sendStellarPayment).toHaveBeenCalledWith( + expect.any(String), expect.any(String), collaboratorWallet, "300", expect.any(String) + ); + }); + + it('should send full amount when no collaborators', async () => { + const invoiceId = 'inv_456'; + const freelancerWallet = 'G_FREELANCER'; + + vi.mocked(shared.getAuthContext).mockResolvedValue({ email: 'client@example.com', user: {} } as any); + vi.mocked(shared.EscrowReleaseSchema.safeParse).mockReturnValue({ + success: true, + data: { invoiceId, clientEmail: 'client@example.com' } + } as any); + + const mockInvoice = { + id: invoiceId, + amount: 1000, + clientEmail: 'client@example.com', + escrowEnabled: true, + escrowStatus: 'held', + invoiceNumber: 'INV-002', + user: { wallet: { address: freelancerWallet }, email: 'free@example.com' }, + collaborators: [], + }; + + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as any); + vi.mocked(prisma.invoice.updateMany).mockResolvedValue({ count: 1 } as any); + + const res = await releaseRoute(mockRequest({ invoiceId, clientEmail: 'client@example.com' })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(stellarLib.sendStellarPayment).toHaveBeenCalledTimes(1); + expect(stellarLib.sendStellarPayment).toHaveBeenCalledWith( + expect.any(String), expect.any(String), freelancerWallet, "1000", expect.any(String) + ); + }); + + it('should handle partial collaborator payment failures', async () => { + const invoiceId = 'inv_789'; + const freelancerWallet = 'G_FREELANCER'; + + vi.mocked(shared.getAuthContext).mockResolvedValue({ email: 'client@example.com', user: {} } as any); + vi.mocked(shared.EscrowReleaseSchema.safeParse).mockReturnValue({ + success: true, + data: { invoiceId, clientEmail: 'client@example.com' } + } as any); + + const mockInvoice = { + id: invoiceId, + amount: 1000, + clientEmail: 'client@example.com', + escrowEnabled: true, + escrowStatus: 'held', + invoiceNumber: 'INV-003', + user: { wallet: { address: freelancerWallet }, email: 'free@example.com' }, + collaborators: [{ id: 'col_1' }, { id: 'col_2' }], + }; + + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as any); + vi.mocked(prisma.invoice.updateMany).mockResolvedValue({ count: 1 } as any); + + vi.mocked(waterfallLib.processWaterfallPayments).mockResolvedValue({ + processed: true, + leadShare: 500, + distributions: [ + { + subContractorId: 'sub_1', + email: 'sub1@example.com', + walletAddress: 'G_SUB1', + sharePercentage: 20, + amount: 200, + status: 'completed', + }, + { + subContractorId: 'sub_2', + email: 'sub2@example.com', + walletAddress: '', // no wallet — will fail + sharePercentage: 30, + amount: 300, + status: 'failed', + error: 'No wallet found' + } + ] + } as any); + + const res = await releaseRoute(mockRequest({ invoiceId, clientEmail: 'client@example.com' })); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.distributions).toHaveLength(2); + expect(data.distributions[0].status).toBe('completed'); + expect(data.distributions[1].status).toBe('failed'); + }); +});