diff --git a/apps/web/src/app/admin/treasury/page.tsx b/apps/web/src/app/admin/treasury/page.tsx index 4c0c1d2..2125a1c 100644 --- a/apps/web/src/app/admin/treasury/page.tsx +++ b/apps/web/src/app/admin/treasury/page.tsx @@ -67,6 +67,12 @@ export default function TreasuryDashboard() { const [walletNote, setWalletNote] = useState(""); const [verifyingWallet, setVerifyingWallet] = useState(false); const [walletMessage, setWalletMessage] = useState(""); + const [affiliateLedgerId, setAffiliateLedgerId] = useState(""); + const [affiliateDestination, setAffiliateDestination] = useState(""); + const [affiliateReference, setAffiliateReference] = useState(""); + const [affiliateNote, setAffiliateNote] = useState(""); + const [settlingAffiliate, setSettlingAffiliate] = useState<"PAID" | "REFUNDED" | "">(""); + const [affiliateMessage, setAffiliateMessage] = useState(""); const cents = (value: number | undefined) => value ?? 0; const pendingWalletReceipts = stats?.pending_wallet_receipts || []; const pendingAffiliatePayouts = stats?.pending_affiliate_payouts || []; @@ -159,6 +165,60 @@ export default function TreasuryDashboard() { setWalletMessage("Pending receipt loaded. Verify only after checking the transaction on-chain."); }; + const selectAffiliatePayout = (row: NonNullable[number]) => { + setAffiliateLedgerId(row.id); + setAffiliateDestination(row.scout_wallet || ""); + setAffiliateReference(""); + setAffiliateNote(`task=${row.task_id}; scout=${row.scout_id}`); + setAffiliateMessage("Pending payout loaded. Mark paid only after the actual transfer is complete."); + }; + + const handleSettleAffiliatePayout = async (status: "PAID" | "REFUNDED") => { + if (!affiliateLedgerId.trim()) { + setAffiliateMessage("Affiliate ledger ID is required."); + return; + } + + if (status === "PAID" && !affiliateReference.trim()) { + setAffiliateMessage("Payout reference is required before marking paid."); + return; + } + + setSettlingAffiliate(status); + setAffiliateMessage(""); + + try { + const res = await fetch("/api/admin/affiliate/payouts/settle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ledger_id: affiliateLedgerId.trim(), + status, + destination: affiliateDestination.trim(), + payout_reference: affiliateReference.trim(), + note: affiliateNote.trim(), + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.success) { + throw new Error(data.error || `Affiliate payout settlement failed (${res.status})`); + } + + setAffiliateMessage( + `Affiliate payout ${data.duplicate ? "was already" : "marked"} ${data.status}; ${(data.amount_cents / 100).toFixed(2)} ${data.currency}.` + ); + setAffiliateLedgerId(""); + setAffiliateDestination(""); + setAffiliateReference(""); + setAffiliateNote(""); + await loadStats(); + } catch (error) { + setAffiliateMessage(error instanceof Error ? error.message : "Affiliate payout settlement failed."); + } finally { + setSettlingAffiliate(""); + } + }; + const handleWithdraw = async () => { if (!stats) { setMessage("⚠️ Treasury stats are unavailable."); @@ -457,6 +517,13 @@ export default function TreasuryDashboard() { {row.proposal_status} {row.scout_wallet ? "wallet bound" : "wallet missing"} + ))} {pendingAffiliatePayouts.length === 0 ? ( @@ -466,6 +533,63 @@ export default function TreasuryDashboard() { ) : null} +
+

Affiliate payout settlement

+
+ setAffiliateLedgerId(e.target.value)} + placeholder="Affiliate ledger ID" + className="w-full rounded-lg border border-[#333] bg-[#0a0a0a] px-3 py-2 text-sm text-white outline-none focus:border-emerald-500" + /> + setAffiliateDestination(e.target.value)} + placeholder="Destination wallet or payout account" + className="w-full rounded-lg border border-[#333] bg-[#0a0a0a] px-3 py-2 text-sm text-white outline-none focus:border-emerald-500" + /> + setAffiliateReference(e.target.value)} + placeholder="Payout tx hash / payout reference" + className="w-full rounded-lg border border-[#333] bg-[#0a0a0a] px-3 py-2 text-sm text-white outline-none focus:border-emerald-500" + /> + setAffiliateNote(e.target.value)} + placeholder="Settlement note" + className="w-full rounded-lg border border-[#333] bg-[#0a0a0a] px-3 py-2 text-sm text-white outline-none focus:border-emerald-500" + /> +
+
+ + +
+ {affiliateMessage ? ( +
+ {affiliateMessage} +
+ ) : null} +
+

Recent Cashflows

{stats.recent_transactions?.map((tx, idx) => ( diff --git a/apps/web/src/app/api/a2a/referrals/status/route.ts b/apps/web/src/app/api/a2a/referrals/status/route.ts index 8ca421f..8eed06f 100644 --- a/apps/web/src/app/api/a2a/referrals/status/route.ts +++ b/apps/web/src/app/api/a2a/referrals/status/route.ts @@ -237,7 +237,7 @@ export async function GET(request: NextRequest) { }), payout_policy: { referral_fee: "10% of collected proposal routing fees.", - status: "Affiliate ledger starts PENDING. Payout requires platform review and wallet or Stripe Connect binding.", + status: "Affiliate ledger starts PENDING. Platform review marks each ledger PAID or REFUNDED after wallet or Stripe Connect binding checks.", payment_truth: "Paid conversion is counted only after Stripe webhook or verified USDC wallet receipt.", }, traffic_funnel: { diff --git a/apps/web/src/app/api/admin/affiliate/payouts/settle/route.ts b/apps/web/src/app/api/admin/affiliate/payouts/settle/route.ts new file mode 100644 index 0000000..d9370be --- /dev/null +++ b/apps/web/src/app/api/admin/affiliate/payouts/settle/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { adminUnauthorizedResponse, isAdminRequestAuthorized } from "@/lib/admin-auth"; + +function asRecord(value: unknown) { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStatus(value: string) { + const status = value.trim().toUpperCase(); + return status === "PAID" || status === "REFUNDED" ? status : ""; +} + +function withdrawalTypeForCurrency(currency: string) { + return currency.toUpperCase() === "USDC" ? "CRYPTO" : "FIAT"; +} + +export async function POST(request: NextRequest) { + if (!isAdminRequestAuthorized(request)) { + return adminUnauthorizedResponse(); + } + + try { + const body = asRecord(await request.json().catch(() => ({}))); + const ledgerId = stringValue(body.ledger_id || body.ledgerId || body.affiliate_ledger_id); + const status = normalizeStatus(stringValue(body.status)); + const payoutReference = stringValue(body.payout_reference || body.payoutReference || body.tx_hash || body.txHash); + const destination = stringValue(body.destination || body.wallet || body.wallet_address); + const note = stringValue(body.note || body.settlement_note || body.verification_note).slice(0, 500); + + if (!ledgerId) { + return NextResponse.json({ error: "ledger_id is required" }, { status: 400 }); + } + + if (!status) { + return NextResponse.json({ error: "status must be PAID or REFUNDED" }, { status: 400 }); + } + + if (status === "PAID" && !payoutReference) { + return NextResponse.json({ error: "payout_reference is required when marking a payout PAID" }, { status: 400 }); + } + + const ledger = await prisma.affiliateLedger.findUnique({ + where: { id: ledgerId }, + select: { + id: true, + scout_id: true, + task_id: true, + amount: true, + currency: true, + status: true, + scout_agent: { + select: { + wallet_address: true, + status: true, + }, + }, + task: { + select: { + title: true, + stripe_payment_intent_id: true, + }, + }, + }, + }); + + if (!ledger) { + return NextResponse.json({ error: "Affiliate ledger not found" }, { status: 404 }); + } + + const currentStatus = ledger.status.toUpperCase(); + if (currentStatus === status) { + return NextResponse.json({ + success: true, + duplicate: true, + affiliate_ledger_id: ledger.id, + status: currentStatus, + }); + } + + if (currentStatus !== "PENDING") { + return NextResponse.json({ + error: `Affiliate ledger is already ${currentStatus}; refusing to change it to ${status}.`, + }, { status: 409 }); + } + + const resolvedDestination = destination || ledger.scout_agent?.wallet_address || ""; + if (status === "PAID" && !resolvedDestination) { + return NextResponse.json({ + error: "destination or scout wallet is required when marking a payout PAID", + }, { status: 400 }); + } + + const withdrawalType = withdrawalTypeForCurrency(ledger.currency); + const action = status === "PAID" ? "AFFILIATE_PAYOUT_MARKED_PAID" : "AFFILIATE_PAYOUT_REFUNDED"; + + const result = await prisma.$transaction(async (tx) => { + const updatedLedger = await tx.affiliateLedger.update({ + where: { id: ledger.id }, + data: { status }, + select: { + id: true, + scout_id: true, + task_id: true, + amount: true, + currency: true, + status: true, + updated_at: true, + }, + }); + + const settlementMetadata = { + affiliate_ledger_id: ledger.id, + scout_id: ledger.scout_id, + task_id: ledger.task_id, + task_title: ledger.task?.title || null, + amount: ledger.amount, + currency: ledger.currency, + previous_status: ledger.status, + status, + destination: resolvedDestination || null, + payout_reference: payoutReference || null, + payment_reference: ledger.task?.stripe_payment_intent_id || null, + scout_wallet: ledger.scout_agent?.wallet_address || null, + scout_status: ledger.scout_agent?.status || null, + note: note || null, + }; + + await tx.auditEvent.create({ + data: { + actorType: "ADMIN", + actorId: "admin", + action, + entityType: "AFFILIATE_LEDGER", + entityId: ledger.id, + metadata: settlementMetadata, + }, + }); + + if (status === "PAID") { + await tx.auditEvent.create({ + data: { + actorType: "ADMIN", + actorId: "admin", + action: "TREASURY_WITHDRAWAL", + entityType: "AFFILIATE_LEDGER", + entityId: ledger.id, + metadata: { + ...settlementMetadata, + type: withdrawalType, + reason: "affiliate_referral_payout", + status: "COMPLETED", + }, + }, + }); + } + + return updatedLedger; + }); + + return NextResponse.json({ + success: true, + duplicate: false, + affiliate_ledger_id: result.id, + scout_id: result.scout_id, + task_id: result.task_id, + amount_cents: result.amount, + currency: result.currency, + status: result.status, + destination: resolvedDestination || null, + payout_reference: payoutReference || null, + updated_at: result.updated_at.toISOString(), + }); + } catch (error: unknown) { + console.error("[Affiliate Payout Settlement Error]", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/web/src/lib/a2a-growth.ts b/apps/web/src/lib/a2a-growth.ts index c2d3ab0..c9ec748 100644 --- a/apps/web/src/lib/a2a-growth.ts +++ b/apps/web/src/lib/a2a-growth.ts @@ -242,7 +242,7 @@ export function buildAgentDemandCampaignKit(params: { payout_boundaries: { referral_fee: "10% of collected proposal routing fees after payment confirmation.", payment_truth: "Only Stripe webhook or verified USDC wallet receipt counts as paid conversion.", - review_gate: "Affiliate ledger starts PENDING and requires platform review before payout.", + review_gate: "Affiliate ledger starts PENDING and requires platform review before payout release or refund.", }, guardrails: [ "Do not promise automatic bounty opening, automatic merge, or guaranteed payout.", @@ -279,7 +279,7 @@ export function buildAgentGrowthKit(params: { gateway_api: AGENT_GATEWAY_URL, incentive: { referral_fee: "10% of collected proposal routing fees, tracked as pending affiliate ledger after paid conversion.", - qualification: "Agent must pass platform review before payout.", + qualification: "Agent payout is released or refunded by platform review after paid conversion.", }, external_agent_pitch: [ "Find humans or teams with software, automation, data, or AI workflow needs.",