Skip to content
Merged
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
118 changes: 89 additions & 29 deletions app/api/routes-d/escrow/release/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -54,6 +61,7 @@ export async function POST(request: NextRequest) {
},
data: {
escrowStatus: 'released',
status: 'paid',
escrowReleasedAt: now,
},
})
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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 })
}
}

2 changes: 2 additions & 0 deletions lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,3 +871,5 @@ export async function sendPathPayment(
throw { type: "payment_failed", message } as StellarError;
}
}

export const sendStellarPayment = sendUSDCPayment;
6 changes: 6 additions & 0 deletions lib/waterfall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface WaterfallResult {
distributions: Array<{
subContractorId: string
email: string
walletAddress: string
sharePercentage: number
amount: number
status: 'completed' | 'failed'
Expand Down Expand Up @@ -47,6 +48,7 @@ export async function validateCollaboratorPercentages(
export async function processWaterfallPayments(
invoiceId: string,
invoiceAmount: number,
source: 'payment' | 'escrow' = 'payment',
tx?: any
): Promise<WaterfallResult> {
const db = tx ?? prisma
Expand Down Expand Up @@ -87,6 +89,7 @@ export async function processWaterfallPayments(
data: {
payoutStatus: 'completed',
internalTxId,
paymentSource: source,
paidAt: new Date(),
},
})
Expand All @@ -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' },
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading