Business Merchant Platform
Merchant-facing portal and shared component platform for businesses managing payments, transactions, campaigns, settlements, and account operations.
Data verified from git log, Jira, and monorepo structure
Every time a customer taps their phone to pay at a store in Japan, a chain of systems fires in under a second. The POS terminal talks to the gateway. The gateway routes to the payment processor. The processor debits the account. And somewhere, a merchant is watching it happen in real time on a dashboard.
I built that dashboard.
The realtime flow works like this: when a payment succeeds, a scoped JWT is issued to the merchant dashboard. The client opens a WebSocket through AWS API Gateway. Messages flow into a Vuex store — no page refreshes, no polling — and the merchant sees every transaction as it happens. If the connection drops, the client reconnects automatically, catches up on missed messages, and syncs state back.
The Hidden Complexity
Japan's payment landscape is unique. Customers use QR codes, NFC, and online transfers. Merchants deal with settlement cycles, campaign payouts, refunds, and reconciliation reports. Each workflow has different data requirements, and they all converge on a single dashboard.
The platform was growing fast. Multiple teams shipped features simultaneously, creating a familiar startup-to-scaleup problem: duplicated UI patterns, inconsistent API integration approaches, and a transaction page that tried to be everything at once.
The filter problem
The transaction page used one global filter scope. When a merchant selected a date range, the summary chart, the transaction list, and the CSV export all reacted to the same filter — even when the merchant wanted to see today's summary while searching last week's transactions.
The Four Workflows
The transaction page had four distinct use cases sharing one filter:
Separating Concerns, One View at a Time
Each view got its own filter scope. The summary always showed today. The list had its own date picker. The CSV had separate parameters. The store selection component fetched the merchant's locations once and fed independent parameters to each view.
1const selectedStoreIds = ref<string[]>([])2 3const listParams = computed(() => ({4storeIds: selectedStoreIds.value,5dateFrom: listDateRange.value.start,6dateTo: listDateRange.value.end,7status: listStatus.value,8}))9 10const chartParams = computed(() => ({11storeIds: selectedStoreIds.value,12period: chartPeriod.value,13comparison: chartYoY.value,14}))15 16const csvParams = computed(() => ({17storeIds: selectedStoreIds.value,18dateFrom: csvDateRange.value.start,19dateTo: csvDateRange.value.end,20columns: csvColumns.value,21}))The result
Merchants can now look at today's revenue while searching last month's transactions — without losing context. Each view answers the question merchants actually ask.
The store selection component feeds store IDs to independent computed properties — each view builds its own query parameters.
1const selectedStoreIds = ref<string[]>([])2 3const listParams = computed(() => ({4storeIds: selectedStoreIds.value,5dateFrom: listDateRange.value.start,6dateTo: listDateRange.value.end,7status: listStatus.value,8}))9 10const chartParams = computed(() => ({11storeIds: selectedStoreIds.value,12period: chartPeriod.value,13comparison: chartYoY.value,14}))Realtime Delivery: Designing for the Unreliable
The WebSocket implementation used a singleton pattern — one connection shared across all components. A Vue mixin handled the full lifecycle: token acquisition, connection setup, keep-alive, reconnection with backoff, and cleanup.
Custom close codes distinguished auth failures (no retry) from network transitions (wait and retry) from intentional backgrounding (stop and save battery). JSBridge events on mobile handled foreground/background transitions.
The summary refresh was the most nuanced piece. The transaction list could update immediately — merchants watch this in real time. But summary totals needed to stabilize before refreshing, otherwise numbers flickered as they caught up with partial data.
1const DEBOUNCE_MS = 10_0002let refreshTimer: number | null = null3 4function debouncedRefreshSummary(): void {5if (refreshTimer) clearTimeout(refreshTimer)6refreshTimer = window.setTimeout(async () => {7 const totals = await fetchSummaryTotals(currentFilters.value)8 summaryStore.update(totals)9 refreshTimer = null10}, DEBOUNCE_MS)11}Polling would have been simpler to implement, but sub-second updates on a merchant dashboard are table stakes — not a differentiator. The WebSocket approach eliminated 40+ unnecessary API calls per merchant per day and made the dashboard feel instant.
Engineering decisionWebSocket over polling
Shared Packages in a Monorepo
25 packages in a pnpm / Turborepo workspace, each with its own build, test, and release cycle:
Base Components
UI PrimitivesButtons, modals, tables, form controls — UI primitives usable by any team without introducing merchant-specific concepts.
Feature Modules
Domain IsolationCampaign, notification, settings — each domain had its own package with routes, store modules, and API clients.
Configuration
Shared StandardsSCSS variables, lint rules, build presets — standardized conventions without duplicating config files across packages.
Engineering Decisions
WebSocket with Singleton Client + Vue Mixin
Multiple components needed realtime updates, but each establishing its own WebSocket connection would waste resources and create inconsistent connection states.
- One connection per merchant per session instead of one per component
- Auth refresh and reconnection behavior centralized and testable
- Components consumed realtime data declaratively via the mixin
- Mixin pattern didn't scale cleanly to Vue 3 Composition API
- All components shared one connection — no per-component isolation
Independent Filter Scopes over Global State
Merchants use summary, chart, and list views for fundamentally different questions. A single global filter served none of them well.
- Each view now answers the question merchants actually ask
- Feature teams evolve views independently without cascading breakage
- Reduced API calls — changing one view doesn't cascade to others
- More state to manage across the page (22 Jira issues, 140+ files)
- Cross-view interactions need store-level coordination
Practical Lessons
Realtime UX needs to account for mobile lifecycle, network recovery, auth refresh, and backpressure from day one. These aren't edge cases — they're the normal operating environment. Design for the unreliable, and the reliable parts take care of themselves.
A shared component library without governance creates its own kind of inconsistency. The hard work isn't writing the components — it's establishing the conventions for when and how to use them across multiple teams with competing priorities.
Production observability was non-negotiable. Every WebSocket event, reconnection, and error was instrumented through New Relic and Sentry. When we rolled out the realtime dashboard, we could see exactly how many merchants were connected, how often reconnections happened, and which failure modes were most common.
Confidentiality note: Case study details are anonymized. Metrics are verified from git log and Jira data. No proprietary business logic, internal API contracts, or customer data are exposed.