diff --git a/dapps/pos-app/README.md b/dapps/pos-app/README.md index 0f2f54d0..7d62cd23 100644 --- a/dapps/pos-app/README.md +++ b/dapps/pos-app/README.md @@ -26,6 +26,10 @@ Follow the official React Native documentation to set up your environment: Update the `.env` file with your configuration values. + To simulate payments without merchant credentials or a wallet, enable Test + Mode in Settings. In Test Mode, `0.02` is declined, every other valid amount + succeeds, and Transactions shows local sample records. + 3. Create native folders ```bash diff --git a/dapps/pos-app/__tests__/services/test-payment.test.ts b/dapps/pos-app/__tests__/services/test-payment.test.ts new file mode 100644 index 00000000..0f4a6581 --- /dev/null +++ b/dapps/pos-app/__tests__/services/test-payment.test.ts @@ -0,0 +1,11 @@ +import { isTestPaymentFailure } from "@/services/test-payment"; + +describe("isTestPaymentFailure", () => { + it("fails only the 0.02 Test Mode amount", () => { + expect(isTestPaymentFailure("0.02")).toBe(true); + }); + + it.each(["0.01", "1.00", "25.50"])("succeeds for %s", (amount) => { + expect(isTestPaymentFailure(amount)).toBe(false); + }); +}); diff --git a/dapps/pos-app/__tests__/services/test-transactions.test.ts b/dapps/pos-app/__tests__/services/test-transactions.test.ts new file mode 100644 index 00000000..80acf3a7 --- /dev/null +++ b/dapps/pos-app/__tests__/services/test-transactions.test.ts @@ -0,0 +1,34 @@ +import { getTestTransactions } from "@/services/test-transactions"; +import { useSettingsStore } from "@/store/useSettingsStore"; + +describe("getTestTransactions", () => { + beforeEach(() => { + useSettingsStore.setState({ currency: "USD" }); + }); + + it("returns one local record for every payment status", () => { + const response = getTestTransactions(); + + expect(response.data).toHaveLength(6); + expect(response.data.map((payment) => payment.status)).toEqual([ + "requires_action", + "processing", + "succeeded", + "failed", + "expired", + "cancelled", + ]); + expect(response.nextCursor).toBeNull(); + }); + + it("applies status filters and never needs a cursor", () => { + const response = getTestTransactions({ + status: ["succeeded", "failed"], + limit: 1, + cursor: "ignored", + }); + + expect(response.data).toHaveLength(1); + expect(response.data[0].status).toBe("succeeded"); + }); +}); diff --git a/dapps/pos-app/__tests__/services/transactions.test.ts b/dapps/pos-app/__tests__/services/transactions.test.ts new file mode 100644 index 00000000..9928a486 --- /dev/null +++ b/dapps/pos-app/__tests__/services/transactions.test.ts @@ -0,0 +1,37 @@ +jest.mock("@/services/client", () => ({ + getApiHeaders: jest.fn(async () => ({ "Api-Key": "local-key" })), + merchantApiClient: { get: jest.fn() }, +})); + +import { getTransactions } from "@/services/transactions"; +import { merchantApiClient } from "@/services/client"; +import { useSettingsStore } from "@/store/useSettingsStore"; + +describe("native transaction service", () => { + beforeEach(() => { + jest.clearAllMocks(); + useSettingsStore.setState({ testMode: false, currency: "USD" }); + }); + + it("uses local records only while Test Mode is enabled", async () => { + useSettingsStore.setState({ testMode: true }); + + await expect( + getTransactions({ status: ["succeeded"] }), + ).resolves.toMatchObject({ + data: [expect.objectContaining({ paymentId: "test_succeeded" })], + nextCursor: null, + }); + expect(merchantApiClient.get).not.toHaveBeenCalled(); + }); + + it("uses the merchant API while Test Mode is disabled", async () => { + (merchantApiClient.get as jest.Mock).mockResolvedValueOnce({ data: [] }); + + await expect(getTransactions()).resolves.toEqual({ data: [] }); + expect(merchantApiClient.get).toHaveBeenCalledWith( + "/merchants/payments", + expect.objectContaining({ headers: { "Api-Key": "local-key" } }), + ); + }); +}); diff --git a/dapps/pos-app/__tests__/services/web-bridge-services.test.ts b/dapps/pos-app/__tests__/services/web-bridge-services.test.ts index ec3dea45..0c78952e 100644 --- a/dapps/pos-app/__tests__/services/web-bridge-services.test.ts +++ b/dapps/pos-app/__tests__/services/web-bridge-services.test.ts @@ -56,6 +56,7 @@ describe("web services with the POS bridge", () => { useSettingsStore.setState({ merchantId: "merchant-direct", isCustomerApiKeySet: true, + testMode: false, getCustomerApiKey: jest.fn(async () => "local-key"), }); }); @@ -163,6 +164,21 @@ describe("web services with the POS bridge", () => { ); }); + it("uses local test transactions instead of the bridge or proxy", async () => { + setEmbeddedWindow(); + configureBridge(parentWindow, parentOrigin, "merchant-bridge"); + useSettingsStore.setState({ testMode: true }); + + await expect( + getTransactions({ status: ["succeeded"] }), + ).resolves.toMatchObject({ + data: [expect.objectContaining({ paymentId: "test_succeeded" })], + nextCursor: null, + }); + expect(parentPostMessage).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + it("does not fall back to standalone credentials while an iframe awaits bridge configuration", async () => { setEmbeddedWindow(); const getCustomerApiKey = useSettingsStore.getState() diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index 832cf79e..36240eca 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -54,6 +54,10 @@ describe("useSettingsStore", () => { const { biometricEnabled } = useSettingsStore.getState(); expect(biometricEnabled).toBe(false); }); + + it("should have Test Mode disabled", () => { + expect(useSettingsStore.getState().testMode).toBe(false); + }); }); describe("setThemeMode", () => { @@ -76,6 +80,16 @@ describe("useSettingsStore", () => { }); }); + describe("setTestMode", () => { + it("enables and disables Test Mode", () => { + useSettingsStore.getState().setTestMode(true); + expect(useSettingsStore.getState().testMode).toBe(true); + + useSettingsStore.getState().setTestMode(false); + expect(useSettingsStore.getState().testMode).toBe(false); + }); + }); + describe("setDeviceId", () => { it("should set device ID", () => { const { setDeviceId } = useSettingsStore.getState(); @@ -539,7 +553,7 @@ describe("useSettingsStore", () => { // Check persist name and version are set (for storage key) expect(persistOptions?.name).toBe("settings"); - expect(persistOptions?.version).toBe(19); + expect(persistOptions?.version).toBe(20); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -573,5 +587,14 @@ describe("useSettingsStore", () => { expect(migrated.hasInitializedDefaults).toBe(true); }); + + it("defaults Test Mode to off for installs from before it existed", () => { + const migrate = useSettingsStore.persist?.getOptions?.().migrate; + expect(migrate).toBeDefined(); + + const migrated: any = migrate!({ variant: "default" }, 19); + + expect(migrated.testMode).toBe(false); + }); }); }); diff --git a/dapps/pos-app/__tests__/utils/store-helpers.ts b/dapps/pos-app/__tests__/utils/store-helpers.ts index 06aa92a4..5770c71a 100644 --- a/dapps/pos-app/__tests__/utils/store-helpers.ts +++ b/dapps/pos-app/__tests__/utils/store-helpers.ts @@ -21,6 +21,7 @@ export function resetSettingsStore() { pinFailedAttempts: 0, pinLockoutUntil: null, biometricEnabled: false, + testMode: false, }); } diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index efdc154c..3b231e09 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -2,6 +2,7 @@ import { EmptyState } from "@/components/empty-state"; import { FilterButtons } from "@/components/filter-buttons"; import { RadioList, RadioOption } from "@/components/radio-list"; import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; +import { TestModeOverlay } from "@/components/test-mode-pill"; import { TransactionCard } from "@/components/transaction-card"; import { TransactionDetailModal } from "@/components/transaction-detail-modal"; import { Spacing } from "@/constants/spacing"; @@ -48,6 +49,8 @@ const DATE_RANGE_LABELS: Record = { export default function ActivityScreen() { const theme = useTheme(); + const testMode = useSettingsStore((state) => state.testMode); + const isTestPayment = testMode; const transactionFilter = useSettingsStore( (state) => state.transactionFilter, ); @@ -243,6 +246,7 @@ export default function ActivityScreen() { return ( + {isTestPayment && } {!isInitialLoadError && ( <> @@ -330,6 +334,7 @@ export default function ActivityScreen() { const styles = StyleSheet.create({ container: { flex: 1, + position: "relative", paddingTop: Spacing["spacing-4"], }, list: { diff --git a/dapps/pos-app/app/amount.tsx b/dapps/pos-app/app/amount.tsx index 0edae1e7..66a327f2 100644 --- a/dapps/pos-app/app/amount.tsx +++ b/dapps/pos-app/app/amount.tsx @@ -1,6 +1,7 @@ import { BigAmountInput } from "@/components/big-amount-input"; import { Button } from "@/components/button"; import { NumericKeyboard } from "@/components/numeric-keyboard"; +import { TestModeOverlay } from "@/components/test-mode-pill"; import { Spacing } from "@/constants/spacing"; import { useIsTablet } from "@/hooks/use-is-tablet"; import { useTheme } from "@/hooks/use-theme-color"; @@ -37,6 +38,8 @@ const formatAmount = (amount: string) => { export default function AmountScreen() { const Theme = useTheme(); + const testMode = useSettingsStore((state) => state.testMode); + const isTestPayment = testMode; const isTablet = useIsTablet(); const currencyCode = useSettingsStore((state) => state.currency); const currency = getCurrency(currencyCode); @@ -64,6 +67,7 @@ export default function AmountScreen() { return ( + {isTestPayment && } state.isCustomerApiKeySet, ); + const testMode = useSettingsStore((state) => state.testMode); const isBridgeConfigured = usePosBridgeStore((state) => state.isConfigured); const bridgeMerchantId = usePosBridgeStore((state) => state.merchantId); const isIframeSession = isRunningInIframe(); const handleStartPayment = () => { if ( + !testMode && !isTerminalConfigured( getMerchantIdForSession(isIframeSession, merchantId, bridgeMerchantId), isIframeSession ? false : isCustomerApiKeySet, diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index ff988453..5fc9a9c6 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -24,16 +24,19 @@ import { buildReceiptLogo } from "@/utils/build-receipt-logo"; import { resetNavigation } from "@/utils/navigation"; import { connectPrinter, printReceipt } from "@/utils/printer"; import { Image } from "expo-image"; -import { StatusBar } from "expo-status-bar"; +// The params can't be declared optional here: `UnknownOutputParams` indexes to +// `string | string[]`, so `?` widens to undefined and breaks the constraint. +// Read them through `Partial` below instead, since the token fields are only +// passed when the payment has a displayable token. interface SuccessParams extends UnknownOutputParams { amount: string; - chainName?: string; - token?: string; + chainName: string; + token: string; timestamp: string; paymentId: string; - tokenAmount?: string; - tokenDecimals?: string; + tokenAmount: string; + tokenDecimals: string; } const { width: screenWidth, height: screenHeight } = Dimensions.get("screen"); @@ -48,8 +51,8 @@ export default function PaymentSuccessScreen() { useDisableBackButton(); const Theme = useTheme(); const isTablet = useIsTablet(); - const params = useLocalSearchParams(); - const themeMode = useSettingsStore((state) => state.themeMode); + const params: Partial = useLocalSearchParams(); + const currencyCode = useSettingsStore((state) => state.currency); const variant = useSettingsStore((state) => state.variant); const getVariantPrinterLogo = useSettingsStore( @@ -58,11 +61,9 @@ export default function PaymentSuccessScreen() { const currency = getCurrency(currencyCode); const addLog = useLogsStore((state) => state.addLog); const { top, bottom } = useSafeAreaInsets(); - const { amount } = params; + const { amount = "" } = params; const [isPrinterConnected, setIsPrinterConnected] = useState(false); const [isPrinting, setIsPrinting] = useState(false); - const [isThemeBackgroundVisible, setIsThemeBackgroundVisible] = - useState(false); const [isSuccessAnimationVisible, setIsSuccessAnimationVisible] = useState(false); const isPrintingRef = useRef(false); @@ -90,7 +91,7 @@ export default function PaymentSuccessScreen() { const logoBase64 = (await buildReceiptLogo(variant)) ?? getVariantPrinterLogo(); await printReceipt({ - txnId: params.paymentId, + txnId: params.paymentId ?? "", amountFiat: Number(amount), currency, tokenSymbol: params.token, @@ -156,7 +157,6 @@ export default function PaymentSuccessScreen() { withTiming(0, { duration: contentRevealDuration }), ); const revealTimeout = setTimeout(() => { - setIsThemeBackgroundVisible(true); setIsSuccessAnimationVisible(true); }, contentRevealDelay); @@ -295,15 +295,6 @@ export default function PaymentSuccessScreen() { - ); } diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 8bd836e5..5d359253 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/button"; import QRCode from "@/components/qr-code"; +import { TestModeOverlay } from "@/components/test-mode-pill"; import { ThemedText } from "@/components/themed-text"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; import { Spacing } from "@/constants/spacing"; @@ -10,6 +11,7 @@ import { useNfcPayment } from "@/hooks/use-nfc-payment"; import { useTheme } from "@/hooks/use-theme-color"; import { usePaymentStatus } from "@/services/hooks"; import { cancelPayment, startPayment } from "@/services/payment"; +import { isTestPaymentFailure } from "@/services/test-payment"; import { useLogsStore } from "@/store/useLogsStore"; import { usePosBridgeStore } from "@/store/usePosBridgeStore"; import { useSettingsStore } from "@/store/useSettingsStore"; @@ -38,7 +40,7 @@ import { useLocalSearchParams, useNavigation, } from "expo-router"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AccessibilityInfo, StyleSheet, View } from "react-native"; import { v4 as uuidv4 } from "uuid"; @@ -56,10 +58,15 @@ export default function ScanScreen() { require("@/assets/images/wc-logo-dark.png"), require("@/assets/images/nfc.png"), ]); + const qrLogoSource = useMemo(() => { + const uri = assets?.[0]?.uri; + return uri ? { uri } : undefined; + }, [assets]); const [qrUri, setQrUri] = useState(""); const [paymentId, setPaymentId] = useState(null); const [expiresAt, setExpiresAt] = useState(null); + const [testProcessing, setTestProcessing] = useState(false); const hasNavigatedRef = useRef(false); const hasCancelledRef = useRef(false); const hasLeftRef = useRef(false); @@ -67,6 +74,7 @@ export default function ScanScreen() { const deviceId = useSettingsStore((state) => state.deviceId); const storedMerchantId = useSettingsStore((state) => state.merchantId); + const testMode = useSettingsStore((state) => state.testMode); const bridgeMerchantId = usePosBridgeStore((state) => state.merchantId); const merchantId = getMerchantIdForSession( isRunningInIframe(), @@ -81,6 +89,7 @@ export default function ScanScreen() { const isTablet = useIsTablet(); const { amount } = params; + const isTestPayment = testMode; const { nfcMode } = useNfcPayment({ paymentUrl: qrUri, @@ -133,11 +142,16 @@ export default function ScanScreen() { ); const handleOnCancelPress = () => { + if (isTestPayment) { + setTestProcessing(false); + } // The `beforeRemove` listener below cancels the payment on leave. resetNavigation("/amount"); }; const handleCopyPaymentUrl = async () => { + // No real URL to copy in test mode. + if (isTestPayment) return; await Clipboard.setStringAsync(qrUri); showSuccessToast("Payment link copied"); }; @@ -146,7 +160,7 @@ export default function ScanScreen() { if (!deviceId || !amount) return; async function initiatePayment() { - if (!merchantId) { + if (!isTestPayment && !merchantId) { addLog( "error", "Merchant ID is not configured", @@ -160,6 +174,23 @@ export default function ScanScreen() { } try { + if (isTestPayment) { + const testPaymentId = `test_${Date.now()}`; + const testQrUrl = `${testPaymentId}?amount=${encodeURIComponent(amount)}`; + + addLog("info", "Test payment started", "scan", "initiatePayment", { + paymentId: testPaymentId, + amount, + }); + setQrUri(testQrUrl); + setPaymentId(testPaymentId); + // useCountdown expects an epoch timestamp in seconds, matching the + // API response format. + setExpiresAt(Math.floor(Date.now() / 1000) + 15 * 60); + setTestProcessing(true); + return; + } + const paymentRequest = { referenceId: uuidv4().replace(/-/g, ""), amount: { @@ -218,10 +249,27 @@ export default function ScanScreen() { initiatePayment(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceId, amount, merchantId]); + }, [deviceId, amount, merchantId, isTestPayment]); + + useEffect(() => { + if (!isTestPayment || !paymentId) return; + + const timeout = setTimeout(() => { + setTestProcessing(false); + if (isTestPaymentFailure(amount)) { + addLog("info", "Test payment declined", "scan", "testPayment"); + onFailure("failed"); + } else { + addLog("info", "Test payment completed", "scan", "testPayment"); + onSuccess(paymentId); + } + }, 3000); + + return () => clearTimeout(timeout); + }, [addLog, amount, isTestPayment, onFailure, onSuccess, paymentId]); const { data: paymentStatusData } = usePaymentStatus(paymentId, { - enabled: !!paymentId && !!qrUri, + enabled: !isTestPayment && !!paymentId && !!qrUri, onTerminalState: (data) => { if (data.status === "succeeded") { if (!paymentId) { @@ -255,6 +303,7 @@ export default function ScanScreen() { // resolved yet — cancel then too. const cancelPendingPayment = useCallback(() => { if (hasNavigatedRef.current || hasCancelledRef.current) return; + if (isTestPayment) return; // Record the leave so an in-flight `startPayment` cancels the payment it // creates instead of leaking it (see `initiatePayment`). hasLeftRef.current = true; @@ -271,7 +320,7 @@ export default function ScanScreen() { showErrorToast("We couldn't cancel this payment."); }); } - }, [paymentId, paymentStatusData?.status, addLog]); + }, [paymentId, paymentStatusData?.status, addLog, isTestPayment]); // Hold the latest callback in a ref so the `beforeRemove` listener stays // registered once for the screen's lifetime instead of being torn down and @@ -347,6 +396,7 @@ export default function ScanScreen() { gestureEnabled: !backHidden, }} /> + {isTestPayment && } {isProcessing ? ( - {showNfc ? "Scan or tap to pay" : "Scan to pay"} + {testProcessing + ? "Waiting for confirmation..." + : showNfc + ? "Scan or tap to pay" + : "Scan to pay"} state.setCurrency); const nfcEnabled = useSettingsStore((state) => state.nfcEnabled); const setNfcEnabled = useSettingsStore((state) => state.setNfcEnabled); + const testMode = useSettingsStore((state) => state.testMode); + const setTestMode = useSettingsStore((state) => state.setTestMode); const nfcCapabilities = useNfcCapabilities(); const addLog = useLogsStore((state) => state.addLog); const logsCount = useLogsStore((state) => state.logs.length); @@ -175,6 +174,16 @@ export default function SettingsScreen() { handleCustomerApiKeyConfirm(); }; + const handleTestModeChange = (enabled: boolean) => { + setTestMode(enabled); + if (enabled) { + showInfoToast( + "Enter 0.02 to simulate a failed payment. All other amounts simulate success.", + 5000, + ); + } + }; + const showNfcToggle = isNfcHceEnabled && Platform.OS === "android" && @@ -183,21 +192,15 @@ export default function SettingsScreen() { const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; const hasMerchantId = !!storedMerchantId?.trim(); - const setupRemaining = isIframeSession - ? 0 - : getConnectionSetupRemaining( - hasMerchantId, - hasStoredCustomerApiKey, - isIframeBridgeConfigured, - ); - const showConnectionSection = - isIframeSession || - shouldShowConnectionSection( - isIframeBridgeConfigured, - !!bridgeMerchantId, - showNfcToggle || showBiometricToggle, - ); - + const testActive = testMode; + const setupRemaining = + testActive || isIframeSession + ? 0 + : getConnectionSetupRemaining( + hasMerchantId, + hasStoredCustomerApiKey, + isIframeBridgeConfigured, + ); const handleTestPrinterPress = async () => { try { const isBluetoothPermissionGranted = await requestBluetoothPermission(); @@ -279,90 +282,98 @@ export default function SettingsScreen() { /> - {showConnectionSection && ( - - {isIframeSession && !isIframeBridgeConfigured ? ( - undefined} - showCaret={false} - disabled - /> - ) : isIframeBridgeConfigured ? ( + + {isIframeSession && !isIframeBridgeConfigured ? ( + undefined} + showCaret={false} + disabled + /> + ) : isIframeBridgeConfigured ? ( + undefined} + showCaret={false} + disabled + /> + ) : ( + <> undefined} - showCaret={false} - disabled + value={hasMerchantId ? merchantIdInput : undefined} + bullet={!hasMerchantId} + badge={ + hasMerchantId ? undefined : ( + + ) + } + caret="right" + showCaret + disabled={testActive} + onPress={() => setActiveSheet("merchantId")} /> - ) : ( - <> - - ) - } - caret="right" - showCaret - onPress={() => setActiveSheet("merchantId")} - /> - - ) - } - caret="right" - showCaret - onPress={() => setActiveSheet("customerApiKey")} - /> - - )} - - {showNfcToggle && ( - - )} - - {/* Biometric toggle - only show if PIN is set and biometrics available */} - {showBiometricToggle && ( - + ) + } + caret="right" + showCaret + disabled={testActive} + onPress={() => setActiveSheet("customerApiKey")} /> - )} - - )} + + )} + + {showNfcToggle && ( + + )} + + + + {/* Biometric toggle - only show if PIN is set and biometrics available */} + {showBiometricToggle && ( + + )} + { return ( prevProps.size === nextProps.size && prevProps.uri === nextProps.uri && + prevProps.imageSrc === nextProps.imageSrc && prevProps.style === nextProps.style && prevProps.logoBorderRadius === nextProps.logoBorderRadius ); diff --git a/dapps/pos-app/components/settings-toggle-item.tsx b/dapps/pos-app/components/settings-toggle-item.tsx index b9cc6f53..82fad80f 100644 --- a/dapps/pos-app/components/settings-toggle-item.tsx +++ b/dapps/pos-app/components/settings-toggle-item.tsx @@ -64,7 +64,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - height: 68, + minHeight: 68, paddingHorizontal: Spacing["spacing-5"], borderRadius: BorderRadius["4"], gap: Spacing["spacing-2"], diff --git a/dapps/pos-app/components/test-mode-pill.tsx b/dapps/pos-app/components/test-mode-pill.tsx new file mode 100644 index 00000000..33873499 --- /dev/null +++ b/dapps/pos-app/components/test-mode-pill.tsx @@ -0,0 +1,75 @@ +import { Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { ThemedText } from "./themed-text"; + +interface TestModePillProps { + style?: StyleProp; +} + +export function TestModePill({ style }: TestModePillProps) { + const theme = useTheme(); + + return ( + + + Test mode + + + ); +} + +interface TestModeOverlayProps { + // Vertical space reserved below the floating pill so screen content isn't + // hidden underneath it. The pill's on-screen position is fixed by `top` and + // is unaffected by this value. + spacerHeight?: number; +} + +export function TestModeOverlay({ + spacerHeight = Spacing["spacing-8"], +}: TestModeOverlayProps) { + return ( + <> + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignSelf: "center", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: Spacing["spacing-3"], + paddingVertical: Spacing["spacing-1"], + borderRadius: 999, + }, + overlay: { + position: "absolute", + top: Spacing["spacing-3"], + left: 0, + right: 0, + alignItems: "center", + zIndex: 1, + }, +}); diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index 29e4a5cd..2fed7ad9 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -1,9 +1,8 @@ import { useLogsStore } from "@/store/useLogsStore"; +import { useSettingsStore } from "@/store/useSettingsStore"; import { getDateRange } from "@/utils/date-range"; import { DateRangeFilterType, - PaymentRecord, - PaymentStatus, PaymentStatusResponse, StartPaymentRequest, StartPaymentResponse, @@ -204,6 +203,8 @@ function filterToStatusArray( */ export function useTransactions(options: UseTransactionsOptions = {}) { const { enabled = true, filter = "all", dateRangeFilter = "today" } = options; + const testMode = useSettingsStore((state) => state.testMode); + const testActive = testMode; const addLog = useLogsStore.getState().addLog; @@ -214,7 +215,7 @@ export function useTransactions(options: UseTransactionsOptions = {}) { ); const query = useInfiniteQuery({ - queryKey: ["transactions", filter, dateRangeFilter], + queryKey: ["transactions", filter, dateRangeFilter, testActive], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); return getTransactions({ diff --git a/dapps/pos-app/services/test-payment.ts b/dapps/pos-app/services/test-payment.ts new file mode 100644 index 00000000..4fdd12cc --- /dev/null +++ b/dapps/pos-app/services/test-payment.ts @@ -0,0 +1,9 @@ +import { amountToCents } from "@/utils/currency"; + +/** + * Test Mode reserves 0.02 as the deterministic declined-payment amount. + * Every other validated amount completes successfully. + */ +export function isTestPaymentFailure(amount: string): boolean { + return amountToCents(amount) === 2; +} diff --git a/dapps/pos-app/services/test-transactions.ts b/dapps/pos-app/services/test-transactions.ts new file mode 100644 index 00000000..24b55181 --- /dev/null +++ b/dapps/pos-app/services/test-transactions.ts @@ -0,0 +1,93 @@ +import { getCurrency } from "@/utils/currency"; +import { PaymentRecord, TransactionsResponse } from "@/utils/types"; +import { useSettingsStore } from "@/store/useSettingsStore"; + +export interface TestTransactionsOptions { + status?: string | string[]; + limit?: number; + cursor?: string; + startTs?: string; + endTs?: string; +} + +/** + * Returns local records for the test transaction screen. This deliberately + * lives below the platform-specific transaction services so native and web + * builds have identical, request-free test behavior. + */ +export function getTestTransactions( + options: TestTransactionsOptions = {}, +): TransactionsResponse { + const currency = getCurrency(useSettingsStore.getState().currency); + const now = Date.now(); + const statuses = options.status + ? new Set(Array.isArray(options.status) ? options.status : [options.status]) + : null; + + const records: PaymentRecord[] = [ + "requires_action", + "processing", + "succeeded", + "failed", + "expired", + "cancelled", + ].map((status, index) => { + const createdAt = new Date(now - index * 60_000).toISOString(); + const isTerminal = status !== "requires_action" && status !== "processing"; + + return { + paymentId: `test_${status}`, + merchantId: "test", + referenceId: `test-reference-${status}`, + status: status as PaymentRecord["status"], + isTerminal, + fiatAmount: { + value: String((index + 1) * 100), + unit: currency.unit, + }, + tokenAmount: { + value: String((index + 1) * 1000000), + unit: "eip155:8453/slip44:60", + display: { + formatted: `${index + 1}.00`, + assetSymbol: "USDC", + decimals: 6, + networkName: "Base", + }, + }, + transaction: + status === "succeeded" ? { hash: "0xtesttransactionhash" } : undefined, + createdAt, + lastUpdatedAt: createdAt, + settledAt: status === "succeeded" ? createdAt : undefined, + }; + }); + + const start = options.startTs ? Date.parse(options.startTs) : undefined; + const end = options.endTs ? Date.parse(options.endTs) : undefined; + const filtered = records.filter((record) => { + if (statuses && !statuses.has(record.status)) return false; + const createdAt = Date.parse(record.createdAt ?? ""); + if (start !== undefined && createdAt < start) return false; + if (end !== undefined && createdAt > end) return false; + return true; + }); + + return { + data: options.limit ? filtered.slice(0, options.limit) : filtered, + stats: { + totalTransactions: filtered.length, + totalCustomers: filtered.length, + totalRevenue: [ + { + amount: filtered.reduce( + (total, record) => total + Number(record.fiatAmount?.value ?? 0), + 0, + ), + currency: currency.code, + }, + ], + }, + nextCursor: null, + }; +} diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index 4576c7da..1036b420 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -1,5 +1,7 @@ import { TransactionsResponse } from "@/utils/types"; +import { useSettingsStore } from "@/store/useSettingsStore"; import { merchantApiClient, getApiHeaders } from "./client"; +import { getTestTransactions } from "./test-transactions"; export interface GetTransactionsOptions { status?: string | string[]; @@ -19,6 +21,10 @@ export interface GetTransactionsOptions { export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { + if (useSettingsStore.getState().testMode) { + return getTestTransactions(options); + } + const headers = await getApiHeaders(); // Build query string from options diff --git a/dapps/pos-app/services/transactions.web.ts b/dapps/pos-app/services/transactions.web.ts index fe7d2cec..84e7b0f3 100644 --- a/dapps/pos-app/services/transactions.web.ts +++ b/dapps/pos-app/services/transactions.web.ts @@ -5,6 +5,7 @@ import { } from "@/services/pos-bridge"; import { isRunningInIframe } from "@/utils/is-running-in-iframe"; import { TransactionsResponse } from "@/utils/types"; +import { getTestTransactions } from "./test-transactions"; export type GetTransactionsOptions = GetTransactionsBridgeOptions; @@ -16,6 +17,10 @@ export type GetTransactionsOptions = GetTransactionsBridgeOptions; export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { + if (useSettingsStore.getState().testMode) { + return getTestTransactions(options); + } + if (isRunningInIframe()) { return requestBridge({ operation: "get-transactions", diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 504435db..2ebf24aa 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -77,6 +77,9 @@ interface SettingsStore { // NFC nfcEnabled: boolean; + // Test + testMode: boolean; + // Actions setThemeMode: (themeMode: ThemeMode) => void; setDeviceId: (deviceId: string) => void; @@ -99,6 +102,7 @@ interface SettingsStore { resetPinAttempts: () => void; setBiometricEnabled: (enabled: boolean) => void; setNfcEnabled: (enabled: boolean) => void; + setTestMode: (enabled: boolean) => void; // Transaction filters setTransactionFilter: (filter: TransactionFilterType) => void; @@ -123,6 +127,7 @@ export const useSettingsStore = create()( pinLockoutUntil: null, biometricEnabled: false, nfcEnabled: true, + testMode: false, setThemeMode: (themeMode: ThemeMode) => set({ themeMode }), setDeviceId: (deviceId: string) => set({ deviceId }), setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), @@ -247,6 +252,7 @@ export const useSettingsStore = create()( setBiometricEnabled: (enabled: boolean) => set({ biometricEnabled: enabled }), setNfcEnabled: (enabled: boolean) => set({ nfcEnabled: enabled }), + setTestMode: (enabled: boolean) => set({ testMode: enabled }), setTransactionFilter: (filter: TransactionFilterType) => set({ transactionFilter: filter }), @@ -255,7 +261,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 19, + version: 20, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -342,6 +348,10 @@ export const useSettingsStore = create()( persistedState.hasInitializedDefaults = true; } + if (version < 20) { + persistedState.testMode = false; + } + return persistedState; }, onRehydrateStorage: () => async (state, error) => { diff --git a/dapps/pos-app/utils/payment-success-params.ts b/dapps/pos-app/utils/payment-success-params.ts index 25fbb964..90872eee 100644 --- a/dapps/pos-app/utils/payment-success-params.ts +++ b/dapps/pos-app/utils/payment-success-params.ts @@ -1,6 +1,9 @@ import { PaymentStatusResponse } from "./types"; export interface PaymentSuccessParams { + // Index signature keeps this assignable to Expo Router's `UnknownInputParams` + // when passed to `router.replace({ params })`. + [key: string]: string | undefined; amount: string; paymentId: string; tokenAmount?: string; diff --git a/dapps/pos-app/utils/toast.ts b/dapps/pos-app/utils/toast.ts index f09f619a..5ed136bd 100644 --- a/dapps/pos-app/utils/toast.ts +++ b/dapps/pos-app/utils/toast.ts @@ -3,12 +3,14 @@ import Toast from "react-native-toast-message"; interface ToastProps { message?: string; type: "success" | "error" | "info" | "warning"; + visibilityTime?: number; } -export const showToast = ({ message, type }: ToastProps) => { +export const showToast = ({ message, type, visibilityTime }: ToastProps) => { Toast.show({ type, text1: message, + ...(visibilityTime && { visibilityTime }), }); }; @@ -24,6 +26,6 @@ export const showSuccessToast = (message: string) => { showToast({ message, type: "success" }); }; -export const showInfoToast = (message: string) => { - showToast({ message, type: "info" }); +export const showInfoToast = (message: string, visibilityTime?: number) => { + showToast({ message, type: "info", visibilityTime }); };