From f07c6ac868a6ffd8e8d81d54e3e4d68323fb5293 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:19:06 +0200 Subject: [PATCH 1/2] feat(core,react): add the payment link pay flow contract --- .../src/__tests__/payment-links-api.test.ts | 153 ++++++++++++++++++ packages/core/src/client/PaymentLinksApi.ts | 40 +++++ packages/core/src/definitions/index.ts | 15 ++ packages/core/src/definitions/route.ts | 135 ++++++++++++++++ packages/core/src/index.ts | 15 ++ packages/react/src/definitions/route.ts | 15 ++ packages/react/src/index.ts | 15 ++ 7 files changed, 388 insertions(+) create mode 100644 packages/core/src/__tests__/payment-links-api.test.ts diff --git a/packages/core/src/__tests__/payment-links-api.test.ts b/packages/core/src/__tests__/payment-links-api.test.ts new file mode 100644 index 00000000..d57fbec7 --- /dev/null +++ b/packages/core/src/__tests__/payment-links-api.test.ts @@ -0,0 +1,153 @@ +import { DfxHttpClient } from '../client/DfxHttpClient'; +import { PaymentLinksApi } from '../client/PaymentLinksApi'; +import { + PaymentLinkMode, + PaymentLinkPayResponse, + PaymentLinkPaymentStatus, + PaymentStandardType, + hasPaymentQuote, +} from '../definitions/route'; + +function createMockHttpClient(response?: any) { + const requestMock = jest.fn().mockResolvedValue(response); + + return { + request: requestMock, + requestAbsolute: jest.fn(), + getBaseUrl: jest.fn().mockReturnValue('https://api.dfx.swiss'), + getApiUrl: jest.fn().mockReturnValue('https://api.dfx.swiss/v1'), + setToken: jest.fn(), + getToken: jest.fn(), + } as unknown as DfxHttpClient & { request: jest.Mock }; +} + +const requestBase = { + id: 'pl_1', + displayName: 'Shop', + standard: PaymentStandardType.OPEN_CRYPTO_PAY, + possibleStandards: [PaymentStandardType.OPEN_CRYPTO_PAY], + displayQr: true, + recipient: { name: 'Shop' }, + mode: PaymentLinkMode.SINGLE, + transferAmounts: [], +}; + +describe('PaymentLinksApi pay flow', () => { + describe('getStandards', () => { + it('reads the public standard list without a token', async () => { + const mockHttp = createMockHttpClient([]); + const api = new PaymentLinksApi(mockHttp); + + await api.getStandards(); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/standard', + method: 'GET', + token: false, + }); + }); + + it('reads a single standard by id', async () => { + const mockHttp = createMockHttpClient({}); + const api = new PaymentLinksApi(mockHttp); + + await api.getStandard(PaymentStandardType.PAY_TO_ADDRESS); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/standard/PayToAddress', + method: 'GET', + token: false, + }); + }); + }); + + describe('waitForPayment', () => { + it('passes the link identification as query parameters', async () => { + const mockHttp = createMockHttpClient({}); + const api = new PaymentLinksApi(mockHttp); + + await api.waitForPayment({ externalLinkId: 'ext 1', key: 'k' }); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/payment/wait?externalLinkId=ext%201&key=k', + method: 'GET', + }); + }); + + it('omits parameters that were not set', async () => { + const mockHttp = createMockHttpClient({}); + const api = new PaymentLinksApi(mockHttp); + + await api.waitForPayment({ linkId: '7' }); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/payment/wait?linkId=7', + method: 'GET', + }); + }); + }); + + describe('confirmPayment', () => { + it('confirms via PUT', async () => { + const mockHttp = createMockHttpClient({}); + const api = new PaymentLinksApi(mockHttp); + + await api.confirmPayment({ externalPaymentId: 'p1', key: 'k' }); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/payment/confirm?externalPaymentId=p1&key=k', + method: 'PUT', + }); + }); + }); + + describe('getHistory', () => { + it('sends statuses as a comma separated list and dates in ISO form', async () => { + const mockHttp = createMockHttpClient([]); + const api = new PaymentLinksApi(mockHttp); + + await api.getHistory({ + externalLinkId: 'ext1', + status: [PaymentLinkPaymentStatus.PENDING, PaymentLinkPaymentStatus.COMPLETED], + from: new Date('2026-01-01T00:00:00.000Z'), + }); + + expect(mockHttp.request).toHaveBeenCalledWith({ + url: 'paymentLink/history?externalLinkId=ext1&status=Pending,Completed&from=2026-01-01T00%3A00%3A00.000Z', + method: 'GET', + }); + }); + + it('sends no query at all for an empty filter', async () => { + const mockHttp = createMockHttpClient([]); + const api = new PaymentLinksApi(mockHttp); + + await api.getHistory({}); + + expect(mockHttp.request).toHaveBeenCalledWith({ url: 'paymentLink/history', method: 'GET' }); + }); + }); +}); + +describe('hasPaymentQuote', () => { + it('is true for a quoted pay request', () => { + const response: PaymentLinkPayResponse = { + ...requestBase, + tag: 'payRequest', + callback: 'https://example.com/callback', + metadata: '[]', + minSendable: 1, + maxSendable: 2, + quote: { id: 'q1', expiration: new Date(), payment: 'pay1' }, + requestedAmount: { asset: 'CHF', amount: 1 }, + }; + + expect(hasPaymentQuote(response)).toBe(true); + }); + + it('is false for an idle terminal response', () => { + const response: PaymentLinkPayResponse = { ...requestBase, error: 'No pending payment', statusCode: 404 }; + + expect(hasPaymentQuote(response)).toBe(false); + }); +}); diff --git a/packages/core/src/client/PaymentLinksApi.ts b/packages/core/src/client/PaymentLinksApi.ts index 34e1e8f9..161e6260 100644 --- a/packages/core/src/client/PaymentLinksApi.ts +++ b/packages/core/src/client/PaymentLinksApi.ts @@ -5,10 +5,15 @@ import { UpdatePaymentLink, AssignPaymentLink, CreatePaymentLinkPayment, + PaymentLinkHistory, + PaymentLinkHistoryQuery, + PaymentLinkPaymentQuery, PaymentLinkRecipient, PaymentLinkConfig, UpdatePaymentLinkConfig, PaymentLinkPos, + PaymentStandard, + PaymentStandardType, } from '../definitions/route'; import { CustomFile } from '../definitions/file'; import { Utils } from '../utils'; @@ -75,4 +80,39 @@ export class PaymentLinksApi { const query = Utils.buildQuery(params); return this.http.request({ url: `${PaymentLinksUrl.pos}${query}`, method: 'PUT' }); } + + /** The payment standards the API supports. Public - no session needed. */ + async getStandards(): Promise { + return this.http.request({ url: PaymentLinksUrl.standard, method: 'GET', token: false }); + } + + async getStandard(id: PaymentStandardType): Promise { + return this.http.request({ + url: PaymentLinksUrl.standardById(id), + method: 'GET', + token: false, + }); + } + + /** + * Long-polls until the payment reaches a final state, then returns the link with that payment. + * + * The request stays open for as long as the API keeps it open, and it resolves on a timeout as + * well - read the returned status instead of assuming the payment moved. + */ + async waitForPayment(params: PaymentLinkPaymentQuery): Promise { + const query = Utils.buildQuery({ ...params }); + return this.http.request({ url: `${PaymentLinksUrl.paymentWait}${query}`, method: 'GET' }); + } + + async confirmPayment(params: PaymentLinkPaymentQuery): Promise { + const query = Utils.buildQuery({ ...params }); + return this.http.request({ url: `${PaymentLinksUrl.paymentConfirm}${query}`, method: 'PUT' }); + } + + /** Payments of a link within a period. Without `status` the API returns completed payments only. */ + async getHistory(params: PaymentLinkHistoryQuery): Promise { + const query = Utils.buildQuery({ ...params }); + return this.http.request({ url: `${PaymentLinksUrl.history}${query}`, method: 'GET' }); + } } diff --git a/packages/core/src/definitions/index.ts b/packages/core/src/definitions/index.ts index 48e7e7f6..ad55cafe 100644 --- a/packages/core/src/definitions/index.ts +++ b/packages/core/src/definitions/index.ts @@ -115,6 +115,8 @@ export { PaymentQuoteStatus, MinCompletionStatus, PaymentLinkBlockchain, + C2BPaymentMethod, + hasPaymentQuote, } from './route'; export type { MinAmount, @@ -136,6 +138,19 @@ export type { UpdatePaymentLink, AssignPaymentLink, PaymentLinkPos, + PaymentStandard, + TransferMethod, + TransferAmount, + PaymentAmount, + PaymentQuote, + PaymentLinkRequestBase, + PaymentLinkPayRequest, + PaymentLinkPayTerminal, + PaymentLinkPayResponse, + PaymentLinkHistory, + PaymentLinkHistoryPayment, + PaymentLinkPaymentQuery, + PaymentLinkHistoryQuery, } from './route'; export { SellUrl } from './sell'; export type { Eip5792Call, Eip5792Data, UnsignedTx, Sell, Beneficiary, SellPaymentInfo, ConfirmSellData } from './sell'; diff --git a/packages/core/src/definitions/route.ts b/packages/core/src/definitions/route.ts index a4c7718f..e438e5ba 100644 --- a/packages/core/src/definitions/route.ts +++ b/packages/core/src/definitions/route.ts @@ -9,6 +9,11 @@ export const PaymentLinksUrl = { update: 'paymentLink', assign: 'paymentLink/assign', payment: 'paymentLink/payment', + paymentWait: 'paymentLink/payment/wait', + paymentConfirm: 'paymentLink/payment/confirm', + history: 'paymentLink/history', + standard: 'paymentLink/standard', + standardById: (id: PaymentStandardType) => `paymentLink/standard/${id}`, userPaymentLinksConfig: 'paymentLink/config', recipient: (route: string) => `paymentLink/recipient?id=${route}`, stickers: 'paymentLink/stickers', @@ -227,3 +232,133 @@ export interface AssignPaymentLink { export interface PaymentLinkPos { url: string; } + +// --- PAY FLOW --- // + +/** Descriptor of a payment standard, as served by `paymentLink/standard`. */ +export interface PaymentStandard { + id: PaymentStandardType; + label: string; + description: string; + paymentIdentifierLabel?: string; + blockchain?: Blockchain; +} + +/** Customer-to-business payment providers. Not blockchains, but usable as a transfer method. */ +export enum C2BPaymentMethod { + BINANCE_PAY = 'BinancePay', + KUCOIN_PAY = 'KucoinPay', +} + +export type TransferMethod = Blockchain | C2BPaymentMethod; + +export interface PaymentAmount { + asset: string; + /** Absent while no amount has been requested yet. */ + amount?: number; +} + +export interface TransferAmount { + method: TransferMethod; + minFee: number; + assets: PaymentAmount[]; + /** False when the method is currently not payable, e.g. for a missing balance. */ + available: boolean; +} + +export interface PaymentQuote { + id: string; + expiration: Date; + payment: string; +} + +/** + * Common part of every pay request response. A response either carries a quote + * (`PaymentLinkPayRequest`) or an error (`PaymentLinkPayTerminal`); use `hasPaymentQuote` to tell + * them apart. + */ +export interface PaymentLinkRequestBase { + id: string; + externalId?: string; + displayName: string; + standard: PaymentStandardType; + possibleStandards: PaymentStandardType[]; + displayQr: boolean; + recipient: PaymentLinkRecipient; + mode: PaymentLinkMode; + route?: string; + currency?: string; + transferAmounts: TransferAmount[]; +} + +/** A payable request: a payment is active and quoted. */ +export interface PaymentLinkPayRequest extends PaymentLinkRequestBase { + tag: string; + callback: string; + metadata: string; + minSendable: number; + maxSendable: number; + quote: PaymentQuote; + requestedAmount: PaymentAmount; +} + +/** + * A request without an active payment, e.g. an idle terminal. The link itself is described as + * usual, and the error fields say why nothing is payable. + */ +export interface PaymentLinkPayTerminal extends PaymentLinkRequestBase { + error?: string; + message?: string; + statusCode?: number; +} + +export type PaymentLinkPayResponse = PaymentLinkPayRequest | PaymentLinkPayTerminal; + +/** Narrows a pay request response to the quoted variant. */ +export function hasPaymentQuote(response: PaymentLinkPayResponse): response is PaymentLinkPayRequest { + return 'quote' in response; +} + +export interface PaymentLinkHistoryPayment { + id: number; + externalId?: string; + note?: string; + status: PaymentLinkPaymentStatus; + amount: number; + currency: string; + mode: PaymentLinkPaymentMode; + date: Date; + expiryDate: Date; + txCount: number; + isConfirmed: boolean; + url: string; + lnurl: string; +} + +/** A payment link with its payments, as served by `paymentLink/history`. Carries no single `payment`. */ +export interface PaymentLinkHistory extends Omit { + payments: PaymentLinkHistoryPayment[]; + totalCompletedAmount: number; +} + +/** Identifies a payment link, or a payment on it. The API needs at least one of these. */ +export interface PaymentLinkPaymentQuery { + linkId?: string; + externalLinkId?: string; + externalPaymentId?: string; + /** Payment link access key, for terminals that hold no session. */ + key?: string; + route?: string; +} + +export interface PaymentLinkHistoryQuery { + linkId?: string; + externalLinkId?: string; + key?: string; + /** Defaults to completed payments only when omitted. */ + status?: PaymentLinkPaymentStatus[]; + /** Defaults to the first day of the current month when omitted. */ + from?: Date; + /** Defaults to the last day of the current month when omitted. */ + to?: Date; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5bcfe80d..6f26ff74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,6 +90,8 @@ export { PaymentQuoteStatus, MinCompletionStatus, PaymentLinkBlockchain, + C2BPaymentMethod, + hasPaymentQuote, // Sell SellUrl, // Settings @@ -216,6 +218,19 @@ export type { UpdatePaymentLink, AssignPaymentLink, PaymentLinkPos, + PaymentStandard, + TransferMethod, + TransferAmount, + PaymentAmount, + PaymentQuote, + PaymentLinkRequestBase, + PaymentLinkPayRequest, + PaymentLinkPayTerminal, + PaymentLinkPayResponse, + PaymentLinkHistory, + PaymentLinkHistoryPayment, + PaymentLinkPaymentQuery, + PaymentLinkHistoryQuery, // Sell Eip5792Call, Eip5792Data, diff --git a/packages/react/src/definitions/route.ts b/packages/react/src/definitions/route.ts index 288344a5..abeb7752 100644 --- a/packages/react/src/definitions/route.ts +++ b/packages/react/src/definitions/route.ts @@ -9,6 +9,8 @@ export { PaymentQuoteStatus, MinCompletionStatus, PaymentLinkBlockchain, + C2BPaymentMethod, + hasPaymentQuote, } from '@dfx.swiss/core'; export type { @@ -31,4 +33,17 @@ export type { UpdatePaymentLink, AssignPaymentLink, PaymentLinkPos, + PaymentStandard, + TransferMethod, + TransferAmount, + PaymentAmount, + PaymentQuote, + PaymentLinkRequestBase, + PaymentLinkPayRequest, + PaymentLinkPayTerminal, + PaymentLinkPayResponse, + PaymentLinkHistory, + PaymentLinkHistoryPayment, + PaymentLinkPaymentQuery, + PaymentLinkHistoryQuery, } from '@dfx.swiss/core'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e749ddb3..b817169f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -88,6 +88,21 @@ export { PaymentLinkPos, PaymentRoutesUrl, PaymentLinksUrl, + PaymentStandard, + C2BPaymentMethod, + TransferMethod, + TransferAmount, + PaymentAmount, + PaymentQuote, + PaymentLinkRequestBase, + PaymentLinkPayRequest, + PaymentLinkPayTerminal, + PaymentLinkPayResponse, + PaymentLinkHistory, + PaymentLinkHistoryPayment, + PaymentLinkPaymentQuery, + PaymentLinkHistoryQuery, + hasPaymentQuote, } from './definitions/route'; export { InfoBanner, SettingsUrl } from './definitions/settings'; export { PriceStep } from './definitions/price-step'; From 06bbcf7d7231020e79a8acbe4b600f2bbd626ebc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:28:16 +0200 Subject: [PATCH 2/2] fix(core): correct the payment link pay flow contract --- .../src/__tests__/payment-links-api.test.ts | 7 +++++- packages/core/src/definitions/index.ts | 1 + packages/core/src/definitions/route.ts | 24 ++++++++++++++----- packages/core/src/index.ts | 1 + packages/react/src/definitions/route.ts | 1 + packages/react/src/index.ts | 1 + 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/payment-links-api.test.ts b/packages/core/src/__tests__/payment-links-api.test.ts index d57fbec7..82b94b70 100644 --- a/packages/core/src/__tests__/payment-links-api.test.ts +++ b/packages/core/src/__tests__/payment-links-api.test.ts @@ -146,7 +146,12 @@ describe('hasPaymentQuote', () => { }); it('is false for an idle terminal response', () => { - const response: PaymentLinkPayResponse = { ...requestBase, error: 'No pending payment', statusCode: 404 }; + const response: PaymentLinkPayResponse = { + ...requestBase, + error: 'Not Found', + message: 'No pending payment', + statusCode: 404, + }; expect(hasPaymentQuote(response)).toBe(false); }); diff --git a/packages/core/src/definitions/index.ts b/packages/core/src/definitions/index.ts index ad55cafe..0455fc14 100644 --- a/packages/core/src/definitions/index.ts +++ b/packages/core/src/definitions/index.ts @@ -116,6 +116,7 @@ export { MinCompletionStatus, PaymentLinkBlockchain, C2BPaymentMethod, + ManualPaymentMethod, hasPaymentQuote, } from './route'; export type { diff --git a/packages/core/src/definitions/route.ts b/packages/core/src/definitions/route.ts index e438e5ba..c3b653b9 100644 --- a/packages/core/src/definitions/route.ts +++ b/packages/core/src/definitions/route.ts @@ -1,6 +1,7 @@ import { Asset } from './asset'; import { Blockchain } from './blockchain'; import { Fiat } from './fiat'; +import { GoodsCategory, GoodsType, MerchantCategory, StoreType } from './kyc'; export const PaymentRoutesUrl = { get: 'route' }; export const PaymentLinksUrl = { @@ -149,6 +150,7 @@ export interface PaymentLink { routeId: string; externalId?: string; label?: string; + webhookUrl?: string; recipient?: PaymentLinkRecipient; status: PaymentLinkStatus; mode: PaymentLinkMode; @@ -156,6 +158,7 @@ export interface PaymentLink { config?: PaymentLinkConfig; url: string; lnurl: string; + frontendUrl: string; } export interface PaymentLinkRecipient { @@ -164,6 +167,11 @@ export interface PaymentLinkRecipient { phone?: string; mail?: string; website?: string; + registrationNumber?: string; + storeType?: StoreType; + merchantCategory?: MerchantCategory; + goodsType?: GoodsType; + goodsCategory?: GoodsCategory; } export interface PaymentLinkRecipientAddress { @@ -250,7 +258,12 @@ export enum C2BPaymentMethod { KUCOIN_PAY = 'KucoinPay', } -export type TransferMethod = Blockchain | C2BPaymentMethod; +/** Methods that are settled by hand and are not blockchains of their own. */ +export enum ManualPaymentMethod { + TAPROOT_ASSET = 'TaprootAsset', +} + +export type TransferMethod = Blockchain | C2BPaymentMethod | ManualPaymentMethod; export interface PaymentAmount { asset: string; @@ -307,9 +320,9 @@ export interface PaymentLinkPayRequest extends PaymentLinkRequestBase { * usual, and the error fields say why nothing is payable. */ export interface PaymentLinkPayTerminal extends PaymentLinkRequestBase { - error?: string; - message?: string; - statusCode?: number; + error: string; + message: string; + statusCode: number; } export type PaymentLinkPayResponse = PaymentLinkPayRequest | PaymentLinkPayTerminal; @@ -333,6 +346,7 @@ export interface PaymentLinkHistoryPayment { isConfirmed: boolean; url: string; lnurl: string; + frontendUrl: string; } /** A payment link with its payments, as served by `paymentLink/history`. Carries no single `payment`. */ @@ -348,11 +362,9 @@ export interface PaymentLinkPaymentQuery { externalPaymentId?: string; /** Payment link access key, for terminals that hold no session. */ key?: string; - route?: string; } export interface PaymentLinkHistoryQuery { - linkId?: string; externalLinkId?: string; key?: string; /** Defaults to completed payments only when omitted. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f26ff74..9c143761 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,6 +91,7 @@ export { MinCompletionStatus, PaymentLinkBlockchain, C2BPaymentMethod, + ManualPaymentMethod, hasPaymentQuote, // Sell SellUrl, diff --git a/packages/react/src/definitions/route.ts b/packages/react/src/definitions/route.ts index abeb7752..0dd61dd5 100644 --- a/packages/react/src/definitions/route.ts +++ b/packages/react/src/definitions/route.ts @@ -10,6 +10,7 @@ export { MinCompletionStatus, PaymentLinkBlockchain, C2BPaymentMethod, + ManualPaymentMethod, hasPaymentQuote, } from '@dfx.swiss/core'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b817169f..44ba2f44 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -90,6 +90,7 @@ export { PaymentLinksUrl, PaymentStandard, C2BPaymentMethod, + ManualPaymentMethod, TransferMethod, TransferAmount, PaymentAmount,