{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "withdraw", "type": "registry:block", "title": "Withdraw block", "description": "Fiat out to your own verified bank account, rendered truthfully: destination picker that disables unverified accounts with the reason, a fee quote in the unit you typed, four-eyes approval that renders the rule instead of the error, deposit instructions with the mandatory reference, and an event timeline with actors and absolute timestamps.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/money", "@venlyfinance/data-table", "@venlyfinance/status-pill", "@venlyfinance/timeline", "@venlyfinance/field-list", "@venlyfinance/arithmetic-ladder", "@venlyfinance/bank-accounts" ], "files": [ { "path": "registry/blocks/withdraw.tsx", "type": "registry:component", "target": "~/components/venly/blocks/withdraw.tsx", "content": "import { useMemo, useState, type CSSProperties, type ReactElement } from \"react\";\nimport type { FundflowComponents, RampRequest } from \"@venlyfinance/sdk\";\nimport {\n describeRampStatus,\n useCompanyBankAccounts,\n useCreateRampRequest,\n useFeeQuote,\n useFourEyesApproval,\n useInitiateRamp,\n useRampPairs,\n useRampRequest,\n useRampRequests,\n useReferenceData,\n useWallets,\n} from \"@venlyfinance/react\";\nimport { Money, formatAmount } from \"../lib/money.js\";\nimport { DataTable, RowText, type DataTableColumn } from \"../components/data-table.js\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\nimport { Timeline, type TimelineStep } from \"../components/timeline.js\";\nimport { FieldList } from \"../components/field-list.js\";\nimport { ArithmeticLadder } from \"../components/arithmetic-ladder.js\";\nimport { BANK_ACCOUNT_STATUS_PILL, type CompanyBankAccountListItem } from \"./bank-accounts.js\";\n\n/**\n * Withdraw block – fiat out to the company's own verified bank account.\n *\n * What the API models, and therefore what this flow renders: a withdrawal\n * is a ramp request. It is created in crypto units (the amount you send),\n * needs a second person's approval (four-eyes: the creator can never\n * approve their own request), then waits for you to send the crypto to a\n * deposit wallet and report the transaction hash, then processes. The fiat\n * figures – gross, fee, net, rate – exist on the CREATED record; the\n * pre-create review shows only what is known (amount, fee quote,\n * destination) and never invents a rate or an arrival time.\n *\n * Design contract encoded by this block:\n * - Destination is one of YOUR whitelisted, verified bank accounts;\n * unverified rows are disabled with the reason, never hidden.\n * - The fee quote renders with its percentage AND its unit (the entered\n * asset) – the quote is computed from the amount you typed.\n * - Approval actions render the rule, not the error: the creator sees why\n * they can't approve; a stale decision (someone acted first) re-fetches\n * and asks the operator to re-decide, never auto-retries.\n * - Every status renders word + glyph; a refusal never reads as a wait.\n * - The event timeline carries actor, role and absolute timestamps -\n * this rail's records actually have history, so the UI shows it.\n */\n\ntype fundflow = FundflowComponents[\"schemas\"];\ntype RampListItem = fundflow[\"RampRequestListItem\"];\n\n// ── Status label map (NAV vocabulary, applied once) ────────────────────\n\nexport const WITHDRAW_STATUS_PILL: Record = {\n AWAITING_APPROVAL: { label: \"Awaiting approval\", intent: \"pending\" },\n AWAITING_FUNDS: { label: \"Approved · awaiting funds\", intent: \"pending\" },\n PROCESSING: { label: \"Processing\", intent: \"active\" },\n SUCCEEDED: { label: \"Paid out\", intent: \"positive\" },\n FAILED: { label: \"Failed\", intent: \"negative\" },\n REJECTED: { label: \"Rejected\", intent: \"negative\" },\n CANCELLED: { label: \"Cancelled\", intent: \"neutral\" },\n BLOCKED: { label: \"On hold · contact support\", intent: \"neutral\" },\n DENIED: { label: \"Declined · contact support\", intent: \"negative\" },\n};\n\nconst PENDING_FAMILY = new Set([\"AWAITING_APPROVAL\", \"AWAITING_FUNDS\", \"PROCESSING\", \"BLOCKED\"]);\n\n/** Pending family above terminal – a pending debit never sits beside a settled one. */\nexport function withdrawalGroups(items: RampListItem[]): { key: string; label: string; rows: RampListItem[]; attention?: boolean }[] {\n // Three bands: a failed or refused movement must never sit under a\n // header that asserts success.\n const pending = items.filter((i) => PENDING_FAMILY.has(i.status ?? \"\"));\n const completed = items.filter((i) => i.status === \"SUCCEEDED\");\n const incomplete = items.filter(\n (i) => !PENDING_FAMILY.has(i.status ?? \"\") && i.status !== \"SUCCEEDED\",\n );\n return [\n { key: \"pending\", label: \"In progress\", rows: pending, attention: pending.length > 0 },\n { key: \"completed\", label: \"Completed\", rows: completed },\n { key: \"incomplete\", label: \"Didn't complete\", rows: incomplete },\n ];\n}\n\n// ── Shared styling ─────────────────────────────────────────────────────\n\nconst primaryButton: CSSProperties = {\n border: \"none\",\n background: \"var(--accent)\",\n color: \"var(--accent-fg)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n fontWeight: 500,\n cursor: \"pointer\",\n};\n\nconst quietButton: CSSProperties = {\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n background: \"var(--surface-raised)\",\n color: \"var(--text-primary)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n cursor: \"pointer\",\n};\n\nconst inputStyle: CSSProperties = {\n width: \"100%\",\n boxSizing: \"border-box\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-md)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n};\n\nconst cardStyle: CSSProperties = {\n background: \"var(--surface-raised)\",\n border: \"var(--border-w-hairline) solid var(--border-hairline)\",\n borderRadius: \"var(--radius-card)\",\n padding: \"var(--card-pad)\",\n};\n\nconst sectionHeading: CSSProperties = {\n margin: 0,\n fontSize: \"var(--font-size-label)\",\n fontWeight: 600,\n color: \"var(--text-secondary)\",\n textTransform: \"uppercase\",\n letterSpacing: \"0.04em\",\n};\n\n// ── Withdrawals table ──────────────────────────────────────────────────\n\nexport interface WithdrawalsTableProps {\n items: RampListItem[];\n onOpen?: (item: RampListItem) => void;\n selectedId?: string;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function WithdrawalsTable({ items, onOpen, selectedId, style, className }: WithdrawalsTableProps): ReactElement {\n const columns: DataTableColumn[] = [\n {\n key: \"request\",\n header: \"Withdrawal\",\n cell: (r) => (\n \n ),\n },\n {\n key: \"status\",\n header: \"Status\",\n cell: (r) => {\n const pill = WITHDRAW_STATUS_PILL[r.status ?? \"\"];\n return pill ? : ;\n },\n },\n {\n key: \"created\",\n header: \"Created\",\n cell: (r) => (\n \n {r.createdAt ? new Date(r.createdAt).toISOString().slice(0, 10) : \"—\"}\n \n ),\n },\n {\n key: \"amount\",\n header: \"Amount\",\n money: true,\n cell: (r) => ,\n },\n ];\n\n return (\n r.id ?? \"\"}\n onRowClick={onOpen}\n selectedKey={selectedId}\n emptyMessage=\"No withdrawals yet.\"\n />\n );\n}\n\n// ── Destination picker ─────────────────────────────────────────────────\n\nexport interface DestinationPickerProps {\n accounts: CompanyBankAccountListItem[];\n onSelect: (account: CompanyBankAccountListItem) => void;\n onGoToBankAccounts?: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function DestinationPicker({ accounts, onSelect, onGoToBankAccounts, style, className }: DestinationPickerProps): ReactElement {\n const verified = accounts.filter((a) => a.verificationStatus === \"VERIFIED\");\n\n if (verified.length === 0) {\n return (\n
\n {/* Copy-owned: \"Settings\" names this app's bank-accounts page - rename it to match yours. */}\n

\n You need a verified bank account before you can withdraw. Add one in Settings.\n

\n {onGoToBankAccounts ? (\n \n ) : null}\n
\n );\n }\n\n return (\n
\n

Withdraw to

\n {accounts.map((a) => {\n const usable = a.verificationStatus === \"VERIFIED\";\n const reason =\n a.verificationStatus === \"PENDING\"\n ? \"In review – available once verified.\"\n : a.verificationStatus === \"DENIED\"\n ? \"Declined – this account can't receive withdrawals. Add a different account or contact support for details.\"\n : null;\n return (\n usable && onSelect(a)}\n style={{\n ...cardStyle,\n textAlign: \"left\",\n cursor: usable ? \"pointer\" : \"not-allowed\",\n opacity: usable ? 1 : 0.75,\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: \"var(--space-lg)\",\n fontFamily: \"var(--font-family)\",\n }}\n >\n \n \n {a.name}\n \n \n {a.bankName} · {a.bankCountry}\n \n {reason ? (\n {reason}\n ) : null}\n \n {(() => {\n const pill = BANK_ACCOUNT_STATUS_PILL[a.verificationStatus ?? \"\"];\n return pill ? : null;\n })()}\n \n );\n })}\n
\n );\n}\n\n// ── Amount + quote step ────────────────────────────────────────────────\n\n/** Amount input guard: \"\" and \"Infinity\" must not stage. */\nexport function parseWithdrawAmount(raw: string): number | null {\n if (raw.trim() === \"\") return null;\n const value = Number(raw);\n return Number.isFinite(value) && value > 0 ? value : null;\n}\n\ninterface StagedWithdrawal {\n destination: CompanyBankAccountListItem;\n amount: number;\n cryptoCurrency: string;\n cryptoCurrencyId: string;\n fiatCurrency: string;\n fiatCurrencyId: string;\n feeAmount?: number;\n feePercentage?: number;\n}\n\nfunction AmountStep({\n accountId,\n destination,\n onStaged,\n onBack,\n}: {\n accountId?: string;\n destination: CompanyBankAccountListItem;\n onStaged: (staged: StagedWithdrawal) => void;\n onBack: () => void;\n}): ReactElement {\n const { data: pairs } = useRampPairs(\"off\");\n const { data: reference } = useReferenceData();\n const { data: wallets } = useWallets(accountId);\n const [raw, setRaw] = useState(\"\");\n const [pairIndex, setPairIndex] = useState(0);\n\n const pair = (pairs ?? [])[pairIndex];\n const cryptoCode = pair?.from?.currency ?? \"\";\n const fiatCode = pair?.to?.currency ?? \"\";\n const amount = parseWithdrawAmount(raw);\n const { data: quote } = useFeeQuote(amount ? { amount, type: \"OFF_RAMP\" } : undefined);\n\n const available = useMemo(() => {\n let sum = 0;\n for (const wallet of wallets?.items ?? []) {\n for (const balance of wallet.balances ?? []) {\n if (balance.asset === cryptoCode) sum += Number(balance.amount?.available ?? 0);\n }\n }\n return sum;\n }, [wallets, cryptoCode]);\n const overBalance = accountId !== undefined && amount !== null && amount > available;\n\n const cryptoId = (reference?.cryptoCurrencies ?? []).find((c) => c.currency === cryptoCode)?.id;\n const fiatId = (reference?.fiatCurrencies ?? []).find((c) => c.currency === fiatCode)?.id;\n const ready = amount !== null && !overBalance && cryptoId && fiatId;\n\n return (\n {\n e.preventDefault();\n if (!ready) return;\n onStaged({\n destination,\n amount: amount as number,\n cryptoCurrency: cryptoCode,\n cryptoCurrencyId: cryptoId as string,\n fiatCurrency: fiatCode,\n fiatCurrencyId: fiatId as string,\n feeAmount: quote?.amount,\n feePercentage: quote?.percentage,\n });\n }}\n >\n

Amount

\n
\n \n You send\n \n {/* Amount + asset are ONE field: selector inside the right end. */}\n
\n setRaw(e.target.value)}\n />\n setPairIndex(Number(e.target.value))}\n >\n {(pairs ?? []).map((p, i) => (\n \n ))}\n \n
\n {/* The unit trap this flow must never spring: the amount is CRYPTO\n units, and 1,000 USDC is not €1,000. Said here, at the field. */}\n {cryptoCode && fiatCode ? (\n

\n This amount is in {cryptoCode}, the asset you send. Your bank receives {fiatCode} – the exact amount is confirmed on creation.\n

\n ) : null}\n {accountId !== undefined ? (\n

\n Available: {formatAmount(available)} {cryptoCode}\n

\n ) : null}\n {overBalance ? (\n

\n That's more than your available balance.\n

\n ) : null}\n
\n\n {amount !== null && quote ? (\n

\n Fee ({quote.percentage ?? 0}%) − {formatAmount(quote.amount ?? 0)} {cryptoCode}\n

\n ) : null}\n\n
\n \n \n
\n \n );\n}\n\n// ── Review (pre-create: the KNOWN figures only) ────────────────────────\n\nexport function WithdrawReview({\n staged,\n submitting,\n onConfirm,\n onEdit,\n style,\n className,\n}: {\n staged: StagedWithdrawal;\n submitting?: boolean;\n onConfirm: () => void;\n onEdit: () => void;\n style?: CSSProperties;\n className?: string;\n}): ReactElement {\n return (\n
\n

Review

\n
\n \n
\n {/* The bank-receives figure is not known before creation: the created\n request carries the fiat amounts and rate, and the detail opens on\n them. Nothing is rendered in its place. */}\n
\n \n \n
\n
\n );\n}\n\n// ── Detail (the created record: real figures, approvals, instructions) ─\n\nfunction eventsToTimeline(events: fundflow[\"RampRequestEventDto\"][] | undefined): TimelineStep[] {\n return (events ?? []).map((event, index, all) => ({\n key: event.id ?? String(index),\n label: (event.eventType ?? \"\").replaceAll(\"_\", \" \").toLowerCase().replace(/^./, (c) => c.toUpperCase()),\n meta: `${event.username ?? \"\"} (${(event.role ?? \"\").replaceAll(\"COMPANY_\", \"\").toLowerCase()}) · ${event.createdAt ?? \"\"} UTC`,\n state: index === all.length - 1 ? \"current\" : \"completed\",\n }));\n}\n\nexport interface WithdrawDetailProps {\n request: RampRequest & { createdBy?: string };\n /** The signed-in operator, for the four-eyes capability read. */\n actorId?: string;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function WithdrawDetail({ request, actorId, style, className }: WithdrawDetailProps): ReactElement {\n const approval = useFourEyesApproval(request, actorId);\n const initiate = useInitiateRamp();\n const [txHash, setTxHash] = useState(\"\");\n const descriptor = describeRampStatus(request.status);\n const pill = WITHDRAW_STATUS_PILL[request.status ?? \"\"] ?? { label: descriptor.label, intent: \"neutral\" as StatusIntent };\n const cryptoCode = request.cryptoCurrency?.currency ?? \"\";\n const fiatCode = request.fiatCurrency?.currency ?? \"\";\n\n return (\n
\n
\n

\n Withdrawal {request.paymentReference ?? \"\"}\n

\n \n
\n

{descriptor.explanation}

\n\n {/* The created record carries the real arithmetic: show all of it. */}\n {request.fiatAmount !== undefined ? (\n
\n {/* The ladder chains ONLY the figures that participate in the sum;\n the rate was applied upstream of the converted amount, so it\n renders beside the ladder, never behind an operator. */}\n \n {request.exchangeRate !== undefined ? (\n

\n Rate applied: 1 {cryptoCode} = {request.exchangeRate} {fiatCode}\n

\n ) : null}\n
\n ) : null}\n\n
\n \n
\n\n {/* Four-eyes action bar: render the rule, not the error. */}\n {request.status === \"AWAITING_APPROVAL\" ? (\n
\n {approval.capability.reason === \"actor-is-creator\" ? (\n

\n Because you created this request, another team member must approve it. You can still cancel.\n

\n ) : null}\n {approval.state.phase === \"failed\" && approval.state.failure === \"stale-version\" ? (\n

\n This request changed while you were viewing it. Here's the latest version – review it and decide again.\n

\n ) : approval.state.phase === \"failed\" ? (\n

\n That decision was refused. Refresh and try from the current state.\n

\n ) : null}\n
\n {approval.capability.canApprove ? (\n \n ) : null}\n {approval.capability.canReject ? (\n \n ) : null}\n {approval.capability.canCancel ? (\n \n ) : null}\n
\n
\n ) : null}\n\n {/* The manual crypto leg: instructions + the hash report. */}\n {request.status === \"AWAITING_FUNDS\" ? (\n
\n

\n Send {formatAmount(request.cryptoAmount ?? 0)} {cryptoCode} on {request.depositWallet?.chain ?? \"\"} to this address, then report the transaction hash.\n

\n \n {\n e.preventDefault();\n if (!request.id || !txHash) return;\n initiate.mutate({ id: request.id, body: { version: request.version ?? 0, blockchainTransactionHash: txHash } });\n }}\n >\n \n setTxHash(e.target.value)} />\n \n {initiate.isError ? (\n

\n {initiate.error.message}\n

\n ) : null}\n \n
\n ) : null}\n\n {(request.events ?? []).length > 0 ? (\n
\n

History

\n \n
\n ) : null}\n
\n );\n}\n\n/** Connected detail: fetches the record + the list row that carries createdBy. */\nexport function ConnectedWithdrawDetail({ id, actorId, style, className }: { id: string; actorId?: string; style?: CSSProperties; className?: string }): ReactElement {\n const { data: request } = useRampRequest(id);\n const { data: list } = useRampRequests({ rampType: \"OFF_RAMP\" });\n if (!request) {\n return (\n

Loading withdrawal…

\n );\n }\n // createdBy exists on LIST items only; graft it for the capability read.\n const createdBy = (list?.items ?? []).find((i) => i.id === id)?.createdBy;\n return ;\n}\n\n// ── The flow ───────────────────────────────────────────────────────────\n\nexport interface WithdrawFlowProps {\n /** Finance account whose balance gates the amount step (optional). */\n accountId?: string;\n /** The signed-in operator, for four-eyes capability on the detail. */\n actorId?: string;\n onGoToBankAccounts?: () => void;\n /** Fires with the created request id (e.g. to route to a detail page). */\n onCreated?: (id: string) => void;\n style?: CSSProperties;\n className?: string;\n}\n\ntype FlowStep =\n | { step: \"pick\" }\n | { step: \"amount\"; destination: CompanyBankAccountListItem }\n | { step: \"review\"; staged: StagedWithdrawal }\n | { step: \"detail\"; id: string };\n\nexport function WithdrawFlow({ accountId, actorId, onGoToBankAccounts, onCreated, style, className }: WithdrawFlowProps): ReactElement {\n const { data: accounts } = useCompanyBankAccounts();\n const create = useCreateRampRequest();\n const [flow, setFlow] = useState({ step: \"pick\" });\n\n if (flow.step === \"detail\") {\n return ;\n }\n\n if (flow.step === \"review\") {\n return (\n setFlow({ step: \"amount\", destination: flow.staged.destination })}\n onConfirm={() => {\n create.mutate(\n {\n rampType: \"OFF_RAMP\",\n amount: flow.staged.amount,\n fiatCurrencyId: flow.staged.fiatCurrencyId,\n cryptoCurrencyId: flow.staged.cryptoCurrencyId,\n companyBankAccountId: flow.staged.destination.id,\n },\n {\n onSuccess: (request) => {\n if (request.id) {\n onCreated?.(request.id);\n setFlow({ step: \"detail\", id: request.id });\n }\n },\n },\n );\n }}\n />\n );\n }\n\n if (flow.step === \"amount\") {\n return (\n
\n setFlow({ step: \"pick\" })}\n onStaged={(staged) => setFlow({ step: \"review\", staged })}\n />\n
\n );\n }\n\n return (\n setFlow({ step: \"amount\", destination })}\n />\n );\n}\n" } ] }