From 805059230715320e0d32626873bf874ab86faae3 Mon Sep 17 00:00:00 2001 From: Depo-Lonedev Date: Thu, 26 Feb 2026 10:46:31 +0100 Subject: [PATCH] feat: implement Invoice Template System (#268) --- app/api/routes-d/escrow/release/route.ts | 118 +++++++++---- lib/stellar.ts | 2 + lib/waterfall.ts | 8 +- prisma/schema.prisma | 1 + tests/escrow-waterfall.test.ts | 203 +++++++++++++++++++++++ 5 files changed, 302 insertions(+), 30 deletions(-) create mode 100644 tests/escrow-waterfall.test.ts diff --git a/app/api/routes-d/escrow/release/route.ts b/app/api/routes-d/escrow/release/route.ts index 9af8575..7ba1d5d 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' export async function POST(request: NextRequest) { try { @@ -19,31 +22,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) { - console.error('On-chain escrow release failed:', err) - 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, @@ -53,6 +60,7 @@ export async function POST(request: NextRequest) { }, data: { escrowStatus: 'released', + status: 'paid', escrowReleasedAt: now, }, }) @@ -61,6 +69,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) { + console.error('On-chain escrow release failed:', err) + 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, @@ -71,15 +90,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 + console.error(`Failed to send payment to collaborator ${dist.email}:`, err); + } + } + } + } - if (!updated) { - return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) - } + return { distributions }; + }) if (invoice.user.email) { await sendEscrowReleasedEmail({ @@ -92,19 +155,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 }) } console.error('Escrow release error:', 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 fe3324d..d38f94b 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' @@ -46,7 +47,8 @@ export async function validateCollaboratorPercentages( export async function processWaterfallPayments( invoiceId: string, - invoiceAmount: number + invoiceAmount: number, + source: 'payment' | 'escrow' = 'payment' ): Promise { const collaborators = await prisma.invoiceCollaborator.findMany({ where: { invoiceId, payoutStatus: 'pending' }, @@ -84,6 +86,7 @@ export async function processWaterfallPayments( data: { payoutStatus: 'completed', internalTxId, + paymentSource: source, paidAt: new Date(), }, }) @@ -92,12 +95,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 prisma.invoiceCollaborator.update({ where: { id: collaborator.id }, data: { payoutStatus: 'failed' }, @@ -106,6 +111,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 3389b2a..cf30df0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -515,6 +515,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'); + }); +});