Skip to main content

Business Merchant Platform

Merchant-facing portal and shared component platform for businesses managing payments, transactions, campaigns, settlements, and account operations.

Role: Senior Software Engineer — Frontend & PlatformPeriod: Jan 2022 — May 20249 min read
Vue 2TypeScriptVuexElement PlusWebSocketAWS API GatewayTurborepopnpm
1,200+
Merchant portal commits
140+
Files in transaction revamp
25
Shared packages
2+ yrs
Active development

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.

scanrouteconfirmpushPOS TerminalNFC / QR ScanAPI GatewayAWS WebSocketAWSPayment ProcessorTransaction EngineMerchant DashboardVue 3 UIWebSocket flow · pulse every 1.6s

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.

Warning·

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:

Shows today's totals — revenue, transaction count, success rate. Merchants glance at this throughout the day. It should always show the current day, not a stale date range.

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.

Store filter — feeding independent views
Vue
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}))
Representational — simplified from the production codebase. Actual implementations include additional error handling and edge case coverage.
Tip·

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.

Vue
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

Live Transactions
updates every 3s
TimeMerchantAmountStatus
Waiting for transactions...

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.

Debounced refresh — preventing API floods
TypeScript
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}
Representational pattern. Production includes additional guard conditions and error recovery.

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 Primitives

Buttons, modals, tables, form controls — UI primitives usable by any team without introducing merchant-specific concepts.

Feature Modules

Domain Isolation

Campaign, notification, settings — each domain had its own package with routes, store modules, and API clients.

Configuration

Shared Standards

SCSS variables, lint rules, build presets — standardized conventions without duplicating config files across packages.

Engineering Decisions

Engineering Decision

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.

Decision: A singleton WebSocket client managed a single connection. A Vue mixin provided lifecycle hooks — connect, reconnect, auth refresh, cleanup — eliminating duplication across 10+ components.
Consequences:
  • 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
Tradeoffs:
  • Mixin pattern didn't scale cleanly to Vue 3 Composition API
  • All components shared one connection — no per-component isolation
Engineering Decision

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.

Decision: Each view component owns its filter state. Summary always shows today. List has its own date picker. CSV has separate export parameters.
Consequences:
  • 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
Tradeoffs:
  • 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.