feat: add affiliate payout settlement
All checks were successful
CI and Production Smoke / smoke (push) Successful in 6s
All checks were successful
CI and Production Smoke / smoke (push) Successful in 6s
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
182
apps/web/src/app/api/admin/affiliate/payouts/settle/route.ts
Normal file
182
apps/web/src/app/api/admin/affiliate/payouts/settle/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user