From 9716e8fb8201ed10a7da057bd76fe5944074acc6 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Tue, 29 Sep 2026 22:43:53 +0300 Subject: [PATCH 1/6] feat(payments): run every Payments flow in demo mode, with a simulated identity check Adds the Demo switch: Payments answers from fixtures and replays the session's changes, every ramp provider completes through a stand-in checkout, amounts are prefilled, and BVNK's identity check can be simulated. Only on NEW DESIGN. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../messages/en/dashboard-payments.json | 25 + apps/sdp-web/src/app/dashboard/layout.tsx | 11 +- .../payments/payments-workspace.data.ts | 12 +- .../components/demo-provider-checkout.tsx | 50 + .../offramp-step-content.redesign.tsx | 24 + .../onramp-step-content.redesign.tsx | 48 +- .../components/ramp-onboarding-panel.tsx | 19 +- ...ansfer-instructions.redesign.unit.test.tsx | 238 +++ .../hooks/use-batch-send-wizard.redesign.ts | 8 +- .../hooks/use-counterparty-requirements.ts | 7 +- .../hooks/use-offramp-wizard.redesign.ts | 9 +- .../hooks/use-onchain-send-wizard.redesign.ts | 5 +- .../ramps/hooks/use-onramp-wizard.redesign.ts | 55 +- .../ramps/hooks/use-ramp-wizard.redesign.ts | 36 +- .../payments/ramps/offramp-rail.redesign.tsx | 7 + .../ramps/offramp-rail.redesign.unit.test.tsx | 258 +++ .../payments/ramps/onramp-rail.redesign.tsx | 48 +- .../recurring/use-recurring-payment-create.ts | 5 +- .../payment-request-create-workspace.tsx | 5 +- .../src/components/dashboard-shell.tsx | 485 +++-- .../src/components/payments-demo-notice.tsx | 28 + .../src/components/payments-demo-toggle.tsx | 85 + .../payments-demo-toggle.unit.test.tsx | 104 + .../src/lib/payments-demo/demo-cookie.ts | 27 + .../src/lib/payments-demo/demo-fixtures.ts | 1871 +++++++++++++++++ .../payments-demo/demo-fixtures.unit.test.ts | 793 +++++++ .../src/lib/payments-demo/demo-handlers.ts | 1517 +++++++++++++ .../src/lib/payments-demo/demo-mode-action.ts | 41 + .../src/lib/payments-demo/demo-mode.ts | 199 ++ .../lib/payments-demo/demo-mode.unit.test.ts | 647 ++++++ .../sdp-web/src/lib/payments-demo/demo-ops.ts | 136 ++ .../src/lib/payments-demo/demo-prefill.ts | 19 + .../src/lib/payments-demo/demo-replay.ts | 601 ++++++ .../src/lib/payments-demo/demo-session.ts | 109 + .../payments-demo/payments-demo-context.tsx | 41 + apps/sdp-web/src/lib/sdp-api.ts | 20 +- apps/sdp-web/src/proxy.ts | 27 + apps/sdp-web/src/proxy.unit.test.ts | 22 +- 38 files changed, 7353 insertions(+), 289 deletions(-) create mode 100644 apps/sdp-web/src/app/dashboard/payments/ramps/components/demo-provider-checkout.tsx create mode 100644 apps/sdp-web/src/app/dashboard/payments/ramps/components/terminal-transfer-instructions.redesign.unit.test.tsx create mode 100644 apps/sdp-web/src/app/dashboard/payments/ramps/offramp-rail.redesign.unit.test.tsx create mode 100644 apps/sdp-web/src/components/payments-demo-notice.tsx create mode 100644 apps/sdp-web/src/components/payments-demo-toggle.tsx create mode 100644 apps/sdp-web/src/components/payments-demo-toggle.unit.test.tsx create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-cookie.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-fixtures.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-handlers.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-mode.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-ops.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-prefill.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-replay.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-session.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/payments-demo-context.tsx diff --git a/apps/sdp-web/messages/en/dashboard-payments.json b/apps/sdp-web/messages/en/dashboard-payments.json index 8624b78c50..7b0a526b5d 100644 --- a/apps/sdp-web/messages/en/dashboard-payments.json +++ b/apps/sdp-web/messages/en/dashboard-payments.json @@ -1,5 +1,27 @@ { "DashboardPayments": { + "demo": { + "label": "Demo", + "turnOn": "Try Payments with sample data. Nothing you do in demo mode is sent anywhere; it stays in this browser until you reload.", + "turnOff": "Turn off demo mode", + "sandboxOnly": "Demo mode runs on sandbox projects. Switch to your sandbox project to try it.", + "notice": { + "state": "Demo mode", + "body": "Sample data for presentation. Every action is simulated." + }, + "checkout": { + "title": "{provider} checkout", + "onrampBody": "In demo mode, {provider}'s checkout doesn't open. Use Simulate deposit below to see the deposit arrive.", + "offrampBody": "In demo mode, {provider}'s page doesn't open. Send the funds with the button below to finish the payout.", + "settling": "Payment received. {provider} is delivering the funds to the wallet." + }, + "verification": { + "simulate": "Simulate verification", + "simulating": "Simulating verification", + "body": "In demo mode, {provider}'s verification page doesn't open. Use Simulate verification below to approve the contact.", + "failed": "Couldn't simulate the verification. Try again." + } + }, "signingUnavailable": "Signing is disabled for this wallet.", "restricted": "Restricted", "page": { @@ -165,6 +187,9 @@ "transferStatus": "Status: {status}", "submittingOnchainTransfer": "Submitting on-chain transfer.", "simulatingQuoteFunding": "Simulating quote funding.", + "simulateDeposit": "Simulate deposit", + "simulatingDeposit": "Simulating deposit", + "depositSimulated": "Deposit simulated", "quoteFundingSimulated": "Quote funding simulated.", "quoteSimulationFailed": "Quote simulation failed.", "sandboxSimulationFailed": "Sandbox simulation failed.", diff --git a/apps/sdp-web/src/app/dashboard/layout.tsx b/apps/sdp-web/src/app/dashboard/layout.tsx index 64572f2306..9d8de0e19b 100644 --- a/apps/sdp-web/src/app/dashboard/layout.tsx +++ b/apps/sdp-web/src/app/dashboard/layout.tsx @@ -12,6 +12,7 @@ import { getAuthEntryPath } from "@/lib/auth-entry"; import { resolveDashboardAccess } from "@/lib/dashboard-access"; import { type DashboardCacheScope, getDashboardCacheScopeKey } from "@/lib/dashboard-cache-scope"; import { resolveDashboardProjectSelection } from "@/lib/dashboard-project-selection"; +import { PAYMENTS_DEMO_COOKIE_NAME } from "@/lib/payments-demo/demo-cookie"; import { PROJECT_COOKIE_NAME } from "@/lib/project-cookie"; import { loadQuickStartStep } from "@/lib/quick-start-server"; import { getSdpAuth, listSdpProjects } from "@/lib/sdp-api"; @@ -68,7 +69,15 @@ export default async function DashboardLayout({ children }: { children: ReactNod shouldRepairInitialProjectCookie={projectSelection.shouldRepairCookie} > - {children} + + {children} + ); diff --git a/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts b/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts index fc3eb664e3..f4def7ccee 100644 --- a/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts +++ b/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts @@ -24,6 +24,7 @@ import type { RampEventProvider, RampFiatCurrency, RampProviderEstimateResult, + RampProviderId, PaymentTransferEnvelope as TransferEnvelope, PaymentTransferSummary as TransferRecord, PaymentWalletPolicy as WalletPolicy, @@ -842,7 +843,8 @@ type SandboxTransferSimulationInput = }; } | { - provider: "bvnk"; + /** BVNK's sandbox, and demo mode's stand-in checkouts for the widget providers. */ + provider: Exclude; payload: { transferId: string; }; @@ -854,6 +856,14 @@ type SandboxTransferSimulationInput = amount: number; fiatCurrency: MuralSandboxPayinCurrency; }; + } + | { + /** Demo mode only: BVNK approves the contact's identity check (Simulate verification). */ + provider: "bvnk"; + payload: { + counterpartyId: string; + verification: "approved"; + }; }; export async function simulateSandboxTransfer(input: SandboxTransferSimulationInput, t: Translate) { diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/components/demo-provider-checkout.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/components/demo-provider-checkout.tsx new file mode 100644 index 0000000000..c586f7ebd0 --- /dev/null +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/components/demo-provider-checkout.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { PaymentTransferSummary, RampDirection, RampProviderId } from "@sdp/types"; +import Image from "next/image"; +import { useTranslations } from "@/i18n/provider"; +import { getRampProviderLabel, RAMP_PROVIDER_LOGOS } from "@/lib/ramps"; + +/** + * Demo mode's stand-in for a provider's own checkout (MoonPay's and Coinbase's pages, Stripe's + * and MoneyGram's widgets), which can't open on sample data. A deposit is paid with the footer's + * Simulate deposit; a payout's crypto is sent with its Send button, as for a bank payout. Either + * way the transfer then settles and the flow finishes on its own completion screen. + */ +export function DemoProviderCheckout({ + direction, + provider, + transfer, +}: { + direction: RampDirection; + provider: RampProviderId; + transfer: PaymentTransferSummary | undefined; +}) { + const t = useTranslations(); + const name = getRampProviderLabel(provider); + const settling = transfer !== undefined && transfer.status !== "awaiting_payment"; + const body = + direction === "offramp" + ? t("DashboardPayments.demo.checkout.offrampBody", { provider: name }) + : settling + ? t("DashboardPayments.demo.checkout.settling", { provider: name }) + : t("DashboardPayments.demo.checkout.onrampBody", { provider: name }); + + return ( +
+
+ +

+ {t("DashboardPayments.demo.checkout.title", { provider: name })} +

+
+

{body}

+
+ ); +} diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx index 392c49eefa..63cde87af0 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx @@ -7,10 +7,12 @@ import { useMemo } from "react"; import { Combobox } from "@/components/ui/combobox"; import type { MessageKey, TranslationValues } from "@/i18n/messages"; import { useTranslations } from "@/i18n/provider"; +import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import { hasEnabledRampProvider } from "@/lib/provider-availability"; import type { OfframpWizard } from "../hooks/use-offramp-wizard.redesign"; import { walletComboboxOptions } from "../wallet-options"; import { BvnkAgreementConsent } from "./bvnk-agreement-consent.redesign"; +import { DemoProviderCheckout } from "./demo-provider-checkout"; import { ManualInstructionsQuote } from "./manual-instructions-quote.redesign"; import { MemoStepContent } from "./memo-step-content.redesign"; import { MoneygramRampWidget } from "./moneygram-ramp-widget"; @@ -75,6 +77,7 @@ function OfframpManualQuoteStep({ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: step dispatch keeps every offramp stage in one component while each branch stays simple. export function OfframpStepContent({ wizard }: { wizard: OfframpWizard }) { const t = useTranslations(); + const demo = usePaymentsDemo(); const { currentStepId, enabledRampProviders, @@ -261,6 +264,27 @@ export function OfframpStepContent({ wizard }: { wizard: OfframpWizard }) { return ; } + // A provider's own page or widget can't open on sample data; the demo stands in for it. + if ( + currentStepId === "COMPLETE" && + demo && + quote && + quote.deliveryMode !== "manual_instructions" + ) { + return ( +
+ +
+ +
+
+ ); + } + if (currentStepId === "COMPLETE" && quote?.deliveryMode === "hosted") { return ( ; @@ -180,21 +174,7 @@ function ManualInstructionsStep({ ); } - const labels = - quote.provider === "mural" && !isMuralSandboxPayinCurrency(selectedRampPair.fiatCurrency) - ? null - : simulateActionLabels(quote.provider, t); - const simulateAction = labels - ? { - loading: quoteSimulationLoading, - succeeded: quoteSimulationSucceeded, - onClick: () => void simulateCurrentQuote(), - icon: , - idleLabel: labels.idle, - busyLabel: labels.busy, - doneLabel: labels.done, - } - : undefined; + // The sandbox's Simulate deposit is the footer's primary action, not part of the instructions. const instructionsQuote = ( ); return refresh ? ( @@ -266,6 +245,7 @@ function HostedQuoteStep({ quote }: { quote: HostedQuoteRecord }) { * quote's own surface (a hosted frame, a widget, bank instructions). */ function QuoteStep({ wizard, quote }: { wizard: OnrampWizard; quote: OnrampQuote }) { + const demo = usePaymentsDemo(); const { transferStatus } = wizard; if (wizard.showCompleteScreen) { @@ -275,6 +255,20 @@ function QuoteStep({ wizard, quote }: { wizard: OnrampWizard; quote: OnrampQuote return ; } + // A provider's own checkout can't open on sample data; the demo stands in for it. + if (demo && quote.deliveryMode !== "manual_instructions") { + return ( +
+ {quote.provider === "coinbase" ? : null} + +
+ ); + } + if (quote.provider === "stripe") { return ( diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-onboarding-panel.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-onboarding-panel.tsx index 8dc0fbeb70..a666fc8286 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-onboarding-panel.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-onboarding-panel.tsx @@ -3,6 +3,8 @@ import type { CounterpartyRequirements, RampDirection } from "@sdp/types/ramp-requirements"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/i18n/provider"; +import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; +import { getRampProviderLabel } from "@/lib/ramps"; import { openExternalRampUrl } from "@/lib/trusted-ramp-destinations"; import { isOnboardingPanelStatus, onboardingCopy, provisioningDetail } from "./providers"; @@ -16,14 +18,20 @@ export function RampOnboardingPanel({ onRetry: () => void; }) { const t = useTranslations(); + const demo = usePaymentsDemo(); if (!isOnboardingPanelStatus(onboarding)) { throw new Error(`RampOnboardingPanel received non-onboarding status: ${onboarding.status}`); } const { provider, status } = onboarding; const copy = onboardingCopy(onboarding, t); const Icon = copy.icon; - const hostedAction = - status === "terms_of_service_required" + // The provider's own verification page can't open on sample data; the footer's Simulate + // verification stands in for it. + const demoVerification = + demo && provider === "bvnk" && status === "customer_verification_required"; + const hostedAction = demoVerification + ? null + : status === "terms_of_service_required" ? { label: t("DashboardPayments.ramps.acceptTerms"), url: onboarding.termsOfServiceUrl } : status === "customer_verification_required" ? { @@ -36,6 +44,13 @@ export function RampOnboardingPanel({

{copy.title}

{copy.description}

+ {demoVerification ? ( +

+ {t("DashboardPayments.demo.verification.body", { + provider: getRampProviderLabel(provider), + })} +

+ ) : null} {hostedAction ? ( - + + - {/* Unmounted, not CSS-hidden, while the slide-over is open: a covered + {/* Unmounted, not CSS-hidden, while the slide-over is open: a covered duplicate of every destination would otherwise sit behind the overlay. A refresh route has no bar at all: the design's phone reaches the navigation through the menu button over the title. */} - {isRefresh || isMobileSidebarOpen || isMoreSheetOpen ? null : ( - setMoreSheetOpen(true)} - /> - )} - - {isMoreSheetOpen ? ( - setMoreSheetOpen(false)} - /> - ) : null} + {isRefresh || isMobileSidebarOpen || isMoreSheetOpen ? null : ( + setMoreSheetOpen(true)} + /> + )} - {isMobileSidebarOpen ? ( -
-
- - ) : null} + ) : null} - {/* The refresh page is flat: no card, no radius; the sidebar's rule separates it. The + {/* The refresh page is flat: no card, no radius; the sidebar's rule separates it. The `page` group lets the header react to the content (an empty state hiding the header's action). On a refresh route it is also the work area's size container: the scroll panel reads its width (`100cqw`) to span it edge to edge. */} -
-
- {/* Refresh: the gutter sits outside the centred column, so the title's left edge is +
+ {/* Refresh: the gutter sits outside the centred column, so the title's left edge is the content's at every width; 32px above the title and 24px from the title to the tabs are the design's. */} -
- - } - /> -
- - {headerTabs ? (
+ + ) : undefined + ) : ( + + ) + } + /> +
+ + {headerTabs ? (
- +
+ +
-
- ) : null} + ) : null} - {routeTabs ? ( -
-
- + {routeTabs ? ( +
+
+ +
-
- ) : null} + ) : null} +
-
- {/* On a refresh page the content column takes the same gutter as the header, in a + {/* On a refresh page the content column takes the same gutter as the header, in a wrapper so the column's box stays exactly the header's. In the locked layout the wrapper carries on the flex column, so the content box still fills it. */} -
-
-
- + + + diff --git a/apps/sdp-web/src/components/payments-demo-notice.tsx b/apps/sdp-web/src/components/payments-demo-notice.tsx new file mode 100644 index 0000000000..fcb9cba994 --- /dev/null +++ b/apps/sdp-web/src/components/payments-demo-notice.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "@/i18n/provider"; + +const NOTICE_TOAST_ID = "payments-demo-notice"; + +/** + * Demo mode's disclaimer, a toast in the app's stack that stays until closed. Turning demo mode + * off or leaving Payments takes it away; turning demo mode on again or reloading brings it back. + */ +export function PaymentsDemoNotice() { + const t = useTranslations(); + const title = t("DashboardPayments.demo.notice.state"); + const body = t("DashboardPayments.demo.notice.body"); + useEffect(() => { + toast.info(title, { + id: NOTICE_TOAST_ID, + description: body, + duration: Number.POSITIVE_INFINITY, + }); + return () => { + toast.dismiss(NOTICE_TOAST_ID); + }; + }, [title, body]); + return null; +} diff --git a/apps/sdp-web/src/components/payments-demo-toggle.tsx b/apps/sdp-web/src/components/payments-demo-toggle.tsx new file mode 100644 index 0000000000..afb1618a79 --- /dev/null +++ b/apps/sdp-web/src/components/payments-demo-toggle.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useSWRConfig } from "swr"; +import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; +import { useTranslations } from "@/i18n/provider"; +import { setPaymentsDemoAction } from "@/lib/payments-demo/demo-mode-action"; +import { + isPaymentsDemoOn, + type PaymentsDemoState, + paymentsDemoProjectId, +} from "@/lib/payments-demo/payments-demo-context"; +import { cn } from "@/lib/utils"; + +/** + * The Payments header's demo mode switch, on every Payments screen. It can be turned on on a + * sandbox project, and off on any project, so a demo can never get stuck. Switching redraws the + * page in place, no reload: every cached read is dropped and fetched again, the server parts are + * drawn again, and the shell remounts the page (see dashboard-shell.tsx), so no real or demo + * data, and nothing done in the demo, outlives the change. + */ +export function PaymentsDemoToggle(state: PaymentsDemoState) { + const t = useTranslations(); + const router = useRouter(); + const { mutate } = useSWRConfig(); + const { selectedProjectId, sdpEnvironment } = useDashboardWorkspace(); + const [pending, startTransition] = useTransition(); + const [target, setTarget] = useState(null); + if (!paymentsDemoProjectId(state, selectedProjectId)) { + return null; + } + const on = isPaymentsDemoOn(state, selectedProjectId); + // The switch shows where it is going until the page has been drawn in the new mode. + const checked = pending && target !== null ? target : on; + const productionOnly = !on && sdpEnvironment === "production"; + + const change = (next: boolean) => { + setTarget(next); + startTransition(async () => { + try { + if (await setPaymentsDemoAction(next, selectedProjectId)) { + await mutate(() => true, undefined, { revalidate: true }); + router.refresh(); + } + } catch { + // The switch goes back to where it was. + } + }); + }; + + // One control, its label inside the track: the knob sits at the start when off and slides + // to the end when on, the word taking the space it leaves. + return ( + + ); +} diff --git a/apps/sdp-web/src/components/payments-demo-toggle.unit.test.tsx b/apps/sdp-web/src/components/payments-demo-toggle.unit.test.tsx new file mode 100644 index 0000000000..909fa81cae --- /dev/null +++ b/apps/sdp-web/src/components/payments-demo-toggle.unit.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getMessages } from "@/i18n/messages"; +import { I18nProvider } from "@/i18n/provider"; +import type { PaymentsDemoState } from "@/lib/payments-demo/payments-demo-context"; +import { PaymentsDemoToggle } from "./payments-demo-toggle"; + +const workspace = vi.hoisted(() => ({ + selectedProjectId: "proj_sandbox" as string | null, + sdpEnvironment: "sandbox" as "sandbox" | "production", +})); +const setPaymentsDemoAction = vi.hoisted(() => vi.fn()); + +vi.mock("@/contexts/dashboard-workspace-context", () => ({ + useDashboardWorkspace: () => workspace, +})); +vi.mock("@/lib/payments-demo/demo-mode-action", () => ({ setPaymentsDemoAction })); +const router = vi.hoisted(() => ({ refresh: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => router })); + +const reload = vi.fn(); + +function renderToggle(state: PaymentsDemoState) { + return render( + + + + ); +} + +beforeEach(() => { + workspace.selectedProjectId = "proj_sandbox"; + workspace.sdpEnvironment = "sandbox"; + setPaymentsDemoAction.mockResolvedValue(true); + vi.stubGlobal("location", { ...window.location, reload }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("PaymentsDemoToggle", () => { + it("turns demo mode on for the selected project and redraws the page without a reload", async () => { + renderToggle({ demoProjectId: null, cookieProjectId: "proj_sandbox" }); + const toggle = screen.getByRole("switch", { name: "Demo" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + await userEvent.click(toggle); + + expect(setPaymentsDemoAction).toHaveBeenCalledWith(true, "proj_sandbox"); + await waitFor(() => expect(router.refresh).toHaveBeenCalled()); + expect(reload).not.toHaveBeenCalled(); + }); + + it("shows on for the project the demo names and turns it off", async () => { + renderToggle({ demoProjectId: "proj_sandbox", cookieProjectId: "proj_sandbox" }); + const toggle = screen.getByRole("switch", { name: "Demo" }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + await userEvent.click(screen.getByText("Demo")); + + expect(setPaymentsDemoAction).toHaveBeenCalledWith(false, "proj_sandbox"); + }); + + it("stays reachable when the project list didn't load", () => { + workspace.selectedProjectId = null; + renderToggle({ demoProjectId: "proj_sandbox", cookieProjectId: "proj_sandbox" }); + + expect(screen.getByRole("switch", { name: "Demo" }).getAttribute("aria-checked")).toBe("true"); + }); + + it("can't be turned on on a production project, but can be turned off", () => { + workspace.selectedProjectId = "proj_production"; + workspace.sdpEnvironment = "production"; + const { rerender } = renderToggle({ demoProjectId: null, cookieProjectId: null }); + expect((screen.getByRole("switch", { name: "Demo" }) as HTMLButtonElement).disabled).toBe(true); + + rerender( + + + + ); + expect((screen.getByRole("switch", { name: "Demo" }) as HTMLButtonElement).disabled).toBe( + false + ); + }); + + it("goes back when the switch doesn't take", async () => { + setPaymentsDemoAction.mockResolvedValue(false); + renderToggle({ demoProjectId: null, cookieProjectId: "proj_sandbox" }); + const toggle = screen.getByRole("switch", { name: "Demo" }); + + await userEvent.click(toggle); + + await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false")); + expect((toggle as HTMLButtonElement).disabled).toBe(false); + expect(router.refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-cookie.ts b/apps/sdp-web/src/lib/payments-demo/demo-cookie.ts new file mode 100644 index 0000000000..169cb90378 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-cookie.ts @@ -0,0 +1,27 @@ +/** + * Holds the id of the project whose Payments screens run in demo mode. Naming the project means + * switching to another one (a production project included) turns the demo off by itself. + */ +export const PAYMENTS_DEMO_COOKIE_NAME = "sdp-payments-demo"; + +/** + * What the visitor has done in demo mode since the page was loaded: the log of demo actions, + * split over numbered cookies (`sdp-demo-session.0`, `.1`, …). It lives in the browser only; + * the server replays it over the demo fixtures on each read and keeps nothing. A full page load + * starts it over (the proxy drops it), so a refresh or turning the demo off forgets it. + */ +export const DEMO_SESSION_COOKIE_PREFIX = "sdp-demo-session"; + +export const PAYMENTS_PATH_PREFIX = "/dashboard/payments"; + +/** Whether a dashboard path is a Payments screen. */ +export function isPaymentsPath(pathname: string | null | undefined): boolean { + return ( + pathname === PAYMENTS_PATH_PREFIX || Boolean(pathname?.startsWith(`${PAYMENTS_PATH_PREFIX}/`)) + ); +} + +/** Whether a cookie is one of the demo session's chunks. */ +export function isDemoSessionCookie(name: string): boolean { + return name === DEMO_SESSION_COOKIE_PREFIX || name.startsWith(`${DEMO_SESSION_COOKIE_PREFIX}.`); +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-fixtures.ts b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.ts new file mode 100644 index 0000000000..91b8238b60 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.ts @@ -0,0 +1,1871 @@ +import { + type Counterparty, + type CounterpartyAccount, + type CounterpartyAccountSummary, + type CounterpartyEntityType, + type CounterpartyProviderAccount, + type CustodyProvider, + type CustodyWalletAggregate, + type CustodyWalletTokenBalance, + type PaymentRampQuoteDeliveryMode, + type PaymentRecurringPayment, + type PaymentRecurringPaymentStatus, + type PaymentRequest, + type PaymentRequestStatus, + type PaymentSubscriptionCollectionAttempt, + type PaymentsDashboardWallet, + type PaymentTransactionKind, + type PaymentTransferBatch, + type PaymentTransferBatchStatus, + type PaymentTransferRecipient, + type PaymentTransferStatus, + type PaymentTransferSummary, + type PaymentTransferType, + type RampProviderId, + SOL_MINT, + UNIFIED_TRANSACTION_MODULE_CONTRACTS, + type UnifiedTransaction, + WELL_KNOWN_TOKENS, +} from "@sdp/types"; + +/* + * Demo data for the Payments screens. Every GET the Payments pages send upstream is answered + * from one small, cross-referenced world built fresh for each call: three wallets, seven + * contacts, their transfers, batches, requests and schedules. Timestamps are offsets from + * `now`, so the activity always reads as recent; ids carry a `demo_` prefix; addresses and + * signatures are derived from fixed seeds, so they are stable across calls and renders. + */ + +export const MINUTE_MS = 60_000; +export const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +export const ORGANIZATION_ID = "demo_org"; +export const PROJECT_ID = "demo_prj"; +export const CREATED_BY = "demo_user_ops"; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const DEFAULT_TRANSACTIONS_LIMIT = 50; +const TRANSACTIONS_CURSOR_PREFIX = "demo_cursor_"; + +// ─── Tokens ────────────────────────────────────────────────────────────────── + +export const DEMO_TOKENS = { + USDC: { + symbol: WELL_KNOWN_TOKENS.USDC.symbol, + mint: WELL_KNOWN_TOKENS.USDC.mints.devnet.address, + decimals: WELL_KNOWN_TOKENS.USDC.mints.devnet.decimals, + usdPrice: 1, + }, + SOL: { + symbol: WELL_KNOWN_TOKENS.SOL.symbol, + mint: SOL_MINT, + decimals: WELL_KNOWN_TOKENS.SOL.mints.devnet.decimals, + usdPrice: 148.2, + }, + EURC: { + symbol: WELL_KNOWN_TOKENS.EURC.symbol, + mint: WELL_KNOWN_TOKENS.EURC.mints.devnet.address, + decimals: WELL_KNOWN_TOKENS.EURC.mints.devnet.decimals, + usdPrice: 1.08, + }, +} as const; + +export type DemoTokenKey = keyof typeof DEMO_TOKENS; + +export function toBaseUnits(uiAmount: string, decimals: number): bigint { + const [whole = "0", fraction = ""] = uiAmount.split("."); + const scaledFraction = fraction.padEnd(decimals, "0").slice(0, decimals); + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(scaledFraction || "0"); +} + +/** A base-unit amount as a decimal string, keeping at least `minFractionDigits`. */ +export function fromBaseUnits(amount: bigint, decimals: number, minFractionDigits = 0): string { + const scale = 10n ** BigInt(decimals); + const whole = amount / scale; + const trimmed = (amount % scale).toString().padStart(decimals, "0").replace(/0+$/, ""); + const fraction = trimmed.padEnd(minFractionDigits, "0"); + return fraction ? `${whole}.${fraction}` : whole.toString(); +} + +function sumAmounts(amounts: readonly string[], token: DemoTokenKey): string { + const { decimals } = DEMO_TOKENS[token]; + const total = amounts.reduce((sum, amount) => sum + toBaseUnits(amount, decimals), 0n); + return fromBaseUnits(total, decimals, 2); +} + +export function usdValueOf(uiAmount: string, usdPrice: number): number { + return Math.round(Number(uiAmount) * usdPrice * 100) / 100; +} + +export function tokenBalance(key: DemoTokenKey, uiAmount: string): CustodyWalletTokenBalance { + const token = DEMO_TOKENS[key]; + const amount = toBaseUnits(uiAmount, token.decimals); + const normalizedUiAmount = fromBaseUnits(amount, token.decimals); + return { + token: token.symbol, + mint: token.mint, + amount: amount.toString(), + uiAmount: normalizedUiAmount, + decimals: token.decimals, + usdPrice: token.usdPrice, + usdValue: usdValueOf(normalizedUiAmount, token.usdPrice), + }; +} + +export function symbolForMint(mint: string | null | undefined): string | undefined { + return Object.values(DEMO_TOKENS).find((token) => token.mint === mint)?.symbol; +} + +function matchesToken(mint: string | null | undefined, filter: string): boolean { + return mint === filter || symbolForMint(mint)?.toUpperCase() === filter.toUpperCase(); +} + +// ─── Stable base58 addresses and signatures ────────────────────────────────── + +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const encodedSeeds = new Map(); + +/** Deterministic bytes for a seed: an FNV-1a hash of the seed drives a mulberry32 stream. */ +function seededBytes(seed: string, length: number): Uint8Array { + let state = 0x811c9dc5; + for (let index = 0; index < seed.length; index += 1) { + state = Math.imul(state ^ seed.charCodeAt(index), 0x01000193) >>> 0; + } + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index += 1) { + state = (state + 0x6d2b79f5) >>> 0; + let mixed = Math.imul(state ^ (state >>> 15), state | 1); + mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61); + bytes[index] = (mixed ^ (mixed >>> 14)) & 0xff; + } + // A leading zero byte would shorten the encoding; real keys rarely start with one. + if (bytes[0] === 0) bytes[0] = 1; + return bytes; +} + +function base58(bytes: Uint8Array): string { + let value = 0n; + for (const byte of bytes) { + value = value * 256n + BigInt(byte); + } + let encoded = ""; + while (value > 0n) { + encoded = `${BASE58_ALPHABET[Number(value % 58n)]}${encoded}`; + value /= 58n; + } + return encoded; +} + +function seededBase58(kind: "address" | "signature", seed: string): string { + const key = `${kind}:${seed}`; + const cached = encodedSeeds.get(key); + if (cached !== undefined) return cached; + const encoded = base58(seededBytes(key, kind === "address" ? 32 : 64)); + encodedSeeds.set(key, encoded); + return encoded; +} + +export const demoAddress = (seed: string) => seededBase58("address", seed); +export const demoSignature = (seed: string) => seededBase58("signature", seed); + +// ─── Specs ─────────────────────────────────────────────────────────────────── + +type WalletOwner = { custodyConfigId: string } | { custodyConnectionId: string }; + +interface WalletSpec { + walletName: string; + provider: CustodyProvider; + walletId: string; + owner: WalletOwner; + holdings: ReadonlyArray; +} + +const WALLET_SPECS = { + treasury: { + walletName: "Treasury", + provider: "privy", + walletId: "demo_privy_treasury", + owner: { custodyConnectionId: "demo_conn_privy" }, + holdings: [ + ["USDC", "128450.25"], + ["SOL", "214.5"], + ], + }, + payroll: { + walletName: "Payroll", + provider: "fireblocks", + walletId: "demo_fireblocks_vault_12", + owner: { custodyConfigId: "demo_cfg_fireblocks" }, + holdings: [ + ["USDC", "42300"], + ["SOL", "1.25"], + ], + }, + settlement: { + walletName: "Settlement", + provider: "privy", + walletId: "demo_privy_settlement", + owner: { custodyConnectionId: "demo_conn_privy" }, + holdings: [ + ["USDC", "8760.4"], + ["SOL", "36.8"], + ["EURC", "2500"], + ], + }, +} as const satisfies Record; + +type WalletKey = keyof typeof WALLET_SPECS; + +interface ContactSpec { + displayName: string; + entityType: CounterpartyEntityType; + externalId: string | null; + accountLabel: string; + createdDaysAgo: number; +} + +const CONTACT_SPECS = { + acme: { + displayName: "Acme Logistics", + entityType: "business", + externalId: "ACME-001", + accountLabel: "Operations wallet", + createdDaysAgo: 96, + }, + northwind: { + displayName: "Northwind Traders", + entityType: "business", + externalId: "NW-2291", + accountLabel: "Accounts payable", + createdDaysAgo: 88, + }, + lumen: { + displayName: "Lumen Studio", + entityType: "business", + externalId: null, + accountLabel: "Studio treasury", + createdDaysAgo: 74, + }, + orbit: { + displayName: "Orbit Payroll Ltd", + entityType: "business", + externalId: "ORB-PAY-07", + accountLabel: "Payroll funding", + createdDaysAgo: 61, + }, + jane: { + displayName: "Jane Smith", + entityType: "individual", + externalId: null, + accountLabel: "Personal wallet", + createdDaysAgo: 120, + }, + kai: { + displayName: "Kai Nakamura", + entityType: "individual", + externalId: "EMP-0142", + accountLabel: "Phantom", + createdDaysAgo: 45, + }, + priya: { + displayName: "Priya Raman", + entityType: "individual", + externalId: null, + accountLabel: "Main wallet", + createdDaysAgo: 19, + }, +} as const satisfies Record; + +type ContactKey = keyof typeof CONTACT_SPECS; + +interface RampSpec { + provider: RampProviderId; + fiatCurrency: string; + fiatAmount: string; + providerReference: string; + deliveryMode: PaymentRampQuoteDeliveryMode; +} + +interface TransferSpec { + id: string; + /** How long before `now` the transfer was created. */ + ago: number; + wallet: WalletKey; + direction: "inbound" | "outbound"; + kind: PaymentTransactionKind; + status: PaymentTransferStatus; + token: DemoTokenKey; + amount: string; + type?: PaymentTransferType; + contact?: ContactKey; + memo?: string; + error?: string; + /** False when no transaction reached the chain. */ + signed?: boolean; + /** Seed for the other party's address when it is not the contact's saved wallet. */ + external?: string; + /** A batch chunk pays several recipients, so it names no single destination. */ + multiRecipient?: boolean; + ramp?: RampSpec; + rampsMemo?: Record; + /** Chain history SDP did not initiate; listed only with `includeObserved=true`. */ + observed?: boolean; +} + +const TRANSFER_SPECS: readonly TransferSpec[] = [ + { + id: "demo_xfr_acme_inv_20931", + ago: 12 * MINUTE_MS, + wallet: "treasury", + contact: "acme", + direction: "outbound", + kind: "pay", + status: "processing", + token: "USDC", + amount: "4820.00", + memo: "INV-20931", + }, + { + id: "demo_xfr_northwind_po_7714", + ago: 47 * MINUTE_MS, + wallet: "treasury", + contact: "northwind", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "USDC", + amount: "12500.00", + memo: "PO-7714", + }, + { + id: "demo_xfr_onramp_coinbase", + ago: 2 * HOUR_MS + 5 * MINUTE_MS, + wallet: "treasury", + contact: "acme", + direction: "inbound", + type: "onramp", + kind: "onramp", + status: "completed", + token: "USDC", + amount: "4982.50", + external: "coinbase:onramp-hot-wallet", + ramp: { + provider: "coinbase", + fiatCurrency: "USD", + fiatAmount: "5000.00", + providerReference: "demo_cb_order_7q2k9", + deliveryMode: "hosted", + }, + }, + { + id: "demo_xfr_priya_design_review", + ago: 5 * HOUR_MS + 20 * MINUTE_MS, + wallet: "settlement", + contact: "priya", + direction: "outbound", + kind: "pay", + status: "failed", + token: "USDC", + amount: "350.00", + memo: "Design review sprint 4", + error: "Blockhash expired before the transaction landed. No funds moved.", + signed: false, + }, + { + id: "demo_xfr_lumen_request", + ago: 26 * HOUR_MS, + wallet: "treasury", + contact: "lumen", + direction: "inbound", + kind: "request_deposit", + status: "finalized", + token: "USDC", + amount: "2400.00", + }, + { + id: "demo_xfr_offramp_orbit", + ago: 2 * DAY_MS + 3 * HOUR_MS, + wallet: "treasury", + contact: "orbit", + direction: "outbound", + type: "offramp", + kind: "offramp", + status: "settling", + token: "USDC", + amount: "10000.00", + external: "bvnk:deposit-address", + ramp: { + provider: "bvnk", + fiatCurrency: "USD", + fiatAmount: "9975.00", + providerReference: "demo_bvnk_payout_4471", + deliveryMode: "manual_instructions", + }, + rampsMemo: { invoice: "ORB-INV-5521" }, + }, + { + id: "demo_xfr_northwind_restock", + ago: 2 * DAY_MS + 7 * HOUR_MS, + wallet: "treasury", + contact: "northwind", + direction: "outbound", + kind: "pay", + status: "finalized", + token: "USDC", + amount: "7640.00", + memo: "Q3 inventory restock", + }, + { + id: "demo_xfr_acme_freight_rebate", + ago: 3 * DAY_MS + 2 * HOUR_MS, + wallet: "settlement", + contact: "acme", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "USDC", + amount: "3150.00", + memo: "Freight rebate", + }, + { + id: "demo_xfr_lumen_render_credits", + ago: 4 * DAY_MS + HOUR_MS, + wallet: "treasury", + contact: "lumen", + direction: "outbound", + kind: "pay", + status: "finalized", + token: "SOL", + amount: "12.5", + memo: "Render farm credits", + }, + { + id: "demo_xfr_acme_request", + ago: 5 * DAY_MS + 4 * HOUR_MS, + wallet: "treasury", + contact: "acme", + direction: "inbound", + kind: "request_deposit", + status: "confirmed", + token: "USDC", + amount: "1875.00", + }, + { + id: "demo_xfr_onramp_moonpay", + ago: 11 * DAY_MS, + wallet: "settlement", + contact: "jane", + direction: "inbound", + type: "onramp", + kind: "onramp", + status: "expired", + token: "USDC", + amount: "248.10", + signed: false, + external: "moonpay:onramp-hot-wallet", + ramp: { + provider: "moonpay", + fiatCurrency: "USD", + fiatAmount: "250.00", + providerReference: "demo_mp_tx_31c8", + deliveryMode: "hosted", + }, + }, + { + id: "demo_xfr_orbit_funding", + ago: 15 * DAY_MS, + wallet: "treasury", + contact: "orbit", + direction: "outbound", + kind: "pay", + status: "finalized", + token: "USDC", + amount: "25000.00", + memo: "Payroll funding cycle 18", + }, + { + id: "demo_obs_treasury_sol", + ago: 6 * HOUR_MS + 10 * MINUTE_MS, + wallet: "treasury", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "SOL", + amount: "2", + observed: true, + }, + { + id: "demo_obs_settlement_sol", + ago: DAY_MS + 5 * HOUR_MS, + wallet: "settlement", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "SOL", + amount: "5", + observed: true, + }, + { + id: "demo_obs_payroll_usdc", + ago: 3 * DAY_MS + 5 * HOUR_MS, + wallet: "payroll", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "USDC", + amount: "20000.00", + observed: true, + }, + { + id: "demo_obs_treasury_usdc", + ago: 4 * DAY_MS + 9 * HOUR_MS, + wallet: "treasury", + direction: "inbound", + kind: "deposit", + status: "finalized", + token: "USDC", + amount: "500.00", + observed: true, + }, +]; + +interface BatchChunkSpec { + status: "finalized" | "failed"; + error?: string; + recipients: ReadonlyArray; +} + +interface BatchSpec { + key: string; + externalId: string; + wallet: WalletKey; + ago: number; + status: PaymentTransferBatchStatus; + chunks: readonly BatchChunkSpec[]; +} + +const BATCH_SPECS: readonly BatchSpec[] = [ + { + key: "contractors_14", + externalId: "contractor-payouts-run-14", + wallet: "payroll", + ago: DAY_MS + 3 * HOUR_MS + 20 * MINUTE_MS, + status: "confirmed", + chunks: [ + { + status: "finalized", + recipients: [ + ["kai", "950.00"], + ["priya", "1450.00"], + ["jane", "600.00"], + ], + }, + ], + }, + { + key: "vendors_6", + externalId: "vendor-settlement-run-6", + wallet: "treasury", + ago: 4 * DAY_MS + 6 * HOUR_MS, + status: "partially_failed", + chunks: [ + { + status: "finalized", + recipients: [ + ["acme", "2100.00"], + ["northwind", "3400.00"], + ], + }, + { + status: "failed", + error: + "Recipient token account for USDC is closed. Ask Lumen Studio to reopen it, then retry.", + recipients: [["lumen", "780.00"]], + }, + ], + }, +]; + +interface CycleSpec { + cycle: number; + /** A failed automated collection, retried and settled 30 minutes later. */ + failure?: string; +} + +interface ScheduleSpec { + key: string; + contact: ContactKey; + wallet: WalletKey; + amount: string; + periodHours: number; + status: PaymentRecurringPaymentStatus; + /** Signed offsets from `now`. */ + createdAt: number; + firstCollectionAt: number; + endedAt?: number; + cycles: readonly CycleSpec[]; +} + +const RETRY_DELAY_MS = 30 * MINUTE_MS; + +const SCHEDULE_SPECS: readonly ScheduleSpec[] = [ + { + key: "northwind_supply", + contact: "northwind", + wallet: "treasury", + amount: "8000.00", + periodHours: 720, + status: "pending_activation", + createdAt: -2 * HOUR_MS, + firstCollectionAt: 5 * DAY_MS, + cycles: [], + }, + { + key: "kai_weekly", + contact: "kai", + wallet: "payroll", + amount: "1200.00", + periodHours: 168, + status: "active", + createdAt: -28 * DAY_MS, + firstCollectionAt: -(27 * DAY_MS + 3 * HOUR_MS), + cycles: [ + { cycle: 0 }, + { cycle: 1 }, + { + cycle: 2, + failure: "Source wallet balance too low: 842.17 USDC available, 1,200.00 USDC due.", + }, + { cycle: 3 }, + ], + }, + { + key: "lumen_retainer", + contact: "lumen", + wallet: "treasury", + amount: "4500.00", + periodHours: 720, + status: "active", + createdAt: -43 * DAY_MS, + firstCollectionAt: -42 * DAY_MS + 2 * HOUR_MS, + cycles: [{ cycle: 0 }, { cycle: 1 }], + }, + { + key: "jane_stipend", + contact: "jane", + wallet: "settlement", + amount: "800.00", + periodHours: 720, + status: "canceled", + createdAt: -81 * DAY_MS, + firstCollectionAt: -80 * DAY_MS, + endedAt: -35 * DAY_MS, + cycles: [{ cycle: 0 }, { cycle: 1 }], + }, +]; + +interface RequestSpec { + key: string; + contact: ContactKey | null; + wallet: WalletKey; + amount: string; + status: PaymentRequestStatus; + createdAgo: number; + /** Signed offset from `now`, or null for a request that never expires. */ + expiresAt: number | null; + paidByTransferId?: string; + canceledAgo?: number; +} + +const REQUEST_SPECS: readonly RequestSpec[] = [ + { + key: "open_invoice", + contact: null, + wallet: "treasury", + amount: "1000.00", + status: "awaiting_payment", + createdAgo: 30 * MINUTE_MS, + expiresAt: null, + }, + { + key: "orbit_q4_fees", + contact: "orbit", + wallet: "treasury", + amount: "18250.00", + status: "awaiting_payment", + createdAgo: 4 * HOUR_MS, + expiresAt: 7 * DAY_MS - 4 * HOUR_MS, + }, + { + key: "priya_workshop", + contact: "priya", + wallet: "settlement", + amount: "320.00", + status: "awaiting_payment", + createdAgo: DAY_MS, + expiresAt: 2 * DAY_MS, + }, + { + key: "lumen_retainer", + contact: "lumen", + wallet: "treasury", + amount: "2400.00", + status: "paid", + createdAgo: 3 * DAY_MS, + expiresAt: 4 * DAY_MS, + paidByTransferId: "demo_xfr_lumen_request", + }, + { + key: "acme_rebill", + contact: "acme", + wallet: "treasury", + amount: "1875.00", + status: "paid", + createdAgo: 6 * DAY_MS, + expiresAt: DAY_MS, + paidByTransferId: "demo_xfr_acme_request", + }, + { + key: "kai_equipment", + contact: "kai", + wallet: "payroll", + amount: "150.00", + status: "canceled", + createdAgo: 9 * DAY_MS, + expiresAt: -2 * DAY_MS, + canceledAgo: 8 * DAY_MS, + }, + { + key: "northwind_deposit", + contact: "northwind", + wallet: "treasury", + amount: "5600.00", + status: "expired", + createdAgo: 12 * DAY_MS, + expiresAt: -5 * DAY_MS, + }, +]; + +// ─── The demo world ────────────────────────────────────────────────────────── + +export type DemoWallet = PaymentsDashboardWallet & { balances: CustodyWalletTokenBalance[] }; + +export type DemoTransfer = PaymentTransferSummary & { + organizationId: string; + projectId: string; + type: PaymentTransferType; + kind: PaymentTransactionKind; + direction: "inbound" | "outbound"; + error: string | null; + createdAt: string; + updatedAt: string; +}; + +export interface DemoTransferRow { + transfer: DemoTransfer; + observed: boolean; +} + +export interface DemoBatch { + batch: PaymentTransferBatch; + recipients: PaymentTransferRecipient[]; +} + +/** A payout account a provider holds for a contact. */ +export interface DemoProviderAccount { + counterpartyId: string; + account: CounterpartyProviderAccount; +} + +/** + * The fixtures, then whatever the visitor did in demo mode on top: contacts and addresses are + * keyed by fixture key for the seeded ones and by id for the ones added since. + */ +export interface DemoWorld { + wallets: Record; + contacts: Record; + accounts: Record; + providerAccounts: DemoProviderAccount[]; + transfers: DemoTransferRow[]; + batches: DemoBatch[]; + requests: PaymentRequest[]; + schedules: PaymentRecurringPayment[]; + attempts: PaymentSubscriptionCollectionAttempt[]; + /** `provider:counterpartyId` for each contact that accepted a ramp provider's agreements. */ + consents: string[]; + /** When a ramp provider approved a contact's identity check, by `provider:counterpartyId`. */ + verifications: Record; +} + +interface Clock { + /** ISO time `ms` before now. */ + ago: (ms: number) => string; + /** ISO time at a signed offset from now. */ + at: (offset: number) => string; +} + +function createClock(now: Date): Clock { + // Whole minutes, so every read in one render agrees on the same world. + const nowMs = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + return { + ago: (ms) => new Date(nowMs - ms).toISOString(), + at: (offset) => new Date(nowMs + offset).toISOString(), + }; +} + +function mapRecord( + record: Record, + map: (value: V, key: K) => R +): Record { + return Object.fromEntries( + (Object.entries(record) as [K, V][]).map(([key, value]) => [key, map(value, key)]) + ) as Record; +} + +function buildWallet(spec: WalletSpec, key: WalletKey): DemoWallet { + return { + id: `demo_cwlt_${key}`, + walletId: spec.walletId, + publicKey: demoAddress(`wallet:${key}`), + label: spec.walletName, + provider: spec.provider, + ...spec.owner, + isRuntimeExecutionAllowed: true, + balances: spec.holdings.map(([token, amount]) => tokenBalance(token, amount)), + }; +} + +function buildContact(spec: ContactSpec, key: ContactKey, clock: Clock): Counterparty { + return { + id: `demo_cpty_${key}`, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + externalId: spec.externalId, + entityType: spec.entityType, + displayName: spec.displayName, + status: "active", + createdBy: CREATED_BY, + createdAt: clock.ago(spec.createdDaysAgo * DAY_MS), + updatedAt: clock.ago(spec.createdDaysAgo * DAY_MS - 20 * MINUTE_MS), + }; +} + +function buildAccount(spec: ContactSpec, key: ContactKey, clock: Clock): CounterpartyAccount { + const createdAt = clock.ago(spec.createdDaysAgo * DAY_MS - 5 * MINUTE_MS); + return { + id: `demo_cpa_${key}`, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + counterpartyId: `demo_cpty_${key}`, + accountKind: "crypto_wallet", + label: spec.accountLabel, + details: { network: "solana", address: demoAddress(`contact:${key}`) }, + providerAccountData: {}, + status: "active", + createdAt, + updatedAt: createdAt, + }; +} + +type WorldBase = Pick; + +function otherPartyAddress(spec: TransferSpec, world: WorldBase): string { + if (spec.external) return demoAddress(spec.external); + if (spec.contact) return world.accounts[spec.contact].details.address; + return demoAddress(`external:${spec.id}`); +} + +function rampFields(ramp: RampSpec | undefined): Partial { + if (!ramp) return {}; + return { + provider: ramp.provider, + providerReference: ramp.providerReference, + deliveryMode: ramp.deliveryMode, + fiatCurrency: ramp.fiatCurrency, + fiatAmount: ramp.fiatAmount, + }; +} + +function buildTransfer(spec: TransferSpec, world: WorldBase, clock: Clock): DemoTransferRow { + const wallet = world.wallets[spec.wallet]; + const contact = spec.contact ? world.contacts[spec.contact] : undefined; + const otherParty = otherPartyAddress(spec, world); + const inbound = spec.direction === "inbound"; + const inFlight = spec.status === "processing" || spec.status === "settling"; + const transfer: DemoTransfer = { + id: spec.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + custodyWalletId: spec.observed ? null : wallet.id, + providerWalletId: wallet.walletId, + type: spec.type ?? "transfer", + kind: spec.kind, + direction: spec.direction, + status: spec.status, + signature: spec.signed === false ? null : demoSignature(spec.id), + error: spec.error ?? null, + source: inbound ? otherParty : wallet.publicKey, + ...(spec.multiRecipient ? {} : { destination: inbound ? wallet.publicKey : otherParty }), + token: DEMO_TOKENS[spec.token].mint, + amount: spec.amount, + ...(spec.memo ? { memo: spec.memo } : {}), + rampsMemo: spec.rampsMemo ?? {}, + ...(contact + ? { counterpartyId: contact.id, counterpartyDisplayName: contact.displayName } + : {}), + ...rampFields(spec.ramp), + createdAt: clock.ago(spec.ago), + updatedAt: clock.ago(inFlight ? spec.ago : Math.max(spec.ago - 2 * MINUTE_MS, 0)), + }; + return { transfer, observed: spec.observed === true }; +} + +function batchChunkId(batch: BatchSpec, index: number): string { + return `demo_xfr_batch_${batch.key}_${index + 1}`; +} + +function batchTransferSpecs(): TransferSpec[] { + return BATCH_SPECS.flatMap((batch) => + batch.chunks.map((chunk, index) => ({ + id: batchChunkId(batch, index), + ago: batch.ago - index * MINUTE_MS, + wallet: batch.wallet, + direction: "outbound" as const, + type: "transfer_batch" as const, + kind: "batch_pay" as const, + status: chunk.status, + token: "USDC" as const, + amount: sumAmounts( + chunk.recipients.map(([, amount]) => amount), + "USDC" + ), + multiRecipient: true, + ...(chunk.error ? { error: chunk.error, signed: false } : {}), + })) + ); +} + +function buildBatch(spec: BatchSpec, world: WorldBase, clock: Clock): DemoBatch { + const wallet = world.wallets[spec.wallet]; + const batchId = `demo_batch_${spec.key}`; + const createdAt = clock.ago(spec.ago + MINUTE_MS); + const updatedAt = clock.ago(Math.max(spec.ago - 3 * MINUTE_MS, 0)); + let position = 0; + const recipients = spec.chunks.flatMap((chunk, chunkIndex) => + chunk.recipients.map(([contact, amount]): PaymentTransferRecipient => { + position += 1; + return { + id: `demo_ptr_${spec.key}_${position}`, + batchId, + transferId: batchChunkId(spec, chunkIndex), + externalId: `${spec.externalId}-${String(position).padStart(3, "0")}`, + counterpartyId: world.contacts[contact].id, + counterpartyAccountId: world.accounts[contact].id, + destination: world.accounts[contact].details.address, + amount, + status: chunk.status === "failed" ? "failed" : "confirmed", + error: chunk.error ?? null, + createdAt, + updatedAt, + }; + }) + ); + return { + batch: { + id: batchId, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + externalId: spec.externalId, + sourceCustodyWalletId: wallet.id, + sourceProviderWalletId: wallet.walletId, + sourceAddress: wallet.publicKey, + token: DEMO_TOKENS.USDC.mint, + status: spec.status, + totalAmount: sumAmounts( + recipients.map((recipient) => recipient.amount), + "USDC" + ), + recipientCount: recipients.length, + transactionCount: spec.chunks.length, + createdAt, + updatedAt, + }, + recipients, + }; +} + +interface ScheduleOutput { + schedule: PaymentRecurringPayment; + attempts: PaymentSubscriptionCollectionAttempt[]; + transfers: TransferSpec[]; +} + +function buildScheduleAttempts( + spec: ScheduleSpec, + nowOffset: (offset: number) => string +): Pick { + const recurringPaymentId = `demo_rp_${spec.key}`; + const subscriptionId = `demo_sub_${spec.key}`; + const periodMs = spec.periodHours * HOUR_MS; + const attempts: PaymentSubscriptionCollectionAttempt[] = []; + const transfers: TransferSpec[] = []; + const base = { + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + subscriptionId, + token: DEMO_TOKENS.USDC.mint, + amount: spec.amount, + }; + const settle = (id: string, dueOffset: number, attemptOffset: number) => { + const transferId = `demo_xfr_rp_${spec.key}_${transfers.length + 1}`; + transfers.push({ + id: transferId, + ago: -attemptOffset, + wallet: spec.wallet, + contact: spec.contact, + direction: "outbound", + kind: "recurring_pay", + status: "finalized", + token: "USDC", + amount: spec.amount, + }); + return { id, transferId, dueAt: nowOffset(dueOffset), attemptedAt: nowOffset(attemptOffset) }; + }; + for (const { cycle, failure } of spec.cycles) { + const dueOffset = spec.firstCollectionAt + cycle * periodMs; + const attemptId = `demo_psca_${spec.key}_${cycle + 1}`; + if (failure === undefined) { + const settled = settle(attemptId, dueOffset, dueOffset + MINUTE_MS); + attempts.push({ + ...base, + ...settled, + status: "confirmed", + signature: demoSignature(settled.transferId), + error: null, + metadata: { source: "automated", recurringPaymentId, initiatedByKeyId: null }, + createdAt: settled.dueAt, + updatedAt: nowOffset(dueOffset + 2 * MINUTE_MS), + }); + continue; + } + attempts.push({ + ...base, + id: attemptId, + transferId: null, + dueAt: nowOffset(dueOffset), + attemptedAt: nowOffset(dueOffset + MINUTE_MS), + status: "failed", + signature: null, + error: failure, + metadata: { source: "automated", recurringPaymentId, initiatedByKeyId: null }, + createdAt: nowOffset(dueOffset), + updatedAt: nowOffset(dueOffset + MINUTE_MS), + }); + const retried = settle(`${attemptId}_retry`, dueOffset, dueOffset + RETRY_DELAY_MS + MINUTE_MS); + attempts.push({ + ...base, + ...retried, + status: "confirmed", + signature: demoSignature(retried.transferId), + error: null, + metadata: { + source: "retry", + initialSource: "automated", + transferId: null, + error: failure, + retryAfterAt: nowOffset(dueOffset + RETRY_DELAY_MS), + recurringPaymentId, + initiatedByKeyId: null, + }, + createdAt: nowOffset(dueOffset + RETRY_DELAY_MS), + updatedAt: nowOffset(dueOffset + RETRY_DELAY_MS + 2 * MINUTE_MS), + }); + } + return { attempts, transfers }; +} + +function nextCollectionOffset(spec: ScheduleSpec): number | null { + if (spec.status === "pending_activation") return spec.firstCollectionAt; + if (spec.status !== "active") return null; + const cyclesRun = Math.max(-1, ...spec.cycles.map(({ cycle }) => cycle)) + 1; + return spec.firstCollectionAt + cyclesRun * spec.periodHours * HOUR_MS; +} + +function buildSchedule(spec: ScheduleSpec, world: WorldBase, clock: Clock): ScheduleOutput { + const wallet = world.wallets[spec.wallet]; + const account = world.accounts[spec.contact]; + const activated = spec.status !== "pending_activation"; + const onchain = (value: string) => (activated ? value : null); + const { attempts, transfers } = buildScheduleAttempts(spec, clock.at); + const nextOffset = nextCollectionOffset(spec); + const lastActivity = attempts.at(-1)?.updatedAt; + return { + schedule: { + id: `demo_rp_${spec.key}`, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + sourceCustodyWalletId: wallet.id, + sourceProviderWalletId: wallet.walletId, + sourceAddress: wallet.publicKey, + counterpartyId: world.contacts[spec.contact].id, + counterpartyAccountId: account.id, + destinationAddress: account.details.address, + destinationTokenAccount: onchain(demoAddress(`token-account:${spec.key}`)), + token: DEMO_TOKENS.USDC.mint, + amount: spec.amount, + periodHours: spec.periodHours, + firstCollectionAt: clock.at(spec.firstCollectionAt), + nextCollectionDueAt: nextOffset === null ? null : clock.at(nextOffset), + planId: onchain(`demo_plan_${spec.key}`), + subscriptionId: onchain(`demo_sub_${spec.key}`), + planPda: onchain(demoAddress(`plan:${spec.key}`)), + planCreatedAt: onchain(clock.at(spec.createdAt + 2 * MINUTE_MS)), + planCreationSignature: onchain(demoSignature(`plan:${spec.key}`)), + subscriptionPda: onchain(demoAddress(`subscription:${spec.key}`)), + subscriptionAuthorityAddress: onchain(demoAddress(`subscription-authority:${spec.key}`)), + authorizationSignature: onchain(demoSignature(`authorization:${spec.key}`)), + status: spec.status, + metadataUri: null, + createdBy: CREATED_BY, + createdAt: clock.at(spec.createdAt), + updatedAt: + spec.endedAt !== undefined + ? clock.at(spec.endedAt) + : (lastActivity ?? clock.at(spec.createdAt)), + }, + attempts, + transfers, + }; +} + +function buildRequest( + spec: RequestSpec, + world: WorldBase, + transfers: readonly DemoTransferRow[], + clock: Clock +): PaymentRequest { + const wallet = world.wallets[spec.wallet]; + const createdAt = clock.ago(spec.createdAgo); + const paidAt = transfers.find((row) => row.transfer.id === spec.paidByTransferId)?.transfer + .createdAt; + const expiresAt = spec.expiresAt === null ? null : clock.at(spec.expiresAt); + const closedAt = + spec.status === "paid" + ? paidAt + : spec.status === "canceled" && spec.canceledAgo !== undefined + ? clock.ago(spec.canceledAgo) + : spec.status === "expired" + ? (expiresAt ?? undefined) + : undefined; + return { + id: `demo_preq_${spec.key}`, + publicToken: `demo_pt_${demoAddress(`request-token:${spec.key}`).slice(0, 22)}`, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + counterpartyId: spec.contact ? world.contacts[spec.contact].id : null, + walletId: wallet.walletId, + destinationAddress: wallet.publicKey, + token: DEMO_TOKENS.USDC.mint, + amount: spec.amount, + reference: demoAddress(`request-reference:${spec.key}`), + status: spec.status, + expiresAt, + fulfilledByTransferId: spec.status === "paid" ? (spec.paidByTransferId ?? null) : null, + canceledBy: spec.status === "canceled" ? CREATED_BY : null, + lifecycle: [ + { status: "awaiting_payment", at: createdAt }, + ...(closedAt === undefined ? [] : [{ status: spec.status, at: closedAt }]), + ], + createdBy: CREATED_BY, + createdAt, + updatedAt: closedAt ?? createdAt, + }; +} + +export function newestFirst(rows: T[]): T[] { + return rows.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +export function buildWorld(now: Date): DemoWorld { + const clock = createClock(now); + const base: WorldBase = { + wallets: mapRecord(WALLET_SPECS, (spec: WalletSpec, key) => buildWallet(spec, key)), + contacts: mapRecord(CONTACT_SPECS, (spec: ContactSpec, key) => buildContact(spec, key, clock)), + accounts: mapRecord(CONTACT_SPECS, (spec: ContactSpec, key) => buildAccount(spec, key, clock)), + }; + const scheduleOutputs = SCHEDULE_SPECS.map((spec) => buildSchedule(spec, base, clock)); + const transfers = [ + ...TRANSFER_SPECS, + ...batchTransferSpecs(), + ...scheduleOutputs.flatMap((output) => output.transfers), + ].map((spec) => buildTransfer(spec, base, clock)); + transfers.sort((left, right) => right.transfer.createdAt.localeCompare(left.transfer.createdAt)); + return { + ...base, + providerAccounts: buildProviderAccounts(base), + transfers, + batches: BATCH_SPECS.map((spec) => buildBatch(spec, base, clock)).sort((left, right) => + right.batch.createdAt.localeCompare(left.batch.createdAt) + ), + requests: newestFirst(REQUEST_SPECS.map((spec) => buildRequest(spec, base, transfers, clock))), + schedules: newestFirst(scheduleOutputs.map((output) => output.schedule)), + attempts: scheduleOutputs.flatMap((output) => output.attempts), + consents: [], + verifications: {}, + }; +} + +// ─── Query helpers ─────────────────────────────────────────────────────────── + +function positiveInteger(value: string | null, fallback: number): number { + if (value === null || !/^\d+$/.test(value)) return fallback; + const parsed = Number(value); + return parsed > 0 ? parsed : fallback; +} + +interface Page { + rows: T[]; + total: number; + page: number; + pageSize: number; +} + +function pageOf(rows: readonly T[], params: URLSearchParams): Page { + const page = positiveInteger(params.get("page"), 1); + const pageSize = Math.min( + positiveInteger(params.get("pageSize"), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE + ); + return { + rows: rows.slice((page - 1) * pageSize, page * pageSize), + total: rows.length, + page, + pageSize, + }; +} + +function paginatedMeta(page: Page) { + return { + total: page.total, + page: page.page, + pageSize: page.pageSize, + hasMore: page.page * page.pageSize < page.total, + requestId: "demo_request", + }; +} + +function csv(params: URLSearchParams, key: string): string[] | null { + const value = params.get(key); + if (value === null || value.trim() === "") return null; + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function includesSearch(values: ReadonlyArray, search: string): boolean { + const needle = search.trim().toLowerCase(); + return values.some((value) => value?.toLowerCase().includes(needle) === true); +} + +function withinRange(createdAt: string, from: string | null, to: string | null): boolean { + const time = Date.parse(createdAt); + if (from !== null && time < Date.parse(from)) return false; + if (to !== null && time > Date.parse(to)) return false; + return true; +} + +// ─── Wallets ───────────────────────────────────────────────────────────────── + +function walletList(world: DemoWorld): DemoWallet[] { + return Object.values(world.wallets); +} + +export function findWallet( + world: DemoWorld, + id: string | null | undefined +): DemoWallet | undefined { + return walletList(world).find((wallet) => wallet.id === id || wallet.walletId === id); +} + +function walletsBody(world: DemoWorld, params: URLSearchParams) { + const includeBalances = params.get("includeBalances") === "true"; + return { + data: { + wallets: walletList(world).map(({ balances, ...wallet }) => + includeBalances ? { ...wallet, balances } : wallet + ), + }, + }; +} + +function aggregateBody(world: DemoWorld) { + const byMint = new Map(); + for (const wallet of walletList(world)) { + for (const balance of wallet.balances) { + const current = byMint.get(balance.mint); + byMint.set(balance.mint, { + balance, + amount: (current?.amount ?? 0n) + BigInt(balance.amount), + }); + } + } + const aggregate: CustodyWalletAggregate = { + walletCount: walletList(world).length, + balances: [...byMint.values()].map(({ balance, amount }) => { + const uiAmount = fromBaseUnits(amount, balance.decimals); + return { + ...balance, + amount: amount.toString(), + uiAmount, + usdValue: usdValueOf(uiAmount, balance.usdPrice ?? 0), + }; + }), + }; + return { data: { aggregate } }; +} + +function walletsRoute(rest: string[], params: URLSearchParams, world: DemoWorld) { + if (rest.length === 0) return walletsBody(world, params); + if (rest.length === 1 && rest[0] === "aggregate") return aggregateBody(world); + return undefined; +} + +function walletBalancesBody(world: DemoWorld, walletId: string) { + const wallet = findWallet(world, walletId); + if (!wallet) return undefined; + return { + data: { + walletBalances: { walletId, address: wallet.publicKey, balances: wallet.balances }, + }, + }; +} + +// ─── Transfers ─────────────────────────────────────────────────────────────── + +type TransferPredicate = (transfer: DemoTransfer) => boolean; + +function transferFilters(params: URLSearchParams): TransferPredicate[] { + const filters: TransferPredicate[] = []; + const types = csv(params, "type"); + if (types) filters.push((transfer) => types.includes(transfer.type)); + const statuses = csv(params, "status"); + if (statuses) filters.push((transfer) => statuses.includes(transfer.status)); + const category = params.get("category"); + if (category === "wallet" || category === "ramp") { + const rampTypes: readonly string[] = ["onramp", "offramp"]; + filters.push((transfer) => rampTypes.includes(transfer.type) === (category === "ramp")); + } + for (const key of ["counterpartyId", "direction", "provider", "providerReference"] as const) { + const value = params.get(key); + if (value !== null) filters.push((transfer) => transfer[key] === value); + } + const token = params.get("token"); + if (token !== null) filters.push((transfer) => matchesToken(transfer.token, token)); + const search = params.get("search"); + if (search !== null && search.trim().length >= 3) { + filters.push((transfer) => + includesSearch( + [ + transfer.id, + transfer.signature, + transfer.providerReference, + transfer.source, + transfer.destination, + transfer.memo, + transfer.counterpartyId, + transfer.counterpartyDisplayName, + ], + search + ) + ); + } + const from = params.get("from"); + const to = params.get("to"); + if (from !== null || to !== null) { + filters.push((transfer) => withinRange(transfer.createdAt, from, to)); + } + return filters; +} + +/** Which rows a wallet scope lists: its persisted transfers, plus its observed history on request. */ +function inTransferScope( + row: DemoTransferRow, + wallet: DemoWallet | undefined, + includeObserved: boolean +): boolean { + if (!row.observed) return wallet === undefined || row.transfer.custodyWalletId === wallet.id; + if (!wallet || !includeObserved) return false; + return row.transfer.destination === wallet.publicKey || row.transfer.source === wallet.publicKey; +} + +function transfersBody(world: DemoWorld, params: URLSearchParams) { + const custodyWalletId = params.get("custodyWalletId"); + const wallet = custodyWalletId === null ? undefined : findWallet(world, custodyWalletId); + const unknownWallet = custodyWalletId !== null && wallet?.id !== custodyWalletId; + const includeObserved = params.get("includeObserved") === "true"; + const filters = transferFilters(params); + const rows = unknownWallet + ? [] + : world.transfers + .filter((row) => inTransferScope(row, wallet, includeObserved)) + .map((row) => row.transfer) + .filter((transfer) => filters.every((filter) => filter(transfer))); + const page = pageOf(rows, params); + return { data: page.rows, meta: paginatedMeta(page) }; +} + +function transferBody(world: DemoWorld, transferId: string) { + const transfer = world.transfers.find((row) => row.transfer.id === transferId)?.transfer; + return transfer ? { data: { transfer } } : undefined; +} + +// ─── Unified transactions ──────────────────────────────────────────────────── + +function toUnifiedTransaction(transfer: DemoTransfer, world: DemoWorld): UnifiedTransaction { + return { + id: transfer.id, + moduleId: transfer.id, + module: "payments", + kind: transfer.kind, + moduleStatus: transfer.status, + status: UNIFIED_TRANSACTION_MODULE_CONTRACTS.payments.status[transfer.status], + organizationId: transfer.organizationId, + projectId: transfer.projectId, + custodyWalletId: transfer.custodyWalletId, + custodyWalletLabel: findWallet(world, transfer.custodyWalletId)?.label ?? null, + token: transfer.token ?? null, + amount: transfer.amount ?? null, + counterpartyId: transfer.counterpartyId ?? null, + signature: transfer.signature, + createdAt: transfer.createdAt, + }; +} + +function transactionFilters(params: URLSearchParams): ((row: UnifiedTransaction) => boolean)[] { + const filters: ((row: UnifiedTransaction) => boolean)[] = []; + for (const key of ["kind", "status", "custodyWalletId", "counterpartyId"] as const) { + const value = params.get(key); + if (value !== null) filters.push((row) => row[key] === value); + } + const token = params.get("token"); + if (token !== null) filters.push((row) => matchesToken(row.token, token)); + const search = params.get("search")?.trim().toLowerCase(); + if (search) { + filters.push((row) => + [row.id, row.moduleId, row.signature].some((value) => value?.toLowerCase().startsWith(search)) + ); + } + const from = params.get("createdAtFrom"); + const to = params.get("createdAtTo"); + if (from !== null || to !== null) filters.push((row) => withinRange(row.createdAt, from, to)); + return filters; +} + +function cursorOffset(cursor: string | null): number { + if (cursor === null || !cursor.startsWith(TRANSACTIONS_CURSOR_PREFIX)) return 0; + return positiveInteger(cursor.slice(TRANSACTIONS_CURSOR_PREFIX.length), 0); +} + +function transactionsBody(world: DemoWorld, params: URLSearchParams) { + const module = params.get("module"); + const filters = transactionFilters(params); + const rows = + module !== null && module !== "payments" + ? [] + : world.transfers + .filter((row) => !row.observed) + .map((row) => toUnifiedTransaction(row.transfer, world)) + .filter((row) => filters.every((filter) => filter(row))); + const limit = Math.min( + positiveInteger(params.get("limit"), DEFAULT_TRANSACTIONS_LIMIT), + MAX_PAGE_SIZE + ); + const offset = cursorOffset(params.get("cursor")); + const end = offset + limit; + return { + data: { + transactions: rows.slice(offset, end), + nextCursor: end < rows.length ? `${TRANSACTIONS_CURSOR_PREFIX}${end}` : null, + }, + }; +} + +// ─── Batches, requests, schedules ──────────────────────────────────────────── + +function batchesBody(world: DemoWorld, params: URLSearchParams) { + const page = pageOf( + world.batches.map((entry) => entry.batch), + params + ); + return { data: page.rows, meta: paginatedMeta(page) }; +} + +function batchBody(world: DemoWorld, batchId: string) { + const entry = world.batches.find((candidate) => candidate.batch.id === batchId); + if (!entry) return undefined; + const transferIds = new Set(entry.recipients.map((recipient) => recipient.transferId)); + return { + data: { + batch: entry.batch, + recipients: entry.recipients, + transfers: world.transfers + .map((row) => row.transfer) + .filter((transfer) => transferIds.has(transfer.id)), + }, + }; +} + +function requestsBody(world: DemoWorld, params: URLSearchParams) { + const status = params.get("status"); + const counterpartyId = params.get("counterpartyId"); + const page = pageOf( + world.requests.filter( + (request) => + (status === null || request.status === status) && + (counterpartyId === null || request.counterpartyId === counterpartyId) + ), + params + ); + return { + data: { + paymentRequests: page.rows, + total: page.total, + page: page.page, + pageSize: page.pageSize, + }, + }; +} + +function recurringPaymentsBody(world: DemoWorld, params: URLSearchParams) { + const status = params.get("status"); + const counterpartyId = params.get("counterpartyId"); + const page = pageOf( + world.schedules.filter( + (schedule) => + (status === null || schedule.status === status) && + (counterpartyId === null || schedule.counterpartyId === counterpartyId) + ), + params + ); + return { + data: { + recurringPayments: page.rows, + total: page.total, + page: page.page, + pageSize: page.pageSize, + }, + }; +} + +function recurringPaymentBody(world: DemoWorld, recurringPaymentId: string) { + const recurringPayment = world.schedules.find((schedule) => schedule.id === recurringPaymentId); + return recurringPayment ? { data: { recurringPayment } } : undefined; +} + +function collectionAttemptsBody(world: DemoWorld, subscriptionId: string, params: URLSearchParams) { + if (!world.schedules.some((schedule) => schedule.subscriptionId === subscriptionId)) { + return undefined; + } + const attempts = world.attempts + .filter((attempt) => attempt.subscriptionId === subscriptionId) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const page = pageOf(attempts, params); + return { + data: { + collectionAttempts: page.rows, + total: page.total, + page: page.page, + pageSize: page.pageSize, + }, + }; +} + +function paymentsRoute(rest: string[], params: URLSearchParams, world: DemoWorld) { + const [collection, id, child, ...extra] = rest; + if (extra.length > 0) return undefined; + if (id === undefined) { + switch (collection) { + case "transfers": + return transfersBody(world, params); + case "transfer-batches": + return batchesBody(world, params); + case "requests": + return requestsBody(world, params); + case "recurring-payments": + return recurringPaymentsBody(world, params); + default: + return undefined; + } + } + if (child === undefined) { + switch (collection) { + case "transfers": + return transferBody(world, id); + case "transfer-batches": + return batchBody(world, id); + case "recurring-payments": + return recurringPaymentBody(world, id); + default: + return undefined; + } + } + if (collection === "subscriptions" && child === "collection-attempts") { + return collectionAttemptsBody(world, id, params); + } + if (collection === "wallets" && child === "balances") return walletBalancesBody(world, id); + return undefined; +} + +// ─── Counterparties ────────────────────────────────────────────────────────── + +/** Payout accounts providers hold for a few contacts, so a contact's page has rows to show. */ +const PROVIDER_ACCOUNT_SPECS: Partial< + Record< + ContactKey, + readonly Pick< + CounterpartyProviderAccount, + | "provider" + | "fiatCurrency" + | "destinationCountry" + | "paymentRail" + | "bankName" + | "accountNumberLast4" + >[] + > +> = { + jane: [ + { + provider: "lightspark", + fiatCurrency: "USD", + destinationCountry: "US", + paymentRail: "ACH", + bankName: "Chase Bank", + accountNumberLast4: "3321", + }, + ], + acme: [ + { + provider: "lightspark", + fiatCurrency: "EUR", + destinationCountry: "DE", + paymentRail: "SEPA", + bankName: "Deutsche Bank", + accountNumberLast4: "8841", + }, + ], + northwind: [ + { + provider: "lightspark", + fiatCurrency: "GBP", + destinationCountry: "GB", + paymentRail: "FPS", + bankName: "Barclays", + accountNumberLast4: "5307", + }, + ], + lumen: [ + { + provider: "lightspark", + fiatCurrency: "USD", + destinationCountry: "US", + paymentRail: "WIRE", + bankName: "Silicon Valley Bank", + accountNumberLast4: "1188", + }, + ], + orbit: [ + { + provider: "lightspark", + fiatCurrency: "EUR", + destinationCountry: "IE", + paymentRail: "SEPA", + bankName: "Bank of Ireland", + accountNumberLast4: "6620", + }, + ], + kai: [ + { + provider: "lightspark", + fiatCurrency: "USD", + destinationCountry: "US", + paymentRail: "ACH", + bankName: "Wells Fargo", + accountNumberLast4: "4472", + }, + ], + priya: [ + { + provider: "lightspark", + fiatCurrency: "USD", + destinationCountry: "US", + paymentRail: "ACH", + bankName: "Bank of America", + accountNumberLast4: "9015", + }, + ], +}; + +function buildProviderAccounts(base: WorldBase): DemoProviderAccount[] { + return (Object.keys(PROVIDER_ACCOUNT_SPECS) as ContactKey[]).flatMap((key) => + (PROVIDER_ACCOUNT_SPECS[key] ?? []).map((spec, index) => ({ + counterpartyId: base.contacts[key].id, + account: { + ...spec, + id: `demo_cppa_${key}_${index}`, + kind: "payout_account" as const, + status: "active" as const, + providerStatus: "VERIFIED", + createdAt: base.contacts[key].createdAt, + }, + })) + ); +} + +function providerAccountsBody(world: DemoWorld, counterparty: Counterparty) { + const accounts = world.providerAccounts + .filter((entry) => entry.counterpartyId === counterparty.id) + .map((entry) => entry.account); + return { data: { accounts } }; +} + +function counterpartiesBody(world: DemoWorld, params: URLSearchParams) { + const page = pageOf(newestFirst(Object.values(world.contacts)), params); + return { + data: { + counterparties: page.rows, + total: page.total, + page: page.page, + pageSize: page.pageSize, + }, + }; +} + +function projectAccountsBody(world: DemoWorld, params: URLSearchParams) { + const idList = csv(params, "ids"); + const ids = idList === null ? null : new Set(idList); + const search = params.get("search"); + const summaries = newestFirst(Object.values(world.accounts)) + .map( + (account): CounterpartyAccountSummary => ({ + counterpartyId: account.counterpartyId, + counterpartyAccountId: account.id, + name: + Object.values(world.contacts).find((contact) => contact.id === account.counterpartyId) + ?.displayName ?? account.counterpartyId, + address: account.details.address, + label: account.label, + }) + ) + .filter( + (summary) => + (ids === null || ids.has(summary.counterpartyAccountId)) && + (search === null || + search.trim() === "" || + includesSearch([summary.name, summary.address, summary.label], search)) + ); + const page = pageOf(summaries, params); + return { + data: { accounts: page.rows, total: page.total, page: page.page, pageSize: page.pageSize }, + }; +} + +function counterpartyAccountsBody( + world: DemoWorld, + counterparty: Counterparty, + params: URLSearchParams +) { + const accountKind = params.get("accountKind"); + const page = pageOf( + Object.values(world.accounts).filter( + (account) => + account.counterpartyId === counterparty.id && + (accountKind === null || account.accountKind === accountKind) + ), + params + ); + return { + data: { accounts: page.rows, total: page.total, page: page.page, pageSize: page.pageSize }, + }; +} + +function counterpartiesRoute(rest: string[], params: URLSearchParams, world: DemoWorld) { + const [id, child, ...extra] = rest; + if (id === undefined) return counterpartiesBody(world, params); + if (id === "accounts" && child === undefined) return projectAccountsBody(world, params); + const counterparty = Object.values(world.contacts).find((contact) => contact.id === id); + if (!counterparty || extra.length > 0) return undefined; + switch (child) { + case undefined: + return { data: { counterparty } }; + case "accounts": + return counterpartyAccountsBody(world, counterparty, params); + case "provider-accounts": + return providerAccountsBody(world, counterparty); + default: + return undefined; + } +} + +// ─── Entry point ───────────────────────────────────────────────────────────── + +function emptyIssuedTokensBody(params: URLSearchParams) { + return { data: [], meta: paginatedMeta(pageOf([], params)) }; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +/** A request path as the demo routes it: its `/v1` segments, decoded, and its query. */ +export function demoPathParts( + pathWithQuery: string +): { segments: string[]; params: URLSearchParams } | undefined { + let url: URL; + try { + url = new URL(pathWithQuery, "http://demo.local"); + } catch { + return undefined; + } + const [version, ...segments] = url.pathname + .split("/") + .filter((segment) => segment.length > 0) + .map(decodeSegment); + if (version !== "v1") return undefined; + return { segments, params: url.searchParams }; +} + +/** + * The JSON body the SDP API would send for this GET (the full envelope, e.g. `{ data: … }`), + * read from the fixtures alone, or undefined when they hold no answer. + */ +export function paymentsDemoBody(pathWithQuery: string, now?: Date): unknown | undefined { + return demoWorldBody(buildWorld(now ?? new Date()), pathWithQuery); +} + +/** The same answer, read from a given world (the fixtures with the session's actions applied). */ +export function demoWorldBody(world: DemoWorld, pathWithQuery: string): unknown | undefined { + const parts = demoPathParts(pathWithQuery); + if (parts === undefined) return undefined; + const [resource, ...rest] = parts.segments; + const params = parts.params; + switch (resource) { + case "wallets": + return walletsRoute(rest, params, world); + case "payments": + return paymentsRoute(rest, params, world); + case "transactions": + return rest.length === 0 ? transactionsBody(world, params) : undefined; + case "counterparties": + return counterpartiesRoute(rest, params, world); + case "issuance": + return rest.length === 1 && rest[0] === "tokens" ? emptyIssuedTokensBody(params) : undefined; + default: + return undefined; + } +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts new file mode 100644 index 0000000000..d8a1f5704c --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts @@ -0,0 +1,793 @@ +import { + type Counterparty, + type CounterpartyAccount, + type CounterpartyAccountSummary, + type ListCounterpartiesResponse, + type ListCounterpartyProviderAccountsResponse, + type PaymentRecurringPayment, + type PaymentRequest, + type PaymentSubscriptionCollectionAttempt, + type PaymentsDashboardWallet, + type PaymentTransferSummary, + UNIFIED_TRANSACTION_MODULE_CONTRACTS, + type UnifiedTransaction, + WELL_KNOWN_TOKEN_BY_MINT, +} from "@sdp/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as counterpartyAccountsGET } from "@/app/api/dashboard/counterparty/[counterpartyId]/accounts/route"; +import { GET as providerAccountsGET } from "@/app/api/dashboard/counterparty/[counterpartyId]/provider-accounts/route"; +import { GET as projectAccountsGET } from "@/app/api/dashboard/counterparty/accounts/route"; +import { GET as counterpartiesGET } from "@/app/api/dashboard/counterparty/route"; +import { GET as recurringPaymentGET } from "@/app/api/dashboard/payments/recurring-payments/[recurringPaymentId]/route"; +import { GET as recurringPaymentsGET } from "@/app/api/dashboard/payments/recurring-payments/route"; +import { GET as transactionsGET } from "@/app/api/dashboard/payments/transactions/route"; +import { GET as transfersGET } from "@/app/api/dashboard/payments/transfers/route"; +import { GET as aggregateGET } from "@/app/api/dashboard/wallets/aggregate/route"; +import { GET as walletsGET } from "@/app/api/dashboard/wallets/route"; +import CounterpartyDetailRoute from "@/app/dashboard/payments/counterparty/[counterpartyId]/page"; +import { + fetchCounterparties, + fetchCounterparty, +} from "@/app/dashboard/payments/counterparty/counterparty-page.data"; +import CounterpartyPage from "@/app/dashboard/payments/counterparty/page"; +import { + normalizeAggregateBalances, + resolveTotalBalance, + selectTopAggregateBalanceRows, +} from "@/app/dashboard/payments/payments-overview.utils"; +import { + fetchDashboardPaymentTransfers, + fetchIssuedTokensByMint, + fetchPaymentsAggregate, + fetchPaymentsIssuedTokenSymbols, + fetchPaymentsWallets, + fetchPaymentTransfers, + fetchTransferBatches, + fetchTransferBatchRecipients, +} from "@/app/dashboard/payments/payments-page.data"; +import { summarizeBatch } from "@/app/dashboard/payments/payments-presentation"; +import { + fetchAllCounterparties, + fetchBatchRecipients, + fetchCounterpartyAccounts, + fetchTransfers, + fetchWalletAggregate, + fetchWallets, +} from "@/app/dashboard/payments/payments-workspace.data"; +import RecurringPaymentDetailRoute from "@/app/dashboard/payments/recurring/[recurringPaymentId]/page"; +import RecurringPaymentsPage from "@/app/dashboard/payments/recurring/page"; +import { + fetchRecurringPaymentCollectionAttempts, + fetchRecurringPayments, + getRecurringPayment, + listRecurringPayments, +} from "@/app/dashboard/payments/recurring/recurring-payments.data.redesign"; +import PaymentRequestDetailRoute from "@/app/dashboard/payments/requests/[requestId]/page"; +import PaymentRequestsPage from "@/app/dashboard/payments/requests/page"; +import { fetchPaymentRequests } from "@/app/dashboard/payments/requests/payment-requests-page.data"; +import TransactionsPage from "@/app/dashboard/payments/transactions/page"; +import { + fetchTransactionsPageFromDashboard, + transactionsApiQuery, +} from "@/app/dashboard/payments/transactions/transactions-page.data.redesign"; +import { parseTransactionFilters } from "@/app/dashboard/payments/transactions/transactions-query.redesign"; +import { dashboardFetch } from "@/lib/dashboard-fetch"; +import { paymentsDemoBody } from "./demo-fixtures"; + +/* + * Every demo body goes through the parsers the Payments screens use: the server pages and data + * functions read it through a fake `request` that answers from the fixtures, and the client + * fetchers reach it through the real dashboard API routes, whose SDP API proxy is the fixtures. + */ + +// The pages render on the new design; the flag itself reads Vercel and the request. +vi.mock("@/flags", () => ({ newDesign: async () => true })); + +const harness = vi.hoisted(() => { + const state = { + now: new Date("2026-09-25T12:00:00.000Z"), + unhandled: [] as string[], + }; + type DemoBody = (path: string, now?: Date) => unknown; + let demoBody: DemoBody | undefined; + + async function request(path: string, _init?: RequestInit): Promise { + demoBody ??= (await import("./demo-fixtures")).paymentsDemoBody; + const body = demoBody(path, state.now); + if (body === undefined) { + state.unhandled.push(path); + return Response.json({ error: { message: `No demo body for ${path}` } }, { status: 404 }); + } + return Response.json(body); + } + + /** `SdpApiClient.fetch`: throws on a failed response and unwraps `data`. */ + async function fetchData(path: string): Promise { + const response = await request(path); + if (!response.ok) throw new Error(`SDP API request failed (${response.status})`); + const json = (await response.json()) as { data: T }; + return json.data; + } + + const trace = { + traceId: "trace_demo", + serverTiming: () => "", + childContext: () => ({ traceId: "trace_demo", source: "demo-fixtures.test" }), + step: (_name: string, run: () => T) => run(), + log: () => undefined, + }; + + return { state, request, apiClient: { request, fetch: fetchData }, trace }; +}); + +vi.mock("@/lib/sdp-api", () => ({ + proxyToSdpApi: ({ path }: { path: string }) => harness.request(path), + getSelectedProjectId: async () => "demo_prj", + createSdpApiClient: async () => harness.apiClient, +})); +vi.mock("@/lib/request-tracing", () => ({ + createTimedTrace: () => harness.trace, + logRouteResult: () => undefined, +})); +vi.mock("@/lib/dashboard-page-trace", () => ({ + withDashboardPageTrace: (_source: string, run: (context: unknown) => Promise) => + run({ trace: harness.trace, apiClient: harness.apiClient }), +})); +vi.mock("@clerk/nextjs/server", () => ({ + auth: async () => ({ userId: "user_demo", orgId: "org_demo" }), +})); +vi.mock("@/i18n/server", () => ({ + getTranslations: async () => (key: string) => key, + getRequestLocale: async () => "en", +})); +vi.mock("@/lib/auth-entry", () => ({ getAuthEntryPath: async () => "/sign-in" })); +// The screens themselves are client components; the tests read the props the pages hand them. +vi.mock("@/app/dashboard/payments/counterparty/counterparty-workspace.redesign", () => ({ + CounterpartyWorkspace: () => null, +})); +vi.mock("@/app/dashboard/payments/counterparty/counterparty-detail-workspace.redesign", () => ({ + CounterpartyDetailWorkspace: () => null, +})); +vi.mock("@/app/dashboard/payments/requests/payment-requests-workspace.redesign", () => ({ + PaymentRequestsWorkspace: () => null, +})); +vi.mock("@/app/dashboard/payments/recurring/recurring-payments-workspace.redesign", () => ({ + RecurringPaymentsWorkspace: () => null, +})); +vi.mock("@/app/dashboard/payments/recurring/recurring-payment-detail-workspace.redesign", () => ({ + RecurringPaymentDetailWorkspace: () => null, +})); +vi.mock("@/app/dashboard/payments/transactions/transactions-workspace.redesign", () => ({ + TransactionsWorkspace: () => null, +})); + +const NOW = harness.state.now; +const t = ((key: string) => key) as Parameters[1]; +const BASE58_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; +const BASE58_SIGNATURE = /^[1-9A-HJ-NP-Za-km-z]{86,88}$/; + +type RouteHandler = (request: Request) => Promise; + +const BFF_ROUTES: Record = { + wallets: walletsGET, + "wallets/aggregate": aggregateGET, + "payments/transfers": transfersGET, + "payments/transactions": transactionsGET, + counterparty: counterpartiesGET, + "counterparty/accounts": projectAccountsGET, + "payments/recurring-payments": recurringPaymentsGET, +}; + +/** The browser's `fetch` of `/api/dashboard/...`, served by the real dashboard route handlers. */ +async function dashboardRouteFetch(input: RequestInfo | URL, init?: RequestInit) { + const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const request = new Request(new URL(href, "http://dashboard.test"), { + method: init?.method ?? "GET", + headers: init?.headers, + }); + const route = new URL(request.url).pathname.split("/").filter(Boolean).slice(2); + const handler = BFF_ROUTES[route.join("/")]; + if (handler) return handler(request); + const [collection, second, id, child] = route; + if (collection === "counterparty" && second && id === "accounts") { + return counterpartyAccountsGET(request, { + params: Promise.resolve({ counterpartyId: decodeURIComponent(second) }), + }); + } + if (collection === "counterparty" && second && id === "provider-accounts") { + return providerAccountsGET(request, { + params: Promise.resolve({ counterpartyId: decodeURIComponent(second) }), + }); + } + if (collection === "payments" && second === "recurring-payments" && id && !child) { + return recurringPaymentGET(request, { + params: Promise.resolve({ recurringPaymentId: decodeURIComponent(id) }), + }); + } + throw new Error(`No dashboard route for ${href}`); +} + +function propsOf(element: unknown): T { + return (element as { props: T }).props; +} + +function demoData(path: string, now: Date = NOW): T { + const body = paymentsDemoBody(path, now) as { data: T } | undefined; + if (body === undefined) throw new Error(`No demo body for ${path}`); + return body.data; +} + +function demoContacts(): Counterparty[] { + return demoData("/v1/counterparties?page=1&pageSize=100") + .counterparties; +} + +function demoSchedules(): PaymentRecurringPayment[] { + return demoData<{ recurringPayments: PaymentRecurringPayment[] }>( + "/v1/payments/recurring-payments?page=1&pageSize=100" + ).recurringPayments; +} + +function demoTransfers(query = "page=1&pageSize=100", now: Date = NOW): PaymentTransferSummary[] { + return demoData(`/v1/payments/transfers?${query}`, now); +} + +function isNewestFirst(rows: readonly { createdAt?: string }[]): boolean { + return rows.every( + (row, index) => index === 0 || (rows[index - 1]?.createdAt ?? "") >= (row.createdAt ?? "") + ); +} + +beforeEach(() => { + harness.state.now = NOW; + harness.state.unhandled.length = 0; + vi.stubGlobal("fetch", dashboardRouteFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + // Every read a covered screen makes must have a demo answer. + expect(harness.state.unhandled).toEqual([]); +}); + +describe("paymentsDemoBody pass-through", () => { + it.each([ + "/v1/projects", + "/v1/onboarding/status", + "/v1/organizations/org_1/provider-access", + "/v1/organizations/org_1/members?page=1", + "/v1/rpc/connections", + "/v1/rpc/connections/rpc_1/usage", + "/v1/api-keys", + "/v1/policies", + "/v1/counterparties/cpty_real", + "/v1/counterparties/cpty_real/accounts?pageSize=100", + "/v1/counterparties/metadata", + "/v1/payments/transfers/xfr_real", + "/v1/payments/transfer-batches/batch_real", + "/v1/payments/recurring-payments/prp_real", + "/v1/payments/subscriptions/sub_real/collection-attempts?page=1&pageSize=25", + "/v1/wallets/cwlt_real", + "/v1/issuance/tokens/tok_1", + "/health", + ])("lets %s through to the real API", (path) => { + expect(paymentsDemoBody(path, NOW)).toBeUndefined(); + }); +}); + +describe("demo world", () => { + it("keeps every timestamp relative to now", () => { + const later = new Date("2027-03-02T08:30:00.000Z"); + for (const now of [NOW, later]) { + const [latest] = demoTransfers("page=1&pageSize=1", now); + const latestAge = now.getTime() - Date.parse(latest?.createdAt ?? ""); + expect(latestAge).toBeGreaterThan(0); + expect(latestAge).toBeLessThan(60 * 60_000); + + const schedules = demoData<{ recurringPayments: PaymentRecurringPayment[] }>( + "/v1/payments/recurring-payments?status=active", + now + ).recurringPayments; + for (const schedule of schedules) { + expect(Date.parse(schedule.nextCollectionDueAt ?? "")).toBeGreaterThan(now.getTime()); + } + const open = demoData<{ paymentRequests: PaymentRequest[] }>( + "/v1/payments/requests?status=awaiting_payment", + now + ).paymentRequests; + for (const request of open) { + if (request.expiresAt !== null) { + expect(Date.parse(request.expiresAt)).toBeGreaterThan(now.getTime()); + } + } + } + // Recomputed per call, never cached across clocks: the same row shifts with `now`. + const shift = + Date.parse(demoTransfers("page=1&pageSize=1", later)[0]?.createdAt ?? "") - + Date.parse(demoTransfers("page=1&pageSize=1", NOW)[0]?.createdAt ?? ""); + expect(shift).toBe(later.getTime() - NOW.getTime()); + }); + + it("cross-references every id it hands out", () => { + const walletIds = new Set( + demoData<{ wallets: PaymentsDashboardWallet[] }>("/v1/wallets").wallets.map( + (wallet) => wallet.id + ) + ); + const contactIds = new Set(demoContacts().map((contact) => contact.id)); + const transfers = demoTransfers(); + const transferIds = new Set(transfers.map((transfer) => transfer.id)); + + for (const transfer of transfers) { + expect(transfer.id).toMatch(/^demo_/); + expect(walletIds.has(transfer.custodyWalletId ?? "")).toBe(true); + if (transfer.counterpartyId) expect(contactIds.has(transfer.counterpartyId)).toBe(true); + if (transfer.signature) expect(transfer.signature).toMatch(BASE58_SIGNATURE); + if (transfer.source) expect(transfer.source).toMatch(BASE58_ADDRESS); + if (transfer.destination) expect(transfer.destination).toMatch(BASE58_ADDRESS); + expect(paymentsDemoBody(`/v1/payments/transfers/${transfer.id}`, NOW)).toEqual({ + data: { transfer }, + }); + } + + for (const schedule of demoSchedules()) { + expect(walletIds.has(schedule.sourceCustodyWalletId ?? "")).toBe(true); + const accounts = demoData<{ accounts: CounterpartyAccount[] }>( + `/v1/counterparties/${schedule.counterpartyId}/accounts?pageSize=100` + ).accounts; + expect(accounts.map((account) => account.id)).toContain(schedule.counterpartyAccountId); + expect(accounts[0]?.details.address).toBe(schedule.destinationAddress); + if (schedule.subscriptionId) { + const attempts = demoData<{ collectionAttempts: PaymentSubscriptionCollectionAttempt[] }>( + `/v1/payments/subscriptions/${schedule.subscriptionId}/collection-attempts?page=1&pageSize=25` + ).collectionAttempts; + for (const attempt of attempts) { + if (attempt.transferId) expect(transferIds.has(attempt.transferId)).toBe(true); + } + } + } + + for (const request of demoData<{ paymentRequests: PaymentRequest[] }>( + "/v1/payments/requests?page=1&pageSize=100" + ).paymentRequests) { + if (request.fulfilledByTransferId) { + const transfer = transfers.find(({ id }) => id === request.fulfilledByTransferId); + expect(transfer?.amount).toBe(request.amount); + expect(transfer?.kind).toBe("request_deposit"); + } + if (request.counterpartyId) expect(contactIds.has(request.counterpartyId)).toBe(true); + expect(request.reference).toMatch(BASE58_ADDRESS); + } + }); +}); + +describe("Payments overview", () => { + it("answers the balance with the sum of the wallets", async () => { + const wallets = await fetchPaymentsWallets(harness.request, { includeBalances: true }); + expect(wallets.ok).toBe(true); + expect(wallets.data?.map((wallet) => wallet.label)).toEqual([ + "Treasury", + "Payroll", + "Settlement", + ]); + for (const wallet of wallets.data ?? []) { + expect(wallet.publicKey).toMatch(BASE58_ADDRESS); + expect(wallet.balances?.length).toBeGreaterThan(0); + } + + const aggregate = await fetchPaymentsAggregate(harness.request); + expect(aggregate.ok).toBe(true); + expect(aggregate.data?.walletCount).toBe(3); + const walletSums = new Map(); + for (const balance of (wallets.data ?? []).flatMap((wallet) => wallet.balances ?? [])) { + walletSums.set(balance.mint, (walletSums.get(balance.mint) ?? 0n) + BigInt(balance.amount)); + } + expect( + new Map(aggregate.data?.balances.map((balance) => [balance.mint, BigInt(balance.amount)])) + ).toEqual(walletSums); + for (const balance of aggregate.data?.balances ?? []) { + expect(WELL_KNOWN_TOKEN_BY_MINT.get(balance.mint)?.symbol).toBe(balance.token); + } + + const balances = normalizeAggregateBalances(aggregate.data?.balances ?? []); + expect(resolveTotalBalance(balances)).toBeGreaterThan(200_000); + // Three or fewer balances keep the normalized order: USDC first, then by symbol. + expect(selectTopAggregateBalanceRows(balances, {}).map((row) => row.token)).toEqual([ + "USDC", + "EURC", + "SOL", + ]); + expect(await fetchIssuedTokensByMint(harness.request)).toEqual({}); + }); + + it("counts contacts, open requests and active schedules", async () => { + const [contacts, requests, schedules] = await Promise.all([ + fetchCounterparties(harness.request, { page: 1, pageSize: 1 }), + fetchPaymentRequests(harness.request, { pageSize: 1, status: "awaiting_payment" }), + fetchRecurringPayments(harness.request, t, { page: 1, pageSize: 1, status: "active" }), + ]); + expect(contacts).toMatchObject({ ok: true, total: 7 }); + expect(contacts.data).toHaveLength(1); + expect(requests).toMatchObject({ ok: true, total: 3 }); + expect(requests.data[0]?.status).toBe("awaiting_payment"); + expect(schedules.ok && schedules.data.total).toBe(2); + expect(schedules.ok && schedules.data.recurringPayments).toHaveLength(1); + }); + + it("lists recent transfers and batches", async () => { + const [transfers, batches, tokens, wallets] = await Promise.all([ + fetchPaymentTransfers(harness.request, 5, { + includeObserved: false, + types: ["transfer", "onramp", "offramp"], + }), + fetchTransferBatches(harness.request, 5), + fetchPaymentsIssuedTokenSymbols(harness.request), + fetchPaymentsWallets(harness.request, { view: "summary" }), + ]); + expect(transfers.ok).toBe(true); + expect(transfers.data).toHaveLength(5); + expect(isNewestFirst(transfers.data ?? [])).toBe(true); + for (const transfer of transfers.data ?? []) { + expect(["transfer", "onramp", "offramp"]).toContain(transfer.type); + } + expect(NOW.getTime() - Date.parse(transfers.data?.[0]?.createdAt ?? "")).toBeLessThan( + 60 * 60_000 + ); + expect(tokens).toEqual({ ok: true, data: [] }); + expect(wallets.ok).toBe(true); + expect(wallets.data?.every((wallet) => wallet.balances === undefined)).toBe(true); + + expect(batches.ok).toBe(true); + expect(batches.data).toHaveLength(2); + const summaries = []; + for (const batch of batches.data ?? []) { + const recipients = await fetchTransferBatchRecipients(harness.request, batch.id); + expect(recipients.ok).toBe(true); + expect(recipients.data).toHaveLength(batch.recipientCount); + summaries.push(summarizeBatch(batch, recipients.data).key); + } + expect(summaries).toEqual([ + "DashboardPayments.batchSummary.allSettled", + "DashboardPayments.batchSummary.someFailed", + ]); + }); + + it("merges persisted and observed transfers across wallets", async () => { + const result = await fetchDashboardPaymentTransfers(harness.request, 20, { + walletDeadlineMs: 2_500, + }); + expect(result.ok).toBe(true); + expect(result.walletsNotLoaded).toBe(0); + expect(result.data).toHaveLength(20); + expect(result.data?.some((transfer) => transfer.custodyWalletId === null)).toBe(true); + expect(isNewestFirst(result.data ?? [])).toBe(true); + }); +}); + +describe("Transactions", () => { + it("renders the page with varied rows tied to demo contacts and wallets", async () => { + const props = propsOf<{ + initialResult: { transactions: UnifiedTransaction[]; nextCursor: string | null }; + wallets: { id: string }[]; + counterparties: { id: string; name: string }[]; + }>(await TransactionsPage({ searchParams: Promise.resolve({}) })); + const rows = props.initialResult.transactions; + expect(rows.length).toBeGreaterThanOrEqual(16); + expect(props.initialResult.nextCursor).toBeNull(); + expect(isNewestFirst(rows)).toBe(true); + const contract = UNIFIED_TRANSACTION_MODULE_CONTRACTS.payments; + const walletIds = props.wallets.map((wallet) => wallet.id); + const contactIds = props.counterparties.map((contact) => contact.id); + for (const row of rows) { + expect(row.module).toBe("payments"); + expect(row.moduleId).toBe(row.id); + expect(contract.kinds).toContain(row.kind); + expect(contract.moduleStatuses).toContain(row.moduleStatus); + expect(row.status).toBe( + contract.status[row.moduleStatus as keyof typeof contract.status] ?? "missing" + ); + expect(walletIds).toContain(row.custodyWalletId); + expect(row.custodyWalletLabel).not.toBeNull(); + if (row.counterpartyId !== null) expect(contactIds).toContain(row.counterpartyId); + } + expect(new Set(rows.map((row) => row.kind))).toEqual( + new Set([ + "pay", + "deposit", + "onramp", + "offramp", + "batch_pay", + "recurring_pay", + "request_deposit", + ]) + ); + expect(new Set(rows.map((row) => row.status))).toEqual( + new Set(["pending", "succeeded", "failed", "canceled"]) + ); + }); + + it("pages with cursors and filters through the dashboard route", async () => { + const filters = parseTransactionFilters({ pageSize: "10" }); + const first = await fetchTransactionsPageFromDashboard(transactionsApiQuery(filters)); + expect(first.transactions).toHaveLength(10); + expect(first.nextCursor).not.toBeNull(); + const second = await fetchTransactionsPageFromDashboard( + transactionsApiQuery({ ...filters, cursor: first.nextCursor ?? undefined }) + ); + expect(second.transactions).toHaveLength(10); + const seen = new Set(first.transactions.map((row) => row.id)); + expect(second.transactions.some((row) => seen.has(row.id))).toBe(false); + + const [target] = first.transactions; + const searched = await fetchTransactionsPageFromDashboard( + transactionsApiQuery(parseTransactionFilters({ search: target?.id ?? "" })) + ); + expect(searched.transactions.map((row) => row.id)).toEqual([target?.id]); + + const failed = await fetchTransactionsPageFromDashboard( + transactionsApiQuery(parseTransactionFilters({ module: "payments", status: "failed" })) + ); + expect(failed.transactions.length).toBeGreaterThan(0); + expect(failed.transactions.every((row) => row.status === "failed")).toBe(true); + + const earn = await fetchTransactionsPageFromDashboard( + transactionsApiQuery(parseTransactionFilters({ module: "earn" })) + ); + expect(earn).toEqual({ transactions: [], nextCursor: null }); + }); +}); + +describe("Contacts", () => { + it("renders the list with every contact's saved address", async () => { + const props = propsOf<{ + counterparties: Counterparty[]; + total: number; + accounts: CounterpartyAccountSummary[]; + accountsTotal: number; + }>(await CounterpartyPage({ searchParams: Promise.resolve({}) })); + expect(props.total).toBe(7); + expect(props.counterparties).toHaveLength(7); + expect(props.accountsTotal).toBe(7); + expect(new Set(props.accounts.map((account) => account.counterpartyId))).toEqual( + new Set(props.counterparties.map((contact) => contact.id)) + ); + for (const account of props.accounts) expect(account.address).toMatch(BASE58_ADDRESS); + expect(new Set(props.counterparties.map((contact) => contact.entityType))).toEqual( + new Set(["business", "individual"]) + ); + expect(props.counterparties.filter((contact) => contact.externalId).length).toBeGreaterThan(1); + expect(isNewestFirst(props.counterparties)).toBe(true); + }); + + it("pages the list", async () => { + const page = await fetchCounterparties(harness.request, { page: 2, pageSize: 5 }); + expect(page).toMatchObject({ ok: true, total: 7 }); + expect(page.data).toHaveLength(2); + }); + + it.each(demoContacts().map((contact) => [contact.displayName, contact.id]))( + "renders %s's detail with an account and transfers", + async (_name, counterpartyId) => { + const detail = propsOf<{ children: unknown }>( + await CounterpartyDetailRoute({ params: Promise.resolve({ counterpartyId }) }) + ); + const props = propsOf<{ + counterparty: Counterparty; + initialAccounts: CounterpartyAccount[]; + initialTransfers: PaymentTransferSummary[]; + }>(detail.children); + expect(props.counterparty.id).toBe(counterpartyId); + expect(props.initialAccounts).toHaveLength(1); + expect(props.initialAccounts[0]?.details.address).toMatch(BASE58_ADDRESS); + expect(props.initialTransfers.length).toBeGreaterThan(0); + for (const transfer of props.initialTransfers) { + expect(transfer.counterpartyId).toBe(counterpartyId); + expect(typeof transfer.rampsMemo).toBe("object"); + } + + const providerAccounts = await dashboardFetch<{ + data: ListCounterpartyProviderAccountsResponse; + }>(`/api/dashboard/counterparty/${encodeURIComponent(counterpartyId)}/provider-accounts`); + expect(providerAccounts.ok).toBe(true); + const rows = providerAccounts.ok ? providerAccounts.data.data.accounts : []; + // Every demo contact has a payout account, so the page's tables all have rows. + expect(rows).toHaveLength(1); + for (const row of rows) { + expect(row).toMatchObject({ kind: "payout_account", status: "active" }); + expect(row.accountNumberLast4).toMatch(/^\d{4}$/); + } + } + ); +}); + +describe("Requests", () => { + it("renders the list across every status, tied to demo wallets and contacts", async () => { + const props = propsOf<{ + initialPaymentRequests: PaymentRequest[]; + total: number; + initialError: string | undefined; + counterparties: Counterparty[]; + }>(await PaymentRequestsPage({ searchParams: Promise.resolve({}) })); + expect(props.initialError).toBeUndefined(); + expect(props.total).toBe(props.initialPaymentRequests.length); + expect(props.initialPaymentRequests.length).toBeGreaterThanOrEqual(6); + expect(new Set(props.initialPaymentRequests.map((request) => request.status))).toEqual( + new Set(["awaiting_payment", "paid", "expired", "canceled"]) + ); + const contactIds = props.counterparties.map((contact) => contact.id); + for (const request of props.initialPaymentRequests) { + if (request.counterpartyId) expect(contactIds).toContain(request.counterpartyId); + expect(request.lifecycle[0]?.status).toBe("awaiting_payment"); + } + expect(isNewestFirst(props.initialPaymentRequests)).toBe(true); + }); + + it("opens each request's page on a demo wallet, naming its contact", async () => { + const { initialPaymentRequests } = propsOf<{ initialPaymentRequests: PaymentRequest[] }>( + await PaymentRequestsPage({ searchParams: Promise.resolve({}) }) + ); + for (const request of initialPaymentRequests) { + const detail = propsOf<{ children: unknown }>( + await PaymentRequestDetailRoute({ params: Promise.resolve({ requestId: request.id }) }) + ); + const props = propsOf<{ + request: PaymentRequest | null; + contactName: string | null; + walletName: string | null; + }>(detail.children); + expect(props.request?.id).toBe(request.id); + expect(props.walletName).not.toBeNull(); + expect(props.contactName === null).toBe(request.counterpartyId === null); + } + }); +}); + +describe("Schedules", () => { + it("renders the list with every contact resolved", async () => { + const props = propsOf<{ children: unknown }>( + await RecurringPaymentsPage({ searchParams: Promise.resolve({}) }) + ); + const workspace = propsOf<{ + initialRecurringPayments: PaymentRecurringPayment[]; + total: number; + wallets: PaymentsDashboardWallet[]; + counterparties: { id: string; displayName: string }[]; + lookupError: string | undefined; + }>(props.children); + expect(workspace.total).toBe(4); + expect(workspace.lookupError).toBeUndefined(); + expect(workspace.counterparties).toHaveLength(4); + expect(new Set(workspace.initialRecurringPayments.map((schedule) => schedule.status))).toEqual( + new Set(["active", "pending_activation", "canceled"]) + ); + expect( + new Set(workspace.initialRecurringPayments.map((schedule) => schedule.periodHours)) + ).toEqual(new Set([168, 720])); + + const active = propsOf<{ children: unknown }>( + await RecurringPaymentsPage({ searchParams: Promise.resolve({ status: "active" }) }) + ); + expect(propsOf<{ total: number }>(active.children).total).toBe(2); + }); + + it.each(demoSchedules().map((schedule) => [schedule.id, schedule.status]))( + "renders %s (%s) with its wallet, contact account and collection history", + async (recurringPaymentId, status) => { + const props = propsOf<{ + recurringPayment: PaymentRecurringPayment; + wallet: PaymentsDashboardWallet | null; + counterpartyAccounts: CounterpartyAccount[]; + counterpartyLabel: string; + collectionAttempts: PaymentSubscriptionCollectionAttempt[]; + collectionAttemptsTotal: number; + collectionAttemptsError: string | undefined; + }>(await RecurringPaymentDetailRoute({ params: Promise.resolve({ recurringPaymentId }) })); + const contact = await fetchCounterparty( + harness.request, + props.recurringPayment.counterpartyId + ); + expect(props.recurringPayment.id).toBe(recurringPaymentId); + expect(props.wallet?.id).toBe(props.recurringPayment.sourceCustodyWalletId); + expect(props.counterpartyLabel).toBe(contact?.displayName); + expect(props.counterpartyAccounts.map((account) => account.id)).toEqual([ + props.recurringPayment.counterpartyAccountId, + ]); + expect(WELL_KNOWN_TOKEN_BY_MINT.get(props.recurringPayment.token)?.symbol).toBe("USDC"); + expect(props.collectionAttemptsError).toBeUndefined(); + expect(props.collectionAttemptsTotal).toBe(props.collectionAttempts.length); + if (status === "pending_activation") { + expect(props.recurringPayment.subscriptionId).toBeNull(); + expect(props.collectionAttempts).toEqual([]); + } else { + expect(props.collectionAttempts.length).toBeGreaterThan(1); + } + if (status === "active") { + expect(Date.parse(props.recurringPayment.nextCollectionDueAt ?? "")).toBeGreaterThan( + NOW.getTime() + ); + } + } + ); + + it("shows a failed collection with a readable reason, then its retry", async () => { + const weekly = demoSchedules().find((schedule) => schedule.periodHours === 168); + const result = await fetchRecurringPaymentCollectionAttempts( + harness.request, + weekly?.subscriptionId ?? "", + t + ); + expect(result.ok).toBe(true); + const attempts = result.ok ? result.data.collectionAttempts : []; + const failed = attempts.find((attempt) => attempt.status === "failed"); + expect(failed?.error).toMatch(/balance too low/); + expect(failed?.transferId).toBeNull(); + const retry = attempts.find((attempt) => attempt.metadata.source === "retry"); + expect(retry).toMatchObject({ status: "confirmed", metadata: { error: failed?.error } }); + }); + + it("loads the client list and detail through the dashboard routes", async () => { + const list = await listRecurringPayments({ page: 1, pageSize: 25, status: "active" }, t); + expect(list.total).toBe(2); + const [first] = list.recurringPayments; + const detail = await getRecurringPayment(first?.id ?? "", undefined, t); + expect(detail).toEqual(first); + }); +}); + +describe("Pay and Deposit", () => { + it("offers wallets with balances and contacts with their accounts", async () => { + const wallets = await fetchWallets({ includeBalances: true }, t); + expect(wallets).toHaveLength(3); + expect(wallets.every((wallet) => (wallet.balances?.length ?? 0) > 0)).toBe(true); + expect((await fetchWalletAggregate(t)).walletCount).toBe(3); + + const contacts = await fetchAllCounterparties(); + expect(contacts.ok).toBe(true); + expect(contacts.data).toHaveLength(7); + for (const contact of contacts.data) { + const accounts = await fetchCounterpartyAccounts(contact.id, t); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ accountKind: "crypto_wallet", status: "active" }); + } + + const recipients = await fetchBatchRecipients({ page: 1, pageSize: 25 }, t); + expect(recipients.total).toBe(7); + const kai = await fetchBatchRecipients({ page: 1, pageSize: 25, search: "kai" }, t); + expect(kai.accounts.map((account) => account.name)).toEqual(["Kai Nakamura"]); + const byId = await fetchBatchRecipients( + { ids: [kai.accounts[0]?.counterpartyAccountId ?? ""] }, + t + ); + expect(byId.accounts).toEqual(kai.accounts); + }); + + it("shows each wallet's recent inbound deposits, observed ones included", async () => { + const wallets = await fetchWallets({ includeBalances: true }, t); + for (const wallet of wallets) { + const deposits = await fetchTransfers( + { pageSize: 5, custodyWalletId: wallet.id, direction: "inbound", includeObserved: true }, + t + ); + expect(deposits.length).toBeGreaterThan(0); + expect(deposits.length).toBeLessThanOrEqual(5); + expect(isNewestFirst(deposits)).toBe(true); + for (const deposit of deposits) { + expect(deposit.direction).toBe("inbound"); + expect(deposit.destination).toBe(wallet.publicKey); + } + } + const treasury = await fetchTransfers( + { pageSize: 5, custodyWalletId: wallets[0]?.id, direction: "inbound", includeObserved: true }, + t + ); + expect(treasury).toHaveLength(5); + expect(treasury.some((deposit) => deposit.custodyWalletId === null)).toBe(true); + expect(treasury.some((deposit) => deposit.type === "onramp")).toBe(true); + }); + + it("merges every wallet's transfers when the route has no direct filter", async () => { + const transfers = await fetchTransfers({ pageSize: 10 }, t); + expect(transfers).toHaveLength(10); + expect(isNewestFirst(transfers)).toBe(true); + }); +}); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts b/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts new file mode 100644 index 0000000000..8f1b58b99c --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts @@ -0,0 +1,1517 @@ +import { + OFFRAMP_SUPPORT, + ONRAMP_SUPPORT, + type PaymentRampEstimate, + type PaymentRampQuote, + RAMP_PROVIDERS, + type RampFiatCurrency, + type RampProviderEstimateResult, + type RampProviderId, +} from "@sdp/types"; +import { type CryptoRailId, getCryptoRailAssetLabel } from "@sdp/types/payment-rails"; +import type { + CounterpartyRequirements, + PayoutRequirementAccount, + PayoutRequirementTree, + RequirementField, + RequirementOption, +} from "@sdp/types/ramp-requirements"; +import type { Address } from "@solana/kit"; +import { z } from "zod"; +import { getRampProviderLabel } from "../ramps"; +import { + DEMO_TOKENS, + type DemoWorld, + findWallet, + fromBaseUnits, + MINUTE_MS, + symbolForMint, + toBaseUnits, +} from "./demo-fixtures"; +import { type DemoOp, newDemoId } from "./demo-ops"; +import { + accountById, + consentKey, + contactById, + DEMO_BATCH_RECIPIENTS_PER_TRANSACTION, + mintForRail, + rampDepositAddress, + rampReference, + tokenKeyForMint, + transferById, + walletHolding, +} from "./demo-replay"; + +/* + * What the demo answers for the writes and mid-flow reads the Payments screens make: the same + * envelopes the SDP API sends, after the checks it would run, so a demo walks every flow to its + * last screen. A write records its action for the session (see demo-mode.ts); the answer is read + * from the world with that action applied. + */ + +export interface DemoAnswer { + status: number; + body?: unknown; +} + +export interface DemoWriteResult { + ops: DemoOp[]; + answer: (world: DemoWorld) => DemoAnswer; +} + +interface WriteContext { + segments: readonly string[]; + body: unknown; + world: DemoWorld; + ops: readonly DemoOp[]; + now: Date; +} + +/** The provider whose payouts go to a contact's saved bank account. */ +const PAYOUT_ACCOUNT_PROVIDER = "lightspark"; + +const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; +const DECIMAL = /^\d+(\.\d+)?$/; + +const amountSchema = z + .string() + .trim() + .regex(DECIMAL, "Enter an amount, like 25.00.") + .refine((value) => Number(value) > 0, "Enter an amount greater than zero."); +const addressSchema = z.string().trim().regex(SOLANA_ADDRESS, "Enter a valid Solana address."); + +function ok(body: unknown, status = 200): DemoAnswer { + return { status, body }; +} + +function error(status: number, message: string, code = "invalid_request"): DemoWriteResult { + return { ops: [], answer: () => ({ status, body: { error: { code, message } } }) }; +} + +function record(ops: DemoOp[], answer: (world: DemoWorld) => DemoAnswer): DemoWriteResult { + return { ops, answer }; +} + +function parse(schema: z.ZodType, body: unknown): { data: T } | { failure: DemoWriteResult } { + const parsed = schema.safeParse(body); + if (parsed.success) return { data: parsed.data }; + const issue = parsed.error.issues[0]; + const field = issue?.path.join("."); + return { + failure: error( + 400, + issue ? `${field ? `${field}: ` : ""}${issue.message}` : "The request is not valid." + ), + }; +} + +function tokenSymbol(mint: string): string { + return symbolForMint(mint) ?? "tokens"; +} + +/** Null when the wallet holds enough of the token; otherwise why it cannot pay. */ +function shortfall(world: DemoWorld, walletId: string, mint: string, amount: string) { + const wallet = findWallet(world, walletId); + const key = tokenKeyForMint(mint); + if (!wallet) return "That wallet is not in this project."; + if (!key) return "The demo wallets don't hold that token."; + const { decimals } = DEMO_TOKENS[key]; + const held = walletHolding(wallet, mint); + if (toBaseUnits(held, decimals) >= toBaseUnits(amount, decimals)) return null; + return `Not enough ${tokenSymbol(mint)} in ${wallet.label ?? "this wallet"}: ${held} available.`; +} + +function scaled(value: number, decimals: number): string { + return value.toFixed(decimals); +} + +// ─── Contacts ──────────────────────────────────────────────────────────────── + +const createContactSchema = z.object({ + entityType: z.enum(["individual", "business"]), + displayName: z.string().trim().min(1, "Enter a name.").max(120), + externalId: z.string().trim().max(120).nullish(), +}); + +function createContact({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(createContactSchema, body); + if ("failure" in input) return input.failure; + const externalId = input.data.externalId || null; + if ( + externalId !== null && + Object.values(world.contacts).some((contact) => contact.externalId === externalId) + ) { + return error(409, "A contact with that external ID already exists.", "conflict"); + } + const id = newDemoId("cpty"); + return record( + [ + { + k: "contact", + id, + at: now.getTime(), + name: input.data.displayName, + entity: input.data.entityType, + ext: externalId, + }, + ], + (after) => ok({ data: { counterparty: contactById(after, id) } }, 201) + ); +} + +function archiveContact({ segments, world, now }: WriteContext): DemoWriteResult { + const id = segments[1] ?? ""; + if (!contactById(world, id)) return error(404, "Contact not found.", "not_found"); + return record([{ k: "contact-archive", id, at: now.getTime() }], () => ({ status: 204 })); +} + +const createAccountSchema = z.object({ + accountKind: z.literal("crypto_wallet"), + label: z.string().trim().max(120).nullish(), + details: z.object({ network: z.string().optional(), address: addressSchema }), +}); + +function createAccount({ segments, body, world, now }: WriteContext): DemoWriteResult { + const counterpartyId = segments[1] ?? ""; + const contact = contactById(world, counterpartyId); + if (!contact) return error(404, "Contact not found.", "not_found"); + const input = parse(createAccountSchema, body); + if ("failure" in input) return input.failure; + const address = input.data.details.address; + const duplicate = Object.values(world.accounts).some( + (account) => account.counterpartyId === counterpartyId && account.details.address === address + ); + if (duplicate) { + return error(409, `This address is already saved for ${contact.displayName}.`, "conflict"); + } + const id = newDemoId("cpa"); + return record( + [ + { + k: "address", + id, + at: now.getTime(), + cp: counterpartyId, + address, + label: input.data.label || null, + }, + ], + (after) => ok({ data: { account: accountById(after, id) } }, 201) + ); +} + +// ─── Compliance ────────────────────────────────────────────────────────────── + +const screeningSchema = z.object({ address: addressSchema }); + +function screenAddress({ body, now }: WriteContext): DemoWriteResult { + const input = parse(screeningSchema, body); + if ("failure" in input) return input.failure; + const evaluatedAt = now.toISOString(); + return record([], () => + ok({ + data: { + screening: { + checkedAt: evaluatedAt, + providers: [ + { provider: "range", status: "ok", riskScore: 1, riskLevel: "low", evaluatedAt }, + ], + }, + }, + }) + ); +} + +// ─── Ramps: requirements ───────────────────────────────────────────────────── + +interface Corridor { + country: string; + rails: RequirementOption[]; +} + +/** A rail as the provider lists it, value and English name (the API's own language). */ +function rail(value: string, name = value): RequirementOption { + return { value, label: name }; +} + +/** A bank detail the provider asks for, with the pattern it checks. */ +function bankField(key: string, name: string, pattern?: string): RequirementField { + return { kind: "text", key, label: name, required: true, ...(pattern ? { pattern } : {}) }; +} + +const PAYOUT_CORRIDORS: Record = { + USD: [{ country: "US", rails: [rail("ACH"), rail("WIRE", "Wire")] }], + EUR: [ + { country: "DE", rails: [rail("SEPA")] }, + { country: "FR", rails: [rail("SEPA")] }, + { country: "IE", rails: [rail("SEPA")] }, + ], + GBP: [{ country: "GB", rails: [rail("FPS", "Faster Payments")] }], +}; + +const BANK_NAME_FIELD = bankField("bankName", "Bank name"); +const US_ACCOUNT_FIELDS = [ + BANK_NAME_FIELD, + bankField("accountNumber", "Account number", "^\\d{4,17}$"), + bankField("routingNumber", "Routing number", "^\\d{9}$"), +]; + +const RAIL_FIELDS: Record = { + ACH: US_ACCOUNT_FIELDS, + WIRE: US_ACCOUNT_FIELDS, + SEPA: [BANK_NAME_FIELD, bankField("iban", "IBAN", "^[A-Z]{2}\\d{2}[A-Z0-9]{10,30}$")], + FPS: [ + BANK_NAME_FIELD, + bankField("accountNumber", "Account number", "^\\d{8}$"), + bankField("sortCode", "Sort code", "^\\d{6}$"), + ], +}; + +function corridorsFor(fiatCurrency: string): Corridor[] { + return PAYOUT_CORRIDORS[fiatCurrency] ?? PAYOUT_CORRIDORS.USD ?? []; +} + +function savedPayoutAccounts( + world: DemoWorld, + counterpartyId: string, + fiatCurrency: string +): PayoutRequirementAccount[] { + return world.providerAccounts + .filter( + (entry) => + entry.counterpartyId === counterpartyId && + entry.account.provider === PAYOUT_ACCOUNT_PROVIDER && + entry.account.fiatCurrency === fiatCurrency && + entry.account.destinationCountry !== null + ) + .map(({ account }) => ({ + id: account.id, + destinationCountry: + account.destinationCountry as PayoutRequirementAccount["destinationCountry"], + paymentRail: account.paymentRail, + status: "ACTIVE", + ...(account.bankName ? { bankName: account.bankName } : {}), + ...(account.accountNumberLast4 ? { accountNumberLast4: account.accountNumberLast4 } : {}), + })); +} + +function payoutTree( + world: DemoWorld, + counterpartyId: string, + fiatCurrency: string +): PayoutRequirementTree { + const corridors = corridorsFor(fiatCurrency); + const rails = new Set(corridors.flatMap((corridor) => corridor.rails.map((rail) => rail.value))); + return { + countryRails: Object.fromEntries( + corridors.map((corridor) => [corridor.country, corridor.rails]) + ) as PayoutRequirementTree["countryRails"], + railFields: Object.fromEntries( + [...rails].map((rail) => [rail, RAIL_FIELDS[rail] ?? [BANK_NAME_FIELD]]) + ), + accounts: savedPayoutAccounts(world, counterpartyId, fiatCurrency), + }; +} + +function isRampProvider(value: string): value is RampProviderId { + return (RAMP_PROVIDERS as readonly string[]).includes(value); +} + +/** + * BVNK's agreements, as it asks a contact to accept them before its first ramp. The links are + * BVNK's public legal pages, which the consent step only offers to open. + */ +function bvnkAgreement(name: string, displayName: string, description: string, page: string) { + return { + name, + displayName, + description, + url: `https://www.bvnk.com/legal/${page}`, + privacyPolicyUrl: "https://www.bvnk.com/legal/privacy-policy", + }; +} + +const BVNK_AGREEMENTS = [ + bvnkAgreement( + "bvnk_terms_of_service", + "BVNK Terms of Service", + "How BVNK holds, converts and pays out funds for this contact.", + "terms-of-service" + ), +]; + +function bvnkAgreements(direction: "onramp" | "offramp"): CounterpartyRequirements { + return { + provider: "bvnk", + direction, + status: "counterparty_collect_agreement", + agreements: BVNK_AGREEMENTS, + }; +} + +/** How long BVNK reviews a contact's identity check, then takes to open the contact's account. */ +const BVNK_REVIEW_MS = 8_000; +const BVNK_ACCOUNT_SETUP_MS = 5_000; +/** Where BVNK's hosted identity check opens; demo mode approves it with Simulate verification. */ +const BVNK_VERIFICATION_URL = "https://www.bvnk.com/"; + +/** + * Where a demo contact stands with BVNK, as its onboarding runs: the agreements to accept, then + * the identity check waiting for the contact, then BVNK's review for a few seconds once Simulate + * verification approves it, then the account BVNK opens, then ready. + */ +function bvnkStanding( + world: DemoWorld, + counterpartyId: string, + direction: "onramp" | "offramp", + now: Date +): CounterpartyRequirements { + const key = consentKey("bvnk", counterpartyId); + if (!world.consents.includes(key)) return bvnkAgreements(direction); + const approvedAt = world.verifications[key]; + if (approvedAt === undefined) { + return { + provider: "bvnk", + direction, + status: "customer_verification_required", + verificationUrl: BVNK_VERIFICATION_URL, + }; + } + const elapsed = now.getTime() - approvedAt; + if (elapsed < BVNK_REVIEW_MS) + return { provider: "bvnk", direction, status: "customer_verifying" }; + if (elapsed < BVNK_REVIEW_MS + BVNK_ACCOUNT_SETUP_MS) { + return { provider: "bvnk", direction, status: "customer_funding_account_provisioning" }; + } + return { provider: "bvnk", direction, status: "ready" }; +} + +/** + * Where a demo contact stands with a ramp provider. Most are ready at once; BVNK first runs its + * onboarding (agreements, then an identity check); a Lightspark payout asks which bank account to + * pay, offering the ones saved and a form for a new one. + */ +function requirementsFor( + world: DemoWorld, + counterpartyId: string, + params: URLSearchParams, + now: Date +): CounterpartyRequirements { + const provider = params.get("provider") ?? PAYOUT_ACCOUNT_PROVIDER; + const direction = params.get("direction") === "offramp" ? "offramp" : "onramp"; + if (!isRampProvider(provider)) { + return { + provider: PAYOUT_ACCOUNT_PROVIDER, + direction, + status: "unsupported", + reason: "That provider isn't part of the demo.", + }; + } + if (provider === "bvnk") return bvnkStanding(world, counterpartyId, direction, now); + if (provider !== "lightspark") return { provider, direction, status: "ready" }; + if (direction === "onramp") return { provider, direction, status: "ready" }; + return { + provider, + direction, + status: "collect_account", + payout: payoutTree(world, counterpartyId, params.get("fiatCurrency") ?? "USD"), + }; +} + +const advanceSchema = z.object({ + provider: z.string(), + direction: z.enum(["onramp", "offramp"]), + fiatCurrency: z.string().default("USD"), + collectedData: z.record(z.string(), z.string()).optional(), + providerAccountId: z.string().optional(), + agreementConsent: z.boolean().optional(), +}); + +function advanceRequirements({ segments, body, world, now }: WriteContext): DemoWriteResult { + const counterpartyId = segments[1] ?? ""; + if (!contactById(world, counterpartyId)) return error(404, "Contact not found.", "not_found"); + const input = parse(advanceSchema, body); + if ("failure" in input) return input.failure; + const { provider, direction, fiatCurrency, collectedData, providerAccountId } = input.data; + if (!isRampProvider(provider)) return error(400, "That provider isn't part of the demo."); + if (provider === "bvnk") { + const standing = bvnkStanding(world, counterpartyId, direction, now); + if (standing.status !== "counterparty_collect_agreement" || !input.data.agreementConsent) { + return record([], () => ok({ data: standing })); + } + // Accepting the agreements moves the contact on to BVNK's identity check. + return record([{ k: "consent", id: counterpartyId, at: now.getTime(), provider }], () => + ok({ + data: bvnkStanding( + { ...world, consents: [...world.consents, consentKey(provider, counterpartyId)] }, + counterpartyId, + direction, + now + ), + }) + ); + } + if (provider !== "lightspark" || direction === "onramp") { + return record([], () => ok({ data: { provider, direction, status: "ready" } })); + } + const ready = (id: string) => + ok({ data: { provider, direction, status: "ready", providerAccountId: id } }); + if (providerAccountId !== undefined) { + const saved = savedPayoutAccounts(world, counterpartyId, fiatCurrency); + if (!saved.some((account) => account.id === providerAccountId)) { + return error(404, "That bank account is not saved for this contact.", "not_found"); + } + return record([], () => ready(providerAccountId)); + } + const country = collectedData?.destinationCountry; + const corridor = corridorsFor(fiatCurrency).find((candidate) => candidate.country === country); + if (!country || !corridor) return error(400, "Choose the country the bank account is in."); + const rail = collectedData?.paymentRails ?? corridor.rails[0]?.value ?? "ACH"; + const accountNumber = collectedData?.accountNumber ?? collectedData?.iban ?? ""; + const id = newDemoId("cppa"); + return record( + [ + { + k: "payout-account", + id, + at: now.getTime(), + cp: counterpartyId, + country, + rail, + fiat: fiatCurrency, + bank: collectedData?.bankName?.trim() || null, + last4: accountNumber.length >= 4 ? accountNumber.slice(-4) : null, + }, + ], + () => ready(id) + ); +} + +// ─── Ramps: estimates and quotes ───────────────────────────────────────────── + +/** US dollars per unit of each fiat currency, for demo rates. */ +const USD_PER_UNIT: Record = { + USD: 1, + EUR: 1.08, + GBP: 1.27, + CAD: 0.73, + AUD: 0.66, + MXN: 0.055, + BRL: 0.18, + ARS: 0.0011, + NGN: 0.00065, + KES: 0.0077, +}; +/** Each provider's cut of what is sent, so the provider cards compare the way real ones do. */ +const PROVIDER_FEE_RATE: Record = { + lightspark: 0.01, + bvnk: 0.008, + mural: 0.006, + moonpay: 0.035, + coinbase: 0.015, + moneygram: 0.02, + stripe: 0.029, +}; +const QUOTE_TTL_MS = 15 * MINUTE_MS; + +type RampDirection = "onramp" | "offramp"; + +function usdPerUnit(fiatCurrency: string): number { + return USD_PER_UNIT[fiatCurrency] ?? 1; +} + +/** The providers that run a pair, as the support tables list them. */ +function pairProviders( + direction: RampDirection, + fiatCurrency: string, + assetRail: string +): readonly RampProviderId[] { + const row = + direction === "onramp" + ? ONRAMP_SUPPORT.find((entry) => entry.source === fiatCurrency && entry.dest === assetRail) + : OFFRAMP_SUPPORT.find((entry) => entry.source === assetRail && entry.dest === fiatCurrency); + return row?.providers ?? []; +} + +interface RampAmounts { + fiat: number; + crypto: number; + fee: number; + /** Units of crypto per unit of fiat. */ + rate: number; + mint: string; + symbol: string; + decimals: number; +} + +/** Both sides of a ramp at the demo rate, the provider's fee taken from what is sent. */ +function rampAmounts( + provider: RampProviderId, + direction: RampDirection, + fiatCurrency: string, + assetRail: string, + amount: number +): RampAmounts { + const mint = mintForRail(assetRail); + const token = DEMO_TOKENS[tokenKeyForMint(mint) ?? "USDC"]; + const rate = usdPerUnit(fiatCurrency) / token.usdPrice; + const feeRate = PROVIDER_FEE_RATE[provider]; + const common = { rate, mint, symbol: token.symbol, decimals: token.decimals }; + if (direction === "onramp") { + const fee = amount * feeRate; + return { ...common, fiat: amount, crypto: (amount - fee) * rate, fee }; + } + const gross = amount / rate; + const fee = gross * feeRate; + return { ...common, fiat: gross - fee, crypto: amount, fee }; +} + +/** A crypto amount as a person reads it: cents for a stablecoin, four places for SOL. */ +function cryptoText(amounts: RampAmounts): string { + return scaled(amounts.crypto, amounts.symbol === "SOL" ? 4 : 2); +} + +const estimateSchema = z.object({ + assetRail: z.string(), + fiatCurrency: z.string(), + fiatAmount: z.string().optional(), + cryptoAmount: z.string().optional(), +}); + +function estimate({ segments, body, now }: WriteContext): DemoWriteResult { + const direction = segments[2] === "offramp" ? "offramp" : "onramp"; + const input = parse(estimateSchema, body); + if ("failure" in input) return input.failure; + const { assetRail, fiatCurrency } = input.data; + const raw = direction === "onramp" ? input.data.fiatAmount : input.data.cryptoAmount; + const amount = raw !== undefined && DECIMAL.test(raw) ? Number(raw) : 0; + const expiresAt = new Date(now.getTime() + QUOTE_TTL_MS).toISOString(); + const estimates: RampProviderEstimateResult[] = pairProviders( + direction, + fiatCurrency, + assetRail + ).map((provider) => { + const amounts = rampAmounts(provider, direction, fiatCurrency, assetRail, amount); + return { + provider, + status: "ok", + estimate: { + provider, + direction, + fiatCurrency: fiatCurrency as PaymentRampEstimate["fiatCurrency"], + assetRail: assetRail as PaymentRampEstimate["assetRail"], + fiatAmount: scaled(amounts.fiat, 2), + cryptoAmount: cryptoText(amounts), + exchangeRate: scaled(amounts.rate, 6), + fees: { + currency: fiatCurrency as PaymentRampEstimate["fiatCurrency"], + total: scaled(amounts.fee, 2), + }, + expiresAt, + }, + }; + }); + return record([], () => ok({ data: { estimates } })); +} + +const onrampQuoteSchema = z.object({ + provider: z.string(), + counterpartyId: z.string().min(1), + destinationCustodyWalletId: z.string().min(1), + assetRail: z.string(), + fiatCurrency: z.string(), + fiatAmount: amountSchema, +}); + +const offrampQuoteSchema = z.object({ + provider: z.string(), + counterpartyId: z.string().min(1), + sourceCustodyWalletId: z.string().min(1), + assetRail: z.string(), + cryptoAmount: amountSchema, + fiatCurrency: z.string().default("USD"), + providerAccountId: z.string().optional(), +}); + +/** The bank account an on-ramp's customer pays into, as the provider shows it. */ +function fundingAccount(fiatCurrency: string, reference: string) { + if (fiatCurrency === "EUR") { + return { + accountType: "IBAN", + accountNumber: "DE89370400440532013000", + paymentRails: ["SEPA"], + reference, + bankName: "Lightspark Sandbox Bank", + }; + } + if (fiatCurrency === "GBP") { + return { + accountType: "GB_ACCOUNT", + accountNumber: "31926819", + routingNumber: "601613", + paymentRails: ["FPS"], + reference, + bankName: "Lightspark Sandbox Bank", + }; + } + return { + accountType: "US_ACCOUNT", + accountNumber: "000123456789", + routingNumber: "021000021", + paymentRails: ["ACH", "WIRE"], + reference, + bankName: "Lightspark Sandbox Bank", + }; +} + +/** Everything a provider's demo quote is drawn from. */ +interface QuoteContext { + quoteId: string; + transferId: string; + reference: string; + expiresAt: string; + fiatCurrency: string; + amounts: RampAmounts; + crypto: string; + /** The wallet the crypto lands in (on-ramp) or leaves from (off-ramp). */ + walletAddress: string; + /** Where an off-ramp's crypto is sent. */ + depositAddress: Address; +} + +function bvnkBankAccount(fiatCurrency: string, reference: string) { + return fiatCurrency === "EUR" + ? { + accountNumber: "GB33BUKB20201555555555", + code: "BUKBGB22", + accountNumberFormat: "IBAN", + paymentReference: reference, + bankName: "BVNK Sandbox Bank", + } + : { + accountNumber: "8310005521", + routingNumber: "026073150", + accountNumberFormat: "ACH", + paymentReference: reference, + bankName: "BVNK Sandbox Bank", + }; +} + +function muralBankDetails(fiatCurrency: string, reference: string): Record { + return { + bankName: "Mural Sandbox Bank", + beneficiaryName: "Mural Pay Sandbox", + accountNumber: fiatCurrency === "USD" ? "7721009934" : "CLABE 646180157000000004", + ...(fiatCurrency === "USD" ? { routingNumber: "101019644" } : {}), + reference, + }; +} + +/** A provider's on-ramp quote: bank instructions, a hosted checkout, or a widget session. */ +function onrampQuoteFor(provider: RampProviderId, q: QuoteContext): PaymentRampQuote { + const base = { id: q.quoteId, status: "pending" as const }; + const { amounts, fiatCurrency, reference } = q; + switch (provider) { + case "lightspark": + return { + ...base, + provider, + deliveryMode: "manual_instructions", + paymentInstructions: [ + { + provider, + accountOrWalletInfo: fundingAccount(fiatCurrency, reference), + instructionsNotes: "Include the reference so the payment is matched to this deposit.", + isPlatformAccount: true, + }, + ], + exchangeRate: amounts.rate, + totalSendingAmount: Math.round(amounts.fiat * 100), + sendingCurrency: { code: fiatCurrency, decimals: 2 }, + totalReceivingAmount: Number(toBaseUnits(q.crypto, amounts.decimals)), + receivingCurrency: { code: amounts.symbol, decimals: amounts.decimals }, + feesIncluded: Math.round(amounts.fee * 100), + feeCurrency: { code: fiatCurrency, decimals: 2 }, + expiresAt: q.expiresAt, + }; + case "bvnk": + return { + ...base, + provider, + deliveryMode: "manual_instructions", + paymentInstructions: [ + { + provider, + kind: "fiat_funding", + onboardingStatus: "ready", + fiatCurrency, + beneficiaryAddress: q.walletAddress, + network: "SOLANA", + bankAccount: bvnkBankAccount(fiatCurrency, reference), + paymentReference: reference, + instructionsNotes: "Include the reference so BVNK matches the payment to this deposit.", + }, + ], + }; + case "mural": + return { + ...base, + provider, + deliveryMode: "manual_instructions", + paymentInstructions: [ + { + provider, + fiatCurrency, + payinRails: fiatCurrency === "USD" ? ["ACH", "WIRE"] : ["SPEI"], + bankDetails: muralBankDetails(fiatCurrency, reference), + }, + ], + }; + case "moonpay": + return { + ...base, + provider, + deliveryMode: "hosted", + hostedUrl: `https://buy-sandbox.moonpay.com/?externalTransactionId=${q.transferId}`, + }; + case "coinbase": + return { + ...base, + provider, + deliveryMode: "hosted", + hostedUrl: `https://pay.coinbase.com/v2/api-onramp/purchase?orderId=${q.quoteId}`, + paymentCurrency: fiatCurrency, + paymentSubtotal: scaled(amounts.fiat - amounts.fee, 2), + paymentTotal: scaled(amounts.fiat, 2), + purchaseCurrency: amounts.symbol, + purchaseAmount: q.crypto, + exchangeRate: scaled(1 / amounts.rate, 2), + fees: [ + { + feeAmount: scaled(amounts.fee, 2), + feeCurrency: fiatCurrency, + feeType: "FEE_TYPE_EXCHANGE", + }, + ], + }; + case "moneygram": + return { + ...base, + provider, + deliveryMode: "session_widget", + sessionToken: `demo.${q.quoteId}`, + sessionId: q.quoteId, + widgetUrl: "https://playground.xramps.moneygram.com/", + expiresAt: q.expiresAt, + }; + case "stripe": + return { + ...base, + provider, + deliveryMode: "session_widget", + clientSecret: `cos_${q.quoteId}_secret_demo`, + sessionId: q.quoteId, + publishableKey: "pk_test_demo", + }; + } +} + +/** A provider's off-ramp quote: where to send the crypto, or its hosted page or widget. */ +function offrampQuoteFor(provider: RampProviderId, q: QuoteContext): PaymentRampQuote { + const base = { id: q.quoteId, status: "pending" as const }; + const { amounts, fiatCurrency, reference } = q; + const symbol = amounts.symbol as "USDC"; + switch (provider) { + case "bvnk": + return { + ...base, + provider, + deliveryMode: "manual_instructions", + paymentInstructions: [ + { + provider, + kind: "crypto_deposit", + destinationAddress: q.depositAddress, + cryptoCurrency: symbol, + network: "SOLANA", + reference, + fiatCurrency: fiatCurrency as RampFiatCurrency, + instructionsNotes: "Send exactly this amount; BVNK pays out once it arrives.", + }, + ], + }; + case "moonpay": + return { + ...base, + provider, + deliveryMode: "hosted", + hostedUrl: `https://sell-sandbox.moonpay.com/?externalTransactionId=${q.transferId}`, + }; + case "moneygram": + return { + ...base, + provider, + deliveryMode: "session_widget", + sessionToken: `demo.${q.quoteId}`, + sessionId: q.quoteId, + widgetUrl: "https://playground.xramps.moneygram.com/", + expiresAt: q.expiresAt, + }; + default: + return { + ...base, + provider: "lightspark", + deliveryMode: "manual_instructions", + paymentInstructions: [ + { + provider: "lightspark", + kind: "crypto_deposit", + destinationAddress: q.depositAddress, + cryptoCurrency: symbol, + network: "SOLANA", + reference, + accountOrWalletInfo: { + accountType: "SOLANA_WALLET", + address: q.depositAddress, + assetType: symbol, + }, + instructionsNotes: "Send exactly this amount; the payout starts once it arrives.", + }, + ], + exchangeRate: 1 / amounts.rate, + totalSendingAmount: Number(toBaseUnits(q.crypto, amounts.decimals)), + sendingCurrency: { code: symbol, decimals: amounts.decimals }, + totalReceivingAmount: Math.round(amounts.fiat * 100), + receivingCurrency: { code: fiatCurrency, decimals: 2 }, + feesIncluded: Math.round(amounts.fee * 100), + feeCurrency: { code: fiatCurrency, decimals: 2 }, + expiresAt: q.expiresAt, + }; + } +} + +function pairRefusal(provider: RampProviderId, from: string, to: string): DemoWriteResult { + return error(400, `${getRampProviderLabel(provider)} doesn't run ${from} to ${to}.`); +} + +/** Whether a contact finished a provider's onboarding; only BVNK runs one in the demo. */ +function onboardedWith( + provider: RampProviderId, + world: DemoWorld, + counterpartyId: string, + now: Date +): boolean { + return ( + provider !== "bvnk" || bvnkStanding(world, counterpartyId, "onramp", now).status === "ready" + ); +} + +function notOnboarded(provider: RampProviderId): DemoWriteResult { + return error( + 409, + `${getRampProviderLabel(provider)} hasn't finished onboarding this contact.`, + "conflict" + ); +} + +function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { + const direction = segments[2] === "offramp" ? "offramp" : "onramp"; + const head = z.object({ provider: z.string() }).safeParse(body); + const provider = head.success ? head.data.provider : ""; + if (!isRampProvider(provider)) return error(400, "That provider isn't part of the demo."); + const at = now.getTime(); + const id = newDemoId("xfr"); + const quoteId = newDemoId("quote"); + const context = { + quoteId, + transferId: id, + reference: rampReference(provider, quoteId), + expiresAt: new Date(at + QUOTE_TTL_MS).toISOString(), + depositAddress: rampDepositAddress(id) as Address, + }; + + if (direction === "onramp") { + const input = parse(onrampQuoteSchema, body); + if ("failure" in input) return input.failure; + const { counterpartyId, destinationCustodyWalletId, assetRail, fiatCurrency } = input.data; + if (!contactById(world, counterpartyId)) return error(404, "Contact not found.", "not_found"); + if (!onboardedWith(provider, world, counterpartyId, now)) return notOnboarded(provider); + const wallet = findWallet(world, destinationCustodyWalletId); + if (!wallet) return error(404, "That wallet is not in this project.", "not_found"); + if (!pairProviders(direction, fiatCurrency, assetRail).includes(provider)) { + return pairRefusal( + provider, + fiatCurrency, + getCryptoRailAssetLabel(assetRail as CryptoRailId) + ); + } + const amounts = rampAmounts( + provider, + direction, + fiatCurrency, + assetRail, + Number(input.data.fiatAmount) + ); + const crypto = cryptoText(amounts); + const onrampQuote = onrampQuoteFor(provider, { + ...context, + fiatCurrency, + amounts, + crypto, + walletAddress: wallet.publicKey, + }); + return record( + [ + { + k: "ramp", + id, + at, + provider, + dir: direction, + cp: counterpartyId, + wallet: destinationCustodyWalletId, + rail: assetRail, + fiat: fiatCurrency, + fiatAmount: scaled(amounts.fiat, 2), + crypto, + quote: quoteId, + }, + ], + () => ok({ data: { quote: onrampQuote, transferId: id } }, 201) + ); + } + + const input = parse(offrampQuoteSchema, body); + if ("failure" in input) return input.failure; + const { counterpartyId, sourceCustodyWalletId, assetRail, fiatCurrency, cryptoAmount } = + input.data; + if (!contactById(world, counterpartyId)) return error(404, "Contact not found.", "not_found"); + if (!onboardedWith(provider, world, counterpartyId, now)) return notOnboarded(provider); + const wallet = findWallet(world, sourceCustodyWalletId); + if (!wallet) return error(404, "That wallet is not in this project.", "not_found"); + if (!pairProviders(direction, fiatCurrency, assetRail).includes(provider)) { + return pairRefusal(provider, getCryptoRailAssetLabel(assetRail as CryptoRailId), fiatCurrency); + } + const amounts = rampAmounts(provider, direction, fiatCurrency, assetRail, Number(cryptoAmount)); + const notEnough = shortfall(world, sourceCustodyWalletId, amounts.mint, cryptoAmount); + if (notEnough) return error(400, notEnough, "insufficient_funds"); + const offrampQuote = offrampQuoteFor(provider, { + ...context, + fiatCurrency, + amounts, + crypto: cryptoAmount, + walletAddress: wallet.publicKey, + }); + return record( + [ + { + k: "ramp", + id, + at, + provider, + dir: direction, + cp: counterpartyId, + wallet: sourceCustodyWalletId, + rail: assetRail, + fiat: fiatCurrency, + fiatAmount: scaled(amounts.fiat, 2), + crypto: cryptoAmount, + quote: quoteId, + }, + ], + () => ok({ data: { quote: offrampQuote, transferId: id } }, 201) + ); +} + +const simulateSchema = z.object({ + provider: z.string(), + payload: z + .object({ + quoteId: z.string().optional(), + transferId: z.string().optional(), + counterpartyId: z.string().optional(), + /** Demo mode's Simulate verification: the provider approves the contact's identity check. */ + verification: z.literal("approved").optional(), + }) + .passthrough(), +}); + +/** Simulate verification: BVNK approves the identity check of a contact that accepted its terms. */ +function approveVerification( + provider: string, + counterpartyId: string | undefined, + world: DemoWorld, + now: Date +): DemoWriteResult { + if (provider !== "bvnk") return error(400, "Only BVNK asks for an identity check in the demo."); + if (!counterpartyId || !contactById(world, counterpartyId)) { + return error(404, "Contact not found.", "not_found"); + } + const status = bvnkStanding(world, counterpartyId, "onramp", now).status; + if (status === "counterparty_collect_agreement") { + return error(409, "The contact hasn't accepted BVNK's agreements yet.", "conflict"); + } + if (status !== "customer_verification_required") { + return error(409, "BVNK has already verified this contact.", "conflict"); + } + return record([{ k: "verified", id: counterpartyId, at: now.getTime(), provider }], () => + ok({ data: {} }) + ); +} + +/** The sandbox's "the customer paid": the on-ramp it names settles a few seconds later. */ +function simulatePayIn({ body, world, ops, now }: WriteContext): DemoWriteResult { + const input = parse(simulateSchema, body); + if ("failure" in input) return input.failure; + const { quoteId, transferId, counterpartyId, verification } = input.data.payload; + if (verification === "approved") { + return approveVerification(input.data.provider, counterpartyId, world, now); + } + const rampOps = ops.filter((op) => op.k === "ramp" && op.dir === "onramp"); + const match = + rampOps.find((op) => op.k === "ramp" && (op.quote === quoteId || op.id === transferId)) ?? + [...rampOps] + .reverse() + .find( + (op) => + op.k === "ramp" && + op.cp === counterpartyId && + transferById(world, op.id)?.status === "awaiting_payment" + ); + const target = match ? transferById(world, match.id) : undefined; + if (!match || !target) return error(404, "No deposit is waiting for that payment.", "not_found"); + if (target.status !== "awaiting_payment") { + return error(409, "That deposit has already been paid.", "conflict"); + } + return record([{ k: "ramp-paid", id: match.id, at: now.getTime() }], () => ok({ data: {} })); +} + +const cancelRampSchema = z.object({ transferId: z.string().min(1) }); + +function cancelRamp({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(cancelRampSchema, body); + if ("failure" in input) return input.failure; + const transfer = transferById(world, input.data.transferId); + if (!transfer) return error(404, "Transfer not found.", "not_found"); + if (transfer.status !== "awaiting_payment") { + return error(409, "This transfer can no longer be canceled.", "conflict"); + } + const id = transfer.id; + return record([{ k: "ramp-cancel", id, at: now.getTime() }], (after) => + ok({ data: { transfer: transferById(after, id) } }) + ); +} + +// ─── Transfers and batches ─────────────────────────────────────────────────── + +const transferSchema = z.object({ + transferId: z.string().trim().min(1).max(80).optional(), + sourceCustodyWalletId: z.string().min(1), + destination: addressSchema, + token: z.string().min(1), + amount: amountSchema, + memo: z.string().max(200).nullish(), +}); + +function createTransfer({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(transferSchema, body); + if ("failure" in input) return input.failure; + const { transferId, sourceCustodyWalletId, destination, token, amount, memo } = input.data; + const existing = transferId === undefined ? undefined : transferById(world, transferId); + const at = now.getTime(); + + // Funding an off-ramp: the crypto goes to the provider's deposit address. + if (existing?.type === "offramp") { + if (existing.status !== "awaiting_payment") { + return error(409, "This payout has already been funded.", "conflict"); + } + const notEnough = shortfall(world, sourceCustodyWalletId, token, amount); + if (notEnough) return error(400, notEnough, "insufficient_funds"); + const id = existing.id; + return record([{ k: "ramp-paid", id, at }], (after) => + ok({ data: { transfer: transferById(after, id) } }, 201) + ); + } + // The same payment sent again answers with the first one. + if (existing) return record([], () => ok({ data: { transfer: existing } })); + + const wallet = findWallet(world, sourceCustodyWalletId); + if (!wallet) return error(404, "That wallet is not in this project.", "not_found"); + if (destination === wallet.publicKey) { + return error(400, "Choose an address other than the wallet sending the payment."); + } + const notEnough = shortfall(world, wallet.id, token, amount); + if (notEnough) return error(400, notEnough, "insufficient_funds"); + const id = transferId?.startsWith("demo_") ? transferId : newDemoId("xfr"); + return record( + [{ k: "send", id, at, wallet: wallet.id, to: destination, token, amount, memo: memo || null }], + (after) => ok({ data: { transfer: transferById(after, id) } }, 201) + ); +} + +const batchSchema = z.object({ + externalId: z.string().trim().max(80).nullish(), + sourceCustodyWalletId: z.string().min(1), + token: z.string().min(1), + recipients: z + .array( + z.object({ + counterpartyId: z.string().min(1), + counterpartyAccountId: z.string().min(1), + amount: amountSchema, + }) + ) + .min(1, "Add at least one recipient.") + .max(200), +}); + +function batchTotal(amounts: readonly string[], mint: string): string { + const { decimals } = DEMO_TOKENS[tokenKeyForMint(mint) ?? "USDC"]; + return fromBaseUnits( + amounts.reduce((sum, amount) => sum + toBaseUnits(amount, decimals), 0n), + decimals, + 2 + ); +} + +function checkBatch(world: DemoWorld, input: z.infer): DemoWriteResult | null { + if (!findWallet(world, input.sourceCustodyWalletId)) { + return error(404, "That wallet is not in this project.", "not_found"); + } + for (const recipient of input.recipients) { + const account = accountById(world, recipient.counterpartyAccountId); + if (!account || account.counterpartyId !== recipient.counterpartyId) { + return error(400, "A recipient's address is not saved for that contact."); + } + } + const total = batchTotal( + input.recipients.map((recipient) => recipient.amount), + input.token + ); + const notEnough = shortfall(world, input.sourceCustodyWalletId, input.token, total); + return notEnough ? error(400, notEnough, "insufficient_funds") : null; +} + +function estimateBatch({ body, world }: WriteContext): DemoWriteResult { + const input = parse(batchSchema, body); + if ("failure" in input) return input.failure; + const problem = checkBatch(world, input.data); + if (problem) return problem; + const recipientCount = input.data.recipients.length; + const transactionCount = Math.ceil(recipientCount / DEMO_BATCH_RECIPIENTS_PER_TRANSACTION); + return record([], () => + ok({ + data: { + estimate: { + recipientCount, + transactionCount, + estimatedFees: { + networkFeeLamports: String(5_000 * transactionCount), + priorityFeeLamports: String(10_000 * transactionCount), + tokenAccountRentLamports: "0", + sponsored: false, + }, + }, + }, + }) + ); +} + +function createBatch({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(batchSchema, body); + if ("failure" in input) return input.failure; + const problem = checkBatch(world, input.data); + if (problem) return problem; + const id = newDemoId("batch"); + return record( + [ + { + k: "batch", + id, + at: now.getTime(), + wallet: input.data.sourceCustodyWalletId, + token: input.data.token, + ext: input.data.externalId || null, + to: input.data.recipients.map((recipient) => [ + recipient.counterpartyId, + recipient.counterpartyAccountId, + recipient.amount, + ]), + }, + ], + (after) => { + const entry = after.batches.find((candidate) => candidate.batch.id === id); + const transferIds = new Set(entry?.recipients.map((recipient) => recipient.transferId)); + return ok( + { + data: { + batch: entry?.batch, + recipients: entry?.recipients ?? [], + transfers: after.transfers + .map((row) => row.transfer) + .filter((transfer) => transferIds.has(transfer.id)), + }, + }, + 201 + ); + } + ); +} + +// ─── Requests ──────────────────────────────────────────────────────────────── + +const requestSchema = z.object({ + walletId: z.string().min(1), + token: z.string().min(1), + amount: amountSchema, + counterpartyId: z.string().nullish(), + expiresAt: z.string().nullish(), +}); + +function createRequest({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(requestSchema, body); + if ("failure" in input) return input.failure; + const wallet = findWallet(world, input.data.walletId); + if (!wallet) return error(404, "That wallet is not in this project.", "not_found"); + const counterpartyId = input.data.counterpartyId ?? null; + if (counterpartyId !== null && !contactById(world, counterpartyId)) { + return error(404, "Contact not found.", "not_found"); + } + const expiresAt = input.data.expiresAt ?? null; + if (expiresAt !== null && !(Date.parse(expiresAt) > now.getTime())) { + return error(400, "Choose an expiry in the future."); + } + const id = newDemoId("preq"); + return record( + [ + { + k: "request", + id, + at: now.getTime(), + wallet: wallet.id, + token: input.data.token, + amount: input.data.amount, + cp: counterpartyId, + expires: expiresAt, + }, + ], + (after) => ok({ data: after.requests.find((request) => request.id === id) }, 201) + ); +} + +// ─── Schedules ─────────────────────────────────────────────────────────────── + +const periodSchema = z.number().int().min(1, "Repeat at least every hour.").max(8760); + +const scheduleSchema = z.object({ + sourceCustodyWalletId: z.string().min(1), + counterpartyId: z.string().min(1), + counterpartyAccountId: z.string().min(1), + token: z.string().min(1), + amount: amountSchema, + periodHours: periodSchema, + firstCollectionAt: z.string().nullish(), +}); + +function scheduleAnswer(id: string, status = 200) { + return (after: DemoWorld) => + ok( + { data: { recurringPayment: after.schedules.find((schedule) => schedule.id === id) } }, + status + ); +} + +function createSchedule({ body, world, now }: WriteContext): DemoWriteResult { + const input = parse(scheduleSchema, body); + if ("failure" in input) return input.failure; + const { sourceCustodyWalletId, counterpartyId, counterpartyAccountId } = input.data; + if (!findWallet(world, sourceCustodyWalletId)) { + return error(404, "That wallet is not in this project.", "not_found"); + } + const account = accountById(world, counterpartyAccountId); + if (!account || account.counterpartyId !== counterpartyId) { + return error(400, "Choose an address saved for this contact."); + } + const first = input.data.firstCollectionAt ?? null; + if (first !== null && !(Date.parse(first) > now.getTime())) { + return error(400, "Choose a start date after today."); + } + const id = newDemoId("rp"); + return record( + [ + { + k: "schedule", + id, + at: now.getTime(), + wallet: sourceCustodyWalletId, + cp: counterpartyId, + account: counterpartyAccountId, + token: input.data.token, + amount: input.data.amount, + period: input.data.periodHours, + first, + }, + ], + scheduleAnswer(id, 201) + ); +} + +const updateScheduleSchema = z.object({ + amount: amountSchema.optional(), + token: z.string().min(1).optional(), + periodHours: periodSchema.optional(), + sourceCustodyWalletId: z.string().min(1).optional(), + counterpartyAccountId: z.string().min(1).optional(), +}); + +function updateSchedule({ segments, body, world, now }: WriteContext): DemoWriteResult { + const id = segments[2] ?? ""; + const schedule = world.schedules.find((candidate) => candidate.id === id); + if (!schedule) return error(404, "Schedule not found.", "not_found"); + if (schedule.status !== "pending_activation" && schedule.status !== "active") { + return error(409, "This schedule cannot be changed from its current status.", "conflict"); + } + const input = parse(updateScheduleSchema, body); + if ("failure" in input) return input.failure; + const { sourceCustodyWalletId, counterpartyAccountId } = input.data; + if (sourceCustodyWalletId !== undefined && !findWallet(world, sourceCustodyWalletId)) { + return error(404, "That wallet is not in this project.", "not_found"); + } + if (counterpartyAccountId !== undefined) { + const account = accountById(world, counterpartyAccountId); + if (!account || account.counterpartyId !== schedule.counterpartyId) { + return error(400, "Choose an address saved for this contact."); + } + } + return record( + [ + { + k: "schedule-update", + id, + at: now.getTime(), + ...(input.data.amount === undefined ? {} : { amount: input.data.amount }), + ...(input.data.token === undefined ? {} : { token: input.data.token }), + ...(input.data.periodHours === undefined ? {} : { period: input.data.periodHours }), + ...(sourceCustodyWalletId === undefined ? {} : { wallet: sourceCustodyWalletId }), + ...(counterpartyAccountId === undefined ? {} : { account: counterpartyAccountId }), + }, + ], + scheduleAnswer(id) + ); +} + +const SCHEDULE_ACTIONS = ["activate", "collect", "cancel", "resume"] as const; +type ScheduleAction = (typeof SCHEDULE_ACTIONS)[number]; + +function scheduleActionRefusal( + action: ScheduleAction, + schedule: DemoWorld["schedules"][number], + world: DemoWorld, + now: Date +): DemoWriteResult | null { + switch (action) { + case "activate": + return schedule.status === "pending_activation" + ? null + : error(409, "Only a schedule waiting for activation can be activated.", "conflict"); + case "collect": { + if (schedule.status !== "active") { + return error(409, "Only an active schedule can collect.", "conflict"); + } + const due = schedule.nextCollectionDueAt; + if (due === null || Date.parse(due) > now.getTime()) { + return error(409, "The next run isn't due yet.", "conflict"); + } + const notEnough = shortfall( + world, + schedule.sourceCustodyWalletId ?? "", + schedule.token, + schedule.amount + ); + return notEnough ? error(400, notEnough, "insufficient_funds") : null; + } + case "cancel": + return schedule.status === "pending_activation" || schedule.status === "active" + ? null + : error(409, "This schedule is not running.", "conflict"); + case "resume": + return schedule.status === "canceled" && schedule.subscriptionId + ? null + : error(409, "Only a canceled schedule that has run can be resumed.", "conflict"); + } +} + +function runScheduleAction({ segments, world, now }: WriteContext): DemoWriteResult { + const id = segments[2] ?? ""; + const action = SCHEDULE_ACTIONS.find((candidate) => candidate === segments[3]); + const schedule = world.schedules.find((candidate) => candidate.id === id); + if (!schedule || !action) return error(404, "Schedule not found.", "not_found"); + const refusal = scheduleActionRefusal(action, schedule, world, now); + if (refusal) return refusal; + return record([{ k: "schedule-action", id, at: now.getTime(), action }], scheduleAnswer(id)); +} + +// ─── Routing ───────────────────────────────────────────────────────────────── + +type Handler = (context: WriteContext) => DemoWriteResult; + +/** The handler for a write, by method and path shape (`*` stands for any one segment). */ +const WRITE_ROUTES: ReadonlyArray<[method: string, shape: string, handler: Handler]> = [ + ["POST", "counterparties", createContact], + ["DELETE", "counterparties/*", archiveContact], + ["POST", "counterparties/*/accounts", createAccount], + ["POST", "counterparties/*/requirements", advanceRequirements], + ["POST", "compliance/address-screenings", screenAddress], + ["POST", "payments/transfers", createTransfer], + ["POST", "payments/transfer-batches/estimate", estimateBatch], + ["POST", "payments/transfer-batches", createBatch], + ["POST", "payments/ramps/*/estimate", estimate], + ["POST", "payments/ramps/*/quote", quote], + ["POST", "payments/ramps/sandbox/simulate", simulatePayIn], + ["POST", "payments/ramps/transfers/cancel", cancelRamp], + ["POST", "payments/ramps/*/events", () => record([], () => ({ status: 204 }))], + ["POST", "payments/requests", createRequest], + ["POST", "payments/recurring-payments", createSchedule], + ["PATCH", "payments/recurring-payments/*", updateSchedule], + ["POST", "payments/recurring-payments/*/*", runScheduleAction], +]; + +function matchesShape(segments: readonly string[], shape: string): boolean { + const parts = shape.split("/"); + return ( + parts.length === segments.length && + parts.every((part, index) => part === "*" || part === segments[index]) + ); +} + +/** + * The demo's handling of a write, or undefined when demo mode has no stand-in for it. Literal + * shapes are tried before wildcard ones, so `ramps/sandbox/simulate` never reads as a quote. + */ +export function demoWrite(method: string, context: WriteContext): DemoWriteResult | undefined { + const candidates = WRITE_ROUTES.filter( + ([routeMethod, shape]) => routeMethod === method && matchesShape(context.segments, shape) + ).sort(([, left], [, right]) => left.split("*").length - right.split("*").length); + const handler = candidates[0]?.[2]; + return handler ? handler(context) : undefined; +} + +/** Reads a flow makes that the fixtures don't hold: ramp requirements and wallet approvals. */ +export function demoFlowRead( + segments: readonly string[], + params: URLSearchParams, + world: DemoWorld, + now: Date +): DemoAnswer | undefined { + if (matchesShape(segments, "counterparties/*/requirements")) { + const counterpartyId = segments[1] ?? ""; + if (!contactById(world, counterpartyId)) { + return { status: 404, body: { error: { code: "not_found", message: "Contact not found." } } }; + } + return ok({ data: requirementsFor(world, counterpartyId, params, now) }); + } + if (matchesShape(segments, "wallets/approval-requests")) { + return ok({ data: { approvalRequests: [] } }); + } + return undefined; +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts new file mode 100644 index 0000000000..62f29accc5 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts @@ -0,0 +1,41 @@ +"use server"; + +import { cookies } from "next/headers"; +import { PROJECT_COOKIE_NAME } from "../project-cookie"; +import { isDemoSessionCookie, PAYMENTS_DEMO_COOKIE_NAME } from "./demo-cookie"; + +const DEMO_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +// Project ids are URL-safe; anything else is not written into a cookie. +const COOKIE_SAFE_VALUE = /^[\w-]{1,80}$/; + +/** + * The demo mode switch. On, it names the project whose Payments screens run the demo: the one + * the dashboard has selected, or the project cookie's when the project list didn't load. Off, + * it clears that. Either way it forgets what was done in the demo so far. Returns whether the + * switch took; the caller then redraws the page, dropping what it had cached. + */ +export async function setPaymentsDemoAction( + enabled: boolean, + projectId: string | null +): Promise { + const store = await cookies(); + for (const { name } of store.getAll()) { + if (isDemoSessionCookie(name)) store.delete(name); + } + if (!enabled) { + store.delete(PAYMENTS_DEMO_COOKIE_NAME); + return true; + } + const project = projectId ?? store.get(PROJECT_COOKIE_NAME)?.value ?? null; + if (!project || !COOKIE_SAFE_VALUE.test(project)) { + return false; + } + store.set(PAYMENTS_DEMO_COOKIE_NAME, project, { + path: "/", + maxAge: DEMO_COOKIE_MAX_AGE_SECONDS, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + }); + return true; +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode.ts new file mode 100644 index 0000000000..6b49bdedb0 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode.ts @@ -0,0 +1,199 @@ +import { + COMPLIANCE_PROVIDERS, + CUSTODY_PROVIDERS, + EARN_PROVIDERS, + ORGANIZATION_RPC_PROVIDERS, + type OrganizationProviderAvailabilityResponse, + type ProviderAvailabilityEntry, + RAMP_PROVIDERS, +} from "@sdp/types"; +import { cookies, headers } from "next/headers"; +import { cache } from "react"; +import { PROJECT_COOKIE_NAME } from "../project-cookie"; +import { isPaymentsPath, PAYMENTS_DEMO_COOKIE_NAME } from "./demo-cookie"; +import { buildWorld, demoPathParts, demoWorldBody } from "./demo-fixtures"; +import { type DemoAnswer, demoFlowRead, demoWrite } from "./demo-handlers"; +import { applyDemoOps } from "./demo-replay"; +import { appendDemoOps, readDemoOps } from "./demo-session"; + +/* + * Demo mode: on the Payments screens of the project the demo cookie names, nothing about + * payments reaches the SDP API. Reads come from the fixtures with the visitor's own actions + * replayed over them; writes are checked as the API would, recorded in the browser's demo + * session and answered with the result. Reads that aren't about payment data (the project, the + * organization) still go to the API, and change nothing. + */ + +/** Resources the demo owns: in demo mode these never reach the API, read or write. */ +const DEMO_RESOURCES = new Set([ + "wallets", + "payments", + "transactions", + "counterparties", + "issuance", + "compliance", +]); + +/** Writes that only look something up (an address search), so they may still go out. */ +const LOOKUP_WRITES = new Set(["places"]); + +/** + * Whether this request renders, or is fetched by, a Payments screen of the project the demo + * cookie names. A page carries its own path (the proxy stamps `x-sdp-pathname`); a dashboard + * API route counts only when a Payments page called it (its Referer). Everything else, the API + * playground included, keeps real data. An organization-level read has no project of its own, + * so it is judged by the project the dashboard has selected. + */ +const demoRequested = cache(async (projectId: string | null): Promise => { + try { + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + const project = projectId ?? cookieStore.get(PROJECT_COOKIE_NAME)?.value ?? null; + if (!project || cookieStore.get(PAYMENTS_DEMO_COOKIE_NAME)?.value !== project) { + return false; + } + // Demo data is part of the new design; the previous design never serves it. Imported here, + // not at the top: the flags module reads auth through sdp-api, which imports this file. + const { newDesign } = await import("@/flags"); + if (!(await newDesign())) { + return false; + } + const pathname = headerStore.get("x-sdp-pathname"); + if (isPaymentsPath(pathname)) { + return true; + } + if (!pathname?.startsWith("/api/dashboard/")) { + return false; + } + const referer = headerStore.get("referer"); + return referer ? isPaymentsPath(new URL(referer).pathname) : false; + } catch { + // Outside a request there are no cookies or headers to read, so no demo. + return false; + } +}); + +function respond({ status, body }: DemoAnswer): Response { + return status === 204 ? new Response(null, { status }) : Response.json(body, { status }); +} + +function notInDemo(): Response { + return Response.json( + { error: { code: "not_found", message: "That isn't part of the demo." } }, + { status: 404 } + ); +} + +function parseBody(body: RequestInit["body"]): unknown { + if (typeof body !== "string" || body.length === 0) return {}; + try { + return JSON.parse(body); + } catch { + return undefined; + } +} + +const PROVIDER_ON: ProviderAvailabilityEntry = { entitled: true, configured: true, enabled: true }; + +function allOn(providers: readonly T[]): Record { + return Object.fromEntries(providers.map((provider) => [provider, PROVIDER_ON])) as Record< + T, + ProviderAvailabilityEntry + >; +} + +/** Every provider on, for when the organization's own answer can't be read. */ +function demoProviderAvailability(): OrganizationProviderAvailabilityResponse { + return { + tier: "enterprise", + providers: { + custody: allOn(CUSTODY_PROVIDERS), + rpc: allOn(ORGANIZATION_RPC_PROVIDERS), + compliance: allOn(COMPLIANCE_PROVIDERS), + ramps: allOn(RAMP_PROVIDERS), + earn: allOn(EARN_PROVIDERS), + }, + }; +} + +/** + * The organization's providers as the demo offers them: every ramp provider and address + * screening on, whatever the plan, so every flow can be walked to its end. The rest is the + * organization's real answer, or everything on when the API can't be reached. + */ +async function demoProviderAccess(upstream: () => Promise): Promise { + const fallback = demoProviderAvailability(); + let json: { data?: Partial } = {}; + try { + const response = await upstream(); + if (response.ok) json = await response.json(); + } catch { + // The API is down; the demo carries on with its own answer. + } + const real = json.data ?? {}; + const providers = { ...fallback.providers, ...real.providers }; + providers.ramps = fallback.providers.ramps; + providers.compliance = { ...providers.compliance, range: PROVIDER_ON }; + return Response.json({ ...json, data: { ...fallback, ...real, providers } }); +} + +/** + * The demo's answer to an SDP API request, or null to send it upstream. `upstream` sends it + * for real, for the one read the demo adjusts rather than replaces. + */ +export async function paymentsDemoResponse( + method: string, + path: string, + projectId: string | null, + body: RequestInit["body"], + upstream: () => Promise +): Promise { + if (!(await demoRequested(projectId))) { + return null; + } + const parts = demoPathParts(path); + const verb = method.toUpperCase(); + if (parts === undefined) { + return verb === "GET" ? null : notInDemo(); + } + const [resource = ""] = parts.segments; + const now = new Date(); + const ops = await readDemoOps(); + const world = applyDemoOps(buildWorld(now), ops, now); + + if (verb === "GET") { + if (resource === "organizations" && parts.segments[2] === "provider-access") { + return demoProviderAccess(upstream); + } + const fixture = demoWorldBody(world, path); + if (fixture !== undefined) return Response.json(fixture); + const flow = demoFlowRead(parts.segments, parts.params, world, now); + if (flow) return respond(flow); + return DEMO_RESOURCES.has(resource) || path.includes("demo_") ? notInDemo() : null; + } + + if (LOOKUP_WRITES.has(resource)) { + return null; + } + const parsedBody = parseBody(body); + const result = + parsedBody === undefined + ? undefined + : demoWrite(verb, { segments: parts.segments, body: parsedBody, world, ops, now }); + if (!result) { + console.warn(JSON.stringify({ event: "payments_demo_unhandled_write", method: verb, path })); + return Response.json( + { + error: { + code: "payments_demo", + message: "Demo mode doesn't cover this step. Turn off demo mode to do it for real.", + }, + }, + { status: 409 } + ); + } + if (result.ops.length === 0) { + return respond(result.answer(world)); + } + const nextOps = await appendDemoOps(...result.ops); + return respond(result.answer(applyDemoOps(buildWorld(now), nextOps, now))); +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts new file mode 100644 index 0000000000..a0acf3a55c --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts @@ -0,0 +1,647 @@ +import { RAMP_PROVIDERS } from "@sdp/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/* + * Demo mode end to end: every request is what a Payments screen's route handler would send the + * SDP API, answered by the demo with the browser's demo session carried between requests as a + * browser would carry its cookies. Nothing may reach the real API except the reads that aren't + * about payment data. + */ + +const browser = vi.hoisted(() => { + const jar = new Map(); + const store = { + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) ?? "" } : undefined), + getAll: () => [...jar].map(([name, value]) => ({ name, value })), + set: (name: string, value: string) => { + jar.set(name, value); + }, + delete: (name: string) => { + jar.delete(name); + }, + }; + return { jar, store, pathname: "/dashboard/payments", newDesign: true }; +}); + +// Demo mode is part of the new design; the flag itself reads Vercel and the request. +vi.mock("@/flags", () => ({ newDesign: async () => browser.newDesign })); + +vi.mock("next/headers", () => ({ + // A fresh store object per request, as Next gives each request its own. + cookies: async () => ({ ...browser.store }), + headers: async () => new Headers({ "x-sdp-pathname": browser.pathname }), +})); + +const { paymentsDemoResponse } = await import("./demo-mode"); +const { decodeDemoOps, encodeDemoOps } = await import("./demo-session"); +const { DEMO_TOKENS } = await import("./demo-fixtures"); +const { PROJECT_COOKIE_NAME } = await import("../project-cookie"); + +const PROJECT = "proj_sandbox"; +const USDC = DEMO_TOKENS.USDC.mint; +const NOW = new Date("2026-09-28T12:00:00.000Z"); +const upstream = vi.fn(async () => Response.json({ data: { ok: true } })); + +async function call(method: string, path: string, body?: unknown) { + const response = await paymentsDemoResponse( + method, + path, + PROJECT, + body === undefined ? undefined : JSON.stringify(body), + upstream + ); + if (response === null) return { status: null, body: null }; + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : null }; +} + +async function data(path: string): Promise { + const { status, body } = await call("GET", path); + expect(status).toBe(200); + return body.data as T; +} + +beforeEach(() => { + browser.jar.clear(); + browser.jar.set("sdp-payments-demo", PROJECT); + browser.pathname = "/dashboard/payments"; + browser.newDesign = true; + upstream.mockClear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); + // Payment data never leaves the demo. + expect(upstream).not.toHaveBeenCalled(); +}); + +describe("scope", () => { + it("stays out of the way without the demo cookie, or off the Payments screens", async () => { + browser.jar.delete("sdp-payments-demo"); + expect((await call("GET", "/v1/counterparties")).status).toBeNull(); + browser.jar.set("sdp-payments-demo", PROJECT); + browser.pathname = "/dashboard/wallets"; + expect((await call("GET", "/v1/counterparties")).status).toBeNull(); + }); + + it("stays out of the way with NEW DESIGN off, cookie or not", async () => { + browser.newDesign = false; + expect((await call("GET", "/v1/counterparties")).status).toBeNull(); + }); + + it("answers anything about payment data itself, and lets project reads through", async () => { + expect((await call("GET", "/v1/counterparties/cpty_real")).status).toBe(404); + expect((await call("GET", "/v1/payments/transfers/xfr_real")).status).toBe(404); + expect((await call("GET", "/v1/projects")).status).toBeNull(); + expect((await call("GET", "/v1/wallets/approval-requests?status=pending")).body).toEqual({ + data: { approvalRequests: [] }, + }); + }); + + it("offers Lightspark as the only ramp, on the organization's real provider answer", async () => { + // An organization-level read names no project; the dashboard's selected one decides. + browser.jar.set(PROJECT_COOKIE_NAME, PROJECT); + upstream.mockResolvedValueOnce( + Response.json({ + data: { + tier: "sandbox", + providers: { + ramps: { moonpay: { entitled: true, configured: true, enabled: true } }, + compliance: {}, + }, + }, + }) + ); + const response = await paymentsDemoResponse( + "GET", + "/v1/organizations/org_1/provider-access", + null, + undefined, + upstream + ); + const body = await response?.json(); + for (const provider of RAMP_PROVIDERS) { + expect(body.data.providers.ramps[provider].enabled).toBe(true); + } + expect(body.data.providers.compliance.range.enabled).toBe(true); + upstream.mockClear(); + }); + + it("offers every provider even when the API can't be reached", async () => { + browser.jar.set(PROJECT_COOKIE_NAME, PROJECT); + upstream.mockRejectedValueOnce(new Error("connect ECONNREFUSED")); + const response = await paymentsDemoResponse( + "GET", + "/v1/organizations/org_1/provider-access", + null, + undefined, + upstream + ); + const body = await response?.json(); + expect(response?.status).toBe(200); + expect(body.data.providers.ramps.stripe.enabled).toBe(true); + expect(body.data.providers.compliance.range.enabled).toBe(true); + upstream.mockClear(); + }); +}); + +describe("contacts", () => { + it("creates a contact with an address, lists it, and archives it", async () => { + const created = await call("POST", "/v1/counterparties", { + entityType: "business", + displayName: "Harbor Freight Co", + }); + expect(created.status).toBe(201); + const id: string = created.body.data.counterparty.id; + expect(id).toMatch(/^demo_new_cpty_/); + + const screening = await call("POST", "/v1/compliance/address-screenings", { + address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + network: "solana", + }); + expect(screening.body.data.screening.providers[0].status).toBe("ok"); + + const account = await call("POST", `/v1/counterparties/${id}/accounts`, { + accountKind: "crypto_wallet", + label: "Treasury", + details: { network: "solana", address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin" }, + }); + expect(account.status).toBe(201); + + const list = await data<{ counterparties: { id: string }[] }>("/v1/counterparties?page=1"); + expect(list.counterparties[0]?.id).toBe(id); + const accounts = await data<{ accounts: { label: string }[] }>( + `/v1/counterparties/${id}/accounts` + ); + expect(accounts.accounts.map((entry) => entry.label)).toEqual(["Treasury"]); + + expect((await call("DELETE", `/v1/counterparties/${id}`)).status).toBe(204); + const after = await data<{ counterparties: { id: string }[] }>("/v1/counterparties?page=1"); + expect(after.counterparties.some((contact) => contact.id === id)).toBe(false); + }); + + it("refuses what the API would: a bad address, a duplicate external ID", async () => { + const bad = await call("POST", "/v1/counterparties/demo_cpty_jane/accounts", { + accountKind: "crypto_wallet", + details: { network: "solana", address: "not-an-address" }, + }); + expect(bad.status).toBe(400); + const duplicate = await call("POST", "/v1/counterparties", { + entityType: "business", + displayName: "Acme again", + externalId: "ACME-001", + }); + expect(duplicate.status).toBe(409); + }); +}); + +describe("pay", () => { + it("sends a payment that lists in Transactions and lowers the wallet's balance", async () => { + const before = await data<{ + wallets: { id: string; balances: { mint: string; uiAmount: string }[] }[]; + }>("/v1/wallets?includeBalances=true"); + const treasury = before.wallets.find((wallet) => wallet.id === "demo_cwlt_treasury"); + const held = Number(treasury?.balances.find((balance) => balance.mint === USDC)?.uiAmount); + + const sent = await call("POST", "/v1/payments/transfers", { + sourceCustodyWalletId: "demo_cwlt_treasury", + destination: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + token: USDC, + amount: "250.00", + }); + expect(sent.status).toBe(201); + expect(sent.body.data.transfer.status).toBe("finalized"); + expect(sent.body.data.transfer.signature).toBeTruthy(); + + const transactions = await data<{ transactions: { id: string }[] }>("/v1/transactions?limit=5"); + expect(transactions.transactions[0]?.id).toBe(sent.body.data.transfer.id); + const after = await data<{ + wallets: { id: string; balances: { mint: string; uiAmount: string }[] }[]; + }>("/v1/wallets?includeBalances=true"); + const now = after.wallets.find((wallet) => wallet.id === "demo_cwlt_treasury"); + expect(Number(now?.balances.find((balance) => balance.mint === USDC)?.uiAmount)).toBe( + held - 250 + ); + }); + + it("refuses a payment the wallet cannot cover", async () => { + const refused = await call("POST", "/v1/payments/transfers", { + sourceCustodyWalletId: "demo_cwlt_settlement", + destination: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + token: USDC, + amount: "1000000", + }); + expect(refused.status).toBe(400); + expect(refused.body.error.message).toMatch(/^Not enough USDC in Settlement/); + }); + + it("pays a batch in one go", async () => { + const recipients = [ + { counterpartyId: "demo_cpty_kai", counterpartyAccountId: "demo_cpa_kai", amount: "100" }, + { counterpartyId: "demo_cpty_priya", counterpartyAccountId: "demo_cpa_priya", amount: "50" }, + ]; + const estimate = await call("POST", "/v1/payments/transfer-batches/estimate", { + sourceCustodyWalletId: "demo_cwlt_payroll", + token: USDC, + recipients, + }); + expect(estimate.body.data.estimate.recipientCount).toBe(2); + const batch = await call("POST", "/v1/payments/transfer-batches", { + sourceCustodyWalletId: "demo_cwlt_payroll", + token: USDC, + recipients, + }); + expect(batch.status).toBe(201); + expect(batch.body.data.batch.status).toBe("confirmed"); + expect(batch.body.data.recipients).toHaveLength(2); + expect(batch.body.data.transfers).toHaveLength(1); + }); +}); + +describe("ramps", () => { + it("runs a deposit from quote to a completed transfer, then credits the wallet", async () => { + const requirements = await data<{ status: string }>( + "/v1/counterparties/demo_cpty_jane/requirements?provider=lightspark&direction=onramp&assetRail=usdc.solana&fiatCurrency=USD" + ); + expect(requirements.status).toBe("ready"); + const estimate = await call("POST", "/v1/payments/ramps/onramp/estimate", { + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "500", + }); + expect(estimate.body.data.estimates[0].status).toBe("ok"); + + const quoted = await call("POST", "/v1/payments/ramps/onramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: "demo_cwlt_treasury", + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "500", + }); + expect(quoted.status).toBe(201); + const { quote, transferId } = quoted.body.data; + expect(quote.paymentInstructions[0].accountOrWalletInfo.accountType).toBe("US_ACCOUNT"); + const waiting = await data<{ transfer: { status: string } }>( + `/v1/payments/transfers/${transferId}` + ); + expect(waiting.transfer.status).toBe("awaiting_payment"); + + const paid = await call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider: "lightspark", + payload: { quoteId: quote.id, currencyCode: "USD" }, + }); + expect(paid.status).toBe(200); + expect( + (await data<{ transfer: { status: string } }>(`/v1/payments/transfers/${transferId}`)) + .transfer.status + ).toBe("settling"); + vi.setSystemTime(new Date(NOW.getTime() + 6_000)); + const done = await data<{ transfer: { status: string; signature: string } }>( + `/v1/payments/transfers/${transferId}` + ); + expect(done.transfer.status).toBe("completed"); + expect(done.transfer.signature).toBeTruthy(); + }); + + it("pays out to a saved bank account once the crypto is sent", async () => { + const requirements = await data<{ + status: string; + payout: { accounts: { id: string; destinationCountry: string }[] }; + }>( + "/v1/counterparties/demo_cpty_jane/requirements?provider=lightspark&direction=offramp&assetRail=usdc.solana&fiatCurrency=USD" + ); + expect(requirements.status).toBe("collect_account"); + const saved = requirements.payout.accounts[0]; + expect(saved?.destinationCountry).toBe("US"); + + const advanced = await call("POST", "/v1/counterparties/demo_cpty_jane/requirements", { + provider: "lightspark", + direction: "offramp", + assetRail: "usdc.solana", + fiatCurrency: "USD", + destinationCustodyWalletId: "demo_cwlt_treasury", + collectedData: { destinationCountry: "US" }, + providerAccountId: saved?.id, + }); + expect(advanced.body.data).toMatchObject({ status: "ready", providerAccountId: saved?.id }); + + const quoted = await call("POST", "/v1/payments/ramps/offramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: "demo_cwlt_treasury", + assetRail: "usdc.solana", + cryptoAmount: "300", + fiatCurrency: "USD", + destinationCountry: "US", + providerAccountId: saved?.id, + }); + const { quote, transferId } = quoted.body.data; + const deposit = quote.paymentInstructions[0]; + expect(deposit.kind).toBe("crypto_deposit"); + + const funded = await call("POST", "/v1/payments/transfers", { + transferId, + sourceCustodyWalletId: "demo_cwlt_treasury", + destination: deposit.destinationAddress, + token: USDC, + amount: "300", + }); + expect(funded.body.data.transfer.id).toBe(transferId); + vi.setSystemTime(new Date(NOW.getTime() + 6_000)); + expect( + (await data<{ transfer: { status: string } }>(`/v1/payments/transfers/${transferId}`)) + .transfer.status + ).toBe("completed"); + }); + + it("adds a new bank account from the collected details", async () => { + const advanced = await call("POST", "/v1/counterparties/demo_cpty_priya/requirements", { + provider: "lightspark", + direction: "offramp", + fiatCurrency: "GBP", + collectedData: { + destinationCountry: "GB", + bankName: "Monzo", + accountNumber: "12345678", + sortCode: "040004", + }, + }); + const id: string = advanced.body.data.providerAccountId; + const accounts = await data<{ accounts: { id: string; bankName: string }[] }>( + "/v1/counterparties/demo_cpty_priya/provider-accounts" + ); + expect(accounts.accounts.find((entry) => entry.id === id)?.bankName).toBe("Monzo"); + }); + + it("cancels a deposit before it is paid", async () => { + const quoted = await call("POST", "/v1/payments/ramps/onramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_kai", + destinationCustodyWalletId: "demo_cwlt_payroll", + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "80", + }); + const canceled = await call("POST", "/v1/payments/ramps/transfers/cancel", { + transferId: quoted.body.data.transferId, + }); + expect(canceled.body.data.transfer.status).toBe("canceled"); + }); +}); + +describe("every ramp provider", () => { + const onrampQuote = (provider: string, assetRail: string, fiatCurrency = "USD") => + call("POST", "/v1/payments/ramps/onramp/quote", { + provider, + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: "demo_cwlt_treasury", + assetRail, + fiatCurrency, + fiatAmount: "250", + }); + + async function statusAfterSettling(transferId: string) { + vi.setSystemTime(new Date(Date.now() + 6_000)); + return ( + await data<{ transfer: { status: string; provider: string; token: string } }>( + `/v1/payments/transfers/${transferId}` + ) + ).transfer; + } + + it("estimates with every provider that runs the pair, each at its own fee", async () => { + const estimate = await call("POST", "/v1/payments/ramps/onramp/estimate", { + assetRail: "sol.solana", + fiatCurrency: "USD", + fiatAmount: "250", + }); + const results: { + provider: string; + estimate: { cryptoAmount: string; fees: { total: string } }; + }[] = estimate.body.data.estimates; + expect(results.map((result) => result.provider).sort()).toEqual([ + "bvnk", + "coinbase", + "moonpay", + "stripe", + ]); + expect(new Set(results.map((result) => result.estimate.fees.total)).size).toBe(4); + // 250 USD less MoonPay's 3.5%, at 148.20 USD a SOL. + expect(results.find((result) => result.provider === "moonpay")?.estimate.cryptoAmount).toBe( + "1.6279" + ); + }); + + it.each([ + ["moonpay", "hosted"], + ["coinbase", "hosted"], + ["stripe", "session_widget"], + ])( + "runs a %s deposit from its stand-in checkout to a credited wallet", + async (provider, mode) => { + const quoted = await onrampQuote(provider, "sol.solana"); + expect(quoted.status).toBe(201); + const { quote, transferId } = quoted.body.data; + expect(quote).toMatchObject({ provider, deliveryMode: mode }); + + const paid = await call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider, + payload: { transferId }, + }); + expect(paid.status).toBe(200); + expect(await statusAfterSettling(transferId)).toMatchObject({ + status: "completed", + provider, + token: DEMO_TOKENS.SOL.mint, + }); + } + ); + + it("runs a MoneyGram deposit and pays out through MoneyGram", async () => { + const deposit = await onrampQuote("moneygram", "usdc.solana"); + expect(deposit.body.data.quote.deliveryMode).toBe("session_widget"); + await call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider: "moneygram", + payload: { transferId: deposit.body.data.transferId }, + }); + expect((await statusAfterSettling(deposit.body.data.transferId)).status).toBe("completed"); + + const payout = await call("POST", "/v1/payments/ramps/offramp/quote", { + provider: "moneygram", + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: "demo_cwlt_treasury", + assetRail: "usdc.solana", + cryptoAmount: "100", + fiatCurrency: "USD", + }); + const { transferId } = payout.body.data; + const waiting = await data<{ + transfer: { cryptoDeposit: { destinationAddress: string; amount: string } }; + }>(`/v1/payments/transfers/${transferId}`); + expect(waiting.transfer.cryptoDeposit.amount).toBe("100"); + const funded = await call("POST", "/v1/payments/transfers", { + transferId, + sourceCustodyWalletId: "demo_cwlt_treasury", + destination: waiting.transfer.cryptoDeposit.destinationAddress, + token: USDC, + amount: "100", + }); + expect(funded.status).toBe(201); + expect((await statusAfterSettling(transferId)).status).toBe("completed"); + }); + + it("runs BVNK's onboarding: agreements, a simulated identity check, review, then the deposit", async () => { + const path = + "/v1/counterparties/demo_cpty_jane/requirements?provider=bvnk&direction=onramp&assetRail=usdc.solana&fiatCurrency=EUR"; + const status = async () => (await data<{ status: string }>(path)).status; + const advance = (extra: Record) => + call("POST", "/v1/counterparties/demo_cpty_jane/requirements", { + provider: "bvnk", + direction: "onramp", + assetRail: "usdc.solana", + fiatCurrency: "EUR", + ...extra, + }); + const simulateVerification = (counterpartyId = "demo_cpty_jane", provider = "bvnk") => + call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider, + payload: { counterpartyId, verification: "approved" }, + }); + + expect(await status()).toBe("counterparty_collect_agreement"); + expect((await advance({ collectedData: {} })).body.data.status).toBe( + "counterparty_collect_agreement" + ); + expect((await simulateVerification()).status).toBe(409); + expect((await onrampQuote("bvnk", "usdc.solana", "EUR")).status).toBe(409); + + const consented = await advance({ agreementConsent: true }); + expect(consented.body.data).toMatchObject({ + status: "customer_verification_required", + verificationUrl: expect.stringMatching(/^https:\/\//), + }); + expect(await status()).toBe("customer_verification_required"); + + expect((await simulateVerification("demo_cpty_jane", "mural")).status).toBe(400); + expect((await simulateVerification()).status).toBe(200); + expect(await status()).toBe("customer_verifying"); + expect((await simulateVerification()).status).toBe(409); + expect((await onrampQuote("bvnk", "usdc.solana", "EUR")).status).toBe(409); + + vi.setSystemTime(new Date(Date.now() + 9_000)); + expect(await status()).toBe("customer_funding_account_provisioning"); + vi.setSystemTime(new Date(Date.now() + 5_000)); + expect(await status()).toBe("ready"); + expect((await advance({ collectedData: {} })).body.data.status).toBe("ready"); + + const quoted = await onrampQuote("bvnk", "usdc.solana", "EUR"); + const { quote, transferId } = quoted.body.data; + expect(quote.paymentInstructions[0]).toMatchObject({ + kind: "fiat_funding", + onboardingStatus: "ready", + }); + await call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider: "bvnk", + payload: { transferId }, + }); + expect((await statusAfterSettling(transferId)).status).toBe("completed"); + }); + + it("runs a Mural deposit for a business contact", async () => { + const quoted = await call("POST", "/v1/payments/ramps/onramp/quote", { + provider: "mural", + counterpartyId: "demo_cpty_acme", + destinationCustodyWalletId: "demo_cwlt_treasury", + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "250", + }); + expect(quoted.body.data.quote.paymentInstructions[0].bankDetails.bankName).toBeTruthy(); + await call("POST", "/v1/payments/ramps/sandbox/simulate", { + provider: "mural", + payload: { counterpartyId: "demo_cpty_acme", amount: 250, fiatCurrency: "USD" }, + }); + expect((await statusAfterSettling(quoted.body.data.transferId)).status).toBe("completed"); + }); + + it("refuses a provider on a pair it doesn't run", async () => { + const refused = await onrampQuote("stripe", "usdc.solana", "EUR"); + expect(refused.status).toBe(400); + expect(refused.body.error.message).toBe("Stripe doesn't run EUR to USDC."); + }); +}); + +describe("requests and schedules", () => { + it("creates a payment request the list and its page can find", async () => { + const created = await call("POST", "/v1/payments/requests", { + walletId: "demo_privy_treasury", + token: USDC, + amount: "75.00", + counterpartyId: null, + expiresAt: null, + }); + expect(created.status).toBe(201); + const list = await data<{ paymentRequests: { id: string }[] }>( + "/v1/payments/requests?page=1&pageSize=100" + ); + expect(list.paymentRequests[0]?.id).toBe(created.body.data.id); + expect(created.body.data.publicToken).toMatch(/^demo_pt_/); + }); + + it("creates, activates, collects, cancels and resumes a schedule", async () => { + const created = await call("POST", "/v1/payments/recurring-payments", { + sourceCustodyWalletId: "demo_cwlt_payroll", + counterpartyId: "demo_cpty_kai", + counterpartyAccountId: "demo_cpa_kai", + token: USDC, + amount: "40", + periodHours: 168, + }); + expect(created.status).toBe(201); + const id: string = created.body.data.recurringPayment.id; + expect(created.body.data.recurringPayment.status).toBe("pending_activation"); + + const run = (action: string) => + call("POST", `/v1/payments/recurring-payments/${id}/${action}`, {}); + expect((await run("collect")).status).toBe(409); + expect((await run("activate")).body.data.recurringPayment.status).toBe("active"); + const collected = await run("collect"); + expect(collected.status).toBe(200); + const schedule = collected.body.data.recurringPayment; + const attempts = await data<{ collectionAttempts: { status: string }[] }>( + `/v1/payments/subscriptions/${schedule.subscriptionId}/collection-attempts` + ); + expect(attempts.collectionAttempts.map((attempt) => attempt.status)).toEqual(["confirmed"]); + expect((await run("collect")).status).toBe(409); + + const updated = await call("PATCH", `/v1/payments/recurring-payments/${id}`, { + amount: "45", + }); + expect(updated.body.data.recurringPayment.amount).toBe("45"); + expect((await run("cancel")).body.data.recurringPayment.status).toBe("canceled"); + expect((await run("resume")).body.data.recurringPayment.status).toBe("active"); + }); +}); + +describe("session", () => { + it("round-trips the log, and drops the oldest actions past its budget", () => { + const ops = Array.from({ length: 400 }, (_, index) => ({ + k: "contact" as const, + id: `demo_new_cpty_${index.toString(36).padStart(12, "x")}`, + at: NOW.getTime() + index, + name: `Contact ${crypto.randomUUID()}`, + entity: "business" as const, + ext: null, + })); + expect(decodeDemoOps(encodeDemoOps(ops.slice(0, 3)))).toEqual(ops.slice(0, 3)); + const chunks = encodeDemoOps(ops); + const kept = decodeDemoOps(chunks); + expect(chunks.length).toBeLessThanOrEqual(3); + expect(kept.length).toBeLessThan(ops.length); + expect(kept.at(-1)).toEqual(ops.at(-1)); + expect(decodeDemoOps(["not base64 at all"])).toEqual([]); + }); +}); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-ops.ts b/apps/sdp-web/src/lib/payments-demo/demo-ops.ts new file mode 100644 index 0000000000..41ddd6526a --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-ops.ts @@ -0,0 +1,136 @@ +import { RAMP_PROVIDERS } from "@sdp/types"; +import { z } from "zod"; + +/* + * What a visitor can do in demo mode, one record per action, in the order they happened. The + * session keeps these (see demo-session.ts) and every demo read replays them over the fixtures + * (see demo-replay.ts), so a contact added, a payment sent or a schedule activated shows up on + * every screen until the page is reloaded. Times are epoch milliseconds; ids carry the + * `demo_new_` prefix, so nothing about them can reach the real API. + */ + +const id = z.string().min(1).max(80); +const at = z.number().int().nonnegative(); +const text = z.string().max(200); + +const demoOpSchema = z.discriminatedUnion("k", [ + z.object({ + k: z.literal("contact"), + id, + at, + name: text, + entity: z.enum(["individual", "business"]), + ext: text.nullable(), + }), + z.object({ k: z.literal("contact-archive"), id, at }), + z.object({ k: z.literal("address"), id, at, cp: id, address: text, label: text.nullable() }), + z.object({ + k: z.literal("send"), + id, + at, + wallet: id, + to: text, + token: text, + amount: text, + memo: text.nullable(), + }), + z.object({ + k: z.literal("batch"), + id, + at, + wallet: id, + token: text, + ext: text.nullable(), + /** Recipients as [counterpartyId, counterpartyAccountId, amount]. */ + to: z.array(z.tuple([id, id, text])).max(200), + }), + z.object({ + k: z.literal("ramp"), + id, + at, + /** The ramp provider; sessions recorded before providers were named ran Lightspark. */ + provider: z.enum(RAMP_PROVIDERS).default("lightspark"), + dir: z.enum(["onramp", "offramp"]), + cp: id, + wallet: id, + rail: text, + fiat: text, + fiatAmount: text, + crypto: text, + quote: id, + }), + /** An on-ramp's pay-in arrived (the sandbox simulation), or an off-ramp's crypto was sent. */ + z.object({ k: z.literal("ramp-paid"), id, at }), + z.object({ k: z.literal("ramp-cancel"), id, at }), + /** A contact accepted a ramp provider's agreements (BVNK asks before its first ramp). */ + z.object({ k: z.literal("consent"), id, at, provider: z.enum(RAMP_PROVIDERS) }), + /** A ramp provider approved a contact's identity check (demo mode's Simulate verification). */ + z.object({ k: z.literal("verified"), id, at, provider: z.enum(RAMP_PROVIDERS) }), + z.object({ + k: z.literal("payout-account"), + id, + at, + cp: id, + country: text, + rail: text, + fiat: text, + bank: text.nullable(), + last4: text.nullable(), + }), + z.object({ + k: z.literal("request"), + id, + at, + wallet: id, + token: text, + amount: text, + cp: id.nullable(), + expires: text.nullable(), + }), + z.object({ + k: z.literal("schedule"), + id, + at, + wallet: id, + cp: id, + account: id, + token: text, + amount: text, + period: z.number().int().positive(), + first: text.nullable(), + }), + z.object({ + k: z.literal("schedule-action"), + id, + at, + action: z.enum(["activate", "collect", "cancel", "resume"]), + }), + z.object({ + k: z.literal("schedule-update"), + id, + at, + amount: text.optional(), + token: text.optional(), + period: z.number().int().positive().optional(), + wallet: id.optional(), + account: id.optional(), + }), +]); + +export type DemoOp = z.infer; +export type DemoOpKind = DemoOp["k"]; +export type DemoOpOf = Extract; + +/** The session's log back from JSON, keeping only records that still read as actions. */ +export function parseDemoOps(value: unknown): DemoOp[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const parsed = demoOpSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); +} + +/** A fresh id for something created in demo mode, shaped like the real one it stands for. */ +export function newDemoId(prefix: string): string { + return `demo_new_${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`; +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-prefill.ts b/apps/sdp-web/src/lib/payments-demo/demo-prefill.ts new file mode 100644 index 0000000000..035ecfa5e4 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-prefill.ts @@ -0,0 +1,19 @@ +/** + * The amounts demo mode starts each Payments form with, so a flow can be walked straight to its + * end. Each fits every provider and every sample wallet: a deposit clears the highest provider + * minimum (MoonPay's 20 USD), and a payout or send is well inside the wallets' balances. + */ +export const DEMO_PREFILL_AMOUNTS = { + /** Fiat into a wallet, in the pair's currency. */ + deposit: "250", + /** Crypto out to a bank account. */ + payout: "100", + /** Crypto to an address. */ + send: "25", + /** Each recipient's share of a batch. */ + batchRecipient: "50", + /** What a payment request asks for. */ + request: "120", + /** Each run of a schedule. */ + schedule: "500", +} as const; diff --git a/apps/sdp-web/src/lib/payments-demo/demo-replay.ts b/apps/sdp-web/src/lib/payments-demo/demo-replay.ts new file mode 100644 index 0000000000..b943be8b70 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-replay.ts @@ -0,0 +1,601 @@ +import { + type Counterparty, + type CounterpartyAccount, + isCountryCode, + type PaymentRampQuoteDeliveryMode, + type PaymentRecurringPayment, + type PaymentTransferRecipient, + type RampProviderId, +} from "@sdp/types"; +import { + CREATED_BY, + DEMO_TOKENS, + type DemoTokenKey, + type DemoTransfer, + type DemoWallet, + type DemoWorld, + demoAddress, + demoSignature, + findWallet, + fromBaseUnits, + HOUR_MS, + newestFirst, + ORGANIZATION_ID, + PROJECT_ID, + toBaseUnits, + tokenBalance, +} from "./demo-fixtures"; +import type { DemoOp, DemoOpOf } from "./demo-ops"; + +/* + * The session's actions applied to the fixture world, oldest first. Each one changes the world + * the way the real API would have: a payment lists in Transactions and lowers the wallet's + * balance, a schedule activates, a contact gains an address. An action whose subject is gone + * (the session dropped it for space, or it was archived) changes nothing. + */ + +/** How long after its pay-in a ramp shows as settling before it completes. */ +export const DEMO_SETTLE_MS = 5_000; +/** Recipients per transaction in a demo batch, as the API packs them. */ +export const DEMO_BATCH_RECIPIENTS_PER_TRANSACTION = 8; + +const DECIMAL = /^\d+(\.\d+)?$/; + +function iso(at: number): string { + return new Date(at).toISOString(); +} + +export function tokenKeyForMint(mint: string): DemoTokenKey | undefined { + return (Object.keys(DEMO_TOKENS) as DemoTokenKey[]).find((key) => DEMO_TOKENS[key].mint === mint); +} + +/** The mint a ramp's asset rail delivers ("usdc.solana" → USDC), USDC when the demo lacks it. */ +export function mintForRail(rail: string): string { + const symbol = rail.split(".", 1)[0]?.toUpperCase(); + const key = (Object.keys(DEMO_TOKENS) as DemoTokenKey[]).find( + (candidate) => DEMO_TOKENS[candidate].symbol === symbol + ); + return DEMO_TOKENS[key ?? "USDC"].mint; +} + +export function contactById(world: DemoWorld, id: string | null | undefined) { + return Object.values(world.contacts).find((contact) => contact.id === id); +} + +export function accountById(world: DemoWorld, id: string | null | undefined) { + return Object.values(world.accounts).find((account) => account.id === id); +} + +function contactKeyOf(world: DemoWorld, id: string): string | undefined { + return Object.keys(world.contacts).find((key) => world.contacts[key]?.id === id); +} + +export function transferById(world: DemoWorld, id: string): DemoTransfer | undefined { + return world.transfers.find((row) => row.transfer.id === id)?.transfer; +} + +/** A wallet's holding of a token, in display units ("0" when it holds none). */ +export function walletHolding(wallet: DemoWallet, mint: string): string { + return wallet.balances.find((balance) => balance.mint === mint)?.uiAmount ?? "0"; +} + +function adjustBalance( + world: DemoWorld, + walletId: string, + mint: string, + amount: string, + sign: 1 | -1 +): void { + const wallet = findWallet(world, walletId); + const key = tokenKeyForMint(mint); + if (!wallet || !key || !DECIMAL.test(amount)) return; + const { decimals } = DEMO_TOKENS[key]; + const index = wallet.balances.findIndex((balance) => balance.mint === mint); + const current = index === -1 ? 0n : BigInt(wallet.balances[index]?.amount ?? "0"); + const next = current + BigInt(sign) * toBaseUnits(amount, decimals); + const balance = tokenBalance(key, fromBaseUnits(next < 0n ? 0n : next, decimals)); + if (index === -1) wallet.balances.push(balance); + else wallet.balances[index] = balance; +} + +function outboundTransfer( + id: string, + at: number, + wallet: DemoWallet, + fields: Partial +): DemoTransfer { + return { + id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + custodyWalletId: wallet.id, + providerWalletId: wallet.walletId, + type: "transfer", + kind: "pay", + direction: "outbound", + status: "finalized", + signature: demoSignature(id), + error: null, + source: wallet.publicKey, + rampsMemo: {}, + createdAt: iso(at), + updatedAt: iso(at + 2_000), + ...fields, + }; +} + +function withContact(contact: Counterparty | undefined): Partial { + return contact + ? { counterpartyId: contact.id, counterpartyDisplayName: contact.displayName } + : {}; +} + +function applyContact(world: DemoWorld, op: DemoOpOf<"contact">): void { + world.contacts[op.id] = { + id: op.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + externalId: op.ext, + entityType: op.entity, + displayName: op.name, + status: "active", + createdBy: CREATED_BY, + createdAt: iso(op.at), + updatedAt: iso(op.at), + }; +} + +function applyContactArchive(world: DemoWorld, op: DemoOpOf<"contact-archive">): void { + const key = contactKeyOf(world, op.id); + if (key === undefined) return; + delete world.contacts[key]; + for (const [accountKey, account] of Object.entries(world.accounts)) { + if (account.counterpartyId === op.id) delete world.accounts[accountKey]; + } +} + +function applyAddress(world: DemoWorld, op: DemoOpOf<"address">): void { + if (!contactById(world, op.cp)) return; + const account: CounterpartyAccount = { + id: op.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + counterpartyId: op.cp, + accountKind: "crypto_wallet", + label: op.label, + details: { network: "solana", address: op.address }, + providerAccountData: {}, + status: "active", + createdAt: iso(op.at), + updatedAt: iso(op.at), + }; + world.accounts[op.id] = account; +} + +function applySend(world: DemoWorld, op: DemoOpOf<"send">): void { + const wallet = findWallet(world, op.wallet); + if (!wallet) return; + const account = Object.values(world.accounts).find( + (candidate) => candidate.details.address === op.to + ); + world.transfers.push({ + transfer: outboundTransfer(op.id, op.at, wallet, { + destination: op.to, + token: op.token, + amount: op.amount, + ...(op.memo ? { memo: op.memo } : {}), + ...withContact(contactById(world, account?.counterpartyId)), + }), + observed: false, + }); + adjustBalance(world, wallet.id, op.token, op.amount, -1); +} + +function sum(amounts: readonly string[], mint: string): string { + const key = tokenKeyForMint(mint) ?? "USDC"; + const { decimals } = DEMO_TOKENS[key]; + const total = amounts.reduce( + (running, amount) => running + (DECIMAL.test(amount) ? toBaseUnits(amount, decimals) : 0n), + 0n + ); + return fromBaseUnits(total, decimals, 2); +} + +function applyBatch(world: DemoWorld, op: DemoOpOf<"batch">): void { + const wallet = findWallet(world, op.wallet); + if (!wallet) return; + const createdAt = iso(op.at); + const recipients: PaymentTransferRecipient[] = []; + const chunks: (typeof op.to)[] = []; + for (let index = 0; index < op.to.length; index += DEMO_BATCH_RECIPIENTS_PER_TRANSACTION) { + chunks.push(op.to.slice(index, index + DEMO_BATCH_RECIPIENTS_PER_TRANSACTION)); + } + chunks.forEach((chunk, chunkIndex) => { + const transferId = `${op.id}_${chunkIndex + 1}`; + world.transfers.push({ + transfer: outboundTransfer(transferId, op.at + chunkIndex, wallet, { + type: "transfer_batch", + kind: "batch_pay", + token: op.token, + amount: sum( + chunk.map(([, , amount]) => amount), + op.token + ), + }), + observed: false, + }); + for (const [counterpartyId, accountId, amount] of chunk) { + recipients.push({ + id: `${op.id}_r${recipients.length + 1}`, + batchId: op.id, + transferId, + externalId: op.ext ? `${op.ext}-${String(recipients.length + 1).padStart(3, "0")}` : null, + counterpartyId, + counterpartyAccountId: accountId, + destination: accountById(world, accountId)?.details.address ?? demoAddress(accountId), + amount, + status: "confirmed", + error: null, + createdAt, + updatedAt: iso(op.at + 2_000), + }); + } + }); + const totalAmount = sum( + op.to.map(([, , amount]) => amount), + op.token + ); + world.batches.push({ + batch: { + id: op.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + externalId: op.ext, + sourceCustodyWalletId: wallet.id, + sourceProviderWalletId: wallet.walletId, + sourceAddress: wallet.publicKey, + token: op.token, + status: "confirmed", + totalAmount, + recipientCount: recipients.length, + transactionCount: chunks.length, + createdAt, + updatedAt: iso(op.at + 2_000), + }, + recipients, + }); + adjustBalance(world, wallet.id, op.token, totalAmount, -1); +} + +/** Where the demo provider asks an off-ramp's crypto to be sent. */ +export function rampDepositAddress(rampId: string): string { + return demoAddress(`lightspark-deposit:${rampId}`); +} + +/** How each provider hands a ramp over, and the prefix of the reference it gives it. */ +export const DEMO_RAMP_PROVIDERS: Record< + RampProviderId, + { deliveryMode: PaymentRampQuoteDeliveryMode; reference: string } +> = { + lightspark: { deliveryMode: "manual_instructions", reference: "LS" }, + bvnk: { deliveryMode: "manual_instructions", reference: "BVNK" }, + mural: { deliveryMode: "manual_instructions", reference: "MRL" }, + moonpay: { deliveryMode: "hosted", reference: "MP" }, + coinbase: { deliveryMode: "hosted", reference: "CB" }, + moneygram: { deliveryMode: "session_widget", reference: "MG" }, + stripe: { deliveryMode: "session_widget", reference: "STR" }, +}; + +/** The reference a provider shows for a demo quote. */ +export function rampReference(provider: RampProviderId, quoteId: string): string { + return `${DEMO_RAMP_PROVIDERS[provider].reference}-${quoteId.slice(-10).toUpperCase()}`; +} + +export function consentKey(provider: RampProviderId, counterpartyId: string): string { + return `${provider}:${counterpartyId}`; +} + +function applyRamp(world: DemoWorld, op: DemoOpOf<"ramp">): void { + const wallet = findWallet(world, op.wallet); + if (!wallet) return; + const onramp = op.dir === "onramp"; + const depositAddress = rampDepositAddress(op.id); + const { deliveryMode } = DEMO_RAMP_PROVIDERS[op.provider]; + world.transfers.push({ + transfer: outboundTransfer(op.id, op.at, wallet, { + type: op.dir, + kind: op.dir, + direction: onramp ? "inbound" : "outbound", + status: "awaiting_payment", + signature: null, + source: onramp ? demoAddress(`${op.provider}:${op.id}`) : wallet.publicKey, + destination: onramp ? wallet.publicKey : depositAddress, + token: mintForRail(op.rail), + amount: op.crypto, + provider: op.provider, + providerReference: rampReference(op.provider, op.quote), + deliveryMode, + fiatCurrency: op.fiat, + fiatAmount: op.fiatAmount, + ...(onramp + ? {} + : { cryptoDeposit: { destinationAddress: depositAddress, amount: op.crypto } }), + ...withContact(contactById(world, op.cp)), + updatedAt: iso(op.at), + }), + observed: false, + }); +} + +function applyRampPaid(world: DemoWorld, op: DemoOpOf<"ramp-paid">, now: number): void { + const transfer = transferById(world, op.id); + if (transfer?.status !== "awaiting_payment" || !transfer.custodyWalletId) return; + const settled = now - op.at >= DEMO_SETTLE_MS; + transfer.status = settled ? "completed" : "settling"; + transfer.signature = demoSignature(`${op.id}:paid`); + transfer.updatedAt = iso(settled ? op.at + DEMO_SETTLE_MS : op.at); + if (transfer.cryptoDeposit) delete transfer.cryptoDeposit; + const token = transfer.token ?? DEMO_TOKENS.USDC.mint; + const amount = transfer.amount ?? "0"; + if (transfer.direction === "outbound") { + adjustBalance(world, transfer.custodyWalletId, token, amount, -1); + } else if (settled) { + adjustBalance(world, transfer.custodyWalletId, token, amount, 1); + } +} + +function applyRampCancel(world: DemoWorld, op: DemoOpOf<"ramp-cancel">): void { + const transfer = transferById(world, op.id); + if (transfer?.status !== "awaiting_payment") return; + transfer.status = "canceled"; + transfer.updatedAt = iso(op.at); + if (transfer.cryptoDeposit) delete transfer.cryptoDeposit; +} + +function applyPayoutAccount(world: DemoWorld, op: DemoOpOf<"payout-account">): void { + if (!contactById(world, op.cp)) return; + world.providerAccounts.push({ + counterpartyId: op.cp, + account: { + id: op.id, + provider: "lightspark", + kind: "payout_account", + fiatCurrency: op.fiat, + destinationCountry: isCountryCode(op.country) ? op.country : null, + paymentRail: op.rail, + status: "active", + providerStatus: "VERIFIED", + createdAt: iso(op.at), + ...(op.bank ? { bankName: op.bank } : {}), + ...(op.last4 ? { accountNumberLast4: op.last4 } : {}), + }, + }); +} + +function applyRequest(world: DemoWorld, op: DemoOpOf<"request">): void { + const wallet = findWallet(world, op.wallet); + if (!wallet) return; + const createdAt = iso(op.at); + world.requests.push({ + id: op.id, + publicToken: `demo_pt_${demoAddress(`request-token:${op.id}`).slice(0, 22)}`, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + counterpartyId: op.cp, + walletId: wallet.walletId, + destinationAddress: wallet.publicKey, + token: op.token, + amount: op.amount, + reference: demoAddress(`request-reference:${op.id}`), + status: "awaiting_payment", + expiresAt: op.expires, + fulfilledByTransferId: null, + canceledBy: null, + lifecycle: [{ status: "awaiting_payment", at: createdAt }], + createdBy: CREATED_BY, + createdAt, + updatedAt: createdAt, + }); +} + +function applySchedule(world: DemoWorld, op: DemoOpOf<"schedule">): void { + const wallet = findWallet(world, op.wallet); + const account = accountById(world, op.account); + if (!wallet || !account) return; + world.schedules.push({ + id: op.id, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + sourceCustodyWalletId: wallet.id, + sourceProviderWalletId: wallet.walletId, + sourceAddress: wallet.publicKey, + counterpartyId: op.cp, + counterpartyAccountId: account.id, + destinationAddress: account.details.address, + destinationTokenAccount: null, + token: op.token, + amount: op.amount, + periodHours: op.period, + firstCollectionAt: op.first, + nextCollectionDueAt: op.first, + planId: null, + subscriptionId: null, + planPda: null, + planCreatedAt: null, + planCreationSignature: null, + subscriptionPda: null, + subscriptionAuthorityAddress: null, + authorizationSignature: null, + status: "pending_activation", + metadataUri: null, + createdBy: CREATED_BY, + createdAt: iso(op.at), + updatedAt: iso(op.at), + }); +} + +function activate(schedule: PaymentRecurringPayment, at: number): void { + const seed = schedule.id; + schedule.status = "active"; + schedule.planId = schedule.planId ?? `demo_plan_${seed}`; + schedule.subscriptionId = schedule.subscriptionId ?? `demo_sub_${seed}`; + schedule.planPda = schedule.planPda ?? demoAddress(`plan:${seed}`); + schedule.planCreatedAt = schedule.planCreatedAt ?? iso(at); + schedule.planCreationSignature = schedule.planCreationSignature ?? demoSignature(`plan:${seed}`); + schedule.subscriptionPda = schedule.subscriptionPda ?? demoAddress(`subscription:${seed}`); + schedule.subscriptionAuthorityAddress = + schedule.subscriptionAuthorityAddress ?? demoAddress(`subscription-authority:${seed}`); + schedule.authorizationSignature = + schedule.authorizationSignature ?? demoSignature(`authorization:${seed}`); + schedule.destinationTokenAccount = + schedule.destinationTokenAccount ?? demoAddress(`token-account:${seed}`); + schedule.firstCollectionAt = schedule.firstCollectionAt ?? iso(at); + schedule.nextCollectionDueAt = schedule.firstCollectionAt; +} + +/** One run collected now: a settled attempt, its transfer, and the next run a period on. */ +function collect(world: DemoWorld, schedule: PaymentRecurringPayment, at: number): void { + const wallet = findWallet(world, schedule.sourceCustodyWalletId); + if (!wallet || !schedule.subscriptionId) return; + const runId = `${schedule.id}_run_${at.toString(36)}`; + const transferId = `demo_new_xfr_${at.toString(36)}_${schedule.id.slice(-6)}`; + const dueAt = schedule.nextCollectionDueAt ?? iso(at); + world.transfers.push({ + transfer: outboundTransfer(transferId, at, wallet, { + kind: "recurring_pay", + destination: schedule.destinationAddress, + token: schedule.token, + amount: schedule.amount, + ...withContact(contactById(world, schedule.counterpartyId)), + }), + observed: false, + }); + world.attempts.push({ + id: runId, + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + subscriptionId: schedule.subscriptionId, + transferId, + token: schedule.token, + amount: schedule.amount, + dueAt, + attemptedAt: iso(at), + status: "confirmed", + signature: demoSignature(transferId), + error: null, + metadata: { source: "manual", recurringPaymentId: schedule.id, initiatedByKeyId: null }, + createdAt: iso(at), + updatedAt: iso(at + 2_000), + }); + schedule.nextCollectionDueAt = iso(Date.parse(dueAt) + schedule.periodHours * HOUR_MS); + adjustBalance(world, wallet.id, schedule.token, schedule.amount, -1); +} + +function applyScheduleAction(world: DemoWorld, op: DemoOpOf<"schedule-action">): void { + const schedule = world.schedules.find((candidate) => candidate.id === op.id); + if (!schedule) return; + switch (op.action) { + case "activate": + if (schedule.status === "pending_activation") activate(schedule, op.at); + break; + case "collect": + if (schedule.status === "active") collect(world, schedule, op.at); + break; + case "cancel": + if (schedule.status === "pending_activation" || schedule.status === "active") { + schedule.status = "canceled"; + schedule.nextCollectionDueAt = null; + } + break; + case "resume": + if (schedule.status === "canceled" && schedule.subscriptionId) { + schedule.status = "active"; + schedule.nextCollectionDueAt = iso(op.at + schedule.periodHours * HOUR_MS); + } + break; + } + schedule.updatedAt = iso(op.at); +} + +function applyScheduleUpdate(world: DemoWorld, op: DemoOpOf<"schedule-update">): void { + const schedule = world.schedules.find((candidate) => candidate.id === op.id); + if (!schedule) return; + if (op.amount !== undefined) schedule.amount = op.amount; + if (op.token !== undefined) schedule.token = op.token; + if (op.period !== undefined) schedule.periodHours = op.period; + const wallet = op.wallet === undefined ? undefined : findWallet(world, op.wallet); + if (wallet) { + schedule.sourceCustodyWalletId = wallet.id; + schedule.sourceProviderWalletId = wallet.walletId; + schedule.sourceAddress = wallet.publicKey; + } + const account = op.account === undefined ? undefined : accountById(world, op.account); + if (account) { + schedule.counterpartyAccountId = account.id; + schedule.destinationAddress = account.details.address; + } + schedule.updatedAt = iso(op.at); +} + +function applyOp(world: DemoWorld, op: DemoOp, now: number): void { + switch (op.k) { + case "contact": + applyContact(world, op); + return; + case "contact-archive": + applyContactArchive(world, op); + return; + case "address": + applyAddress(world, op); + return; + case "send": + applySend(world, op); + return; + case "batch": + applyBatch(world, op); + return; + case "ramp": + applyRamp(world, op); + return; + case "ramp-paid": + applyRampPaid(world, op, now); + return; + case "ramp-cancel": + applyRampCancel(world, op); + return; + case "consent": + if (contactById(world, op.id)) world.consents.push(consentKey(op.provider, op.id)); + return; + case "verified": + if (contactById(world, op.id)) world.verifications[consentKey(op.provider, op.id)] = op.at; + return; + case "payout-account": + applyPayoutAccount(world, op); + return; + case "request": + applyRequest(world, op); + return; + case "schedule": + applySchedule(world, op); + return; + case "schedule-action": + applyScheduleAction(world, op); + return; + case "schedule-update": + applyScheduleUpdate(world, op); + return; + } +} + +/** The world with the session's actions applied, every list back in newest-first order. */ +export function applyDemoOps(world: DemoWorld, ops: readonly DemoOp[], now: Date): DemoWorld { + if (ops.length === 0) return world; + for (const op of ops) applyOp(world, op, now.getTime()); + world.transfers.sort((left, right) => + right.transfer.createdAt.localeCompare(left.transfer.createdAt) + ); + world.batches.sort((left, right) => right.batch.createdAt.localeCompare(left.batch.createdAt)); + newestFirst(world.requests); + newestFirst(world.schedules); + return world; +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-session.ts b/apps/sdp-web/src/lib/payments-demo/demo-session.ts new file mode 100644 index 0000000000..b39bd2c7e9 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-session.ts @@ -0,0 +1,109 @@ +import { deflateRawSync, inflateRawSync } from "node:zlib"; +import { cookies } from "next/headers"; +import { DEMO_SESSION_COOKIE_PREFIX, isDemoSessionCookie } from "./demo-cookie"; +import { type DemoOp, parseDemoOps } from "./demo-ops"; + +/* + * The demo session: the log of what the visitor did in demo mode since the page loaded, kept in + * the browser as a few short cookies and replayed over the fixtures on every demo read. The + * server stores nothing, so any instance answers the same, and nothing survives a full page + * load (the proxy drops the cookies then). + */ + +/** Cookie values stay well under the 4KB a browser keeps per cookie. */ +const CHUNK_LENGTH = 3600; +/** At most this many chunks, so the request headers stay small next to the session's own. */ +const MAX_CHUNKS = 3; +const MAX_ENCODED_LENGTH = CHUNK_LENGTH * MAX_CHUNKS; + +type CookieStore = Awaited>; + +/** The log as this request has it, writes included, since a cookie set is not read back. */ +const requestLogs = new WeakMap(); + +function chunkName(index: number): string { + return `${DEMO_SESSION_COOKIE_PREFIX}.${index}`; +} + +/** The log as the cookies carry it: deflated JSON, base64url, split into chunks. */ +export function encodeDemoOps(ops: readonly DemoOp[]): string[] { + let kept = [...ops]; + let encoded = deflateRawSync(JSON.stringify(kept)).toString("base64url"); + // Past the budget the oldest actions go first; the replay tolerates what they referred to. + while (encoded.length > MAX_ENCODED_LENGTH && kept.length > 0) { + kept = kept.slice(1); + encoded = deflateRawSync(JSON.stringify(kept)).toString("base64url"); + } + if (kept.length === 0) return []; + const chunks: string[] = []; + for (let offset = 0; offset < encoded.length; offset += CHUNK_LENGTH) { + chunks.push(encoded.slice(offset, offset + CHUNK_LENGTH)); + } + return chunks; +} + +/** The log back from its chunks; anything unreadable reads as an empty session. */ +export function decodeDemoOps(chunks: readonly string[]): DemoOp[] { + if (chunks.length === 0) return []; + try { + const json = inflateRawSync(Buffer.from(chunks.join(""), "base64url")).toString("utf8"); + return parseDemoOps(JSON.parse(json)); + } catch { + return []; + } +} + +function readChunks(store: CookieStore): string[] { + const chunks: string[] = []; + for (let index = 0; index < MAX_CHUNKS; index += 1) { + const value = store.get(chunkName(index))?.value; + if (!value) break; + chunks.push(value); + } + return chunks; +} + +/** What the visitor has done in demo mode on this page load, oldest first. */ +export async function readDemoOps(): Promise { + let store: CookieStore; + try { + store = await cookies(); + } catch { + return []; + } + const known = requestLogs.get(store); + if (known) return known; + const ops = decodeDemoOps(readChunks(store)); + requestLogs.set(store, ops); + return ops; +} + +/** + * Adds actions to the session and writes it back to the browser. Only a route handler or a + * server action can set cookies; anywhere else the actions hold for this request alone. + */ +export async function appendDemoOps(...added: DemoOp[]): Promise { + const store = await cookies(); + const ops = [...(await readDemoOps()), ...added]; + requestLogs.set(store, ops); + const chunks = encodeDemoOps(ops); + try { + chunks.forEach((chunk, index) => { + store.set(chunkName(index), chunk, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + }); + for (const cookie of store.getAll()) { + const index = Number(cookie.name.slice(DEMO_SESSION_COOKIE_PREFIX.length + 1)); + if (isDemoSessionCookie(cookie.name) && !(index < chunks.length)) { + store.delete(cookie.name); + } + } + } catch { + // A server component render cannot set cookies; the change lives for this request only. + } + return ops; +} diff --git a/apps/sdp-web/src/lib/payments-demo/payments-demo-context.tsx b/apps/sdp-web/src/lib/payments-demo/payments-demo-context.tsx new file mode 100644 index 0000000000..6258a4c5f5 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/payments-demo-context.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext } from "react"; + +/** What the server knows about demo mode when the page is drawn. */ +export type PaymentsDemoState = { + /** The project the demo cookie names, or null when demo mode is off. */ + demoProjectId: string | null; + /** The project cookie, which stands in when the project list didn't load. */ + cookieProjectId: string | null; +}; + +/** The project the demo applies to: the selected one, or the project cookie's. */ +export function paymentsDemoProjectId( + state: PaymentsDemoState, + selectedProjectId: string | null +): string | null { + return selectedProjectId ?? state.cookieProjectId; +} + +/** Whether the demo cookie names the project the dashboard is on. */ +export function isPaymentsDemoOn( + state: PaymentsDemoState, + selectedProjectId: string | null +): boolean { + const projectId = paymentsDemoProjectId(state, selectedProjectId); + return projectId !== null && state.demoProjectId === projectId; +} + +const PaymentsDemoContext = createContext(false); + +/** Set by the dashboard shell: true on a Payments screen running in demo mode. */ +export const PaymentsDemoProvider = PaymentsDemoContext.Provider; + +/** + * Whether this Payments screen runs in demo mode, so its forms can start filled in and its + * provider steps can stand in for checkouts that would open elsewhere. + */ +export function usePaymentsDemo(): boolean { + return useContext(PaymentsDemoContext); +} diff --git a/apps/sdp-web/src/lib/sdp-api.ts b/apps/sdp-web/src/lib/sdp-api.ts index 2c2b9878d4..650f300780 100644 --- a/apps/sdp-web/src/lib/sdp-api.ts +++ b/apps/sdp-web/src/lib/sdp-api.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; import { cache } from "react"; import { readApiErrorMessage } from "./api-error"; import { resolveProjectFromList } from "./dashboard-project-selection"; +import { paymentsDemoResponse } from "./payments-demo/demo-mode"; import { PROJECT_COOKIE_NAME, PROJECT_HEADER_NAME } from "./project-cookie"; import { createTimedTrace, @@ -104,11 +105,20 @@ function createSdpApiRequest( // upstream request still receives the full path. const loggedPath = path.split("?", 1)[0]; - const response = await fetch(url, { - ...options, - headers, - cache: "no-store", - }); + const send = () => + fetch(url, { + ...options, + headers, + cache: "no-store", + }); + + // Payments demo mode answers payment reads and writes itself; nothing about them goes out. + const demoResponse = await paymentsDemoResponse(method, path, projectId, options.body, send); + if (demoResponse) { + return demoResponse; + } + + const response = await send(); console.info( JSON.stringify({ diff --git a/apps/sdp-web/src/proxy.ts b/apps/sdp-web/src/proxy.ts index f4fe44904e..7916f952f0 100644 --- a/apps/sdp-web/src/proxy.ts +++ b/apps/sdp-web/src/proxy.ts @@ -2,6 +2,7 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { AUTH_ENTRY_PATH } from "@/lib/auth-entry"; +import { isDemoSessionCookie } from "@/lib/payments-demo/demo-cookie"; import { PROJECT_COOKIE_NAME, WORKSPACE_SCOPE_COOKIE_NAME, @@ -61,6 +62,21 @@ export function rejectCrossSiteWrite(req: NextRequest): NextResponse | null { return NextResponse.json({ error: { message: "Cross-origin request refused" } }, { status: 403 }); } +/** + * The demo session's cookies to forget on this request: all of them on a full page load (a + * refresh, a new tab, the demo switched on or off), none on the app's own navigations and + * fetches. Demo changes live for one page load, as a browser tab's memory would. + */ +export function demoSessionCookiesToDrop(req: NextRequest): string[] { + if (req.headers.get("sec-fetch-dest") !== "document") { + return []; + } + return req.cookies + .getAll() + .map((cookie) => cookie.name) + .filter(isDemoSessionCookie); +} + function getUnauthenticatedUrl(req: NextRequest): string { const authEntryUrl = new URL(AUTH_ENTRY_PATH, req.url); authEntryUrl.searchParams.set("redirect_url", `${req.nextUrl.pathname}${req.nextUrl.search}`); @@ -105,12 +121,23 @@ export const proxy = clerkMiddleware(async (auth, req) => { const requestHeaders = new Headers(req.headers); requestHeaders.set("x-sdp-pathname", req.nextUrl.pathname); + const droppedDemoCookies = demoSessionCookiesToDrop(req); + if (droppedDemoCookies.length > 0) { + // Filter the raw header, so every other cookie reaches the page byte for byte. + const kept = (req.headers.get("cookie") ?? "") + .split(";") + .filter((pair) => !isDemoSessionCookie(pair.split("=", 1)[0]?.trim() ?? "")); + requestHeaders.set("cookie", kept.join(";").trim()); + } const response = NextResponse.next({ request: { headers: requestHeaders, }, }); + for (const name of droppedDemoCookies) { + response.cookies.set(name, "", { path: "/", maxAge: 0 }); + } return response; }); diff --git a/apps/sdp-web/src/proxy.unit.test.ts b/apps/sdp-web/src/proxy.unit.test.ts index 0842cbeafe..17eb516a6c 100644 --- a/apps/sdp-web/src/proxy.unit.test.ts +++ b/apps/sdp-web/src/proxy.unit.test.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { describe, expect, it } from "vitest"; -import { isPublicRoute, rejectCrossSiteWrite } from "./proxy"; +import { demoSessionCookiesToDrop, isPublicRoute, rejectCrossSiteWrite } from "./proxy"; describe("public web routes", () => { it("keeps the workspace loading transition available during bootstrap", () => { @@ -120,3 +120,23 @@ describe("rejectCrossSiteWrite", () => { ).toBeNull(); }); }); + +describe("demoSessionCookiesToDrop", () => { + const cookie = "__session=jwt; sdp-demo-session.0=abc; sdp-demo-session.1=def; other=1"; + + it("forgets the demo session on a full page load", () => { + const request = new NextRequest("https://dashboard.example.com/dashboard/payments", { + headers: { cookie, "sec-fetch-dest": "document" }, + }); + expect(demoSessionCookiesToDrop(request)).toEqual(["sdp-demo-session.0", "sdp-demo-session.1"]); + }); + + it("keeps it across the app's own navigations and fetches", () => { + for (const dest of ["empty", null]) { + const request = new NextRequest("https://dashboard.example.com/dashboard/payments", { + headers: dest === null ? { cookie } : { cookie, "sec-fetch-dest": dest }, + }); + expect(demoSessionCookiesToDrop(request)).toEqual([]); + } + }); +}); From 854acfeebd807e04de89c911eb1a3343ca1750a6 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Wed, 30 Sep 2026 00:30:07 +0300 Subject: [PATCH 2/6] feat(payments): put the Demo switch behind its own payments-demo-mode flag Demo mode gets a flag of its own, payments-demo-mode (SDP_FLAG_PAYMENTS_DEMO_MODE, on by default outside production). It counts only while NEW DESIGN is on, and demo data is served only to Payments pages on the new design: a page whose design module is off shows neither demo data nor the switch. The server judges each request by its page's design module. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/sdp-web/.env.local.example | 3 ++ .../src/components/dashboard-shell.tsx | 19 ++++++++---- apps/sdp-web/src/flags/dashboard.ts | 6 ++++ apps/sdp-web/src/flags/index.ts | 13 ++++++++ apps/sdp-web/src/i18n/ui-copy-baseline.json | 3 ++ .../payments-demo/demo-fixtures.unit.test.ts | 11 +++++-- .../src/lib/payments-demo/demo-mode.ts | 28 ++++++++++------- .../lib/payments-demo/demo-mode.unit.test.ts | 31 ++++++++++++++++--- 8 files changed, 90 insertions(+), 24 deletions(-) diff --git a/apps/sdp-web/.env.local.example b/apps/sdp-web/.env.local.example index ee3c0bfdb0..4f06151dd4 100644 --- a/apps/sdp-web/.env.local.example +++ b/apps/sdp-web/.env.local.example @@ -32,6 +32,9 @@ SENTRY_AUTH_TOKEN= # SDP_FLAG_NEW_DESIGN_CONTACTS=false # SDP_FLAG_NEW_DESIGN_PAY_DEPOSIT=false # SDP_FLAG_NEW_DESIGN_ACTIVITY=false +# Payments' Demo switch (sample data, simulated ramps and KYC). Needs NEW DESIGN; on by default +# outside production. +# SDP_FLAG_PAYMENTS_DEMO_MODE=false # Dashboard module flags are off by default. They hide dashboard UI only; the # public API and API key authoring remain available. # CUSTODY_ENABLED=true diff --git a/apps/sdp-web/src/components/dashboard-shell.tsx b/apps/sdp-web/src/components/dashboard-shell.tsx index fe74314af6..f242938f78 100644 --- a/apps/sdp-web/src/components/dashboard-shell.tsx +++ b/apps/sdp-web/src/components/dashboard-shell.tsx @@ -426,9 +426,18 @@ export function DashboardShell({ const pathname = usePathname(); const { dashboardAccess, selectedProjectId, isSidebarOpen, setSidebarOpen, isProjectSwitching } = useDashboardWorkspace(); - // Demo data is part of the new design; the previous design never shows it. - const demoMode = newDesignEnabled && isPaymentsDemoOn(paymentsDemo, selectedProjectId); - const paymentsDemoOn = isPaymentsPath(pathname) && demoMode; + // NEW DESIGN styles the shell; the page itself follows its design module's flag too. + const newDesignPage = isNewDesignPage(pathname, flags); + // Demo data is part of the new design and has a flag of its own (payments-demo-mode; absent in + // older fixtures, it follows NEW DESIGN). A page on the previous design never shows it or its + // switch. + const demoAvailable = + isPaymentsPath(pathname) && newDesignPage && flags.paymentsDemoMode !== false; + const demoMode = + newDesignEnabled && + flags.paymentsDemoMode !== false && + isPaymentsDemoOn(paymentsDemo, selectedProjectId); + const paymentsDemoOn = demoAvailable && demoMode; const [isMobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [isMoreSheetOpen, setMoreSheetOpen] = useState(false); const [isOrganizationSwitching, setOrganizationSwitching] = useState(false); @@ -447,8 +456,6 @@ export function DashboardShell({ const subnavHydratedRef = useRef(false); const previousPathnameRef = useRef(pathname); const loadingRoute = resolveDashboardLoadingRoute(pathname) ?? "home"; - // NEW DESIGN styles the shell; the page itself follows its design module's flag too. - const newDesignPage = isNewDesignPage(pathname, flags); const PageLoadingComponent = resolvePageLoadingComponent(loadingRoute, newDesignPage); const isWorkspaceSwitching = isProjectSwitching || isOrganizationSwitching; const themeScope = themeScopeForPath(pathname, newDesignPage); @@ -852,7 +859,7 @@ export function DashboardShell({ // new one moves it to the account menu and gives Payments its demo // switch. newDesignEnabled ? ( - isPaymentsPath(pathname) ? ( + demoAvailable ? ( ) : undefined ) : ( diff --git a/apps/sdp-web/src/flags/dashboard.ts b/apps/sdp-web/src/flags/dashboard.ts index 097c50821c..4890d29853 100644 --- a/apps/sdp-web/src/flags/dashboard.ts +++ b/apps/sdp-web/src/flags/dashboard.ts @@ -8,6 +8,7 @@ import { markets, newDesign, payments, + paymentsDemoMode, policies, privateChannels, } from "@/flags"; @@ -27,6 +28,8 @@ export type DashboardFlags = { /** Each design module's own flag (lib/design-modules.ts); counts only with NEW DESIGN on. */ newDesignModules?: DesignModuleFlags; payments: boolean; + /** Payments' Demo switch; absent (older fixtures) follows NEW DESIGN. */ + paymentsDemoMode?: boolean; policies: boolean; privateChannels: boolean; }; @@ -53,6 +56,7 @@ export async function getDashboardFlags(): Promise { newDesignEnabled, newDesignModules, paymentsEnabled, + paymentsDemoModeEnabled, policiesEnabled, privateChannelsEnabled, ] = await Promise.all([ @@ -66,6 +70,7 @@ export async function getDashboardFlags(): Promise { newDesign(), getDesignModuleFlags(), payments(), + paymentsDemoMode(), policies(), privateChannels(), ]); @@ -81,6 +86,7 @@ export async function getDashboardFlags(): Promise { newDesign: newDesignEnabled, newDesignModules, payments: paymentsEnabled, + paymentsDemoMode: paymentsDemoModeEnabled, policies: policiesEnabled, privateChannels: privateChannelsEnabled, }; diff --git a/apps/sdp-web/src/flags/index.ts b/apps/sdp-web/src/flags/index.ts index 1cad6b0d00..232ee131b0 100644 --- a/apps/sdp-web/src/flags/index.ts +++ b/apps/sdp-web/src/flags/index.ts @@ -271,6 +271,19 @@ export const newDesign = flag({ ], }); +export const paymentsDemoMode = flag({ + key: "payments-demo-mode", + adapter: vercelAdapter(), + identify: identifyDashboardEntities, + defaultValue: flagDefault("SDP_FLAG_PAYMENTS_DEMO_MODE", process.env.VERCEL_ENV !== "production"), + description: + "Offer Payments' Demo switch: sample data and simulated provider, KYC and settlement steps in every ramp flow, per project. Requires the new-design flag, and serves only Payments pages on the new design.", + options: [ + { value: false, label: "Hidden" }, + { value: true, label: "Enabled" }, + ], +}); + export const newDesignContacts = newDesignModuleFlag( "contacts", "Payments' Contacts (the list, a new contact, one contact's page)" diff --git a/apps/sdp-web/src/i18n/ui-copy-baseline.json b/apps/sdp-web/src/i18n/ui-copy-baseline.json index 97d2b7f73c..a0ba1cfc6f 100644 --- a/apps/sdp-web/src/i18n/ui-copy-baseline.json +++ b/apps/sdp-web/src/i18n/ui-copy-baseline.json @@ -85,6 +85,9 @@ "src/flags/index.ts:266:3:NEW DESIGN: show the 2026 refresh's shell (palette, type and sidebar, the language switch in the account menu) and the Privacy connect form. Each redesigned area also has a new-design-* flag of its own, which counts only while this one is on. Off serves the previous design everywhere.", "src/flags/index.ts:269:21:Previous design", "src/flags/index.ts:270:20:New design", + "src/flags/index.ts:279:3:Offer Payments' Demo switch: sample data and simulated provider, KYC and settlement steps in every ramp flow, per project. Requires the new-design flag, and serves only Payments pages on the new design.", + "src/flags/index.ts:282:21:Hidden", + "src/flags/index.ts:283:20:Enabled", "src/flags/index.ts:69:5:`Show ${title} as a selectable provider in the onramp and offramp wizards.`", "src/flags/index.ts:71:23:Hidden", "src/flags/index.ts:72:22:Enabled", diff --git a/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts index d8a1f5704c..8edc256709 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-fixtures.unit.test.ts @@ -80,8 +80,15 @@ import { paymentsDemoBody } from "./demo-fixtures"; * fetchers reach it through the real dashboard API routes, whose SDP API proxy is the fixtures. */ -// The pages render on the new design; the flag itself reads Vercel and the request. -vi.mock("@/flags", () => ({ newDesign: async () => true })); +// The pages render on the new design, with the Demo switch on; the flags themselves read Vercel +// and the request. +vi.mock("@/flags", () => ({ + newDesign: async () => true, + newDesignActivity: async () => true, + newDesignContacts: async () => true, + newDesignPayDeposit: async () => true, + paymentsDemoMode: async () => true, +})); const harness = vi.hoisted(() => { const state = { diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode.ts index 6b49bdedb0..f878dfc318 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-mode.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode.ts @@ -9,6 +9,7 @@ import { } from "@sdp/types"; import { cookies, headers } from "next/headers"; import { cache } from "react"; +import { designModuleForPath } from "../design-modules"; import { PROJECT_COOKIE_NAME } from "../project-cookie"; import { isPaymentsPath, PAYMENTS_DEMO_COOKIE_NAME } from "./demo-cookie"; import { buildWorld, demoPathParts, demoWorldBody } from "./demo-fixtures"; @@ -51,21 +52,24 @@ const demoRequested = cache(async (projectId: string | null): Promise = if (!project || cookieStore.get(PAYMENTS_DEMO_COOKIE_NAME)?.value !== project) { return false; } - // Demo data is part of the new design; the previous design never serves it. Imported here, - // not at the top: the flags module reads auth through sdp-api, which imports this file. - const { newDesign } = await import("@/flags"); - if (!(await newDesign())) { - return false; - } const pathname = headerStore.get("x-sdp-pathname"); - if (isPaymentsPath(pathname)) { - return true; - } - if (!pathname?.startsWith("/api/dashboard/")) { + const referer = pathname?.startsWith("/api/dashboard/") ? headerStore.get("referer") : null; + const page = isPaymentsPath(pathname) ? pathname : referer ? new URL(referer).pathname : null; + if (!page || !isPaymentsPath(page)) { return false; } - const referer = headerStore.get("referer"); - return referer ? isPaymentsPath(new URL(referer).pathname) : false; + // Demo data is part of the new design and has a flag of its own: a page on the previous + // design never gets it. Imported here, not at the top: the flags module reads auth through + // sdp-api, which imports this file. + const [{ paymentsDemoMode }, { isNewDesignOn }] = await Promise.all([ + import("@/flags"), + import("@/flags/new-design"), + ]); + const [demoModeOn, newDesignPage] = await Promise.all([ + paymentsDemoMode(), + isNewDesignOn(designModuleForPath(page) ?? undefined), + ]); + return demoModeOn && newDesignPage; } catch { // Outside a request there are no cookies or headers to read, so no demo. return false; diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts index a0acf3a55c..c571fff55a 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode.unit.test.ts @@ -20,11 +20,25 @@ const browser = vi.hoisted(() => { jar.delete(name); }, }; - return { jar, store, pathname: "/dashboard/payments", newDesign: true }; + return { + jar, + store, + pathname: "/dashboard/payments", + newDesign: true, + demoMode: true, + newDesignModule: undefined as string | undefined, + }; }); -// Demo mode is part of the new design; the flag itself reads Vercel and the request. -vi.mock("@/flags", () => ({ newDesign: async () => browser.newDesign })); +// Demo mode is part of the new design and has a flag of its own; the flags themselves read +// Vercel and the request. +vi.mock("@/flags", () => ({ paymentsDemoMode: async () => browser.demoMode })); +vi.mock("@/flags/new-design", () => ({ + isNewDesignOn: async (designModule?: string) => { + browser.newDesignModule = designModule; + return browser.newDesign; + }, +})); vi.mock("next/headers", () => ({ // A fresh store object per request, as Next gives each request its own. @@ -66,6 +80,7 @@ beforeEach(() => { browser.jar.set("sdp-payments-demo", PROJECT); browser.pathname = "/dashboard/payments"; browser.newDesign = true; + browser.demoMode = true; upstream.mockClear(); vi.useFakeTimers(); vi.setSystemTime(NOW); @@ -86,8 +101,16 @@ describe("scope", () => { expect((await call("GET", "/v1/counterparties")).status).toBeNull(); }); - it("stays out of the way with NEW DESIGN off, cookie or not", async () => { + it("stays out of the way with the page on the previous design, cookie or not", async () => { browser.newDesign = false; + browser.pathname = "/dashboard/payments/counterparty"; + expect((await call("GET", "/v1/counterparties")).status).toBeNull(); + // The page's own design module decides, not NEW DESIGN alone. + expect(browser.newDesignModule).toBe("contacts"); + }); + + it("stays out of the way with the demo flag off, cookie or not", async () => { + browser.demoMode = false; expect((await call("GET", "/v1/counterparties")).status).toBeNull(); }); From af5033e9406412889fc7adf61062ef2857b5d4c0 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Thu, 1 Oct 2026 10:39:39 +0300 Subject: [PATCH 3/6] fix(payments): close demo-mode review gaps The demo switch server action now requires a signed-in organization and the demo flag before writing cookies. Demo session decoding is bounded (encoded length and a 64 KiB inflate cap). Ramps never run an asset the demo wallets lack: the pickers drop those pairs and the demo quotes refuse them instead of crediting USDC. Simulate deposit is offered only on sandbox or demo deposits, and the demo simulates Mural in any currency. Demo payment requests no longer hand out /pay links the public page cannot open. The demo switch copy no longer claims nothing leaves the browser, the flag metadata gets reasoned i18n exemptions, the deprecated zod passthrough is gone, and OfframpStepContent, OfframpRail and useOnrampWizard shed the complexity this PR added. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../messages/en/dashboard-payments.json | 3 +- .../payments/payments-workspace.data.ts | 7 +- .../offramp-step-content.redesign.tsx | 133 ++++++++-------- .../ramp-pair-provider-selector.redesign.tsx | 8 +- .../hooks/use-offramp-wizard.redesign.ts | 3 +- .../ramps/hooks/use-onramp-wizard.redesign.ts | 144 +++++++++++------- .../payments/ramps/offramp-rail.redesign.tsx | 19 ++- .../payment-request-create-workspace.tsx | 16 +- .../payment-request-detail-workspace.tsx | 14 ++ .../payment-requests-workspace.redesign.tsx | 7 + apps/sdp-web/src/i18n/ui-copy-exemptions.json | 12 ++ .../src/lib/payments-demo/demo-handlers.ts | 54 ++++--- .../src/lib/payments-demo/demo-mode-action.ts | 12 ++ .../demo-mode-action.unit.test.ts | 99 ++++++++++++ .../src/lib/payments-demo/demo-ramp-assets.ts | 27 ++++ .../src/lib/payments-demo/demo-replay.ts | 21 ++- .../src/lib/payments-demo/demo-session.ts | 30 +++- 17 files changed, 439 insertions(+), 170 deletions(-) create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-mode-action.unit.test.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-ramp-assets.ts diff --git a/apps/sdp-web/messages/en/dashboard-payments.json b/apps/sdp-web/messages/en/dashboard-payments.json index 7b0a526b5d..684173ff50 100644 --- a/apps/sdp-web/messages/en/dashboard-payments.json +++ b/apps/sdp-web/messages/en/dashboard-payments.json @@ -2,9 +2,10 @@ "DashboardPayments": { "demo": { "label": "Demo", - "turnOn": "Try Payments with sample data. Nothing you do in demo mode is sent anywhere; it stays in this browser until you reload.", + "turnOn": "Try Payments with sample data. Payments, contacts and provider steps in demo mode are simulated and never sent to a provider; they stay in this browser until you reload.", "turnOff": "Turn off demo mode", "sandboxOnly": "Demo mode runs on sandbox projects. Switch to your sandbox project to try it.", + "noPayLink": "Demo requests have no pay link to share. Turn off demo mode to create a real one.", "notice": { "state": "Demo mode", "body": "Sample data for presentation. Every action is simulated." diff --git a/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts b/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts index f4def7ccee..a1d9add433 100644 --- a/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts +++ b/apps/sdp-web/src/app/dashboard/payments/payments-workspace.data.ts @@ -843,8 +843,11 @@ type SandboxTransferSimulationInput = }; } | { - /** BVNK's sandbox, and demo mode's stand-in checkouts for the widget providers. */ - provider: Exclude; + /** + * BVNK's sandbox, and demo mode's stand-in for every provider's pay-in (Mural's in any + * currency, since the demo needs no sandbox to pay in). + */ + provider: Exclude; payload: { transferId: string; }; diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx index 63cde87af0..e9c176d9a0 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/components/offramp-step-content.redesign.tsx @@ -74,10 +74,79 @@ function OfframpManualQuoteStep({ ); } +/** + * The payout's last step once its quote is in: the provider's hosted page or widget (in demo + * mode, the demo's stand-in for them), or the bank instructions. + */ +function OfframpQuoteStep({ + wizard, + quote, + t, +}: { + wizard: OfframpWizard; + quote: NonNullable; + t: Translate; +}) { + const demo = usePaymentsDemo(); + const { selectedWallet, selectedRampPair, fields, transferStatus } = wizard; + + if (quote.deliveryMode === "manual_instructions") { + return ; + } + + // A provider's own page or widget can't open on sample data; the demo stands in for it. + if (demo) { + return ( +
+ +
+ +
+
+ ); + } + + if (quote.deliveryMode === "hosted") { + return ( + + ); + } + + if (quote.provider !== "moneygram" || !selectedWallet || wizard.quoteTransferId === null) { + return ; + } + return ( +
+ +
+ +
+
+ ); +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: step dispatch keeps every offramp stage in one component while each branch stays simple. export function OfframpStepContent({ wizard }: { wizard: OfframpWizard }) { const t = useTranslations(); - const demo = usePaymentsDemo(); const { currentStepId, enabledRampProviders, @@ -100,8 +169,6 @@ export function OfframpStepContent({ wizard }: { wizard: OfframpWizard }) { collectedData, setCollectedField, requirementsBlocker, - sourceTokenMint, - refreshQuote, quoteCreationError, quoteCreationRetrying, retryQuoteCreation, @@ -264,64 +331,8 @@ export function OfframpStepContent({ wizard }: { wizard: OfframpWizard }) { return ; } - // A provider's own page or widget can't open on sample data; the demo stands in for it. - if ( - currentStepId === "COMPLETE" && - demo && - quote && - quote.deliveryMode !== "manual_instructions" - ) { - return ( -
- -
- -
-
- ); - } - - if (currentStepId === "COMPLETE" && quote?.deliveryMode === "hosted") { - return ( - - ); - } - - if (currentStepId === "COMPLETE" && quote?.provider === "moneygram") { - if (!selectedWallet || wizard.quoteTransferId === null) { - return ; - } - return ( -
- -
- -
-
- ); - } - - if (currentStepId === "COMPLETE" && quote?.deliveryMode === "manual_instructions") { - return ; + if (currentStepId === "COMPLETE" && quote) { + return ; } return ; diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-pair-provider-selector.redesign.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-pair-provider-selector.redesign.tsx index 7879646a0f..9098416ffd 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-pair-provider-selector.redesign.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/components/ramp-pair-provider-selector.redesign.tsx @@ -25,6 +25,8 @@ import { useThemeScope } from "@/components/theme-scope"; import { Modal } from "@/components/ui/modal"; import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import { useTranslations } from "@/i18n/provider"; +import { demoRampPairs } from "@/lib/payments-demo/demo-ramp-assets"; +import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import type { RampProviderAccess } from "@/lib/provider-availability"; import { findRampPair, @@ -567,9 +569,13 @@ export function RampPairProviderSelector({ onProviderSelect, }: RampPairProviderSelectorProps) { const { sdpEnvironment } = useDashboardWorkspace(); + const demo = usePaymentsDemo(); const refresh = useThemeScope() === "refresh"; const [unavailableDialogOpen, setUnavailableDialogOpen] = useState(false); - const pairs = pairsForDirection(direction, sdpEnvironment, enabledRampProviders); + const pairs = demoRampPairs( + pairsForDirection(direction, sdpEnvironment, enabledRampProviders), + demo + ); const selectedPairSupport = useMemo( () => findRampPair(pairs, selectedPair), [pairs, selectedPair] diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-offramp-wizard.redesign.ts b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-offramp-wizard.redesign.ts index 02b42d9cfc..204b0925c5 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-offramp-wizard.redesign.ts +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-offramp-wizard.redesign.ts @@ -22,6 +22,7 @@ import { import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import type { MessageKey, TranslationValues } from "@/i18n/messages"; import { useLocale, useTranslations } from "@/i18n/provider"; +import { demoRampPairs } from "@/lib/payments-demo/demo-ramp-assets"; import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import { offrampPairs } from "@/lib/ramps"; import type { WizardSummaryDetail } from "../../wizard-summary-list"; @@ -103,7 +104,7 @@ export function useOfframpWizard(props: UseRampWizardProps) { ); const wizard = useRampWizard(props, { - pairs: offrampPairs(sdpEnvironment, props.enabledRampProviders), + pairs: demoRampPairs(offrampPairs(sdpEnvironment, props.enabledRampProviders), demo), steps: getOfframpSteps(t), stepSchemas: { WALLET: sourceWalletSchema, WITHDRAW: withdrawAmountSchema }, quoteStepId: "MEMO", diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-onramp-wizard.redesign.ts b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-onramp-wizard.redesign.ts index 81cbe52f78..b5610b6185 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-onramp-wizard.redesign.ts +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-onramp-wizard.redesign.ts @@ -19,6 +19,7 @@ import { import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import type { MessageKey, TranslationValues } from "@/i18n/messages"; import { useLocale, useTranslations } from "@/i18n/provider"; +import { demoRampPairs } from "@/lib/payments-demo/demo-ramp-assets"; import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import { onrampPairs } from "@/lib/ramps"; import type { WizardSummaryDetail } from "../../wizard-summary-list"; @@ -78,7 +79,7 @@ function getOnrampRequirementsStep(t: Translate): RampWizardStep { * Whether the provider's sandbox can simulate this quote's pay-in: Lightspark's always, BVNK's * once its funding account is ready, Mural's in the currencies its sandbox pays in. */ -function canSimulateQuote(quote: PaymentRampQuote, muralCurrency: boolean): boolean { +function canSimulateQuote(quote: PaymentRampQuote, fiatCurrency: string): boolean { switch (quote.provider) { case "lightspark": return true; @@ -91,12 +92,74 @@ function canSimulateQuote(quote: PaymentRampQuote, muralCurrency: boolean): bool ) ); case "mural": - return muralCurrency; + return isMuralSandboxPayinCurrency(fiatCurrency); default: return false; } } +interface SimulationContext { + quote: PaymentRampQuote | null; + transferId: string | null; + transferStatus: PaymentTransferSummary | undefined; + succeeded: boolean; + demo: boolean; + sandbox: boolean; + fiatCurrency: string; +} + +/** + * Whether a deposit can be marked paid: while it waits for its money (and as done until it + * finishes), through a sandbox provider's own simulation, or in demo mode for any provider, the + * demo standing in for checkouts that would open elsewhere. A production deposit never offers + * it: the sandbox can't fund a live deposit. + */ +function simulationOffered(context: SimulationContext): context is SimulationContext & { + quote: PaymentRampQuote; + transferId: string; +} { + const { quote, transferId, transferStatus } = context; + if (quote === null || transferId === null || transferStatus === undefined) return false; + if (getRampTransferState(transferStatus.status).terminal) return false; + if (transferStatus.status !== "awaiting_payment" && !context.succeeded) return false; + return context.demo || (context.sandbox && canSimulateQuote(quote, context.fiatCurrency)); +} + +type SimulationRequest = Parameters[0]; + +/** + * What marks the deposit paid. Demo mode answers by the transfer for any provider; the real + * sandboxes each take their own payload, Mural's only in the currencies it pays in. + */ +function simulationRequest( + quote: PaymentRampQuote, + transferId: string, + demo: boolean, + mural: { counterpartyId: string; amount: string; fiatCurrency: string }, + t: Translate +): SimulationRequest { + if (quote.provider === "lightspark") { + return { provider: "lightspark", payload: { quoteId: quote.id, currencyCode: "USD" } }; + } + if (demo || quote.provider !== "mural") { + return { provider: quote.provider, payload: { transferId } }; + } + const { fiatCurrency } = mural; + if (!isMuralSandboxPayinCurrency(fiatCurrency)) { + throw new Error( + t("DashboardPayments.ramps.muralSandboxCurrencyUnsupported", { currency: fiatCurrency }) + ); + } + return { + provider: "mural", + payload: { + counterpartyId: mural.counterpartyId, + amount: Number(mural.amount.trim()), + fiatCurrency, + }, + }; +} + export function useOnrampWizard(props: UseRampWizardProps) { const { sdpEnvironment } = useDashboardWorkspace(); const demo = usePaymentsDemo(); @@ -106,7 +169,7 @@ export function useOnrampWizard(props: UseRampWizardProps) { const [quoteSimulationSucceeded, setQuoteSimulationSucceeded] = useState(false); const wizard = useRampWizard(props, { - pairs: onrampPairs(sdpEnvironment, props.enabledRampProviders), + pairs: demoRampPairs(onrampPairs(sdpEnvironment, props.enabledRampProviders), demo), steps: getOnrampSteps(t), stepSchemas: { DEPOSIT: depositDetailsSchema }, quoteStepId: "MEMO", @@ -187,28 +250,22 @@ export function useOnrampWizard(props: UseRampWizardProps) { bvnkSettlementReached || (transferStatus !== undefined && transferStatus.status === "completed"); - // A sandbox deposit can be marked paid: through the provider's own simulation for the - // providers that have one, and in demo mode for any provider, the demo standing in for - // checkouts that would open elsewhere. It is offered while the deposit waits for its money, - // and shows as done until the deposit finishes. - const transferOpen = - transferStatus !== undefined && !getRampTransferState(transferStatus.status).terminal; - const simulateAvailable = - wizard.quote !== null && - wizard.quoteTransferId !== null && - transferOpen && - (transferStatus.status === "awaiting_payment" || quoteSimulationSucceeded) && - (demo || - canSimulateQuote( - wizard.quote, - isMuralSandboxPayinCurrency(wizard.selectedRampPair.fiatCurrency) - )); + const simulation = { + quote: wizard.quote, + transferId: wizard.quoteTransferId, + transferStatus, + succeeded: quoteSimulationSucceeded, + demo, + sandbox: sdpEnvironment === "sandbox", + fiatCurrency: wizard.selectedRampPair.fiatCurrency, + }; + const simulateAvailable = simulationOffered(simulation); const simulateCurrentQuote = async () => { - const quote = wizard.quote; - if (!simulateAvailable || !quote || !wizard.selectedWallet || wizard.quoteTransferId === null) { + if (!simulationOffered(simulation) || !wizard.selectedWallet) { return; } + const { quote, transferId } = simulation; setQuoteSimulationLoading(true); const toastId = toast.loading(t("DashboardPayments.ramps.simulatingQuoteFunding"), { @@ -216,43 +273,20 @@ export function useOnrampWizard(props: UseRampWizardProps) { }); try { - if (quote.provider === "lightspark") { - await simulateSandboxTransfer( + await simulateSandboxTransfer( + simulationRequest( + quote, + transferId, + demo, { - provider: "lightspark", - payload: { quoteId: quote.id, currencyCode: "USD" }, + counterpartyId: wizard.fields.counterpartyId, + amount: wizard.fields.amount, + fiatCurrency: simulation.fiatCurrency, }, t - ); - } else if (quote.provider === "mural") { - const fiatCurrency = wizard.selectedRampPair.fiatCurrency; - if (!isMuralSandboxPayinCurrency(fiatCurrency)) { - throw new Error( - t("DashboardPayments.ramps.muralSandboxCurrencyUnsupported", { - currency: fiatCurrency, - }) - ); - } - await simulateSandboxTransfer( - { - provider: "mural", - payload: { - counterpartyId: wizard.fields.counterpartyId, - amount: Number(wizard.fields.amount.trim()), - fiatCurrency, - }, - }, - t - ); - } else { - await simulateSandboxTransfer( - { - provider: quote.provider, - payload: { transferId: wizard.quoteTransferId }, - }, - t - ); - } + ), + t + ); setQuoteSimulationSucceeded(true); toast.success(t("DashboardPayments.ramps.quoteFundingSimulated"), { id: toastId, diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/offramp-rail.redesign.tsx b/apps/sdp-web/src/app/dashboard/payments/ramps/offramp-rail.redesign.tsx index 3ccb1c4547..136e342ba9 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/offramp-rail.redesign.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/offramp-rail.redesign.tsx @@ -62,6 +62,17 @@ function offrampPrimaryAction( } } +/** Whether the footer's primary button waits: on a quote, a verification, or the wallets. */ +function offrampPrimaryDisabled(wizard: OfframpWizard, verificationPending: boolean): boolean { + return ( + wizard.hostedQuoteLoading || + verificationPending || + wizard.verificationSimulating || + !wizard.canProceed || + (wizard.currentStepId === "WALLET" && wizard.walletsLoading) + ); +} + /** The final step's heading once the payout reached an outcome worth naming. */ function offrampCompletionTitle(wizard: OfframpWizard, t: Translate): string | undefined { if (wizard.transferStatus?.status === "completed") { @@ -215,13 +226,7 @@ export function OfframpRail({ steps={[...preSteps, ...wizard.steps]} stepIndex={preSteps.length + wizard.stepIndex} completionTitle={offrampCompletionTitle(wizard, t)} - primaryDisabled={ - wizard.hostedQuoteLoading || - verificationPending || - wizard.verificationSimulating || - !wizard.canProceed || - (wizard.currentStepId === "WALLET" && wizard.walletsLoading) - } + primaryDisabled={offrampPrimaryDisabled(wizard, verificationPending)} primaryLabel={offrampPrimaryLabel(wizard, verificationPending, verificationUrl, t)} walletsError={wizard.liveWalletsError} onPrimary={offrampPrimaryAction(wizard, verificationUrl)} diff --git a/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-create-workspace.tsx b/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-create-workspace.tsx index f8635eaf78..efa16de276 100644 --- a/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-create-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-create-workspace.tsx @@ -437,14 +437,18 @@ function PaymentRequestCreateForm({ // The page stays busy from here: it is on its way to the list, and a second press must not // make a second link. const created = res.data?.data; - const copied = created?.publicToken - ? await copyToClipboard(`${window.location.origin}/pay/${created.publicToken}`) - : false; + // A demo request lives in this browser only, so the public pay page can't open its link. + const copied = + !demo && created?.publicToken + ? await copyToClipboard(`${window.location.origin}/pay/${created.publicToken}`) + : false; toast.success(t("DashboardPayments.requests.requestCreated"), { id: "payment-request-created", - description: copied - ? t("DashboardPayments.requests.linkOnClipboard") - : t("DashboardPayments.requests.copyLinkFromRequest"), + description: demo + ? t("DashboardPayments.demo.noPayLink") + : copied + ? t("DashboardPayments.requests.linkOnClipboard") + : t("DashboardPayments.requests.copyLinkFromRequest"), }); router.push(created?.id ? paymentRequestHref(created.id) : PAYMENT_REQUESTS_HREF); } diff --git a/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-detail-workspace.tsx b/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-detail-workspace.tsx index ca82f0e44c..5f7595e618 100644 --- a/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-detail-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/requests/payment-request-detail-workspace.tsx @@ -10,6 +10,7 @@ import { DashboardWorkspaceOverviewPanel } from "@/components/dashboard-workspac import { Button } from "@/components/ui/button"; import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import { useLocale, useTranslations } from "@/i18n/provider"; +import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import { transactionHref } from "@/lib/payments-routes"; import { shortenAddress } from "../payments-overview.utils"; import { formatDateTime, formatDecimalAmount } from "../payments-presentation"; @@ -49,11 +50,24 @@ function requestWhy( function RequestPaymentLink({ request, symbol }: { request: PaymentRequest; symbol: string }) { const t = useTranslations(); const locale = useLocale(); + const demo = usePaymentsDemo(); // The link is on this origin; read after mount so the server render does not guess it. const [origin, setOrigin] = useState(""); useEffect(() => setOrigin(window.location.origin), []); const payLink = `${origin}/pay/${request.publicToken}`; + // A demo request lives in this browser only, so the public pay page can't open its link. + if (demo) { + return ( +
+ + {t("DashboardPayments.requestDetail.paymentLink")} + +

{t("DashboardPayments.demo.noPayLink")}

+
+ ); + } + async function copyLink() { try { await navigator.clipboard.writeText(payLink); diff --git a/apps/sdp-web/src/app/dashboard/payments/requests/payment-requests-workspace.redesign.tsx b/apps/sdp-web/src/app/dashboard/payments/requests/payment-requests-workspace.redesign.tsx index 8b017f2b24..a30292507a 100644 --- a/apps/sdp-web/src/app/dashboard/payments/requests/payment-requests-workspace.redesign.tsx +++ b/apps/sdp-web/src/app/dashboard/payments/requests/payment-requests-workspace.redesign.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/table"; import { useDashboardWorkspace } from "@/contexts/dashboard-workspace-context"; import { useLocale, useTranslations } from "@/i18n/provider"; +import { usePaymentsDemo } from "@/lib/payments-demo/payments-demo-context"; import { PAYMENT_REQUEST_NEW_HREF, paymentRequestHref } from "@/lib/payments-routes"; import { cn } from "@/lib/utils"; import { shortenAddress } from "../payments-overview.utils"; @@ -200,6 +201,7 @@ export function PaymentRequestsWorkspace({ total = initialPaymentRequests.length, }: PaymentRequestsWorkspaceProps) { const t = useTranslations(); + const demo = usePaymentsDemo(); const locale = useLocale(); const { sdpEnvironment } = useDashboardWorkspace(); const tokens = useMemo( @@ -254,6 +256,11 @@ export function PaymentRequestsWorkspace({ const rows = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize); const copyLink = (request: PaymentRequest) => { + // A demo request lives in this browser only, so the public pay page can't open its link. + if (demo) { + toast.info(t("DashboardPayments.demo.noPayLink")); + return; + } void navigator.clipboard.writeText(`${window.location.origin}/pay/${request.publicToken}`); toast.success(t("DashboardPayments.requests.paymentLinkCopied")); }; diff --git a/apps/sdp-web/src/i18n/ui-copy-exemptions.json b/apps/sdp-web/src/i18n/ui-copy-exemptions.json index 6dad4b54b7..201cb3c737 100644 --- a/apps/sdp-web/src/i18n/ui-copy-exemptions.json +++ b/apps/sdp-web/src/i18n/ui-copy-exemptions.json @@ -186,5 +186,17 @@ { "candidate": "src/components/network-debug-panel.tsx:48:7:·", "reason": "Decorative typographic separator" + }, + { + "candidate": "src/flags/index.ts:279:3:Offer Payments' Demo switch: sample data and simulated provider, KYC and settlement steps in every ramp flow, per project. Requires the new-design flag, and serves only Payments pages on the new design.", + "reason": "Vercel Flags Explorer metadata for operators (flag description and option labels); never rendered in the product UI" + }, + { + "candidate": "src/flags/index.ts:282:21:Hidden", + "reason": "Vercel Flags Explorer metadata for operators (flag description and option labels); never rendered in the product UI" + }, + { + "candidate": "src/flags/index.ts:283:20:Enabled", + "reason": "Vercel Flags Explorer metadata for operators (flag description and option labels); never rendered in the product UI" } ] diff --git a/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts b/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts index 8f1b58b99c..adcad312f2 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-handlers.ts @@ -21,6 +21,7 @@ import { z } from "zod"; import { getRampProviderLabel } from "../ramps"; import { DEMO_TOKENS, + type DemoTokenKey, type DemoWorld, findWallet, fromBaseUnits, @@ -34,10 +35,10 @@ import { consentKey, contactById, DEMO_BATCH_RECIPIENTS_PER_TRANSACTION, - mintForRail, rampDepositAddress, rampReference, tokenKeyForMint, + tokenKeyForRail, transferById, walletHolding, } from "./demo-replay"; @@ -549,14 +550,13 @@ function rampAmounts( provider: RampProviderId, direction: RampDirection, fiatCurrency: string, - assetRail: string, + tokenKey: DemoTokenKey, amount: number ): RampAmounts { - const mint = mintForRail(assetRail); - const token = DEMO_TOKENS[tokenKeyForMint(mint) ?? "USDC"]; + const token = DEMO_TOKENS[tokenKey]; const rate = usdPerUnit(fiatCurrency) / token.usdPrice; const feeRate = PROVIDER_FEE_RATE[provider]; - const common = { rate, mint, symbol: token.symbol, decimals: token.decimals }; + const common = { rate, mint: token.mint, symbol: token.symbol, decimals: token.decimals }; if (direction === "onramp") { const fee = amount * feeRate; return { ...common, fiat: amount, crypto: (amount - fee) * rate, fee }; @@ -586,12 +586,12 @@ function estimate({ segments, body, now }: WriteContext): DemoWriteResult { const raw = direction === "onramp" ? input.data.fiatAmount : input.data.cryptoAmount; const amount = raw !== undefined && DECIMAL.test(raw) ? Number(raw) : 0; const expiresAt = new Date(now.getTime() + QUOTE_TTL_MS).toISOString(); - const estimates: RampProviderEstimateResult[] = pairProviders( - direction, - fiatCurrency, - assetRail - ).map((provider) => { - const amounts = rampAmounts(provider, direction, fiatCurrency, assetRail, amount); + // No provider runs a pair whose asset the demo wallets don't hold. + const tokenKey = tokenKeyForRail(assetRail); + if (!tokenKey) return record([], () => ok({ data: { estimates: [] } })); + const providers = pairProviders(direction, fiatCurrency, assetRail); + const estimates: RampProviderEstimateResult[] = providers.map((provider) => { + const amounts = rampAmounts(provider, direction, fiatCurrency, tokenKey, amount); return { provider, status: "ok", @@ -894,6 +894,13 @@ function pairRefusal(provider: RampProviderId, from: string, to: string): DemoWr return error(400, `${getRampProviderLabel(provider)} doesn't run ${from} to ${to}.`); } +function assetRefusal(assetRail: string): DemoWriteResult { + return error( + 400, + `The demo wallets don't hold ${getCryptoRailAssetLabel(assetRail as CryptoRailId)}. Choose USDC, SOL or EURC.` + ); +} + /** Whether a contact finished a provider's onboarding; only BVNK runs one in the demo. */ function onboardedWith( provider: RampProviderId, @@ -934,6 +941,8 @@ function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { const input = parse(onrampQuoteSchema, body); if ("failure" in input) return input.failure; const { counterpartyId, destinationCustodyWalletId, assetRail, fiatCurrency } = input.data; + const tokenKey = tokenKeyForRail(assetRail); + if (!tokenKey) return assetRefusal(assetRail); if (!contactById(world, counterpartyId)) return error(404, "Contact not found.", "not_found"); if (!onboardedWith(provider, world, counterpartyId, now)) return notOnboarded(provider); const wallet = findWallet(world, destinationCustodyWalletId); @@ -949,7 +958,7 @@ function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { provider, direction, fiatCurrency, - assetRail, + tokenKey, Number(input.data.fiatAmount) ); const crypto = cryptoText(amounts); @@ -985,6 +994,8 @@ function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { if ("failure" in input) return input.failure; const { counterpartyId, sourceCustodyWalletId, assetRail, fiatCurrency, cryptoAmount } = input.data; + const tokenKey = tokenKeyForRail(assetRail); + if (!tokenKey) return assetRefusal(assetRail); if (!contactById(world, counterpartyId)) return error(404, "Contact not found.", "not_found"); if (!onboardedWith(provider, world, counterpartyId, now)) return notOnboarded(provider); const wallet = findWallet(world, sourceCustodyWalletId); @@ -992,7 +1003,7 @@ function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { if (!pairProviders(direction, fiatCurrency, assetRail).includes(provider)) { return pairRefusal(provider, getCryptoRailAssetLabel(assetRail as CryptoRailId), fiatCurrency); } - const amounts = rampAmounts(provider, direction, fiatCurrency, assetRail, Number(cryptoAmount)); + const amounts = rampAmounts(provider, direction, fiatCurrency, tokenKey, Number(cryptoAmount)); const notEnough = shortfall(world, sourceCustodyWalletId, amounts.mint, cryptoAmount); if (notEnough) return error(400, notEnough, "insufficient_funds"); const offrampQuote = offrampQuoteFor(provider, { @@ -1025,15 +1036,14 @@ function quote({ segments, body, world, now }: WriteContext): DemoWriteResult { const simulateSchema = z.object({ provider: z.string(), - payload: z - .object({ - quoteId: z.string().optional(), - transferId: z.string().optional(), - counterpartyId: z.string().optional(), - /** Demo mode's Simulate verification: the provider approves the contact's identity check. */ - verification: z.literal("approved").optional(), - }) - .passthrough(), + // Loose: each provider's sandbox payload carries fields of its own the demo doesn't read. + payload: z.looseObject({ + quoteId: z.string().optional(), + transferId: z.string().optional(), + counterpartyId: z.string().optional(), + /** Demo mode's Simulate verification: the provider approves the contact's identity check. */ + verification: z.literal("approved").optional(), + }), }); /** Simulate verification: BVNK approves the identity check of a contact that accepted its terms. */ diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts index 62f29accc5..2610c7eb95 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.ts @@ -1,7 +1,9 @@ "use server"; import { cookies } from "next/headers"; +import { paymentsDemoMode } from "@/flags"; import { PROJECT_COOKIE_NAME } from "../project-cookie"; +import { getSdpAuth } from "../sdp-api"; import { isDemoSessionCookie, PAYMENTS_DEMO_COOKIE_NAME } from "./demo-cookie"; const DEMO_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; @@ -13,11 +15,21 @@ const COOKIE_SAFE_VALUE = /^[\w-]{1,80}$/; * the dashboard has selected, or the project cookie's when the project list didn't load. Off, * it clears that. Either way it forgets what was done in the demo so far. Returns whether the * switch took; the caller then redraws the page, dropping what it had cached. + * + * A server action can be called directly, so it does nothing for a caller who isn't signed in + * to an organization, and turns the demo on only while its flag is on. */ export async function setPaymentsDemoAction( enabled: boolean, projectId: string | null ): Promise { + const { userId, orgId } = await getSdpAuth(); + if (!userId || !orgId) { + return false; + } + if (enabled && !(await paymentsDemoMode())) { + return false; + } const store = await cookies(); for (const { name } of store.getAll()) { if (isDemoSessionCookie(name)) store.delete(name); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-mode-action.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.unit.test.ts new file mode 100644 index 0000000000..858ef48d46 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-mode-action.unit.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/* + * The demo switch is a server action, so anyone can call it directly: it must do nothing for a + * caller who isn't signed in to an organization, and turn the demo on only while its flag is. + */ + +const state = vi.hoisted(() => ({ + jar: new Map(), + auth: { userId: "user_1" as string | null, orgId: "org_1" as string | null }, + demoFlag: true, +})); + +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: (name: string) => + state.jar.has(name) ? { name, value: state.jar.get(name) ?? "" } : undefined, + getAll: () => [...state.jar].map(([name, value]) => ({ name, value })), + set: (name: string, value: string) => { + state.jar.set(name, value); + }, + delete: (name: string) => { + state.jar.delete(name); + }, + }), +})); +vi.mock("../sdp-api", () => ({ getSdpAuth: async () => state.auth })); +vi.mock("@/flags", () => ({ paymentsDemoMode: async () => state.demoFlag })); + +const { setPaymentsDemoAction } = await import("./demo-mode-action"); +const { PROJECT_COOKIE_NAME } = await import("../project-cookie"); + +beforeEach(() => { + state.jar.clear(); + state.auth = { userId: "user_1", orgId: "org_1" }; + state.demoFlag = true; +}); + +describe("setPaymentsDemoAction", () => { + it("names the selected project and forgets the session so far", async () => { + state.jar.set("sdp-demo-session.0", "abc"); + state.jar.set("sdp-demo-session.1", "def"); + state.jar.set("other", "kept"); + + expect(await setPaymentsDemoAction(true, "proj_sandbox")).toBe(true); + + expect(Object.fromEntries(state.jar)).toEqual({ + other: "kept", + "sdp-payments-demo": "proj_sandbox", + }); + }); + + it("falls back to the project cookie when the project list didn't load", async () => { + state.jar.set(PROJECT_COOKIE_NAME, "proj_cookie"); + + expect(await setPaymentsDemoAction(true, null)).toBe(true); + expect(state.jar.get("sdp-payments-demo")).toBe("proj_cookie"); + }); + + it("refuses a missing project or one that isn't cookie-safe", async () => { + expect(await setPaymentsDemoAction(true, null)).toBe(false); + expect(await setPaymentsDemoAction(true, "proj; Path=/")).toBe(false); + expect(state.jar.has("sdp-payments-demo")).toBe(false); + }); + + it("turns the demo off and forgets its session", async () => { + state.jar.set("sdp-payments-demo", "proj_sandbox"); + state.jar.set("sdp-demo-session.0", "abc"); + + expect(await setPaymentsDemoAction(false, "proj_sandbox")).toBe(true); + expect(state.jar.size).toBe(0); + }); + + it("does nothing for a caller who isn't signed in to an organization", async () => { + state.jar.set("sdp-payments-demo", "proj_sandbox"); + state.jar.set("sdp-demo-session.0", "abc"); + + state.auth = { userId: null, orgId: null }; + expect(await setPaymentsDemoAction(false, "proj_sandbox")).toBe(false); + state.auth = { userId: "user_1", orgId: null }; + expect(await setPaymentsDemoAction(true, "proj_other")).toBe(false); + + expect(Object.fromEntries(state.jar)).toEqual({ + "sdp-payments-demo": "proj_sandbox", + "sdp-demo-session.0": "abc", + }); + }); + + it("won't turn the demo on with its flag off, but still turns it off", async () => { + state.demoFlag = false; + + expect(await setPaymentsDemoAction(true, "proj_sandbox")).toBe(false); + expect(state.jar.has("sdp-payments-demo")).toBe(false); + + state.jar.set("sdp-payments-demo", "proj_sandbox"); + expect(await setPaymentsDemoAction(false, "proj_sandbox")).toBe(true); + expect(state.jar.has("sdp-payments-demo")).toBe(false); + }); +}); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-ramp-assets.ts b/apps/sdp-web/src/lib/payments-demo/demo-ramp-assets.ts new file mode 100644 index 0000000000..42617dfe15 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-ramp-assets.ts @@ -0,0 +1,27 @@ +/* + * The crypto a demo ramp can deliver or take: only what the demo wallets hold. A pair for any + * other asset (PYUSD, USDT, USDG) is left out of the demo's pickers and refused by its quotes, + * so a demo never shows one asset chosen and another credited. Kept apart from the fixtures so + * the ramp screens can read it; demo-fixtures' DEMO_TOKENS must hold every one of these. + */ + +/** The asset a rail names, as the rail spells it ("usdc.solana" → "usdc"). */ +export function railAsset(assetRail: string): string { + return assetRail.split(".", 1)[0]?.toLowerCase() ?? ""; +} + +/** The assets the demo wallets hold, as rails spell them. */ +export const DEMO_RAMP_ASSETS: ReadonlySet = new Set(["usdc", "sol", "eurc"]); + +/** Whether a demo ramp can run on this asset rail. */ +export function isDemoRampRail(assetRail: string): boolean { + return DEMO_RAMP_ASSETS.has(railAsset(assetRail)); +} + +/** The pairs a ramp screen offers: in demo mode, only those for an asset the demo holds. */ +export function demoRampPairs( + pairs: readonly T[], + demo: boolean +): T[] { + return pairs.filter((pair) => !demo || isDemoRampRail(pair.assetRail)); +} diff --git a/apps/sdp-web/src/lib/payments-demo/demo-replay.ts b/apps/sdp-web/src/lib/payments-demo/demo-replay.ts index b943be8b70..22b74461f5 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-replay.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-replay.ts @@ -26,6 +26,7 @@ import { tokenBalance, } from "./demo-fixtures"; import type { DemoOp, DemoOpOf } from "./demo-ops"; +import { isDemoRampRail, railAsset } from "./demo-ramp-assets"; /* * The session's actions applied to the fixture world, oldest first. Each one changes the world @@ -49,13 +50,16 @@ export function tokenKeyForMint(mint: string): DemoTokenKey | undefined { return (Object.keys(DEMO_TOKENS) as DemoTokenKey[]).find((key) => DEMO_TOKENS[key].mint === mint); } -/** The mint a ramp's asset rail delivers ("usdc.solana" → USDC), USDC when the demo lacks it. */ -export function mintForRail(rail: string): string { - const symbol = rail.split(".", 1)[0]?.toUpperCase(); - const key = (Object.keys(DEMO_TOKENS) as DemoTokenKey[]).find( - (candidate) => DEMO_TOKENS[candidate].symbol === symbol +/** + * The demo token a ramp's asset rail delivers ("usdc.solana" → USDC), or undefined for an asset + * the demo wallets don't hold: such a ramp is refused, never run in USDC instead. + */ +export function tokenKeyForRail(rail: string): DemoTokenKey | undefined { + if (!isDemoRampRail(rail)) return undefined; + const asset = railAsset(rail); + return (Object.keys(DEMO_TOKENS) as DemoTokenKey[]).find( + (candidate) => DEMO_TOKENS[candidate].symbol.toLowerCase() === asset ); - return DEMO_TOKENS[key ?? "USDC"].mint; } export function contactById(world: DemoWorld, id: string | null | undefined) { @@ -297,7 +301,8 @@ export function consentKey(provider: RampProviderId, counterpartyId: string): st function applyRamp(world: DemoWorld, op: DemoOpOf<"ramp">): void { const wallet = findWallet(world, op.wallet); - if (!wallet) return; + const tokenKey = tokenKeyForRail(op.rail); + if (!wallet || !tokenKey) return; const onramp = op.dir === "onramp"; const depositAddress = rampDepositAddress(op.id); const { deliveryMode } = DEMO_RAMP_PROVIDERS[op.provider]; @@ -310,7 +315,7 @@ function applyRamp(world: DemoWorld, op: DemoOpOf<"ramp">): void { signature: null, source: onramp ? demoAddress(`${op.provider}:${op.id}`) : wallet.publicKey, destination: onramp ? wallet.publicKey : depositAddress, - token: mintForRail(op.rail), + token: DEMO_TOKENS[tokenKey].mint, amount: op.crypto, provider: op.provider, providerReference: rampReference(op.provider, op.quote), diff --git a/apps/sdp-web/src/lib/payments-demo/demo-session.ts b/apps/sdp-web/src/lib/payments-demo/demo-session.ts index b39bd2c7e9..118c82a14d 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-session.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-session.ts @@ -15,6 +15,11 @@ const CHUNK_LENGTH = 3600; /** At most this many chunks, so the request headers stay small next to the session's own. */ const MAX_CHUNKS = 3; const MAX_ENCODED_LENGTH = CHUNK_LENGTH * MAX_CHUNKS; +/** + * The log's JSON is never longer than this, written or read. A cookie is the browser's to send, + * so a small, highly compressible one must not inflate into megabytes on every demo read. + */ +export const MAX_DECODED_LENGTH = 64 * 1024; type CookieStore = Awaited>; @@ -28,11 +33,16 @@ function chunkName(index: number): string { /** The log as the cookies carry it: deflated JSON, base64url, split into chunks. */ export function encodeDemoOps(ops: readonly DemoOp[]): string[] { let kept = [...ops]; - let encoded = deflateRawSync(JSON.stringify(kept)).toString("base64url"); - // Past the budget the oldest actions go first; the replay tolerates what they referred to. - while (encoded.length > MAX_ENCODED_LENGTH && kept.length > 0) { + let json = JSON.stringify(kept); + let encoded = deflateRawSync(json).toString("base64url"); + // Past either budget the oldest actions go first; the replay tolerates what they referred to. + while ( + (encoded.length > MAX_ENCODED_LENGTH || json.length > MAX_DECODED_LENGTH) && + kept.length > 0 + ) { kept = kept.slice(1); - encoded = deflateRawSync(JSON.stringify(kept)).toString("base64url"); + json = JSON.stringify(kept); + encoded = deflateRawSync(json).toString("base64url"); } if (kept.length === 0) return []; const chunks: string[] = []; @@ -42,11 +52,19 @@ export function encodeDemoOps(ops: readonly DemoOp[]): string[] { return chunks; } -/** The log back from its chunks; anything unreadable reads as an empty session. */ +/** + * The log back from its chunks; anything unreadable, or more than the budgets allow, reads as + * an empty session. + */ export function decodeDemoOps(chunks: readonly string[]): DemoOp[] { if (chunks.length === 0) return []; + const encoded = chunks.join(""); + if (encoded.length > MAX_ENCODED_LENGTH) return []; try { - const json = inflateRawSync(Buffer.from(chunks.join(""), "base64url")).toString("utf8"); + // Inflating stops at the budget (it throws past it), so the work is bounded by it too. + const json = inflateRawSync(Buffer.from(encoded, "base64url"), { + maxOutputLength: MAX_DECODED_LENGTH, + }).toString("utf8"); return parseDemoOps(JSON.parse(json)); } catch { return []; From ab8dc383a03b65d9dbd3d9f13334d317ab039735 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Thu, 1 Oct 2026 10:55:12 +0300 Subject: [PATCH 4/6] test(payments): cover the demo handlers' refusals and session budgets The demo modules this PR adds pulled sdp-web's global branch coverage to 61.53%, under the 62% gate that failed "Unit Tests (packages)". The handler tests walk every refusal the SDP API would send, each provider's quote shape and BVNK's onboarding; the session tests pin the decode budgets. Trimming an oversized log now cuts in proportion instead of deflating once per dropped action. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../payments-demo/demo-handlers.unit.test.ts | 703 ++++++++++++++++++ .../src/lib/payments-demo/demo-session.ts | 16 +- .../payments-demo/demo-session.unit.test.ts | 46 ++ 3 files changed, 758 insertions(+), 7 deletions(-) create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-handlers.unit.test.ts create mode 100644 apps/sdp-web/src/lib/payments-demo/demo-session.unit.test.ts diff --git a/apps/sdp-web/src/lib/payments-demo/demo-handlers.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-handlers.unit.test.ts new file mode 100644 index 0000000000..f04fc6b25e --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-handlers.unit.test.ts @@ -0,0 +1,703 @@ +import { OFFRAMP_SUPPORT, ONRAMP_SUPPORT, RAMP_PROVIDERS, type RampProviderId } from "@sdp/types"; +import { beforeEach, describe, expect, it } from "vitest"; +import { buildWorld, DEMO_TOKENS, demoPathParts } from "./demo-fixtures"; +import { demoFlowRead, demoWrite } from "./demo-handlers"; +import type { DemoOp } from "./demo-ops"; +import { DEMO_RAMP_ASSETS, demoRampPairs, isDemoRampRail } from "./demo-ramp-assets"; +import { applyDemoOps, tokenKeyForRail } from "./demo-replay"; + +/* + * The demo's write handlers on their own: each refuses what the SDP API would refuse, with the + * status it would send, and records nothing when it does. The session is a plain log here, + * carried from write to write as the browser's cookies would carry it. + */ + +const NOW = new Date("2026-09-28T12:00:00.000Z"); +const USDC = DEMO_TOKENS.USDC.mint; +const ADDRESS = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; +const TREASURY = "demo_cwlt_treasury"; + +let ops: DemoOp[] = []; +let now = NOW; + +function world() { + return applyDemoOps(buildWorld(now), ops, now); +} + +function write(method: string, path: string, body: unknown = {}) { + const parts = demoPathParts(path); + if (!parts) throw new Error(`not a demo path: ${path}`); + const result = demoWrite(method, { segments: parts.segments, body, world: world(), ops, now }); + if (!result) return undefined; + ops = [...ops, ...result.ops]; + // biome-ignore lint/suspicious/noExplicitAny: test reads loosely shaped API envelopes. + return result.answer(world()) as { status: number; body: any }; +} + +function read(path: string) { + const parts = demoPathParts(path); + if (!parts) throw new Error(`not a demo path: ${path}`); + // biome-ignore lint/suspicious/noExplicitAny: test reads loosely shaped API envelopes. + return demoFlowRead(parts.segments, parts.params, world(), now) as { status: number; body: any }; +} + +function refusal(answer: ReturnType) { + return [answer?.status, answer?.body?.error?.code]; +} + +beforeEach(() => { + ops = []; + now = NOW; +}); + +describe("routing", () => { + it("has no stand-in for a write it doesn't know", () => { + expect(write("POST", "/v1/payments/unknown-thing")).toBeUndefined(); + expect(write("PUT", "/v1/counterparties")).toBeUndefined(); + expect(read("/v1/payments/unknown-thing")).toBeUndefined(); + }); + + it("answers a ramp event with nothing to record", () => { + expect(write("POST", "/v1/payments/ramps/onramp/events")?.status).toBe(204); + expect(ops).toEqual([]); + }); + + it("answers wallet approvals with none waiting", () => { + expect(read("/v1/wallets/approval-requests").body.data.approvalRequests).toEqual([]); + }); +}); + +describe("contacts", () => { + it("names the field a body gets wrong, or the request when it isn't an object", () => { + expect( + write("POST", "/v1/counterparties", { entityType: "robot" })?.body.error.message + ).toMatch(/^entityType: /); + expect(write("POST", "/v1/counterparties", null)?.status).toBe(400); + expect(ops).toEqual([]); + }); + + it("refuses an unknown contact, a bad address and a duplicate one", () => { + expect(refusal(write("DELETE", "/v1/counterparties/demo_cpty_nobody"))).toEqual([ + 404, + "not_found", + ]); + const account = { accountKind: "crypto_wallet", details: { address: ADDRESS } }; + expect(write("POST", "/v1/counterparties/demo_cpty_nobody/accounts", account)?.status).toBe( + 404 + ); + expect( + write("POST", "/v1/counterparties/demo_cpty_jane/accounts", { accountKind: "bank" })?.status + ).toBe(400); + expect(write("POST", "/v1/counterparties/demo_cpty_jane/accounts", account)?.status).toBe(201); + expect(refusal(write("POST", "/v1/counterparties/demo_cpty_jane/accounts", account))).toEqual([ + 409, + "conflict", + ]); + }); + + it("creates a contact without an external ID, and screens only a valid address", () => { + const created = write("POST", "/v1/counterparties", { + entityType: "individual", + displayName: "Rae", + externalId: "", + }); + expect(created?.body.data.counterparty.externalId).toBeNull(); + expect(write("POST", "/v1/compliance/address-screenings", { address: "nope" })?.status).toBe( + 400 + ); + }); +}); + +describe("ramp requirements", () => { + it("refuses an unknown contact, and an unknown provider", () => { + expect(read("/v1/counterparties/demo_cpty_nobody/requirements").status).toBe(404); + expect(read("/v1/counterparties/demo_cpty_jane/requirements?provider=acme").body.data).toEqual( + expect.objectContaining({ status: "unsupported", direction: "onramp" }) + ); + const advance = (body: unknown, id = "demo_cpty_jane") => + write("POST", `/v1/counterparties/${id}/requirements`, body); + expect(advance({ provider: "lightspark", direction: "onramp" }, "demo_cpty_x")?.status).toBe( + 404 + ); + expect(advance({ provider: "lightspark" })?.status).toBe(400); + expect(advance({ provider: "acme", direction: "onramp" })?.status).toBe(400); + }); + + it("is ready at once with providers that run no onboarding", () => { + for (const provider of ["mural", "moonpay", "coinbase", "moneygram", "stripe"]) { + expect( + read(`/v1/counterparties/demo_cpty_jane/requirements?provider=${provider}`).body.data.status + ).toBe("ready"); + expect( + write("POST", "/v1/counterparties/demo_cpty_jane/requirements", { + provider, + direction: "offramp", + })?.body.data.status + ).toBe("ready"); + } + expect( + write("POST", "/v1/counterparties/demo_cpty_jane/requirements", { + provider: "lightspark", + direction: "onramp", + })?.body.data.status + ).toBe("ready"); + }); + + it("walks BVNK's onboarding: agreements, identity check, review, account, ready", () => { + const path = "/v1/counterparties/demo_cpty_kai/requirements?provider=bvnk&direction=offramp"; + const advance = (body: Record = {}) => + write("POST", "/v1/counterparties/demo_cpty_kai/requirements", { + provider: "bvnk", + direction: "offramp", + ...body, + }); + const simulate = () => + write("POST", "/v1/payments/ramps/sandbox/simulate", { + provider: "bvnk", + payload: { counterpartyId: "demo_cpty_kai", verification: "approved" }, + }); + + expect(read(path).body.data.status).toBe("counterparty_collect_agreement"); + expect(refusal(simulate())).toEqual([409, "conflict"]); + // Without consent the agreements are asked for again, and nothing is recorded. + expect(advance()?.body.data.status).toBe("counterparty_collect_agreement"); + expect(ops).toEqual([]); + expect(advance({ agreementConsent: true })?.body.data.status).toBe( + "customer_verification_required" + ); + // Accepted already: the standing is answered as it is. + expect(advance({ agreementConsent: true })?.body.data.status).toBe( + "customer_verification_required" + ); + expect(simulate()?.status).toBe(200); + expect(refusal(simulate())).toEqual([409, "conflict"]); + expect(read(path).body.data.status).toBe("customer_verifying"); + now = new Date(NOW.getTime() + 9_000); + expect(read(path).body.data.status).toBe("customer_funding_account_provisioning"); + now = new Date(NOW.getTime() + 14_000); + expect(read(path).body.data.status).toBe("ready"); + }); + + it("refuses Simulate verification for another provider or an unknown contact", () => { + const simulate = (provider: string, counterpartyId?: string) => + write("POST", "/v1/payments/ramps/sandbox/simulate", { + provider, + payload: { counterpartyId, verification: "approved" }, + }); + expect(simulate("lightspark", "demo_cpty_kai")?.status).toBe(400); + expect(simulate("bvnk")?.status).toBe(404); + expect(simulate("bvnk", "demo_cpty_nobody")?.status).toBe(404); + }); + + it("asks a Lightspark payout which bank account to pay, in the payout's currency", () => { + const tree = (fiat?: string) => + read( + `/v1/counterparties/demo_cpty_acme/requirements?provider=lightspark&direction=offramp${ + fiat ? `&fiatCurrency=${fiat}` : "" + }` + ).body.data; + expect(tree("EUR").status).toBe("collect_account"); + expect(Object.keys(tree("EUR").payout.countryRails)).toEqual(["DE", "FR", "IE"]); + expect(tree("EUR").payout.accounts.map((account: { id: string }) => account.id)).toEqual([ + "demo_cppa_acme_0", + ]); + expect(Object.keys(tree("GBP").payout.countryRails)).toEqual(["GB"]); + // A currency without corridors of its own falls back to US banks. + expect(Object.keys(tree("JPY").payout.countryRails)).toEqual(["US"]); + expect(tree().payout.accounts).toEqual([]); + }); + + it("pays a saved bank account, or saves the one collected", () => { + const advance = (body: Record) => + write("POST", "/v1/counterparties/demo_cpty_acme/requirements", { + provider: "lightspark", + direction: "offramp", + fiatCurrency: "EUR", + ...body, + }); + expect(advance({ providerAccountId: "demo_cppa_acme_0" })?.body.data.providerAccountId).toBe( + "demo_cppa_acme_0" + ); + expect(refusal(advance({ providerAccountId: "demo_cppa_jane_0" }))).toEqual([404, "not_found"]); + expect(advance({ collectedData: {} })?.status).toBe(400); + expect(advance({ collectedData: { destinationCountry: "US" } })?.status).toBe(400); + + const saved = advance({ + collectedData: { + destinationCountry: "DE", + bankName: " Deutsche Demo ", + iban: "DE89370400440532013000", + }, + }); + const id: string = saved?.body.data.providerAccountId; + expect(id).toMatch(/^demo_new_cppa_/); + const added = world().providerAccounts.find((entry) => entry.account.id === id)?.account; + expect(added).toEqual( + expect.objectContaining({ + paymentRail: "SEPA", + bankName: "Deutsche Demo", + accountNumberLast4: "3000", + }) + ); + + const bare = advance({ collectedData: { destinationCountry: "FR", paymentRails: "SEPA" } }); + const bareAccount = world().providerAccounts.find( + (entry) => entry.account.id === bare?.body.data.providerAccountId + )?.account; + expect(bareAccount?.bankName).toBeUndefined(); + expect(bareAccount?.accountNumberLast4).toBeUndefined(); + }); +}); + +/** A pair each provider runs on an asset the demo holds, from the support tables. */ +function supportedPair(direction: "onramp" | "offramp", provider: RampProviderId) { + const row = + direction === "onramp" + ? ONRAMP_SUPPORT.map(({ source, dest, providers }) => ({ + fiat: source, + rail: dest, + providers, + })) + : OFFRAMP_SUPPORT.map(({ source, dest, providers }) => ({ + fiat: dest, + rail: source, + providers, + })); + return row.find( + (entry) => + (entry.providers as readonly string[]).includes(provider) && isDemoRampRail(entry.rail) + ); +} + +function onboardBvnk(counterpartyId: string) { + ops.push( + { k: "consent", id: counterpartyId, at: NOW.getTime() - 60_000, provider: "bvnk" }, + { k: "verified", id: counterpartyId, at: NOW.getTime() - 60_000, provider: "bvnk" } + ); +} + +describe("ramp assets", () => { + it("holds every asset a demo ramp offers, and no other", () => { + expect([...DEMO_RAMP_ASSETS].sort()).toEqual( + Object.values(DEMO_TOKENS) + .map((token) => token.symbol.toLowerCase()) + .sort() + ); + for (const { dest } of ONRAMP_SUPPORT) { + expect(isDemoRampRail(dest)).toBe(tokenKeyForRail(dest) !== undefined); + } + expect(tokenKeyForRail("pyusd.solana")).toBeUndefined(); + expect(tokenKeyForRail("sol.solana")).toBe("SOL"); + }); + + it("leaves pairs for other assets out of the pickers in demo mode only", () => { + const pairs = [{ assetRail: "usdc.solana" }, { assetRail: "usdt.solana" }]; + expect(demoRampPairs(pairs, true)).toEqual([{ assetRail: "usdc.solana" }]); + expect(demoRampPairs(pairs, false)).toEqual(pairs); + }); + + it("estimates and quotes nothing for an asset the demo wallets don't hold", () => { + const estimate = write("POST", "/v1/payments/ramps/onramp/estimate", { + assetRail: "pyusd.solana", + fiatCurrency: "USD", + fiatAmount: "100", + }); + expect(estimate?.body.data.estimates).toEqual([]); + const onramp = write("POST", "/v1/payments/ramps/onramp/quote", { + provider: "moonpay", + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: TREASURY, + assetRail: "pyusd.solana", + fiatCurrency: "USD", + fiatAmount: "100", + }); + expect(onramp?.body.error.message).toMatch(/^The demo wallets don't hold PYUSD/); + const offramp = write("POST", "/v1/payments/ramps/offramp/quote", { + provider: "moonpay", + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: TREASURY, + assetRail: "usdt.solana", + fiatCurrency: "USD", + cryptoAmount: "10", + }); + expect(offramp?.status).toBe(400); + expect(ops).toEqual([]); + }); +}); + +describe("ramp estimates and quotes", () => { + it("estimates an off-ramp from its crypto amount, and a blank amount as zero", () => { + const offramp = write("POST", "/v1/payments/ramps/offramp/estimate", { + assetRail: "usdc.solana", + fiatCurrency: "USD", + cryptoAmount: "100", + }); + expect(offramp?.body.data.estimates.length).toBeGreaterThan(0); + expect(offramp?.body.data.estimates[0].estimate.cryptoAmount).toBe("100.00"); + const blank = write("POST", "/v1/payments/ramps/onramp/estimate", { + assetRail: "sol.solana", + fiatCurrency: "AUD", + fiatAmount: "lots", + }); + for (const result of blank?.body.data.estimates ?? []) { + expect(result.estimate.fiatAmount).toBe("0.00"); + expect(result.estimate.cryptoAmount).toBe("0.0000"); + } + expect(write("POST", "/v1/payments/ramps/onramp/estimate", {})?.status).toBe(400); + }); + + it("refuses an on-ramp quote the API would", () => { + const quote = (body: Record) => + write("POST", "/v1/payments/ramps/onramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: TREASURY, + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "100", + ...body, + }); + expect(quote({ provider: "acme" })?.status).toBe(400); + expect(write("POST", "/v1/payments/ramps/onramp/quote", null)?.status).toBe(400); + expect(quote({ fiatAmount: "0" })?.status).toBe(400); + expect(quote({ counterpartyId: "demo_cpty_nobody" })?.status).toBe(404); + expect(refusal(quote({ provider: "bvnk", counterpartyId: "demo_cpty_priya" }))).toEqual([ + 409, + "conflict", + ]); + expect(quote({ destinationCustodyWalletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect(quote({ fiatCurrency: "JPY" })?.body.error.message).toMatch(/doesn't run JPY to USDC/); + expect(ops).toEqual([]); + }); + + it("quotes an on-ramp in each provider's own shape", () => { + onboardBvnk("demo_cpty_jane"); + const modes: Record = {}; + for (const provider of RAMP_PROVIDERS) { + const pair = supportedPair("onramp", provider); + if (!pair) continue; + const quoted = write("POST", "/v1/payments/ramps/onramp/quote", { + provider, + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: TREASURY, + assetRail: pair.rail, + fiatCurrency: pair.fiat, + fiatAmount: "200", + }); + expect(quoted?.status, provider).toBe(201); + modes[provider] = quoted?.body.data.quote.deliveryMode; + } + expect(modes.lightspark).toBe("manual_instructions"); + expect(Object.keys(modes).length).toBeGreaterThan(3); + }); + + it("draws bank instructions for the deposit's currency", () => { + onboardBvnk("demo_cpty_jane"); + const instructions = (provider: string, fiatCurrency: string) => { + const quoted = write("POST", "/v1/payments/ramps/onramp/quote", { + provider, + counterpartyId: "demo_cpty_jane", + destinationCustodyWalletId: TREASURY, + assetRail: "usdc.solana", + fiatCurrency, + fiatAmount: "100", + }); + return quoted?.status === 201 ? quoted.body.data.quote.paymentInstructions[0] : null; + }; + for (const fiat of ["USD", "EUR", "GBP", "MXN", "ARS"]) { + for (const provider of ["lightspark", "bvnk", "mural"]) { + const instruction = instructions(provider, fiat); + if (instruction) expect(instruction.provider).toBe(provider); + } + } + }); + + it("refuses an off-ramp quote the API would", () => { + const quote = (body: Record) => + write("POST", "/v1/payments/ramps/offramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: "demo_cwlt_settlement", + assetRail: "usdc.solana", + fiatCurrency: "USD", + cryptoAmount: "100", + ...body, + }); + expect(quote({ cryptoAmount: "-1" })?.status).toBe(400); + expect(quote({ counterpartyId: "demo_cpty_nobody" })?.status).toBe(404); + expect(quote({ provider: "bvnk", counterpartyId: "demo_cpty_priya" })?.status).toBe(409); + expect(quote({ sourceCustodyWalletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect(quote({ fiatCurrency: "JPY" })?.body.error.message).toMatch(/doesn't run USDC to JPY/); + expect(refusal(quote({ cryptoAmount: "999999" }))).toEqual([400, "insufficient_funds"]); + expect(ops).toEqual([]); + }); + + it("quotes an off-ramp in each provider's own shape", () => { + onboardBvnk("demo_cpty_jane"); + for (const provider of RAMP_PROVIDERS) { + const pair = supportedPair("offramp", provider); + if (!pair) continue; + const quoted = write("POST", "/v1/payments/ramps/offramp/quote", { + provider, + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: TREASURY, + assetRail: pair.rail, + fiatCurrency: pair.fiat, + cryptoAmount: "1", + }); + expect(quoted?.status, provider).toBe(201); + expect(quoted?.body.data.transferId).toMatch(/^demo_new_xfr_/); + } + }); +}); + +describe("ramp pay-ins and cancels", () => { + function deposit(counterpartyId = "demo_cpty_jane") { + return write("POST", "/v1/payments/ramps/onramp/quote", { + provider: "lightspark", + counterpartyId, + destinationCustodyWalletId: TREASURY, + assetRail: "usdc.solana", + fiatCurrency: "USD", + fiatAmount: "100", + })?.body.data as { transferId: string; quote: { id: string } }; + } + const simulate = (payload: Record) => + write("POST", "/v1/payments/ramps/sandbox/simulate", { provider: "mural", payload }); + + it("pays the deposit the contact has waiting, once", () => { + expect(write("POST", "/v1/payments/ramps/sandbox/simulate", {})?.status).toBe(400); + expect(refusal(simulate({ counterpartyId: "demo_cpty_jane" }))).toEqual([404, "not_found"]); + deposit(); + expect(simulate({ counterpartyId: "demo_cpty_jane", amount: 100 })?.status).toBe(200); + // That one is paid; nothing else waits for the contact. + expect(simulate({ counterpartyId: "demo_cpty_jane" })?.status).toBe(404); + }); + + it("refuses to pay a deposit twice by its transfer", () => { + const { transferId } = deposit(); + expect(simulate({ transferId })?.status).toBe(200); + expect(refusal(simulate({ transferId }))).toEqual([409, "conflict"]); + }); + + it("cancels only a deposit still waiting for its money", () => { + expect(write("POST", "/v1/payments/ramps/transfers/cancel", {})?.status).toBe(400); + expect( + write("POST", "/v1/payments/ramps/transfers/cancel", { transferId: "demo_xfr_nobody" }) + ?.status + ).toBe(404); + expect( + refusal( + write("POST", "/v1/payments/ramps/transfers/cancel", { + transferId: "demo_xfr_offramp_orbit", + }) + ) + ).toEqual([409, "conflict"]); + const { transferId } = deposit(); + const canceled = write("POST", "/v1/payments/ramps/transfers/cancel", { transferId }); + expect(canceled?.body.data.transfer.status).toBe("canceled"); + }); +}); + +describe("transfers and batches", () => { + const send = (body: Record) => + write("POST", "/v1/payments/transfers", { + sourceCustodyWalletId: TREASURY, + destination: ADDRESS, + token: USDC, + amount: "5", + ...body, + }); + + it("refuses a transfer the API would", () => { + expect(send({ destination: "nope" })?.status).toBe(400); + expect(send({ sourceCustodyWalletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect(send({ destination: "9brFR8oxX8nHVrU3LiEGV1LZWyUPcKtjaHdvWAqU6sP8" })?.status).toBe(400); + expect(send({ token: "MintThatTheDemoDoesNotHold111111111111111" })?.body.error.message).toBe( + "The demo wallets don't hold that token." + ); + expect(ops).toEqual([]); + }); + + it("keeps a demo transfer id, and answers a resend with the first transfer", () => { + const first = send({ transferId: "demo_new_xfr_resend", memo: "rent" }); + expect(first?.body.data.transfer.id).toBe("demo_new_xfr_resend"); + const again = send({ transferId: "demo_new_xfr_resend" }); + expect(again?.status).toBe(200); + expect(ops).toHaveLength(1); + const fresh = send({ transferId: "client_supplied" }); + expect(fresh?.body.data.transfer.id).toMatch(/^demo_new_xfr_/); + }); + + it("funds an off-ramp's payout once, from a wallet that can cover it", () => { + const quoted = write("POST", "/v1/payments/ramps/offramp/quote", { + provider: "lightspark", + counterpartyId: "demo_cpty_jane", + sourceCustodyWalletId: "demo_cwlt_settlement", + assetRail: "usdc.solana", + fiatCurrency: "USD", + cryptoAmount: "100", + }); + const transferId: string = quoted?.body.data.transferId; + expect( + send({ transferId, sourceCustodyWalletId: "demo_cwlt_settlement", amount: "999999" })?.status + ).toBe(400); + expect( + send({ transferId, sourceCustodyWalletId: "demo_cwlt_settlement", amount: "100" })?.status + ).toBe(201); + expect( + refusal(send({ transferId, sourceCustodyWalletId: "demo_cwlt_settlement", amount: "100" })) + ).toEqual([409, "conflict"]); + }); + + it("refuses a batch the API would", () => { + const batch = (body: Record) => + write("POST", "/v1/payments/transfer-batches", { + sourceCustodyWalletId: "demo_cwlt_payroll", + token: USDC, + recipients: [ + { counterpartyId: "demo_cpty_kai", counterpartyAccountId: "demo_cpa_kai", amount: "1" }, + ], + ...body, + }); + expect(batch({ recipients: [] })?.status).toBe(400); + expect(write("POST", "/v1/payments/transfer-batches/estimate", {})?.status).toBe(400); + expect(batch({ sourceCustodyWalletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect( + batch({ + recipients: [ + { counterpartyId: "demo_cpty_kai", counterpartyAccountId: "demo_cpa_jane", amount: "1" }, + ], + })?.status + ).toBe(400); + expect( + refusal( + batch({ + recipients: [ + { + counterpartyId: "demo_cpty_kai", + counterpartyAccountId: "demo_cpa_kai", + amount: "9999999", + }, + ], + }) + ) + ).toEqual([400, "insufficient_funds"]); + expect( + write("POST", "/v1/payments/transfer-batches/estimate", { + sourceCustodyWalletId: "demo_cwlt_nobody", + token: USDC, + recipients: [ + { counterpartyId: "demo_cpty_kai", counterpartyAccountId: "demo_cpa_kai", amount: "1" }, + ], + })?.status + ).toBe(404); + expect(batch({ externalId: "RUN-1" })?.status).toBe(201); + }); +}); + +describe("requests", () => { + const request = (body: Record) => + write("POST", "/v1/payments/requests", { + walletId: TREASURY, + token: USDC, + amount: "40", + ...body, + }); + + it("refuses a request the API would", () => { + expect(request({ amount: "" })?.status).toBe(400); + expect(request({ walletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect(request({ counterpartyId: "demo_cpty_nobody" })?.status).toBe(404); + expect(request({ expiresAt: "2026-09-01T00:00:00.000Z" })?.status).toBe(400); + expect(request({ expiresAt: "not a date" })?.status).toBe(400); + expect(ops).toEqual([]); + }); + + it("creates one for a contact, with an expiry", () => { + const created = request({ + counterpartyId: "demo_cpty_acme", + expiresAt: "2026-10-28T00:00:00.000Z", + }); + expect(created?.status).toBe(201); + expect(created?.body.data).toEqual( + expect.objectContaining({ + counterpartyId: "demo_cpty_acme", + expiresAt: "2026-10-28T00:00:00.000Z", + }) + ); + }); +}); + +describe("schedules", () => { + const create = (body: Record) => + write("POST", "/v1/payments/recurring-payments", { + sourceCustodyWalletId: TREASURY, + counterpartyId: "demo_cpty_jane", + counterpartyAccountId: "demo_cpa_jane", + token: USDC, + amount: "10", + periodHours: 24, + ...body, + }); + const update = (id: string, body: Record) => + write("PATCH", `/v1/payments/recurring-payments/${id}`, body); + const act = (id: string, action: string) => + write("POST", `/v1/payments/recurring-payments/${id}/${action}`); + + it("refuses a schedule the API would", () => { + expect(create({ periodHours: 0 })?.status).toBe(400); + expect(create({ sourceCustodyWalletId: "demo_cwlt_nobody" })?.status).toBe(404); + expect(create({ counterpartyAccountId: "demo_cpa_kai" })?.status).toBe(400); + expect(create({ counterpartyAccountId: "demo_cpa_nobody" })?.status).toBe(400); + expect(create({ firstCollectionAt: "2026-09-01T00:00:00.000Z" })?.status).toBe(400); + expect(create({ firstCollectionAt: "2026-10-01T00:00:00.000Z" })?.status).toBe(201); + }); + + it("refuses a change the API would, and applies every field it accepts", () => { + expect(update("demo_rp_nobody", {})?.status).toBe(404); + expect(refusal(update("demo_rp_jane_stipend", {}))).toEqual([409, "conflict"]); + expect(update("demo_rp_lumen_retainer", { periodHours: 99999 })?.status).toBe(400); + expect( + update("demo_rp_lumen_retainer", { sourceCustodyWalletId: "demo_cwlt_nobody" })?.status + ).toBe(404); + expect( + update("demo_rp_lumen_retainer", { counterpartyAccountId: "demo_cpa_kai" })?.status + ).toBe(400); + expect(ops).toEqual([]); + const changed = update("demo_rp_lumen_retainer", { + amount: "12.5", + token: USDC, + periodHours: 48, + sourceCustodyWalletId: "demo_cwlt_settlement", + counterpartyAccountId: "demo_cpa_lumen", + }); + expect(changed?.status).toBe(200); + expect(ops[0]).toEqual( + expect.objectContaining({ + k: "schedule-update", + amount: "12.5", + period: 48, + wallet: "demo_cwlt_settlement", + account: "demo_cpa_lumen", + }) + ); + update("demo_rp_lumen_retainer", {}); + expect(Object.keys(ops[1] ?? {}).sort()).toEqual(["at", "id", "k"]); + }); + + it("refuses an action the schedule's status doesn't allow", () => { + expect(act("demo_rp_nobody", "activate")?.status).toBe(404); + expect(act("demo_rp_lumen_retainer", "explode")?.status).toBe(404); + expect(act("demo_rp_lumen_retainer", "activate")?.status).toBe(409); + expect(act("demo_rp_northwind_supply", "collect")?.status).toBe(409); + expect(act("demo_rp_lumen_retainer", "collect")?.body.error.message).toBe( + "The next run isn't due yet." + ); + expect(act("demo_rp_jane_stipend", "cancel")?.status).toBe(409); + expect(act("demo_rp_lumen_retainer", "resume")?.status).toBe(409); + expect(act("demo_rp_northwind_supply", "cancel")?.status).toBe(200); + // Canceled before it ever ran, so there is nothing to resume. + expect(act("demo_rp_northwind_supply", "resume")?.status).toBe(409); + }); +}); diff --git a/apps/sdp-web/src/lib/payments-demo/demo-session.ts b/apps/sdp-web/src/lib/payments-demo/demo-session.ts index 118c82a14d..6d79491c57 100644 --- a/apps/sdp-web/src/lib/payments-demo/demo-session.ts +++ b/apps/sdp-web/src/lib/payments-demo/demo-session.ts @@ -32,17 +32,19 @@ function chunkName(index: number): string { /** The log as the cookies carry it: deflated JSON, base64url, split into chunks. */ export function encodeDemoOps(ops: readonly DemoOp[]): string[] { + // Past either budget the oldest actions go first; the replay tolerates what they referred to. let kept = [...ops]; let json = JSON.stringify(kept); + // The JSON budget is cut in proportion, so a long log isn't deflated once per action dropped. + while (json.length > MAX_DECODED_LENGTH && kept.length > 0) { + const keep = Math.floor((kept.length * MAX_DECODED_LENGTH) / json.length); + kept = kept.slice(kept.length - Math.min(keep, kept.length - 1)); + json = JSON.stringify(kept); + } let encoded = deflateRawSync(json).toString("base64url"); - // Past either budget the oldest actions go first; the replay tolerates what they referred to. - while ( - (encoded.length > MAX_ENCODED_LENGTH || json.length > MAX_DECODED_LENGTH) && - kept.length > 0 - ) { + while (encoded.length > MAX_ENCODED_LENGTH && kept.length > 0) { kept = kept.slice(1); - json = JSON.stringify(kept); - encoded = deflateRawSync(json).toString("base64url"); + encoded = deflateRawSync(JSON.stringify(kept)).toString("base64url"); } if (kept.length === 0) return []; const chunks: string[] = []; diff --git a/apps/sdp-web/src/lib/payments-demo/demo-session.unit.test.ts b/apps/sdp-web/src/lib/payments-demo/demo-session.unit.test.ts new file mode 100644 index 0000000000..4bd31059d1 --- /dev/null +++ b/apps/sdp-web/src/lib/payments-demo/demo-session.unit.test.ts @@ -0,0 +1,46 @@ +import { deflateRawSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { decodeDemoOps, encodeDemoOps, MAX_DECODED_LENGTH } from "./demo-session"; + +/* + * The session cookies are the browser's to send, so reading them must stay cheap whatever they + * hold: a small cookie that inflates into megabytes reads as an empty session. + */ + +const contact = (index: number) => ({ + k: "contact" as const, + id: `demo_new_cpty_${index}`, + at: index, + name: `Contact ${index}`, + entity: "individual" as const, + ext: null, +}); + +describe("demo session cookies", () => { + it("reads nothing from no cookies, or from ones that aren't a session", () => { + expect(decodeDemoOps([])).toEqual([]); + expect(decodeDemoOps(["not base64 deflate"])).toEqual([]); + expect(decodeDemoOps([deflateRawSync('{"k":"contact"}').toString("base64url")])).toEqual([]); + }); + + it("refuses a cookie that inflates past the budget", () => { + const bomb = deflateRawSync(`[${" ".repeat(MAX_DECODED_LENGTH * 4)}]`).toString("base64url"); + expect(bomb.length).toBeLessThan(3600); + expect(decodeDemoOps([bomb])).toEqual([]); + }); + + it("refuses more encoded text than the chunks may carry", () => { + expect(decodeDemoOps(["a".repeat(3600 * 3 + 1)])).toEqual([]); + }); + + it("keeps the newest actions when the log's JSON outgrows the budget", () => { + // Highly compressible, so only the decoded budget trims it. + const ops = Array.from({ length: 2_000 }, (_, index) => contact(index)); + const chunks = encodeDemoOps(ops); + const decoded = decodeDemoOps(chunks); + expect(decoded.length).toBeGreaterThan(0); + expect(decoded.length).toBeLessThan(ops.length); + expect(JSON.stringify(decoded).length).toBeLessThanOrEqual(MAX_DECODED_LENGTH); + expect(decoded.at(-1)).toEqual(ops.at(-1)); + }); +}); From c009d32adc04945b7738ac1a22de74b2c01f9183 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Thu, 1 Oct 2026 13:03:56 +0300 Subject: [PATCH 5/6] refactor(payments): lift the counterparty requirements hook's pure derivations into helpers The subject key, corridor identity, corridor-scoped record reads, poll key, current onboarding answer, pending agreements, consent check and block reason move out of useCounterpartyRequirements into named module functions. The hook drops below React Doctor's complexity limit; what it returns is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../hooks/use-counterparty-requirements.ts | 198 +++++++++++++----- 1 file changed, 149 insertions(+), 49 deletions(-) diff --git a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-counterparty-requirements.ts b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-counterparty-requirements.ts index f8e807b151..4d87f0cf42 100644 --- a/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-counterparty-requirements.ts +++ b/apps/sdp-web/src/app/dashboard/payments/ramps/hooks/use-counterparty-requirements.ts @@ -317,6 +317,141 @@ function payoutTreeOf(answer: CounterpartyRequirements | undefined): PayoutRequi return answer !== undefined && answer.status === "collect_account" ? answer.payout : null; } +type PendingAgreements = Extract< + CounterpartyRequirements, + { status: "counterparty_collect_agreement" } +>["agreements"]; + +/** + * Names the request corridor: every field of the request that, when it + * changes, resets the collected answers. + * + * @param params - The hook's request, or null when it is disabled. + * @returns The subject key, empty when disabled. + */ +function requirementsSubjectKey(params: CounterpartyRequirementsParams | null): string { + return params === null + ? "" + : `${params.counterpartyId}:${params.provider}:${params.direction}:${params.assetRail}:${params.fiatCurrency}:${params.destinationCustodyWalletId}`; +} + +/** + * The request subject plus the routing choice living outside the subject key — + * the collected destination country and the explicitly picked account: together + * the full submission an advance or poll response answers for. + */ +function corridorIdentityOf( + subjectKey: string, + destinationCountry: string | undefined, + selectedPayoutAccountId: string | null +): string { + return `${subjectKey}:${destinationCountry === undefined ? "" : destinationCountry}:${ + selectedPayoutAccountId === null ? "" : selectedPayoutAccountId + }`; +} + +/** + * Reads a corridor-tagged record only while it answers for the current corridor. + * + * @param record - The stored record, tagged with the corridor it answered for. + * @param corridor - The current corridor identity. + * @returns The record, or null when it belongs to another corridor. + */ +function recordForCorridor( + record: Entry | null, + corridor: string +): Entry | null { + return record !== null && record.corridor === corridor ? record : null; +} + +/** + * Keys the onboarding status poll, or disables it while there is nothing pending to poll. + * + * @param advance - The current corridor's advance, if any. + * @param params - The hook's request. + * @param corridorIdentity - The current corridor identity. + * @returns The poll key, or null. + */ +function onboardingPollKey( + advance: AdvanceRecord | null, + params: CounterpartyRequirementsParams | null, + corridorIdentity: string +) { + return advance !== null && + params?.provider && + isCounterpartyRequirementsPollStatus(advance.result.status) + ? paymentsQueryKeys.requirementsStatusPoll({ + subjectKey: `${corridorIdentity}#${advance.advanceId}`, + }) + : null; +} + +/** + * The freshest onboarding answer for the current corridor: the poll's when it + * has one, otherwise the advance response. + */ +function currentOnboarding( + advance: AdvanceRecord | null, + polled: CounterpartyRequirements | undefined +): CounterpartyRequirements | null { + return advance === null ? null : polled !== undefined ? polled : advance.result; +} + +/** + * Reads the agreements awaiting consent off a requirements answer. + * + * @param answer - Requirements answer to inspect. + * @returns The agreements, or null when the step collects fields instead. + */ +function pendingAgreementsOf( + answer: CounterpartyRequirements | undefined +): PendingAgreements | null { + return answer !== undefined && answer.status === "counterparty_collect_agreement" + ? answer.agreements + : null; +} + +/** + * Whether every pending agreement and its privacy policy has been checked. + * + * @param pending - Agreements awaiting consent, or null when there are none. + * @param acceptedAgreements - Consent keys checked so far. + * @returns False when nothing is pending. + */ +function everyAgreementAccepted( + pending: PendingAgreements | null, + acceptedAgreements: readonly string[] +): boolean { + if (pending === null) { + return false; + } + const accepted = new Set(acceptedAgreements); + return pending.every((agreement) => { + const keys = bvnkAgreementConsentKeys(agreement); + return accepted.has(keys.agreement) && accepted.has(keys.privacyPolicy); + }); +} + +/** + * Every status the provider can return is handled: "collect" → needsCollection, + * "ready" → proceed, "unsupported" → block with its reason, plus fetch errors. + * A fetch error only blocks while no usable answer exists — a failed + * post-advance refresh must not strand a wizard whose advance succeeded. + */ +function requirementsBlockReason( + error: unknown, + requirementsData: CounterpartyRequirements | undefined, + data: CounterpartyRequirements | undefined +): string | null { + if (error instanceof Error && requirementsData === undefined) { + return error.message; + } + if (data?.status === "unsupported") { + return data.reason; + } + return null; +} + export interface CounterpartyRequirementsState { /** Fields the client must collect; empty unless the provider returned `collect`. */ fields: RequirementField[]; @@ -420,10 +555,7 @@ export function useCounterpartyRequirements( // the previous value during render (React's no-effect way to reset state on a change), // so stale KYC or bank details never leak into a different corridor's payload and a // pending onboarding never survives into one. - const subjectKey = - params === null - ? "" - : `${params.counterpartyId}:${params.provider}:${params.direction}:${params.assetRail}:${params.fiatCurrency}:${params.destinationCustodyWalletId}`; + const subjectKey = requirementsSubjectKey(params); const [trackedSubject, setTrackedSubject] = useState(subjectKey); // The completed advance, tagged with the corridor it answered for. Responses // are data addressed by their corridor, never commands: a write from a @@ -435,12 +567,11 @@ export function useCounterpartyRequirements( result: CounterpartyRequirements; } | null>(null); const [isAdvancing, setIsAdvancing] = useState(false); - // The request subject plus the routing choice living outside the subject key — - // the collected destination country and the explicitly picked account: together - // the full submission an advance or poll response answers for. - const corridorIdentity = `${subjectKey}:${ - collectedData.destinationCountry === undefined ? "" : collectedData.destinationCountry - }:${selectedPayoutAccountId === null ? "" : selectedPayoutAccountId}`; + const corridorIdentity = corridorIdentityOf( + subjectKey, + collectedData.destinationCountry, + selectedPayoutAccountId + ); if (subjectKey !== trackedSubject) { setTrackedSubject(subjectKey); setCollectedData({}); @@ -454,8 +585,7 @@ export function useCounterpartyRequirements( accepted ? [...previous, key] : previous.filter((acceptedKey) => acceptedKey !== key) ); }, []); - const advance = - advanceRecord !== null && advanceRecord.corridor === corridorIdentity ? advanceRecord : null; + const advance = recordForCorridor(advanceRecord, corridorIdentity); const key = buildCounterpartyRequirementsKey(params); // Requirements never revalidate on their own — `needsCollection` (and thus the @@ -543,14 +673,7 @@ export function useCounterpartyRequirements( // https://github.com/vercel/swr/discussions/2293) and cache.delete cannot // stop an in-flight tick from repopulating a shared key, so no two advances // ever share a key. - const pollKey = - advance !== null && - params?.provider && - isCounterpartyRequirementsPollStatus(advance.result.status) - ? paymentsQueryKeys.requirementsStatusPoll({ - subjectKey: `${corridorIdentity}#${advance.advanceId}`, - }) - : null; + const pollKey = onboardingPollKey(advance, params, corridorIdentity); const { data: polledOnboarding, mutate: refreshPolledOnboarding } = useSWR( pollKey, () => { @@ -581,8 +704,7 @@ export function useCounterpartyRequirements( // The live onboarding lifecycle for the CURRENT corridor: the freshest of the // advance response and its status poll. Both sources are corridor-addressed, // so an abandoned corridor's result can never surface here. - const onboarding = - advance === null ? null : polledOnboarding !== undefined ? polledOnboarding : advance.result; + const onboarding = currentOnboarding(advance, polledOnboarding); const advanceReady = lightsparkOfframpReadyAnswer(onboarding); const resolvedProviderAccountId = advanceReady === null ? null : advanceReady.providerAccountId; @@ -595,22 +717,9 @@ export function useCounterpartyRequirements( ); // Furthest collect stage wins for stage/field selection; the payout tree // prefers the GET answer, which a post-advance refetch keeps fresh. - const collectAnswer = - collectRecord !== null && collectRecord.corridor === corridorIdentity - ? collectRecord.result - : undefined; - const requirementsData = collectAnswer !== undefined ? collectAnswer : data; - const pendingAgreements = - requirementsData !== undefined && requirementsData.status === "counterparty_collect_agreement" - ? requirementsData.agreements - : null; - const accepted = new Set(acceptedAgreements); - const allAgreementsAccepted = - pendingAgreements !== null && - pendingAgreements.every((agreement) => { - const keys = bvnkAgreementConsentKeys(agreement); - return accepted.has(keys.agreement) && accepted.has(keys.privacyPolicy); - }); + const collectAnswer = recordForCorridor(collectRecord, corridorIdentity); + const requirementsData = collectAnswer !== null ? collectAnswer.result : data; + const pendingAgreements = pendingAgreementsOf(requirementsData); const freshTree = payoutTreeOf(data); const payout = freshTree !== null ? freshTree : payoutTreeOf(requirementsData); const fields = useMemo(() => { @@ -641,19 +750,10 @@ export function useCounterpartyRequirements( const isComplete = requirementsData !== undefined && (pendingAgreements !== null - ? allAgreementsAccepted + ? everyAgreementAccepted(pendingAgreements, acceptedAgreements) : selectedPayoutAccount !== null || fieldsComplete); - // Every status the provider can return is handled: "collect" → needsCollection, - // "ready" → proceed, "unsupported" → block with its reason, plus fetch errors. - // A fetch error only blocks while no usable answer exists — a failed - // post-advance refresh must not strand a wizard whose advance succeeded. - let blockReason: string | null = null; - if (error instanceof Error && requirementsData === undefined) { - blockReason = error.message; - } else if (data?.status === "unsupported") { - blockReason = data.reason; - } + const blockReason = requirementsBlockReason(error, requirementsData, data); return { fields, From 32072be29ad3a46d96ca0fc14f27a2339e687172 Mon Sep 17 00:00:00 2001 From: Arseniy Nikitochkin Date: Thu, 1 Oct 2026 16:05:34 +0300 Subject: [PATCH 6/6] fix(payments): stop the demo switch overstating what stays private The tooltip said demo payments, contacts and provider steps stay in this browser and never reach a provider. Address searches typed in a provider step still go through the SDP API to the real address lookup, and the demo log rides in cookies to the dashboard server. Say what is true: nothing is saved to the project or sent to a payment provider, address searches are real, and changes reset on reload. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/sdp-web/messages/en/dashboard-payments.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sdp-web/messages/en/dashboard-payments.json b/apps/sdp-web/messages/en/dashboard-payments.json index 33c7e20761..5410120534 100644 --- a/apps/sdp-web/messages/en/dashboard-payments.json +++ b/apps/sdp-web/messages/en/dashboard-payments.json @@ -2,7 +2,7 @@ "DashboardPayments": { "demo": { "label": "Demo", - "turnOn": "Try Payments with sample data. Payments, contacts and provider steps in demo mode are simulated and never sent to a provider; they stay in this browser until you reload.", + "turnOn": "Try Payments with sample data. Payments, contacts and provider steps are simulated: nothing is saved to your project or sent to a payment provider. Address searches still use the real address lookup. Your demo changes reset when you reload the page.", "turnOff": "Turn off demo mode", "sandboxOnly": "Demo mode runs on sandbox projects. Switch to your sandbox project to try it.", "noPayLink": "Demo requests have no pay link to share. Turn off demo mode to create a real one.",