diff --git a/apps/web/src/app/admin/treasury/page.tsx b/apps/web/src/app/admin/treasury/page.tsx index e7f04c4..4c0c1d2 100644 --- a/apps/web/src/app/admin/treasury/page.tsx +++ b/apps/web/src/app/admin/treasury/page.tsx @@ -24,6 +24,33 @@ type TreasuryStats = { direction?: "in" | "out"; date: string; }>; + pending_wallet_receipts?: Array<{ + id: string; + task_id: string; + payment_reference: string; + raw_payment_reference: string; + payer_wallet?: string | null; + network?: string | null; + amount_cents?: number; + package_id?: string | null; + referral_agent?: string | null; + note?: string | null; + submitted_at: string; + }>; + pending_affiliate_payouts?: Array<{ + id: string; + scout_id: string; + scout_wallet?: string | null; + scout_status: string; + task_id: string; + task_title: string; + proposal_status: string; + payment_reference?: string | null; + amount_cents: number; + currency: string; + status: string; + created_at: string; + }>; }; export default function TreasuryDashboard() { @@ -41,6 +68,8 @@ export default function TreasuryDashboard() { const [verifyingWallet, setVerifyingWallet] = useState(false); const [walletMessage, setWalletMessage] = useState(""); const cents = (value: number | undefined) => value ?? 0; + const pendingWalletReceipts = stats?.pending_wallet_receipts || []; + const pendingAffiliatePayouts = stats?.pending_affiliate_payouts || []; const loadStats = useCallback(async () => { try { @@ -114,6 +143,22 @@ export default function TreasuryDashboard() { } }; + const selectWalletReceipt = (receipt: NonNullable[number]) => { + setWalletTaskId(receipt.task_id); + setWalletReference(receipt.raw_payment_reference || receipt.payment_reference.replace(/^wallet:/, "")); + setWalletAmountUsd(receipt.amount_cents ? (receipt.amount_cents / 100).toFixed(2) : ""); + setWalletNote( + [ + receipt.network ? `network=${receipt.network}` : "", + receipt.payer_wallet ? `payer=${receipt.payer_wallet}` : "", + receipt.note ? `note=${receipt.note}` : "", + ] + .filter(Boolean) + .join("; ") + ); + setWalletMessage("Pending receipt loaded. Verify only after checking the transaction on-chain."); + }; + const handleWithdraw = async () => { if (!stats) { setMessage("⚠️ Treasury stats are unavailable."); @@ -246,6 +291,40 @@ export default function TreasuryDashboard() { Verify USDC Proposal Receipt +
+
+

Pending wallet receipts

+ + {pendingWalletReceipts.length} + +
+
+ {pendingWalletReceipts.map((receipt) => ( + + ))} + {pendingWalletReceipts.length === 0 ? ( +
+ No submitted wallet receipts are waiting for verification. +
+ ) : null} +
+
+
@@ -360,6 +439,33 @@ export default function TreasuryDashboard() { {/* Activity Feed */}
+

Pending Affiliate Payouts

+
+ {pendingAffiliatePayouts.map((row) => ( +
+
+
+

{row.scout_id}

+

{row.task_title}

+
+

+ ${(row.amount_cents / 100).toFixed(2)} {row.currency} +

+
+
+ {row.scout_status} + {row.proposal_status} + {row.scout_wallet ? "wallet bound" : "wallet missing"} +
+
+ ))} + {pendingAffiliatePayouts.length === 0 ? ( +
+ No pending affiliate payouts. +
+ ) : 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 5eaf120..8ca421f 100644 --- a/apps/web/src/app/api/a2a/referrals/status/route.ts +++ b/apps/web/src/app/api/a2a/referrals/status/route.ts @@ -14,6 +14,7 @@ const TRACKED_REFERRAL_ACTIONS = [ "EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED", "EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED", "EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING", + "EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED", "EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED", ] as const; @@ -248,6 +249,7 @@ export async function GET(request: NextRequest) { proposal_created_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED || 0, proposal_checkout_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED || 0, proposal_wallet_pending_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING || 0, + proposal_wallet_receipt_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED || 0, proposal_paid_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED || 0, latest_event_at: latestTrafficAt ? latestTrafficAt.toISOString() : null, }, diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index bae95b0..46d5160 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -443,6 +443,7 @@ export async function GET(request: NextRequest) { const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalWalletReceiptEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED"] || 0; const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; @@ -467,6 +468,7 @@ export async function GET(request: NextRequest) { proposal_created_events: proposalCreatedEvents, proposal_checkout_events: proposalCheckoutEvents, proposal_wallet_pending_events: proposalWalletPendingEvents, + proposal_wallet_receipt_events: proposalWalletReceiptEvents, proposal_paid_events: proposalPaidEvents, claim_events: claimEvents, submit_events: submitEvents, diff --git a/apps/web/src/app/propose/success/page.tsx b/apps/web/src/app/propose/success/page.tsx index fec6f41..b3cf1c4 100644 --- a/apps/web/src/app/propose/success/page.tsx +++ b/apps/web/src/app/propose/success/page.tsx @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { submitWalletReceipt } from "@/app/propose/wallet/actions"; import { getProposalPackage, TREASURY_USDC_ADDRESS, @@ -25,6 +26,7 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara const params = searchParams ? await searchParams : {}; const taskId = getParam(params, "task_id"); const payment = getParam(params, "payment") || "stripe"; + const receiptStatus = getParam(params, "receipt"); const proposalPackage = getProposalPackage(getParam(params, "package")); const task = taskId ? await prisma.task.findUnique({ @@ -40,8 +42,34 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara }, }) : null; + const latestWalletReceipt = taskId + ? await prisma.auditEvent.findFirst({ + where: { + action: "DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED", + entityType: "TASK", + entityId: taskId, + }, + orderBy: { createdAt: "desc" }, + select: { + metadata: true, + createdAt: true, + }, + }) + : null; const stripeCaptured = payment === "stripe" && Boolean(task?.stripe_payment_intent_id); const walletCaptured = payment === "wallet" && Boolean(task?.stripe_payment_intent_id?.startsWith("wallet:")); + const receiptMetadata = + typeof latestWalletReceipt?.metadata === "object" && + latestWalletReceipt.metadata !== null && + !Array.isArray(latestWalletReceipt.metadata) + ? (latestWalletReceipt.metadata as Record) + : {}; + const latestReceiptReference = + typeof receiptMetadata.raw_payment_reference === "string" + ? receiptMetadata.raw_payment_reference + : typeof receiptMetadata.payment_reference === "string" + ? receiptMetadata.payment_reference.replace(/^wallet:/, "") + : ""; return (
@@ -138,6 +166,76 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara 轉帳備註請包含 Proposal ID;入帳確認前 referral ledger 不會進入可出金狀態。
) : null} + + {!walletCaptured ? ( +
+

提交付款 receipt

+

+ 送出 tx hash 後會進入人工確認;確認前不會計入 paid conversion,也不會建立可出金 referral ledger。 +

+ + {receiptStatus === "submitted" || latestWalletReceipt ? ( +
+ 已收到 wallet receipt + {latestReceiptReference ? `:${latestReceiptReference}` : ""}。目前狀態:等待 VibeWork 人工確認。 +
+ ) : null} + + {receiptStatus === "already_verified" ? ( +
+ 此 wallet receipt 已經確認過,請勿重複轉帳。 +
+ ) : null} + +
+ + + +
+ + +
+ + +
+
+ ) : null}
) : null} diff --git a/apps/web/src/app/propose/wallet/actions.ts b/apps/web/src/app/propose/wallet/actions.ts new file mode 100644 index 0000000..a39b720 --- /dev/null +++ b/apps/web/src/app/propose/wallet/actions.ts @@ -0,0 +1,167 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; +import { sanitizeAgentId } from "@/lib/a2a-growth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +function getString(formData: FormData, key: string) { + const value = formData.get(key); + return typeof value === "string" ? value.trim() : ""; +} + +function normalizePaymentReference(value: string) { + return value.trim().replace(/\s+/g, "").slice(0, 180); +} + +function cleanText(value: string, maxLength: number) { + return value.replace(/\r/g, "").replace(/[^\S\n]+/g, " ").trim().slice(0, maxLength); +} + +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 parseAmountCents(value: string) { + const normalized = value.replace(/[,$]/g, "").trim(); + if (!normalized) return 0; + const amount = Number.parseFloat(normalized); + if (!Number.isFinite(amount) || amount <= 0) return 0; + return Math.round(amount * 100); +} + +function asRecord(value: unknown) { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function buildWalletSuccessUrl(taskId: string, packageId: string, receipt: string) { + const params = new URLSearchParams({ + task_id: taskId, + payment: "wallet", + receipt, + }); + if (packageId) params.set("package", packageId); + return `/propose/success?${params.toString()}`; +} + +export async function submitWalletReceipt(formData: FormData) { + const requestHeaders = await headers(); + const taskId = getString(formData, "taskId"); + const paymentReference = normalizePaymentReference(getString(formData, "paymentReference")); + const payerWallet = cleanText(getString(formData, "payerWallet"), 180); + const network = cleanText(getString(formData, "network"), 80); + const amountCents = parseAmountCents(getString(formData, "amountUsdc")); + const note = cleanText(getString(formData, "note"), 500); + + if (!taskId) { + throw new Error("缺少 Proposal ID。"); + } + + if (!paymentReference || paymentReference.length < 8) { + throw new Error("請提供有效的 tx hash 或付款 reference。"); + } + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { + id: true, + referred_by_agent: true, + scout_id: true, + stripe_payment_intent_id: true, + }, + }); + + if (!task) { + throw new Error("找不到 proposal draft。"); + } + + 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 packageId = typeof metadata.package_id === "string" ? metadata.package_id : ""; + const proposalFeeCents = positiveInteger(metadata.proposal_fee_cents); + const referralAgent = sanitizeAgentId( + task.referred_by_agent || task.scout_id || (typeof metadata.referral_agent === "string" ? metadata.referral_agent : "") + ); + const source = typeof metadata.source === "string" ? metadata.source : "wallet-receipt"; + const campaign = typeof metadata.campaign === "string" ? metadata.campaign : null; + const normalizedAmountCents = proposalFeeCents || amountCents; + const idempotencyKey = `proposal-wallet-receipt:${paymentReference}`; + const capturedReference = `wallet:${paymentReference}`; + const existingCapture = await prisma.ledgerEntry.findFirst({ + where: { + OR: [ + { idempotency_key: `proposal-wallet-fee:${paymentReference}` }, + { stripe_object_id: capturedReference }, + ], + }, + select: { id: true }, + }); + + await prisma.auditEvent.create({ + data: { + actorType: "USER", + actorId: "wallet-proposer", + action: "DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED", + entityType: "TASK", + entityId: task.id, + metadata: { + payment_provider: "wallet", + payment_reference: capturedReference, + raw_payment_reference: paymentReference, + payer_wallet: payerWallet || null, + network: network || null, + amount_cents: normalizedAmountCents || null, + expected_fee_cents: proposalFeeCents || null, + package_id: packageId || null, + referral_agent: referralAgent || null, + source, + campaign, + note: note || null, + idempotency_key: idempotencyKey, + already_verified: Boolean(existingCapture), + }, + }, + }); + + if (referralAgent) { + await logA2aTrafficEvent({ + headers: requestHeaders, + fallbackAgentId: referralAgent, + action: "EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED", + surface: "propose/wallet-receipt", + entityType: "TASK", + entityId: task.id, + reason: "wallet_receipt_submitted_by_referred_demand_proposer", + metadata: { + payment_provider: "wallet", + payment_reference: capturedReference, + amount_cents: normalizedAmountCents || null, + expected_fee_cents: proposalFeeCents || null, + package_id: packageId || null, + referral_agent: referralAgent, + source, + campaign, + response_status: 202, + response_summary: existingCapture + ? "wallet_receipt_already_verified" + : "wallet_receipt_pending_manual_verification", + }, + }); + } + + redirect(buildWalletSuccessUrl(task.id, packageId, existingCapture ? "already_verified" : "submitted")); +} diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx index 1845472..7fc91f7 100644 --- a/apps/web/src/app/traffic/page.tsx +++ b/apps/web/src/app/traffic/page.tsx @@ -26,6 +26,7 @@ const EVENT_LABELS: Record = { EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED: "外部導流需求方建立提案", EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED: "外部導流需求方開始線上結帳", EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING: "外部導流需求方取得錢包付款指示", + EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED: "外部導流需求方提交錢包 receipt", EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED: "外部導流提案費付款成功", EXTERNAL_LIST_OPEN_TASKS_SURGE: "外部公開流量突增告警", EXTERNAL_LIST_OPEN_TASKS_MCP: "外部整合入口查看可接需求", @@ -347,6 +348,7 @@ async function getTrafficSummary(minutes: number) { const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalWalletReceiptEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED"] || 0; const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; @@ -366,6 +368,7 @@ async function getTrafficSummary(minutes: number) { proposal_created_events: proposalCreatedEvents, proposal_checkout_events: proposalCheckoutEvents, proposal_wallet_pending_events: proposalWalletPendingEvents, + proposal_wallet_receipt_events: proposalWalletReceiptEvents, proposal_paid_events: proposalPaidEvents, claim_events: claimEvents, submit_events: submitEvents, @@ -828,6 +831,7 @@ export default async function TrafficDashboard({
線上結帳開始{conversionSummary.proposal_checkout_events}
錢包付款待確認{conversionSummary.proposal_wallet_pending_events}
+
錢包 receipt 已提交{conversionSummary.proposal_wallet_receipt_events}
通過任務{conversionSummary.judge_pass_events}
未通過任務{conversionSummary.judge_fail_events}
已出金{conversionSummary.payout_captured}
diff --git a/apps/web/src/lib/a2a-growth.ts b/apps/web/src/lib/a2a-growth.ts index 1f4c6eb..c2d3ab0 100644 --- a/apps/web/src/lib/a2a-growth.ts +++ b/apps/web/src/lib/a2a-growth.ts @@ -236,6 +236,7 @@ export function buildAgentDemandCampaignKit(params: { "EXTERNAL_DEMAND_PROPOSAL_VIEW", "EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED", "EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED", + "EXTERNAL_DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED", "EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED", ], payout_boundaries: { diff --git a/apps/web/src/lib/treasury-snapshot.ts b/apps/web/src/lib/treasury-snapshot.ts index f06a2d2..fcec7ae 100644 --- a/apps/web/src/lib/treasury-snapshot.ts +++ b/apps/web/src/lib/treasury-snapshot.ts @@ -49,6 +49,8 @@ export async function getTreasurySnapshot() { completedTasks, arbitrationCount, proposalFeeEvents, + walletReceiptEvents, + pendingAffiliateRows, withdrawalEvents, recentAuditEvents, ] = await Promise.all([ @@ -65,6 +67,40 @@ export async function getTreasurySnapshot() { select: { id: true, entityId: true, metadata: true, createdAt: true }, orderBy: { createdAt: "desc" }, }), + prisma.auditEvent.findMany({ + where: { action: "DEMAND_PROPOSAL_WALLET_RECEIPT_SUBMITTED" }, + select: { id: true, entityId: true, metadata: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: 50, + }), + prisma.affiliateLedger.findMany({ + where: { status: "PENDING" }, + orderBy: { created_at: "desc" }, + take: 25, + select: { + id: true, + scout_id: true, + task_id: true, + amount: true, + currency: true, + status: true, + created_at: true, + updated_at: true, + scout_agent: { + select: { + wallet_address: true, + status: true, + }, + }, + task: { + select: { + title: true, + status: true, + stripe_payment_intent_id: true, + }, + }, + }, + }), prisma.auditEvent.findMany({ where: { action: "TREASURY_WITHDRAWAL" }, select: { metadata: true }, @@ -90,6 +126,11 @@ export async function getTreasurySnapshot() { const proposalFeeRevenue = emptyBucket(); const arbitrationRevenue = emptyBucket(); const withdrawn = emptyBucket(); + const capturedWalletReferences = new Set( + proposalFeeEvents + .map((event) => stringValue(asRecord(event.metadata).payment_reference)) + .filter((reference) => reference.startsWith("wallet:")) + ); for (const task of allTasks) { addToBucket(gmv, task.reward_currency === "USDC" ? "usdc" : "fiat", task.reward_amount); @@ -144,6 +185,51 @@ export async function getTreasurySnapshot() { date: event.createdAt.toISOString(), }; }); + const seenPendingWalletReceipts = new Set(); + const pendingWalletReceipts = walletReceiptEvents + .map((event) => { + const metadata = asRecord(event.metadata); + const paymentReference = stringValue(metadata.payment_reference); + const rawPaymentReference = stringValue(metadata.raw_payment_reference) || paymentReference.replace(/^wallet:/, ""); + return { + id: event.id, + task_id: event.entityId, + payment_reference: paymentReference, + raw_payment_reference: rawPaymentReference, + payer_wallet: stringValue(metadata.payer_wallet) || null, + network: stringValue(metadata.network) || null, + amount_cents: numberValue(metadata.amount_cents) || numberValue(metadata.expected_fee_cents), + package_id: stringValue(metadata.package_id) || null, + referral_agent: stringValue(metadata.referral_agent) || null, + note: stringValue(metadata.note) || null, + already_verified: capturedWalletReferences.has(paymentReference) || metadata.already_verified === true, + submitted_at: event.createdAt.toISOString(), + }; + }) + .filter((receipt) => { + if (receipt.already_verified) return false; + const receiptKey = receipt.payment_reference || `${receipt.task_id}:${receipt.raw_payment_reference}` || receipt.id; + if (seenPendingWalletReceipts.has(receiptKey)) return false; + seenPendingWalletReceipts.add(receiptKey); + return true; + }) + .slice(0, 20); + + const pendingAffiliatePayouts = pendingAffiliateRows.map((row) => ({ + id: row.id, + scout_id: row.scout_id, + scout_wallet: row.scout_agent?.wallet_address || null, + scout_status: row.scout_agent?.status || "UNKNOWN", + task_id: row.task_id, + task_title: row.task?.title || row.task_id, + proposal_status: row.task?.status || "UNKNOWN", + payment_reference: row.task?.stripe_payment_intent_id || null, + amount_cents: row.amount, + currency: row.currency, + status: row.status, + created_at: row.created_at.toISOString(), + updated_at: row.updated_at.toISOString(), + })); return { gmv, @@ -154,5 +240,7 @@ export async function getTreasurySnapshot() { task_platform_fee_revenue: taskPlatformFees, arbitration_fee_revenue: arbitrationRevenue, recent_transactions: recentTransactions, + pending_wallet_receipts: pendingWalletReceipts, + pending_affiliate_payouts: pendingAffiliatePayouts, }; }