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/__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/api/transactions.ts b/dapps/pos-app/api/transactions.ts index dc203bc2..c172ed71 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,20 +45,24 @@ 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 = `/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(); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; if (!response.ok) { return res.status(response.status).json(data); diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index a485c501..7d5a418c 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,74 @@ 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", + pending: "Pending", + completed: "Completed", + failed: "Failed", + expired: "Expired", + cancelled: "Cancelled", +}; + +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: "pending", + label: "Pending", + dotColor: theme["icon-default"], + }, + { + value: "completed", + 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], + ); const { transactions, @@ -38,6 +105,7 @@ export default function ActivityScreen() { isFetchingNextPage, } = useTransactions({ filter: transactionFilter, + dateRangeFilter, }); // Show error toast when fetch fails @@ -47,13 +115,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); @@ -75,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) { @@ -116,18 +194,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 +236,30 @@ export default function ActivityScreen() { } /> + + + + + + + + New payment diff --git a/dapps/pos-app/components/filter-buttons.tsx b/dapps/pos-app/components/filter-buttons.tsx new file mode 100644 index 00000000..b774c89b --- /dev/null +++ b/dapps/pos-app/components/filter-buttons.tsx @@ -0,0 +1,93 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { useAssets } from "expo-asset"; +import { Image } from "expo-image"; +import { memo } from "react"; +import { StyleSheet, View } from "react-native"; +import { Button } from "./button"; +import { ThemedText } from "./themed-text"; + +interface FilterButtonsProps { + statusLabel: string; + dateRangeLabel: string; + onStatusPress: () => 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/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 ded022d3..582043f6 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, @@ -9,9 +11,9 @@ 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"; +import { getTransactions } from "./transactions"; const KNOWN_STATUSES: string[] = [ "requires_action", @@ -162,10 +164,14 @@ interface UseTransactionsOptions { * @default "all" */ filter?: TransactionFilterType; + /** + * Filter transactions by date range + * @default "today" + */ + dateRangeFilter?: DateRangeFilterType; /** * Additional query options for the API */ - queryOptions?: GetTransactionsOptions; } /** @@ -175,12 +181,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; @@ -193,20 +203,28 @@ 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", + } = 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), + [dateRangeFilter], + ); const query = useInfiniteQuery({ - queryKey: ["transactions", filter, sortBy, sortDir, limit], + queryKey: ["transactions", filter, dateRangeFilter], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); return getTransactions({ - ...queryOptions, status: statusFilter, + startTs, + endTs, sortBy: "date", sortDir: "desc", limit: 20, @@ -214,7 +232,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 3ac76fad..a0f52d06 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[]; @@ -11,6 +7,8 @@ export interface GetTransactionsOptions { sortDir?: "asc" | "desc"; limit?: number; cursor?: string; + startTs?: string; + endTs?: string; } /** @@ -21,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(); @@ -43,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) { @@ -58,12 +48,18 @@ 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 endpoint = `/merchants/${merchantId}/payments${queryString ? `?${queryString}` : ""}`; + const endpoint = `/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/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/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/date-range.ts b/dapps/pos-app/utils/date-range.ts new file mode 100644 index 00000000..2bc376b0 --- /dev/null +++ b/dapps/pos-app/utils/date-range.ts @@ -0,0 +1,51 @@ +import { DateRangeFilterType } from "./types"; + +interface DateRange { + startTs?: string; + endTs?: 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 endTs = 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; + } + default: { + const _exhaustive: never = filter; + return {}; + } + } + + return { startTs: from.toISOString(), endTs }; +} 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 2e88dc8c..c6f6aa59 100644 --- a/dapps/pos-app/utils/types.ts +++ b/dapps/pos-app/utils/types.ts @@ -40,43 +40,81 @@ 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" + | "today" + | "7_days" + | "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; }