From 02ffcb7c9a8012ea8c5282718529410e9f93548a Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:42:28 -0300 Subject: [PATCH 1/7] feat(pos-app): replace transaction filters with bottom sheet modals and add date filtering - Replace horizontal scrollable filter pills with two compact buttons (Status & Date range) - Add date range filtering with options: All Time, Today, 7 Days, This Week, This Month - Status filter button now shows selected filter label instead of generic "Status" - Each filter opens a bottom sheet modal with radio selection list - Add color-coded dots in status filter modal matching transaction states - Add DateRangeFilterType and getDateRange utility for date computation - Extend API with fromDate/toDate query parameters for server-side filtering - Store date range filter preference in Zustand (defaults to "Today") - Bump settings store version to 14 with migration support Co-Authored-By: Claude Haiku 4.5 --- dapps/pos-app/app/activity.tsx | 126 ++++++++++++++++++-- dapps/pos-app/components/filter-buttons.tsx | 93 +++++++++++++++ dapps/pos-app/components/filter-tabs.tsx | 125 ------------------- dapps/pos-app/components/radio-list.tsx | 11 ++ dapps/pos-app/services/hooks.ts | 19 ++- dapps/pos-app/services/transactions.ts | 10 ++ dapps/pos-app/store/useSettingsStore.ts | 22 +++- dapps/pos-app/utils/date-range.ts | 47 ++++++++ dapps/pos-app/utils/types.ts | 7 ++ 9 files changed, 317 insertions(+), 143 deletions(-) create mode 100644 dapps/pos-app/components/filter-buttons.tsx delete mode 100644 dapps/pos-app/components/filter-tabs.tsx create mode 100644 dapps/pos-app/utils/date-range.ts diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index a485c501..9111ce92 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -1,14 +1,20 @@ import { EmptyState } from "@/components/empty-state"; -import { FilterTabs } from "@/components/filter-tabs"; +import { FilterButtons } from "@/components/filter-buttons"; +import { RadioList, RadioOption } from "@/components/radio-list"; +import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { TransactionCard } from "@/components/transaction-card"; import { TransactionDetailModal } from "@/components/transaction-detail-modal"; import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useTransactions } from "@/services/hooks"; import { useSettingsStore } from "@/store/useSettingsStore"; -import { PaymentRecord, TransactionFilterType } from "@/utils/types"; +import { + DateRangeFilterType, + PaymentRecord, + TransactionFilterType, +} from "@/utils/types"; import { showErrorToast } from "@/utils/toast"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -18,13 +24,66 @@ import { View, } from "react-native"; +type ActiveSheet = "status" | "dateRange" | null; + +const DATE_RANGE_OPTIONS: { value: DateRangeFilterType; label: string }[] = [ + { value: "all_time", label: "All Time" }, + { value: "today", label: "Today" }, + { value: "7_days", label: "7 Days" }, + { value: "this_week", label: "This Week" }, + { value: "this_month", label: "This Month" }, +]; + +const STATUS_LABELS: Record = { + all: "Status", + failed: "Failed", + pending: "Pending", + completed: "Completed", +}; + +const DATE_RANGE_LABELS: Record = { + all_time: "Date range", + today: "Today", + "7_days": "7 Days", + this_week: "This Week", + this_month: "This Month", +}; + export default function ActivityScreen() { const theme = useTheme(); - const { transactionFilter, setTransactionFilter } = useSettingsStore(); + const { + transactionFilter, + setTransactionFilter, + dateRangeFilter, + setDateRangeFilter, + } = useSettingsStore(); const [selectedPayment, setSelectedPayment] = useState( null, ); const [modalVisible, setModalVisible] = useState(false); + const [activeSheet, setActiveSheet] = useState(null); + + const statusOptions: RadioOption[] = useMemo( + () => [ + { + value: "all", + label: "All", + dotColor: theme["icon-accent-primary"], + }, + { value: "failed", label: "Failed", dotColor: theme["icon-error"] }, + { + value: "pending", + label: "Pending", + dotColor: theme["icon-default"], + }, + { + value: "completed", + label: "Completed", + dotColor: theme["icon-success"], + }, + ], + [theme], + ); const { transactions, @@ -38,6 +97,7 @@ export default function ActivityScreen() { isFetchingNextPage, } = useTransactions({ filter: transactionFilter, + dateRangeFilter, }); // Show error toast when fetch fails @@ -47,13 +107,26 @@ export default function ActivityScreen() { } }, [isError, error]); - const handleFilterChange = useCallback( + const closeSheet = useCallback(() => { + setActiveSheet(null); + }, []); + + const handleStatusChange = useCallback( (filter: TransactionFilterType) => { setTransactionFilter(filter); + setActiveSheet(null); }, [setTransactionFilter], ); + const handleDateRangeChange = useCallback( + (filter: DateRangeFilterType) => { + setDateRangeFilter(filter); + setActiveSheet(null); + }, + [setDateRangeFilter], + ); + const handleTransactionPress = useCallback((payment: PaymentRecord) => { setSelectedPayment(payment); setModalVisible(true); @@ -116,18 +189,25 @@ export default function ActivityScreen() { ); }, [isFetchingNextPage, theme]); + const listHeader = useMemo( + () => ( + setActiveSheet("status")} + onDateRangePress={() => setActiveSheet("dateRange")} + /> + ), + [transactionFilter, dateRangeFilter], + ); + return ( <> - } + ListHeaderComponent={listHeader} contentContainerStyle={[ styles.listContent, (!transactions || transactions?.length === 0) && @@ -151,6 +231,30 @@ export default function ActivityScreen() { } /> + + + + + + + + void; + onDateRangePress: () => void; +} + +function FilterButtonsBase({ + statusLabel, + dateRangeLabel, + onStatusPress, + onDateRangePress, +}: FilterButtonsProps) { + const theme = useTheme(); + const [assets] = useAssets([require("@/assets/images/caret-up-down.png")]); + + return ( + + + + + ); +} + +export const FilterButtons = memo(FilterButtonsBase); + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + gap: Spacing["spacing-2"], + paddingHorizontal: Spacing["spacing-5"], + paddingVertical: Spacing["spacing-1"], + }, + button: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + height: 48, + paddingHorizontal: Spacing["spacing-5"], + paddingVertical: Spacing["spacing-4"], + borderRadius: BorderRadius["4"], + gap: Spacing["spacing-2"], + }, + caretIcon: { + width: 16, + height: 16, + }, +}); diff --git a/dapps/pos-app/components/filter-tabs.tsx b/dapps/pos-app/components/filter-tabs.tsx deleted file mode 100644 index 952ca7ac..00000000 --- a/dapps/pos-app/components/filter-tabs.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { BorderRadius, Spacing } from "@/constants/spacing"; -import { useTheme } from "@/hooks/use-theme-color"; -import { TransactionFilterType } from "@/utils/types"; -import { memo, useCallback, useRef } from "react"; -import { LayoutChangeEvent, ScrollView, StyleSheet, View } from "react-native"; -import { Button } from "./button"; -import { ThemedText } from "./themed-text"; - -interface FilterTabsProps { - selectedFilter: TransactionFilterType; - onFilterChange: (filter: TransactionFilterType) => void; -} - -interface FilterOption { - key: TransactionFilterType; - label: string; - dotThemeKey?: "icon-error" | "foreground-tertiary" | "icon-success"; -} - -const FILTER_OPTIONS: FilterOption[] = [ - { key: "all", label: "All" }, - { key: "failed", label: "Failed", dotThemeKey: "icon-error" }, - { key: "pending", label: "Pending", dotThemeKey: "foreground-tertiary" }, - { key: "completed", label: "Completed", dotThemeKey: "icon-success" }, -]; - -function FilterTabsBase({ selectedFilter, onFilterChange }: FilterTabsProps) { - const theme = useTheme(); - const scrollRef = useRef(null); - const tabLayouts = useRef>({}); - - const handleTabLayout = useCallback( - (key: string, event: LayoutChangeEvent) => { - const { x, width } = event.nativeEvent.layout; - tabLayouts.current[key] = { x, width }; - }, - [], - ); - - const handleTabPress = useCallback( - (key: TransactionFilterType) => { - const layout = tabLayouts.current[key]; - if (layout && scrollRef.current) { - const padding = Spacing["spacing-5"]; - scrollRef.current.scrollTo({ - x: layout.x - padding, - animated: true, - }); - } - onFilterChange(key); - }, - [onFilterChange], - ); - - return ( - - {FILTER_OPTIONS.map((option) => { - const isSelected = selectedFilter === option.key; - - return ( - - ); - })} - - ); -} - -export const FilterTabs = memo(FilterTabsBase); - -const styles = StyleSheet.create({ - scrollContent: { - gap: Spacing["spacing-2"], - paddingVertical: Spacing["spacing-1"], - paddingHorizontal: Spacing["spacing-5"], - }, - tab: { - borderWidth: 1, - flexDirection: "row", - alignItems: "center", - paddingVertical: Spacing["spacing-4"], - paddingHorizontal: Spacing["spacing-5"], - borderRadius: BorderRadius["4"], - gap: Spacing["spacing-2"], - }, - dot: { - width: 10, - height: 10, - borderRadius: BorderRadius["full"], - }, -}); diff --git a/dapps/pos-app/components/radio-list.tsx b/dapps/pos-app/components/radio-list.tsx index 4003341a..5c81b113 100644 --- a/dapps/pos-app/components/radio-list.tsx +++ b/dapps/pos-app/components/radio-list.tsx @@ -8,6 +8,7 @@ export interface RadioOption { value: T; label: string; icon?: ImageSource; + dotColor?: string; } interface RadioListProps { @@ -64,6 +65,11 @@ export function RadioList({ cachePolicy="memory-disk" /> )} + {option.dotColor && ( + + )} {option.label} @@ -112,6 +118,11 @@ const styles = StyleSheet.create({ width: 24, height: 24, }, + dot: { + width: 10, + height: 10, + borderRadius: BorderRadius["full"], + }, radioOuter: { width: 24, height: 24, diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index ded022d3..f86699b1 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -1,5 +1,7 @@ import { useLogsStore } from "@/store/useLogsStore"; +import { getDateRange } from "@/utils/date-range"; import { + DateRangeFilterType, PaymentRecord, PaymentStatus, PaymentStatusResponse, @@ -162,6 +164,11 @@ interface UseTransactionsOptions { * @default "all" */ filter?: TransactionFilterType; + /** + * Filter transactions by date range + * @default "today" + */ + dateRangeFilter?: DateRangeFilterType; /** * Additional query options for the API */ @@ -193,7 +200,12 @@ function filterToStatusArray( * @returns Infinite query result with paginated transactions */ export function useTransactions(options: UseTransactionsOptions = {}) { - const { enabled = true, filter = "all", queryOptions = {} } = options; + const { + enabled = true, + filter = "all", + dateRangeFilter = "today", + queryOptions = {}, + } = options; const addLog = useLogsStore.getState().addLog; @@ -201,12 +213,15 @@ export function useTransactions(options: UseTransactionsOptions = {}) { const { sortBy, sortDir, limit } = queryOptions; const query = useInfiniteQuery({ - queryKey: ["transactions", filter, sortBy, sortDir, limit], + queryKey: ["transactions", filter, dateRangeFilter, sortBy, sortDir, limit], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); + const { fromDate, toDate } = getDateRange(dateRangeFilter); return getTransactions({ ...queryOptions, status: statusFilter, + fromDate, + toDate, sortBy: "date", sortDir: "desc", limit: 20, diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index 3ac76fad..d0a37301 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -11,6 +11,8 @@ export interface GetTransactionsOptions { sortDir?: "asc" | "desc"; limit?: number; cursor?: string; + fromDate?: string; + toDate?: string; } /** @@ -58,6 +60,14 @@ export async function getTransactions( params.append("cursor", options.cursor); } + if (options.fromDate) { + params.append("from_date", options.fromDate); + } + + if (options.toDate) { + params.append("to_date", options.toDate); + } + const queryString = params.toString(); const endpoint = `/merchants/${merchantId}/payments${queryString ? `?${queryString}` : ""}`; diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index cbb62bd3..3597759b 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -10,9 +10,12 @@ import { } from "@/utils/secure-storage"; import { isEmbedded } from "@/utils/is-embedded"; import { storage } from "@/utils/storage"; -import { ThemeMode, TransactionFilterType } from "@/utils/types"; +import { + DateRangeFilterType, + ThemeMode, + TransactionFilterType, +} from "@/utils/types"; import * as Crypto from "expo-crypto"; -import { Appearance } from "react-native"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { useLogsStore } from "./useLogsStore"; @@ -60,8 +63,9 @@ interface SettingsStore { merchantId: string | null; isCustomerApiKeySet: boolean; - // Transaction filter + // Transaction filters transactionFilter: TransactionFilterType; + dateRangeFilter: DateRangeFilterType; // PIN protection pinFailedAttempts: number; @@ -89,8 +93,9 @@ interface SettingsStore { resetPinAttempts: () => void; setBiometricEnabled: (enabled: boolean) => void; - // Transaction filter + // Transaction filters setTransactionFilter: (filter: TransactionFilterType) => void; + setDateRangeFilter: (filter: DateRangeFilterType) => void; // Others getVariantPrinterLogo: () => string; @@ -107,6 +112,7 @@ export const useSettingsStore = create()( merchantId: null, isCustomerApiKeySet: false, transactionFilter: "all", + dateRangeFilter: "today", pinFailedAttempts: 0, pinLockoutUntil: null, biometricEnabled: false, @@ -262,6 +268,8 @@ export const useSettingsStore = create()( setTransactionFilter: (filter: TransactionFilterType) => set({ transactionFilter: filter }), + setDateRangeFilter: (filter: DateRangeFilterType) => + set({ dateRangeFilter: filter }), getVariantPrinterLogo: () => { return Variants[get().variant]?.printerLogo ?? DEFAULT_LOGO_BASE64; @@ -269,7 +277,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 13, + version: 14, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -318,6 +326,10 @@ export const useSettingsStore = create()( } } + if (version < 14) { + persistedState.dateRangeFilter = "today"; + } + return persistedState; }, onRehydrateStorage: () => async (state, error) => { diff --git a/dapps/pos-app/utils/date-range.ts b/dapps/pos-app/utils/date-range.ts new file mode 100644 index 00000000..a7bea02d --- /dev/null +++ b/dapps/pos-app/utils/date-range.ts @@ -0,0 +1,47 @@ +import { DateRangeFilterType } from "./types"; + +interface DateRange { + fromDate?: string; + toDate?: string; +} + +/** + * Computes ISO date strings for a given date range filter. + */ +export function getDateRange(filter: DateRangeFilterType): DateRange { + if (filter === "all_time") { + return {}; + } + + const now = new Date(); + const toDate = now.toISOString(); + let from: Date; + + switch (filter) { + case "today": { + from = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + break; + } + case "7_days": { + from = new Date(now); + from.setDate(from.getDate() - 7); + from.setHours(0, 0, 0, 0); + break; + } + case "this_week": { + from = new Date(now); + const day = from.getDay(); + // Monday = 1, Sunday = 0 → shift so Monday is start of week + const diff = day === 0 ? 6 : day - 1; + from.setDate(from.getDate() - diff); + from.setHours(0, 0, 0, 0); + break; + } + case "this_month": { + from = new Date(now.getFullYear(), now.getMonth(), 1); + break; + } + } + + return { fromDate: from.toISOString(), toDate }; +} diff --git a/dapps/pos-app/utils/types.ts b/dapps/pos-app/utils/types.ts index 2e88dc8c..d03670f8 100644 --- a/dapps/pos-app/utils/types.ts +++ b/dapps/pos-app/utils/types.ts @@ -42,6 +42,13 @@ export type TransactionStatus = PaymentStatus; export type TransactionFilterType = "all" | "failed" | "pending" | "completed"; +export type DateRangeFilterType = + | "all_time" + | "today" + | "7_days" + | "this_week" + | "this_month"; + export interface PaymentRecord { payment_id: string; reference_id: string; From 4773484815551fd8a458535adddfb67fc8d6900f Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:52:47 -0300 Subject: [PATCH 2/7] fix(pos-app): address PR review feedback for activity filters - Add exhaustive default guard in getDateRange switch to prevent runtime errors - Stabilize toDate across paginated fetches by memoizing getDateRange outside queryFn - Update store version assertion in test from 13 to 14 Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/__tests__/store/useSettingsStore.test.ts | 2 +- dapps/pos-app/services/hooks.ts | 9 +++++++-- dapps/pos-app/utils/date-range.ts | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index ddf39622..38c7a7f1 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -579,7 +579,7 @@ describe("useSettingsStore", () => { // Check persist name and version are set (for storage key) expect(persistOptions?.name).toBe("settings"); - expect(persistOptions?.version).toBe(13); + expect(persistOptions?.version).toBe(14); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index f86699b1..c00520f9 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -11,7 +11,7 @@ import { TransactionsResponse, } from "@/utils/types"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { cancelPayment, getPaymentStatus, startPayment } from "./payment"; import { getTransactions, GetTransactionsOptions } from "./transactions"; @@ -212,11 +212,16 @@ export function useTransactions(options: UseTransactionsOptions = {}) { // Extract relevant fields for query key to avoid cache misses from object reference changes const { sortBy, sortDir, limit } = queryOptions; + // Compute date range once per filter change so toDate stays stable across paginated fetches + const { fromDate, toDate } = useMemo( + () => getDateRange(dateRangeFilter), + [dateRangeFilter], + ); + const query = useInfiniteQuery({ queryKey: ["transactions", filter, dateRangeFilter, sortBy, sortDir, limit], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); - const { fromDate, toDate } = getDateRange(dateRangeFilter); return getTransactions({ ...queryOptions, status: statusFilter, diff --git a/dapps/pos-app/utils/date-range.ts b/dapps/pos-app/utils/date-range.ts index a7bea02d..2c3d9920 100644 --- a/dapps/pos-app/utils/date-range.ts +++ b/dapps/pos-app/utils/date-range.ts @@ -41,6 +41,10 @@ export function getDateRange(filter: DateRangeFilterType): DateRange { from = new Date(now.getFullYear(), now.getMonth(), 1); break; } + default: { + const _exhaustive: never = filter; + return {}; + } } return { fromDate: from.toISOString(), toDate }; From 15362e7354d2e9d67445bdbebc4b9ec175fffc44 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:24:48 -0300 Subject: [PATCH 3/7] fix(pos-app): align date range API params with merchant payments spec Rename fromDate/toDate to startTs/endTs across the codebase to match the actual GET /v1/merchants/payments query parameter names. Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/services/hooks.ts | 6 +++--- dapps/pos-app/services/transactions.ts | 12 ++++++------ dapps/pos-app/utils/date-range.ts | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index c00520f9..ab5aa42d 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -213,7 +213,7 @@ export function useTransactions(options: UseTransactionsOptions = {}) { const { sortBy, sortDir, limit } = queryOptions; // Compute date range once per filter change so toDate stays stable across paginated fetches - const { fromDate, toDate } = useMemo( + const { startTs, endTs } = useMemo( () => getDateRange(dateRangeFilter), [dateRangeFilter], ); @@ -225,8 +225,8 @@ export function useTransactions(options: UseTransactionsOptions = {}) { return getTransactions({ ...queryOptions, status: statusFilter, - fromDate, - toDate, + startTs, + endTs, sortBy: "date", sortDir: "desc", limit: 20, diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index d0a37301..87fd44fb 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -11,8 +11,8 @@ export interface GetTransactionsOptions { sortDir?: "asc" | "desc"; limit?: number; cursor?: string; - fromDate?: string; - toDate?: string; + startTs?: string; + endTs?: string; } /** @@ -60,12 +60,12 @@ export async function getTransactions( params.append("cursor", options.cursor); } - if (options.fromDate) { - params.append("from_date", options.fromDate); + if (options.startTs) { + params.append("startTs", options.startTs); } - if (options.toDate) { - params.append("to_date", options.toDate); + if (options.endTs) { + params.append("endTs", options.endTs); } const queryString = params.toString(); diff --git a/dapps/pos-app/utils/date-range.ts b/dapps/pos-app/utils/date-range.ts index 2c3d9920..2bc376b0 100644 --- a/dapps/pos-app/utils/date-range.ts +++ b/dapps/pos-app/utils/date-range.ts @@ -1,8 +1,8 @@ import { DateRangeFilterType } from "./types"; interface DateRange { - fromDate?: string; - toDate?: string; + startTs?: string; + endTs?: string; } /** @@ -14,7 +14,7 @@ export function getDateRange(filter: DateRangeFilterType): DateRange { } const now = new Date(); - const toDate = now.toISOString(); + const endTs = now.toISOString(); let from: Date; switch (filter) { @@ -47,5 +47,5 @@ export function getDateRange(filter: DateRangeFilterType): DateRange { } } - return { fromDate: from.toISOString(), toDate }; + return { startTs: from.toISOString(), endTs }; } From 8e5afb8c49efc2e0e1d7cc706793880a2540cfd9 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:33:55 -0300 Subject: [PATCH 4/7] feat(pos-app): add individual status filters for expired and cancelled Split the "Failed" filter into separate Failed, Expired, and Cancelled options to match the backend payment statuses. Each status now maps to its own API value instead of grouping expired/cancelled under failed. Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/app/activity.tsx | 12 ++++++++++-- dapps/pos-app/services/hooks.ts | 10 +++++++--- dapps/pos-app/utils/types.ts | 8 +++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index 9111ce92..cbe74d2f 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -36,9 +36,11 @@ const DATE_RANGE_OPTIONS: { value: DateRangeFilterType; label: string }[] = [ const STATUS_LABELS: Record = { all: "Status", - failed: "Failed", pending: "Pending", completed: "Completed", + failed: "Failed", + expired: "Expired", + cancelled: "Cancelled", }; const DATE_RANGE_LABELS: Record = { @@ -70,7 +72,6 @@ export default function ActivityScreen() { label: "All", dotColor: theme["icon-accent-primary"], }, - { value: "failed", label: "Failed", dotColor: theme["icon-error"] }, { value: "pending", label: "Pending", @@ -81,6 +82,13 @@ export default function ActivityScreen() { label: "Completed", dotColor: theme["icon-success"], }, + { value: "failed", label: "Failed", dotColor: theme["icon-error"] }, + { value: "expired", label: "Expired", dotColor: theme["icon-error"] }, + { + value: "cancelled", + label: "Cancelled", + dotColor: theme["icon-default"], + }, ], [theme], ); diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index ab5aa42d..ae16454e 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -182,12 +182,16 @@ function filterToStatusArray( filter: TransactionFilterType, ): string[] | undefined { switch (filter) { + case "pending": + return ["requires_action", "processing"]; case "completed": return ["succeeded"]; case "failed": - return ["failed", "expired", "cancelled"]; - case "pending": - return ["requires_action", "processing"]; + return ["failed"]; + case "expired": + return ["expired"]; + case "cancelled": + return ["cancelled"]; case "all": default: return undefined; diff --git a/dapps/pos-app/utils/types.ts b/dapps/pos-app/utils/types.ts index d03670f8..1ea3f6b9 100644 --- a/dapps/pos-app/utils/types.ts +++ b/dapps/pos-app/utils/types.ts @@ -40,7 +40,13 @@ export interface ApiError { // Transaction/Activity Types export type TransactionStatus = PaymentStatus; -export type TransactionFilterType = "all" | "failed" | "pending" | "completed"; +export type TransactionFilterType = + | "all" + | "pending" + | "completed" + | "failed" + | "expired" + | "cancelled"; export type DateRangeFilterType = | "all_time" From 49e7bab0fe420d95834fc316ceec171209633e84 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:39:32 -0300 Subject: [PATCH 5/7] feat(pos-app): migrate transactions API to unified Pay API Migrate from old Merchant API (`/merchants/{id}/payments` with separate `MERCHANT_PORTAL_API_KEY`) to unified Pay API (`/v1/merchants/payments` with shared `getApiHeaders()`). Update response types from flat snake_case to nested camelCase DTOs. Replace CAIP-19 token parsing with server-provided display values. Remove `utils/tokens.ts` and merchant API env vars. Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/.env.example | 2 - dapps/pos-app/AGENTS.md | 19 +-- dapps/pos-app/api/transactions.ts | 56 +++---- dapps/pos-app/app/activity.tsx | 5 +- dapps/pos-app/app/payment-success.tsx | 5 +- dapps/pos-app/components/transaction-card.tsx | 7 +- .../components/transaction-detail-modal.tsx | 59 +++---- dapps/pos-app/services/hooks.ts | 2 +- dapps/pos-app/services/transactions.ts | 26 +-- dapps/pos-app/services/transactions.web.ts | 22 ++- dapps/pos-app/utils/currency.test.ts | 20 ++- dapps/pos-app/utils/currency.ts | 10 +- dapps/pos-app/utils/tokens.ts | 156 ------------------ dapps/pos-app/utils/types.ts | 79 ++++++--- 14 files changed, 152 insertions(+), 316 deletions(-) delete mode 100644 dapps/pos-app/utils/tokens.ts diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index d1d7311c..98aa5c20 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -5,5 +5,3 @@ EXPO_PUBLIC_API_URL="" EXPO_PUBLIC_GATEWAY_URL="" EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" -EXPO_PUBLIC_MERCHANT_API_URL="" -EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY="" diff --git a/dapps/pos-app/AGENTS.md b/dapps/pos-app/AGENTS.md index 43ea21f4..efa229c0 100644 --- a/dapps/pos-app/AGENTS.md +++ b/dapps/pos-app/AGENTS.md @@ -79,6 +79,7 @@ The app uses **Zustand** for state management with two main stores: - Biometric authentication settings - Printer connection status - Transaction filter preference (for Activity screen) + - Date range filter preference (for Activity screen) 2. **`useLogsStore`** (`store/useLogsStore.ts`) - Debug logs for troubleshooting @@ -220,20 +221,19 @@ All Payment API requests include: ### Transactions Service (`services/transactions.ts`) -> **Note:** The Merchants API currently has its own auth layer separate from the Payment API. Both share the same base URL (`EXPO_PUBLIC_API_URL`), but merchant endpoints authenticate via `EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY` (sent as `x-api-key` header) rather than the partner API key used by payment endpoints. This will be unified in the future. - **`getTransactions(options)`** - Fetches merchant transaction history -- Endpoint: `GET /merchants/{merchant_id}/payments` -- Uses the shared base URL (`EXPO_PUBLIC_API_URL`) but authenticates with `EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY` -- Supports filtering by status, date range, pagination -- Returns array of `PaymentRecord` objects +- Endpoint: `GET /v1/merchants/payments` +- Uses `getApiHeaders()` for authentication (same as payment endpoints) +- Supports filtering by status, date range (`startTs`/`endTs`), pagination (`cursor`/`limit`) +- Returns `TransactionsResponse` with nested camelCase DTOs (`PaymentRecord`, `AmountWithDisplay`, `BuyerInfo`, `TransactionInfo`, `SettlementInfo`) ### Server-Side Proxy (`api/transactions.ts`) - Vercel serverless function that proxies transaction requests (web only) -- Client only sends `x-merchant-id` header; API key is handled server-side via `EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY` +- Uses shared `extractCredentials()` and `getApiHeaders()` from `api/_utils.ts` +- Client sends `x-api-key` and `x-merchant-id` headers; proxy forwards with full auth headers - Avoids CORS issues by making requests server-side ### useTransactions Hook (`services/hooks.ts`) @@ -242,7 +242,8 @@ All Payment API requests include: import { useTransactions } from "@/services/hooks"; const { data, isLoading, isError, refetch } = useTransactions({ - filter: "all", // "all" | "completed" | "pending" | "failed" + filter: "all", // "all" | "pending" | "completed" | "failed" | "expired" | "cancelled" + dateRangeFilter: "today", // "all_time" | "today" | "7_days" | "this_week" | "this_month" enabled: true, }); ``` @@ -264,8 +265,6 @@ EXPO_PUBLIC_API_URL="" # Payment API base URL EXPO_PUBLIC_GATEWAY_URL="" # WalletConnect gateway URL EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" # Default merchant ID (optional) EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # Default customer API key (optional) -EXPO_PUBLIC_MERCHANT_API_URL="" # Merchant Portal API base URL -EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY="" # Merchant Portal API key (for Activity screen) ``` Copy `.env.example` to `.env` and fill in values. diff --git a/dapps/pos-app/api/transactions.ts b/dapps/pos-app/api/transactions.ts index dc203bc2..70a9d092 100644 --- a/dapps/pos-app/api/transactions.ts +++ b/dapps/pos-app/api/transactions.ts @@ -1,8 +1,5 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; - -const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL; -// TODO: Once Merchants API unifies auth with Payment API, forward client credentials instead -const MERCHANT_PORTAL_API_KEY = process.env.EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY; +import { extractCredentials, getApiBaseUrl, getApiHeaders } from "./_utils"; /** * Vercel Serverless Function to proxy transaction list requests @@ -17,30 +14,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } try { - // Extract merchant ID from request headers - const merchantId = req.headers["x-merchant-id"] as string; - - if (!merchantId) { - return res.status(400).json({ - message: "Missing required header: x-merchant-id", - }); - } - - if (!API_BASE_URL) { - return res.status(500).json({ - message: "API_BASE_URL is not configured", - }); - } + const credentials = extractCredentials(req, res); + if (!credentials) return; - if (!MERCHANT_PORTAL_API_KEY) { - return res.status(500).json({ - message: "MERCHANT_PORTAL_API_KEY is not configured", - }); - } + const apiBaseUrl = getApiBaseUrl(res); + if (!apiBaseUrl) return; - // Build query string from request query params + // Forward query params as-is (already camelCase from client) const params = new URLSearchParams(); - const { status, sort_by, sort_dir, limit, cursor } = req.query; + const { status, sortBy, sortDir, limit, cursor, startTs, endTs } = + req.query; // Handle status (can be array for multiple status filters) if (status) { @@ -50,11 +33,11 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { params.append("status", status); } } - if (sort_by && typeof sort_by === "string") { - params.append("sort_by", sort_by); + if (sortBy && typeof sortBy === "string") { + params.append("sortBy", sortBy); } - if (sort_dir && typeof sort_dir === "string") { - params.append("sort_dir", sort_dir); + if (sortDir && typeof sortDir === "string") { + params.append("sortDir", sortDir); } if (limit && typeof limit === "string") { params.append("limit", limit); @@ -62,17 +45,20 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (cursor && typeof cursor === "string") { params.append("cursor", cursor); } + if (startTs && typeof startTs === "string") { + params.append("startTs", startTs); + } + if (endTs && typeof endTs === "string") { + params.append("endTs", endTs); + } const queryString = params.toString(); - const normalizedBaseUrl = API_BASE_URL.replace(/\/+$/, ""); - const endpoint = `/merchants/${encodeURIComponent(merchantId)}/payments${queryString ? `?${queryString}` : ""}`; + const normalizedBaseUrl = apiBaseUrl.replace(/\/+$/, ""); + const endpoint = `/v1/merchants/payments${queryString ? `?${queryString}` : ""}`; const response = await fetch(`${normalizedBaseUrl}${endpoint}`, { method: "GET", - headers: { - "Content-Type": "application/json", - "x-api-key": MERCHANT_PORTAL_API_KEY, - }, + headers: getApiHeaders(credentials.apiKey, credentials.merchantId), }); const data = await response.json(); diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index cbe74d2f..7d5a418c 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -156,10 +156,7 @@ export default function ActivityScreen() { [handleTransactionPress], ); - const keyExtractor = useCallback( - (item: PaymentRecord) => item.payment_id, - [], - ); + const keyExtractor = useCallback((item: PaymentRecord) => item.paymentId, []); const renderEmptyComponent = useCallback(() => { if (isLoading) { diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 8a83500c..65457195 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -212,10 +212,7 @@ export default function PaymentSuccessScreen() { onPress={handleNewPayment} > New payment diff --git a/dapps/pos-app/components/transaction-card.tsx b/dapps/pos-app/components/transaction-card.tsx index e64f3b9c..45c8b169 100644 --- a/dapps/pos-app/components/transaction-card.tsx +++ b/dapps/pos-app/components/transaction-card.tsx @@ -33,7 +33,10 @@ function TransactionCardBase({ > - {formatFiatAmount(payment.fiat_amount, payment.fiat_currency)} + {formatFiatAmount( + payment.fiatAmount?.value, + payment.fiatAmount?.unit, + )} - {formatShortDate(payment.created_at)} + {formatShortDate(payment.createdAt)} diff --git a/dapps/pos-app/components/transaction-detail-modal.tsx b/dapps/pos-app/components/transaction-detail-modal.tsx index 4f7a1dda..796b9861 100644 --- a/dapps/pos-app/components/transaction-detail-modal.tsx +++ b/dapps/pos-app/components/transaction-detail-modal.tsx @@ -2,7 +2,6 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { formatFiatAmount } from "@/utils/currency"; import { formatDateTime } from "@/utils/misc"; -import { formatCryptoReceived, getTokenSymbol } from "@/utils/tokens"; import { PaymentRecord } from "@/utils/types"; import { memo, useEffect } from "react"; import { @@ -46,23 +45,6 @@ function truncateHash(hash?: string): string { return `${hash.slice(0, 4)}...${hash.slice(-4)}`; } -/** - * Get the token icon based on CAIP-19 identifier - * Returns the icon source or null if not USDC/USDT - */ -function getTokenIcon(tokenCaip19?: string): number | null { - const symbol = getTokenSymbol(tokenCaip19); - if (!symbol) return null; - - if (symbol === "USDC") { - return require("@/assets/images/tokens/usdc.png"); - } - if (symbol === "USDT") { - return require("@/assets/images/tokens/usdt.png"); - } - return null; -} - interface DetailRowProps { label: string; value?: string; @@ -142,14 +124,15 @@ function TransactionDetailModalBase({ if (!payment) return null; const handleCopyPaymentId = async () => { - if (!payment?.payment_id) return; - await Clipboard.setStringAsync(payment.payment_id); + if (!payment?.paymentId) return; + await Clipboard.setStringAsync(payment.paymentId); showSuccessToast("Payment ID copied to clipboard"); }; + const txHash = payment.transaction?.hash; const handleCopyHash = async () => { - if (!payment?.tx_hash) return; - await Clipboard.setStringAsync(payment.tx_hash); + if (!txHash) return; + await Clipboard.setStringAsync(txHash); showSuccessToast("Transaction hash copied to clipboard"); }; @@ -190,7 +173,7 @@ function TransactionDetailModalBase({ @@ -200,12 +183,12 @@ function TransactionDetailModalBase({ - {payment.token_amount && payment.token_caip19 && ( + {payment.tokenAmount?.value && ( - {formatCryptoReceived( - payment.token_caip19, - payment.token_amount, - ) ?? payment.token_amount} + {payment.tokenAmount.display?.formatted ?? + payment.tokenAmount.value} - {(() => { - const icon = getTokenIcon(payment.token_caip19); - return icon ? ( - - ) : null; - })()} + {payment.tokenAmount.display?.iconUrl && ( + + )} )} - {payment.tx_hash && ( + {txHash && ( diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index ae16454e..b81e4ca8 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -238,7 +238,7 @@ export function useTransactions(options: UseTransactionsOptions = {}) { }); }, initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, enabled, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime) diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index 87fd44fb..39f140c3 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -1,9 +1,5 @@ -import { useSettingsStore } from "@/store/useSettingsStore"; import { TransactionsResponse } from "@/utils/types"; -// TODO: Once Merchants API unifies auth with Payment API, switch to getApiHeaders() -import { apiClient } from "./client"; - -const MERCHANT_PORTAL_API_KEY = process.env.EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY; +import { apiClient, getApiHeaders } from "./client"; export interface GetTransactionsOptions { status?: string | string[]; @@ -23,15 +19,7 @@ export interface GetTransactionsOptions { export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { - const merchantId = useSettingsStore.getState().merchantId; - - if (!merchantId) { - throw new Error("Merchant ID is not configured"); - } - - if (!MERCHANT_PORTAL_API_KEY) { - throw new Error("Merchant Portal API key is not configured"); - } + const headers = await getApiHeaders(); // Build query string from options const params = new URLSearchParams(); @@ -45,11 +33,11 @@ export async function getTransactions( } if (options.sortBy) { - params.append("sort_by", options.sortBy); + params.append("sortBy", options.sortBy); } if (options.sortDir) { - params.append("sort_dir", options.sortDir); + params.append("sortDir", options.sortDir); } if (options.limit) { @@ -69,11 +57,9 @@ export async function getTransactions( } const queryString = params.toString(); - const endpoint = `/merchants/${merchantId}/payments${queryString ? `?${queryString}` : ""}`; + const endpoint = `/v1/merchants/payments${queryString ? `?${queryString}` : ""}`; return apiClient.get(endpoint, { - headers: { - "x-api-key": MERCHANT_PORTAL_API_KEY, - }, + headers, }); } diff --git a/dapps/pos-app/services/transactions.web.ts b/dapps/pos-app/services/transactions.web.ts index 6f736ff1..0ad1bd24 100644 --- a/dapps/pos-app/services/transactions.web.ts +++ b/dapps/pos-app/services/transactions.web.ts @@ -7,12 +7,12 @@ export interface GetTransactionsOptions { sortDir?: "asc" | "desc"; limit?: number; cursor?: string; + startTs?: string; + endTs?: string; } /** * Fetch merchant transactions via server-side proxy (web version) - * API key is handled server-side, only merchantId is sent from client - * TODO: Once Merchants API unifies auth with Payment API, pass credentials from client * @param options - Optional query parameters for filtering and pagination * @returns TransactionsResponse with list of payments and stats */ @@ -20,11 +20,16 @@ export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { const merchantId = useSettingsStore.getState().merchantId; + const apiKey = await useSettingsStore.getState().getCustomerApiKey(); if (!merchantId || merchantId.trim().length === 0) { throw new Error("Merchant ID is not configured"); } + if (!apiKey || apiKey.trim().length === 0) { + throw new Error("Customer API key is not configured"); + } + // Build query string from options const params = new URLSearchParams(); @@ -37,11 +42,11 @@ export async function getTransactions( } if (options.sortBy) { - params.append("sort_by", options.sortBy); + params.append("sortBy", options.sortBy); } if (options.sortDir) { - params.append("sort_dir", options.sortDir); + params.append("sortDir", options.sortDir); } if (options.limit) { @@ -52,6 +57,14 @@ export async function getTransactions( params.append("cursor", options.cursor); } + if (options.startTs) { + params.append("startTs", options.startTs); + } + + if (options.endTs) { + params.append("endTs", options.endTs); + } + const queryString = params.toString(); const url = `/api/transactions${queryString ? `?${queryString}` : ""}`; @@ -59,6 +72,7 @@ export async function getTransactions( method: "GET", headers: { "Content-Type": "application/json", + "x-api-key": apiKey, "x-merchant-id": merchantId, }, }); diff --git a/dapps/pos-app/utils/currency.test.ts b/dapps/pos-app/utils/currency.test.ts index 58ec1818..70deefc7 100644 --- a/dapps/pos-app/utils/currency.test.ts +++ b/dapps/pos-app/utils/currency.test.ts @@ -165,20 +165,24 @@ describe("formatFiatAmount", () => { expect(formatFiatAmount(undefined)).toBe("-"); }); + it("returns dash for non-numeric string", () => { + expect(formatFiatAmount("abc")).toBe("-"); + }); + it("formats USD amounts with symbol on the left", () => { - expect(formatFiatAmount(1000, "iso4217/USD")).toBe("$10.00"); - expect(formatFiatAmount(99, "iso4217/USD")).toBe("$0.99"); - expect(formatFiatAmount(123456, "iso4217/USD")).toBe("$1,234.56"); + expect(formatFiatAmount("1000", "iso4217/USD")).toBe("$10.00"); + expect(formatFiatAmount("99", "iso4217/USD")).toBe("$0.99"); + expect(formatFiatAmount("123456", "iso4217/USD")).toBe("$1,234.56"); }); it("formats EUR amounts with symbol on the right", () => { - expect(formatFiatAmount(1000, "iso4217/EUR")).toBe("10.00€"); - expect(formatFiatAmount(99, "iso4217/EUR")).toBe("0.99€"); - expect(formatFiatAmount(123456, "iso4217/EUR")).toBe("1,234.56€"); + expect(formatFiatAmount("1000", "iso4217/EUR")).toBe("10.00€"); + expect(formatFiatAmount("99", "iso4217/EUR")).toBe("0.99€"); + expect(formatFiatAmount("123456", "iso4217/EUR")).toBe("1,234.56€"); }); it("defaults to USD for missing currency", () => { - expect(formatFiatAmount(1000)).toBe("$10.00"); - expect(formatFiatAmount(1000, undefined)).toBe("$10.00"); + expect(formatFiatAmount("1000")).toBe("$10.00"); + expect(formatFiatAmount("1000", undefined)).toBe("$10.00"); }); }); diff --git a/dapps/pos-app/utils/currency.ts b/dapps/pos-app/utils/currency.ts index fc1c0118..e62d8580 100644 --- a/dapps/pos-app/utils/currency.ts +++ b/dapps/pos-app/utils/currency.ts @@ -84,15 +84,17 @@ export function extractCurrencyCode(currency?: string): string { /** * Format fiat amount from cents to display string - * @param amount - Amount in cents (e.g., 1000 = $10.00) + * @param amount - Amount in cents as a string (e.g., "1000" = $10.00) * @param currency - Currency in CAIP format (e.g., "iso4217/USD") * @returns Formatted string (e.g., "$10.00" for USD, "10.00€" for EUR) */ -export function formatFiatAmount(amount?: number, currency?: string): string { - if (amount === undefined) return "-"; +export function formatFiatAmount(amount?: string, currency?: string): string { + if (!amount) return "-"; + const parsed = parseInt(amount, 10); + if (isNaN(parsed)) return "-"; // Convert cents to dollars - const value = amount / 100; + const value = parsed / 100; const currencyCode = extractCurrencyCode(currency); const currencyData = getCurrency(currencyCode); diff --git a/dapps/pos-app/utils/tokens.ts b/dapps/pos-app/utils/tokens.ts deleted file mode 100644 index 7cab797a..00000000 --- a/dapps/pos-app/utils/tokens.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Token utilities for parsing CAIP-19 identifiers and formatting token amounts - */ - -/** - * Known token contract addresses mapped to their symbols and decimals - * Format: { [chainId]: { [contractAddress]: { symbol, decimals } } } - * Note: Addresses are stored lowercase for case-insensitive comparison - */ -const KNOWN_TOKENS: Record< - string, - Record -> = { - // Ethereum Mainnet - "1": { - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": { - symbol: "USDC", - decimals: 6, - }, - "0xdac17f958d2ee523a2206206994597c13d831ec7": { - symbol: "USDT", - decimals: 6, - }, - }, - // Base - "8453": { - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { - symbol: "USDC", - decimals: 6, - }, - }, - // Arbitrum - "42161": { - "0xaf88d065e77c8cc2239327c5edb3a432268e5831": { - symbol: "USDC", - decimals: 6, - }, - "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9": { - symbol: "USDT", - decimals: 6, - }, - }, - // Polygon - "137": { - "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359": { - symbol: "USDC", - decimals: 6, - }, - "0xc2132d05d31c914a87c6611c10748aeb04b58e8f": { - symbol: "USDT", - decimals: 6, - }, - }, - // Optimism - "10": { - "0x0b2c639c533813f4aa9d7837caf62653d097ff85": { - symbol: "USDC", - decimals: 6, - }, - "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58": { - symbol: "USDT", - decimals: 6, - }, - }, -}; - -export interface TokenInfo { - symbol: string; - decimals: number; - chainId: string; - contractAddress: string; -} - -/** - * Parse a CAIP-19 token identifier - * Format: eip155:{chainId}/erc20:{contractAddress} - * @param caip19 - The CAIP-19 identifier - * @returns Parsed token info or null if invalid/unknown - */ -export function parseTokenCaip19(caip19?: string): TokenInfo | null { - if (!caip19) return null; - - // Parse format: eip155:{chainId}/erc20:{contractAddress} - const match = caip19.match(/^eip155:(\d+)\/erc20:(.+)$/i); - if (!match) return null; - - const chainId = match[1]; - const contractAddress = match[2].toLowerCase(); - - const chainTokens = KNOWN_TOKENS[chainId]; - if (!chainTokens) return null; - - const tokenInfo = chainTokens[contractAddress]; - if (!tokenInfo) return null; - - return { - symbol: tokenInfo.symbol, - decimals: tokenInfo.decimals, - chainId, - contractAddress, - }; -} - -/** - * Format a raw token amount with proper decimals - * @param rawAmount - The raw amount string (e.g., "100000") - * @param decimals - Number of decimals (e.g., 6 for USDC) - * @returns Formatted amount string (e.g., "0.10") - */ -export function formatTokenAmount(rawAmount: string, decimals: number): string { - if (!rawAmount) return "0.00"; - - // Handle the raw amount as a string to avoid precision issues - const paddedAmount = rawAmount.padStart(decimals + 1, "0"); - const integerPart = paddedAmount.slice(0, -decimals) || "0"; - const decimalPart = paddedAmount.slice(-decimals); - - // Format integer part with commas using regex to avoid precision loss - const formattedInteger = - integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",") || "0"; - - // Trim trailing zeros from decimal part but keep at least 2 decimals - let trimmedDecimal = decimalPart.replace(/0+$/, ""); - if (trimmedDecimal.length < 2) { - trimmedDecimal = decimalPart.slice(0, 2); - } - - return `${formattedInteger}.${trimmedDecimal}`; -} - -/** - * Format crypto received display string from token_caip19 and token_amount - * @param tokenCaip19 - CAIP-19 identifier (e.g., "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") - * @param tokenAmount - Raw amount string (e.g., "100000") - * @returns Formatted display string (e.g., "0.10 USDC") or null if unknown token - */ -export function formatCryptoReceived( - tokenCaip19?: string, - tokenAmount?: string, -): string | null { - const tokenInfo = parseTokenCaip19(tokenCaip19); - if (!tokenInfo || !tokenAmount) return null; - - const formattedAmount = formatTokenAmount(tokenAmount, tokenInfo.decimals); - return `${formattedAmount} ${tokenInfo.symbol}`; -} - -/** - * Get the token symbol from a CAIP-19 identifier - * @param tokenCaip19 - CAIP-19 identifier - * @returns Token symbol (e.g., "USDC") or null if unknown - */ -export function getTokenSymbol(tokenCaip19?: string): string | null { - const tokenInfo = parseTokenCaip19(tokenCaip19); - return tokenInfo?.symbol ?? null; -} diff --git a/dapps/pos-app/utils/types.ts b/dapps/pos-app/utils/types.ts index 1ea3f6b9..c6f6aa59 100644 --- a/dapps/pos-app/utils/types.ts +++ b/dapps/pos-app/utils/types.ts @@ -55,41 +55,66 @@ export type DateRangeFilterType = | "this_week" | "this_month"; +export interface DisplayAmount { + formatted?: string; + assetSymbol?: string; + decimals?: number; + iconUrl?: string; + networkName?: string; +} + +export interface AmountWithDisplay { + unit?: string; + value?: string; + display?: DisplayAmount; +} + +export interface BuyerInfo { + accountCaip10?: string; + accountProviderName?: string; + accountProviderIcon?: string; +} + +export interface TransactionInfo { + networkId?: string; + hash?: string; + nonce?: number; +} + +export interface SettlementInfo { + status?: string; + txHash?: string; +} + export interface PaymentRecord { - payment_id: string; - reference_id: string; + paymentId: string; + merchantId?: string; + referenceId?: string; status: TransactionStatus; - merchant_id: string; - is_terminal: boolean; - wallet_name: string; - version: string; - tx_hash?: string; - fiat_amount?: number; - fiat_currency?: string; - token_amount?: string; - token_caip19?: string; - chain_id?: string; - created_at?: string; - confirmed_at?: string; - broadcasted_at?: string; - processing_at?: string; - finalized_at?: string; - last_updated_at?: string; - buyer_caip10?: string; - nonce?: number; + isTerminal: boolean; + fiatAmount?: AmountWithDisplay; + tokenAmount?: AmountWithDisplay; + buyer?: BuyerInfo; + transaction?: TransactionInfo; + settlement?: SettlementInfo; + createdAt?: string; + lastUpdatedAt?: string; + settledAt?: string; +} + +export interface TotalRevenue { + amount: number; + currency: string; } export interface TransactionStats { - total_transactions: number; - total_customers: number; - total_revenue?: { - amount: number; - currency: string; - }; + totalTransactions: number; + totalCustomers: number; + totalRevenue?: TotalRevenue[]; } export interface TransactionsResponse { data: PaymentRecord[]; stats?: TransactionStats; - next_cursor?: string | null; + nextCursor?: string | null; } From 5e44aeeadf5be552e9d6979a781680bcd3d28512 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:44:07 -0300 Subject: [PATCH 6/7] fix(pos-app): handle empty response body in transactions proxy The upstream API can return an empty body on error responses, causing JSON.parse to fail. Read as text first and only parse if non-empty. Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/api/transactions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dapps/pos-app/api/transactions.ts b/dapps/pos-app/api/transactions.ts index 70a9d092..bc7eb205 100644 --- a/dapps/pos-app/api/transactions.ts +++ b/dapps/pos-app/api/transactions.ts @@ -61,7 +61,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { headers: getApiHeaders(credentials.apiKey, credentials.merchantId), }); - const data = await response.json(); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; if (!response.ok) { return res.status(response.status).json(data); From 9b0da29e787f93a2a3ae1d7105a224dc5caabeaf Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:20:20 -0300 Subject: [PATCH 7/7] fix(pos-app): remove /v1 prefix from merchants endpoint and clean up queryOptions Base URL already includes /v1, so endpoint was resolving to /v1/v1/merchants/payments. Also remove unused queryOptions from useTransactions since sort/limit are hardcoded. Co-Authored-By: Claude Opus 4.6 --- dapps/pos-app/api/transactions.ts | 2 +- dapps/pos-app/services/hooks.ts | 10 ++-------- dapps/pos-app/services/transactions.ts | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/dapps/pos-app/api/transactions.ts b/dapps/pos-app/api/transactions.ts index bc7eb205..c172ed71 100644 --- a/dapps/pos-app/api/transactions.ts +++ b/dapps/pos-app/api/transactions.ts @@ -54,7 +54,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const queryString = params.toString(); const normalizedBaseUrl = apiBaseUrl.replace(/\/+$/, ""); - const endpoint = `/v1/merchants/payments${queryString ? `?${queryString}` : ""}`; + const endpoint = `/merchants/payments${queryString ? `?${queryString}` : ""}`; const response = await fetch(`${normalizedBaseUrl}${endpoint}`, { method: "GET", diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index b81e4ca8..582043f6 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -13,7 +13,7 @@ import { import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { cancelPayment, getPaymentStatus, startPayment } from "./payment"; -import { getTransactions, GetTransactionsOptions } from "./transactions"; +import { getTransactions } from "./transactions"; const KNOWN_STATUSES: string[] = [ "requires_action", @@ -172,7 +172,6 @@ interface UseTransactionsOptions { /** * Additional query options for the API */ - queryOptions?: GetTransactionsOptions; } /** @@ -208,14 +207,10 @@ export function useTransactions(options: UseTransactionsOptions = {}) { enabled = true, filter = "all", dateRangeFilter = "today", - queryOptions = {}, } = options; const addLog = useLogsStore.getState().addLog; - // Extract relevant fields for query key to avoid cache misses from object reference changes - const { sortBy, sortDir, limit } = queryOptions; - // Compute date range once per filter change so toDate stays stable across paginated fetches const { startTs, endTs } = useMemo( () => getDateRange(dateRangeFilter), @@ -223,11 +218,10 @@ export function useTransactions(options: UseTransactionsOptions = {}) { ); const query = useInfiniteQuery({ - queryKey: ["transactions", filter, dateRangeFilter, sortBy, sortDir, limit], + queryKey: ["transactions", filter, dateRangeFilter], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); return getTransactions({ - ...queryOptions, status: statusFilter, startTs, endTs, diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index 39f140c3..a0f52d06 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -57,7 +57,7 @@ export async function getTransactions( } const queryString = params.toString(); - const endpoint = `/v1/merchants/payments${queryString ? `?${queryString}` : ""}`; + const endpoint = `/merchants/payments${queryString ? `?${queryString}` : ""}`; return apiClient.get(endpoint, { headers,