From 8f66dd5dcab9fb9a76b1e4b0fff940986ec80a2c Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:24:01 -0300 Subject: [PATCH 1/5] feat(pos-app): add CI-controlled sandbox mode --- .github/workflows/release-android-base.yaml | 5 + .github/workflows/release-ios-base.yaml | 6 ++ .github/workflows/release-pos.yaml | 7 ++ dapps/pos-app/.env.example | 2 + dapps/pos-app/README.md | 5 + .../services/sandbox-transactions.test.ts | 34 +++++++ .../pos-app/__tests__/utils/store-helpers.ts | 1 + dapps/pos-app/app.json | 2 +- dapps/pos-app/app/activity.tsx | 9 ++ dapps/pos-app/app/amount.tsx | 8 ++ dapps/pos-app/app/index.tsx | 5 +- dapps/pos-app/app/payment-success.tsx | 15 +-- dapps/pos-app/app/scan.tsx | 70 ++++++++++++-- dapps/pos-app/app/settings.tsx | 22 ++++- dapps/pos-app/components/qr-code.tsx | 1 + dapps/pos-app/components/sandbox-banner.tsx | 41 ++++++++ dapps/pos-app/services/hooks.ts | 8 +- .../pos-app/services/sandbox-transactions.ts | 95 +++++++++++++++++++ dapps/pos-app/services/transactions.ts | 7 ++ dapps/pos-app/services/transactions.web.ts | 6 ++ dapps/pos-app/store/useSettingsStore.ts | 12 ++- dapps/pos-app/utils/feature-flags.ts | 6 ++ 22 files changed, 338 insertions(+), 29 deletions(-) create mode 100644 dapps/pos-app/__tests__/services/sandbox-transactions.test.ts create mode 100644 dapps/pos-app/components/sandbox-banner.tsx create mode 100644 dapps/pos-app/services/sandbox-transactions.ts diff --git a/.github/workflows/release-android-base.yaml b/.github/workflows/release-android-base.yaml index c3dad5319..4309b37c8 100644 --- a/.github/workflows/release-android-base.yaml +++ b/.github/workflows/release-android-base.yaml @@ -19,6 +19,10 @@ on: description: 'Release type of the project (debug/internal/production)' default: 'internal' type: string + sandbox-enabled: + description: 'Show the Sandbox mode switch in the app' + default: false + type: boolean project-type: description: 'Type of project (wallet/dapp)' required: true @@ -101,6 +105,7 @@ jobs: exit 1 fi echo "${{ secrets.env-file }}" > ${{ inputs.root-path }}/.env + echo "EXPO_PUBLIC_SANDBOX_ENABLED=${{ inputs.sandbox-enabled }}" >> ${{ inputs.root-path }}/.env - name: Copy variant files run: | diff --git a/.github/workflows/release-ios-base.yaml b/.github/workflows/release-ios-base.yaml index 8690ce941..686c76129 100644 --- a/.github/workflows/release-ios-base.yaml +++ b/.github/workflows/release-ios-base.yaml @@ -18,6 +18,11 @@ on: release-type: description: "Release type of the project (debug/internal/production)" type: string + sandbox-enabled: + description: "Show the Sandbox mode switch in the app" + required: false + default: false + type: boolean project-type: description: "Type of project (wallet/dapp)" required: true @@ -175,6 +180,7 @@ jobs: exit 1 fi echo "${{ secrets.env-file }}" > ${{ inputs.root-path }}/.env + echo "EXPO_PUBLIC_SANDBOX_ENABLED=${{ inputs.sandbox-enabled }}" >> ${{ inputs.root-path }}/.env - name: Copy variant files run: | diff --git a/.github/workflows/release-pos.yaml b/.github/workflows/release-pos.yaml index 032a401bd..8bc757fed 100644 --- a/.github/workflows/release-pos.yaml +++ b/.github/workflows/release-pos.yaml @@ -24,6 +24,11 @@ on: options: - internal - production + sandbox-enabled: + description: 'Show the Sandbox mode switch in the app' + required: false + default: false + type: boolean external-group: description: 'iOS TestFlight external group to also release to. Leave empty for internal-only.' required: false @@ -45,6 +50,7 @@ jobs: output-path: ${{ inputs.release-type == 'production' && 'dapps/pos-app/android/app/build/outputs/apk/release/app-release.apk' || 'dapps/pos-app/android/app/build/outputs/apk/internal/app-internal.apk' }} package-manager: 'npm' is-expo-project: true + sandbox-enabled: ${{ inputs.sandbox-enabled || false }} firebase-app-id: ${{ inputs.release-type == 'production' && vars.POS_ANDROID_FIREBASE_APP_ID || vars.POS_ANDROID_INTERNAL_FIREBASE_APP_ID }} secrets: env-file: ${{ secrets.POS_ENV_FILE }} @@ -72,6 +78,7 @@ jobs: package-manager: 'npm' testflight-groups: ${{ inputs.external-group }} is-expo-project: true + sandbox-enabled: ${{ inputs.sandbox-enabled || false }} secrets: env-file: ${{ secrets.POS_ENV_FILE }} sentry-file: ${{ secrets.POS_SENTRY_FILE }} diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index 1b1ec5655..700673674 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -5,3 +5,5 @@ EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # "true" enables NFC/HCE tap-to-pay; anything else (or unset) disables it EXPO_PUBLIC_NFC_HCE_ENABLED="" +# "true" shows the Sandbox mode toggle in Settings +EXPO_PUBLIC_SANDBOX_ENABLED="" diff --git a/dapps/pos-app/README.md b/dapps/pos-app/README.md index 0f2f54d02..b2d4dc263 100644 --- a/dapps/pos-app/README.md +++ b/dapps/pos-app/README.md @@ -26,6 +26,11 @@ Follow the official React Native documentation to set up your environment: Update the `.env` file with your configuration values. + For a test build that does not require merchant credentials or a wallet, set + `EXPO_PUBLIC_SANDBOX_ENABLED="true"`. The Settings screen will then show a + Sandbox mode switch. In sandbox mode, `$0.01` succeeds, `$0.02` is declined, + and Transactions shows local sample records. + 3. Create native folders ```bash diff --git a/dapps/pos-app/__tests__/services/sandbox-transactions.test.ts b/dapps/pos-app/__tests__/services/sandbox-transactions.test.ts new file mode 100644 index 000000000..e1fb23130 --- /dev/null +++ b/dapps/pos-app/__tests__/services/sandbox-transactions.test.ts @@ -0,0 +1,34 @@ +import { getSandboxTransactions } from "@/services/sandbox-transactions"; +import { useSettingsStore } from "@/store/useSettingsStore"; + +describe("getSandboxTransactions", () => { + beforeEach(() => { + useSettingsStore.setState({ currency: "USD" }); + }); + + it("returns one local record for every payment status", () => { + const response = getSandboxTransactions(); + + 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 = getSandboxTransactions({ + 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__/utils/store-helpers.ts b/dapps/pos-app/__tests__/utils/store-helpers.ts index 06aa92a49..8e4cc8d36 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, + sandboxMode: false, }); } diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index a853b9452..e43d21b0a 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -36,7 +36,7 @@ "android.permission.BLUETOOTH_ADVERTISE", "android.permission.USB_PERMISSION" ], - "versionCode": 30 + "versionCode": 31 }, "web": { "output": "static", diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index 4646c2b23..b00c2a2e9 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 { SandboxBanner } from "@/components/sandbox-banner"; import { TransactionCard } from "@/components/transaction-card"; import { TransactionDetailModal } from "@/components/transaction-detail-modal"; import { Spacing } from "@/constants/spacing"; @@ -15,6 +16,7 @@ import { TransactionFilterType, } from "@/utils/types"; import { showErrorToast } from "@/utils/toast"; +import { isSandboxModeAvailable } from "@/utils/feature-flags"; import { router } from "expo-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -47,6 +49,8 @@ const DATE_RANGE_LABELS: Record = { export default function ActivityScreen() { const theme = useTheme(); + const sandboxMode = useSettingsStore((state) => state.sandboxMode); + const isSandboxPayment = isSandboxModeAvailable && sandboxMode; const transactionFilter = useSettingsStore( (state) => state.transactionFilter, ); @@ -221,6 +225,7 @@ export default function ActivityScreen() { return ( + {isSandboxPayment && } { export default function AmountScreen() { const Theme = useTheme(); + const sandboxMode = useSettingsStore((state) => state.sandboxMode); + const isSandboxPayment = isSandboxModeAvailable && sandboxMode; const currencyCode = useSettingsStore((state) => state.currency); const currency = getCurrency(currencyCode); const { @@ -62,6 +66,7 @@ export default function AmountScreen() { return ( + {isSandboxPayment && } state.isCustomerApiKeySet, ); + const sandboxMode = useSettingsStore((state) => state.sandboxMode); const handleStartPayment = () => { - if (!merchantId || !isCustomerApiKeySet) { + const canUseSandbox = isSandboxModeAvailable && sandboxMode; + if (!canUseSandbox && (!merchantId || !isCustomerApiKeySet)) { router.push("/settings"); showErrorToast("Finish setup in Settings before starting a payment."); return; diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 347bb6dc2..f2debf30c 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -23,7 +23,6 @@ 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"; interface SuccessParams extends UnknownOutputParams { amount: string; @@ -47,7 +46,7 @@ export default function PaymentSuccessScreen() { useDisableBackButton(); const Theme = useTheme(); const params = useLocalSearchParams(); - const themeMode = useSettingsStore((state) => state.themeMode); + const currencyCode = useSettingsStore((state) => state.currency); const variant = useSettingsStore((state) => state.variant); const getVariantPrinterLogo = useSettingsStore( @@ -59,8 +58,6 @@ export default function PaymentSuccessScreen() { 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); @@ -154,7 +151,6 @@ export default function PaymentSuccessScreen() { withTiming(0, { duration: contentRevealDuration }), ); const revealTimeout = setTimeout(() => { - setIsThemeBackgroundVisible(true); setIsSuccessAnimationVisible(true); }, contentRevealDelay); @@ -272,15 +268,6 @@ export default function PaymentSuccessScreen() { - ); } diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 984cbe5d6..699f8156c 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 { SandboxBanner } from "@/components/sandbox-banner"; import { ThemedText } from "@/components/themed-text"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; import { Spacing } from "@/constants/spacing"; @@ -17,7 +18,7 @@ import { } from "@/utils/currency"; import { formatCountdown, formatCountdownSpoken } from "@/utils/misc"; import { resetNavigation } from "@/utils/navigation"; -import { isNfcHceEnabled } from "@/utils/feature-flags"; +import { isNfcHceEnabled, isSandboxModeAvailable } from "@/utils/feature-flags"; import { AMOUNT_TOO_LOW, parseMinAmountCents } from "@/utils/payment-errors"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; import { useAssets } from "expo-asset"; @@ -29,7 +30,7 @@ import { UnknownOutputParams, useLocalSearchParams, } 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"; @@ -47,14 +48,20 @@ 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 [sandboxProcessing, setSandboxProcessing] = useState(false); const hasNavigatedRef = useRef(false); const deviceId = useSettingsStore((state) => state.deviceId); const merchantId = useSettingsStore((state) => state.merchantId); + const sandboxMode = useSettingsStore((state) => state.sandboxMode); const currencyCode = useSettingsStore((state) => state.currency); const nfcEnabled = useSettingsStore((state) => state.nfcEnabled); const currency = getCurrency(currencyCode); @@ -62,6 +69,7 @@ export default function ScanScreen() { const Theme = useTheme(); const { amount } = params; + const isSandboxPayment = isSandboxModeAvailable && sandboxMode; const { nfcMode } = useNfcPayment({ paymentUrl: qrUri, @@ -114,6 +122,12 @@ export default function ScanScreen() { ); const handleOnCancelPress = () => { + if (isSandboxPayment) { + setSandboxProcessing(false); + resetNavigation("/amount"); + return; + } + // Before the first status poll resolves, `paymentStatusData` is undefined // but the payment is already open at the gateway — cancel it then too. const status = paymentStatusData?.status; @@ -138,7 +152,7 @@ export default function ScanScreen() { if (!deviceId || !amount) return; async function initiatePayment() { - if (!merchantId) { + if (!isSandboxPayment && !merchantId) { addLog( "error", "Merchant ID is not configured", @@ -152,6 +166,23 @@ export default function ScanScreen() { } try { + if (isSandboxPayment) { + const sandboxPaymentId = `sandbox_${Date.now()}`; + const sandboxQrUrl = `${sandboxPaymentId}?amount=${encodeURIComponent(amount)}`; + + addLog("info", "Sandbox payment started", "scan", "initiatePayment", { + paymentId: sandboxPaymentId, + amount, + }); + setQrUri(sandboxQrUrl); + setPaymentId(sandboxPaymentId); + // useCountdown expects an epoch timestamp in seconds, matching the + // API response format. + setExpiresAt(Math.floor(Date.now() / 1000) + 15 * 60); + setSandboxProcessing(true); + return; + } + const paymentRequest = { referenceId: uuidv4().replace(/-/g, ""), amount: { @@ -190,10 +221,27 @@ export default function ScanScreen() { initiatePayment(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceId, amount, merchantId]); + }, [deviceId, amount, merchantId, isSandboxPayment]); + + useEffect(() => { + if (!isSandboxPayment || !paymentId) return; + + const timeout = setTimeout(() => { + setSandboxProcessing(false); + if (amountToCents(amount) === 1) { + addLog("info", "Sandbox payment completed", "scan", "sandboxPayment"); + onSuccess(); + } else { + addLog("info", "Sandbox payment declined", "scan", "sandboxPayment"); + onFailure("failed"); + } + }, 3000); + + return () => clearTimeout(timeout); + }, [addLog, amount, isSandboxPayment, onFailure, onSuccess, paymentId]); const { data: paymentStatusData } = usePaymentStatus(paymentId, { - enabled: !!paymentId && !!qrUri, + enabled: !isSandboxPayment && !!paymentId && !!qrUri, onTerminalState: (data) => { if (data.status === "succeeded") { addLog("info", "Payment completed", "scan", "usePaymentStatus", { @@ -265,6 +313,7 @@ export default function ScanScreen() { gestureEnabled: !backHidden, }} /> + {isSandboxPayment && } {isProcessing ? ( @@ -308,13 +357,18 @@ export default function ScanScreen() { - {showNfc ? "Scan or tap to pay" : "Scan to pay"} + {sandboxProcessing + ? "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 sandboxMode = useSettingsStore((state) => state.sandboxMode); + const setSandboxMode = useSettingsStore((state) => state.setSandboxMode); const nfcCapabilities = useNfcCapabilities(); const addLog = useLogsStore((state) => state.addLog); const logsCount = useLogsStore((state) => state.logs.length); @@ -170,8 +172,10 @@ export default function SettingsScreen() { const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; const hasMerchantId = !!storedMerchantId?.trim(); - const setupRemaining = - (hasMerchantId ? 0 : 1) + (hasStoredCustomerApiKey ? 0 : 1); + const sandboxActive = isSandboxModeAvailable && sandboxMode; + const setupRemaining = sandboxActive + ? 0 + : (hasMerchantId ? 0 : 1) + (hasStoredCustomerApiKey ? 0 : 1); const handleTestPrinterPress = async () => { try { @@ -289,6 +293,7 @@ export default function SettingsScreen() { } caret="right" showCaret + disabled={sandboxActive} onPress={() => setActiveSheet("merchantId")} /> @@ -308,6 +313,7 @@ export default function SettingsScreen() { } caret="right" showCaret + disabled={sandboxActive} onPress={() => setActiveSheet("customerApiKey")} /> @@ -321,6 +327,16 @@ export default function SettingsScreen() { /> )} + {isSandboxModeAvailable && ( + + )} + {/* 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/sandbox-banner.tsx b/dapps/pos-app/components/sandbox-banner.tsx new file mode 100644 index 000000000..a382818c3 --- /dev/null +++ b/dapps/pos-app/components/sandbox-banner.tsx @@ -0,0 +1,41 @@ +import { BorderRadius, 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 SandboxBannerProps { + style?: StyleProp; +} + +export function SandboxBanner({ style }: SandboxBannerProps) { + const theme = useTheme(); + + return ( + + + Sandbox mode · Payments are simulated + + + ); +} + +const styles = StyleSheet.create({ + container: { + minHeight: 48, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: Spacing["spacing-4"], + paddingVertical: Spacing["spacing-2"], + borderRadius: BorderRadius["4"], + }, +}); diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index 29e4a5cd5..a68e81116 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -1,15 +1,15 @@ import { useLogsStore } from "@/store/useLogsStore"; +import { useSettingsStore } from "@/store/useSettingsStore"; import { getDateRange } from "@/utils/date-range"; import { DateRangeFilterType, - PaymentRecord, - PaymentStatus, PaymentStatusResponse, StartPaymentRequest, StartPaymentResponse, TransactionFilterType, TransactionsResponse, } from "@/utils/types"; +import { isSandboxModeAvailable } from "@/utils/feature-flags"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { cancelPayment, getPaymentStatus, startPayment } from "./payment"; @@ -204,6 +204,8 @@ function filterToStatusArray( */ export function useTransactions(options: UseTransactionsOptions = {}) { const { enabled = true, filter = "all", dateRangeFilter = "today" } = options; + const sandboxMode = useSettingsStore((state) => state.sandboxMode); + const sandboxActive = isSandboxModeAvailable && sandboxMode; const addLog = useLogsStore.getState().addLog; @@ -214,7 +216,7 @@ export function useTransactions(options: UseTransactionsOptions = {}) { ); const query = useInfiniteQuery({ - queryKey: ["transactions", filter, dateRangeFilter], + queryKey: ["transactions", filter, dateRangeFilter, sandboxActive], queryFn: ({ pageParam }) => { const statusFilter = filterToStatusArray(filter); return getTransactions({ diff --git a/dapps/pos-app/services/sandbox-transactions.ts b/dapps/pos-app/services/sandbox-transactions.ts new file mode 100644 index 000000000..de18f39d3 --- /dev/null +++ b/dapps/pos-app/services/sandbox-transactions.ts @@ -0,0 +1,95 @@ +import { getCurrency } from "@/utils/currency"; +import { PaymentRecord, TransactionsResponse } from "@/utils/types"; +import { useSettingsStore } from "@/store/useSettingsStore"; + +export interface SandboxTransactionsOptions { + status?: string | string[]; + limit?: number; + cursor?: string; + startTs?: string; + endTs?: string; +} + +/** + * Returns local records for the sandbox transaction screen. This deliberately + * lives below the platform-specific transaction services so native and web + * builds have identical, request-free sandbox behavior. + */ +export function getSandboxTransactions( + options: SandboxTransactionsOptions = {}, +): 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: `sandbox_${status}`, + merchantId: "sandbox", + referenceId: `sandbox-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: "0xsandboxtransactionhash" } + : 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 4576c7da3..c2aa56df7 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -1,5 +1,8 @@ import { TransactionsResponse } from "@/utils/types"; +import { isSandboxModeAvailable } from "@/utils/feature-flags"; +import { useSettingsStore } from "@/store/useSettingsStore"; import { merchantApiClient, getApiHeaders } from "./client"; +import { getSandboxTransactions } from "./sandbox-transactions"; export interface GetTransactionsOptions { status?: string | string[]; @@ -19,6 +22,10 @@ export interface GetTransactionsOptions { export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { + if (isSandboxModeAvailable && useSettingsStore.getState().sandboxMode) { + return getSandboxTransactions(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 0ad1bd24c..502643d4e 100644 --- a/dapps/pos-app/services/transactions.web.ts +++ b/dapps/pos-app/services/transactions.web.ts @@ -1,5 +1,7 @@ import { useSettingsStore } from "@/store/useSettingsStore"; import { TransactionsResponse } from "@/utils/types"; +import { isSandboxModeAvailable } from "@/utils/feature-flags"; +import { getSandboxTransactions } from "./sandbox-transactions"; export interface GetTransactionsOptions { status?: string | string[]; @@ -19,6 +21,10 @@ export interface GetTransactionsOptions { export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { + if (isSandboxModeAvailable && useSettingsStore.getState().sandboxMode) { + return getSandboxTransactions(options); + } + const merchantId = useSettingsStore.getState().merchantId; const apiKey = await useSettingsStore.getState().getCustomerApiKey(); diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 9a4bfaec7..01da809fe 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -77,6 +77,9 @@ interface SettingsStore { // NFC nfcEnabled: boolean; + // Sandbox + sandboxMode: 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; + setSandboxMode: (enabled: boolean) => void; // Transaction filters setTransactionFilter: (filter: TransactionFilterType) => void; @@ -123,6 +127,7 @@ export const useSettingsStore = create()( pinLockoutUntil: null, biometricEnabled: false, nfcEnabled: true, + sandboxMode: 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 }), + setSandboxMode: (enabled: boolean) => set({ sandboxMode: 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.sandboxMode = false; + } + return persistedState; }, onRehydrateStorage: () => async (state, error) => { diff --git a/dapps/pos-app/utils/feature-flags.ts b/dapps/pos-app/utils/feature-flags.ts index 5d9c5752c..52708d01f 100644 --- a/dapps/pos-app/utils/feature-flags.ts +++ b/dapps/pos-app/utils/feature-flags.ts @@ -3,3 +3,9 @@ // plugins/withHceFeatureFlag.js. Set EXPO_PUBLIC_NFC_HCE_ENABLED="true" to enable. export const isNfcHceEnabled = process.env.EXPO_PUBLIC_NFC_HCE_ENABLED === "true"; + +// Sandbox mode is intentionally opt-in at build time. When enabled, Settings +// exposes the toggle that lets testers use simulated payments without a +// merchant wallet or API credentials. +export const isSandboxModeAvailable = + process.env.EXPO_PUBLIC_SANDBOX_ENABLED === "true"; From 7eb547396efe0427916a7d8b8f27a37af7441b55 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:59:20 -0300 Subject: [PATCH 2/5] feat(pos-app): replace sandbox mode with test mode --- dapps/pos-app/.env.example | 2 - dapps/pos-app/README.md | 7 +- .../__tests__/services/test-payment.test.ts | 11 + ...ions.test.ts => test-transactions.test.ts} | 8 +- .../__tests__/services/transactions.test.ts | 37 +++ .../services/web-bridge-services.test.ts | 16 ++ .../__tests__/store/useSettingsStore.test.ts | 29 ++- .../pos-app/__tests__/utils/store-helpers.ts | 2 +- dapps/pos-app/app/activity.tsx | 30 ++- dapps/pos-app/app/amount.tsx | 29 ++- dapps/pos-app/app/index.tsx | 5 +- dapps/pos-app/app/scan.tsx | 82 ++++--- dapps/pos-app/app/settings.tsx | 212 +++++++++--------- .../components/settings-toggle-item.tsx | 2 +- ...{sandbox-banner.tsx => test-mode-pill.tsx} | 24 +- dapps/pos-app/services/hooks.ts | 7 +- dapps/pos-app/services/test-payment.ts | 9 + ...x-transactions.ts => test-transactions.ts} | 20 +- dapps/pos-app/services/transactions.ts | 7 +- dapps/pos-app/services/transactions.web.ts | 7 +- dapps/pos-app/store/useSettingsStore.ts | 20 +- dapps/pos-app/utils/feature-flags.ts | 6 - dapps/pos-app/utils/toast.ts | 8 +- 23 files changed, 358 insertions(+), 222 deletions(-) create mode 100644 dapps/pos-app/__tests__/services/test-payment.test.ts rename dapps/pos-app/__tests__/services/{sandbox-transactions.test.ts => test-transactions.test.ts} (78%) create mode 100644 dapps/pos-app/__tests__/services/transactions.test.ts rename dapps/pos-app/components/{sandbox-banner.tsx => test-mode-pill.tsx} (52%) create mode 100644 dapps/pos-app/services/test-payment.ts rename dapps/pos-app/services/{sandbox-transactions.ts => test-transactions.ts} (83%) diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index 700673674..1b1ec5655 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -5,5 +5,3 @@ EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # "true" enables NFC/HCE tap-to-pay; anything else (or unset) disables it EXPO_PUBLIC_NFC_HCE_ENABLED="" -# "true" shows the Sandbox mode toggle in Settings -EXPO_PUBLIC_SANDBOX_ENABLED="" diff --git a/dapps/pos-app/README.md b/dapps/pos-app/README.md index b2d4dc263..7d62cd23c 100644 --- a/dapps/pos-app/README.md +++ b/dapps/pos-app/README.md @@ -26,10 +26,9 @@ Follow the official React Native documentation to set up your environment: Update the `.env` file with your configuration values. - For a test build that does not require merchant credentials or a wallet, set - `EXPO_PUBLIC_SANDBOX_ENABLED="true"`. The Settings screen will then show a - Sandbox mode switch. In sandbox mode, `$0.01` succeeds, `$0.02` is declined, - and Transactions shows local sample records. + 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 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 000000000..0f4a65815 --- /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/sandbox-transactions.test.ts b/dapps/pos-app/__tests__/services/test-transactions.test.ts similarity index 78% rename from dapps/pos-app/__tests__/services/sandbox-transactions.test.ts rename to dapps/pos-app/__tests__/services/test-transactions.test.ts index e1fb23130..80acf3a74 100644 --- a/dapps/pos-app/__tests__/services/sandbox-transactions.test.ts +++ b/dapps/pos-app/__tests__/services/test-transactions.test.ts @@ -1,13 +1,13 @@ -import { getSandboxTransactions } from "@/services/sandbox-transactions"; +import { getTestTransactions } from "@/services/test-transactions"; import { useSettingsStore } from "@/store/useSettingsStore"; -describe("getSandboxTransactions", () => { +describe("getTestTransactions", () => { beforeEach(() => { useSettingsStore.setState({ currency: "USD" }); }); it("returns one local record for every payment status", () => { - const response = getSandboxTransactions(); + const response = getTestTransactions(); expect(response.data).toHaveLength(6); expect(response.data.map((payment) => payment.status)).toEqual([ @@ -22,7 +22,7 @@ describe("getSandboxTransactions", () => { }); it("applies status filters and never needs a cursor", () => { - const response = getSandboxTransactions({ + const response = getTestTransactions({ status: ["succeeded", "failed"], limit: 1, cursor: "ignored", 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 000000000..9928a486d --- /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 ec3dea455..0c78952ec 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 832cf79e5..8981b16c8 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(21); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -573,5 +587,18 @@ describe("useSettingsStore", () => { expect(migrated.hasInitializedDefaults).toBe(true); }); + + it("migrates enabled sandbox mode to Test Mode", () => { + const migrate = useSettingsStore.persist?.getOptions?.().migrate; + expect(migrate).toBeDefined(); + + const migrated: any = migrate!( + { variant: "default", sandboxMode: true }, + 20, + ); + + expect(migrated.testMode).toBe(true); + expect(migrated.sandboxMode).toBeUndefined(); + }); }); }); diff --git a/dapps/pos-app/__tests__/utils/store-helpers.ts b/dapps/pos-app/__tests__/utils/store-helpers.ts index 8e4cc8d36..5770c71ad 100644 --- a/dapps/pos-app/__tests__/utils/store-helpers.ts +++ b/dapps/pos-app/__tests__/utils/store-helpers.ts @@ -21,7 +21,7 @@ export function resetSettingsStore() { pinFailedAttempts: 0, pinLockoutUntil: null, biometricEnabled: false, - sandboxMode: false, + testMode: false, }); } diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index 5d045b33e..d122c6b80 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -2,7 +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 { SandboxBanner } from "@/components/sandbox-banner"; +import { TestModePill } from "@/components/test-mode-pill"; import { TransactionCard } from "@/components/transaction-card"; import { TransactionDetailModal } from "@/components/transaction-detail-modal"; import { Spacing } from "@/constants/spacing"; @@ -16,7 +16,6 @@ import { TransactionFilterType, } from "@/utils/types"; import { showErrorToast } from "@/utils/toast"; -import { isSandboxModeAvailable } from "@/utils/feature-flags"; import * as Sentry from "@sentry/react-native"; import { router } from "expo-router"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -50,8 +49,8 @@ const DATE_RANGE_LABELS: Record = { export default function ActivityScreen() { const theme = useTheme(); - const sandboxMode = useSettingsStore((state) => state.sandboxMode); - const isSandboxPayment = isSandboxModeAvailable && sandboxMode; + const testMode = useSettingsStore((state) => state.testMode); + const isTestPayment = testMode; const transactionFilter = useSettingsStore( (state) => state.transactionFilter, ); @@ -247,7 +246,14 @@ export default function ActivityScreen() { return ( - {isSandboxPayment && } + {isTestPayment && ( + <> + + + + + + )} {!isInitialLoadError && ( <> @@ -335,6 +341,7 @@ export default function ActivityScreen() { const styles = StyleSheet.create({ container: { flex: 1, + position: "relative", paddingTop: Spacing["spacing-4"], }, list: { @@ -361,9 +368,16 @@ const styles = StyleSheet.create({ marginTop: Spacing["spacing-1"], marginBottom: Spacing["spacing-3"], }, - sandboxBanner: { - marginHorizontal: Spacing["spacing-5"], - marginBottom: Spacing["spacing-2"], + testModePillContainer: { + position: "absolute", + top: Spacing["spacing-3"], + left: 0, + right: 0, + alignItems: "center", + zIndex: 1, + }, + testModePillSpacer: { + height: Spacing["spacing-8"], }, footerLoader: { paddingVertical: Spacing["spacing-4"], diff --git a/dapps/pos-app/app/amount.tsx b/dapps/pos-app/app/amount.tsx index 2869d4d75..1ffd4844b 100644 --- a/dapps/pos-app/app/amount.tsx +++ b/dapps/pos-app/app/amount.tsx @@ -1,11 +1,10 @@ import { BigAmountInput } from "@/components/big-amount-input"; import { Button } from "@/components/button"; import { NumericKeyboard } from "@/components/numeric-keyboard"; -import { SandboxBanner } from "@/components/sandbox-banner"; +import { TestModePill } from "@/components/test-mode-pill"; import { Spacing } from "@/constants/spacing"; import { useIsTablet } from "@/hooks/use-is-tablet"; import { useTheme } from "@/hooks/use-theme-color"; -import { isSandboxModeAvailable } from "@/utils/feature-flags"; import { useSettingsStore } from "@/store/useSettingsStore"; import { exceedsU64Max, @@ -39,8 +38,8 @@ const formatAmount = (amount: string) => { export default function AmountScreen() { const Theme = useTheme(); - const sandboxMode = useSettingsStore((state) => state.sandboxMode); - const isSandboxPayment = isSandboxModeAvailable && sandboxMode; + const testMode = useSettingsStore((state) => state.testMode); + const isTestPayment = testMode; const isTablet = useIsTablet(); const currencyCode = useSettingsStore((state) => state.currency); const currency = getCurrency(currencyCode); @@ -68,7 +67,14 @@ export default function AmountScreen() { return ( - {isSandboxPayment && } + {isTestPayment && ( + <> + + + + + + )} state.isCustomerApiKeySet, ); - const sandboxMode = useSettingsStore((state) => state.sandboxMode); + const testMode = useSettingsStore((state) => state.testMode); const isBridgeConfigured = usePosBridgeStore((state) => state.isConfigured); const bridgeMerchantId = usePosBridgeStore((state) => state.merchantId); const isIframeSession = isRunningInIframe(); const handleStartPayment = () => { if ( - !(isSandboxModeAvailable && sandboxMode) && + !testMode && !isTerminalConfigured( getMerchantIdForSession(isIframeSession, merchantId, bridgeMerchantId), isIframeSession ? false : isCustomerApiKeySet, diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 5b955da8b..c40102e50 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/button"; import QRCode from "@/components/qr-code"; -import { SandboxBanner } from "@/components/sandbox-banner"; +import { TestModePill } from "@/components/test-mode-pill"; import { ThemedText } from "@/components/themed-text"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; import { Spacing } from "@/constants/spacing"; @@ -11,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"; @@ -21,7 +22,7 @@ import { } from "@/utils/currency"; import { formatCountdown, formatCountdownSpoken } from "@/utils/misc"; import { resetNavigation } from "@/utils/navigation"; -import { isNfcHceEnabled, isSandboxModeAvailable } from "@/utils/feature-flags"; +import { isNfcHceEnabled } from "@/utils/feature-flags"; import { isRunningInIframe } from "@/utils/is-running-in-iframe"; import { AMOUNT_TOO_LOW, parseMinAmountCents } from "@/utils/payment-errors"; import { getMerchantIdForSession } from "@/utils/pos-bridge-ui"; @@ -65,7 +66,7 @@ export default function ScanScreen() { const [qrUri, setQrUri] = useState(""); const [paymentId, setPaymentId] = useState(null); const [expiresAt, setExpiresAt] = useState(null); - const [sandboxProcessing, setSandboxProcessing] = useState(false); + const [testProcessing, setTestProcessing] = useState(false); const hasNavigatedRef = useRef(false); const hasCancelledRef = useRef(false); const hasLeftRef = useRef(false); @@ -73,7 +74,7 @@ export default function ScanScreen() { const deviceId = useSettingsStore((state) => state.deviceId); const storedMerchantId = useSettingsStore((state) => state.merchantId); - const sandboxMode = useSettingsStore((state) => state.sandboxMode); + const testMode = useSettingsStore((state) => state.testMode); const bridgeMerchantId = usePosBridgeStore((state) => state.merchantId); const merchantId = getMerchantIdForSession( isRunningInIframe(), @@ -88,7 +89,7 @@ export default function ScanScreen() { const isTablet = useIsTablet(); const { amount } = params; - const isSandboxPayment = isSandboxModeAvailable && sandboxMode; + const isTestPayment = testMode; const { nfcMode } = useNfcPayment({ paymentUrl: qrUri, @@ -141,8 +142,8 @@ export default function ScanScreen() { ); const handleOnCancelPress = () => { - if (isSandboxPayment) { - setSandboxProcessing(false); + if (isTestPayment) { + setTestProcessing(false); } // The `beforeRemove` listener below cancels the payment on leave. resetNavigation("/amount"); @@ -157,7 +158,7 @@ export default function ScanScreen() { if (!deviceId || !amount) return; async function initiatePayment() { - if (!isSandboxPayment && !merchantId) { + if (!isTestPayment && !merchantId) { addLog( "error", "Merchant ID is not configured", @@ -171,20 +172,20 @@ export default function ScanScreen() { } try { - if (isSandboxPayment) { - const sandboxPaymentId = `sandbox_${Date.now()}`; - const sandboxQrUrl = `${sandboxPaymentId}?amount=${encodeURIComponent(amount)}`; + if (isTestPayment) { + const testPaymentId = `test_${Date.now()}`; + const testQrUrl = `${testPaymentId}?amount=${encodeURIComponent(amount)}`; - addLog("info", "Sandbox payment started", "scan", "initiatePayment", { - paymentId: sandboxPaymentId, + addLog("info", "Test payment started", "scan", "initiatePayment", { + paymentId: testPaymentId, amount, }); - setQrUri(sandboxQrUrl); - setPaymentId(sandboxPaymentId); + setQrUri(testQrUrl); + setPaymentId(testPaymentId); // useCountdown expects an epoch timestamp in seconds, matching the // API response format. setExpiresAt(Math.floor(Date.now() / 1000) + 15 * 60); - setSandboxProcessing(true); + setTestProcessing(true); return; } @@ -246,27 +247,27 @@ export default function ScanScreen() { initiatePayment(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceId, amount, merchantId, isSandboxPayment]); + }, [deviceId, amount, merchantId, isTestPayment]); useEffect(() => { - if (!isSandboxPayment || !paymentId) return; + if (!isTestPayment || !paymentId) return; const timeout = setTimeout(() => { - setSandboxProcessing(false); - if (amountToCents(amount) === 1) { - addLog("info", "Sandbox payment completed", "scan", "sandboxPayment"); - onSuccess(); - } else { - addLog("info", "Sandbox payment declined", "scan", "sandboxPayment"); + setTestProcessing(false); + if (isTestPaymentFailure(amount)) { + addLog("info", "Test payment declined", "scan", "testPayment"); onFailure("failed"); + } else { + addLog("info", "Test payment completed", "scan", "testPayment"); + onSuccess(); } }, 3000); return () => clearTimeout(timeout); - }, [addLog, amount, isSandboxPayment, onFailure, onSuccess, paymentId]); + }, [addLog, amount, isTestPayment, onFailure, onSuccess, paymentId]); const { data: paymentStatusData } = usePaymentStatus(paymentId, { - enabled: !isSandboxPayment && !!paymentId && !!qrUri, + enabled: !isTestPayment && !!paymentId && !!qrUri, onTerminalState: (data) => { if (data.status === "succeeded") { if (!paymentId) { @@ -300,7 +301,7 @@ export default function ScanScreen() { // resolved yet — cancel then too. const cancelPendingPayment = useCallback(() => { if (hasNavigatedRef.current || hasCancelledRef.current) return; - if (isSandboxPayment) 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; @@ -317,7 +318,7 @@ export default function ScanScreen() { showErrorToast("We couldn't cancel this payment."); }); } - }, [paymentId, paymentStatusData?.status, addLog, isSandboxPayment]); + }, [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 @@ -393,7 +394,14 @@ export default function ScanScreen() { gestureEnabled: !backHidden, }} /> - {isSandboxPayment && } + {isTestPayment && ( + <> + + + + + + )} {isProcessing ? ( - {sandboxProcessing + {testProcessing ? "Waiting for confirmation..." : showNfc ? "Scan or tap to pay" @@ -542,6 +550,7 @@ export default function ScanScreen() { const styles = StyleSheet.create({ container: { flex: 1, + position: "relative", }, loadingContainer: { flex: 1, @@ -613,9 +622,16 @@ const styles = StyleSheet.create({ alignItems: "center", gap: Spacing["spacing-4"], }, - sandboxBanner: { - marginHorizontal: Spacing["spacing-5"], - marginTop: Spacing["spacing-3"], + testModePillContainer: { + position: "absolute", + top: Spacing["spacing-3"], + left: 0, + right: 0, + alignItems: "center", + zIndex: 1, + }, + testModePillSpacer: { + height: Spacing["spacing-9"], }, qrSectionTablet: { gap: Spacing["spacing-5"], diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 5f735889c..dd615d327 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -18,20 +18,17 @@ import { useSettingsStore } from "@/store/useSettingsStore"; import { usePosBridgeStore } from "@/store/usePosBridgeStore"; import { isRunningInIframe } from "@/utils/is-running-in-iframe"; import { ThemeMode } from "@/utils/types"; -import { - getConnectionSetupRemaining, - shouldShowConnectionSection, -} from "@/utils/pos-bridge-ui"; +import { getConnectionSetupRemaining } from "@/utils/pos-bridge-ui"; import { getBiometricLabel } from "@/utils/biometrics"; import { buildReceiptLogo } from "@/utils/build-receipt-logo"; import { CURRENCIES, CurrencyCode, getCurrency } from "@/utils/currency"; -import { isNfcHceEnabled, isSandboxModeAvailable } from "@/utils/feature-flags"; +import { isNfcHceEnabled } from "@/utils/feature-flags"; import { connectPrinter, printReceipt, requestBluetoothPermission, } from "@/utils/printer"; -import { showErrorToast } from "@/utils/toast"; +import { showErrorToast, showInfoToast } from "@/utils/toast"; import * as Application from "expo-application"; import Constants from "expo-constants"; import { Image } from "expo-image"; @@ -82,8 +79,8 @@ export default function SettingsScreen() { const setCurrency = useSettingsStore((state) => state.setCurrency); const nfcEnabled = useSettingsStore((state) => state.nfcEnabled); const setNfcEnabled = useSettingsStore((state) => state.setNfcEnabled); - const sandboxMode = useSettingsStore((state) => state.sandboxMode); - const setSandboxMode = useSettingsStore((state) => state.setSandboxMode); + 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); @@ -177,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" && @@ -185,24 +192,15 @@ export default function SettingsScreen() { const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; const hasMerchantId = !!storedMerchantId?.trim(); - const sandboxActive = isSandboxModeAvailable && sandboxMode; + const testActive = testMode; const setupRemaining = - sandboxActive || isIframeSession + testActive || isIframeSession ? 0 : getConnectionSetupRemaining( hasMerchantId, hasStoredCustomerApiKey, isIframeBridgeConfigured, ); - const showConnectionSection = - isSandboxModeAvailable || - isIframeSession || - shouldShowConnectionSection( - isIframeBridgeConfigured, - !!bridgeMerchantId, - showNfcToggle || showBiometricToggle, - ); - const handleTestPrinterPress = async () => { try { const isBluetoothPermissionGranted = await requestBluetoothPermission(); @@ -284,102 +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 - disabled={sandboxActive} - onPress={() => setActiveSheet("merchantId")} - /> - - ) - } - caret="right" - showCaret - disabled={sandboxActive} - onPress={() => setActiveSheet("customerApiKey")} - /> - - )} - - {showNfcToggle && ( - - )} - - {isSandboxModeAvailable && ( - - )} - - {/* 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 && ( + + )} + ; } -export function SandboxBanner({ style }: SandboxBannerProps) { +export function TestModePill({ style }: TestModePillProps) { const theme = useTheme(); return ( - Sandbox mode · Payments are simulated + Test mode ); @@ -31,11 +35,11 @@ export function SandboxBanner({ style }: SandboxBannerProps) { const styles = StyleSheet.create({ container: { - minHeight: 48, + alignSelf: "center", alignItems: "center", justifyContent: "center", - paddingHorizontal: Spacing["spacing-4"], - paddingVertical: Spacing["spacing-2"], - borderRadius: BorderRadius["4"], + paddingHorizontal: Spacing["spacing-3"], + paddingVertical: Spacing["spacing-1"], + borderRadius: 999, }, }); diff --git a/dapps/pos-app/services/hooks.ts b/dapps/pos-app/services/hooks.ts index a68e81116..2fed7ad96 100644 --- a/dapps/pos-app/services/hooks.ts +++ b/dapps/pos-app/services/hooks.ts @@ -9,7 +9,6 @@ import { TransactionFilterType, TransactionsResponse, } from "@/utils/types"; -import { isSandboxModeAvailable } from "@/utils/feature-flags"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { cancelPayment, getPaymentStatus, startPayment } from "./payment"; @@ -204,8 +203,8 @@ function filterToStatusArray( */ export function useTransactions(options: UseTransactionsOptions = {}) { const { enabled = true, filter = "all", dateRangeFilter = "today" } = options; - const sandboxMode = useSettingsStore((state) => state.sandboxMode); - const sandboxActive = isSandboxModeAvailable && sandboxMode; + const testMode = useSettingsStore((state) => state.testMode); + const testActive = testMode; const addLog = useLogsStore.getState().addLog; @@ -216,7 +215,7 @@ export function useTransactions(options: UseTransactionsOptions = {}) { ); const query = useInfiniteQuery({ - queryKey: ["transactions", filter, dateRangeFilter, sandboxActive], + 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 000000000..4fdd12ccd --- /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/sandbox-transactions.ts b/dapps/pos-app/services/test-transactions.ts similarity index 83% rename from dapps/pos-app/services/sandbox-transactions.ts rename to dapps/pos-app/services/test-transactions.ts index de18f39d3..24b551817 100644 --- a/dapps/pos-app/services/sandbox-transactions.ts +++ b/dapps/pos-app/services/test-transactions.ts @@ -2,7 +2,7 @@ import { getCurrency } from "@/utils/currency"; import { PaymentRecord, TransactionsResponse } from "@/utils/types"; import { useSettingsStore } from "@/store/useSettingsStore"; -export interface SandboxTransactionsOptions { +export interface TestTransactionsOptions { status?: string | string[]; limit?: number; cursor?: string; @@ -11,12 +11,12 @@ export interface SandboxTransactionsOptions { } /** - * Returns local records for the sandbox transaction screen. This deliberately + * 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 sandbox behavior. + * builds have identical, request-free test behavior. */ -export function getSandboxTransactions( - options: SandboxTransactionsOptions = {}, +export function getTestTransactions( + options: TestTransactionsOptions = {}, ): TransactionsResponse { const currency = getCurrency(useSettingsStore.getState().currency); const now = Date.now(); @@ -36,9 +36,9 @@ export function getSandboxTransactions( const isTerminal = status !== "requires_action" && status !== "processing"; return { - paymentId: `sandbox_${status}`, - merchantId: "sandbox", - referenceId: `sandbox-reference-${status}`, + paymentId: `test_${status}`, + merchantId: "test", + referenceId: `test-reference-${status}`, status: status as PaymentRecord["status"], isTerminal, fiatAmount: { @@ -56,9 +56,7 @@ export function getSandboxTransactions( }, }, transaction: - status === "succeeded" - ? { hash: "0xsandboxtransactionhash" } - : undefined, + status === "succeeded" ? { hash: "0xtesttransactionhash" } : undefined, createdAt, lastUpdatedAt: createdAt, settledAt: status === "succeeded" ? createdAt : undefined, diff --git a/dapps/pos-app/services/transactions.ts b/dapps/pos-app/services/transactions.ts index c2aa56df7..1036b420d 100644 --- a/dapps/pos-app/services/transactions.ts +++ b/dapps/pos-app/services/transactions.ts @@ -1,8 +1,7 @@ import { TransactionsResponse } from "@/utils/types"; -import { isSandboxModeAvailable } from "@/utils/feature-flags"; import { useSettingsStore } from "@/store/useSettingsStore"; import { merchantApiClient, getApiHeaders } from "./client"; -import { getSandboxTransactions } from "./sandbox-transactions"; +import { getTestTransactions } from "./test-transactions"; export interface GetTransactionsOptions { status?: string | string[]; @@ -22,8 +21,8 @@ export interface GetTransactionsOptions { export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { - if (isSandboxModeAvailable && useSettingsStore.getState().sandboxMode) { - return getSandboxTransactions(options); + if (useSettingsStore.getState().testMode) { + return getTestTransactions(options); } const headers = await getApiHeaders(); diff --git a/dapps/pos-app/services/transactions.web.ts b/dapps/pos-app/services/transactions.web.ts index ece3f8c77..84e7b0f34 100644 --- a/dapps/pos-app/services/transactions.web.ts +++ b/dapps/pos-app/services/transactions.web.ts @@ -5,8 +5,7 @@ import { } from "@/services/pos-bridge"; import { isRunningInIframe } from "@/utils/is-running-in-iframe"; import { TransactionsResponse } from "@/utils/types"; -import { isSandboxModeAvailable } from "@/utils/feature-flags"; -import { getSandboxTransactions } from "./sandbox-transactions"; +import { getTestTransactions } from "./test-transactions"; export type GetTransactionsOptions = GetTransactionsBridgeOptions; @@ -18,8 +17,8 @@ export type GetTransactionsOptions = GetTransactionsBridgeOptions; export async function getTransactions( options: GetTransactionsOptions = {}, ): Promise { - if (isSandboxModeAvailable && useSettingsStore.getState().sandboxMode) { - return getSandboxTransactions(options); + if (useSettingsStore.getState().testMode) { + return getTestTransactions(options); } if (isRunningInIframe()) { diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 801063c6c..47599af6b 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -77,8 +77,8 @@ interface SettingsStore { // NFC nfcEnabled: boolean; - // Sandbox - sandboxMode: boolean; + // Test + testMode: boolean; // Actions setThemeMode: (themeMode: ThemeMode) => void; @@ -102,7 +102,7 @@ interface SettingsStore { resetPinAttempts: () => void; setBiometricEnabled: (enabled: boolean) => void; setNfcEnabled: (enabled: boolean) => void; - setSandboxMode: (enabled: boolean) => void; + setTestMode: (enabled: boolean) => void; // Transaction filters setTransactionFilter: (filter: TransactionFilterType) => void; @@ -127,7 +127,7 @@ export const useSettingsStore = create()( pinLockoutUntil: null, biometricEnabled: false, nfcEnabled: true, - sandboxMode: false, + testMode: false, setThemeMode: (themeMode: ThemeMode) => set({ themeMode }), setDeviceId: (deviceId: string) => set({ deviceId }), setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), @@ -252,7 +252,7 @@ export const useSettingsStore = create()( setBiometricEnabled: (enabled: boolean) => set({ biometricEnabled: enabled }), setNfcEnabled: (enabled: boolean) => set({ nfcEnabled: enabled }), - setSandboxMode: (enabled: boolean) => set({ sandboxMode: enabled }), + setTestMode: (enabled: boolean) => set({ testMode: enabled }), setTransactionFilter: (filter: TransactionFilterType) => set({ transactionFilter: filter }), @@ -261,7 +261,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 20, + version: 21, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -349,7 +349,13 @@ export const useSettingsStore = create()( } if (version < 20) { - persistedState.sandboxMode = false; + persistedState.testMode = false; + } + + if (version < 21) { + persistedState.testMode = + persistedState.testMode ?? persistedState.sandboxMode ?? false; + delete persistedState.sandboxMode; } return persistedState; diff --git a/dapps/pos-app/utils/feature-flags.ts b/dapps/pos-app/utils/feature-flags.ts index 52708d01f..5d9c5752c 100644 --- a/dapps/pos-app/utils/feature-flags.ts +++ b/dapps/pos-app/utils/feature-flags.ts @@ -3,9 +3,3 @@ // plugins/withHceFeatureFlag.js. Set EXPO_PUBLIC_NFC_HCE_ENABLED="true" to enable. export const isNfcHceEnabled = process.env.EXPO_PUBLIC_NFC_HCE_ENABLED === "true"; - -// Sandbox mode is intentionally opt-in at build time. When enabled, Settings -// exposes the toggle that lets testers use simulated payments without a -// merchant wallet or API credentials. -export const isSandboxModeAvailable = - process.env.EXPO_PUBLIC_SANDBOX_ENABLED === "true"; diff --git a/dapps/pos-app/utils/toast.ts b/dapps/pos-app/utils/toast.ts index f09f619a2..5ed136bdf 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 }); }; From 853fa717877683fc919d0a8203ad0d88e26a614f Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:56:34 -0300 Subject: [PATCH 3/5] fix(pos-app): address test-mode review feedback - Pass paymentId to onSuccess so the success screen gets a valid txn id - Skip copying the synthetic QR string in test mode - Add accessibility role/label to TestModePill - Fix route-param typing for payment-success params Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/payment-success.tsx | 18 +++++++++++------- dapps/pos-app/app/scan.tsx | 4 +++- dapps/pos-app/components/test-mode-pill.tsx | 2 ++ dapps/pos-app/utils/payment-success-params.ts | 3 +++ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 569fa63d6..5fc9a9c66 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -25,14 +25,18 @@ import { resetNavigation } from "@/utils/navigation"; import { connectPrinter, printReceipt } from "@/utils/printer"; import { Image } from "expo-image"; +// 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"); @@ -47,7 +51,7 @@ export default function PaymentSuccessScreen() { useDisableBackButton(); const Theme = useTheme(); const isTablet = useIsTablet(); - const params = useLocalSearchParams(); + const params: Partial = useLocalSearchParams(); const currencyCode = useSettingsStore((state) => state.currency); const variant = useSettingsStore((state) => state.variant); @@ -57,7 +61,7 @@ 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 [isSuccessAnimationVisible, setIsSuccessAnimationVisible] = @@ -87,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, diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index c40102e50..2fecbebb6 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -150,6 +150,8 @@ export default function ScanScreen() { }; const handleCopyPaymentUrl = async () => { + // No real URL to copy in test mode. + if (isTestPayment) return; await Clipboard.setStringAsync(qrUri); showSuccessToast("Payment link copied"); }; @@ -259,7 +261,7 @@ export default function ScanScreen() { onFailure("failed"); } else { addLog("info", "Test payment completed", "scan", "testPayment"); - onSuccess(); + onSuccess(paymentId); } }, 3000); diff --git a/dapps/pos-app/components/test-mode-pill.tsx b/dapps/pos-app/components/test-mode-pill.tsx index f66760c96..b62352f1f 100644 --- a/dapps/pos-app/components/test-mode-pill.tsx +++ b/dapps/pos-app/components/test-mode-pill.tsx @@ -12,6 +12,8 @@ export function TestModePill({ style }: TestModePillProps) { return ( Date: Tue, 15 Sep 2026 12:07:30 -0300 Subject: [PATCH 4/5] fix(pos-app): remove dead sandbox flag and simplify test-mode migration - Drop the unused sandbox-enabled input / EXPO_PUBLIC_SANDBOX_ENABLED env wiring from the release workflows; the app's only switch is the persisted testMode setting - Simplify the settings-store migration to seed testMode at version 20 (sandbox never shipped to prod, so 20 was free) instead of bumping to 21 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-android-base.yaml | 5 ----- .github/workflows/release-ios-base.yaml | 6 ------ .github/workflows/release-pos.yaml | 7 ------- .../pos-app/__tests__/store/useSettingsStore.test.ts | 12 ++++-------- dapps/pos-app/store/useSettingsStore.ts | 8 +------- 5 files changed, 5 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release-android-base.yaml b/.github/workflows/release-android-base.yaml index 4309b37c8..c3dad5319 100644 --- a/.github/workflows/release-android-base.yaml +++ b/.github/workflows/release-android-base.yaml @@ -19,10 +19,6 @@ on: description: 'Release type of the project (debug/internal/production)' default: 'internal' type: string - sandbox-enabled: - description: 'Show the Sandbox mode switch in the app' - default: false - type: boolean project-type: description: 'Type of project (wallet/dapp)' required: true @@ -105,7 +101,6 @@ jobs: exit 1 fi echo "${{ secrets.env-file }}" > ${{ inputs.root-path }}/.env - echo "EXPO_PUBLIC_SANDBOX_ENABLED=${{ inputs.sandbox-enabled }}" >> ${{ inputs.root-path }}/.env - name: Copy variant files run: | diff --git a/.github/workflows/release-ios-base.yaml b/.github/workflows/release-ios-base.yaml index 686c76129..8690ce941 100644 --- a/.github/workflows/release-ios-base.yaml +++ b/.github/workflows/release-ios-base.yaml @@ -18,11 +18,6 @@ on: release-type: description: "Release type of the project (debug/internal/production)" type: string - sandbox-enabled: - description: "Show the Sandbox mode switch in the app" - required: false - default: false - type: boolean project-type: description: "Type of project (wallet/dapp)" required: true @@ -180,7 +175,6 @@ jobs: exit 1 fi echo "${{ secrets.env-file }}" > ${{ inputs.root-path }}/.env - echo "EXPO_PUBLIC_SANDBOX_ENABLED=${{ inputs.sandbox-enabled }}" >> ${{ inputs.root-path }}/.env - name: Copy variant files run: | diff --git a/.github/workflows/release-pos.yaml b/.github/workflows/release-pos.yaml index 341b0a14f..3c9ca458b 100644 --- a/.github/workflows/release-pos.yaml +++ b/.github/workflows/release-pos.yaml @@ -24,11 +24,6 @@ on: options: - internal - production - sandbox-enabled: - description: 'Show the Sandbox mode switch in the app' - required: false - default: false - type: boolean external-group: description: 'iOS TestFlight external group to also release to. Leave empty for internal-only.' required: false @@ -50,7 +45,6 @@ jobs: output-path: ${{ inputs.release-type == 'production' && 'dapps/pos-app/android/app/build/outputs/apk/release/app-release.apk' || 'dapps/pos-app/android/app/build/outputs/apk/internal/app-internal.apk' }} package-manager: 'npm' is-expo-project: true - sandbox-enabled: ${{ inputs.sandbox-enabled || false }} firebase-app-id: ${{ inputs.release-type == 'production' && vars.POS_ANDROID_FIREBASE_APP_ID || vars.POS_ANDROID_INTERNAL_FIREBASE_APP_ID }} secrets: env-file: ${{ secrets.POS_ENV_FILE }} @@ -78,7 +72,6 @@ jobs: package-manager: 'npm' testflight-groups: ${{ inputs.external-group }} is-expo-project: true - sandbox-enabled: ${{ inputs.sandbox-enabled || false }} secrets: env-file: ${{ secrets.POS_ENV_FILE }} sentry-file: ${{ secrets.POS_SENTRY_FILE }} diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index 8981b16c8..36240eca7 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -553,7 +553,7 @@ describe("useSettingsStore", () => { // Check persist name and version are set (for storage key) expect(persistOptions?.name).toBe("settings"); - expect(persistOptions?.version).toBe(21); + expect(persistOptions?.version).toBe(20); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -588,17 +588,13 @@ describe("useSettingsStore", () => { expect(migrated.hasInitializedDefaults).toBe(true); }); - it("migrates enabled sandbox mode to Test Mode", () => { + 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", sandboxMode: true }, - 20, - ); + const migrated: any = migrate!({ variant: "default" }, 19); - expect(migrated.testMode).toBe(true); - expect(migrated.sandboxMode).toBeUndefined(); + expect(migrated.testMode).toBe(false); }); }); }); diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 47599af6b..2ebf24aa7 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -261,7 +261,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 21, + version: 20, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -352,12 +352,6 @@ export const useSettingsStore = create()( persistedState.testMode = false; } - if (version < 21) { - persistedState.testMode = - persistedState.testMode ?? persistedState.sandboxMode ?? false; - delete persistedState.sandboxMode; - } - return persistedState; }, onRehydrateStorage: () => async (state, error) => { From f445a68b66742baed984e4511e43dc6640ed71b0 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:16:23 -0300 Subject: [PATCH 5/5] refactor(pos-app): extract TestModeOverlay to dedup pill wrapper styles Collapse the duplicated absolute-overlay container and flow spacer that were copy-pasted into the amount, activity and scan screens into a single TestModeOverlay component, with the reserved spacer height as a prop. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/activity.tsx | 22 ++-------------- dapps/pos-app/app/amount.tsx | 22 ++-------------- dapps/pos-app/app/scan.tsx | 22 ++-------------- dapps/pos-app/components/test-mode-pill.tsx | 28 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 60 deletions(-) diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index d122c6b80..3b231e09a 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -2,7 +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 { TestModePill } from "@/components/test-mode-pill"; +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"; @@ -246,14 +246,7 @@ export default function ActivityScreen() { return ( - {isTestPayment && ( - <> - - - - - - )} + {isTestPayment && } {!isInitialLoadError && ( <> @@ -368,17 +361,6 @@ const styles = StyleSheet.create({ marginTop: Spacing["spacing-1"], marginBottom: Spacing["spacing-3"], }, - testModePillContainer: { - position: "absolute", - top: Spacing["spacing-3"], - left: 0, - right: 0, - alignItems: "center", - zIndex: 1, - }, - testModePillSpacer: { - height: Spacing["spacing-8"], - }, footerLoader: { paddingVertical: Spacing["spacing-4"], alignItems: "center", diff --git a/dapps/pos-app/app/amount.tsx b/dapps/pos-app/app/amount.tsx index 1ffd4844b..66a327f2c 100644 --- a/dapps/pos-app/app/amount.tsx +++ b/dapps/pos-app/app/amount.tsx @@ -1,7 +1,7 @@ import { BigAmountInput } from "@/components/big-amount-input"; import { Button } from "@/components/button"; import { NumericKeyboard } from "@/components/numeric-keyboard"; -import { TestModePill } from "@/components/test-mode-pill"; +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"; @@ -67,14 +67,7 @@ export default function AmountScreen() { return ( - {isTestPayment && ( - <> - - - - - - )} + {isTestPayment && } - {isTestPayment && ( - <> - - - - - - )} + {isTestPayment && } {isProcessing ? (