From 9e366f89541ad3bfc679a92a34fc3fb37c7e8760 Mon Sep 17 00:00:00 2001 From: OG T Date: Thu, 11 Jun 2026 15:38:00 +0800 Subject: [PATCH] feat: close wallet proposal revenue loop --- apps/web/src/app/admin/treasury/page.tsx | 168 ++++++++++++-- .../admin/proposals/wallet/verify/route.ts | 112 +++++++++ .../src/app/api/admin/treasury/stats/route.ts | 74 +----- apps/web/src/app/api/admin/withdraw/route.ts | 65 +----- apps/web/src/app/api/webhooks/stripe/route.ts | 145 ++---------- apps/web/src/app/propose/success/page.tsx | 11 +- apps/web/src/lib/payment.ts | 23 +- apps/web/src/lib/proposal-payment-ledger.ts | 212 ++++++++++++++++++ apps/web/src/lib/treasury-snapshot.ts | 158 +++++++++++++ 9 files changed, 690 insertions(+), 278 deletions(-) create mode 100644 apps/web/src/app/api/admin/proposals/wallet/verify/route.ts create mode 100644 apps/web/src/lib/proposal-payment-ledger.ts create mode 100644 apps/web/src/lib/treasury-snapshot.ts diff --git a/apps/web/src/app/admin/treasury/page.tsx b/apps/web/src/app/admin/treasury/page.tsx index 59d3232..e7f04c4 100644 --- a/apps/web/src/app/admin/treasury/page.tsx +++ b/apps/web/src/app/admin/treasury/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; type TreasuryStats = { available?: { @@ -16,9 +16,12 @@ type TreasuryStats = { fiat?: number; }; recent_transactions?: Array<{ + id?: string; type: string; source: string; amount: number; + currency?: string; + direction?: "in" | "out"; date: string; }>; }; @@ -31,26 +34,86 @@ export default function TreasuryDashboard() { const [withdrawType, setWithdrawType] = useState<"CRYPTO" | "FIAT">("CRYPTO"); const [destination, setDestination] = useState(""); const [message, setMessage] = useState(""); + const [walletTaskId, setWalletTaskId] = useState(""); + const [walletReference, setWalletReference] = useState(""); + const [walletAmountUsd, setWalletAmountUsd] = useState(""); + const [walletNote, setWalletNote] = useState(""); + const [verifyingWallet, setVerifyingWallet] = useState(false); + const [walletMessage, setWalletMessage] = useState(""); const cents = (value: number | undefined) => value ?? 0; - useEffect(() => { - const loadStats = async () => { - try { - const res = await fetch("/api/admin/treasury/stats"); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || `Treasury stats request failed (${res.status})`); - } - setStats(data); - } catch (error) { - setLoadError(error instanceof Error ? error.message : "Unable to load treasury stats."); - } finally { - setLoading(false); + const loadStats = useCallback(async () => { + try { + const res = await fetch("/api/admin/treasury/stats"); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || `Treasury stats request failed (${res.status})`); } - }; - void loadStats(); + setStats(data); + setLoadError(""); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "Unable to load treasury stats."); + } finally { + setLoading(false); + } }, []); + useEffect(() => { + const timer = window.setTimeout(() => { + void loadStats(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadStats]); + + const handleVerifyWalletReceipt = async () => { + if (!walletTaskId.trim() || !walletReference.trim()) { + setWalletMessage("Proposal ID and wallet reference are required."); + return; + } + + const amountCents = walletAmountUsd.trim() + ? Math.round(Number.parseFloat(walletAmountUsd.replace(/[,$]/g, "")) * 100) + : undefined; + + if (walletAmountUsd.trim() && (!amountCents || amountCents <= 0)) { + setWalletMessage("Amount must be a positive number."); + return; + } + + setVerifyingWallet(true); + setWalletMessage(""); + + try { + const res = await fetch("/api/admin/proposals/wallet/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + task_id: walletTaskId.trim(), + payment_reference: walletReference.trim(), + amount_cents: amountCents, + verification_note: walletNote.trim(), + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.success) { + throw new Error(data.error || `Wallet verification failed (${res.status})`); + } + + setWalletMessage( + `Wallet receipt ${data.duplicate ? "was already recorded" : "verified"}; affiliate pending ${(data.affiliate_fee_cents / 100).toFixed(2)}.` + ); + setWalletTaskId(""); + setWalletReference(""); + setWalletAmountUsd(""); + setWalletNote(""); + await loadStats(); + } catch (error) { + setWalletMessage(error instanceof Error ? error.message : "Wallet verification failed."); + } finally { + setVerifyingWallet(false); + } + }; + const handleWithdraw = async () => { if (!stats) { setMessage("⚠️ Treasury stats are unavailable."); @@ -176,6 +239,68 @@ export default function TreasuryDashboard() {
+ {/* Wallet receipt verification */} +
+

+ + Verify USDC Proposal Receipt +

+ +
+
+ + setWalletTaskId(e.target.value)} + className="w-full bg-[#0a0a0a] border border-[#333] rounded-xl px-4 py-3 text-white font-mono focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + setWalletReference(e.target.value)} + className="w-full bg-[#0a0a0a] border border-[#333] rounded-xl px-4 py-3 text-white font-mono focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + setWalletAmountUsd(e.target.value)} + placeholder="Leave blank to use proposal package" + className="w-full bg-[#0a0a0a] border border-[#333] rounded-xl px-4 py-3 text-white font-mono focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + setWalletNote(e.target.value)} + className="w-full bg-[#0a0a0a] border border-[#333] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-emerald-500 transition-colors" + /> +
+
+ + + + {walletMessage && ( +
+ {walletMessage} +
+ )} +
+ {/* Withdrawal Panel */}

@@ -238,17 +363,24 @@ export default function TreasuryDashboard() {

Recent Cashflows

{stats.recent_transactions?.map((tx, idx) => ( -
+

{tx.type.replace('_', ' ')}

{tx.source}

-

+${(tx.amount / 100).toFixed(2)}

+

+ {tx.direction === "out" ? "-" : "+"}${(tx.amount / 100).toFixed(2)} {tx.currency || "USD"} +

{new Date(tx.date).toLocaleTimeString()}

))} + {!stats.recent_transactions?.length ? ( +
+ No verified cashflows yet. +
+ ) : null}
diff --git a/apps/web/src/app/api/admin/proposals/wallet/verify/route.ts b/apps/web/src/app/api/admin/proposals/wallet/verify/route.ts new file mode 100644 index 0000000..3f0f401 --- /dev/null +++ b/apps/web/src/app/api/admin/proposals/wallet/verify/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { adminUnauthorizedResponse, isAdminRequestAuthorized } from "@/lib/admin-auth"; +import { recordDemandProposalFeeCaptured } from "@/lib/proposal-payment-ledger"; +import { sanitizeAgentId } from "@/lib/a2a-growth"; + +function asRecord(value: unknown) { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function positiveInteger(value: unknown) { + const parsed = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + return Math.floor(parsed); +} + +function normalizePaymentReference(value: string) { + return value.trim().replace(/\s+/g, "").slice(0, 180); +} + +export async function POST(request: NextRequest) { + if (!isAdminRequestAuthorized(request)) { + return adminUnauthorizedResponse(); + } + + try { + const body = asRecord(await request.json().catch(() => ({}))); + const taskId = stringValue(body.task_id || body.taskId); + const paymentReference = normalizePaymentReference( + stringValue(body.payment_reference || body.tx_hash || body.txHash) + ); + const overrideAmountCents = positiveInteger(body.amount_cents || body.amountCents); + const verificationNote = stringValue(body.verification_note || body.note); + + if (!taskId) { + return NextResponse.json({ error: "task_id is required" }, { status: 400 }); + } + + if (!paymentReference) { + return NextResponse.json({ error: "payment_reference or tx_hash is required" }, { status: 400 }); + } + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { + id: true, + title: true, + referred_by_agent: true, + scout_id: true, + }, + }); + + if (!task) { + return NextResponse.json({ error: "Proposal task not found" }, { status: 404 }); + } + + const intakeEvent = await prisma.auditEvent.findFirst({ + where: { + action: "DEMAND_PROPOSAL_INTAKE_CREATED", + entityType: "TASK", + entityId: task.id, + }, + orderBy: { createdAt: "desc" }, + select: { metadata: true }, + }); + + const metadata = asRecord(intakeEvent?.metadata); + const feeCents = overrideAmountCents || positiveInteger(metadata.proposal_fee_cents); + + if (!feeCents) { + return NextResponse.json({ + error: "Unable to resolve proposal fee amount. Provide amount_cents.", + }, { status: 400 }); + } + + const referralAgent = sanitizeAgentId( + task.referred_by_agent || task.scout_id || stringValue(metadata.referral_agent) + ); + + const result = await recordDemandProposalFeeCaptured({ + taskId: task.id, + referralAgent: referralAgent || null, + proposalFeeCents: feeCents, + paymentProvider: "wallet", + paymentReference: `wallet:${paymentReference}`, + idempotencyKey: `proposal-wallet-fee:${paymentReference}`, + responseStatus: "verified", + packageId: stringValue(metadata.package_id) || null, + source: stringValue(metadata.source) || "wallet-verification", + campaign: stringValue(metadata.campaign) || null, + verificationNote: verificationNote || null, + }); + + return NextResponse.json({ + success: true, + duplicate: result.duplicate, + task_id: result.task_id, + ledger_entry_id: result.ledger_entry_id, + referral_agent: referralAgent || null, + fee_cents: feeCents, + affiliate_fee_cents: result.affiliate_fee_cents, + payment_reference: paymentReference, + }); + } catch (error: unknown) { + console.error("[Wallet Proposal Verification Error]", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/admin/treasury/stats/route.ts b/apps/web/src/app/api/admin/treasury/stats/route.ts index 73a8769..fc8eeaa 100644 --- a/apps/web/src/app/api/admin/treasury/stats/route.ts +++ b/apps/web/src/app/api/admin/treasury/stats/route.ts @@ -1,18 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; import { adminUnauthorizedResponse, isAdminRequestAuthorized } from "@/lib/admin-auth"; - -function getWithdrawalMetadata(metadata: unknown) { - if (typeof metadata !== "object" || metadata === null) { - return null; - } - - const value = metadata as { currency?: unknown; amount?: unknown }; - return { - currency: typeof value.currency === "string" ? value.currency : null, - amount: typeof value.amount === "number" ? value.amount : 0, - }; -} +import { getTreasurySnapshot } from "@/lib/treasury-snapshot"; export async function GET(request: NextRequest) { if (!isAdminRequestAuthorized(request)) { @@ -20,65 +8,7 @@ export async function GET(request: NextRequest) { } try { - const allTasks = await prisma.task.findMany({ - select: { reward_amount: true, reward_currency: true, status: true } - }); - - let totalGmvUsdc = 0; - let totalGmvFiat = 0; - let totalUsdcRevenue = 0; - let totalFiatRevenue = 0; - - for (const t of allTasks) { - if (t.reward_currency === "USDC") { - totalGmvUsdc += t.reward_amount; - if (t.status === "COMPLETED") totalUsdcRevenue += t.reward_amount * 0.05; - } else { - totalGmvFiat += t.reward_amount; - if (t.status === "COMPLETED") totalFiatRevenue += t.reward_amount * 0.05; - } - } - - const arbitrations = await prisma.arbitration.count(); - totalUsdcRevenue += arbitrations * 5000; // 50 USDC court fee - - // Get previous withdrawals - const withdrawals = await prisma.auditEvent.findMany({ - where: { action: "TREASURY_WITHDRAWAL" } - }); - - let withdrawnUsdc = 0; - let withdrawnFiat = 0; - - for (const w of withdrawals) { - const data = getWithdrawalMetadata(w.metadata); - if (!data) { - continue; - } - if (data.currency === "USDC") { - withdrawnUsdc += data.amount; - } else { - withdrawnFiat += data.amount; - } - } - - const availableUsdc = totalUsdcRevenue - withdrawnUsdc; - const availableFiat = totalFiatRevenue - withdrawnFiat; - - // Provide some fake recent history to make it look active - const recentTransactions = [ - { id: "tx-1", type: "FEE_COLLECTION", amount: 2500, currency: "USDC", source: "Task #epic-build-2", date: new Date().toISOString() }, - { id: "tx-2", type: "ARBITRATION_FEE", amount: 5000, currency: "USDC", source: "Arbitration #arb-091", date: new Date(Date.now() - 3600000).toISOString() }, - { id: "tx-3", type: "SANDBOX_COMPUTE", amount: 50, currency: "USD", source: "Sandbox VM-1928", date: new Date(Date.now() - 7200000).toISOString() }, - ]; - - return NextResponse.json({ - gmv: { usdc: totalGmvUsdc, fiat: totalGmvFiat }, - revenue: { usdc: totalUsdcRevenue, fiat: totalFiatRevenue }, - withdrawn: { usdc: withdrawnUsdc, fiat: withdrawnFiat }, - available: { usdc: availableUsdc, fiat: availableFiat }, - recent_transactions: recentTransactions - }); + return NextResponse.json(await getTreasurySnapshot()); } catch (error: unknown) { console.error("[Treasury Stats Error]", error); diff --git a/apps/web/src/app/api/admin/withdraw/route.ts b/apps/web/src/app/api/admin/withdraw/route.ts index 50ea9b3..6bbec65 100644 --- a/apps/web/src/app/api/admin/withdraw/route.ts +++ b/apps/web/src/app/api/admin/withdraw/route.ts @@ -1,18 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { adminUnauthorizedResponse, isAdminRequestAuthorized } from "@/lib/admin-auth"; - -function getWithdrawalMetadata(metadata: unknown) { - if (typeof metadata !== "object" || metadata === null) { - return null; - } - - const value = metadata as { currency?: unknown; amount?: unknown }; - return { - currency: typeof value.currency === "string" ? value.currency : null, - amount: typeof value.amount === "number" ? value.amount : 0, - }; -} +import { getTreasurySnapshot } from "@/lib/treasury-snapshot"; export async function POST(request: NextRequest) { if (!isAdminRequestAuthorized(request)) { @@ -27,72 +16,40 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } - // Calculate actual platform balance to ensure sufficient funds - const completedTasks = await prisma.task.findMany({ - where: { status: "COMPLETED" }, - select: { reward_amount: true, reward_currency: true } - }); - - let totalUsdcRevenue = 0; - let totalFiatRevenue = 0; - - for (const t of completedTasks) { - const feeCut = t.reward_amount * 0.05; // 5% platform fee - if (t.reward_currency === "USDC") { - totalUsdcRevenue += feeCut; - } else { - totalFiatRevenue += feeCut; - } + const amountCents = Math.floor(Number(amount)); + if (!Number.isFinite(amountCents) || amountCents <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); } - // Add revenue from Arbitrations (simulated) - const arbitrations = await prisma.arbitration.count(); - totalUsdcRevenue += arbitrations * 5000; // 50 USDC court fee per arbitration + const snapshot = await getTreasurySnapshot(); - // Get previous withdrawals - const withdrawals = await prisma.auditEvent.findMany({ - where: { action: "TREASURY_WITHDRAWAL" } - }); - - for (const w of withdrawals) { - const data = getWithdrawalMetadata(w.metadata); - if (!data) continue; - if (data.currency === "USDC") totalUsdcRevenue -= data.amount; - else totalFiatRevenue -= data.amount; - } - - // Validate balance - if (type === "CRYPTO" && amount > totalUsdcRevenue) { + if (type === "CRYPTO" && amountCents > snapshot.available.usdc) { return NextResponse.json({ error: "Insufficient USDC balance in Treasury" }, { status: 400 }); } - if (type === "FIAT" && amount > totalFiatRevenue) { + if (type === "FIAT" && amountCents > snapshot.available.fiat) { return NextResponse.json({ error: "Insufficient Fiat balance in Treasury" }, { status: 400 }); } - // Simulate blockchain/stripe transfer latency - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Record the withdrawal await prisma.auditEvent.create({ data: { actorType: "ADMIN", actorId: "admin", - action: "TREASURY_WITHDRAWAL", + action: "TREASURY_WITHDRAWAL_REQUESTED", entityType: "TREASURY", entityId: "platform-treasury", metadata: { destination, type, - amount, + amount: amountCents, currency: type === "CRYPTO" ? "USDC" : "USD", - txHash: type === "CRYPTO" ? "0x" + Math.random().toString(16).substring(2, 64) : `pi_${Math.random().toString(36).substring(2, 16)}` + status: "PENDING_OPERATOR_EXECUTION", } } }); return NextResponse.json({ success: true, - message: `Successfully transferred ${(amount / 100).toFixed(2)} ${type === "CRYPTO" ? "USDC" : "USD"} to ${destination}` + message: `Withdrawal request recorded for ${(amountCents / 100).toFixed(2)} ${type === "CRYPTO" ? "USDC" : "USD"} to ${destination}. Execute the actual transfer from the treasury wallet or Stripe dashboard, then record the completed withdrawal.` }); } catch (error: unknown) { diff --git a/apps/web/src/app/api/webhooks/stripe/route.ts b/apps/web/src/app/api/webhooks/stripe/route.ts index 56f3c56..6c86fe6 100644 --- a/apps/web/src/app/api/webhooks/stripe/route.ts +++ b/apps/web/src/app/api/webhooks/stripe/route.ts @@ -5,6 +5,7 @@ import { TaskStatus } from "@agent-bounty/contracts"; import { broadcastFomoEvent } from "@/lib/x-broadcaster"; import { sanitizeAgentId } from "@/lib/a2a-growth"; import { resolveA2aTrafficContext } from "@/lib/a2a-traffic"; +import { recordDemandProposalFeeCaptured } from "@/lib/proposal-payment-ledger"; const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-05-27.dahlia", @@ -21,7 +22,6 @@ async function handleDemandProposalFee(session: Stripe.Checkout.Session, request const referralAgent = sanitizeAgentId(metadata.referral_agent); const proposalFeeCents = Number(metadata.proposal_fee_cents || session.amount_total || 0); const paymentIntentId = getStripeObjectId(session.payment_intent); - const idempotencyKey = `proposal-fee:${session.id}`; const trafficContext = referralAgent ? resolveA2aTrafficContext(requestHeaders, referralAgent) : null; @@ -37,137 +37,30 @@ async function handleDemandProposalFee(session: Stripe.Checkout.Session, request return; } - const existingCapture = await prisma.ledgerEntry.findUnique({ - where: { idempotency_key: idempotencyKey }, + const result = await recordDemandProposalFeeCaptured({ + taskId: task.id, + referralAgent, + proposalFeeCents, + paymentProvider: "stripe", + paymentReference: paymentIntentId || session.id, + idempotencyKey: `proposal-fee:${session.id}`, + responseStatus: "captured", + packageId: metadata.package_id || null, + source: metadata.source || null, + campaign: metadata.campaign || null, + trafficContext, }); - if (existingCapture) { + await prisma.task.update({ + where: { id: task.id }, + data: { stripe_checkout_session_id: session.id }, + }); + + if (result.duplicate) { console.log(`[Webhook] Duplicate proposal fee event ignored for session: ${session.id}`); return; } - await prisma.$transaction(async (tx) => { - await tx.ledgerEntry.create({ - data: { - task_id: task.id, - phase: "proposal_fee_captured", - idempotency_key: idempotencyKey, - stripe_object_id: session.id, - response_status: "captured", - http_status: 200, - }, - }); - - await tx.task.update({ - where: { id: task.id }, - data: { - stripe_checkout_session_id: session.id, - stripe_payment_intent_id: paymentIntentId, - status: TaskStatus.DRAFT, - }, - }); - - if (referralAgent) { - await tx.agentProfile.upsert({ - where: { agent_id: referralAgent }, - update: { - discovery_source: "DEMAND_PROPOSAL_PAID_REFERRAL", - }, - create: { - agent_id: referralAgent, - type: "SCOUT", - status: "PENDING", - discovery_source: "DEMAND_PROPOSAL_PAID_REFERRAL", - capabilities: { - growth_referral: true, - source: metadata.source || "stripe-webhook", - }, - }, - }); - - await tx.scoutReputation.upsert({ - where: { scout_id: referralAgent }, - update: { - successful_conversions: { increment: 1 }, - }, - create: { - scout_id: referralAgent, - successful_conversions: 1, - }, - }); - - const existingAffiliate = await tx.affiliateLedger.findFirst({ - where: { - scout_id: referralAgent, - task_id: task.id, - }, - }); - - if (!existingAffiliate && proposalFeeCents > 0) { - await tx.affiliateLedger.create({ - data: { - scout_id: referralAgent, - task_id: task.id, - amount: Math.floor(proposalFeeCents * 0.1), - currency: "USD", - status: "PENDING", - }, - }); - } - } - - await tx.auditEvent.create({ - data: { - actorType: "SYSTEM", - actorId: "stripe-webhook", - action: "DEMAND_PROPOSAL_FEE_CAPTURED", - entityType: "TASK", - entityId: task.id, - metadata: { - stripe_session_id: session.id, - stripe_payment_intent_id: paymentIntentId, - fee_cents: proposalFeeCents, - package_id: metadata.package_id || null, - referral_agent: referralAgent || null, - affiliate_fee_cents: referralAgent ? Math.floor(proposalFeeCents * 0.1) : 0, - source: metadata.source || null, - campaign: metadata.campaign || null, - }, - }, - }); - - if (referralAgent && trafficContext) { - await tx.auditEvent.create({ - data: { - actorType: trafficContext.actor_type, - actorId: trafficContext.actor_id, - action: "EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED", - entityType: "TASK", - entityId: task.id, - reason: "referred_demand_proposal_payment_captured", - metadata: { - stripe_session_id: session.id, - stripe_payment_intent_id: paymentIntentId, - fee_cents: proposalFeeCents, - package_id: metadata.package_id || null, - referral_agent: referralAgent, - affiliate_fee_cents: Math.floor(proposalFeeCents * 0.1), - source: metadata.source || null, - campaign: metadata.campaign || null, - request_id: trafficContext.request_id, - source_ip: trafficContext.source_ip, - user_agent: trafficContext.user_agent, - is_public_ip: trafficContext.is_public_ip, - surface: "stripe/webhook", - request_actor_headers: trafficContext.request_actor_headers, - response_status: 200, - response_summary: "demand_proposal_fee_captured", - }, - }, - }); - } - }); - console.log(`[Webhook] Proposal fee captured for task ${task.id}. Payment Intent: ${paymentIntentId}`); } diff --git a/apps/web/src/app/propose/success/page.tsx b/apps/web/src/app/propose/success/page.tsx index 86140da..fec6f41 100644 --- a/apps/web/src/app/propose/success/page.tsx +++ b/apps/web/src/app/propose/success/page.tsx @@ -41,6 +41,7 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara }) : null; const stripeCaptured = payment === "stripe" && Boolean(task?.stripe_payment_intent_id); + const walletCaptured = payment === "wallet" && Boolean(task?.stripe_payment_intent_id?.startsWith("wallet:")); return (
@@ -55,7 +56,9 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara

{payment === "wallet" - ? "待 USDC 轉帳確認" + ? walletCaptured + ? "USDC 付款已確認" + : "待 USDC 轉帳確認" : stripeCaptured ? "提案付款已確認" : "付款返回成功,等待 webhook 入帳確認"} @@ -122,6 +125,12 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara {TREASURY_USDC_ADDRESS || `${TREASURY_WALLET_LABEL} is not configured yet`}

+ {walletCaptured ? ( +
+
Receipt
+
{task?.stripe_payment_intent_id?.replace(/^wallet:/, "")}
+
+ ) : null} {TREASURY_USDC_ADDRESS ? (
diff --git a/apps/web/src/lib/payment.ts b/apps/web/src/lib/payment.ts index 7e8663f..a17c681 100644 --- a/apps/web/src/lib/payment.ts +++ b/apps/web/src/lib/payment.ts @@ -211,13 +211,10 @@ export async function executePayout( where: { idempotency_key: idempotencyKey }, }); if (existing) { - if (existing.response_status === "SUCCESS") return existing; + if (existing.response_status === "SUCCESS" || existing.response_status.startsWith("PENDING_")) return existing; throw new Error(`Previous executePayout failed for idempotencyKey: ${idempotencyKey}`); } - let transferId = `mock_transfer_${taskId}`; - - // If Stripe is configured and wallet looks like a Stripe Connect Account if (stripe && developerWallet.startsWith("acct_") && !ALLOW_MCP_CLAIM_WITHOUT_STRIPE) { try { const transfer = await stripe.transfers.create({ @@ -226,7 +223,17 @@ export async function executePayout( destination: developerWallet, description: `Bounty Payout for Task ${taskId}`, }, { idempotencyKey }); - transferId = transfer.id; + + return await tx.ledgerEntry.create({ + data: { + task_id: taskId, + phase: "PAYOUT", + idempotency_key: idempotencyKey, + stripe_object_id: transfer.id, + response_status: "SUCCESS", + http_status: 200, + }, + }); } catch (error: unknown) { await tx.ledgerEntry.create({ data: { @@ -247,8 +254,10 @@ export async function executePayout( task_id: taskId, phase: "PAYOUT", idempotency_key: idempotencyKey, - stripe_object_id: transferId, - response_status: "SUCCESS", + stripe_object_id: developerWallet, + response_status: developerWallet.startsWith("acct_") + ? "PENDING_STRIPE_CONNECT_TRANSFER" + : "PENDING_MANUAL_WALLET_TRANSFER", http_status: 200, }, }); diff --git a/apps/web/src/lib/proposal-payment-ledger.ts b/apps/web/src/lib/proposal-payment-ledger.ts new file mode 100644 index 0000000..b2f115f --- /dev/null +++ b/apps/web/src/lib/proposal-payment-ledger.ts @@ -0,0 +1,212 @@ +import { prisma } from "@/lib/prisma"; +import { asA2aAgentActorId } from "@/lib/a2a-traffic"; +import { Prisma } from "../../prisma/generated/client"; +import { TaskStatus } from "@agent-bounty/contracts"; + +type ProposalFeeTrafficContext = { + actor_type: "AGENT" | "USER"; + actor_id: string; + request_id: string; + source_ip: string; + user_agent: string; + is_public_ip: boolean; + request_actor_headers?: Record; +}; + +export type ProposalFeeCaptureInput = { + taskId: string; + referralAgent?: string | null; + proposalFeeCents: number; + paymentProvider: "stripe" | "wallet"; + paymentReference: string; + idempotencyKey: string; + responseStatus?: "captured" | "verified"; + packageId?: string | null; + source?: string | null; + campaign?: string | null; + verificationNote?: string | null; + trafficContext?: ProposalFeeTrafficContext | null; +}; + +function positiveCents(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value)); +} + +function externalTrafficContextForReferral(referralAgent: string): ProposalFeeTrafficContext { + return { + actor_type: "AGENT", + actor_id: asA2aAgentActorId(referralAgent), + request_id: `admin-wallet-${Date.now()}`, + source_ip: "admin-verification", + user_agent: "vibework-admin-wallet-verifier", + is_public_ip: false, + request_actor_headers: {}, + }; +} + +export async function recordDemandProposalFeeCaptured(input: ProposalFeeCaptureInput) { + const proposalFeeCents = positiveCents(input.proposalFeeCents); + const affiliateFeeCents = input.referralAgent ? Math.floor(proposalFeeCents * 0.1) : 0; + const responseStatus = input.responseStatus || (input.paymentProvider === "wallet" ? "verified" : "captured"); + const trafficContext = + input.referralAgent && input.paymentProvider === "wallet" && !input.trafficContext + ? externalTrafficContextForReferral(input.referralAgent) + : input.trafficContext; + + const task = await prisma.task.findUnique({ + where: { id: input.taskId }, + select: { id: true }, + }); + + if (!task) { + throw new Error(`Proposal task not found: ${input.taskId}`); + } + + const existingCapture = await prisma.ledgerEntry.findUnique({ + where: { idempotency_key: input.idempotencyKey }, + }); + + if (existingCapture) { + return { + task_id: input.taskId, + duplicate: true, + ledger_entry_id: existingCapture.id, + affiliate_fee_cents: affiliateFeeCents, + }; + } + + const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const ledger = await tx.ledgerEntry.create({ + data: { + task_id: input.taskId, + phase: "proposal_fee_captured", + idempotency_key: input.idempotencyKey, + stripe_object_id: input.paymentReference, + response_status: responseStatus, + http_status: 200, + }, + }); + + await tx.task.update({ + where: { id: input.taskId }, + data: { + stripe_payment_intent_id: input.paymentReference, + status: TaskStatus.DRAFT, + }, + }); + + if (input.referralAgent) { + await tx.agentProfile.upsert({ + where: { agent_id: input.referralAgent }, + update: { + discovery_source: "DEMAND_PROPOSAL_PAID_REFERRAL", + }, + create: { + agent_id: input.referralAgent, + type: "SCOUT", + status: "PENDING", + discovery_source: "DEMAND_PROPOSAL_PAID_REFERRAL", + capabilities: { + growth_referral: true, + source: input.source || input.paymentProvider, + }, + }, + }); + + await tx.scoutReputation.upsert({ + where: { scout_id: input.referralAgent }, + update: { + successful_conversions: { increment: 1 }, + }, + create: { + scout_id: input.referralAgent, + successful_conversions: 1, + }, + }); + + const existingAffiliate = await tx.affiliateLedger.findFirst({ + where: { + scout_id: input.referralAgent, + task_id: input.taskId, + }, + }); + + if (!existingAffiliate && affiliateFeeCents > 0) { + await tx.affiliateLedger.create({ + data: { + scout_id: input.referralAgent, + task_id: input.taskId, + amount: affiliateFeeCents, + currency: input.paymentProvider === "wallet" ? "USDC" : "USD", + status: "PENDING", + }, + }); + } + } + + await tx.auditEvent.create({ + data: { + actorType: "SYSTEM", + actorId: input.paymentProvider === "wallet" ? "wallet-verifier" : "stripe-webhook", + action: "DEMAND_PROPOSAL_FEE_CAPTURED", + entityType: "TASK", + entityId: input.taskId, + metadata: { + payment_provider: input.paymentProvider, + payment_reference: input.paymentReference, + fee_cents: proposalFeeCents, + currency: input.paymentProvider === "wallet" ? "USDC" : "USD", + package_id: input.packageId || null, + referral_agent: input.referralAgent || null, + affiliate_fee_cents: affiliateFeeCents, + source: input.source || null, + campaign: input.campaign || null, + verification_note: input.verificationNote || null, + }, + }, + }); + + if (input.referralAgent && trafficContext) { + await tx.auditEvent.create({ + data: { + actorType: trafficContext.actor_type, + actorId: trafficContext.actor_id, + action: "EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED", + entityType: "TASK", + entityId: input.taskId, + reason: "referred_demand_proposal_payment_captured", + metadata: { + payment_provider: input.paymentProvider, + payment_reference: input.paymentReference, + fee_cents: proposalFeeCents, + currency: input.paymentProvider === "wallet" ? "USDC" : "USD", + package_id: input.packageId || null, + referral_agent: input.referralAgent, + affiliate_fee_cents: affiliateFeeCents, + source: input.source || null, + campaign: input.campaign || null, + verification_note: input.verificationNote || null, + request_id: trafficContext.request_id, + source_ip: trafficContext.source_ip, + user_agent: trafficContext.user_agent, + is_public_ip: trafficContext.is_public_ip, + surface: input.paymentProvider === "wallet" ? "admin/wallet-verification" : "stripe/webhook", + request_actor_headers: trafficContext.request_actor_headers || {}, + response_status: 200, + response_summary: "demand_proposal_fee_captured", + }, + }, + }); + } + + return ledger; + }); + + return { + task_id: input.taskId, + duplicate: false, + ledger_entry_id: result.id, + affiliate_fee_cents: affiliateFeeCents, + }; +} diff --git a/apps/web/src/lib/treasury-snapshot.ts b/apps/web/src/lib/treasury-snapshot.ts new file mode 100644 index 0000000..f06a2d2 --- /dev/null +++ b/apps/web/src/lib/treasury-snapshot.ts @@ -0,0 +1,158 @@ +import { prisma } from "@/lib/prisma"; + +type Bucket = { + usdc: number; + fiat: number; +}; + +function emptyBucket(): Bucket { + return { usdc: 0, fiat: 0 }; +} + +function asRecord(value: unknown) { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function numberValue(value: unknown) { + const parsed = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10); + return Number.isFinite(parsed) ? Math.floor(parsed) : 0; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function bucketKeyFromMetadata(metadata: Record): keyof Bucket { + const currency = stringValue(metadata.currency).toUpperCase(); + const paymentProvider = stringValue(metadata.payment_provider).toLowerCase(); + const withdrawalType = stringValue(metadata.type).toUpperCase(); + if (currency === "USDC" || paymentProvider === "wallet" || withdrawalType === "CRYPTO") { + return "usdc"; + } + return "fiat"; +} + +function addToBucket(bucket: Bucket, key: keyof Bucket, amount: number) { + bucket[key] += amount; +} + +function subtractBucket(left: Bucket, right: Bucket): Bucket { + return { + usdc: left.usdc - right.usdc, + fiat: left.fiat - right.fiat, + }; +} + +export async function getTreasurySnapshot() { + const [ + allTasks, + completedTasks, + arbitrationCount, + proposalFeeEvents, + withdrawalEvents, + recentAuditEvents, + ] = await Promise.all([ + prisma.task.findMany({ + select: { reward_amount: true, reward_currency: true }, + }), + prisma.task.findMany({ + where: { status: "COMPLETED" }, + select: { reward_amount: true, reward_currency: true }, + }), + prisma.arbitration.count(), + prisma.auditEvent.findMany({ + where: { action: "DEMAND_PROPOSAL_FEE_CAPTURED" }, + select: { id: true, entityId: true, metadata: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.auditEvent.findMany({ + where: { action: "TREASURY_WITHDRAWAL" }, + select: { metadata: true }, + }), + prisma.auditEvent.findMany({ + where: { + action: { + in: [ + "DEMAND_PROPOSAL_FEE_CAPTURED", + "TREASURY_WITHDRAWAL", + "TREASURY_WITHDRAWAL_REQUESTED", + ], + }, + }, + select: { id: true, action: true, entityId: true, metadata: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: 20, + }), + ]); + + const gmv = emptyBucket(); + const taskPlatformFees = emptyBucket(); + const proposalFeeRevenue = emptyBucket(); + const arbitrationRevenue = emptyBucket(); + const withdrawn = emptyBucket(); + + for (const task of allTasks) { + addToBucket(gmv, task.reward_currency === "USDC" ? "usdc" : "fiat", task.reward_amount); + } + + for (const task of completedTasks) { + addToBucket(taskPlatformFees, task.reward_currency === "USDC" ? "usdc" : "fiat", Math.floor(task.reward_amount * 0.05)); + } + + addToBucket(arbitrationRevenue, "usdc", arbitrationCount * 5000); + + for (const event of proposalFeeEvents) { + const metadata = asRecord(event.metadata); + const amount = numberValue(metadata.fee_cents); + if (amount <= 0) continue; + addToBucket(proposalFeeRevenue, bucketKeyFromMetadata(metadata), amount); + } + + for (const event of withdrawalEvents) { + const metadata = asRecord(event.metadata); + const amount = numberValue(metadata.amount); + if (amount <= 0) continue; + addToBucket(withdrawn, bucketKeyFromMetadata(metadata), amount); + } + + const revenue = { + usdc: taskPlatformFees.usdc + proposalFeeRevenue.usdc + arbitrationRevenue.usdc, + fiat: taskPlatformFees.fiat + proposalFeeRevenue.fiat + arbitrationRevenue.fiat, + }; + + const recentTransactions = recentAuditEvents.map((event) => { + const metadata = asRecord(event.metadata); + const isOutflow = event.action.startsWith("TREASURY_WITHDRAWAL"); + const amount = numberValue(metadata.amount) || numberValue(metadata.fee_cents); + const currency = bucketKeyFromMetadata(metadata) === "usdc" ? "USDC" : "USD"; + const provider = stringValue(metadata.payment_provider); + const reference = stringValue(metadata.payment_reference) || stringValue(metadata.txHash) || stringValue(metadata.destination); + const source = + event.action === "DEMAND_PROPOSAL_FEE_CAPTURED" + ? `Proposal ${event.entityId}` + : reference || "Treasury"; + + return { + id: event.id, + type: event.action === "DEMAND_PROPOSAL_FEE_CAPTURED" + ? `${provider || "proposal"}_proposal_fee` + : event.action.toLowerCase(), + source, + amount, + currency, + direction: isOutflow ? "out" : "in", + date: event.createdAt.toISOString(), + }; + }); + + return { + gmv, + revenue, + withdrawn, + available: subtractBucket(revenue, withdrawn), + proposal_fee_revenue: proposalFeeRevenue, + task_platform_fee_revenue: taskPlatformFees, + arbitration_fee_revenue: arbitrationRevenue, + recent_transactions: recentTransactions, + }; +}