Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
15 changes: 15 additions & 0 deletions dapps/pos-app/api/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL;
const MERCHANT_API_BASE_URL =
process.env.EXPO_PUBLIC_MERCHANT_DEV_API_URL || API_BASE_URL;

/**
* Extract and validate merchant credentials from a proxied request.
Expand Down Expand Up @@ -36,6 +38,19 @@ export function getApiBaseUrl(res: VercelResponse): string | null {
return API_BASE_URL;
}

/**
* Get the merchant API base URL (uses dev override when set, otherwise falls back to default).
*/
export function getMerchantApiBaseUrl(res: VercelResponse): string | null {
if (!MERCHANT_API_BASE_URL) {
res.status(500).json({
message: "API_BASE_URL is not configured",
});
return null;
}
return MERCHANT_API_BASE_URL;
}

/**
* Build the headers for forwarding a request to the merchant API.
*/
Expand Down
63 changes: 27 additions & 36 deletions dapps/pos-app/api/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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,
getMerchantApiBaseUrl,
getApiHeaders,
} from "./_utils";

/**
* Vercel Serverless Function to proxy transaction list requests
Expand All @@ -17,30 +18,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 = getMerchantApiBaseUrl(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 +37,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
Loading
Loading