From 32ff9588b304952da423c96d817a3565345cc610 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 5 Mar 2026 13:36:58 -0800 Subject: [PATCH] Add gift card transfer flow for cross-account fund movement --- app/features/gift-cards/gift-card-details.tsx | 3 +- .../transfer/transfer-confirmation.tsx | 134 ++++++++ app/features/transfer/transfer-hooks.ts | 78 +++++ app/features/transfer/transfer-input.tsx | 194 +++++++++++ app/features/transfer/transfer-provider.tsx | 53 +++ app/features/transfer/transfer-service.ts | 317 ++++++++++++++++++ app/features/transfer/transfer-store.ts | 69 ++++ ...ed.gift-cards_.$cardId.transfer._index.tsx | 5 + ...d.gift-cards_.$cardId.transfer.confirm.tsx | 15 + ...protected.gift-cards_.$cardId.transfer.tsx | 41 +++ 10 files changed, 907 insertions(+), 2 deletions(-) create mode 100644 app/features/transfer/transfer-confirmation.tsx create mode 100644 app/features/transfer/transfer-hooks.ts create mode 100644 app/features/transfer/transfer-input.tsx create mode 100644 app/features/transfer/transfer-provider.tsx create mode 100644 app/features/transfer/transfer-service.ts create mode 100644 app/features/transfer/transfer-store.ts create mode 100644 app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx create mode 100644 app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx create mode 100644 app/routes/_protected.gift-cards_.$cardId.transfer.tsx diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx index 5c019ed8..8959a1e1 100644 --- a/app/features/gift-cards/gift-card-details.tsx +++ b/app/features/gift-cards/gift-card-details.tsx @@ -152,8 +152,7 @@ export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) {
{ + return ( +
+

{label}

+
{value}
+
+ ); +}; + +type TransferConfirmationProps = { + transferQuote: TransferQuote; +}; + +export default function TransferConfirmation({ + transferQuote, +}: TransferConfirmationProps) { + const { toast } = useToast(); + const navigate = useNavigateWithViewTransition(); + + const destinationAccountId = useTransferStore((s) => s.destinationAccountId); + const sourceAccountId = useTransferStore((s) => s.sourceAccountId); + + const destinationAccount = useAccount(destinationAccountId); + const sourceAccount = useAccount(sourceAccountId); + + const { mutate: initiateTransfer, status: transferStatus } = + useInitiateTransfer({ + onSuccess: ({ receiveTransactionId }) => { + const params = new URLSearchParams({ + showOkButton: 'true', + redirectTo: `/gift-cards/${destinationAccount.id}`, + }); + navigate( + { + pathname: `/transactions/${receiveTransactionId}`, + search: params.toString(), + }, + { + transition: 'slideLeft', + applyTo: 'newView', + }, + ); + }, + onError: (error) => { + if (error instanceof DomainError) { + toast({ description: error.message }); + } else { + console.error('Failed to initiate transfer', { cause: error }); + toast({ + description: 'Transfer failed. Please try again.', + variant: 'destructive', + }); + } + }, + }); + + const handleConfirm = () => { + initiateTransfer({ + sourceAccount, + destinationAccount, + transferQuote, + }); + }; + + const isPending = ['pending', 'success'].includes(transferStatus); + + return ( + <> + + + Confirm + + + +
+ + + + } + /> + + } + /> + + + + +
+
+ + + + + ); +} diff --git a/app/features/transfer/transfer-hooks.ts b/app/features/transfer/transfer-hooks.ts new file mode 100644 index 00000000..e54976d5 --- /dev/null +++ b/app/features/transfer/transfer-hooks.ts @@ -0,0 +1,78 @@ +import { useMutation } from '@tanstack/react-query'; +import type { Money } from '~/lib/money'; +import type { Account } from '../accounts/account'; +import { ConcurrencyError, DomainError } from '../shared/error'; +import { useUser } from '../user/user-hooks'; +import type { TransferQuote } from './transfer-service'; +import { useTransferService } from './transfer-service'; + +export function useCreateTransferQuote() { + const transferService = useTransferService(); + + return useMutation({ + scope: { id: 'create-transfer-quote' }, + mutationFn: ({ + sourceAccount, + destinationAccount, + amount, + }: { + sourceAccount: Account; + destinationAccount: Account; + amount: Money; + }) => + transferService.getTransferQuote({ + sourceAccount, + destinationAccount, + amount, + }), + retry: (failureCount, error) => { + if (error instanceof DomainError) return false; + return failureCount < 1; + }, + }); +} + +export function useInitiateTransfer({ + onSuccess, + onError, +}: { + onSuccess: (data: { + sendTransactionId: string; + receiveTransactionId: string; + }) => void; + onError: (error: Error) => void; +}) { + const userId = useUser((user) => user.id); + const transferService = useTransferService(); + + return useMutation({ + scope: { id: 'initiate-transfer' }, + mutationFn: ({ + sourceAccount, + destinationAccount, + transferQuote, + }: { + sourceAccount: Account; + destinationAccount: Account; + transferQuote: TransferQuote; + }) => + transferService.initiateTransfer({ + userId, + sourceAccount, + destinationAccount, + transferQuote, + }), + onSuccess: (data) => { + onSuccess({ + sendTransactionId: data.sendTransactionId, + receiveTransactionId: data.receiveTransactionId, + }); + }, + onError, + retry: (failureCount, error) => { + if (error instanceof ConcurrencyError) return true; + if (error instanceof DomainError) return false; + return failureCount < 1; + }, + }); +} diff --git a/app/features/transfer/transfer-input.tsx b/app/features/transfer/transfer-input.tsx new file mode 100644 index 00000000..2016e494 --- /dev/null +++ b/app/features/transfer/transfer-input.tsx @@ -0,0 +1,194 @@ +import { getEncodedToken } from '@cashu/cashu-ts'; +import { Clipboard, Scan } from 'lucide-react'; +import { MoneyInputDisplay } from '~/components/money-display'; +import { Numpad } from '~/components/numpad'; +import { + ClosePageButton, + PageContent, + PageFooter, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher'; +import { getDefaultUnit } from '~/features/shared/currencies'; +import { DomainError } from '~/features/shared/error'; +import useAnimation from '~/hooks/use-animation'; +import { useMoneyInput } from '~/hooks/use-money-input'; +import { useToast } from '~/hooks/use-toast'; +import { extractCashuToken } from '~/lib/cashu'; +import type { Money } from '~/lib/money'; +import { readClipboard } from '~/lib/read-clipboard'; +import { + LinkWithViewTransition, + useNavigateWithViewTransition, +} from '~/lib/transitions'; +import { useAccount } from '../accounts/account-hooks'; +import { useTransferStore } from './transfer-provider'; + +export default function TransferInput() { + const navigate = useNavigateWithViewTransition(); + const { toast } = useToast(); + const { animationClass: shakeAnimationClass, start: startShakeAnimation } = + useAnimation({ name: 'shake' }); + + const destinationAccountId = useTransferStore((s) => s.destinationAccountId); + const getTransferQuote = useTransferStore((s) => s.getTransferQuote); + const status = useTransferStore((s) => s.status); + + const destinationAccount = useAccount(destinationAccountId); + + const { + rawInputValue, + maxInputDecimals, + inputValue, + convertedValue, + exchangeRateError, + handleNumberInput, + switchInputCurrency, + } = useMoneyInput({ + initialRawInputValue: '0', + initialInputCurrency: destinationAccount.currency, + initialOtherCurrency: destinationAccount.currency === 'BTC' ? 'USD' : 'BTC', + }); + + const handleContinue = async () => { + let amount: Money; + if (inputValue.currency === destinationAccount.currency) { + amount = inputValue; + } else { + if (!convertedValue) { + return; + } + amount = convertedValue; + } + + if (amount.isZero()) return; + + const result = await getTransferQuote(amount); + + if (!result.success) { + if (result.error instanceof DomainError) { + toast({ description: result.error.message }); + } else { + console.error('Failed to get transfer quote', { cause: result.error }); + toast({ + description: 'Failed to get quote. Please try again.', + variant: 'destructive', + }); + } + return; + } + + navigate('confirm', { + transition: 'slideLeft', + applyTo: 'newView', + }); + }; + + const handlePaste = async () => { + const clipboardContent = await readClipboard(); + if (!clipboardContent) { + return; + } + + const token = extractCashuToken(clipboardContent); + if (!token) { + toast({ + title: 'Invalid input', + description: 'Please paste a valid cashu token', + variant: 'destructive', + }); + return; + } + + const encodedToken = getEncodedToken(token); + const hash = `#${encodedToken}`; + + window.history.replaceState(null, '', hash); + navigate( + { + pathname: '/receive/cashu/token', + search: new URLSearchParams({ + selectedAccountId: destinationAccount.id, + redirectTo: `/gift-cards/${destinationAccount.id}`, + }).toString(), + hash, + }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + }; + + return ( + <> + + + Add + + + +
+
+ +
+ + {!exchangeRateError && ( + + )} +
+ +
+
+
+ + + + +
+
+
+ +
+
+
+ + + 0} + onButtonClick={(value) => { + handleNumberInput(value, startShakeAnimation); + }} + /> + + + ); +} diff --git a/app/features/transfer/transfer-provider.tsx b/app/features/transfer/transfer-provider.tsx new file mode 100644 index 00000000..a41d8f60 --- /dev/null +++ b/app/features/transfer/transfer-provider.tsx @@ -0,0 +1,53 @@ +import { + type PropsWithChildren, + createContext, + useContext, + useState, +} from 'react'; +import { useStore } from 'zustand'; +import type { Account } from '../accounts/account'; +import { useGetAccount } from '../accounts/account-hooks'; +import { useCreateTransferQuote } from './transfer-hooks'; +import type { TransferState, TransferStore } from './transfer-store'; +import { createTransferStore } from './transfer-store'; + +const TransferContext = createContext(null); + +type Props = PropsWithChildren<{ + sourceAccount: Account; + destinationAccount: Account; +}>; + +export const TransferProvider = ({ + children, + sourceAccount, + destinationAccount, +}: Props) => { + const getAccount = useGetAccount(); + const { mutateAsync: createTransferQuote } = useCreateTransferQuote(); + + const [store] = useState(() => + createTransferStore({ + sourceAccount, + destinationAccount, + getAccount, + createTransferQuote, + }), + ); + + return ( + + {children} + + ); +}; + +export const useTransferStore = ( + selector: (state: TransferState) => T, +): T => { + const store = useContext(TransferContext); + if (!store) { + throw new Error('Missing TransferProvider in the tree'); + } + return useStore(store, selector); +}; diff --git a/app/features/transfer/transfer-service.ts b/app/features/transfer/transfer-service.ts new file mode 100644 index 00000000..e0f149c8 --- /dev/null +++ b/app/features/transfer/transfer-service.ts @@ -0,0 +1,317 @@ +import { Money } from '~/lib/money'; +import type { Account, CashuAccount } from '../accounts/account'; +import { + canReceiveFromLightning, + canSendToLightning, +} from '../accounts/account'; +import type { AgicashDb } from '../agicash-db/database'; +import { agicashDbClient } from '../agicash-db/database.client'; +import type { CashuReceiveQuote } from '../receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from '../receive/cashu-receive-quote-core'; +import { + type CashuReceiveQuoteService, + useCashuReceiveQuoteService, +} from '../receive/cashu-receive-quote-service'; +import type { SparkReceiveQuote } from '../receive/spark-receive-quote'; +import { + type SparkReceiveLightningQuote, + getLightningQuote as getSparkLightningQuote, +} from '../receive/spark-receive-quote-core'; +import { + type SparkReceiveQuoteService, + useSparkReceiveQuoteService, +} from '../receive/spark-receive-quote-service'; +import type { CashuSendQuote } from '../send/cashu-send-quote'; +import { + type CashuLightningQuote, + type CashuSendQuoteService, + useCashuSendQuoteService, +} from '../send/cashu-send-quote-service'; +import type { SparkSendQuote } from '../send/spark-send-quote'; +import { + type SparkLightningQuote, + type SparkSendQuoteService, + useSparkSendQuoteService, +} from '../send/spark-send-quote-service'; +import { DomainError } from '../shared/error'; + +export type ReceiveLightningQuote = + | { type: 'cashu'; quote: CashuReceiveLightningQuote } + | { type: 'spark'; quote: SparkReceiveLightningQuote }; + +export type TransferQuote = { + receiveLightningQuote: ReceiveLightningQuote; + sendQuote: SparkLightningQuote | CashuLightningQuote; + sourceAccountId: string; + destinationAccountId: string; + amount: Money; + estimatedFee: Money; + estimatedTotal: Money; +}; + +type GetTransferQuoteParams = { + sourceAccount: Account; + destinationAccount: Account; + amount: Money; +}; + +type InitiateTransferParams = { + userId: string; + sourceAccount: Account; + destinationAccount: Account; + transferQuote: TransferQuote; +}; + +type InitiateTransferResult = { + sendTransactionId: string; + receiveTransactionId: string; + sendQuote: SparkSendQuote | CashuSendQuote; + receiveQuote: CashuReceiveQuote | SparkReceiveQuote; +}; + +export class TransferService { + constructor( + private readonly db: AgicashDb, + private readonly cashuReceiveQuoteService: CashuReceiveQuoteService, + private readonly sparkReceiveQuoteService: SparkReceiveQuoteService, + private readonly sparkSendQuoteService: SparkSendQuoteService, + private readonly cashuSendQuoteService: CashuSendQuoteService, + ) {} + + async getTransferQuote({ + sourceAccount, + destinationAccount, + amount, + }: GetTransferQuoteParams): Promise { + if (!canSendToLightning(sourceAccount)) { + throw new DomainError('Source account cannot send to Lightning'); + } + + if (!canReceiveFromLightning(destinationAccount)) { + throw new DomainError( + 'Destination account cannot receive from Lightning', + ); + } + + if (sourceAccount.currency !== destinationAccount.currency) { + throw new DomainError( + 'Source and destination accounts must have the same currency', + ); + } + + const receiveLightningQuote = await this.getReceiveQuote( + destinationAccount, + amount, + ); + + const paymentRequest = getPaymentRequest(receiveLightningQuote); + const receiveFee = getReceiveFee(receiveLightningQuote, amount.currency); + + const sendQuote = await this.getSendQuote(sourceAccount, paymentRequest); + + const estimatedFee = receiveFee.add(sendQuote.estimatedTotalFee); + const estimatedTotal = amount.add(estimatedFee); + + return { + receiveLightningQuote, + sendQuote, + sourceAccountId: sourceAccount.id, + destinationAccountId: destinationAccount.id, + amount, + estimatedFee, + estimatedTotal, + }; + } + + async initiateTransfer({ + userId, + sourceAccount, + destinationAccount, + transferQuote, + }: InitiateTransferParams): Promise { + const { receiveLightningQuote, sendQuote } = transferQuote; + + const receiveExpiresAt = getReceiveQuoteExpiry(receiveLightningQuote); + if (new Date(receiveExpiresAt) < new Date()) { + throw new DomainError('Transfer quote has expired. Please try again.'); + } + + if (sendQuote.expiresAt && sendQuote.expiresAt < new Date()) { + throw new DomainError('Transfer quote has expired. Please try again.'); + } + + const receiveQuote = await this.createReceiveQuote( + userId, + destinationAccount, + receiveLightningQuote, + ); + + let createdSendQuote: SparkSendQuote | CashuSendQuote; + try { + createdSendQuote = await this.createSendQuote( + userId, + sourceAccount, + sendQuote, + ); + } catch (error) { + await this.failReceiveTransaction(receiveQuote.transactionId); + throw error; + } + + return { + sendTransactionId: createdSendQuote.transactionId, + receiveTransactionId: receiveQuote.transactionId, + sendQuote: createdSendQuote, + receiveQuote, + }; + } + + private async getReceiveQuote( + destinationAccount: Account, + amount: Money, + ): Promise { + if (destinationAccount.type === 'spark') { + const quote = await getSparkLightningQuote({ + wallet: destinationAccount.wallet, + amount, + }); + return { type: 'spark', quote }; + } + + const quote = await this.cashuReceiveQuoteService.getLightningQuote({ + wallet: destinationAccount.wallet, + amount, + }); + return { type: 'cashu', quote }; + } + + private async createReceiveQuote( + userId: string, + destinationAccount: Account, + receiveLightningQuote: ReceiveLightningQuote, + ): Promise { + if ( + destinationAccount.type === 'spark' && + receiveLightningQuote.type === 'spark' + ) { + return this.sparkReceiveQuoteService.createReceiveQuote({ + userId, + account: destinationAccount, + lightningQuote: receiveLightningQuote.quote, + receiveType: 'TRANSFER', + }); + } + + if ( + destinationAccount.type === 'cashu' && + receiveLightningQuote.type === 'cashu' + ) { + return this.cashuReceiveQuoteService.createReceiveQuote({ + userId, + account: destinationAccount, + lightningQuote: receiveLightningQuote.quote, + receiveType: 'TRANSFER', + }); + } + + throw new Error( + `Mismatched destination account type (${destinationAccount.type}) and receive quote type (${receiveLightningQuote.type})`, + ); + } + + private async getSendQuote( + sourceAccount: Account, + paymentRequest: string, + ): Promise { + if (sourceAccount.type === 'spark') { + return this.sparkSendQuoteService.getLightningSendQuote({ + account: sourceAccount, + paymentRequest, + }); + } + + return this.cashuSendQuoteService.getLightningQuote({ + account: sourceAccount, + paymentRequest, + }); + } + + private async createSendQuote( + userId: string, + sourceAccount: Account, + sendQuote: SparkLightningQuote | CashuLightningQuote, + ): Promise { + if (sourceAccount.type === 'spark') { + return this.sparkSendQuoteService.createSendQuote({ + userId, + account: sourceAccount, + quote: sendQuote as SparkLightningQuote, + }); + } + + const cashuQuote = sendQuote as CashuLightningQuote; + return this.cashuSendQuoteService.createSendQuote({ + userId, + account: sourceAccount as CashuAccount, + sendQuote: { + paymentRequest: cashuQuote.paymentRequest, + amountRequested: cashuQuote.amountRequested, + amountRequestedInBtc: cashuQuote.amountRequestedInBtc, + meltQuote: cashuQuote.meltQuote, + }, + }); + } + + private async failReceiveTransaction(transactionId: string): Promise { + const { error } = await this.db + .from('transactions') + .update({ state: 'FAILED', failed_at: new Date().toISOString() }) + .eq('id', transactionId) + .eq('state', 'PENDING'); + + if (error) { + console.error( + `Failed to mark receive transaction ${transactionId} as FAILED`, + { cause: error }, + ); + } + } +} + +function getPaymentRequest(quote: ReceiveLightningQuote): string { + if (quote.type === 'spark') { + return quote.quote.invoice.encodedInvoice; + } + return quote.quote.mintQuote.request; +} + +function getReceiveFee( + quote: ReceiveLightningQuote, + currency: Money['currency'], +): Money { + if (quote.type === 'spark') { + return Money.zero(currency); + } + return quote.quote.mintingFee ?? Money.zero(currency); +} + +function getReceiveQuoteExpiry(quote: ReceiveLightningQuote): string { + if (quote.type === 'spark') { + return quote.quote.invoice.expiresAt; + } + return quote.quote.expiresAt; +} + +export function useTransferService() { + const cashuReceiveQuoteService = useCashuReceiveQuoteService(); + const sparkReceiveQuoteService = useSparkReceiveQuoteService(); + const sparkSendQuoteService = useSparkSendQuoteService(); + const cashuSendQuoteService = useCashuSendQuoteService(); + return new TransferService( + agicashDbClient, + cashuReceiveQuoteService, + sparkReceiveQuoteService, + sparkSendQuoteService, + cashuSendQuoteService, + ); +} diff --git a/app/features/transfer/transfer-store.ts b/app/features/transfer/transfer-store.ts new file mode 100644 index 00000000..317e0de2 --- /dev/null +++ b/app/features/transfer/transfer-store.ts @@ -0,0 +1,69 @@ +import { create } from 'zustand'; +import type { Money } from '~/lib/money'; +import type { Account } from '../accounts/account'; +import type { TransferQuote } from './transfer-service'; + +type GetTransferQuoteResult = + | { success: true; quote: TransferQuote } + | { success: false; error: unknown }; + +export type TransferState = { + status: 'idle' | 'quoting' | 'success'; + /** ID of the account to send from. */ + sourceAccountId: string; + /** ID of the account to receive into. */ + destinationAccountId: string; + /** The amount to transfer. */ + amount: Money | null; + /** Quote for the transfer (bundled receive + send quotes). */ + transferQuote: TransferQuote | null; + /** Create a transfer quote for the given amount. */ + getTransferQuote: (amount: Money) => Promise; +}; + +type CreateTransferStoreProps = { + sourceAccount: Account; + destinationAccount: Account; + getAccount: (id: string) => Account; + createTransferQuote: (params: { + sourceAccount: Account; + destinationAccount: Account; + amount: Money; + }) => Promise; +}; + +export const createTransferStore = ({ + sourceAccount, + destinationAccount, + getAccount, + createTransferQuote, +}: CreateTransferStoreProps) => { + return create((set, get) => ({ + status: 'idle', + sourceAccountId: sourceAccount.id, + destinationAccountId: destinationAccount.id, + amount: null, + transferQuote: null, + getTransferQuote: async (amount) => { + const source = getAccount(get().sourceAccountId); + const dest = getAccount(get().destinationAccountId); + set({ status: 'quoting', amount }); + + try { + const quote = await createTransferQuote({ + sourceAccount: source, + destinationAccount: dest, + amount, + }); + + set({ status: 'success', transferQuote: quote }); + return { success: true, quote }; + } catch (error) { + set({ status: 'idle' }); + return { success: false, error }; + } + }, + })); +}; + +export type TransferStore = ReturnType; diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx new file mode 100644 index 00000000..afeb9f13 --- /dev/null +++ b/app/routes/_protected.gift-cards_.$cardId.transfer._index.tsx @@ -0,0 +1,5 @@ +import TransferInput from '~/features/transfer/transfer-input'; + +export default function GiftCardTransferInputRoute() { + return ; +} diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx new file mode 100644 index 00000000..22ef819a --- /dev/null +++ b/app/routes/_protected.gift-cards_.$cardId.transfer.confirm.tsx @@ -0,0 +1,15 @@ +import { Redirect } from '~/components/redirect'; +import TransferConfirmation from '~/features/transfer/transfer-confirmation'; +import { useTransferStore } from '~/features/transfer/transfer-provider'; + +export default function GiftCardTransferConfirmRoute() { + const transferQuote = useTransferStore((s) => s.transferQuote); + + if (!transferQuote) { + return ( + + ); + } + + return ; +} diff --git a/app/routes/_protected.gift-cards_.$cardId.transfer.tsx b/app/routes/_protected.gift-cards_.$cardId.transfer.tsx new file mode 100644 index 00000000..d63297ea --- /dev/null +++ b/app/routes/_protected.gift-cards_.$cardId.transfer.tsx @@ -0,0 +1,41 @@ +import { Outlet } from 'react-router'; +import { Page } from '~/components/page'; +import { canSendToLightning } from '~/features/accounts/account'; +import { + useDefaultAccount, + useGetAccount, +} from '~/features/accounts/account-hooks'; +import { DomainError } from '~/features/shared/error'; +import { TransferProvider } from '~/features/transfer/transfer-provider'; +import type { Route } from './+types/_protected.gift-cards_.$cardId.transfer'; + +export default function GiftCardTransferLayout({ + params, +}: Route.ComponentProps) { + const getAccount = useGetAccount('cashu'); + const destinationAccount = getAccount(params.cardId); + const sourceAccount = useDefaultAccount(); + + if (!canSendToLightning(sourceAccount)) { + throw new DomainError( + 'Your default account cannot send Lightning payments. Please change your default account.', + ); + } + + if (sourceAccount.currency !== destinationAccount.currency) { + throw new DomainError( + 'Your default account currency does not match the gift card currency.', + ); + } + + return ( + + + + + + ); +}