New page, 2026-09-13. Not a merchant SDK — a payer surface, like
@vaam-apps/vpay-stripe-js — so it is not folded into
merchant-sdks.md. See
docs/flows/mobile-checkout.md for the process
and ADR-0021 for the decisions.
Built by Lane B of
docs/plans/2026-09-13-flutter-plugin-brief.md,
on claude/flutter-lane-b-gate:
| Piece | State |
|---|---|
cargo xtask verify-sdk-parity reads Dart |
✅ dart_test_names collects test('…')/testWidgets('…')/group('…') titles, single- and double-quoted, with escapes and raw (r'…') strings; PARITY_SKIPPED_DIRS grew .dart_tool and build. Proven against a synthetic fixture tree in .xtask/src/main.rs's sdk_parity_tests module — not against this repository's own sdks/, which has no Dart column yet. |
| The skip property, the one this gate exists for | ✅ test('x', skip: true) and a string skip: reason are not collected, mirroring rust_test_names' drop of #[ignore]d tests and ts_test_names' drop of it.skip(…). Proven by mutation: dart_decisive_mutation_a_live_test_passes_verify_sdk_parity calls verify_sdk_parity on a synthetic tree and gets Ok(()); the same fixture with skip: true added (dart_decisive_mutation_skip_true_fails_verify_sdk_parity_naming_the_cell) gets Err naming both the cell and the SDK column. See the dated verification page below for the literal before/after. |
just install-flutter / analyze-flutter / test-flutter |
✅ exist, each refuses with a named, human-readable reason — not a stack trace — when sdks/flutter/vpay_checkout_flutter/ does not exist (true on this branch) or when the flutter SDK is not on PATH. Not in just ci or just verify (D-M3): confirmed by reading both recipe lists; neither names any of the three. |
flutter-toolchain.toml |
✅ pins Flutter 3.47.2 / Dart 3.13.2 — the version installed on the host this lane was authored and verified against, not a floor computed from a pubspec.yaml (there is none yet). The file's own comment records that the host reports channel [user-branch], not a clean channel pin the way rust-toolchain.toml's channel is, and says the maintainer may want to fix that before this ships in an image. |
| ADR-0021 | ✅ docs/adr/0021-flutter-checkout-plugin.md — records D1–D9 and D-M1–D-M6 as accepted. |
docs/flows/mobile-checkout.md |
✅ the flow doc, carrying D9's Apple 3.1.3(e) and 3.1.1 quotes verbatim (not paraphrased) as a constraint on adoption, and naming — rather than deciding — the one open documentation-structure question against hosted-checkout/page-memory-and-protocols.md's popup table. |
Lane B's table above described the gate and the docs. The plugin itself landed afterwards, and until 2026-09-14 this page still said "No Dart file exists in this repository" — true when Lane B wrote it, false from Lane A's merge onward. Corrected here by the review.
| Piece | State |
|---|---|
The Dart core (lib/) |
✅ browser client, pure state machine, result/error types, redaction, the pigeon seam. flutter test green; counts and skips on the dated verification page. |
| The Android host | ✅ exists and compiles: flutter build apk --debug on example/, and the merged manifest carries VpayCheckoutActivity with android:exported="false". onReceivedSslError is not overridden and there is no addJavascriptInterface call. No device, no emulator, no instrumentation test. |
| The web host | ✅ exists and compiles: flutter build web on example/, and Flutter's generated web_plugin_registrant.dart calls WebVpayCheckoutPlatform.registerWith. No browser has driven the popup. |
| iOS and macOS hosts | ⛔ xcodebuild, reviewed by reading onlyVpayCheckoutExternalBrowserSession, unaffected by this row. |
VpayCheckoutMode.externalBrowser (D8) |
✅ closed by D8, 2026-09-14 — see the D8 section below. No longer ⛔. |
| The parity table | ✅ docs/sdks/parity.md's third table, every Flutter ✅ cell naming a Dart test that actually ran. |
Each was measured by mutation; the before/after exit codes are on the dated page below.
externalBrowserwas silently ignored — the public API accepted a mode it did not have and handed back the in-app WebView.- The pigeon-generated
toStringrendered the session secret.ShowCheckoutRequest.url's fragment is the sessionclient_secret, and the generated Dart, Kotlin and Swift all interpolated it. The design doc predicted this by name ("generated code is how this regresses") and the D6 parity row was ✅ with no test over that file. no_logging_test.dartexcluded member calls, sodeveloper.log(secret)inlib/passed it.- The gate's Dart reader collected tests that never run — a skipped group's tests, commented-out declarations, and titles quoted in strings.
- The window's one event could be dropped, hanging
start()forever, because the broadcast stream was subscribed only aftershow()returned. - A malformed 200 escaped as a bare
TypeErrorout of a poll, against this package's own stated contract. allowInsecureUrlwas hard-codedfalseand never reached the host.
Until this lane, every server in this package's test suite was
package:http/testing.dart's MockClient. That is no longer true for
test_e2e/real_stack_e2e_test.dart, run only by just test-flutter-e2e,
never by plain flutter test (which stays MockClient-only, stack-
independent, and still 80 passed / 0 skipped).
| Piece | State |
|---|---|
| A real Checkout Session, minted for real | ✅ the recipe mints two, through examples/shop's real server — a real POST /v1/payment_intents + POST /v1/checkout/sessions, authenticated with a real private_key_jwt client_credentials exchange (the same two calls examples/shop/src/server/orders.ts makes for a paying customer) — never a credential this package itself holds. |
BrowserClient/CheckoutController end to end |
✅ mints, preflights, confirms and polls a real session to succeeded — a real session read, a real pre-flight, a real confirm (POST /v1/browser/payment_intents/{id}/confirm, standing in for what vpay's own hosted page submits — this package has no confirm method by design), a real poll through the package's own code to a real terminal outcome, re-checked twice more independently (once more through BrowserClient, once with no package code at all). |
| The uniform 404 | ✅ an unknown id, a wrong secret and a wrong key all answer the same 404 — against the real server, not a stub. |
A session that is not open refuses the confirm |
✅ the intent read still answers, the pre-flight fails closed, and the confirm is refused with checkout_session_expired — the recipe expires a real session with a real merchant access token (minted the same private_key_jwt way examples/merchant-demo does, off whichever key the running shop container already has), then proves the intent read still works, the pre-flight fails closed, and the confirm answers the real 409 checkout_session_expired. |
| A real bug this found | ✅ fixed the same day — CheckoutSession.fromJson required a client_secret field the real GET /v1/browser/checkout/sessions/{id} never sends back (it only ever renders the intent's). Every MockClient fixture in test/ had been fabricating that field, so flutter test stayed green while every real pre-flight failed with unexpected_response(200). |
| The decisive test — a stack that is down | ✅ just demo_port=<nothing listening> test-flutter-e2e fails LOUDLY (exit 1) before any Flutter process runs, at the /healthz check. A green run with nothing listening was measured to be possible before this check existed and is exactly what this recipe refuses. |
Still true, narrower than before: the rail behind that real vpay is
WireMock, exactly as it is everywhere else in this repository
(docs/status.md's banner). docs/sdks/parity.md now carries this as two
rows rather than one, for exactly this reason.
Until this lane, VpayCheckoutMode.externalBrowser was designed and unwired:
pigeons/checkout.dart's ShowCheckoutRequest carried no mode field, so
VpayCheckout.start refused it with UnimplementedError before the
pre-flight ever ran. That special case is gone.
| Piece | State |
|---|---|
| The pigeon seam | ✅ pigeons/checkout.dart's CheckoutWindowMode (inApp/externalBrowser) and ShowCheckoutRequest.mode, regenerated for Dart, Kotlin and Swift; the D6 redaction hand-edit was re-applied in all four generated files after regeneration wiped it, and re-verified by test/messages_redaction_test.dart. Counts: Dart 4, Kotlin 4, iOS Swift 3, macOS Swift 3 — unchanged from the pre-D8 baseline. |
| Dart threading | ✅ VpayCheckoutPlatform.show gains a required mode; VpayCheckout.start always threads it through rather than special-casing externalBrowser — is threaded to the platform host as CheckoutWindowMode.externalBrowser, the default mode is inApp, threaded to the platform host as CheckoutWindowMode.inApp, with no platform host at all, still throws UnimplementedError (not special-cased any more — the seam itself has none). |
| Android — Custom Tabs | ✅ compiled and run for real. VpayCheckoutExternalBrowserSession launches a CustomTabsIntent (androidx.browser:browser:1.8.0) and reports a dismissal on the host Activity's own resume (Application.ActivityLifecycleCallbacks — no startActivityForResult, no scheme). flutter build apk --debug/--release on example/ both exit 0; DEX string count for the debug-only JS test hook unchanged (debug 4, release 0). A dedicated AVD (vpay_d8_avd, created and deleted for this run) drove checkout_external_browser_test.dart: a real CustomTabsIntent opened Chrome (com.android.chrome as the resumed activity, confirmed present on this image), a real back press returned control to the host Activity, and a real dismissed event arrived over the real channel. Run twice, both exit 0. checkout_dismiss_test.dart (in-app) re-run on the same device to confirm no regression: exit 0. |
| Web | ✅ WebVpayCheckoutPlatform.show accepts mode (never dropped from the signature) and documents that inApp/externalBrowser collapse to the identical window.open popup — there is no in-app WebView on Flutter web to distinguish them. |
iOS — SFSafariViewController |
✅ wired, ASWebAuthenticationSession — no scheme to call back on below 17.4, and its "sign in" consent alert is the wrong sentence on a payment (design doc D8). SFSafariViewController has no navigation delegate, so the only signal is the payer tapping Done, reported as dismissed. |
macOS — NSWorkspace |
✅ wired, compiled by nobody, maintainer's own call (recorded in VpayCheckoutExternalBrowserSession.swift's header): no SFSafariViewController or Custom-Tabs equivalent exists on macOS, so this opens the payer's default browser and watches for this app's own reactivation (NSApplication.didBecomeActiveNotification), mirroring Android's own tier-0 shape. |
| D8's tier 1 (App Links / Associated Domains) | ⛔ 2026-09-14 — not implemented on any platform. Needs a merchant-hosted assetlinks.json/apple-app-site-association deployment this repository cannot provide or prove against; D8 itself says a merchant may stop at tier 0, which is what ships. |
Evidence: verification/2026-09-14-flutter-d8-external-browser.md.
Every ✅ above for the Android/web window was earned by example/lib/main.dart
compiling and by example/integration_test/*.dart suites that called a
support/ensure_platform_registered.dart helper to register the platform
host by hand — the one thing a real app never does. A hand-driven walk
on a real installed APK (flutter clean → flutter build apk --debug →
adb uninstall → adb install → launch → fill fields → tap "Start
checkout") threw UnimplementedError: ... VpayCheckoutPlatform.windowEvents has no platform host yet and never opened VpayCheckoutActivity. Root
cause: the Flutter engine calls pubspec.yaml's dartPluginClass-generated
plugin registrant before the app's own main()/
WidgetsFlutterBinding.ensureInitialized()/runApp() runs, and
MethodChannelVpayCheckoutPlatform's constructor used to call
VpayCheckoutFlutterApi.setUp(this) eagerly, which touches
ServicesBinding.instance immediately — with no binding yet, that threw
Binding has not yet been initialized, silently swallowed by the engine's
own generated wrapper.
A first fix attempt the same day — deferring VpayCheckoutFlutterApi .setUp out of the constructor into the first call to show — was written
up as done in this section and in the dated verification page below, but
was never actually applied to method_channel_checkout_platform.dart; a
second pass the same day found the constructor still calling it eagerly and
the real device still failing exactly as before. Repeated cold launches
of the same APK, read through adb logcat, then showed the race is real
but genuinely intermittent: _PluginRegistrant.register() sometimes runs
before WidgetsFlutterBinding.ensureInitialized() and sometimes after, so a
single successful hand-driven walk proves nothing on its own. The fix that
actually landed has two parts. First, the constructor now catches the
FlutterError VpayCheckoutFlutterApi.setUp throws with no binding yet,
and show/dismiss/windowEvents each retry it — every one of those only
ever runs after a merchant's own main() has, when a binding always
exists. Second — load-bearing, since the first part alone still depends on
dartPluginClass eventually winning the race — checkout_platform.dart's
VpayCheckoutPlatform.instance getter is now platform-aware on its own,
via a dart:io-vs-web conditional import
(native_mobile_host_stub.dart/native_mobile_host_io.dart): the first
read that finds UnimplementedVpayCheckoutPlatform on Android, iOS or
macOS resolves MethodChannelVpayCheckoutPlatform lazily, at a point
guaranteed to be after the app's own main() has run. A
defaultTargetPlatform/kIsWeb check was tried first for that and
rejected: flutter test forces defaultTargetPlatform to android
whenever FLUTTER_TEST is set, which would have silently defeated
test/vpay_checkout_test.dart's own "no platform host exists yet" case.
dartPluginClass registration winning the race is now purely an
optimisation, never a requirement. support/ensure_platform_registered.dart
stays deleted; all three example/integration_test/*.dart suites rely on
the same automatic registration a real app depends on, and just test-flutter-emulator (VPAY_EMULATOR_SERIAL=emulator-5554, the
maintainer's own device) is still exit 0. The regression test,
test/method_channel_checkout_platform_registration_test.dart, is
unchanged and reproduces the pre-main() state; it still fails with
exactly Binding has not yet been initialized against the pre-fix
constructor (exit 1) and passes against the actual fix (exit 0) — both
re-measured.
Evidence: verification/2026-09-16-flutter-real-app-registration.md.
Requested by the maintainer, verbatim: the full-screen Android/iOS window
"feels like the user is quitting the app." This revises design D5 —
../plans/2026-09-13-flutter-plugin.md's
own "D5, revised 2026-09-16" section carries the full reasoning and every
alternative considered; this page records what was proven.
| Piece | State |
|---|---|
| Android — same Activity, translucent theme | ✅ VpayCheckoutActivity is unchanged as a class of window — android:exported="false" untouched — only AndroidManifest.xml's theme moved from Theme.NoTitleBar.Fullscreen to a translucent Theme.Vpay.CheckoutSheet (res/values/styles.xml), and onCreate now builds a scrim + CoordinatorLayout/Material BottomSheetBehavior sheet around the WebView instead of a bare full-bleed one. Deliberately NOT a BottomSheetDialogFragment hosted by the merchant's own Activity — that would dissolve the Activity isolation exported=false rests on for a cosmetically identical result. |
| The detent | ✅ halfExpandedRatio = 0.9f with isFitToContents = false — the maintainer's explicit "~90% of screen" decision — draggable further to STATE_EXPANDED (full height); skipCollapsed = true so a drag past the detent goes straight to hidden, never a small peek state. |
| One dismissal signal, three triggers | ✅ back press, a scrim tap, and a drag past the detent all set BottomSheetBehavior.state = STATE_HIDDEN; the sheet's own BottomSheetCallback.onStateChanged is the ONE place that then calls the existing finishAsDismissed() — no second, separate "cancel" path (design D4). Proven on-device, not just read: a real adb shell input swipe (drag-down) and a real tap on the scrim area both resolved through the existing VpayCheckoutPending result (D4's poll-before-report) with a real payment intent mid-flight, exactly as a real hardware back press already did. |
| Non-negotiables, re-measured on the new theme | ✅ none assumed unchanged. Fresh flutter clean → flutter build apk --debug/--release, both exit 0. The merged manifest inside BOTH built APKs — read with aapt2 dump xmltree against the actual APK file, not the source AndroidManifest.xml — still carries VpayCheckoutActivity with android:exported="false". DEX string count for evaluateJavascriptForTests: debug 4, release 0 — unchanged. onReceivedSslError still not overridden; still no addJavascriptInterface; allowFileAccess/allowFileAccessFromFileURLs/allowUniversalAccessFromFileURLs still all false (read, since none of these has a dedicated test). |
| The hand-driven walk, three cold launches | ✅ on the maintainer's own emulator-5554 (flutter clean → flutter build apk --debug → adb uninstall → adb install → launch → fill fields → "Start checkout"), all three showing VpayCheckoutActivity on top of dumpsys activity activities with the sheet visibly over the merchant app's own screen. Run 1 was driven all the way through a real MTN MoMo push (steering MSISDN 237600000100) to VpayCheckoutSucceeded and back to the merchant screen through the page's own forward button — the existing stopUrlReached path, unchanged. Runs 2 and 3 each independently exercised a different dismissal trigger (drag-down; a scrim tap) instead of completing the payment. |
just test-flutter-emulator, VPAY_EMULATOR_SERIAL=emulator-5554 |
✅ exit 0, all three suites green (window, dismiss, external-browser) — the dismiss suite's real hardware back press still reports a real dismissal through the sheet exactly as it did through the old full-screen window. |
iOS — UISheetPresentationController on 15+ |
✅ .pageSheet (13/14, unchanged) already rendered as a card with the app visible behind it; viewDidLoad now sets an explicit .large() detent via UISheetPresentationController on 15+, matching the maintainer's "draggable to full height" decision as an actual API call rather than .pageSheet's own implicit default. iOS 12 (D-M4 floor) has no non-full-screen modal presentation API at all and necessarily stays .fullScreen — a stated consequence of supporting that floor, not an oversight. |
| macOS | ⛔ unaffected on purpose — VpayCheckoutViewController there already presents as a sheet (that file's own header, unchanged); the maintainer's complaint was about the full-screen Android/iOS shape, not macOS's existing sheet. |
| Web | ⛔ unaffected on purpose — the web host has never been a native window (window.open popup, surrounded by the browser's own chrome); there is no full-screen takeover to fix. |
Screenshots (not committed to the repository, held by the agent that ran the walk): the merchant app before checkout, the sheet with the merchant app visibly behind it (the money shot), the paid outcome rendered inside the sheet, and the result back on the merchant's own screen.
Evidence: verification/2026-09-16-flutter-bottom-sheet.md.
D5 was revised again the same day, past the bottom sheet above: the plugin
now launches the payer's own browser — a partial Custom Tab on Android,
SFSafariViewController on iOS, NSWorkspace.open on macOS — instead of
rendering the checkout page in an in-app WebView. There is no WebView
anywhere in this plugin any more: VpayCheckoutViewController.swift is
deleted on both Apple platforms and VpayCheckoutActivity.kt no longer
creates one.
That is a correction, not just an addition, to two things this page and verification/2026-09-16-flutter-bottom-sheet.md already claimed above, in the "Non-negotiables, re-measured on the new theme" and D8 rows:
- The "Debug-only JS harness (
evaluateJavascriptForTests) stays debug-only" / "DEX string count … debug 4, release 0" rows measured a real control: a native→JS injection hook (VpayCheckoutActivityTestHarness.kt,android/src/debug/kotlin, reached from Dart only throughexample/integration_test/support/test_js_harness.dart) that stayed out of release DEX. That control is retired, not still holding. The hook calledWebView.evaluateJavascript, and there is noWebViewleft to call it on, so both the hook and its Dart caller were deleted rather than left compiling against nothing. A DEX grep forevaluateJavascriptForTeststoday still reads debug 0 / release 0 — zero because the symbol does not exist anywhere in the source tree any more, not because a debug-only capability is still being kept out of release. just test-flutter-emulator, "all three suites green (window, dismiss, external-browser)": the "window suite" wascheckout_window_test.dart, which drove the realWebViewthrough a full MTN MoMo push using the JS harness above — it could not pass again once theWebViewit drove was gone, so it was deleted with it.just test-flutter-emulatornow runs two suites, dismiss and external-browser, both re-proven against the Custom Tab / browser surface. Lost coverage: no suite in this repository drives a full MTN push through the real hosted checkout page end to end on Android any more;checkout_window_test.dartwas the only one that did, and nothing replaced it.
Everything else the bottom-sheet section proved — exported="false",
onReceivedSslError/addJavascriptInterface/allowFileAccess*, the
drag-down/scrim/back-press dismissal path — is unaffected by this
correction; only the two rows above implied a control that no longer
exists.
What the paragraph above left out, added here rather than treated as a separate pass. The WebView cutover is bigger than "the browser renders the page instead of a WebView" — three more things changed the same day, and this page did not yet say so.
VpayCheckoutMode/CheckoutWindowModeare deleted outright, not one of two selectable modes any more.VpayCheckout.starttakes a session URL and nothing else;VpayCheckoutPlatform.showtakes nomodeargument. What D8 (below) calledexternalBrowseris now the only thing any platform does — there is noinAppto fall back to and nothing left to special-case.CheckoutWindowOutcome.stopUrlReachedchanged meaning. It used to fire on an interceptedWebViewnavigation; now it fires only on an incoming deep link (an Android App Link or an iOS/macOS Universal Link). That signal is unverified on every platform as of 2026-09-16: a real App Link/Universal Link needs an HTTPS origin servingassetlinks.json/apple-app-site-associationfor the merchant's ownsuccess_urlhost, which this repository can neither deploy nor prove against. Every checkout today ends asdismissedin practice — D1/D4's poll is what makes that correctness-complete regardless. A new, narrow, exportedVpayCheckoutAppLinkActivityexists on Android solely to forward an incoming App Link into the plugin; the plugin itself ships no<intent-filter>of its own — a hostlesshttpsfilter would claim everyhttpsURL on the device (harmful on API 21-30, merely inert on 31+, since verified App Links need a host anyway) — so the merchant declares an<intent-filter>for their own verified host, merged viatools:node="merge".- macOS lost its only dismissal signal. The previous code reported
dismissedthe moment the app regained focus, which was never real evidence the browser had actually closed (a payer can switch back to check something else and switch away again without touching the browser). That fake signal is removed rather than kept for appearances: macOS now has no dismissal signal at all unless a Universal Link arrives or the merchant callsdismiss()itself, so D4's poll never starts there on its own.
Verified, and precisely how far — the part this page owes and had not
yet paid. For the first time, this plugin was run — not merely compiled —
on a real iOS Simulator (iPhone 17 Pro, iOS 26.5), on a macOS host with a
real Xcode toolchain (this repository's earlier "no xcodebuild on this
host" claims were about a different, Linux host): the SFSafariViewController
sheet renders with Safari's own address bar; tapping through to
success_url leaves the sheet open, since no navigation interception
exists any more; tapping Done reports dismissed, D4 polls, and the
checkout resolves VpayCheckoutSucceeded. iOS is no longer only
"compiled by nobody" — see the correction to the "iOS and macOS hosts"
row in docs/sdks/parity.md. The Dart suite stays 80 passed / 0 skipped,
dart analyze --fatal-infos clean. Android and example/ compile
(BUILD SUCCESSFUL) but were not run on an emulator against this
architecture — the Lane E/D8 emulator evidence elsewhere on this page
predates the cutover and does not carry forward; citing it as current
proof would be exactly the failure mode CLAUDE.md warns against. macOS
itself (the desktop target, as opposed to iOS) was not exercised in this
pass. Evidence:
verification/2026-09-16-flutter-browser-cutover.md.
The browser cutover above removed the checkout WebView for the UX reasons
issue #189 states; #189 itself is the next step, rendering the checkout as
native Flutter widgets driven by the #186 rail spec instead of the browser
at all. This is lane 1 only — pure-Dart core, no widget renders:
lib/src/models.dart now parses CheckoutSession.rails (RailSpec,
RailField, RailFieldKind, RailDisplayName, RailFlow,
CheckoutSessionPaymentStatus); lib/src/sheet/ carries the payment screen
machine (checkout_screen.dart, the machine.ts port, 13 screens), the
return machine (return_screen.dart, the return.ts port — structurally
credential-free, D6), the shared poll-terminal rule
(outcome.dart), the server-driven (never per-code) rail-support decision
(rails.dart), and Dart ports of money.ts's no-float digit surgery,
msisdn.ts's Cameroon normaliser and failures.ts's providerReason.
flutter test is 200 passed / 0 skipped (was 80 before this lane); three
mutations pinning the money/poll-terminal/no-rail-code-branching traps were
run against the real source and confirmed to fail the tests built for them,
then reverted. docs/sdks/parity.md gained ten rows for what this lane
proves. Evidence, including the pre-existing (not newly introduced)
just fmt-check-web failure this run found:
verification/2026-09-16-flutter-rail-spec-screen-machine.md.
Not done, and owed to a later lane: the sheet widget itself, i18n (the
~71-key catalogue, French default), the "remember this number" feature, the
test-mode banner, focus management/Semantics, wiring the jittered-poll
primitive (poll_jitter.dart) into an actual controller, the redirect
rail's hand-off back into the sheet, and the ADR issue #189 asks for on
dropping D4/D8's iframe/postMessage concepts (frame.ts/origins.ts/
csp.ts) as web-only with no native analogue.
Issue #195 — the browser leg is a controlled surface, the return page suppresses its outcome (2026-09-17)
SheetController.startRedirect no longer hands the rail's own URL to the
browser: it opens /c/{id}/redirect (a page on the checkout origin) and
the rail URL is deliberately never passed — that page asks the server for it,
so a payment origin cannot be an open redirect. The return page
/c/{id}/return, seeing the sessionStorage redirect-leg marker, renders a
neutral "returning to the app" screen instead of a full outcome and never
starts its controller; the sheet stays the outcome reporter, in its own
language and money format.
Two defects were found in review and are what the evidence below is actually about, because each one on its own meant no payer ever reached the rail — strictly worse than the duplicate screen this closes:
- The leg URL was built on
BrowserClient.baseUrl, the API's origin (deployment.public_base_url)./c/…is served byfrontends/apps/checkoutoncheckout.public_base_url— a second deployable,:8080vs:3080incompose.demo.yml, and the API mounts no/c/route. It now comes from the server-minted session URL the sheet already holds (SheetController.sessionPageUrlFrom). - The redirect page read
next_actionoff the session response, which the server never renders there (PaymentIntentObject::try_fromsets itNone; onlywith_next_actionattaches one, and that route does not call it). It now reads the session for the intent's credential andGET /v1/browser/payment_intents/{id}for the rail URL.
Both passed a fully green suite before review: the Dart test used
https://api.example as both origins, and the page's test hand-wrote a
fetch answer carrying a next_action. The tests were rebuilt so they can
fail — two distinct origins in both Dart suites plus a checkout_sheet_test
widget case over the seam that chooses between them, and the page driven
against src/testing/browser-stub.ts (whose session routes now answer
next_action: null, as the API does). Each fix was mutated back and
observed failing.
Measured on this branch on 2026-09-18, and written up with both mutation runs
in
verification/2026-09-17-redirect-leg-review.md:
flutter test 302 passed / 0 skipped, dart analyze --fatal-infos clean,
dart format --set-exit-if-changed clean; just test-web checkout 538
passed / 1 skipped, dashboard 316 / 1 skipped, shop 108; just test-storybook checkout 24 stories, dashboard 25, axe clean including
color-contrast; just lint-web exit 0; verify-ui, verify-status,
verify-links, verify-sdk-parity green. just ci was not run — the
Rust workspace was held by another agent — so nothing here is evidence about
it.
Merging this beside PR #197 needs one hand edit, and git merge will not
say so. The two branches touch sheet_controller.dart in different places
and merge without a conflict, but #197 adds nine SheetController(...)
constructions to test/sheet/sheet_controller_test.dart and this change makes
sessionPageUrl a required parameter, so the merged file fails
dart analyze with nine missing_required_argument errors. One line per site
(sessionPageUrl: _sessionPageUrl,) fixes it; measured on a throwaway merge,
after which flutter test is 314 passed / 0 failed and dart analyze --fatal-infos is clean. Whichever of the two merges second owns that edit.
Not proven: the leg end to end. Nobody has driven it on a device or
against just demo-up — the same unverified-everywhere dismissal signal the
lane-2 section below describes, with D4's poll making it correct regardless.
One consequence of the suppression is worth knowing on call: a neutral
return page renders no "return to the merchant" button, so the browser leg
can no longer reach one of stopUrls on its own and the sheet resolves this
rail on the dismissal signal alone. And the skills still describe the
pre-#192 window, so vpay-skills owes a companion PR — AGENTS.md
§ "Docs↔skills parity". Evidence below is the lane-2 entry, unchanged.
Everything lane 1 left owed above is now built: VpayCheckoutSheet
(showVpayCheckoutSheet/showVpayCheckoutSheetRoute), SheetController
(confirm, jittered poll, the redirect hand-off, D4-shaped dismiss()), the
72-key i18n catalogue (French default), "remember this number"
(shared_preferences, 90-day TTL, no PIN), the test-mode banner, focus
management and a persistent Semantics live region. BrowserClient gained
confirmPaymentIntent — the sheet drives a rail's confirm directly rather
than only reading the outcome of a confirm the hosted page performed.
flutter test is 258 passed / 0 skipped (was 200/0). A real defect this
lane's own gate caught, not a unit test: the form encoder percent-encoded
payment_method_data[type]'s own structural brackets, so the server's
parser (which splits a raw key on the literal [ before decoding anything)
saw one flat key instead of a nested path and refused every confirm; every
MockClient assertion had passed regardless because it read the request
back through Uri.splitQueryString, which decodes before an assertion ever
sees it. Fixed (_bracketKey), and the fix is proven decisive (reverting
it fails the corrected test). Driven by hand on emulator-5554, against
a freshly rebuilt vpay-demo stack (the running one was pre-#186 and had
no rails key at all — confirmed stale on the pre-lane-2 commit too, not a
regression): three cold launches to paid on MTN, one Orange redirect
through the existing Custom Tab hand-off and back to paid, one genuine
mid-payment dismissal (abandoned the rail's own page before pressing
Pay/Cancel) that stayed unpaid/pending for the whole observed window,
never a fabricated canceled. Every session/intent/order id, and exactly
what was not touched (iOS, macOS, the Orange "remember" checkbox's own
persistence, deep-link stop-URL matching — still the same
unverified-everywhere signal the browser cutover left), is in the dated
verification page. Evidence:
verification/2026-09-17-flutter-native-sheet.md.
The lane-2 claim above did not survive a hand walk (iPhone 17 Pro / iOS 26.5,
real just demo-up): write, the existence check and "forget" all worked, but
reading the number back into the field was broken — the field came back empty
and the box unticked after a cold relaunch, even though the "forget" control
proved a record existed. Root cause was two wiring defects, not the store: the
widget's prefill was gated on a screen transition while defaultMsisdn is
populated asynchronously (so it always arrived after its one chance to run),
and rememberChecked was never seeded from the stored record. Fixed in
checkout_sheet.dart (prefill applied on every change, still guarded by
"manually edited" + empty text) and sheet_controller.dart
(rememberChecked now seeds from hasActiveRecord, and memory state loads on the
redirect entry screen too). The 90-day TTL was already enforced on read — this
did not touch it. A review pass found the _rememberSeeded guard on the new
"an unticked submit clears the record" path set before the value it
announces, so a submit inside the seed's own store read destroyed a record the
payer never unticked; fixed, and pinned by a test that fails on the previous
ordering. The web's memory.last_used badge on the rail picker is not
ported — the string exists in both locales and nothing reads it.
flutter test 305 passed / 0 skipped on Flutter 3.47.2, and 306 passed /
0 skipped after the review pass on Flutter 3.48.0-1.0.pre-696 / Dart 3.14.0;
dart analyze --fatal-infos clean on both. Evidence:
verification/2026-09-17-flutter-remember-msisdn-read.md.
- No
just cigate (D-M3).install-flutter/analyze-flutter/test-flutter/test-flutter-e2eexist; none is injust ciorjust verify, anddocs/status.md's gate table does not claim otherwise. Every count this repository quotes for this package is a human running it by hand. - No macOS compile, still. There is no toolchain that has ever built
the macOS target in this repository. iOS is the exception as of
2026-09-16, and only iOS: the browser cutover was run — not merely
compiled — on a real iOS Simulator on a macOS host with a real Xcode
toolchain (see the "Browser, not WebView" section above). The earlier
"no
xcodebuild" claims were about this repository's own (Linux) host, which is still true for that host; they no longer describe every host this plugin has ever been built on. - No browser walk for web's popup end to end against the real hosted
page. Proven by compiling (
flutter build web) and, separately, byjust test-flutter-webrunningweb_checkout_platform_test.dartin a real Chrome (2026-09-15), but no human or automated walk has ever opened the popup end to end against the real hosted page. - Android's browser-cutover architecture itself (the redirect hand-off's
Custom Tab) has been run for real, on
emulator-5554, as of issue #189 lane 2 (2026-09-17) — see that section above. What is still true: it was run driving the native sheet's redirect hand-off, not the olderVpayCheckout.startfull-checkout browser flow this document's earlier sections describe; that path last ran on an emulator against the WebView-based modal sheet the "Modal checkout sheet" section below describes, which "Browser, not WebView" has since replaced, and has not been re-driven against the current architecture.flutter build apk --debug/--releaseonexample/both still exit 0. - D8's tier 1 (Android App Links / iOS 17.4+ Associated Domains) is not
implemented on any platform — see the D8 section above. Since the browser
cutover this is no longer only a UX upgrade: tier 1 is what
CheckoutWindowOutcome.stopUrlReachednow depends on entirely, and it is unverified on every platform — see the "Browser, not WebView" section's own dated addition above. - macOS has no dismissal signal at all any more. The fake
focus-regained signal the previous code used is removed; nothing reports
dismissedon macOS unless a Universal Link arrives (itself unverified, above) or the merchant callsdismiss()— see the "Browser, not WebView" section's own dated addition above. - No real rail.
just test-flutter-e2e(Lane D, above) proved the running-vpay half; the rail behind that stack is still WireMock. - No App Store or Play review. ADR-0021 and D9 read the published rules; a reviewer's verdict is a different thing this repository will not have.
- The Android 21 / iOS 12 floor is a claim nobody will test.
- No desktop Linux or Windows.
- verification/2026-09-13-flutter-lane-b-gate.md
— Lane B's
cargo test -p xtask,cargo clippy --all-targets,just verify-linksandjust verify-sdk-parityruns, and the literal before/after of the decisive skip mutation. - verification/2026-09-14-flutter-review.md — the review's gate output on the merged head, and every mutation it measured, with the exit code read from a file in each case.
- verification/2026-09-14-flutter-e2e-real-stack.md
— Lane D's real-stack run:
just test-flutter-e2egreen with realcs_…/pi_…ids, the same recipe failing loudly against a down stack, andjust test-flutterstill 80 passed / 0 skipped throughout. - verification/2026-09-14-flutter-d8-external-browser.md
— D8's gate output:
flutter test(82/0),dart analyze/dart format, the four redaction counts after regeneration, both APK builds and DEX counts, and the real headless-emulator run ofVpayCheckoutMode .externalBrowser, exit codes read from files throughout. - verification/2026-09-16-flutter-real-app-registration.md
— the real-installed-APK defect, its root cause, the fix, the hand-driven
walk's
adb dumpsys/screenshot evidence, the decisive regression test's both exit codes, and every "do not break" gate rerun on the fix. - verification/2026-09-16-flutter-bottom-sheet.md
— the modal-sheet revision: the merged-manifest and DEX re-measurements on
the new theme, the three-cold-launch hand-driven walk's
dumpsysevidence and dismissal-trigger results,just test-flutter-emulator's exit code, and every "do not break" gate rerun on the change. - verification/2026-09-16-flutter-browser-cutover.md
— the browser cutover: the real iOS Simulator walk, the Dart suite and
dart analyzecounts, the Android/examplecompile-only result, and exactly what remains unverified (deep-link return detection on every platform, macOS's own dismissal signal, and any device/emulator run of the new Android or macOS surface). - verification/2026-09-16-flutter-rail-spec-screen-machine.md
— issue #189 lane 1:
flutter test200/0 (was 80/0),dart analyze/dart formatclean,verify-sdk-parity/verify-linksgreen, all three mutation proofs run and reverted with their exact failure counts, and the pre-existing (unchanged by this lane)just fmt-check-webfailure. - verification/2026-09-17-flutter-native-sheet.md
— issue #189 lane 2:
flutter test258/0 (was 200/0), every gate's exit code, the realpayment_method_data[type]confirm defect this lane's own hand-driven gate found and the decisive test that now catches it, and the full walk onemulator-5554— three MTN cold launches and one Orange redirect topaidinexamples/shop's own database, one genuine mid-payment dismissal proven never to fabricatecanceled, every session/intent/order id, and exactly what this lane did not touch.