diff --git a/packages/sdk/src/common/api.test.ts b/packages/sdk/src/common/api.test.ts index 79011aff..25de2b30 100644 --- a/packages/sdk/src/common/api.test.ts +++ b/packages/sdk/src/common/api.test.ts @@ -1,24 +1,149 @@ import { describe, expect, test } from "vitest"; +import type { ExternalPaymentMethodRequest } from "./api.js"; import { zCreatePaymentMethodRequest } from "./api.js"; describe("zCreatePaymentMethodRequest", () => { - test("accepts BinanceUSDC and BinanceUSDT exchange ids", () => { - for (const exchangeId of ["BinanceUSDC", "BinanceUSDT"] as const) { - expect( - zCreatePaymentMethodRequest.parse({ - clientSecret: "secret", - paymentMethod: { - type: "exchange", - exchangeId, - amountUsd: 10, + test("accepts canonical USD exchange requests", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "exchange", + exchangeId: "Coinbase", + amountUsd: 10, + }, + }).paymentMethod, + ).toEqual({ + type: "exchange", + exchangeId: "Coinbase", + amountUsd: 10, + }); + }); + + test("continues to accept legacy Cash App exchange requests", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "exchange", + exchangeId: "CashApp", + amountUsd: 10, + }, + }).paymentMethod, + ).toEqual({ + type: "exchange", + exchangeId: "CashApp", + amountUsd: 10, + }); + }); + + test("accepts typed external methods and source currencies", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "external", + id: "Revolut", + countryCode: "DE", + sourceAmount: { + units: "10.50", + currency: "EUR", + }, + }, + }).paymentMethod, + ).toEqual({ + type: "external", + id: "Revolut", + countryCode: "DE", + sourceAmount: { + units: "10.50", + currency: "EUR", + }, + }); + }); + + test("accepts Cash App without a country", () => { + const paymentMethod: ExternalPaymentMethodRequest = { + type: "external", + id: "CashApp", + sourceAmount: { + units: "10.50", + currency: "USD", + }, + }; + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod, + }).paymentMethod, + ).toEqual({ + type: "external", + id: "CashApp", + sourceAmount: { + units: "10.50", + currency: "USD", + }, + }); + }); + + test("rejects malformed source amounts without encoding method policy", () => { + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "external", + id: "Revolut", + countryCode: "DE", + sourceAmount: { + units: "0", + currency: "EUR", + }, + }, + }), + ).toThrow(); + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "external", + id: "Revolut", + countryCode: "DE", + sourceAmount: { + units: "10.00", + currency: "eur", + }, + }, + }), + ).toThrow(); + }); + + test("keeps external methods out of the exchange enum", () => { + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "exchange", + exchangeId: "Revolut", + amountUsd: 10, + }, + }), + ).toThrow(); + }); + + test("rejects untyped external method IDs", () => { + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "external", + id: "FutureExternalMethod", + sourceAmount: { + units: "10.00", + currency: "EUR", }, - }).paymentMethod, - ).toEqual({ - type: "exchange", - exchangeId, - amountUsd: 10, - }); - } + }, + }), + ).toThrow(); }); }); diff --git a/packages/sdk/src/common/api.ts b/packages/sdk/src/common/api.ts index f6652fc7..16ac2b37 100644 --- a/packages/sdk/src/common/api.ts +++ b/packages/sdk/src/common/api.ts @@ -1,11 +1,19 @@ import { z } from "zod"; import { zAccountRail } from "./account.js"; +import { + type ExternalPayment, + zExternalPaymentMethodId, +} from "./externalPayment.js"; import type { TronAddress, UUID } from "./primitives.js"; import { zAddress, zSolanaAddress } from "./primitives.js"; import type { SessionPublicInfo } from "./session.js"; const zPlatform = z.enum(["ios", "android", "other", "desktop", "mobile"]); +const zPositiveDecimalUnits = z + .string() + .max(100) + .regex(/^(?:[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)$/); export const zExchangeId = z.enum([ "Coinbase", @@ -41,6 +49,20 @@ export const zCreatePaymentMethodRequest = z.object({ amountUsd: z.number().positive(), platform: zPlatform.optional(), }), + z.object({ + type: z.literal("external"), + id: zExternalPaymentMethodId, + /** ISO-3166 alpha-2 country selected in the backend navigation tree. */ + countryCode: z + .string() + .regex(/^[A-Z]{2}$/) + .optional(), + sourceAmount: z.object({ + units: zPositiveDecimalUnits, + currency: z.string().regex(/^[A-Z]{3}$/), + }), + platform: zPlatform.optional(), + }), z.object({ type: z.literal("stripe"), amountUsd: z.number().positive(), @@ -78,6 +100,12 @@ export type CreatePaymentMethodRequest = z.output< typeof zCreatePaymentMethodRequest >; +/** Provider-neutral request for payment apps and hosted external handoffs. */ +export type ExternalPaymentMethodRequest = Extract< + CreatePaymentMethodRequest["paymentMethod"], + { type: "external" } +>; + export type CheckSessionRequest = z.output; export type TokenOptionsRequest = z.output; @@ -112,7 +140,10 @@ export type CreatePaymentMethodResponse = { /** Base64-encoded Solana transaction for the user to sign. */ serializedTx: string; }; - /** Exchange-specific payment details, present when payment method is Exchange. */ + /** + * @deprecated Use externalPayment. Retained for compatibility with servers + * and clients that predate the provider-neutral external payment contract. + */ exchange?: { /** Deeplink URL for the exchange. */ url: string; @@ -121,6 +152,8 @@ export type CreatePaymentMethodResponse = { /** Invoice expiry time (unix seconds). Present for Lightning invoices. */ expiresAt?: number; }; + /** Provider-neutral details for any external payment handoff. */ + externalPayment?: ExternalPayment; /** Fiat payment details, present when payment method is fiat. */ fiat?: { /** Hosted URL where the user completes KYC and the selected fiat flow. */ @@ -143,6 +176,13 @@ export type CreatePaymentMethodResponse = { }; }; +export type { + ExternalPayment, + ExternalPaymentQuote, + ExternalPaymentMethodId, + Money, +} from "./externalPayment.js"; + export type CheckSessionResponse = { /** Current session state. */ session: SessionPublicInfo; diff --git a/packages/sdk/src/common/externalPayment.ts b/packages/sdk/src/common/externalPayment.ts new file mode 100644 index 00000000..05de42fb --- /dev/null +++ b/packages/sdk/src/common/externalPayment.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const zExternalPaymentMethodId = z.enum(["CashApp", "Revolut"]); + +export type ExternalPaymentMethodId = z.infer; + +export type Money = { + /** ISO 4217 currency or asset symbol. */ + currency: string; + /** Decimal amount in currency units. */ + units: string; +}; + +export type ExternalPayment = { + /** URL where the user completes the external payment. */ + url: string; + /** Message to display while waiting for the payment. */ + waitingMessage: string; + /** Link expiry time (unix seconds), when supplied by the backend. */ + expiresAt?: number; + /** Optional estimate for quoted external payments. */ + quote?: ExternalPaymentQuote; +}; + +export type ExternalPaymentQuote = { + /** Money paid by the user. */ + sourceAmount: Money; + /** Estimated money delivered before the on-chain payment is observed. */ + estimatedDestinationAmount: Money; + fees: { + kind: "service" | "network" | "partner"; + amount: Money; + }[]; +}; diff --git a/packages/sdk/src/common/index.ts b/packages/sdk/src/common/index.ts index d0243972..ee5993f9 100644 --- a/packages/sdk/src/common/index.ts +++ b/packages/sdk/src/common/index.ts @@ -2,6 +2,7 @@ export * from "./account.js"; export * from "./api.js"; export * from "./chain.js"; export * from "./errors.js"; +export * from "./externalPayment.js"; export * from "./primitives.js"; export * from "./session.js"; export * from "./theme.js"; diff --git a/packages/sdk/src/common/session.ts b/packages/sdk/src/common/session.ts index de98f148..f5d4b26f 100644 --- a/packages/sdk/src/common/session.ts +++ b/packages/sdk/src/common/session.ts @@ -2,6 +2,7 @@ import type { Address, Hex } from "viem"; import { z } from "zod"; import type { AccountRail } from "./account.js"; +import type { ExternalPaymentMethodId, Money } from "./externalPayment.js"; import type { DaimoSessionTheme } from "./theme.js"; import type { SolanaAddress, @@ -96,7 +97,18 @@ export type PaymentMethod = | PaymentMethodTron | PaymentMethodSolana | PaymentMethodStripe - | PaymentMethodFiat; + | PaymentMethodFiat + | PaymentMethodExternal; + +export type PaymentMethodExternal = { + type: "external"; + /** User-visible payment method identity. */ + id: ExternalPaymentMethodId; + /** Money the user authorized in the external payment app. */ + sourceAmount: Money; + /** When this payment method was created (unix seconds). */ + createdAt: number; +}; export type PaymentMethodStripe = { type: "stripe"; diff --git a/packages/sdk/src/web/api/index.ts b/packages/sdk/src/web/api/index.ts index b8237518..d315cacb 100644 --- a/packages/sdk/src/web/api/index.ts +++ b/packages/sdk/src/web/api/index.ts @@ -10,6 +10,9 @@ export type { NavNodeDeeplink, NavNodeDepositAddress, NavNodeExchange, + NavExternalPaymentNode, + NavExternalHandoff, + NavNodeExternalPayment, NavNodeFiat, NavNodeKycRequirement, NavNodeKycRequirementDisplayItem, @@ -17,6 +20,7 @@ export type { NavNodeKycRequirementItem, NavNodeKycRequirementKind, NavNodeTronDeposit, + NavSourceAmount, SessionNavInfo, SessionWithNav, } from "./navTree.js"; diff --git a/packages/sdk/src/web/api/navTree.test.ts b/packages/sdk/src/web/api/navTree.test.ts new file mode 100644 index 00000000..bfb427d4 --- /dev/null +++ b/packages/sdk/src/web/api/navTree.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; + +import { + formatNavSourceAmountUnits, + getNavExternalHandoff, + getNavSourceAmount, + type NavNodeExchange, + type NavNodeExternalPayment, +} from "./navTree.js"; + +describe("external payment nav policy", () => { + test("uses backend-provided amount and handoff descriptors", () => { + const node: NavNodeExternalPayment = { + type: "ExternalPayment", + id: "ExternalPayment-Revolut", + title: "Revolut", + externalPaymentMethodId: "Revolut", + countryCode: "JP", + sourceAmount: { + currency: "JPY", + currencySymbol: "¥", + decimals: 0, + minimum: 100, + maximum: 10000, + }, + externalHandoff: { + desktopBehavior: "popup", + popupName: "future-hosted-method", + }, + }; + + expect(getNavSourceAmount(node)).toEqual(node.sourceAmount); + expect(getNavExternalHandoff(node)).toEqual(node.externalHandoff); + expect(formatNavSourceAmountUnits(125, node.sourceAmount)).toBe("125"); + }); + + test("rejects invalid backend amount precision", () => { + expect(() => + formatNavSourceAmountUnits(10, { + currency: "EUR", + currencySymbol: "€", + decimals: 21, + minimum: 1, + maximum: 100, + }), + ).toThrow("invalid external payment amount"); + }); + + test("falls back to legacy USD nav fields", () => { + const node: NavNodeExchange = { + type: "Exchange", + id: "Exchange-Coinbase", + title: "Coinbase", + exchangeId: "Coinbase", + requiredUsd: 10, + minimumUsd: 1, + maximumUsd: 100, + }; + + expect(getNavSourceAmount(node)).toEqual({ + currency: "USD", + currencySymbol: "$", + decimals: 2, + required: 10, + minimum: 1, + maximum: 100, + }); + expect(getNavExternalHandoff(node)).toEqual({ + desktopBehavior: "popup", + legacyQrPlaceholderDensity: "short", + }); + }); + + test.each([ + ["Coinbase", "popup", "short"], + ["MtPelerin", "popup", "short"], + ["Binance", "qr", "medium"], + ["BinanceUSDC", "qr", "medium"], + ["BinanceUSDT", "qr", "medium"], + ["Lemon", "qr", "short"], + ] as const)( + "preserves legacy %s handoff behavior", + (exchangeId, desktopBehavior, legacyQrPlaceholderDensity) => { + const node: NavNodeExchange = { + type: "Exchange", + id: `Exchange-${exchangeId}`, + title: exchangeId, + exchangeId, + minimumUsd: 1, + maximumUsd: 100, + }; + + expect(getNavExternalHandoff(node)).toEqual({ + desktopBehavior, + legacyQrPlaceholderDensity, + }); + }, + ); +}); diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts index 626fbb0c..dbad4cbb 100644 --- a/packages/sdk/src/web/api/navTree.ts +++ b/packages/sdk/src/web/api/navTree.ts @@ -4,6 +4,7 @@ import type { DepositPaymentInteraction, } from "../../common/account.js"; import type { ExchangeId } from "../../common/api.js"; +import type { ExternalPaymentMethodId } from "../../common/externalPayment.js"; import type { SessionPublicInfo } from "../../common/session.js"; /** Session plus server-defined modal navigation data. */ @@ -89,23 +90,137 @@ export type NavNodeDeeplink = NavNodeCommon & { pageIcon?: string; }; +export type NavSourceAmount = { + /** ISO 4217 source currency selected by the backend. */ + currency: string; + /** Presentation symbol selected by the backend, for example "$" or "€". */ + currencySymbol: string; + /** Decimal places submitted to the backend. */ + decimals: number; + required?: number; + minimum: number; + maximum: number; +}; + +export type NavExternalHandoff = { + desktopBehavior: "popup" | "qr"; + popupName?: string; +}; + export type NavNodeExchange = NavNodeCommon & { type: "Exchange"; exchangeId: ExchangeId; icon?: string; + /** Backend-owned amount and currency policy. Optional for old servers. */ + sourceAmount?: NavSourceAmount; + /** Backend-owned external handoff presentation. Optional for old servers. */ + externalHandoff?: NavExternalHandoff; + /** @deprecated Use sourceAmount.required. */ requiredUsd?: number; + /** @deprecated Use sourceAmount.minimum. */ minimumUsd: number; + /** @deprecated Use sourceAmount.maximum. */ maximumUsd: number; }; export type NavNodeCashApp = NavNodeCommon & { type: "CashApp"; icon?: string; + /** + * Present when the server accepts the external-payment request for Cash App. + * Its absence preserves compatibility with pre-external-payment servers. + */ + externalPaymentMethodId?: "CashApp"; + /** Backend-owned amount and currency policy. Optional for old servers. */ + sourceAmount?: NavSourceAmount; + /** Backend-owned external handoff presentation. Optional for old servers. */ + externalHandoff?: NavExternalHandoff; requiredUsd?: number; minimumUsd: number; maximumUsd: number; }; +/** + * Generic external payment handoff. The SDK renders the interaction without + * knowing which implementation owns the method ID. + */ +export type NavNodeExternalPayment = NavNodeCommon & { + type: "ExternalPayment"; + externalPaymentMethodId: ExternalPaymentMethodId; + /** ISO-3166 alpha-2 country to echo when initiating this payment. */ + countryCode?: string; + icon?: string; + sourceAmount: NavSourceAmount; + externalHandoff: NavExternalHandoff; +}; + +export type NavExternalPaymentNode = + | NavNodeExchange + | NavNodeCashApp + | NavNodeExternalPayment; + +/** Compatibility fallback for nav trees produced by pre-sourceAmount servers. */ +export function getNavSourceAmount( + node: NavExternalPaymentNode, +): NavSourceAmount { + if (node.type === "ExternalPayment") return node.sourceAmount; + if (node.sourceAmount != null) return node.sourceAmount; + return { + currency: "USD", + currencySymbol: "$", + decimals: 2, + required: node.requiredUsd, + minimum: node.minimumUsd, + maximum: node.maximumUsd, + }; +} + +/** Compatibility fallback for nav trees produced by pre-handoff servers. */ +export function getNavExternalHandoff( + node: NavExternalPaymentNode, +): NavExternalHandoff & { + legacyQrPlaceholderDensity?: "short" | "medium" | "long"; +} { + if (node.type === "ExternalPayment") return node.externalHandoff; + if (node.externalHandoff != null) return node.externalHandoff; + return getLegacyNavExternalHandoff(node); +} + +/** Preserve pre-handoff SDK behavior while old backend nav trees remain valid. */ +function getLegacyNavExternalHandoff( + node: NavNodeExchange | NavNodeCashApp, +): NavExternalHandoff & { + legacyQrPlaceholderDensity: "short" | "medium"; +} { + const exchangeId = node.type === "CashApp" ? "CashApp" : node.exchangeId; + return { + desktopBehavior: + exchangeId === "Coinbase" || exchangeId === "MtPelerin" ? "popup" : "qr", + legacyQrPlaceholderDensity: + exchangeId === "Binance" || + exchangeId === "BinanceUSDC" || + exchangeId === "BinanceUSDT" + ? "medium" + : "short", + }; +} + +/** Format an amount exactly as required by the backend-owned source policy. */ +export function formatNavSourceAmountUnits( + amount: number, + sourceAmount: NavSourceAmount, +): string { + if ( + !Number.isFinite(amount) || + !Number.isInteger(sourceAmount.decimals) || + sourceAmount.decimals < 0 || + sourceAmount.decimals > 20 + ) { + throw new Error("invalid external payment amount"); + } + return amount.toFixed(sourceAmount.decimals); +} + export type NavNodeStripe = NavNodeCommon & { type: "Stripe"; icon?: string; @@ -144,6 +259,7 @@ export type NavNode = | NavNodeDeeplink | NavNodeExchange | NavNodeCashApp + | NavNodeExternalPayment | NavNodeStripe | NavNodeTronDeposit | NavNodeConnectedWallet diff --git a/packages/sdk/src/web/components/DaimoModal.tsx b/packages/sdk/src/web/components/DaimoModal.tsx index c0816322..99ec44f5 100644 --- a/packages/sdk/src/web/components/DaimoModal.tsx +++ b/packages/sdk/src/web/components/DaimoModal.tsx @@ -19,14 +19,14 @@ import type { NavLocationOption, RecreateSessionWithNavResponse, } from "../api/index.js"; +import { getNavSourceAmount } from "../api/navTree.js"; import type { + NavExternalPaymentNode, NavNode, - NavNodeCashApp, NavNodeChooseOption, NavNodeConnectedWallet, NavNodeDeeplink, NavNodeDepositAddress, - NavNodeExchange, NavNodeStripe, NavNodeTronDeposit, SessionWithNav, @@ -67,7 +67,7 @@ import { ChooseWalletPage } from "./ChooseWalletPage.js"; import { ConfirmationPage } from "./ConfirmationPage.js"; import { EmbeddedContainer, ModalContainer } from "./containers.js"; import { DeeplinkPage } from "./DeeplinkPage.js"; -import { ExchangePage } from "./ExchangePage.js"; +import { ExternalPaymentFlowPage } from "./ExternalPaymentFlowPage.js"; import { ExpiredPage } from "./ExpiredPage.js"; import { AccountPaymentInstructionsPage } from "./account/AccountBankDetailsPage.js"; import { AccountInstitutionPickerPage } from "./account/AccountBankPickerPage.js"; @@ -108,8 +108,6 @@ import { QRCode } from "./QRCode.js"; import { WaitingDepositAddressPage } from "./WaitingDepositAddressPage.js"; import { WalletAmountPage } from "./WalletAmountPage.js"; -type ExchangeLikeNode = NavNodeExchange | NavNodeCashApp; - export type DaimoModalLocalizationProps = { /** Override country used to localize auto payment-method sessions. */ countryCode?: DaimoCountryCode; @@ -821,8 +819,8 @@ function renderEntry( return renderWaitingDeposit(entry, ctx); case "waiting-tron": return renderWaitingTron(entry, ctx); - case "exchange-page": - return renderExchangePage(entry, ctx); + case "external-payment": + return renderExternalPayment(entry, ctx); case "stripe-onramp": return renderStripeOnramp(entry, ctx); case "wallet-choose-chain": @@ -1113,8 +1111,8 @@ function renderSelectAmount( return ( ); } - if (entry.flowType === "exchange") { - const exchangeNode = node as NavNodeExchange; - return ( - - ); - } - if (entry.flowType === "cashapp") { - const cashAppNode = node as NavNodeCashApp; + if ( + entry.flowType === "exchange" || + entry.flowType === "cashapp" || + entry.flowType === "external" + ) { + if ( + node.type !== "Exchange" && + node.type !== "CashApp" && + node.type !== "ExternalPayment" + ) { + return null; + } + const sourceAmount = getNavSourceAmount(node); return ( ); return ( - void; - onRetry?: () => void; - baseUrl: string; -}; - -export function ExchangePage({ - node, - platform, - exchangeUrl, - waitingMessage, - isLoading, - onBack, - baseUrl, -}: ExchangePageProps) { - const exchangeId = node.type === "CashApp" ? "CashApp" : node.exchangeId; - const desktopBehavior = - exchangeId === "Coinbase" || exchangeId === "MtPelerin" ? "popup" : "qr"; - const usesDesktopQR = isDesktop(platform) && desktopBehavior === "qr"; - - const isBinanceExchange = - exchangeId === "Binance" || - exchangeId === "BinanceUSDC" || - exchangeId === "BinanceUSDT"; - const placeholderDensity = isBinanceExchange - ? ("medium" as const) - : ("short" as const); - - return ( - - ); -} diff --git a/packages/sdk/src/web/components/ExternalPaymentFlowPage.tsx b/packages/sdk/src/web/components/ExternalPaymentFlowPage.tsx new file mode 100644 index 00000000..de2e480b --- /dev/null +++ b/packages/sdk/src/web/components/ExternalPaymentFlowPage.tsx @@ -0,0 +1,77 @@ +import type { ExternalPaymentQuote } from "../../common/api.js"; +import { getNavExternalHandoff } from "../api/navTree.js"; +import type { NavExternalPaymentNode } from "../api/navTree.js"; + +import { t } from "../hooks/locale.js"; +import { isDesktop, type DaimoPlatform } from "../platform.js"; +import { ExternalPaymentPage } from "./ExternalPaymentPage.js"; + +type ExternalPaymentFlowPageProps = { + node: NavExternalPaymentNode; + platform: DaimoPlatform; + paymentUrl?: string; + waitingMessage?: string; + expiresAt?: number; + quote?: ExternalPaymentQuote; + isLoading?: boolean; + onBack: () => void; + onRetry?: () => void; + baseUrl: string; +}; + +export function ExternalPaymentFlowPage({ + node, + platform, + paymentUrl, + waitingMessage, + quote, + isLoading, + onBack, + baseUrl, +}: ExternalPaymentFlowPageProps) { + const handoff = getNavExternalHandoff(node); + const { desktopBehavior } = handoff; + const usesDesktopQR = isDesktop(platform) && desktopBehavior === "qr"; + + return ( + ({ + id: `fee-${fee.kind}-${index}`, + label: + fee.kind === "service" + ? t.serviceFee + : fee.kind === "network" + ? t.networkFee + : t.partnerFee, + value: `${fee.amount.units} ${fee.amount.currency}`, + })), + ] + } + /> + ); +} diff --git a/packages/sdk/src/web/components/ExternalPaymentPage.tsx b/packages/sdk/src/web/components/ExternalPaymentPage.tsx index 4f2ae847..320717a2 100644 --- a/packages/sdk/src/web/components/ExternalPaymentPage.tsx +++ b/packages/sdk/src/web/components/ExternalPaymentPage.tsx @@ -15,6 +15,12 @@ import { type QRDensity = "short" | "medium" | "long"; type DesktopBehavior = "popup" | "qr"; +type ExternalPaymentDetail = { + id: string; + label: string; + value: string; +}; + type ExternalPaymentPageProps = { title: string; platform: DaimoPlatform; @@ -29,6 +35,7 @@ type ExternalPaymentPageProps = { popupName?: string; popupFeatures?: string; placeholderDensity?: QRDensity; + details?: ExternalPaymentDetail[]; /** Called with the opened window (null when blocked by the browser). */ onOpened?: (popup: Window | null) => void; }; @@ -50,6 +57,7 @@ export function ExternalPaymentPage({ popupName, popupFeatures = DEFAULT_POPUP_FEATURES, placeholderDensity, + details, onOpened, }: ExternalPaymentPageProps) { const desktop = isDesktop(platform); @@ -111,6 +119,23 @@ export function ExternalPaymentPage({ {message}

)} + {details != null && details.length > 0 && ( +
+ {details.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+ )} {!showQR && ( void; - onContinue: (amountUsd: number) => void; + onContinue: (amount: number) => void; isLoading?: boolean; error?: string | null; baseUrl: string; @@ -30,8 +34,10 @@ type SelectAmountPageProps = { export function SelectAmountPage({ node, - minimumUsd, - maximumUsd, + minimum, + maximum, + decimals, + currencySymbol, tokenSuffix, chainId, onBack, @@ -40,11 +46,7 @@ export function SelectAmountPage({ error, baseUrl, }: SelectAmountPageProps) { - const { - amount: amountUsd, - isValid, - handleChange, - } = useAmountInput(minimumUsd, maximumUsd); + const { amount, isValid, handleChange } = useAmountInput(minimum, maximum); // Create pseudo-token for display if tokenSuffix is USDC or USDT and chainId is provided const selectedToken = @@ -95,8 +97,10 @@ export function SelectAmountPage({ {/* Amount input */}
@@ -110,7 +114,7 @@ export function SelectAmountPage({ )} isValid && !isLoading && onContinue(amountUsd)} + onClick={() => isValid && !isLoading && onContinue(amount)} disabled={!isValid || isLoading} className="daimo-max-w-none" > diff --git a/packages/sdk/src/web/components/shared.tsx b/packages/sdk/src/web/components/shared.tsx index b6f4fadf..8a96ce6a 100644 --- a/packages/sdk/src/web/components/shared.tsx +++ b/packages/sdk/src/web/components/shared.tsx @@ -102,6 +102,8 @@ function useCopyToClipboard(resetDelayMs = 1500) { type AmountInputProps = { minimum: number; maximum: number; + /** Maximum source-currency decimal places. Defaults to 2. */ + decimals?: number; /** Currency symbol prefix (e.g., "$", "CA$"). Defaults to "$". */ currencySymbol?: string; /** Label shown below input (e.g., "Balance: $X.XX" or "Minimum $X.XX") */ @@ -121,6 +123,7 @@ type AmountInputProps = { export function AmountInput({ minimum, maximum, + decimals = 2, currencySymbol = "$", defaultLabel, initialValue, @@ -149,7 +152,7 @@ export function AmountInput({ const handleInputChange = (e: React.ChangeEvent) => { const value = parseDisplayAmount(e.target.value); - if (!isValidAmountInput(value, 2)) return; + if (!isValidAmountInput(value, decimals)) return; setInputValue(value); const newAmount = parseFloat(value) || 0; @@ -164,18 +167,20 @@ export function AmountInput({ }; const displayValue = formatAmountInput(inputValue); - const placeholder = formatAmountInput("0.00"); + const placeholder = formatAmountInput( + decimals === 0 ? "0" : `0.${"0".repeat(decimals)}`, + ); const inputWidth = displayValue.length === 0 ? "3.55ch" : `${Math.min(displayValue.length - (displayValue.match(/\./g) || []).length * 0.55, 12)}ch`; const label = showMinWarning - ? `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum)}` + ? `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum, decimals)}` : showMaxWarning - ? `${t.maximum} ${currencySymbol}${formatFixedAmount(maximum)}` + ? `${t.maximum} ${currencySymbol}${formatFixedAmount(maximum, decimals)}` : (defaultLabel ?? - `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum)}`); + `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum, decimals)}`); const labelClass = showMinWarning || showMaxWarning diff --git a/packages/sdk/src/web/hooks/locales/en.ts b/packages/sdk/src/web/hooks/locales/en.ts index 4e8f781e..cd315e31 100644 --- a/packages/sdk/src/web/hooks/locales/en.ts +++ b/packages/sdk/src/web/hooks/locales/en.ts @@ -23,11 +23,15 @@ export const en = { mobileWallets: "Mobile Wallets", scanWithPhone: "Scan with your phone to open wallet", - // ExchangePage + // ExternalPaymentFlowPage continueTo: "Continue to", toCompleteYourDeposit: "to complete your deposit", open: "Open", refreshInvoice: "Refresh", + estimatedOutput: "Estimated output", + serviceFee: "Service fee", + networkFee: "Network fee", + partnerFee: "Partner fee", // SelectAmountPage selectAmount: "Select Amount", diff --git a/packages/sdk/src/web/hooks/locales/es.ts b/packages/sdk/src/web/hooks/locales/es.ts index 2d937b99..b29cd6e5 100644 --- a/packages/sdk/src/web/hooks/locales/es.ts +++ b/packages/sdk/src/web/hooks/locales/es.ts @@ -23,6 +23,10 @@ export const es: typeof en = { toCompleteYourDeposit: "para completar tu depósito", open: "Abrir", refreshInvoice: "Actualizar", + estimatedOutput: "Importe estimado", + serviceFee: "Comisión de servicio", + networkFee: "Comisión de red", + partnerFee: "Comisión del socio", selectAmount: "Seleccionar monto", loading: "Cargando", continue: "Continuar", diff --git a/packages/sdk/src/web/hooks/locales/ja.ts b/packages/sdk/src/web/hooks/locales/ja.ts index d3f675e6..a3c3b50a 100644 --- a/packages/sdk/src/web/hooks/locales/ja.ts +++ b/packages/sdk/src/web/hooks/locales/ja.ts @@ -25,11 +25,15 @@ export const ja: typeof en = { mobileWallets: "モバイルウォレット", scanWithPhone: "スマートフォンでスキャンしてウォレットを開く", - // ExchangePage + // ExternalPaymentFlowPage continueTo: "", toCompleteYourDeposit: "で入金を完了してください", open: "開く", refreshInvoice: "更新", + estimatedOutput: "受取額(見込み)", + serviceFee: "サービス手数料", + networkFee: "ネットワーク手数料", + partnerFee: "パートナー手数料", // SelectAmountPage selectAmount: "金額を選択", diff --git a/packages/sdk/src/web/hooks/locales/ko.ts b/packages/sdk/src/web/hooks/locales/ko.ts index d6b075a5..25501e4e 100644 --- a/packages/sdk/src/web/hooks/locales/ko.ts +++ b/packages/sdk/src/web/hooks/locales/ko.ts @@ -25,11 +25,15 @@ export const ko: typeof en = { mobileWallets: "모바일 지갑", scanWithPhone: "휴대폰으로 스캔하여 지갑 열기", - // ExchangePage + // ExternalPaymentFlowPage continueTo: "", toCompleteYourDeposit: "에서 입금을 완료하세요", open: "열기", refreshInvoice: "새로고침", + estimatedOutput: "예상 수령액", + serviceFee: "서비스 수수료", + networkFee: "네트워크 수수료", + partnerFee: "파트너 수수료", // SelectAmountPage selectAmount: "금액 선택", diff --git a/packages/sdk/src/web/hooks/locales/pt.ts b/packages/sdk/src/web/hooks/locales/pt.ts index 8eb81e44..0e0cfb16 100644 --- a/packages/sdk/src/web/hooks/locales/pt.ts +++ b/packages/sdk/src/web/hooks/locales/pt.ts @@ -25,11 +25,15 @@ export const pt: typeof en = { mobileWallets: "Carteiras móveis", scanWithPhone: "Escaneie com seu telefone para abrir a carteira", - // ExchangePage + // ExternalPaymentFlowPage continueTo: "Continuar para", toCompleteYourDeposit: "para concluir seu depósito", open: "Abrir", refreshInvoice: "Atualizar", + estimatedOutput: "Valor estimado", + serviceFee: "Taxa de serviço", + networkFee: "Taxa de rede", + partnerFee: "Taxa do parceiro", // SelectAmountPage selectAmount: "Selecionar valor", diff --git a/packages/sdk/src/web/hooks/locales/zh.ts b/packages/sdk/src/web/hooks/locales/zh.ts index 68341f5f..fcc5d3ac 100644 --- a/packages/sdk/src/web/hooks/locales/zh.ts +++ b/packages/sdk/src/web/hooks/locales/zh.ts @@ -25,11 +25,15 @@ export const zh: typeof en = { mobileWallets: "移动钱包", scanWithPhone: "用手机扫码打开钱包", - // ExchangePage + // ExternalPaymentFlowPage continueTo: "前往", toCompleteYourDeposit: "以完成充值", open: "打开", refreshInvoice: "刷新", + estimatedOutput: "预计到账金额", + serviceFee: "服务费", + networkFee: "网络费", + partnerFee: "合作伙伴费用", // SelectAmountPage selectAmount: "选择金额", diff --git a/packages/sdk/src/web/hooks/navEvent.ts b/packages/sdk/src/web/hooks/navEvent.ts index 20178ff3..d2f224a9 100644 --- a/packages/sdk/src/web/hooks/navEvent.ts +++ b/packages/sdk/src/web/hooks/navEvent.ts @@ -17,11 +17,16 @@ export type NavEventAction = | { action: "nav_select"; targetNodeId: string; targetNodeType: NavNodeType } | { action: "nav_back" } | { action: "nav_deeplink"; url: string } - | { action: "flow_amount_continue"; amountUsd: number } + | ({ + action: "flow_amount_continue"; + } & ( + | { amountUsd: number } + | { sourceAmountUnits: string; sourceCurrency: string } + )) | { action: "flow_refresh" } | { - action: "flow_exchange_url"; - exchangeId: string; + action: "flow_external_payment"; + paymentMethodId: string; success: boolean; url?: string; error?: string; diff --git a/packages/sdk/src/web/hooks/types.ts b/packages/sdk/src/web/hooks/types.ts index 39b9af9b..e5877339 100644 --- a/packages/sdk/src/web/hooks/types.ts +++ b/packages/sdk/src/web/hooks/types.ts @@ -4,6 +4,7 @@ import type { AccountRail, DepositPaymentInteraction, } from "../../common/account.js"; +import type { ExternalPaymentQuote } from "../../common/api.js"; import type { NavNode, SessionWithNav } from "../api/navTree.js"; import type { WalletPaymentOption } from "../api/walletTypes.js"; @@ -26,7 +27,13 @@ export type NavEntry = | { type: "select-amount"; nodeId: string; - flowType: "deposit" | "tron" | "exchange" | "cashapp" | "stripe"; + flowType: + | "deposit" + | "tron" + | "exchange" + | "cashapp" + | "external" + | "stripe"; autoNav?: boolean; } | { @@ -49,12 +56,13 @@ export type NavEntry = autoNav?: boolean; } | { - type: "exchange-page"; + type: "external-payment"; nodeId: string; - amountUsd: number; - exchangeUrl?: string; + sourceAmount: number; + paymentUrl?: string; waitingMessage?: string; expiresAt?: number; + quote?: ExternalPaymentQuote; error?: string; autoNav?: boolean; } diff --git a/packages/sdk/src/web/hooks/useSessionNav.ts b/packages/sdk/src/web/hooks/useSessionNav.ts index 8a5cb5c1..95648d2c 100644 --- a/packages/sdk/src/web/hooks/useSessionNav.ts +++ b/packages/sdk/src/web/hooks/useSessionNav.ts @@ -11,17 +11,23 @@ import type { AccountDepositStatus, AccountRail, } from "../../common/account.js"; -import type { ExchangeId } from "../../common/api.js"; +import type { + CreatePaymentMethodRequest, + ExchangeId, +} from "../../common/api.js"; import type { AccountAuthConfig, DaimoCountryCode, RecreateSessionWithNavResponse, } from "../api/index.js"; +import { + formatNavSourceAmountUnits, + getNavSourceAmount, +} from "../api/navTree.js"; import type { + NavExternalPaymentNode, NavNode, - NavNodeCashApp, NavNodeChooseOption, - NavNodeExchange, NavNodeFiat, NavNodeStripe, NavNodeTronDeposit, @@ -55,7 +61,6 @@ import type { InjectedWallet } from "./useInjectedWallets.js"; import { isUserRejection, type WalletFlowResult } from "./useWalletFlow.js"; type NodeContext = { nodeId: string | null; nodeType: NavNodeType | null }; -type ExchangeNode = NavNodeExchange | NavNodeCashApp; type SessionNavResult = { stack: NavEntry[]; @@ -66,7 +71,7 @@ type SessionNavResult = { handleNavigate: (nodeId: string, options?: { autoNav?: boolean }) => void; handleBack: () => void; handleReset: () => void; - handleAmountContinue: (amountUsd: number) => void; + handleAmountContinue: (amount: number) => void; handleRetry: () => void; handleRefresh: () => Promise; handleAccountSessionRecreate: (depositAmount: string) => Promise; @@ -87,8 +92,14 @@ type SessionNavResult = { handleAccountLogout: () => void; }; -function isExchangeNode(node: NavNode | null): node is ExchangeNode { - return node?.type === "Exchange" || node?.type === "CashApp"; +function isExternalPaymentNode( + node: NavNode | null, +): node is NavExternalPaymentNode { + return ( + node?.type === "Exchange" || + node?.type === "CashApp" || + node?.type === "ExternalPayment" + ); } function isStripeNode(node: NavNode | null): node is NavNodeStripe { @@ -99,14 +110,41 @@ function isTrustTronNode(node: NavNode | null): node is NavNodeTronDeposit { return node?.type === "TronDeposit" && node.id === "Trust-Tron"; } -function getExchangeSelection(node: ExchangeNode): { - exchangeId: ExchangeId; - nodeType: "Exchange" | "CashApp"; -} { +function getExternalPaymentSelection(node: NavExternalPaymentNode): + | { + kind: "exchange"; + exchangeId: ExchangeId; + nodeType: "Exchange" | "CashApp"; + } + | { + kind: "external"; + externalPaymentMethodId: "CashApp" | "Revolut"; + countryCode?: string; + nodeType: "CashApp" | "ExternalPayment"; + } { + if (node.type === "ExternalPayment") { + return { + kind: "external", + externalPaymentMethodId: node.externalPaymentMethodId, + countryCode: node.countryCode, + nodeType: "ExternalPayment", + }; + } if (node.type === "CashApp") { - return { exchangeId: "CashApp", nodeType: "CashApp" }; + if (node.externalPaymentMethodId != null) { + return { + kind: "external", + externalPaymentMethodId: node.externalPaymentMethodId, + nodeType: "CashApp", + }; + } + return { kind: "exchange", exchangeId: "CashApp", nodeType: "CashApp" }; } - return { exchangeId: node.exchangeId, nodeType: "Exchange" }; + return { + kind: "exchange", + exchangeId: node.exchangeId, + nodeType: "Exchange", + }; } function replacePendingAccountEntry( @@ -266,65 +304,93 @@ export function useSessionNav( ], ); - const fetchExchangeUrl = useCallback( - async ( - nodeId: string, - exchangeId: ExchangeId, - amountUsd: number, - nodeType: "Exchange" | "CashApp", - ) => { + const fetchExternalPayment = useCallback( + async (nodeId: string, node: NavExternalPaymentNode, amount: number) => { + const selection = getExternalPaymentSelection(node); + const sourceAmount = getNavSourceAmount(node); + const sourceAmountUnits = formatNavSourceAmountUnits( + amount, + sourceAmount, + ); + const paymentMethod: CreatePaymentMethodRequest["paymentMethod"] = + selection.kind === "external" + ? { + type: "external", + id: selection.externalPaymentMethodId, + countryCode: selection.countryCode, + sourceAmount: { + units: sourceAmountUnits, + currency: sourceAmount.currency, + }, + platform: effectivePlatform, + } + : { + type: "exchange", + exchangeId: selection.exchangeId, + amountUsd: amount, + platform: effectivePlatform, + }; try { const result = await client.sessions.paymentMethods.create( session.sessionId, { clientSecret: session.clientSecret, - paymentMethod: { - type: "exchange", - exchangeId, - amountUsd, - platform: effectivePlatform, - }, + paymentMethod, }, ); - if (!result.exchange) return; + const payment = result.externalPayment ?? result.exchange; + if (payment == null) { + throw new Error("external payment details not returned"); + } + const paymentMethodId = + selection.kind === "external" + ? selection.externalPaymentMethodId + : selection.exchangeId; logNavEvent(session.sessionId, session.clientSecret, { nodeId, - nodeType, - action: "flow_exchange_url", - exchangeId, + nodeType: selection.nodeType, + action: "flow_external_payment", + paymentMethodId, success: true, - url: result.exchange.url, + url: payment.url, }); setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page" || top.nodeId !== nodeId) + if (top?.type !== "external-payment" || top.nodeId !== nodeId) return prev; return [ ...prev.slice(0, -1), { ...top, - exchangeUrl: result.exchange!.url, - waitingMessage: result.exchange!.waitingMessage, - expiresAt: result.exchange!.expiresAt, + paymentUrl: payment.url, + waitingMessage: payment.waitingMessage, + expiresAt: payment.expiresAt, + quote: result.externalPayment?.quote, error: undefined, }, ]; }); } catch (error) { - console.error("failed to get exchange url:", error); + console.error("failed to create external payment:", error); const errorMsg = - error instanceof Error ? error.message : "failed to get exchange url"; + error instanceof Error + ? error.message + : "failed to create external payment"; + const paymentMethodId = + selection.kind === "external" + ? selection.externalPaymentMethodId + : selection.exchangeId; logNavEvent(session.sessionId, session.clientSecret, { nodeId, - nodeType, - action: "flow_exchange_url", - exchangeId, + nodeType: selection.nodeType, + action: "flow_external_payment", + paymentMethodId, success: false, error: errorMsg, }); setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page" || top.nodeId !== nodeId) + if (top?.type !== "external-payment" || top.nodeId !== nodeId) return prev; return [...prev.slice(0, -1), { ...top, error: errorMsg }]; }); @@ -728,15 +794,19 @@ export function useSessionNav( return; } - if (isExchangeNode(targetNode)) { - const { exchangeId, nodeType } = getExchangeSelection(targetNode); - const requiredUsd = targetNode.requiredUsd ?? 0; - if (requiredUsd > 0) { + if (isExternalPaymentNode(targetNode)) { + const required = getNavSourceAmount(targetNode).required ?? 0; + if (required > 0) { setStack((prev) => [ ...prev, - { type: "exchange-page", nodeId, amountUsd: requiredUsd, autoNav }, + { + type: "external-payment", + nodeId, + sourceAmount: required, + autoNav, + }, ]); - fetchExchangeUrl(nodeId, exchangeId, requiredUsd, nodeType); + fetchExternalPayment(nodeId, targetNode, required); return; } setStack((prev) => [ @@ -744,7 +814,12 @@ export function useSessionNav( { type: "select-amount", nodeId, - flowType: targetNode.type === "CashApp" ? "cashapp" : "exchange", + flowType: + targetNode.type === "CashApp" + ? "cashapp" + : targetNode.type === "ExternalPayment" + ? "external" + : "exchange", autoNav, }, ]); @@ -782,7 +857,7 @@ export function useSessionNav( session.sessionId, getNodeCtx, fetchTronAddress, - fetchExchangeUrl, + fetchExternalPayment, fetchStripeOnramp, handleAccountNavigate, enableFiatPopup, @@ -829,9 +904,26 @@ export function useSessionNav( // ─── Flow handlers ────────────────────────────────────────────────────── const handleAmountContinue = useCallback( - (amountUsd: number) => { + (amount: number) => { if (!topEntry || topEntry.type !== "select-amount") return; const { nodeId, flowType } = topEntry; + const selectedNode = findNode(nodeId, session.navTree); + const selectedSourceAmount = isExternalPaymentNode(selectedNode) + ? getNavSourceAmount(selectedNode) + : null; + const amountContext = + (flowType === "exchange" || + flowType === "cashapp" || + flowType === "external") && + selectedSourceAmount != null + ? { + sourceAmountUnits: formatNavSourceAmountUnits( + amount, + selectedSourceAmount, + ), + sourceCurrency: selectedSourceAmount.currency, + } + : { amountUsd: amount }; logNavEvent(session.sessionId, session.clientSecret, { nodeId, @@ -842,41 +934,44 @@ export function useSessionNav( ? "TronDeposit" : flowType === "cashapp" ? "CashApp" - : flowType === "stripe" - ? "Stripe" - : "Exchange", + : flowType === "external" + ? "ExternalPayment" + : flowType === "stripe" + ? "Stripe" + : "Exchange", action: "flow_amount_continue", - amountUsd, + ...amountContext, }); if (flowType === "deposit") { setStack((prev) => [ ...prev, - { type: "waiting-deposit", nodeId, amountUsd }, + { type: "waiting-deposit", nodeId, amountUsd: amount }, ]); } else if (flowType === "tron") { setStack((prev) => [ ...prev, - { type: "waiting-tron", nodeId, amountUsd }, + { type: "waiting-tron", nodeId, amountUsd: amount }, ]); - fetchTronAddress(nodeId, amountUsd); - } else if (flowType === "exchange" || flowType === "cashapp") { - const node = findNode(nodeId, session.navTree); - if (!isExchangeNode(node)) return; - const { exchangeId, nodeType } = getExchangeSelection(node); + fetchTronAddress(nodeId, amount); + } else if ( + flowType === "exchange" || + flowType === "cashapp" || + flowType === "external" + ) { + if (!isExternalPaymentNode(selectedNode)) return; setStack((prev) => [ ...prev, - { type: "exchange-page", nodeId, amountUsd }, + { type: "external-payment", nodeId, sourceAmount: amount }, ]); - fetchExchangeUrl(nodeId, exchangeId, amountUsd, nodeType); + fetchExternalPayment(nodeId, selectedNode, amount); } else if (flowType === "stripe") { - const node = findNode(nodeId, session.navTree); - if (!isStripeNode(node)) return; + if (!isStripeNode(selectedNode)) return; setStack((prev) => [ ...prev, - { type: "stripe-onramp", nodeId, amountUsd }, + { type: "stripe-onramp", nodeId, amountUsd: amount }, ]); - fetchStripeOnramp(nodeId, amountUsd); + fetchStripeOnramp(nodeId, amount); } }, [ @@ -884,7 +979,7 @@ export function useSessionNav( session.sessionId, session.navTree, fetchTronAddress, - fetchExchangeUrl, + fetchExternalPayment, fetchStripeOnramp, ], ); @@ -956,29 +1051,23 @@ export function useSessionNav( return; } - if (topEntry.type === "exchange-page") { + if (topEntry.type === "external-payment") { const node = findNode(topEntry.nodeId, session.navTree); - if (!isExchangeNode(node)) return; - const { exchangeId, nodeType } = getExchangeSelection(node); + if (!isExternalPaymentNode(node)) return; setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page") return prev; + if (top?.type !== "external-payment") return prev; return [ ...prev.slice(0, -1), { ...top, - exchangeUrl: undefined, + paymentUrl: undefined, expiresAt: undefined, error: undefined, }, ]; }); - fetchExchangeUrl( - topEntry.nodeId, - exchangeId, - topEntry.amountUsd, - nodeType, - ); + fetchExternalPayment(topEntry.nodeId, node, topEntry.sourceAmount); return; } @@ -1003,7 +1092,7 @@ export function useSessionNav( topEntry, session.navTree, fetchTronAddress, - fetchExchangeUrl, + fetchExternalPayment, fetchStripeOnramp, doWalletSend, ]);