From 7dfac0b42bea7122bfca6b30b135b88597a65a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Mon, 2 Sep 2024 18:25:31 +0700 Subject: [PATCH] feat(mobile): add transaction queue and allow select multiple images (#252) --- apps/mobile/app/(app)/(tabs)/index.tsx | 26 +-- apps/mobile/app/(app)/(tabs)/settings.tsx | 42 +++-- apps/mobile/app/(app)/_layout.tsx | 9 +- apps/mobile/app/(app)/review-transactions.tsx | 27 +++ .../app/(app)/transaction/new-record.tsx | 140 ++++++++++---- .../components/home/time-range-control.tsx | 65 +++++-- .../transaction/draft-transaction-item.tsx | 173 ++++++++++++++++++ .../transaction/draft-transaction-list.tsx | 32 ++++ .../mobile/components/transaction/scanner.tsx | 76 +++++--- .../transaction/select-account-field.tsx | 2 +- .../transaction/select-category-field.tsx | 2 +- .../transaction/transaction-form.tsx | 15 +- apps/mobile/components/ui/tabs.tsx | 4 +- apps/mobile/mutations/transaction.ts | 10 +- apps/mobile/stores/transaction/store.ts | 35 +++- packages/validation/src/transaction.zod.ts | 1 + 16 files changed, 539 insertions(+), 120 deletions(-) create mode 100644 apps/mobile/app/(app)/review-transactions.tsx create mode 100644 apps/mobile/components/transaction/draft-transaction-item.tsx create mode 100644 apps/mobile/components/transaction/draft-transaction-list.tsx diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx index ea6b6cc3..e8428230 100644 --- a/apps/mobile/app/(app)/(tabs)/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -5,6 +5,7 @@ import { HomeHeader } from '@/components/home/header' import { HomeFilter } from '@/components/home/select-filter' import { TimeRangeControl } from '@/components/home/time-range-control' import { HomeView, WalletStatistics } from '@/components/home/wallet-statistics' +import { DraftTransactionList } from '@/components/transaction/draft-transaction-list' import { HandyArrow } from '@/components/transaction/handy-arrow' import { TransactionItem } from '@/components/transaction/transaction-item' import { Text } from '@/components/ui/text' @@ -108,17 +109,20 @@ export default function HomeScreen() { - { - setView(selected) - setCategoryId(undefined) - }} - walletAccountId={walletAccountId} - categoryId={categoryId} - onCategoryChange={setCategoryId} - /> + + + { + setView(selected) + setCategoryId(undefined) + }} + walletAccountId={walletAccountId} + categoryId={categoryId} + onCategoryChange={setCategoryId} + /> + + ) : null } diff --git a/apps/mobile/app/(app)/(tabs)/settings.tsx b/apps/mobile/app/(app)/(tabs)/settings.tsx index 499271e3..86996038 100644 --- a/apps/mobile/app/(app)/(tabs)/settings.tsx +++ b/apps/mobile/app/(app)/(tabs)/settings.tsx @@ -17,6 +17,7 @@ import { useSeed } from '@/hooks/use-seed' import { useColorScheme } from '@/hooks/useColorScheme' import { theme } from '@/lib/theme' import { useLocale } from '@/locales/provider' +import { useTransactionStore } from '@/stores/transaction/store' import { useUserSettingsStore } from '@/stores/user-settings/store' import { useAuth } from '@clerk/clerk-expo' import { t } from '@lingui/macro' @@ -38,6 +39,7 @@ import { ScrollTextIcon, ShapesIcon, Share2Icon, + SparklesIcon, StarIcon, SwatchBookIcon, WalletCardsIcon, @@ -59,6 +61,7 @@ export default function SettingsScreen() { const { setEnabledPushNotifications, enabledPushNotifications } = useUserSettingsStore() const { startSeed } = useSeed() + const { draftTransactions } = useTransactionStore() async function handleCopyVersion() { const fullVersion = `${Application.nativeApplicationVersion} - ${Updates.updateId ?? 'Embedded'}` @@ -108,17 +111,29 @@ export default function SettingsScreen() { } /> - + - {t(i18n)`Coming soon`} + + {draftTransactions.length} } /> + + {t(i18n)`Coming soon`} + + } + disabled + /> @@ -207,15 +222,14 @@ export default function SettingsScreen() { } /> - - - } - /> - + + } + disabled + /> + ( + + )} + keyExtractor={(item) => item.id} + ListEmptyComponent={ + + {t(i18n)`Your pending AI transactions will show up here`} + + } + /> + ) +} diff --git a/apps/mobile/app/(app)/transaction/new-record.tsx b/apps/mobile/app/(app)/transaction/new-record.tsx index 2d42f551..9829f924 100644 --- a/apps/mobile/app/(app)/transaction/new-record.tsx +++ b/apps/mobile/app/(app)/transaction/new-record.tsx @@ -1,9 +1,12 @@ import { toast } from '@/components/common/toast' import { Scanner } from '@/components/transaction/scanner' import { TransactionForm } from '@/components/transaction/transaction-form' +import { Button } from '@/components/ui/button' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useUserMetadata } from '@/hooks/use-user-metadata' import { useWallets, walletQueries } from '@/queries/wallet' import { useCreateTransaction } from '@/stores/transaction/hooks' +import { useTransactionStore } from '@/stores/transaction/store' import { useDefaultCurrency } from '@/stores/user-settings/hooks' import { type TransactionFormValues, @@ -17,15 +20,22 @@ import { createId } from '@paralleldrive/cuid2' import { PortalHost, useModalPortalRoot } from '@rn-primitives/portal' import { useQueryClient } from '@tanstack/react-query' import * as Haptics from 'expo-haptics' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { useRef, useState } from 'react' +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' +import { CameraIcon, KeyboardIcon, Trash2Icon } from 'lucide-react-native' +import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' -import { ActivityIndicator, Alert, View } from 'react-native' -import PagerView from 'react-native-pager-view' +import { + ActivityIndicator, + Alert, + Keyboard, + ScrollView, + View, + useWindowDimensions, +} from 'react-native' export default function NewRecordScreen() { const { i18n } = useLingui() - const ref = useRef(null) + const ref = useRef(null) const queryClient = useQueryClient() const router = useRouter() const { data: walletAccounts } = useWallets() @@ -34,6 +44,9 @@ export default function NewRecordScreen() { const { sideOffset, ...rootProps } = useModalPortalRoot() const [page, setPage] = useState(0) const { defaultBudgetId } = useUserMetadata() + const navigation = useNavigation() + const { width } = useWindowDimensions() + const { removeDraftTransaction } = useTransactionStore() const params = useLocalSearchParams() const parsedParams = zUpdateTransaction.parse(params) @@ -54,13 +67,59 @@ export default function NewRecordScreen() { const { mutateAsync } = useCreateTransaction() + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + navigation.setOptions({ + headerTitle: () => ( + { + setPage(Number(value)) + Keyboard.dismiss() + ref.current?.scrollTo({ + y: 0, + x: value === '0' ? 0 : width, + animated: true, + }) + }} + className="w-[150px]" + > + + + + + + + + + + ), + headerRight: () => + parsedParams?.id ? ( + + ) : null, + }) + }, [page]) + const handleCreateTransaction = async (values: TransactionFormValues) => { try { + if (parsedParams?.id) { + removeDraftTransaction(parsedParams.id) + } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) router.back() toast.success(t(i18n)`Transaction created`) - await mutateAsync({ id: createId(), data: values }) + await mutateAsync({ id: parsedParams.id || createId(), data: values }) // TODO: remove this after the wallet store is implemented queryClient.invalidateQueries({ @@ -84,42 +143,43 @@ export default function NewRecordScreen() { return ( - setPage(nativeEvent.position)} - offscreenPageLimit={2} + horizontal + pagingEnabled + bounces={false} + scrollEventThrottle={16} + onMomentumScrollEnd={({ nativeEvent }) => { + const page = Math.round(nativeEvent.contentOffset.x / width) + setPage(page) + Keyboard.dismiss() + }} + showsHorizontalScrollIndicator={false} > - { - ref.current?.setPage(1) - }} - /> - ref.current?.setScrollEnabled(false)} - onScanResult={(result) => { - transactionForm.reset( - { - ...defaultValues, - ...result, - }, - { - keepDefaultValues: false, - }, - ) - ref.current?.setScrollEnabled(true) - ref.current?.setPage(0) - }} - shouldRender={page === 1} - /> - + + { + ref.current?.scrollTo({ + y: 0, + x: width, + animated: true, + }) + }} + /> + + + { + toast.success(t(i18n)`Transaction added to processing queue`) + }} + shouldRender={page === 1} + /> + + ) diff --git a/apps/mobile/components/home/time-range-control.tsx b/apps/mobile/components/home/time-range-control.tsx index 153f0805..87e0583d 100644 --- a/apps/mobile/components/home/time-range-control.tsx +++ b/apps/mobile/components/home/time-range-control.tsx @@ -28,18 +28,40 @@ export function TimeRangeControl({ function handlePrevious() { if (filter === HomeFilter.ByDay) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).subtract(1, 'day').toDate(), - to: dayjsExtended(timeRange.to).subtract(1, 'day').toDate(), + from: dayjsExtended(timeRange.from) + .subtract(1, 'day') + .startOf('day') + .toDate(), + to: dayjsExtended(timeRange.to) + .subtract(1, 'day') + .endOf('day') + .toDate(), }) } else if (filter === HomeFilter.ByWeek) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).subtract(1, 'week').toDate(), - to: dayjsExtended(timeRange.to).subtract(1, 'week').toDate(), + from: dayjsExtended(timeRange.from) + .subtract(1, 'week') + .startOf('day') + .startOf('week') + .toDate(), + to: dayjsExtended(timeRange.to) + .subtract(1, 'week') + .endOf('day') + .endOf('week') + .toDate(), }) } else if (filter === HomeFilter.ByMonth) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).subtract(1, 'month').toDate(), - to: dayjsExtended(timeRange.to).subtract(1, 'month').toDate(), + from: dayjsExtended(timeRange.from) + .subtract(1, 'month') + .startOf('day') + .startOf('month') + .toDate(), + to: dayjsExtended(timeRange.to) + .subtract(1, 'month') + .endOf('day') + .endOf('month') + .toDate(), }) } } @@ -47,18 +69,37 @@ export function TimeRangeControl({ function handleNext() { if (filter === HomeFilter.ByDay) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).add(1, 'day').toDate(), - to: dayjsExtended(timeRange.to).add(1, 'day').toDate(), + from: dayjsExtended(timeRange.from) + .add(1, 'day') + .startOf('day') + .toDate(), + to: dayjsExtended(timeRange.to).add(1, 'day').endOf('day').toDate(), }) } else if (filter === HomeFilter.ByWeek) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).add(1, 'week').toDate(), - to: dayjsExtended(timeRange.to).add(1, 'week').toDate(), + from: dayjsExtended(timeRange.from) + .add(1, 'week') + .startOf('day') + .startOf('week') + .toDate(), + to: dayjsExtended(timeRange.to) + .add(1, 'week') + .endOf('day') + .endOf('week') + .toDate(), }) } else if (filter === HomeFilter.ByMonth) { onTimeRangeChange({ - from: dayjsExtended(timeRange.from).add(1, 'month').toDate(), - to: dayjsExtended(timeRange.to).add(1, 'month').toDate(), + from: dayjsExtended(timeRange.from) + .add(1, 'month') + .startOf('day') + .startOf('month') + .toDate(), + to: dayjsExtended(timeRange.to) + .add(1, 'month') + .endOf('day') + .endOf('month') + .toDate(), }) } } diff --git a/apps/mobile/components/transaction/draft-transaction-item.tsx b/apps/mobile/components/transaction/draft-transaction-item.tsx new file mode 100644 index 00000000..be5a6cbd --- /dev/null +++ b/apps/mobile/components/transaction/draft-transaction-item.tsx @@ -0,0 +1,173 @@ +import { TRANSACTION_ICONS } from '@/lib/icons/category-icons' +import { getAITransactionData } from '@/mutations/transaction' +import { useCategoryList } from '@/stores/category/hooks' +import { useTransactionStore } from '@/stores/transaction/store' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { useMutation, useMutationState } from '@tanstack/react-query' +import { Link } from 'expo-router' +import { CircleAlert } from 'lucide-react-native' +import type { FC } from 'react' +import { ActivityIndicator, Alert, Image, Pressable, View } from 'react-native' +import { AmountFormat } from '../common/amount-format' +import GenericIcon from '../common/generic-icon' +import { toast } from '../common/toast' +import { Text } from '../ui/text' + +type DraftTransactionItemProps = { + transactionId: string +} + +export const DraftTransactionItem: FC = ({ + transactionId, +}) => { + const { i18n } = useLingui() + const { draftTransactions } = useTransactionStore() + const { categoriesDict } = useCategoryList() + const transaction = draftTransactions.find((t) => t.id === transactionId) + const mutationState = useMutationState({ + filters: { + mutationKey: ['ai-transaction'], + }, + select: (state) => state, + }) + const { updateDraftTransaction, removeDraftTransaction } = + useTransactionStore() + + const { mutateAsync: mutateRetry } = useMutation({ + mutationKey: ['ai-transaction'], + mutationFn: getAITransactionData, + onError(error) { + toast.error(error.message ?? t(i18n)`Cannot extract transaction`) + }, + onSuccess(result) { + if (!result.amount) { + throw new Error(t(i18n)`Cannot extract transaction`) + } + updateDraftTransaction({ + ...result, + id: transactionId, + }) + }, + }) + + function handlePressError() { + Alert.alert('', t(i18n)`Something went wrong. Please try again.`, [ + { + text: t(i18n)`Retry`, + style: 'cancel', + onPress: () => + mutateRetry({ + id: transactionId, + fileUri: transaction?.imageUri ?? '', + }), + }, + { + text: t(i18n)`Delete`, + style: 'destructive', + onPress: () => removeDraftTransaction(transactionId), + }, + ]) + } + + const latestState = mutationState?.findLast( + // biome-ignore lint/suspicious/noExplicitAny: + (s) => (s?.state?.variables as any)?.id === transactionId, + ) + if (!transaction) { + return null + } + const { categoryId } = transaction + const category = (categoryId && categoriesDict[categoryId]) || null + + const iconName = + TRANSACTION_ICONS[transaction.note!] || category?.icon || 'Sparkles' + + const transactionName = + t(i18n)`${transaction.note}` || category?.name || t(i18n)`Uncategorized` + + if (latestState?.state?.status === 'pending') { + return ( + { + Alert.alert('', t(i18n)`Transaction is processing...`, [ + { + text: t(i18n)`Stop`, + style: 'destructive', + onPress: () => { + removeDraftTransaction(transactionId) + latestState.destroy() + }, + }, + { + text: t(i18n)`Understood`, + }, + ]) + }} + > + + + + {t(i18n)`Processing...`} + + + + + ) + } + + if (latestState?.state?.status === 'error' || !transaction.amount) { + return ( + + + + + {latestState?.state?.failureReason?.message ?? + t(i18n)`Cannot process image`} + + + + + ) + } + + return ( + + params: transaction as any, + }} + > + + + + name={iconName as any} + className="size-5 text-foreground" + /> + + {transactionName} + + + + + + ) +} diff --git a/apps/mobile/components/transaction/draft-transaction-list.tsx b/apps/mobile/components/transaction/draft-transaction-list.tsx new file mode 100644 index 00000000..bcec966b --- /dev/null +++ b/apps/mobile/components/transaction/draft-transaction-list.tsx @@ -0,0 +1,32 @@ +import { useTransactionStore } from '@/stores/transaction/store' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { View } from 'react-native' +import { Badge } from '../ui/badge' +import { Text } from '../ui/text' +import { DraftTransactionItem } from './draft-transaction-item' + +export function DraftTransactionList() { + const { draftTransactions } = useTransactionStore() + const { i18n } = useLingui() + + if (!draftTransactions.length) { + return null + } + + return ( + + + {t( + i18n, + )`Waiting for review`} + + {draftTransactions.length} + + + {draftTransactions.map((item) => ( + + ))} + + ) +} diff --git a/apps/mobile/components/transaction/scanner.tsx b/apps/mobile/components/transaction/scanner.tsx index 37bc3892..cb460101 100644 --- a/apps/mobile/components/transaction/scanner.tsx +++ b/apps/mobile/components/transaction/scanner.tsx @@ -4,25 +4,29 @@ import { ScanningOverlay } from '@/components/scanner/scanning-overlay' import { Button } from '@/components/ui/button' import { Text } from '@/components/ui/text' import { getAITransactionData } from '@/mutations/transaction' +import { useTransactionStore } from '@/stores/transaction/store' import type { UpdateTransaction } from '@6pm/validation' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' +import { createId } from '@paralleldrive/cuid2' import { useMutation } from '@tanstack/react-query' import { type CameraType, CameraView, useCameraPermissions } from 'expo-camera' import * as Haptics from 'expo-haptics' import { SaveFormat, manipulateAsync } from 'expo-image-manipulator' import * as ImagePicker from 'expo-image-picker' +import { useRouter } from 'expo-router' import { CameraIcon, - ChevronsUpIcon, + ChevronsRightIcon, ImagesIcon, SwitchCameraIcon, } from 'lucide-react-native' import { cssInterop } from 'nativewind' import { useRef, useState } from 'react' -import { ActivityIndicator, Alert } from 'react-native' +import { ActivityIndicator } from 'react-native' import { ImageBackground, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { toast } from '../common/toast' cssInterop(CameraView, { className: { @@ -32,7 +36,7 @@ cssInterop(CameraView, { type ScannerProps = { onScanStart?: () => void - onScanResult: (result: UpdateTransaction) => void + onScanResult?: (result: UpdateTransaction) => void shouldRender?: boolean } @@ -47,21 +51,30 @@ export function Scanner({ const [imageUri, setImageUri] = useState(null) const { i18n } = useLingui() const { bottom } = useSafeAreaInsets() + const { addDraftTransaction, updateDraftTransaction } = useTransactionStore() + const router = useRouter() const { mutateAsync } = useMutation({ + mutationKey: ['ai-transaction'], mutationFn: getAITransactionData, onMutate() { onScanStart?.() }, onError(error) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) - Alert.alert(error.message ?? t(i18n)`Cannot extract transaction data`) + toast.error(error.message ?? t(i18n)`Cannot extract transaction`) setImageUri(null) }, onSuccess(result) { + if (!result.amount) { + throw new Error(t(i18n)`Cannot extract transaction`) + } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - onScanResult(result) - setImageUri(null) + onScanResult?.(result) + updateDraftTransaction({ + ...result, + id: result.id!, + }) }, }) @@ -70,21 +83,33 @@ export function Scanner({ setFacing(facing === 'back' ? 'front' : 'back') } - async function processImage(uri: string) { - const manipResult = await manipulateAsync( - uri, - [ - { - resize: { width: 1024 }, - }, - ], - { - compress: 0.5, - format: SaveFormat.WEBP, - }, + async function processImages(uris: string[]) { + router.back() + await Promise.all( + uris.map(async (uri) => { + const id = createId() + const manipResult = await manipulateAsync( + uri, + [ + { + resize: { width: 1024 }, + }, + ], + { + compress: 0.5, + format: SaveFormat.WEBP, + }, + ) + addDraftTransaction({ + id, + imageUri: manipResult.uri, + }) + return await mutateAsync({ + id, + fileUri: manipResult.uri, + }) + }), ) - setImageUri(manipResult.uri) - await mutateAsync(manipResult.uri) } async function takePicture() { @@ -94,14 +119,14 @@ export function Scanner({ quality: 0.5, }) if (result?.uri) { - return await processImage(result.uri) + return await processImages([result.uri]) } } async function pickImage() { Haptics.selectionAsync() const result = await ImagePicker.launchImageLibraryAsync({ - allowsMultipleSelection: false, + allowsMultipleSelection: true, mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.5, @@ -110,7 +135,7 @@ export function Scanner({ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) return } - return await processImage(result.assets[0].uri) + return await processImages(result.assets.map((a) => a.uri)) } if (!permission) { @@ -124,9 +149,8 @@ export function Scanner({ if (!shouldRender) { return ( - - - {t(i18n)`Swipe up to scan transaction`} + + ) } diff --git a/apps/mobile/components/transaction/select-account-field.tsx b/apps/mobile/components/transaction/select-account-field.tsx index 9a71152c..0721cac6 100644 --- a/apps/mobile/components/transaction/select-account-field.tsx +++ b/apps/mobile/components/transaction/select-account-field.tsx @@ -39,7 +39,7 @@ export function SelectAccountField({ <> + */} - ) : ( - - )} + ) : null} @@ -197,7 +194,7 @@ export const TransactionForm = ({ /> - + { diff --git a/apps/mobile/components/ui/tabs.tsx b/apps/mobile/components/ui/tabs.tsx index dd784326..057e5df2 100644 --- a/apps/mobile/components/ui/tabs.tsx +++ b/apps/mobile/components/ui/tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< void setTransactions: (transactions: Transaction[]) => void addTransactions: (transactions: Transaction[]) => void @@ -21,6 +25,9 @@ export interface TransactionStore { }) => void updateTransaction: (transaction: Transaction) => void removeTransaction: (transactionId: string) => void + addDraftTransaction: (transaction: DraftTransaction) => void + updateDraftTransaction: (transaction: DraftTransaction) => void + removeDraftTransaction: (transactionId: string) => void } function normalizeTransactions(transactions: Transaction[]) { @@ -35,8 +42,9 @@ export const useTransactionStore = create()( persist( (set) => ({ transactions: [], + draftTransactions: [], // biome-ignore lint/style/useNamingConvention: - _reset: () => set({ transactions: [] }), + _reset: () => set({ transactions: [], draftTransactions: [] }), setTransactions: (transactions) => { set({ transactions: normalizeTransactions(transactions) }) }, @@ -96,6 +104,31 @@ export const useTransactionStore = create()( return { transactions: normalizeTransactions(state.transactions) } }) }, + addDraftTransaction: (transaction) => { + set((state) => ({ + draftTransactions: state.draftTransactions.find( + (i) => i.id === transaction.id, + ) + ? state.draftTransactions.map((i) => + i.id === transaction.id ? transaction : i, + ) + : [transaction, ...state.draftTransactions], + })) + }, + updateDraftTransaction: (transaction) => { + set((state) => ({ + draftTransactions: state.draftTransactions.map((i) => + i.id === transaction.id ? transaction : i, + ), + })) + }, + removeDraftTransaction: (transactionId) => { + set((state) => ({ + draftTransactions: state.draftTransactions.filter( + (t) => t.id !== transactionId, + ), + })) + }, }), { name: 'transaction-storage', diff --git a/packages/validation/src/transaction.zod.ts b/packages/validation/src/transaction.zod.ts index 94fb17e3..0c558372 100644 --- a/packages/validation/src/transaction.zod.ts +++ b/packages/validation/src/transaction.zod.ts @@ -14,6 +14,7 @@ export const zCreateTransaction = z.object({ export type CreateTransaction = z.infer export const zUpdateTransaction = z.object({ + id: z.string().optional(), date: z.date({ coerce: true }).optional(), amount: z.number({ coerce: true }).optional(), currency: z.string().optional(),