Claim now: a button on the account page claims a payout account's profit

For a payout account on a firm that takes claims by API (Vest), the
account page shows what it could claim right now — its whole profit above
the account size — or why not (not flat, history loading, below its
start). Pressing it asks first, then claims to the login's main account
under the lane's mutex, with the same checks and bookkeeping as the
bot's own claim at a stage's target: the account trades its next stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 19:44:10 -05:00
co-authored by Claude Opus 5.5
parent c109aff3e5
commit 71606ffc9e
4 changed files with 236 additions and 8 deletions
+104 -1
View File
@@ -83,6 +83,106 @@ function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Claim now: the account's whole profit above its size, to the firm's main
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
* claims by API. Confirmed inline, since it moves money. */
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
const [info, setInfo] = useState<Claimable | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const check = useCallback(async () => {
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
if (res.ok) setInfo(await res.json());
} catch {
/* keep the last answer */
}
}, [accountId]);
useEffect(() => {
void check();
const t = setInterval(() => { void check(); }, 10_000);
return () => clearInterval(t);
}, [check]);
async function claim(amount: number) {
setBusy(true);
setMessage(null);
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
setMessage({
ok: true,
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
});
onDone();
} catch (err) {
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
} finally {
setBusy(false);
setConfirming(false);
void check();
}
}
if (!info || (info.claimable === null && info.unsupported)) return null;
if (confirming && info.claimable !== null) {
const amount = info.claimable;
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-600">
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
</span>
<button
onClick={() => { void claim(amount); }}
disabled={busy}
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
>
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
</button>
<button
onClick={() => setConfirming(false)}
disabled={busy}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
{info.claimable !== null ? (
<button
onClick={() => { setMessage(null); setConfirming(true); }}
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
>
Claim now · ${fmt(info.claimable)}
</button>
) : (
<span
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
title={info.reason}
>
Claim now — {info.reason}
</span>
)}
</div>
);
}
/** Wipes the account's cached P&L / fund / meta rows and refetches from
* Tradovate. Confirmed inline because rows older than the report's 28-day
* window cannot come back. */
@@ -588,7 +688,10 @@ export default function AccountPage() {
☠ DEAD
</span>
)}
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
<div className="ml-auto flex items-center gap-2">
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
</div>
</div>
{isDead && (