Skip to content

Commit 8f62970

Browse files
feat(pos-app): add payment expiry timer and UI improvements (#409)
1 parent 2f33cc3 commit 8f62970

35 files changed

+530
-188
lines changed

dapps/pos-app/AGENTS.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ Uses **Expo Router** with file-based routing:
184184
### Payment Service (`services/payment.ts` / `services/payment.web.ts`)
185185

186186
> **Important: Platform-specific service files.** The payment service has two implementations:
187+
>
187188
> - **`services/payment.ts`** — Native (iOS/Android): uses `apiClient` from `services/client.ts` to call the merchant API directly.
188189
> - **`services/payment.web.ts`** — Web: uses Vercel serverless proxies (`/api/*`) to avoid CORS issues. Each API function calls a corresponding proxy in the `api/` directory.
189190
>
@@ -686,7 +687,9 @@ import { secureStorage, SECURE_STORAGE_KEYS } from "@/utils/secure-storage";
686687
await secureStorage.setItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY, apiKey);
687688

688689
// Retrieve
689-
const apiKey = await secureStorage.getItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY);
690+
const apiKey = await secureStorage.getItem(
691+
SECURE_STORAGE_KEYS.CUSTOMER_API_KEY,
692+
);
690693
```
691694

692695
## Code Quality Guidelines
@@ -723,6 +726,17 @@ npm test # Run Jest tests
723726

724727
Fix any errors found. Pre-existing TypeScript errors in unrelated files can be ignored.
725728

729+
### Before Creating a PR
730+
731+
**Always run lint and prettier before creating a PR to ensure code is clean:**
732+
733+
```bash
734+
npm run lint --fix # Fix all auto-fixable lint issues
735+
npx prettier --write . # Format all files with Prettier
736+
```
737+
738+
These must pass without errors before pushing or creating a PR.
739+
726740
**When moving exports between modules**, update any `jest.mock()` calls in tests that mock the source or destination module. Mocks that use a manual factory (e.g., `jest.mock("@/services/client", () => ({ ... }))`) replace the entire module — any export not included in the factory becomes `undefined` at runtime, which silently breaks tests.
727741

728742
### Code Style
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { renderHook, act } from "@testing-library/react-native";
2+
import { useCountdown } from "@/hooks/use-countdown";
3+
4+
describe("useCountdown", () => {
5+
beforeEach(() => {
6+
jest.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
jest.useRealTimers();
11+
});
12+
13+
it("returns inactive state when expiresAt is null", () => {
14+
const { result } = renderHook(() => useCountdown({ expiresAt: null }));
15+
16+
expect(result.current.remainingSeconds).toBe(0);
17+
expect(result.current.isExpired).toBe(false);
18+
expect(result.current.isActive).toBe(false);
19+
});
20+
21+
it("calculates remaining seconds from expiresAt", () => {
22+
const now = Date.now() / 1000;
23+
const expiresAt = now + 120; // 2 minutes from now
24+
25+
const { result } = renderHook(() => useCountdown({ expiresAt }));
26+
27+
expect(result.current.remainingSeconds).toBe(120);
28+
expect(result.current.isActive).toBe(true);
29+
expect(result.current.isExpired).toBe(false);
30+
});
31+
32+
it("counts down every second", () => {
33+
const now = Date.now() / 1000;
34+
const expiresAt = now + 10;
35+
36+
const { result } = renderHook(() => useCountdown({ expiresAt }));
37+
38+
expect(result.current.remainingSeconds).toBe(10);
39+
40+
act(() => {
41+
jest.advanceTimersByTime(1000);
42+
});
43+
expect(result.current.remainingSeconds).toBe(9);
44+
45+
act(() => {
46+
jest.advanceTimersByTime(3000);
47+
});
48+
expect(result.current.remainingSeconds).toBe(6);
49+
});
50+
51+
it("calls onExpired exactly once when timer reaches zero", () => {
52+
const onExpired = jest.fn();
53+
const now = Date.now() / 1000;
54+
const expiresAt = now + 3;
55+
56+
renderHook(() => useCountdown({ expiresAt, onExpired }));
57+
58+
act(() => {
59+
jest.advanceTimersByTime(3000);
60+
});
61+
62+
expect(onExpired).toHaveBeenCalledTimes(1);
63+
64+
// Advancing further should not call again
65+
act(() => {
66+
jest.advanceTimersByTime(5000);
67+
});
68+
69+
expect(onExpired).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it("does not call onExpired when expiresAt is null", () => {
73+
const onExpired = jest.fn();
74+
75+
renderHook(() => useCountdown({ expiresAt: null, onExpired }));
76+
77+
act(() => {
78+
jest.advanceTimersByTime(5000);
79+
});
80+
81+
expect(onExpired).not.toHaveBeenCalled();
82+
});
83+
84+
it("returns isExpired true when expiresAt is in the past", () => {
85+
const now = Date.now() / 1000;
86+
const expiresAt = now - 10; // 10 seconds ago
87+
88+
const { result } = renderHook(() => useCountdown({ expiresAt }));
89+
90+
expect(result.current.remainingSeconds).toBe(0);
91+
expect(result.current.isExpired).toBe(true);
92+
expect(result.current.isActive).toBe(false);
93+
});
94+
95+
it("fires onExpired when expiresAt is already in the past", () => {
96+
const onExpired = jest.fn();
97+
const now = Date.now() / 1000;
98+
const expiresAt = now - 5;
99+
100+
renderHook(() => useCountdown({ expiresAt, onExpired }));
101+
102+
act(() => {
103+
jest.advanceTimersByTime(1000);
104+
});
105+
106+
expect(onExpired).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it("resets when expiresAt changes", () => {
110+
const now = Date.now() / 1000;
111+
let expiresAt: number | null = now + 10;
112+
113+
const { result, rerender } = renderHook(() => useCountdown({ expiresAt }));
114+
115+
expect(result.current.remainingSeconds).toBe(10);
116+
117+
act(() => {
118+
jest.advanceTimersByTime(5000);
119+
});
120+
expect(result.current.remainingSeconds).toBe(5);
121+
122+
// Change expiresAt to a new value
123+
expiresAt = now + 60;
124+
rerender({});
125+
126+
expect(result.current.remainingSeconds).toBe(55); // 60 - 5 seconds elapsed
127+
expect(result.current.isActive).toBe(true);
128+
});
129+
});

dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -341,10 +341,7 @@ describe("useUrlCredentials — outbound events", () => {
341341
renderHook(() => useUrlCredentials());
342342
await act(() => waitForAsync());
343343

344-
expect(parentPostMessage).toHaveBeenCalledWith(
345-
{ type: "pos-ready" },
346-
"*",
347-
);
344+
expect(parentPostMessage).toHaveBeenCalledWith({ type: "pos-ready" }, "*");
348345
});
349346

350347
it("posts pos-credentials-updated after successful postMessage credentials", async () => {

dapps/pos-app/__tests__/services/hooks.test.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,9 @@ describe("Payment Hooks", () => {
310310
});
311311
});
312312

313-
const { result } = renderHook(
314-
() => usePaymentStatus("pay_polling"),
315-
{ wrapper: createWrapper() },
316-
);
313+
const { result } = renderHook(() => usePaymentStatus("pay_polling"), {
314+
wrapper: createWrapper(),
315+
});
317316

318317
// Initial fetch should happen immediately
319318
await waitFor(
@@ -391,10 +390,9 @@ describe("Payment Hooks", () => {
391390
});
392391
});
393392

394-
const { result } = renderHook(
395-
() => usePaymentStatus("pay_fail_poll"),
396-
{ wrapper: createWrapper() },
397-
);
393+
const { result } = renderHook(() => usePaymentStatus("pay_fail_poll"), {
394+
wrapper: createWrapper(),
395+
});
398396

399397
// Initial fetch should happen immediately
400398
await waitFor(

dapps/pos-app/__tests__/services/payment.test.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
* Tests for the payment service functions including API headers, startPayment, and getPaymentStatus.
55
*/
66

7-
import { cancelPayment, getPaymentStatus, startPayment } from "@/services/payment";
7+
import {
8+
cancelPayment,
9+
getPaymentStatus,
10+
startPayment,
11+
} from "@/services/payment";
812
import { normalizePaymentStatus } from "@/services/hooks";
913
import { useSettingsStore } from "@/store/useSettingsStore";
1014
import {
@@ -239,8 +243,14 @@ describe("Payment Service", () => {
239243
for (const status of statuses) {
240244
(apiClient.get as jest.Mock).mockResolvedValueOnce({
241245
status,
242-
isFinal: ["succeeded", "failed", "expired", "cancelled"].includes(status),
243-
pollInMs: ["succeeded", "failed", "expired", "cancelled"].includes(status) ? 0 : 2000,
246+
isFinal: ["succeeded", "failed", "expired", "cancelled"].includes(
247+
status,
248+
),
249+
pollInMs: ["succeeded", "failed", "expired", "cancelled"].includes(
250+
status,
251+
)
252+
? 0
253+
: 2000,
244254
});
245255

246256
const result = await getPaymentStatus(`pay_${status}`);
@@ -271,7 +281,11 @@ describe("Payment Service", () => {
271281
] as const;
272282

273283
for (const status of knownStatuses) {
274-
const input = { status, isFinal: status !== "requires_action" && status !== "processing", pollInMs: 0 };
284+
const input = {
285+
status,
286+
isFinal: status !== "requires_action" && status !== "processing",
287+
pollInMs: 0,
288+
};
275289
const result = normalizePaymentStatus(input);
276290
expect(result.status).toBe(status);
277291
}
@@ -326,9 +340,9 @@ describe("Payment Service", () => {
326340
});
327341

328342
it("should throw error when paymentId is null/undefined", async () => {
329-
await expect(
330-
cancelPayment(null as unknown as string),
331-
).rejects.toThrow("paymentId is required");
343+
await expect(cancelPayment(null as unknown as string)).rejects.toThrow(
344+
"paymentId is required",
345+
);
332346
await expect(
333347
cancelPayment(undefined as unknown as string),
334348
).rejects.toThrow("paymentId is required");

dapps/pos-app/__tests__/utils/secure-storage.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@ describe("migrateCustomerApiKey", () => {
4949
"customer_api_key",
5050
"partner-key-456",
5151
);
52-
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
53-
"partner_api_key",
54-
);
52+
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("partner_api_key");
5553
});
5654

5755
it("should migrate from merchant key when both old keys exist", async () => {
@@ -70,9 +68,7 @@ describe("migrateCustomerApiKey", () => {
7068
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
7169
"merchant_api_key",
7270
);
73-
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
74-
"partner_api_key",
75-
);
71+
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("partner_api_key");
7672
});
7773

7874
it("should not overwrite existing customer key", async () => {
@@ -88,9 +84,7 @@ describe("migrateCustomerApiKey", () => {
8884
"customer_api_key",
8985
expect.anything(),
9086
);
91-
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
92-
"partner_api_key",
93-
);
87+
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("partner_api_key");
9488
});
9589

9690
it("should do nothing when no old keys exist", async () => {
@@ -153,8 +147,12 @@ describe("clearStaleSecureStorage", () => {
153147

154148
await clearStaleSecureStorage();
155149

156-
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("customer_api_key");
157-
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("merchant_api_key");
150+
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
151+
"customer_api_key",
152+
);
153+
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
154+
"merchant_api_key",
155+
);
158156
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("partner_api_key");
159157
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("pin_hash");
160158
expect(await SecureStore.getItemAsync("customer_api_key")).toBeNull();
@@ -173,6 +171,8 @@ describe("clearStaleSecureStorage", () => {
173171
await clearStaleSecureStorage();
174172

175173
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
176-
expect(await SecureStore.getItemAsync("partner_api_key")).toBe("partner-key");
174+
expect(await SecureStore.getItemAsync("partner_api_key")).toBe(
175+
"partner-key",
176+
);
177177
});
178178
});

dapps/pos-app/api/cancel-payment.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
2-
import {
3-
extractCredentials,
4-
getApiBaseUrl,
5-
getApiHeaders,
6-
} from "./_utils";
2+
import { extractCredentials, getApiBaseUrl, getApiHeaders } from "./_utils";
73

84
/**
95
* Vercel Serverless Function to proxy payment cancellation requests
@@ -38,10 +34,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
3834
`${apiBaseUrl}/payments/${encodeURIComponent(paymentId)}/cancel`,
3935
{
4036
method: "POST",
41-
headers: getApiHeaders(
42-
credentials.apiKey,
43-
credentials.merchantId,
44-
),
37+
headers: getApiHeaders(credentials.apiKey, credentials.merchantId),
4538
body: JSON.stringify({}),
4639
},
4740
);

dapps/pos-app/api/payment-status.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
2-
import {
3-
extractCredentials,
4-
getApiBaseUrl,
5-
getApiHeaders,
6-
} from "./_utils";
2+
import { extractCredentials, getApiBaseUrl, getApiHeaders } from "./_utils";
73

84
/**
95
* Vercel Serverless Function to proxy payment status requests
@@ -38,10 +34,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
3834
`${apiBaseUrl}/merchant/payment/${encodeURIComponent(paymentId)}/status`,
3935
{
4036
method: "GET",
41-
headers: getApiHeaders(
42-
credentials.apiKey,
43-
credentials.merchantId,
44-
),
37+
headers: getApiHeaders(credentials.apiKey, credentials.merchantId),
4538
},
4639
);
4740

dapps/pos-app/api/payment.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { VercelRequest, VercelResponse } from "@vercel/node";
2-
import {
3-
extractCredentials,
4-
getApiBaseUrl,
5-
getApiHeaders,
6-
} from "./_utils";
2+
import { extractCredentials, getApiBaseUrl, getApiHeaders } from "./_utils";
73

84
/**
95
* Vercel Serverless Function to proxy payment creation requests
@@ -27,10 +23,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
2723
// Forward the request to the merchant API
2824
const response = await fetch(`${apiBaseUrl}/merchant/payment`, {
2925
method: "POST",
30-
headers: getApiHeaders(
31-
credentials.apiKey,
32-
credentials.merchantId,
33-
),
26+
headers: getApiHeaders(credentials.apiKey, credentials.merchantId),
3427
body: JSON.stringify(req.body),
3528
});
3629

0 commit comments

Comments
 (0)