Skip to content
Ali-MostaghimiPublic

About

Simulated Tap-to-Pay POS for Android, built around lost payment answers: explicit state machine, idempotency, reconciliation, process-death recovery, injectable faults.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

TapFlow

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.

Why a simulation

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.

Screenshots

Every screenshot is rendered by the screenshot tests, so they always match the code (ADR 0027).

Home Amount Tap card Paid
Home screen with the card reader ready and the staff tasks Amount entered on the keypad Full-screen prompt to tap the card Payment successful
Outcome unknown Transactions Payment and refunds Refund
A payment whose outcome is unknown blocks new payments Transaction history Payment details with refunds Refund amount on the keypad
Dark: home Dark: amount Dark: tap card Dark: transactions
Home screen in the dark theme with paused payments Amount entry in the dark theme Card tap prompt in the dark theme Transaction history in the dark theme

The challenges it simulates

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

Built with

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.

Architecture

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
Loading

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

Running it

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:8080

On a physical phone, point the app at your machine:

./gradlew :app:installDebug -Ptapflow.backendUrl=http://<your-machine-ip>:8080

For 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 phone

The 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.

Trying the failure scenarios

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.

Checks

./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 app

Design decisions

Every decision, with the alternatives that were rejected and why, is an ADR: docs/adr (34 of them). Good places to start:

License

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.

About

Simulated Tap-to-Pay POS for Android, built around lost payment answers: explicit state machine, idempotency, reconciliation, process-death recovery, injectable faults.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages