diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0342b328..13a0fde7 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -15,6 +15,44 @@ See [docs.daimo.com](https://docs.daimo.com) for more. - `@daimo/sdk/web` — React modal (``) and hooks for the built-in deposit UI - `@daimo/sdk/native` — React Native deposit UI (``) over `react-native-webview` +### Payment methods + +Payment method identity, product category, and client execution are separate +concepts: + +- `PaymentMethodId` is the stable identity selected by a customer, such as + `CashApp`, `MtPelerin`, or `Stripe`. +- `PaymentMethodCategory` is derived product metadata such as `paymentApp`, + `onramp`, or `exchange`. +- `PaymentAction` tells a client what to do next, such as open a URL or render + an embedded widget. + +Custom UIs create action-based methods through the same request shape: + +```ts +const result = await client.sessions.paymentMethods.create(sessionId, { + clientSecret, + paymentMethod: { + id: "CashApp", + sourceAmount: { currency: "USD", units: "25.00" }, + platform: "ios", + }, +}); + +switch (result.action?.type) { + case "openUrl": + window.open(result.action.url, "_blank"); + break; + case "embeddedWidget": + // Mount result.action.sdk with its scoped client credentials. + break; +} +``` + +The built-in modal performs this call and renders the action automatically. +Provider-shaped exchange and Stripe request/response fields remain accepted as +deprecated compatibility adapters. + ### Styles Import `@daimo/sdk/web/theme.css` for the built-in web UI. The distributed stylesheet namespaces internal classes with `daimo-` so it can coexist with a host app's Tailwind build. diff --git a/packages/sdk/src/common/api.test.ts b/packages/sdk/src/common/api.test.ts index 79011aff..cf738334 100644 --- a/packages/sdk/src/common/api.test.ts +++ b/packages/sdk/src/common/api.test.ts @@ -1,24 +1,121 @@ import { describe, expect, test } from "vitest"; +import type { PaymentMethodRequest } 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 requests discriminated by method ID", () => { + const paymentMethod: PaymentMethodRequest = { + id: "CashApp", + sourceAmount: { + units: "10.50", + currency: "USD", + }, + platform: "ios", + }; + + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod, + }).paymentMethod, + ).toEqual(paymentMethod); + }); + + test("allows source currency independently of product category", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + id: "MtPelerin", + countryCode: "CH", + sourceAmount: { + units: "10.50", + currency: "EUR", + }, + }, + }).paymentMethod, + ).toEqual({ + id: "MtPelerin", + countryCode: "CH", + sourceAmount: { + units: "10.50", + currency: "EUR", + }, + }); + }); + + test("accepts Stripe through the same request shape", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + id: "Stripe", + sourceAmount: { + units: "25.00", + currency: "USD", }, - }).paymentMethod, - ).toEqual({ - type: "exchange", - exchangeId, - amountUsd: 10, - }); - } + }, + }).paymentMethod, + ).toMatchObject({ id: "Stripe" }); + }); + + test("rejects malformed source amounts", () => { + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + id: "CashApp", + sourceAmount: { + units: "0", + currency: "USD", + }, + }, + }), + ).toThrow(); + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + id: "CashApp", + sourceAmount: { + units: "10.00", + currency: "usd", + }, + }, + }), + ).toThrow(); + }); + + test("rejects unknown method IDs", () => { + expect(() => + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + id: "FutureMethod", + sourceAmount: { + units: "10.00", + currency: "EUR", + }, + }, + }), + ).toThrow(); + }); + + test("continues to accept legacy provider-shaped requests", () => { + expect( + zCreatePaymentMethodRequest.parse({ + clientSecret: "secret", + paymentMethod: { + type: "exchange", + exchangeId: "CashApp", + amountUsd: 10, + }, + }).paymentMethod, + ).toEqual({ + type: "exchange", + exchangeId: "CashApp", + amountUsd: 10, + }); }); }); diff --git a/packages/sdk/src/common/api.ts b/packages/sdk/src/common/api.ts index f6652fc7..05ae7c7b 100644 --- a/packages/sdk/src/common/api.ts +++ b/packages/sdk/src/common/api.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { zAccountRail } from "./account.js"; +import { type PaymentAction, zMoney } from "./paymentMethod.js"; import type { TronAddress, UUID } from "./primitives.js"; import { zAddress, zSolanaAddress } from "./primitives.js"; import type { SessionPublicInfo } from "./session.js"; @@ -23,32 +24,64 @@ export type ExchangeId = z.infer; export const zSessionId = z.string().describe("Session ID"); +export const zActionPaymentMethodId = z.enum([ + "CashApp", + "Coinbase", + "Binance", + "BinanceUSDC", + "BinanceUSDT", + "Lemon", + "BitgetExchange", + "BybitExchange", + "MtPelerin", + "Stripe", +]); + +export type ActionPaymentMethodId = z.infer; + +export const zActionPaymentMethodRequest = z.object({ + id: zActionPaymentMethodId, + sourceAmount: zMoney, + countryCode: z + .string() + .regex(/^[A-Z]{2}$/) + .optional(), + platform: zPlatform.optional(), +}); + +export type PaymentMethodRequest = z.infer; + +const zLegacyPaymentMethodRequest = z.discriminatedUnion("type", [ + z.object({ type: z.literal("evm") }), + z.object({ type: z.literal("tron"), amountUsd: z.number().positive() }), + z.object({ + type: z.literal("solana"), + walletAddress: z.string().min(1), + inputTokenMint: z.string().min(1), + amountUsd: z.number().positive(), + }), + z.object({ + type: z.literal("exchange"), + exchangeId: zExchangeId, + amountUsd: z.number().positive(), + platform: zPlatform.optional(), + }), + z.object({ + type: z.literal("stripe"), + amountUsd: z.number().positive(), + }), + z.object({ + type: z.literal("fiat"), + fiatMethod: zAccountRail.optional(), + }), +]); + export const zCreatePaymentMethodRequest = z.object({ clientSecret: z.string(), locale: z.string().optional(), - paymentMethod: z.discriminatedUnion("type", [ - z.object({ type: z.literal("evm") }), - z.object({ type: z.literal("tron"), amountUsd: z.number().positive() }), - z.object({ - type: z.literal("solana"), - walletAddress: z.string().min(1), - inputTokenMint: z.string().min(1), - amountUsd: z.number().positive(), - }), - z.object({ - type: z.literal("exchange"), - exchangeId: zExchangeId, - amountUsd: z.number().positive(), - platform: zPlatform.optional(), - }), - z.object({ - type: z.literal("stripe"), - amountUsd: z.number().positive(), - }), - z.object({ - type: z.literal("fiat"), - fiatMethod: zAccountRail.optional(), - }), + paymentMethod: z.union([ + zActionPaymentMethodRequest, + zLegacyPaymentMethodRequest, ]), }); @@ -91,6 +124,8 @@ export type RetrieveSessionResponse = { export type CreatePaymentMethodResponse = { /** Updated session state after payment method creation. */ session: SessionPublicInfo; + /** Canonical next step for the client. */ + action?: PaymentAction; /** Tron-specific payment details, present when payment method is Tron. */ tron?: { /** Tron address to send funds to. */ @@ -112,7 +147,7 @@ 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 action. */ exchange?: { /** Deeplink URL for the exchange. */ url: string; @@ -128,7 +163,7 @@ export type CreatePaymentMethodResponse = { /** Selected fiat method for this hosted flow, when pinned to one method. */ fiatMethod?: z.infer; }; - /** Stripe Onramp details, present when payment method is Stripe. */ + /** @deprecated Use action. */ stripe?: { /** * Stripe OnrampSession client secret, scoped to this onramp session. @@ -143,6 +178,14 @@ export type CreatePaymentMethodResponse = { }; }; +export type { + Money, + PaymentAction, + PaymentMethodCategory, + PaymentMethodId, + PaymentQuote, +} from "./paymentMethod.js"; + export type CheckSessionResponse = { /** Current session state. */ session: SessionPublicInfo; diff --git a/packages/sdk/src/common/index.ts b/packages/sdk/src/common/index.ts index d0243972..56f15eb0 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 "./paymentMethod.js"; export * from "./primitives.js"; export * from "./session.js"; export * from "./theme.js"; diff --git a/packages/sdk/src/common/paymentMethod.test.ts b/packages/sdk/src/common/paymentMethod.test.ts new file mode 100644 index 00000000..0e6b33e1 --- /dev/null +++ b/packages/sdk/src/common/paymentMethod.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { zPaymentAction, zPaymentMethodCategory } from "./paymentMethod.js"; + +describe("payment method contract", () => { + test("keeps product category separate from execution action", () => { + expect(zPaymentMethodCategory.parse("onramp")).toBe("onramp"); + expect( + zPaymentAction.parse({ + type: "openUrl", + url: "https://example.com/pay", + presentation: "popup", + waitingMessage: "finish your payment", + }), + ).toMatchObject({ type: "openUrl" }); + expect( + zPaymentAction.parse({ + type: "embeddedWidget", + sdk: "stripeOnramp", + clientSecret: "secret", + publishableKey: "key", + fallbackUrl: "https://example.com/pay", + }), + ).toMatchObject({ type: "embeddedWidget" }); + }); +}); diff --git a/packages/sdk/src/common/paymentMethod.ts b/packages/sdk/src/common/paymentMethod.ts new file mode 100644 index 00000000..4709c809 --- /dev/null +++ b/packages/sdk/src/common/paymentMethod.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +export const zPaymentMethodId = z.enum([ + "ConnectedWallet", + "Interac", + "ACH", + "SEPA", + "ApplePay", + "CashApp", + "Coinbase", + "Binance", + "BinanceUSDC", + "BinanceUSDT", + "Lemon", + "BitgetExchange", + "BybitExchange", + "MtPelerin", + "Tron", + "ARS", + "BreB", + "JPYC", + "Stripe", +]); + +export type PaymentMethodId = z.infer; + +/** Product grouping only. Execution is described by PaymentAction. */ +export const zPaymentMethodCategory = z.enum([ + "wallet", + "bank", + "card", + "paymentApp", + "onramp", + "exchange", +]); + +export type PaymentMethodCategory = z.infer; + +export const zMoney = z.object({ + currency: z.string().regex(/^[A-Z]{3,10}$/), + units: z + .string() + .max(100) + .regex(/^(?:[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)$/), +}); + +export type Money = z.infer; + +export const zPaymentQuote = z.object({ + /** Money paid by the user. */ + sourceAmount: zMoney, + /** Estimated money delivered before the source payment is observed. */ + estimatedDestinationAmount: zMoney, + fees: z.array( + z.object({ + kind: z.enum(["service", "network", "partner"]), + amount: zMoney, + }), + ), +}); + +export type PaymentQuote = z.infer; + +/** What a client must do after creating a payment method. */ +export const zPaymentAction = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("openUrl"), + url: z.string().url(), + presentation: z.enum(["popup", "qr"]), + popupName: z.string().optional(), + waitingMessage: z.string(), + expiresAt: z.number().optional(), + quote: zPaymentQuote.optional(), + }), + z.object({ + type: z.literal("embeddedWidget"), + sdk: z.literal("stripeOnramp"), + clientSecret: z.string(), + publishableKey: z.string(), + fallbackUrl: z.string().url(), + }), +]); + +export type PaymentAction = z.infer; diff --git a/packages/sdk/src/common/session.ts b/packages/sdk/src/common/session.ts index de98f148..aac1409d 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 { Money, PaymentMethodId } from "./paymentMethod.js"; import type { DaimoSessionTheme } from "./theme.js"; import type { SolanaAddress, @@ -98,21 +99,33 @@ export type PaymentMethod = | PaymentMethodStripe | PaymentMethodFiat; -export type PaymentMethodStripe = { - type: "stripe"; +type PaymentMethodCommon = { + /** + * Stable user-visible payment method identity. + * + * Optional while sessions created by older servers remain readable. + */ + id?: PaymentMethodId; + /** Money the user authorized when the method has a fiat source amount. */ + sourceAmount?: Money; /** When this payment method was created (unix seconds). */ createdAt: number; }; -export type PaymentMethodFiat = { +export type PaymentMethodStripe = PaymentMethodCommon & { + /** @deprecated Inspect id for identity. This describes the source adapter. */ + type: "stripe"; +}; + +export type PaymentMethodFiat = PaymentMethodCommon & { + /** @deprecated Inspect id for identity. This describes the source adapter. */ type: "fiat"; /** Selected fiat method, when known. */ fiatMethod?: AccountRail; - /** When this payment method was created (unix seconds). */ - createdAt: number; }; -export type PaymentMethodEvm = { +export type PaymentMethodEvm = PaymentMethodCommon & { + /** @deprecated Inspect id for identity. This describes the funds source. */ type: "evm"; /** Address that receives user's funds, checksum encoded. */ receiverAddress: Address; @@ -133,11 +146,10 @@ export type PaymentMethodEvm = { /** Source transaction hash, set once tx is confirmed. */ txHash?: Hex; }; - /** When this payment method was created (unix seconds). */ - createdAt: number; }; -export type PaymentMethodTron = { +export type PaymentMethodTron = PaymentMethodCommon & { + /** @deprecated Inspect id for identity. This describes the funds source. */ type: "tron"; /** Address that receives user's funds. */ receiverAddress: TronAddress; @@ -156,11 +168,10 @@ export type PaymentMethodTron = { /** Source transaction hash, set once tx is confirmed. */ txHash?: TronTxHash; }; - /** When this payment method was created (unix seconds). */ - createdAt: number; }; -export type PaymentMethodSolana = { +export type PaymentMethodSolana = PaymentMethodCommon & { + /** @deprecated Inspect id for identity. This describes the funds source. */ type: "solana"; /** Populated once user initiates a transaction. */ source?: { @@ -177,8 +188,6 @@ export type PaymentMethodSolana = { /** Source transaction hash, set once tx is confirmed. */ txHash?: SolanaTxHash; }; - /** When this payment method was created (unix seconds). */ - createdAt: number; }; export type UserMetadata = Record | null; diff --git a/packages/sdk/src/web/api/index.ts b/packages/sdk/src/web/api/index.ts index b8237518..7318d2d5 100644 --- a/packages/sdk/src/web/api/index.ts +++ b/packages/sdk/src/web/api/index.ts @@ -10,13 +10,18 @@ export type { NavNodeDeeplink, NavNodeDepositAddress, NavNodeExchange, + NavActionPaymentMethodNode, + NavExternalHandoff, NavNodeFiat, NavNodeKycRequirement, NavNodeKycRequirementDisplayItem, NavNodeKycRequirementIcon, NavNodeKycRequirementItem, NavNodeKycRequirementKind, + NavNodePaymentMethod, + NavNodeStripe, 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..c5824de1 --- /dev/null +++ b/packages/sdk/src/web/api/navTree.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "vitest"; + +import { + formatNavSourceAmountUnits, + getNavExternalHandoff, + getNavSourceAmount, + hasCanonicalPaymentMethod, + type NavNodeExchange, + type NavNodePaymentMethod, +} from "./navTree.js"; + +describe("payment method nav policy", () => { + test("uses the canonical method descriptor", () => { + const node: NavNodePaymentMethod = { + type: "PaymentMethod", + id: "PaymentMethod-MtPelerin", + title: "Mt Pelerin", + methodId: "MtPelerin", + category: "onramp", + countryCode: "CH", + sourceAmount: { + currency: "EUR", + currencySymbol: "€", + decimals: 2, + minimum: 10, + maximum: 1000, + }, + }; + + expect(getNavSourceAmount(node)).toEqual(node.sourceAmount); + expect(formatNavSourceAmountUnits(125, node.sourceAmount)).toBe("125.00"); + }); + + test("rejects invalid backend amount precision", () => { + expect(() => + formatNavSourceAmountUnits(10, { + currency: "EUR", + currencySymbol: "€", + decimals: 21, + minimum: 1, + maximum: 100, + }), + ).toThrow("invalid payment method 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", + popupName: "coinbase", + legacyQrPlaceholderDensity: "short", + }); + }); + + test("recognizes canonical metadata on a compatibility node", () => { + const node: NavNodeExchange = { + type: "Exchange", + id: "Exchange-MtPelerin", + title: "Mt Pelerin", + exchangeId: "MtPelerin", + methodId: "MtPelerin", + category: "onramp", + sourceAmount: { + currency: "EUR", + currencySymbol: "€", + decimals: 2, + minimum: 10, + maximum: 1000, + }, + minimumUsd: 10, + maximumUsd: 1000, + }; + + expect(hasCanonicalPaymentMethod(node)).toBe(true); + }); + + 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, + popupName: exchangeId.toLowerCase(), + legacyQrPlaceholderDensity, + }); + }, + ); +}); diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts index 626fbb0c..feaf2e35 100644 --- a/packages/sdk/src/web/api/navTree.ts +++ b/packages/sdk/src/web/api/navTree.ts @@ -3,7 +3,8 @@ import type { AccountRail, DepositPaymentInteraction, } from "../../common/account.js"; -import type { ExchangeId } from "../../common/api.js"; +import type { ActionPaymentMethodId, ExchangeId } from "../../common/api.js"; +import type { PaymentMethodCategory } from "../../common/paymentMethod.js"; import type { SessionPublicInfo } from "../../common/session.js"; /** Session plus server-defined modal navigation data. */ @@ -89,31 +90,102 @@ 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; +}; + +/** + * A payment method whose next step is returned as a PaymentAction. + * + * The method ID describes what the customer selected. The response action + * separately describes how the SDK should execute it. + */ +export type NavNodePaymentMethod = NavNodeCommon & { + type: "PaymentMethod"; + methodId: ActionPaymentMethodId; + category: PaymentMethodCategory; + countryCode?: string; + icon?: string; + sourceAmount: NavSourceAmount; +}; + +/** @deprecated Old servers emit provider-shaped navigation nodes. */ export type NavNodeExchange = NavNodeCommon & { type: "Exchange"; exchangeId: ExchangeId; + /** Canonical identity emitted by upgraded servers. */ + methodId?: ActionPaymentMethodId; + /** Canonical product metadata emitted by upgraded servers. */ + category?: PaymentMethodCategory; + /** ISO-3166 alpha-2 country to echo when initiating this payment. */ + countryCode?: string; 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; }; +/** @deprecated Old servers emit provider-shaped navigation nodes. */ export type NavNodeCashApp = NavNodeCommon & { type: "CashApp"; + /** Canonical identity emitted by upgraded servers. */ + methodId?: "CashApp"; + /** Canonical product metadata emitted by upgraded servers. */ + category?: "paymentApp"; + /** ISO-3166 alpha-2 country to echo when initiating this payment. */ + countryCode?: string; 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; requiredUsd?: number; minimumUsd: number; maximumUsd: number; }; +/** @deprecated Old servers emit provider-shaped navigation nodes. */ export type NavNodeStripe = NavNodeCommon & { type: "Stripe"; + /** Canonical identity emitted by upgraded servers. */ + methodId?: "Stripe"; + /** Canonical product metadata emitted by upgraded servers. */ + category?: "onramp"; + /** ISO-3166 alpha-2 country to echo when initiating this payment. */ + countryCode?: string; icon?: string; + sourceAmount?: NavSourceAmount; requiredUsd?: number; minimumUsd: number; maximumUsd: number; }; +export type NavActionPaymentMethodNode = + | NavNodePaymentMethod + | NavNodeExchange + | NavNodeCashApp + | NavNodeStripe; + export type NavNodeTronDeposit = NavNodeCommon & { type: "TronDeposit"; icon?: string; @@ -142,9 +214,82 @@ export type NavNode = | NavNodeChooseOption | NavNodeDepositAddress | NavNodeDeeplink + | NavNodePaymentMethod | NavNodeExchange | NavNodeCashApp | NavNodeStripe | NavNodeTronDeposit | NavNodeConnectedWallet | NavNodeFiat; + +/** True when a compatibility node carries the canonical method contract. */ +export function hasCanonicalPaymentMethod( + node: NavActionPaymentMethodNode, +): node is NavActionPaymentMethodNode & { + methodId: ActionPaymentMethodId; + category: PaymentMethodCategory; +} { + return node.type === "PaymentMethod" || node.methodId != null; +} + +/** Compatibility fallback for nav trees produced by pre-sourceAmount servers. */ +export function getNavSourceAmount( + node: NavActionPaymentMethodNode, +): NavSourceAmount { + if (node.type === "PaymentMethod") 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: NavNodeExchange | NavNodeCashApp, +): NavExternalHandoff & { + legacyQrPlaceholderDensity?: "short" | "medium" | "long"; +} { + if (node.externalHandoff != null) return node.externalHandoff; + return getLegacyNavExternalHandoff(node); +} + +/** 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 payment method amount"); + } + return amount.toFixed(sourceAmount.decimals); +} + +/** 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", + popupName: exchangeId.toLowerCase(), + legacyQrPlaceholderDensity: + exchangeId === "Binance" || + exchangeId === "BinanceUSDC" || + exchangeId === "BinanceUSDT" + ? "medium" + : "short", + }; +} diff --git a/packages/sdk/src/web/components/DaimoModal.tsx b/packages/sdk/src/web/components/DaimoModal.tsx index c0816322..0e0f51c1 100644 --- a/packages/sdk/src/web/components/DaimoModal.tsx +++ b/packages/sdk/src/web/components/DaimoModal.tsx @@ -19,15 +19,14 @@ import type { NavLocationOption, RecreateSessionWithNavResponse, } from "../api/index.js"; +import { getNavSourceAmount } from "../api/navTree.js"; import type { + NavActionPaymentMethodNode, NavNode, - NavNodeCashApp, NavNodeChooseOption, NavNodeConnectedWallet, NavNodeDeeplink, NavNodeDepositAddress, - NavNodeExchange, - NavNodeStripe, NavNodeTronDeposit, SessionWithNav, } from "../api/navTree.js"; @@ -67,7 +66,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 { PaymentActionPage } from "./PaymentActionPage.js"; import { ExpiredPage } from "./ExpiredPage.js"; import { AccountPaymentInstructionsPage } from "./account/AccountBankDetailsPage.js"; import { AccountInstitutionPickerPage } from "./account/AccountBankPickerPage.js"; @@ -93,7 +92,6 @@ import { } from "./account/accountNav.js"; import { SelectAmountPage } from "./SelectAmountPage.js"; import { SelectTokenPage } from "./SelectTokenPage.js"; -import { StripeOnrampPage } from "./StripeOnrampPage.js"; import { CenteredContent, ContactSupportButton, @@ -108,8 +106,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,10 +817,8 @@ function renderEntry( return renderWaitingDeposit(entry, ctx); case "waiting-tron": return renderWaitingTron(entry, ctx); - case "exchange-page": - return renderExchangePage(entry, ctx); - case "stripe-onramp": - return renderStripeOnramp(entry, ctx); + case "payment-method": + return renderPaymentMethod(entry, ctx); case "wallet-choose-chain": return ( ); } - if (entry.flowType === "exchange") { - const exchangeNode = node as NavNodeExchange; - return ( - - ); - } - if (entry.flowType === "cashapp") { - const cashAppNode = node as NavNodeCashApp; - return ( - - ); - } - if (entry.flowType === "stripe") { - const stripeNode = node as NavNodeStripe; + if (entry.flowType === "payment-method") { + if ( + node.type !== "PaymentMethod" && + node.type !== "Exchange" && + node.type !== "CashApp" && + node.type !== "Stripe" + ) { + return null; + } + const sourceAmount = getNavSourceAmount(node); return ( ); return ( - - ); -} - -function renderStripeOnramp( - entry: NavEntry & { type: "stripe-onramp" }, - ctx: RenderContext, -): React.ReactNode { - const node = findNode( - entry.nodeId, - ctx.session.navTree, - ) as NavNodeStripe | null; - if (!node) return null; - - return ( - ); diff --git a/packages/sdk/src/web/components/ExchangePage.tsx b/packages/sdk/src/web/components/ExchangePage.tsx deleted file mode 100644 index 34487fb4..00000000 --- a/packages/sdk/src/web/components/ExchangePage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { NavNodeCashApp, NavNodeExchange } from "../api/navTree.js"; - -import { t } from "../hooks/locale.js"; -import { isDesktop, type DaimoPlatform } from "../platform.js"; -import { ExternalPaymentPage } from "./ExternalPaymentPage.js"; - -type ExchangePageProps = { - node: NavNodeExchange | NavNodeCashApp; - platform: DaimoPlatform; - exchangeUrl?: string; - waitingMessage?: string; - expiresAt?: number; - isLoading?: boolean; - onBack: () => 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/ExternalPaymentPage.tsx b/packages/sdk/src/web/components/HostedPaymentPage.tsx similarity index 78% rename from packages/sdk/src/web/components/ExternalPaymentPage.tsx rename to packages/sdk/src/web/components/HostedPaymentPage.tsx index 4f2ae847..dc38db8c 100644 --- a/packages/sdk/src/web/components/ExternalPaymentPage.tsx +++ b/packages/sdk/src/web/components/HostedPaymentPage.tsx @@ -15,7 +15,13 @@ import { type QRDensity = "short" | "medium" | "long"; type DesktopBehavior = "popup" | "qr"; -type ExternalPaymentPageProps = { +type HostedPaymentDetail = { + id: string; + label: string; + value: string; +}; + +type HostedPaymentPageProps = { title: string; platform: DaimoPlatform; url?: string; @@ -29,6 +35,7 @@ type ExternalPaymentPageProps = { popupName?: string; popupFeatures?: string; placeholderDensity?: QRDensity; + details?: HostedPaymentDetail[]; /** Called with the opened window (null when blocked by the browser). */ onOpened?: (popup: Window | null) => void; }; @@ -36,7 +43,7 @@ type ExternalPaymentPageProps = { const DEFAULT_POPUP_FEATURES = "width=500,height=700"; /** Shared handoff page for providers that complete payment outside the modal. */ -export function ExternalPaymentPage({ +export function HostedPaymentPage({ title, platform, url, @@ -50,8 +57,9 @@ export function ExternalPaymentPage({ popupName, popupFeatures = DEFAULT_POPUP_FEATURES, placeholderDensity, + details, onOpened, -}: ExternalPaymentPageProps) { +}: HostedPaymentPageProps) { const desktop = isDesktop(platform); const showQR = desktop && desktopBehavior === "qr"; @@ -111,6 +119,23 @@ export function ExternalPaymentPage({ {message}

)} + {details != null && details.length > 0 && ( +
+ {details.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+ )} {!showQR && ( void; + baseUrl: string; +}; + +export function PaymentActionPage({ + node, + platform, + sourceAmount, + action, + isLoading, + onBack, + baseUrl, +}: PaymentActionPageProps) { + const openUrl = action?.type === "openUrl" ? action : undefined; + const embeddedWidget = action?.type === "embeddedWidget" ? action : undefined; + const legacyHandoff = + node.type === "Exchange" || node.type === "CashApp" + ? getNavExternalHandoff(node) + : undefined; + const presentation = + openUrl?.presentation ?? legacyHandoff?.desktopBehavior ?? "popup"; + const usesDesktopQR = isDesktop(platform) && presentation === "qr"; + const url = openUrl?.url ?? embeddedWidget?.fallbackUrl; + const sourcePolicy = getNavSourceAmount(node); + const sourceUnits = formatFixedAmount(sourceAmount, sourcePolicy.decimals); + const formattedSourceAmount = `${sourcePolicy.currencySymbol}${sourceUnits} ${sourcePolicy.currency}`; + const placeholderDensity = legacyHandoff?.legacyQrPlaceholderDensity; + const popupName = + openUrl?.popupName ?? + (node.type === "Exchange" + ? node.exchangeId.toLowerCase() + : node.type === "CashApp" + ? "cashapp" + : node.type === "Stripe" + ? "stripe" + : embeddedWidget?.sdk ?? node.id.toLowerCase()); + + 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/SelectAmountPage.tsx b/packages/sdk/src/web/components/SelectAmountPage.tsx index 54d6ac3b..4278c6b4 100644 --- a/packages/sdk/src/web/components/SelectAmountPage.tsx +++ b/packages/sdk/src/web/components/SelectAmountPage.tsx @@ -14,15 +14,19 @@ import { type SelectAmountPageProps = { node: NavNodeDepositAddress | { icon?: string; title: string }; - minimumUsd: number; - maximumUsd: number; + minimum: number; + maximum: number; + /** Maximum source-currency decimal places. Defaults to 2. */ + decimals?: number; + /** Fiat currency symbol shown before the amount. Defaults to USD. */ + currencySymbol?: string; /** Token suffix for display (e.g., "USDC", "USDT") */ tokenSuffix?: string; /** Chain ID for token badge display */ chainId?: number; /** Optional back handler. If undefined, back button is hidden. */ onBack?: () => 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/StripeOnrampPage.tsx b/packages/sdk/src/web/components/StripeOnrampPage.tsx deleted file mode 100644 index 2ee0dcd7..00000000 --- a/packages/sdk/src/web/components/StripeOnrampPage.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { NavNodeStripe } from "../api/navTree.js"; -import { formatFixedAmount } from "../formatAmount.js"; -import { t } from "../hooks/locale.js"; -import type { DaimoPlatform } from "../platform.js"; -import { ErrorPage } from "./ErrorPage.js"; -import { ExternalPaymentPage } from "./ExternalPaymentPage.js"; - -type StripeOnrampPageProps = { - node: NavNodeStripe; - platform: DaimoPlatform; - amountUsd: number; - redirectUrl?: string; - isLoading?: boolean; - error?: string; - onBack: () => void; - onRetry?: () => void; - baseUrl: string; -}; - -export function StripeOnrampPage({ - node, - platform, - amountUsd, - redirectUrl, - isLoading, - error, - onBack, - onRetry, - baseUrl, -}: StripeOnrampPageProps) { - if (error) { - return ( - window.location.reload())} - /> - ); - } - - return ( - - ); -} diff --git a/packages/sdk/src/web/components/account/FiatPopupPage.tsx b/packages/sdk/src/web/components/account/FiatPopupPage.tsx index 05e4413c..8b57a4ed 100644 --- a/packages/sdk/src/web/components/account/FiatPopupPage.tsx +++ b/packages/sdk/src/web/components/account/FiatPopupPage.tsx @@ -5,7 +5,7 @@ import { useDaimoClient } from "../../hooks/DaimoClientContext.js"; import { t } from "../../hooks/locale.js"; import { createNavLogger } from "../../hooks/navEvent.js"; import type { DaimoPlatform } from "../../platform.js"; -import { ExternalPaymentPage } from "../ExternalPaymentPage.js"; +import { HostedPaymentPage } from "../HostedPaymentPage.js"; import { buildFiatPopupUrl, FIAT_POPUP_FEATURES, @@ -65,7 +65,7 @@ export function FiatPopupPage({ }; return ( - ) => { 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..c6326399 100644 --- a/packages/sdk/src/web/hooks/locales/en.ts +++ b/packages/sdk/src/web/hooks/locales/en.ts @@ -23,11 +23,17 @@ export const en = { mobileWallets: "Mobile Wallets", scanWithPhone: "Scan with your phone to open wallet", - // ExchangePage + // PaymentActionPage continueTo: "Continue to", toCompleteYourDeposit: "to complete your deposit", + depositExactlyWith: (amount: string, provider: string) => + `Deposit exactly ${amount} with ${provider}, then return to this page.`, 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..39d6a9d3 100644 --- a/packages/sdk/src/web/hooks/locales/es.ts +++ b/packages/sdk/src/web/hooks/locales/es.ts @@ -21,8 +21,14 @@ export const es: typeof en = { scanWithPhone: "Escanea con tu teléfono para abrir la billetera", continueTo: "Continuar a", toCompleteYourDeposit: "para completar tu depósito", + depositExactlyWith: (amount: string, provider: string) => + `Deposita exactamente ${amount} con ${provider} y luego vuelve a esta página.`, 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..6577c347 100644 --- a/packages/sdk/src/web/hooks/locales/ja.ts +++ b/packages/sdk/src/web/hooks/locales/ja.ts @@ -25,11 +25,17 @@ export const ja: typeof en = { mobileWallets: "モバイルウォレット", scanWithPhone: "スマートフォンでスキャンしてウォレットを開く", - // ExchangePage + // PaymentActionPage continueTo: "", toCompleteYourDeposit: "で入金を完了してください", + depositExactlyWith: (amount: string, provider: string) => + `${provider}で正確に${amount}を入金してから、このページに戻ってください。`, 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..6246f020 100644 --- a/packages/sdk/src/web/hooks/locales/ko.ts +++ b/packages/sdk/src/web/hooks/locales/ko.ts @@ -25,11 +25,17 @@ export const ko: typeof en = { mobileWallets: "모바일 지갑", scanWithPhone: "휴대폰으로 스캔하여 지갑 열기", - // ExchangePage + // PaymentActionPage continueTo: "", toCompleteYourDeposit: "에서 입금을 완료하세요", + depositExactlyWith: (amount: string, provider: string) => + `${provider}에서 정확히 ${amount}를 입금한 후 이 페이지로 돌아오세요.`, 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..b6bb5f9d 100644 --- a/packages/sdk/src/web/hooks/locales/pt.ts +++ b/packages/sdk/src/web/hooks/locales/pt.ts @@ -25,11 +25,17 @@ export const pt: typeof en = { mobileWallets: "Carteiras móveis", scanWithPhone: "Escaneie com seu telefone para abrir a carteira", - // ExchangePage + // PaymentActionPage continueTo: "Continuar para", toCompleteYourDeposit: "para concluir seu depósito", + depositExactlyWith: (amount: string, provider: string) => + `Deposite exatamente ${amount} com ${provider} e depois volte a esta página.`, 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..4a828422 100644 --- a/packages/sdk/src/web/hooks/locales/zh.ts +++ b/packages/sdk/src/web/hooks/locales/zh.ts @@ -25,11 +25,17 @@ export const zh: typeof en = { mobileWallets: "移动钱包", scanWithPhone: "用手机扫码打开钱包", - // ExchangePage + // PaymentActionPage continueTo: "前往", toCompleteYourDeposit: "以完成充值", + depositExactlyWith: (amount: string, provider: string) => + `请使用${provider}准确存入${amount},然后返回此页面。`, 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..094aebf1 100644 --- a/packages/sdk/src/web/hooks/navEvent.ts +++ b/packages/sdk/src/web/hooks/navEvent.ts @@ -17,13 +17,18 @@ 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_payment_action"; + paymentMethodId: string; + actionType?: "openUrl" | "embeddedWidget"; success: boolean; - url?: string; error?: string; } | { @@ -32,11 +37,6 @@ export type NavEventAction = address?: string; error?: string; } - | { - action: "flow_stripe_onramp"; - success: boolean; - error?: string; - } | { action: "popup_open"; reopen: boolean } | { action: "popup_blocked" } | { action: "qr_toggle"; visible: boolean } diff --git a/packages/sdk/src/web/hooks/types.ts b/packages/sdk/src/web/hooks/types.ts index 39b9af9b..13fc43c8 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 { PaymentAction } from "../../common/api.js"; import type { NavNode, SessionWithNav } from "../api/navTree.js"; import type { WalletPaymentOption } from "../api/walletTypes.js"; @@ -26,7 +27,7 @@ export type NavEntry = | { type: "select-amount"; nodeId: string; - flowType: "deposit" | "tron" | "exchange" | "cashapp" | "stripe"; + flowType: "deposit" | "tron" | "payment-method"; autoNav?: boolean; } | { @@ -49,22 +50,10 @@ export type NavEntry = autoNav?: boolean; } | { - type: "exchange-page"; + type: "payment-method"; nodeId: string; - amountUsd: number; - exchangeUrl?: string; - waitingMessage?: string; - expiresAt?: number; - error?: string; - autoNav?: boolean; - } - | { - type: "stripe-onramp"; - nodeId: string; - amountUsd: number; - onrampSessionClientSecret?: string; - publishableKey?: string; - redirectUrl?: string; + sourceAmount: number; + action?: PaymentAction; error?: string; autoNav?: boolean; } diff --git a/packages/sdk/src/web/hooks/useSessionNav.ts b/packages/sdk/src/web/hooks/useSessionNav.ts index 8a5cb5c1..2a5f97f6 100644 --- a/packages/sdk/src/web/hooks/useSessionNav.ts +++ b/packages/sdk/src/web/hooks/useSessionNav.ts @@ -11,19 +11,28 @@ import type { AccountDepositStatus, AccountRail, } from "../../common/account.js"; -import type { ExchangeId } from "../../common/api.js"; +import type { + ActionPaymentMethodId, + CreatePaymentMethodResponse, + CreatePaymentMethodRequest, + PaymentAction, +} from "../../common/api.js"; import type { AccountAuthConfig, DaimoCountryCode, RecreateSessionWithNavResponse, } from "../api/index.js"; +import { + formatNavSourceAmountUnits, + getNavExternalHandoff, + getNavSourceAmount, + hasCanonicalPaymentMethod, +} from "../api/navTree.js"; import type { + NavActionPaymentMethodNode, NavNode, - NavNodeCashApp, NavNodeChooseOption, - NavNodeExchange, NavNodeFiat, - NavNodeStripe, NavNodeTronDeposit, SessionWithNav, } from "../api/navTree.js"; @@ -55,7 +64,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 +74,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,26 +95,98 @@ type SessionNavResult = { handleAccountLogout: () => void; }; -function isExchangeNode(node: NavNode | null): node is ExchangeNode { - return node?.type === "Exchange" || node?.type === "CashApp"; -} - -function isStripeNode(node: NavNode | null): node is NavNodeStripe { - return node?.type === "Stripe"; +function isActionPaymentMethodNode( + node: NavNode | null, +): node is NavActionPaymentMethodNode { + return ( + node?.type === "PaymentMethod" || + node?.type === "Exchange" || + node?.type === "CashApp" || + node?.type === "Stripe" + ); } 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"; -} { - if (node.type === "CashApp") { - return { exchangeId: "CashApp", nodeType: "CashApp" }; +function getPaymentMethodId( + node: NavActionPaymentMethodNode, +): ActionPaymentMethodId { + if (hasCanonicalPaymentMethod(node)) return node.methodId; + if (node.type === "CashApp") return "CashApp"; + if (node.type === "Stripe") return "Stripe"; + return node.exchangeId; +} + +function getPaymentMethodRequest( + node: NavActionPaymentMethodNode, + amount: number, + platform: DaimoPlatform, +): CreatePaymentMethodRequest["paymentMethod"] { + if (hasCanonicalPaymentMethod(node)) { + const sourceAmount = getNavSourceAmount(node); + return { + id: node.methodId, + sourceAmount: { + units: formatNavSourceAmountUnits(amount, sourceAmount), + currency: sourceAmount.currency, + }, + countryCode: node.countryCode, + platform, + }; + } + if (node.type === "Stripe") return { type: "stripe", amountUsd: amount }; + return { + type: "exchange", + exchangeId: node.type === "CashApp" ? "CashApp" : node.exchangeId, + amountUsd: amount, + platform, + }; +} + +function getPaymentAction( + node: NavActionPaymentMethodNode, + result: CreatePaymentMethodResponse, +): PaymentAction { + if (result.action != null) { + const url = + result.action.type === "openUrl" + ? result.action.url + : result.action.fallbackUrl; + if (!url) throw new Error("payment action url not returned"); + return result.action; + } + if (node.type === "Stripe" && result.stripe != null) { + if (!result.stripe.redirectUrl) { + throw new Error("stripe onramp url not returned"); + } + return { + type: "embeddedWidget", + sdk: "stripeOnramp", + clientSecret: result.stripe.onrampSessionClientSecret, + publishableKey: result.stripe.publishableKey, + fallbackUrl: result.stripe.redirectUrl, + }; } - return { exchangeId: node.exchangeId, nodeType: "Exchange" }; + if ( + (node.type === "Exchange" || node.type === "CashApp") && + result.exchange != null + ) { + if (!result.exchange.url) { + throw new Error("exchange url not returned"); + } + const handoff = getNavExternalHandoff(node); + return { + type: "openUrl", + url: result.exchange.url, + presentation: handoff.desktopBehavior, + popupName: handoff.popupName, + waitingMessage: result.exchange.waitingMessage, + expiresAt: result.exchange.expiresAt, + }; + } + throw new Error("payment action not returned"); } function replacePendingAccountEntry( @@ -266,134 +346,70 @@ export function useSessionNav( ], ); - const fetchExchangeUrl = useCallback( + const fetchPaymentMethodAction = useCallback( async ( nodeId: string, - exchangeId: ExchangeId, - amountUsd: number, - nodeType: "Exchange" | "CashApp", + node: NavActionPaymentMethodNode, + amount: number, ) => { + const paymentMethodId = getPaymentMethodId(node); try { const result = await client.sessions.paymentMethods.create( session.sessionId, { clientSecret: session.clientSecret, - paymentMethod: { - type: "exchange", - exchangeId, - amountUsd, - platform: effectivePlatform, - }, - }, - ); - if (!result.exchange) return; - logNavEvent(session.sessionId, session.clientSecret, { - nodeId, - nodeType, - action: "flow_exchange_url", - exchangeId, - success: true, - url: result.exchange.url, - }); - setStack((prev) => { - const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page" || top.nodeId !== nodeId) - return prev; - return [ - ...prev.slice(0, -1), - { - ...top, - exchangeUrl: result.exchange!.url, - waitingMessage: result.exchange!.waitingMessage, - expiresAt: result.exchange!.expiresAt, - error: undefined, - }, - ]; - }); - } catch (error) { - console.error("failed to get exchange url:", error); - const errorMsg = - error instanceof Error ? error.message : "failed to get exchange url"; - logNavEvent(session.sessionId, session.clientSecret, { - nodeId, - nodeType, - action: "flow_exchange_url", - exchangeId, - success: false, - error: errorMsg, - }); - setStack((prev) => { - const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page" || top.nodeId !== nodeId) - return prev; - return [...prev.slice(0, -1), { ...top, error: errorMsg }]; - }); - } - }, - [session.sessionId, session.clientSecret, effectivePlatform, client], - ); - - const fetchStripeOnramp = useCallback( - async (nodeId: string, amountUsd: number) => { - try { - const result = await client.sessions.paymentMethods.create( - session.sessionId, - { - clientSecret: session.clientSecret, - paymentMethod: { type: "stripe", amountUsd }, + paymentMethod: getPaymentMethodRequest( + node, + amount, + effectivePlatform, + ), }, ); - if (!result.stripe) { - throw new Error("stripe onramp session not returned"); - } - if (!result.stripe.redirectUrl) { - throw new Error("stripe onramp url not returned"); - } - + const action = getPaymentAction(node, result); logNavEvent(session.sessionId, session.clientSecret, { nodeId, - nodeType: "Stripe", - action: "flow_stripe_onramp", + nodeType: node.type, + action: "flow_payment_action", + paymentMethodId, + actionType: action.type, success: true, }); setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "stripe-onramp" || top.nodeId !== nodeId) + if (top?.type !== "payment-method" || top.nodeId !== nodeId) return prev; return [ ...prev.slice(0, -1), { ...top, - onrampSessionClientSecret: - result.stripe!.onrampSessionClientSecret, - publishableKey: result.stripe!.publishableKey, - redirectUrl: result.stripe!.redirectUrl, + action, error: undefined, }, ]; }); } catch (error) { - console.error("failed to create stripe onramp:", error); + console.error("failed to create payment action:", error); const errorMsg = error instanceof Error ? error.message - : "failed to create stripe onramp"; + : "failed to create payment action"; logNavEvent(session.sessionId, session.clientSecret, { nodeId, - nodeType: "Stripe", - action: "flow_stripe_onramp", + nodeType: node.type, + action: "flow_payment_action", + paymentMethodId, success: false, error: errorMsg, }); setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "stripe-onramp" || top.nodeId !== nodeId) + if (top?.type !== "payment-method" || top.nodeId !== nodeId) return prev; return [...prev.slice(0, -1), { ...top, error: errorMsg }]; }); } }, - [session.sessionId, session.clientSecret, client], + [session.sessionId, session.clientSecret, effectivePlatform, client], ); // ─── Account deposit handler ──────────────────────────────────────────────── @@ -728,15 +744,19 @@ export function useSessionNav( return; } - if (isExchangeNode(targetNode)) { - const { exchangeId, nodeType } = getExchangeSelection(targetNode); - const requiredUsd = targetNode.requiredUsd ?? 0; - if (requiredUsd > 0) { + if (isActionPaymentMethodNode(targetNode)) { + const required = getNavSourceAmount(targetNode).required ?? 0; + if (required > 0) { setStack((prev) => [ ...prev, - { type: "exchange-page", nodeId, amountUsd: requiredUsd, autoNav }, + { + type: "payment-method", + nodeId, + sourceAmount: required, + autoNav, + }, ]); - fetchExchangeUrl(nodeId, exchangeId, requiredUsd, nodeType); + fetchPaymentMethodAction(nodeId, targetNode, required); return; } setStack((prev) => [ @@ -744,30 +764,13 @@ export function useSessionNav( { type: "select-amount", nodeId, - flowType: targetNode.type === "CashApp" ? "cashapp" : "exchange", + flowType: "payment-method", autoNav, }, ]); return; } - if (isStripeNode(targetNode)) { - const requiredUsd = targetNode.requiredUsd ?? 0; - if (requiredUsd > 0) { - setStack((prev) => [ - ...prev, - { type: "stripe-onramp", nodeId, amountUsd: requiredUsd, autoNav }, - ]); - fetchStripeOnramp(nodeId, requiredUsd); - return; - } - setStack((prev) => [ - ...prev, - { type: "select-amount", nodeId, flowType: "stripe", autoNav }, - ]); - return; - } - if (targetNode.type === "Fiat") { const popupRequired = enableFiatPopup && @@ -782,8 +785,7 @@ export function useSessionNav( session.sessionId, getNodeCtx, fetchTronAddress, - fetchExchangeUrl, - fetchStripeOnramp, + fetchPaymentMethodAction, handleAccountNavigate, enableFiatPopup, ], @@ -829,9 +831,23 @@ 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 = isActionPaymentMethodNode(selectedNode) + ? getNavSourceAmount(selectedNode) + : null; + const amountContext = + flowType === "payment-method" && selectedSourceAmount != null + ? { + sourceAmountUnits: formatNavSourceAmountUnits( + amount, + selectedSourceAmount, + ), + sourceCurrency: selectedSourceAmount.currency, + } + : { amountUsd: amount }; logNavEvent(session.sessionId, session.clientSecret, { nodeId, @@ -840,43 +856,29 @@ export function useSessionNav( ? "DepositAddress" : flowType === "tron" ? "TronDeposit" - : flowType === "cashapp" - ? "CashApp" - : flowType === "stripe" - ? "Stripe" - : "Exchange", + : (selectedNode?.type ?? null), 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 === "payment-method") { + if (!isActionPaymentMethodNode(selectedNode)) return; setStack((prev) => [ ...prev, - { type: "exchange-page", nodeId, amountUsd }, + { type: "payment-method", nodeId, sourceAmount: amount }, ]); - fetchExchangeUrl(nodeId, exchangeId, amountUsd, nodeType); - } else if (flowType === "stripe") { - const node = findNode(nodeId, session.navTree); - if (!isStripeNode(node)) return; - setStack((prev) => [ - ...prev, - { type: "stripe-onramp", nodeId, amountUsd }, - ]); - fetchStripeOnramp(nodeId, amountUsd); + fetchPaymentMethodAction(nodeId, selectedNode, amount); } }, [ @@ -884,8 +886,7 @@ export function useSessionNav( session.sessionId, session.navTree, fetchTronAddress, - fetchExchangeUrl, - fetchStripeOnramp, + fetchPaymentMethodAction, ], ); @@ -956,55 +957,28 @@ export function useSessionNav( return; } - if (topEntry.type === "exchange-page") { + if (topEntry.type === "payment-method") { const node = findNode(topEntry.nodeId, session.navTree); - if (!isExchangeNode(node)) return; - const { exchangeId, nodeType } = getExchangeSelection(node); - setStack((prev) => { - const top = prev[prev.length - 1]; - if (top?.type !== "exchange-page") return prev; - return [ - ...prev.slice(0, -1), - { - ...top, - exchangeUrl: undefined, - expiresAt: undefined, - error: undefined, - }, - ]; - }); - fetchExchangeUrl( - topEntry.nodeId, - exchangeId, - topEntry.amountUsd, - nodeType, - ); - return; - } - - if (topEntry.type === "stripe-onramp") { + if (!isActionPaymentMethodNode(node)) return; setStack((prev) => { const top = prev[prev.length - 1]; - if (top?.type !== "stripe-onramp") return prev; + if (top?.type !== "payment-method") return prev; return [ ...prev.slice(0, -1), { ...top, - onrampSessionClientSecret: undefined, - publishableKey: undefined, - redirectUrl: undefined, + action: undefined, error: undefined, }, ]; }); - fetchStripeOnramp(topEntry.nodeId, topEntry.amountUsd); + fetchPaymentMethodAction(topEntry.nodeId, node, topEntry.sourceAmount); } }, [ topEntry, session.navTree, fetchTronAddress, - fetchExchangeUrl, - fetchStripeOnramp, + fetchPaymentMethodAction, doWalletSend, ]);