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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dapps/pos-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions dapps/pos-app/__tests__/services/test-payment.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
34 changes: 34 additions & 0 deletions dapps/pos-app/__tests__/services/test-transactions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
37 changes: 37 additions & 0 deletions dapps/pos-app/__tests__/services/transactions.test.ts
Original file line number Diff line number Diff line change
@@ -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" } }),
);
});
});
16 changes: 16 additions & 0 deletions dapps/pos-app/__tests__/services/web-bridge-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
});
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 24 additions & 1 deletion dapps/pos-app/__tests__/store/useSettingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions dapps/pos-app/__tests__/utils/store-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function resetSettingsStore() {
pinFailedAttempts: 0,
pinLockoutUntil: null,
biometricEnabled: false,
testMode: false,
});
}

Expand Down
5 changes: 5 additions & 0 deletions dapps/pos-app/app/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,6 +49,8 @@ const DATE_RANGE_LABELS: Record<DateRangeFilterType, string> = {

export default function ActivityScreen() {
const theme = useTheme();
const testMode = useSettingsStore((state) => state.testMode);
const isTestPayment = testMode;
const transactionFilter = useSettingsStore(
(state) => state.transactionFilter,
);
Expand Down Expand Up @@ -243,6 +246,7 @@ export default function ActivityScreen() {

return (
<View style={styles.container}>
{isTestPayment && <TestModeOverlay />}
<Sentry.TimeToFullDisplay ready={!isLoading} />
{!isInitialLoadError && (
<>
Expand Down Expand Up @@ -330,6 +334,7 @@ export default function ActivityScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
position: "relative",
paddingTop: Spacing["spacing-4"],
},
list: {
Expand Down
5 changes: 5 additions & 0 deletions dapps/pos-app/app/amount.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -64,6 +67,7 @@ export default function AmountScreen() {

return (
<View style={[styles.container, isTablet && styles.containerTablet]}>
{isTestPayment && <TestModeOverlay />}
<View
style={[
styles.amountContainer,
Expand Down Expand Up @@ -143,6 +147,7 @@ export default function AmountScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
position: "relative",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: Spacing["spacing-5"],
Expand Down
2 changes: 2 additions & 0 deletions dapps/pos-app/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ export default function HomeScreen() {
const isCustomerApiKeySet = useSettingsStore(
(state) => 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,
Expand Down
33 changes: 12 additions & 21 deletions dapps/pos-app/app/payment-success.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -48,8 +51,8 @@ export default function PaymentSuccessScreen() {
useDisableBackButton();
const Theme = useTheme();
const isTablet = useIsTablet();
const params = useLocalSearchParams<SuccessParams>();
const themeMode = useSettingsStore((state) => state.themeMode);
const params: Partial<SuccessParams> = useLocalSearchParams<SuccessParams>();

const currencyCode = useSettingsStore((state) => state.currency);
const variant = useSettingsStore((state) => state.variant);
const getVariantPrinterLogo = useSettingsStore(
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -156,7 +157,6 @@ export default function PaymentSuccessScreen() {
withTiming(0, { duration: contentRevealDuration }),
);
const revealTimeout = setTimeout(() => {
setIsThemeBackgroundVisible(true);
setIsSuccessAnimationVisible(true);
}, contentRevealDelay);

Expand Down Expand Up @@ -295,15 +295,6 @@ export default function PaymentSuccessScreen() {
</Button>
</View>
</Animated.View>
<StatusBar
style={
isThemeBackgroundVisible
? themeMode === "system"
? "auto"
: themeMode
: "light"
}
/>
</View>
);
}
Expand Down
Loading
Loading