Skip to content

Commit 80ac181

Browse files
pengyingclaude
andauthored
feat: multi-currency support and E2E tests for Kotlin sample (#306)
## Summary - Fix SDK type mismatches so the Kotlin sample compiles and runs against SDK v1.1.0 - Add multi-currency external account support (MXN, BRL, INR, GBP, PHP, EUR) with country selector dropdown - Add source currency dropdown (USD/USDC) on the quote step - Add "Start New Payment" button to restart the flow from the external account step - Extract shared `optText`/`requireText` to `JsonUtils.kt`, deduplicate `BeneficiaryFields` parsing - Add 7 Ktor server E2E tests hitting the Grid sandbox API - Add 8 Playwright browser E2E tests driving the full React UI ## Test plan - [x] `cd samples/kotlin && ./gradlew test` — 7 backend E2E tests pass - [x] `cd samples/frontend && npm run test:e2e` — 8 Playwright browser tests pass (requires backend running) - [x] Manual testing of all country destinations (IN, MX, BR, PH, GB, EU) - [x] Manual testing of USD and USDC source currencies - [x] Manual testing of "Start New Payment" reset flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4e44433 commit 80ac181

17 files changed

Lines changed: 960 additions & 172 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
// These tests drive the full React UI against the running Kotlin backend + Grid sandbox API.
4+
// Prerequisites: `cd samples/kotlin && ./gradlew run` must be running on port 8080.
5+
6+
test.describe('Payout Flow', () => {
7+
8+
test.beforeEach(async ({ page }) => {
9+
await page.goto('/')
10+
await expect(page.locator('h1')).toHaveText('Grid API Sample')
11+
})
12+
13+
test('page loads with step 1 active', async ({ page }) => {
14+
const step1 = page.locator('h3', { hasText: '1. Create Customer' })
15+
await expect(step1).toBeVisible()
16+
17+
// Create Customer button should be enabled
18+
await expect(page.getByRole('button', { name: 'Create Customer' })).toBeEnabled()
19+
})
20+
21+
test('create customer advances to step 2', async ({ page }) => {
22+
await page.getByRole('button', { name: 'Create Customer' }).click()
23+
24+
// Wait for step 2 to become active
25+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
26+
27+
// Step 1 should show a green checkmark summary with ID
28+
const step1Summary = page.locator('span.text-green-400.font-mono')
29+
await expect(step1Summary.first()).toContainText('ID:')
30+
31+
// Country dropdown should be visible
32+
await expect(page.locator('#destination-country')).toBeVisible()
33+
})
34+
35+
test('country dropdown changes the JSON body', async ({ page }) => {
36+
await page.getByRole('button', { name: 'Create Customer' }).click()
37+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
38+
39+
// Default country (IN) should populate the textarea
40+
const textarea = page.locator('textarea').nth(1)
41+
42+
// Switch to India
43+
await page.locator('#destination-country').selectOption('IN')
44+
await expect(textarea).toHaveValue(/INR_ACCOUNT/, { timeout: 3_000 })
45+
await expect(textarea).toHaveValue(/vpa/)
46+
47+
// Switch to Brazil
48+
await page.locator('#destination-country').selectOption('BR')
49+
await expect(textarea).toHaveValue(/BRL_ACCOUNT/, { timeout: 3_000 })
50+
await expect(textarea).toHaveValue(/pixKey/)
51+
})
52+
53+
test('full flow: customer → external account → quote', async ({ page }) => {
54+
// Step 1: Create Customer
55+
await page.getByRole('button', { name: 'Create Customer' }).click()
56+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
57+
58+
// Step 2: Create External Account (default country)
59+
await page.getByRole('button', { name: 'Create External Account' }).click()
60+
await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 })
61+
62+
// Step 2 summary should show ID
63+
const summaries = page.locator('span.text-green-400.font-mono')
64+
await expect(summaries.nth(1)).toContainText('ID:')
65+
66+
// Step 3: Source currency dropdown should be visible
67+
await expect(page.locator('#source-currency')).toBeVisible()
68+
69+
// Verify quote JSON body has the right structure
70+
const quoteTextarea = page.locator('textarea').last()
71+
await expect(quoteTextarea).toHaveValue(/REALTIME_FUNDING/)
72+
await expect(quoteTextarea).toHaveValue(/"currency": "USD"/)
73+
74+
// Create Quote
75+
await page.getByRole('button', { name: 'Create Quote' }).click()
76+
await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 })
77+
})
78+
79+
test('full flow with India INR', async ({ page }) => {
80+
// Step 1: Create Customer
81+
await page.getByRole('button', { name: 'Create Customer' }).click()
82+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
83+
84+
// Step 2: Switch to India and create account
85+
await page.locator('#destination-country').selectOption('IN')
86+
await expect(page.locator('textarea').nth(1)).toHaveValue(/INR_ACCOUNT/, { timeout: 3_000 })
87+
await page.getByRole('button', { name: 'Create External Account' }).click()
88+
await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 })
89+
90+
// Step 3: Create Quote
91+
await page.getByRole('button', { name: 'Create Quote' }).click()
92+
await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 })
93+
})
94+
95+
test('source currency dropdown updates quote body', async ({ page }) => {
96+
// Step 1 + 2: Get to quote step
97+
await page.getByRole('button', { name: 'Create Customer' }).click()
98+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
99+
await page.getByRole('button', { name: 'Create External Account' }).click()
100+
await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 })
101+
102+
// Default should be USD
103+
const quoteTextarea = page.locator('textarea').last()
104+
await expect(quoteTextarea).toHaveValue(/"currency": "USD"/)
105+
106+
// Switch to USDC
107+
await page.locator('#source-currency').selectOption('USDC')
108+
await expect(quoteTextarea).toHaveValue(/"currency": "USDC"/, { timeout: 3_000 })
109+
110+
// Description text should update
111+
await expect(page.getByText('USDC →')).toBeVisible()
112+
})
113+
114+
test('Start New Payment resets to step 2', async ({ page }) => {
115+
// Step 1: Create Customer
116+
await page.getByRole('button', { name: 'Create Customer' }).click()
117+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
118+
119+
// Step 2: Create External Account
120+
await page.getByRole('button', { name: 'Create External Account' }).click()
121+
await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 })
122+
123+
// Click Start New Payment
124+
await page.getByRole('button', { name: 'Start New Payment' }).click()
125+
126+
// Should be back at step 2 with external account button enabled
127+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled()
128+
129+
// Step 3 should be future (dimmed), quote button should not be visible
130+
await expect(page.getByRole('button', { name: 'Create Quote' })).not.toBeVisible()
131+
})
132+
133+
test('full payout flow including sandbox funding', async ({ page }) => {
134+
// Step 1: Create Customer
135+
await page.getByRole('button', { name: 'Create Customer' }).click()
136+
await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 })
137+
138+
// Step 2: Create External Account (default country)
139+
await page.getByRole('button', { name: 'Create External Account' }).click()
140+
await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 })
141+
142+
// Step 3: Create Quote
143+
await page.getByRole('button', { name: 'Create Quote' }).click()
144+
await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 })
145+
146+
// Step 4: Send Sandbox Funds
147+
await page.getByRole('button', { name: 'Send Sandbox Funds' }).click()
148+
149+
// After funding, all 4 steps should be completed (4 green checkmarks)
150+
const checkmarks = page.locator('text=✓')
151+
await expect(checkmarks).toHaveCount(4, { timeout: 15_000 })
152+
})
153+
})

samples/frontend/package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

samples/frontend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
"scripts": {
77
"dev": "vite",
88
"build": "tsc -b && vite build",
9-
"preview": "vite preview"
9+
"preview": "vite preview",
10+
"test:e2e": "playwright test",
11+
"test:e2e:headed": "playwright test --headed"
1012
},
1113
"dependencies": {
1214
"react": "^18.3.1",
1315
"react-dom": "^18.3.1"
1416
},
1517
"devDependencies": {
18+
"@playwright/test": "^1.58.2",
1619
"@tailwindcss/vite": "^4.1.10",
1720
"@types/react": "^18.3.12",
1821
"@types/react-dom": "^18.3.1",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from '@playwright/test'
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
timeout: 60_000,
6+
expect: { timeout: 15_000 },
7+
use: {
8+
baseURL: 'http://localhost:8080',
9+
trace: 'on-first-retry',
10+
},
11+
retries: 0,
12+
projects: [
13+
{ name: 'chromium', use: { browserName: 'chromium' } },
14+
],
15+
})

samples/frontend/src/App.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ export default function App() {
1111
const [customerId, setCustomerId] = useState<string | null>(null)
1212
const [externalAccountId, setExternalAccountId] = useState<string | null>(null)
1313
const [quoteId, setQuoteId] = useState<string | null>(null)
14+
const [selectedCountry, setSelectedCountry] = useState('MX')
1415

1516
const advance = () => setActiveStep((s) => s + 1)
1617

18+
const restartFromExternalAccount = () => {
19+
setExternalAccountId(null)
20+
setQuoteId(null)
21+
setActiveStep(1)
22+
}
23+
1724
const steps = [
1825
{
1926
title: '1. Create Customer',
@@ -35,6 +42,8 @@ export default function App() {
3542
<CreateExternalAccount
3643
customerId={customerId}
3744
disabled={activeStep !== 1}
45+
selectedCountry={selectedCountry}
46+
onCountryChange={setSelectedCountry}
3847
onComplete={(data) => {
3948
setExternalAccountId(data.id as string)
4049
advance()
@@ -49,6 +58,7 @@ export default function App() {
4958
<CreateQuote
5059
customerId={customerId}
5160
externalAccountId={externalAccountId}
61+
selectedCountry={selectedCountry}
5262
disabled={activeStep !== 2}
5363
onComplete={(data) => {
5464
setQuoteId((data.quoteId ?? data.id) as string)
@@ -74,11 +84,19 @@ export default function App() {
7484
<div className="min-h-screen bg-gray-950 text-gray-100">
7585
<header className="border-b border-gray-800 px-6 py-4">
7686
<h1 className="text-xl font-bold">Grid API Sample</h1>
77-
<p className="text-sm text-gray-400">Send a real time payment to a US bank account funded with USDC</p>
87+
<p className="text-sm text-gray-400">Send a real time payment funded with USDC</p>
7888
</header>
7989
<div className="flex">
8090
<main className="w-3/5 p-6 border-r border-gray-800 min-h-[calc(100vh-73px)]">
8191
<StepWizard steps={steps} activeStep={activeStep} />
92+
{activeStep >= 1 && (
93+
<button
94+
onClick={restartFromExternalAccount}
95+
className="mt-6 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300"
96+
>
97+
Start New Payment
98+
</button>
99+
)}
82100
</main>
83101
<aside className="w-2/5 p-6 min-h-[calc(100vh-73px)]">
84102
<WebhookStream />

0 commit comments

Comments
 (0)