Skip to content
Open
2 changes: 0 additions & 2 deletions dapps/pos-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
19 changes: 9 additions & 10 deletions dapps/pos-app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand All @@ -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,
});
```
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion dapps/pos-app/__tests__/store/useSettingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 23 additions & 36 deletions dapps/pos-app/api/transactions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -50,32 +33,36 @@ 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);
}
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);
Expand Down
139 changes: 124 additions & 15 deletions dapps/pos-app/app/activity.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TransactionFilterType, string> = {
all: "Status",
pending: "Pending",
completed: "Completed",
failed: "Failed",
expired: "Expired",
cancelled: "Cancelled",
};

const DATE_RANGE_LABELS: Record<DateRangeFilterType, string> = {
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<PaymentRecord | null>(
null,
);
const [modalVisible, setModalVisible] = useState(false);
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);

const statusOptions: RadioOption<TransactionFilterType>[] = 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,
Expand All @@ -38,6 +105,7 @@ export default function ActivityScreen() {
isFetchingNextPage,
} = useTransactions({
filter: transactionFilter,
dateRangeFilter,
});

// Show error toast when fetch fails
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -116,18 +194,25 @@ export default function ActivityScreen() {
);
}, [isFetchingNextPage, theme]);

const listHeader = useMemo(
() => (
<FilterButtons
statusLabel={STATUS_LABELS[transactionFilter]}
dateRangeLabel={DATE_RANGE_LABELS[dateRangeFilter]}
onStatusPress={() => setActiveSheet("status")}
onDateRangePress={() => setActiveSheet("dateRange")}
/>
),
[transactionFilter, dateRangeFilter],
);

return (
<>
<FlatList
data={transactions}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={
<FilterTabs
selectedFilter={transactionFilter}
onFilterChange={handleFilterChange}
/>
}
ListHeaderComponent={listHeader}
contentContainerStyle={[
styles.listContent,
(!transactions || transactions?.length === 0) &&
Expand All @@ -151,6 +236,30 @@ export default function ActivityScreen() {
}
/>

<SettingsBottomSheet
visible={activeSheet === "status"}
title="Status"
onClose={closeSheet}
>
<RadioList
options={statusOptions}
value={transactionFilter}
onChange={handleStatusChange}
/>
</SettingsBottomSheet>

<SettingsBottomSheet
visible={activeSheet === "dateRange"}
title="Date Range"
onClose={closeSheet}
>
<RadioList
options={DATE_RANGE_OPTIONS}
value={dateRangeFilter}
onChange={handleDateRangeChange}
/>
</SettingsBottomSheet>

<TransactionDetailModal
visible={modalVisible}
payment={selectedPayment}
Expand Down
5 changes: 1 addition & 4 deletions dapps/pos-app/app/payment-success.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,7 @@ export default function PaymentSuccessScreen() {
onPress={handleNewPayment}
>
<ThemedText
style={[
styles.buttonText,
{ color: DarkTheme["text-primary"] },
]}
style={[styles.buttonText, { color: DarkTheme["text-primary"] }]}
>
New payment
</ThemedText>
Expand Down
Loading
Loading