feat: add affiliate payout settlement
All checks were successful
CI and Production Smoke / smoke (push) Successful in 6s

This commit is contained in:
OG T
2026-06-11 21:14:55 +08:00
parent 80e8f3f322
commit eea12f633f
4 changed files with 309 additions and 3 deletions

View File

@@ -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<TreasuryStats["pending_affiliate_payouts"]>[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() {
<span>{row.proposal_status}</span>
<span>{row.scout_wallet ? "wallet bound" : "wallet missing"}</span>
</div>
<button
type="button"
onClick={() => selectAffiliatePayout(row)}
className="mt-3 w-full rounded-lg border border-emerald-500/30 px-3 py-2 text-xs font-semibold text-emerald-200 hover:border-emerald-300"
>
Load payout review
</button>
</div>
))}
{pendingAffiliatePayouts.length === 0 ? (
@@ -466,6 +533,63 @@ export default function TreasuryDashboard() {
) : null}
</div>
<div className="mb-8 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<h3 className="text-sm font-semibold text-emerald-100">Affiliate payout settlement</h3>
<div className="mt-3 grid gap-3">
<input
value={affiliateLedgerId}
onChange={(e) => 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"
/>
<input
value={affiliateDestination}
onChange={(e) => 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"
/>
<input
value={affiliateReference}
onChange={(e) => 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"
/>
<input
value={affiliateNote}
onChange={(e) => 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"
/>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void handleSettleAffiliatePayout("PAID")}
disabled={Boolean(settlingAffiliate)}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-bold text-black hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{settlingAffiliate === "PAID" ? "Marking..." : "Mark Paid"}
</button>
<button
type="button"
onClick={() => void handleSettleAffiliatePayout("REFUNDED")}
disabled={Boolean(settlingAffiliate)}
className="rounded-lg border border-amber-500/40 px-3 py-2 text-xs font-bold text-amber-100 hover:border-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
>
{settlingAffiliate === "REFUNDED" ? "Saving..." : "Refund / Cancel"}
</button>
</div>
{affiliateMessage ? (
<div className={`mt-3 rounded-lg border px-3 py-2 text-xs leading-5 ${
affiliateMessage.includes("failed") || affiliateMessage.includes("required")
? "border-red-500/30 bg-red-900/30 text-red-300"
: "border-emerald-500/30 bg-emerald-900/30 text-emerald-300"
}`}>
{affiliateMessage}
</div>
) : null}
</div>
<h3 className="text-lg font-semibold text-gray-300 mb-6">Recent Cashflows</h3>
<div className="space-y-4">
{stats.recent_transactions?.map((tx, idx) => (

View File

@@ -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: {

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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.",