# MiroPay: Miro's self-serve checkout and billing

Rui Silva proposed extracting Miro's self-serve checkout from the client monolith and was responsible for migrating it into MiroPay, a Next.js microservice with its own BFF; this case study covers the architecture, trade-offs, reliability, and delivery work behind it.

## Overview

MiroPay is Miro's self-serve checkout and billing: how customers pay for plans, seats, add-ons, and AI products, change a payment method, add or remove licenses, and upgrade or downgrade a subscription, for individuals and teams (through Business). It is the pioneer microservice Miro deployed at scale, and I work on it in Miro's Growth Monetization team. I proposed extracting checkout from the client monolith into a dedicated service. The team aligned on the direction, and I was responsible for the migration, from the checkout customers see to the backend layer behind it. I'm also a first-responder on checkout incidents, and I reworked how the app builds, tests, and ships.

- ~25 → <4 min CI pipeline
- 96% less build time (combined changes)
- ~50,000 lines extracted from the client monolith
- 80%+ of the company uses MiTest daily

## Architecture

- **Frontend:** A Next.js app that renders checkout in a cross-origin iframe, using server-side rendering to move some static rendering and heavy logic off the client.
- **Backend-for-Frontend (BFF):** A Node.js layer in the same service that orchestrates checkout: validation, subscription and entitlement context, country-specific tax context, and order flows, so the client doesn't call several backend services itself.
- **Internal services:** The BFF talks to internal billing and entitlement services over native gRPC, with TypeScript contracts generated from Protocol Buffers.
- **Payments:** Stripe Elements (the Payment Element) renders Stripe-hosted card fields inside checkout. The publishable key only identifies Miro's Stripe account, so it is safe in the browser; the client secret, returned by the BFF's init flow for one specific payment or setup, lets the browser confirm it directly with Stripe. Raw card details go to Stripe and stay outside Miro's application backend, which reduces the associated PCI DSS scope.
- **Subscriptions:** Stigg handles the catalog, entitlements, and plan, seat, and add-on changes.
- **Tax and currency:** Stripe calculates tax. The frontend and BFF stay agnostic to country-specific tax/VAT rule combinations, and checkout and order flows support multiple currencies.
- **Asynchronous outcomes:** 3D Secure, webhooks, and delayed confirmations are handled as events: the UI listens across the iframe boundary and the BFF processes webhooks, rather than relying on a single request/response.
- **Payment providers:** We prepared the system with a payment-provider abstraction layer designed to accommodate multiple PSP integrations, keeping provider-specific concerns separate from the core checkout flow.

## Decisions and trade-offs

### A. Extracting checkout into MiroPay

- **Context:** Self-serve checkout and billing lived in Miro's client monolith. I proposed extracting them into a dedicated service; the team aligned on the direction, and I was responsible for the migration.
- **Decision:** Move checkout into MiroPay, a dedicated Next.js microservice with its own BFF. The migration extracted ~50,000 lines of checkout-related code from the client monolith.
- **Alternative considered:** Keep checkout in the client monolith and keep scaling the monolith backend's REST endpoints.
- **Why:** Checkout deploys no longer depend on the main application's release cycle, and checkout gets a clear domain boundary. Within that boundary, we prepared a payment-provider abstraction layer designed to accommodate multiple PSP integrations.
- **What it cost:** Another service to own and operate, cross-origin iframe communication, and migrating live paying traffic safely.
- **How the cost was handled:** Traffic moved progressively behind feature flags, using SplitIO and the in-house rollout service, with rollback available.

### B. Native gRPC from the BFF

- **Context:** Miro's internal backend services already used gRPC as their standard protocol, so the BFF needed to reach internal billing and entitlement services through it.
- **Decision:** I integrated the Node.js BFF directly with native gRPC clients, using TypeScript contracts generated from Protocol Buffers.
- **Alternative considered:** An intermediary REST proxy in front of the internal services.
- **Why:** Typed contracts, alignment with the internal standard, and no unnecessary proxy layer between the BFF and the services.
- **What it cost:** A more complex local build setup, and Protobuf code generation inside a TypeScript/Node.js project.
- **How the cost was handled:** Protobuf compilation is automated in the build pipelines.

### C. Stripe Elements and payment-data isolation

- **Context:** Checkout needed to collect card details securely without sending customers away from Miro.
- **Decision:** Checkout collects card details through Stripe Elements (the Payment Element) in Stripe-hosted fields. I implemented the Stripe Elements frontend integration and coordinated with the team on the supporting payment setup, including SetupIntent creation.
- **Alternative considered:** Custom card fields hosted on Miro's own domain, or a redirect to a separate hosted checkout.
- **Why:** Sensitive card details stay outside Miro's application backend while checkout stays integrated, which reduces the associated PCI DSS scope.
- **What it cost:** Cross-origin messaging and UI state synchronisation, limited customisation inside Stripe-hosted fields, and asynchronous outcomes: 3D Secure redirects, webhooks, and delayed confirmations.
- **How the cost was handled:** Client-side event handling across the iframe boundary, and webhook processing in the BFF for delayed payment confirmations.

### D. BFF orchestration and checkout-context initialisation

- **Context:** Opening checkout needs subscription state, entitlement checks, validation, and country-specific tax context. I owned and implemented most of the BFF orchestration, coordinating and delegating parts of the work across the team.
- **Decision:** Centralise checkout orchestration, validation, and subscription and entitlement context in the BFF. Stripe still calculates tax; the BFF coordinates the context around it.
- **Alternative considered:** The React client orchestrating multiple sequential REST/GraphQL calls itself.
- **Why:** No client-side request waterfalls across backend services, less complexity in the client bundle, sensitive billing and discount logic kept on the server, and a single orchestration layer.
- **What it cost:** Additional checkout-context initialisation latency in complex multi-tenant account scenarios.
- **How the cost was handled:** Independent internal gRPC calls run concurrently with Promise.all, and the immutable pricing catalog and static entitlement data are cached in memory.

### E. Developer tooling as operational leverage

- **Context:** Setting up a test billing account was slow and needed manual work, and the internal translation CLI downloaded more history than it needed.
- **Decision:** I built and maintain MiTest, a Chrome extension with a DevTools panel, and I re-engineered the translation CLI with request filtering and response caching.
- **Alternative considered:** Keep relying on manual database scripts and APIs for test setup, and accept the CLI's existing behaviour.
- **Why:** Faster feedback loops, and QA, product managers, and support can create complex billing test states on their own.
- **What it cost:** Ongoing ownership of internal tools alongside product-feature delivery.
- **How the cost was handled:** MiTest's mock generation is integrated into automated E2E tests, so it is part of the team's test infrastructure.

## Reliability and operations

- I'm a first-responder on checkout incidents and work on payment reliability, observability, and operational health across Growth Monetization.
- I identified and fixed a trial-license validation gap across backend layers, and built internal reporting so fraud and support teams could identify abnormal trials and assess financial exposure.
- The migration moved live paying traffic progressively behind feature flags, with rollback available.
- Asynchronous payment outcomes are handled as events, so the UI and BFF react to 3D Secure, webhooks, and delayed confirmations rather than a single request/response.

## Outcomes

- CI pipeline: ~25 minutes to under 4 minutes.
- Build time: cut by 96% across the combined optimisation work.
- Local startup: 32% faster.
- ~50,000 lines of checkout-related code extracted from the client monolith into MiroPay.
- MiTest: more than 80% of the company uses it daily, and test billing-account setup went from ~3 minutes to 5 seconds.
- Translation CLI: ~23s to ~2.1s.
- Migrated the payments app from Next.js 14 to 16 and standardised it on the App Router.
- Shipped checkout flows for add-ons, AI credit products, Business AI, and free-to-paid experiments.

The CI, build, and startup gains came from combined work across the application lifecycle, not one change:

- **Dependencies:** Simplified, then removed, the custom Express server that started Next.js, and swapped heavy dependencies for lighter ones (Zod → Valibot).
- **Compilation:** Moved Next.js compilation from Webpack to Turbopack, and linting and formatting from ESLint/Prettier to Oxc.
- **Testing:** Moved lint, format, unit, and E2E checks out of the Docker build onto right-sized GitHub-hosted runners.
- **CI orchestration:** Moved build work onto GitHub-hosted runners and maximised parallel execution across CI stages.
- **Containerisation:** Lightweight Docker base images without Node.js, carrying only the build artifacts and the Bun runtime, with fewer layers and operations.
- **Deployment:** Next.js standalone output and a deployment adapter that produces a Bun executable, with Bun also used for scripts and tooling.

- Portfolio: https://ruisilva.dev/
- Resume download: https://ruisilva.dev/rui_silva_cv.docx
