Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 141 additions & 16 deletions packages/sdk/src/common/api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
42 changes: 41 additions & 1 deletion packages/sdk/src/common/api.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<typeof zCheckSessionRequest>;

export type TokenOptionsRequest = z.output<typeof zTokenOptionsRequest>;
Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand All @@ -143,6 +176,13 @@ export type CreatePaymentMethodResponse = {
};
};

export type {
ExternalPayment,
ExternalPaymentQuote,
ExternalPaymentMethodId,
Money,
} from "./externalPayment.js";

export type CheckSessionResponse = {
/** Current session state. */
session: SessionPublicInfo;
Expand Down
34 changes: 34 additions & 0 deletions packages/sdk/src/common/externalPayment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from "zod";

export const zExternalPaymentMethodId = z.enum(["CashApp", "Revolut"]);

export type ExternalPaymentMethodId = z.infer<typeof zExternalPaymentMethodId>;

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;
}[];
};
1 change: 1 addition & 0 deletions packages/sdk/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 13 additions & 1 deletion packages/sdk/src/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/web/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ export type {
NavNodeDeeplink,
NavNodeDepositAddress,
NavNodeExchange,
NavExternalPaymentNode,
NavExternalHandoff,
NavNodeExternalPayment,
NavNodeFiat,
NavNodeKycRequirement,
NavNodeKycRequirementDisplayItem,
NavNodeKycRequirementIcon,
NavNodeKycRequirementItem,
NavNodeKycRequirementKind,
NavNodeTronDeposit,
NavSourceAmount,
SessionNavInfo,
SessionWithNav,
} from "./navTree.js";
Expand Down
Loading