A simulated Tap to Pay point of sale for Android, built around the moment a payment's answer gets lost.
TapFlow is a complete, runnable POS: a staff app, a payment backend, a simulated card terminal and an optional real NFC tap. It exists to show how an in-person payment client is engineered when the network, the terminal and the process itself are unreliable — never charge the customer twice, never lose track of a payment.
Real card acceptance needs a merchant account and a certified SDK from a payment provider (Adyen, Stripe, SumUp and the like), plus a PCI-listed solution for Tap to Pay on a phone. I had access to none of that, so everything below the app is simulated, and the simulation is the interesting part:
- A vendor-style terminal SDK with callbacks, timeouts and read failures, behind the same kind of interface a real SDK would sit behind (ADR 0012).
- A payment backend (Ktor + H2) with idempotency keys, authorization, capture, reversal and refunds, and a fake acquirer that approves, declines, answers late or never answers at all (ADR 0005, ADR 0018).
- Fault injection at three layers — terminal, network and acquirer — either scripted for one payment or as seeded chaos, from a developer panel in debug builds (ADR 0013).
- A real NFC exchange, without card data. The terminal can read a second phone running the included test card app over its own proprietary AID; it receives a random token, never a PAN (ADR 0032).
No real payments, no card data. TapFlow never reads, stores or transmits card data, and cannot move money. Real acceptance would replace the fake terminal behind the existing interface, and requires certification and PCI MPoC. See security.md for the threat model and PCI scope, and ADR 0002.
Every screenshot is rendered by the screenshot tests, so they always match the code (ADR 0027).
| Home | Amount | Tap card | Paid |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
| Outcome unknown | Transactions | Payment and refunds | Refund |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
| Dark: home | Dark: amount | Dark: tap card | Dark: transactions |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Each one can be triggered by hand from the developer panel. The Try it column links to step-by-step scenarios with the exact taps and the expected result.
| Challenge | How TapFlow handles it | Try it |
|---|---|---|
| A lost answer after the money moved | The outcome is Unknown, never "failed". The payment is reconciled instead of guessed (ADR 0015). |
Phase 4 |
| Charging the customer twice | While an outcome is unknown, new payments are paused. Staff may continue after a confirmation, which is recorded on the payment (ADR 0022). | Phase 4 |
| Retries that must not double charge | A UUID idempotency key is stored before the first request. The backend enforces it: a concurrent replay gets 409, the same key with a different body 422 (ADR 0014). | Phase 3 |
| Resolving an unknown outcome | Ask the backend with backoff; if the outcome stays unknown past the window, request a reversal so the customer ends up not charged. In-process, with a WorkManager job as backup (ADR 0021). | Phase 4 |
| The app dies mid-payment | Every state change is written to Room before the side effect that depends on it, and recovery runs at process start (ADR 0020). | Phase 4 |
| Cancelling too late | Cancel is offered only until the card is tapped. After that the app says it is too late, because the authorization may already be on its way (ADR 0025). | Phase 5 |
| Refunds that could exceed the payment | Partial and repeated refunds, each with its own idempotency key. The device counts pending refunds and the backend checks the limit under a row lock, so concurrent refunds cannot over-refund (ADR 0026). | Phase 5 |
| Terminal, network and acquirer failures | Scripted faults for the next payment, or seeded chaos at a chosen rate per layer (ADR 0013). | Phase 2 |
| Knowing what happens in the field | Typed telemetry goes into a Room outbox and is uploaded in idempotent batches; the backend derives approval rate, unknown rate, time to resolution and p95 latency (ADR 0028, ADR 0029). | Phase 6 |
| Switching a feature off without a release | Typed flags with local defaults, remote config, a cache for offline starts, and a refunds kill switch (ADR 0030). | Phase 6 |
| Rolling out a risky change | A payment-policy change (a shorter authorization timeout) goes to a cohort, guarded by the unknown rate. If the cohort gets worse than control, it rolls itself back (ADR 0031). | Phase 6 |
| A real card tap | NFC reader mode reads the included HCE test card over a proprietary AID and gets a fresh random token. Any other tag counts only as "a card was presented" (ADR 0032). | Phase 7 |
| A screen staff can read across a counter | One full-screen step per phase of the payment, a light and dark design system, and OEM forced dark mode switched off so nothing turns invisible (ADR 0033, ADR 0034). | Phase 8 |
App: Kotlin 2.3, Coroutines and Flow, Jetpack Compose with Material 3, Navigation 3 with an app-owned typed back stack, MVVM with ViewModel and immutable UI state, Hilt with KSP, Room (write-ahead storage, migrations and schema tests), WorkManager, Ktor client on OkHttp with kotlinx.serialization, NFC reader mode and Host Card Emulation, edge-to-edge and a light/dark design system with bundled Inter.
Backend: Ktor server, Exposed and H2 with row locks, an OpenAPI contract (api/openapi.yaml) verified by contract tests.
Quality: JUnit Jupiter, assertk, kotest-property, Turbine, Robolectric with Compose UI tests, Roborazzi screenshot tests, detekt with ktlint and Compose rules, a multi-module build with Gradle convention plugins and a version catalog, dependency rules enforced at configuration time, and GitHub Actions CI. Over 540 tests run on the JVM without an emulator.
flowchart TD
card["Card, phone or watch<br/>or the HCE test card app"]
subgraph phone["Staff phone"]
ui["Compose UI<br/>home · checkout · transactions · dev panel"]
domain["core:payment:domain<br/>state machine · coordinators · reconciliation"]
terminal["Card terminal<br/>simulated SDK or NFC reader"]
data["core:payment:data<br/>Ktor client · Room · WorkManager"]
end
subgraph server["Backend · Ktor, Exposed, H2"]
payments["Payments and refunds<br/>idempotency keys · row locks"]
acquirer["Fake acquirer<br/>approves · declines · answers late · never answers"]
ops["Telemetry and metrics<br/>flags · rollout guardrail"]
end
ui --> domain
domain --> terminal
domain --> data
card -. "tap" .-> terminal
data -- "HTTP · api/openapi.yaml" --> payments
payments --> acquirer
data -. "events, config" .-> ops
Features never talk to the terminal, the network or each other; they depend on domain use cases, and the build fails if that rule is broken (ADR 0003). Diagrams of the payment states, the lost-answer sequence and the NFC exchange are in architecture.md.
All modules
:app composition root
:backend Ktor payment backend with a simulated acquirer
:testcard HCE test card app for a second phone
:feature:home start screen: card reader status and staff tasks
:feature:checkout amount entry and full-screen payment steps
:feature:transactions history, payment details and refunds
:feature:devpanel fault injection controls (debug builds only)
:core:payment:domain state machine, orchestration (pure Kotlin)
:core:payment:data HTTP gateway, storage, telemetry upload
:core:terminal:api PaymentTerminal contract (pure Kotlin)
:core:terminal:fake simulated vendor SDK and its adapter (pure Kotlin)
:core:terminal:nfc NFC reader mode terminal, never reads card data
:core:terminal:nfc-protocol test card APDU protocol (pure Kotlin)
:core:fault-injection scripted and chaos fault injection (pure Kotlin)
:core:featureflags typed flags, remote config and rollouts (pure Kotlin)
:core:observability typed telemetry, outbox contract and upload (pure Kotlin)
:core:ui design system: light and dark color roles, Inter, icons, components
build-logic/convention Gradle convention plugins
Requirements: JDK 21 and Android SDK 36. No payment provider account, and no network access beyond your own machine.
./gradlew :backend:run # backend on http://localhost:8080
./gradlew :app:installDebug # the emulator reaches the backend at 10.0.2.2:8080On a physical phone, point the app at your machine:
./gradlew :app:installDebug -Ptapflow.backendUrl=http://<your-machine-ip>:8080For a real NFC tap, install the test card on a second NFC phone and pick the NFC reader in Developer tools:
./gradlew :testcard:installDebug # on the second phoneThe app opens on the home screen: card reader status, Take a payment, Transactions, and Developer tools in debug builds. Everything in the table above is triggered from Developer tools.
Each phase has notes with a Try it table: what to tap, and what should happen. Start with Phase 4, which covers the lost answer, the reversal and the staff override — the heart of the project.
Phase 1 · Phase 2 · Phase 3 · Phase 4 · Phase 5 · Phase 6 · Phase 7 · Phase 8
For a guided walkthrough of the whole app in about seven minutes, follow the demo script.
./gradlew test # unit, Compose UI, backend concurrency and contract tests
./gradlew verifyRoborazziDebug # compare screenshots with src/test/screenshots
./gradlew recordRoborazziDebug # re-record screenshots after an intended visual change
./gradlew detekt # static analysis (ktlint and Compose rules included)
./gradlew assembleDebug # build the appEvery decision, with the alternatives that were rejected and why, is an ADR: docs/adr (34 of them). Good places to start:
- Simulated payments — what is faked, and what is not.
- The payment state machine — including the ambiguous states.
- Request outcomes and retries — which failures may be retried, and which may not.
- Blocking unknown payments — the double-charge guard and the staff override.
- The NFC reader — a real tap that still reads no card data.
MIT, see LICENSE. The bundled Inter font keeps its own license, the SIL Open Font License 1.1, in core/ui/src/main/assets/licenses/inter-OFL.txt.











