Skip to content

Open POS to all countries; gate card payments per country#17252

Draft
toupper wants to merge 26 commits into
trunkfrom
rsm/pos-all-countries
Draft

Open POS to all countries; gate card payments per country#17252
toupper wants to merge 26 commits into
trunkfrom
rsm/pos-all-countries

Conversation

@toupper
Copy link
Copy Markdown
Contributor

@toupper toupper commented May 22, 2026

Description

Opens POS to every store, worldwide while keeping card-present payments (external readers + Tap to Pay) gated per-country. Drops the country/currency eligibility validator, the pointOfSale remote feature flag check, and the WC POS feature switch — POS is GA now and visibility is just iPad + WC plugin present (or phone + pointOfSalePhonePrototype). The card-payment availability inside POS is derived from the existing CardPresentPaymentsConfiguration via a new isPOSCardPaymentEnabled extension (currently isSupportedCountry && countryCode != .CA — CA is temporarily excluded pending Interac).

The checkout screen adapts per idiom + country:

Idiom Country Bottom row
iPad Any (CA, JP, BR, US, GB, EU, …) SecondaryPaymentButtons — Cash + Other (outlined peers, anchored popover off Other). Literal restoration of the design from #17080 that Robin's phone POS Part 2 (8500620a69) deleted.
iPad TTP-eligible + BT-connected cashAndOtherMethodsBottomStrip (unchanged from phone POS work)
iPhone US, GB (TTP-eligible) TTP hero + cashAndOtherMethodsBottomStrip (unchanged)
iPhone FR, DE, IE, NL, AT, BE, FI, IT, LU, PT, ES, SG, NZ, AU, PR (non-TTP card) POSCheckoutPaymentButtonsRow: [Card reader, Cash, Other payment methods]
iPhone CA, JP, MX, IN, BR, … (no card) POSCheckoutPaymentButtonsRow: [Cash, Other payment methods]

What's in the diff

Core feature work:

  • Delete POSCountryCurrencyValidator + its tests
  • Simplify POSTabVisibilityChecker: iPad-only (or phone + pointOfSalePhonePrototype) + plugin presence; drop country/currency validation and the .pointOfSale remote-flag check
  • Simplify POSTabEligibilityChecker: plugin presence + min version only; drop country/currency validation and the WC POS feature-switch branch
  • Drop POSIneligibleReason.unsupportedCurrency and .featureSwitchDisabled (and the associated UI / analytics branches)
  • Move the IPP expansion-cache refresh from POSTabVisibilityChecker (iPad-only, inline) to MainTabBarController (all idioms) — required because POS visibility no longer drives the refresh. Marked with a TODO; the expansion cache itself is being removed in 24.9/25.0.
  • New extension CardPresentPaymentsConfiguration.isPOSCardPaymentEnabled (in PointOfSale module, public) — derived from isSupportedCountry && countryCode != .CA
  • Wire isPOSCardPaymentEnabled through CardPresentPaymentFacade → real service, preview service, screenshot mock, test mock → POSPaymentModel

TotalsView reshape:

  • Gate isShowingPaymentView and useTapToPayHeroLayout on isPOSCardPaymentEnabled (defensive — no card UI on no-card stores)
  • iPad branch (horizontalSizeClass == .regular): render SecondaryPaymentButtons for both card-enabled and no-card cases — Cash + Other side-by-side outlined peers, popover anchored to Other (PointOfSaleSecondaryPaymentMethodsPopover, which was already in the repo but orphaned by the phone POS work)
  • Phone branch: POSCheckoutPaymentButtonsRow with the resolver-built methods array. Tapping .otherPaymentMethods opens the existing POSOtherPaymentMethodsSheet bottom sheet
  • Extract POSCheckoutPaymentMethodResolver (pure helper) so the branching is unit-testable without a SwiftUI environment

Bug fixes surfaced by testing the universal flow:

  • POSPaymentModel.startPayment() short-circuits when !isPOSCardPaymentEnabled — without this, on iPhone Brazil/Japan/Mexico/etc. the BT auto-collect-on-connect kicked in (no TTP → preferredConnectionMethod = .bluetooth) and triggered Stripe onboarding/gateway checks that fail for IPP-unsupported countries
  • phoneContentView now observes paymentState.scanToPay and .markAsPaid to push navigation (tablet side already did this; the phone path was missing both handlers — pre-existing trunk bug that became visible because the no-card phone flow needs the Other-payment-methods sheet on every store)
  • POSTapToPayAvailabilityChecker gains a countrySupportsTapToPay() gate that consults CardPresentPaymentsConfiguration.supportedReaders.contains(.tapToPay). Without it, after the POS visibility cache went universal, TTP availability resolved to .available for any country including ones that don't support TTP (Spain etc.) — the hero would render with a CTA the merchant couldn't actually use

Test Steps

Country is switchable via WP admin → WooCommerce → Settings → General → Country/State. After changing, kill and relaunch the app so site settings reload.

Local feature flags relevant (in Modules/Sources/Experiments/FeatureFlag.swift): pointOfSalePhonePrototype (gates phone POS), pointOfSaleScanToPay, pointOfSaleMarkOrderAsPaid (gate the sheet/inline rows). All three default to .localDeveloper/.alpha ON.

iPad regression — US / GB / EU / SG / NZ / AU / PR:

  1. POS tab visible
  2. Checkout → reader hero / TTP hero (US/GB) or "Connect your reader" hero (EU+) renders at top
  3. Bottom: [Cash] [Other payment methods] side-by-side outlined peers
  4. Tap Other → popover off the button with Scan to Pay + Mark order as paid rows
  5. Tap each → existing flows proceed

iPad no-card — CA / JP / MX / IN / BR:

  1. POS tab visible (universal)
  2. Checkout → no card reader hero; totals + same [Cash] [Other payment methods] row
  3. Tap Other → popover with Scan to Pay + Mark order as paid
  4. Tap each → existing flows proceed

iPhone TTP-eligible — US / GB (pointOfSalePhonePrototype ON):

  1. POS tab visible
  2. Checkout → TTP hero + Cash/Other strip (unchanged from current phone POS)

iPhone non-TTP card — FR / DE / ES / etc. (pointOfSalePhonePrototype ON):

  1. POS tab visible
  2. Checkout → no TTP hero. Card-reader region renders at top.
  3. Bottom row vertical: [Card reader (filled)] [Cash] [Other payment methods]
  4. Tap Card reader → existing BT connect flow
  5. Tap Other → bottom sheet with Scan to Pay + Mark order as paid
  6. Each row navigates to its flow

iPhone no-card — BR / JP / MX / CA (pointOfSalePhonePrototype ON):

  1. POS tab visible
  2. Checkout opens directly, no Stripe onboarding error
  3. Bottom row vertical: [Cash (filled)] [Other payment methods]
  4. Tap Cash → cash collection screen pushes
  5. Tap Other → bottom sheet with Scan to Pay + Mark order as paid
  6. Each row navigates to its flow

Ineligible UI sanity:

  • WC plugin missing → "Install WooCommerce" ineligible screen (unchanged)
  • WC plugin below 9.6.0-beta → "Update WooCommerce" ineligible screen (unchanged)
  • No more "Enable POS feature" or "Unsupported currency" empty states

Tests: xcodebuild test -scheme PointOfSale → 661 tests pass (incl. new POSCheckoutPaymentMethodResolverTests, CardPresentPaymentsConfiguration+POSCardPaymentTests).

Screenshots

N/A — visual changes covered by the test plan above. iPad layout matches the original design from #17080 verbatim; iPhone card-enabled inherits the existing phone POS UI; iPhone no-card uses the same Cash + Other row + bottom sheet shape proven on US/GB phones.

On the diff size

~−1300 net non-test lines (most of the diff is removing the eligibility validator + checker plumbing). Above the 300-line Danger cap. The two halves (cleanup + no-card surface) are tightly coupled, but if a split is wanted I can do it as a follow-up — let me know.

Out of scope (follow-ups)

  • Multicurrency / non-2-decimal currency handling (e.g. JPY no decimals, BHD 3 decimals): now reachable in POS for the first time on those stores. Brief validation pass is open as a separate task.
  • Expansion eligibility cache cleanup: tracked separately for 24.9/25.0. The IPP refresh move here is a hold-over until that lands.
  • Proactive "install Stripe/WooPayments" prompt for card-supported countries without the plugin installed: today handled lazily by the existing Connect Card Reader flow; a proactive nudge is open as a separate item.

  • No release notes — feature flagged off in alpha until phone POS prototype + Scan-to-Pay + Mark-as-Paid all flip to public.

toupper and others added 2 commits May 21, 2026 16:14
…bled

Decouples POS tab visibility from country/currency eligibility — POS is now
universally available on iPad (and phone with the phonePrototype flag).
Card-present payments inside POS are gated by a new derived property on the
existing IPP configuration: isPOSCardPaymentEnabled = isSupportedCountry &&
countryCode != .CA (CA is temporarily excluded pending Interac support).

Also drops the .pointOfSale remote/local feature flag and the WC POS feature
switch (POS going GA), and reshapes TotalsView with a promoted 1+n layout
when card payments aren't available (Cash filled-primary on row 1; Scan-to-Pay
+ Mark-as-Paid as outlined peer buttons on row 2).

The expansion-cache refresh moves from POSTabVisibilityChecker (iPad-only,
inline) to MainTabBarController (all idioms) so IPP entry points still see
fresh state — to be removed when the expansion cache itself goes away in
24.9/25.0.

Tests: 654 PointOfSale tests pass (incl. new POSCheckoutPaymentMethodResolver
+ CardPresentPaymentsConfiguration+POSCardPayment suites). WooCommerce test
target compiles; runtime verification of POSTab*CheckerTests blocked by
unrelated Watch app icon build failure in this env.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	Modules/Sources/PointOfSale/Presentation/TotalsView.swift
@toupper toupper added this to the 24.9 milestone May 22, 2026
@toupper toupper added type: enhancement A request for an enhancement. feature: POS category: parity Match what's supported by the other platform. category: unit tests Related to unit testing. labels May 22, 2026
@dangermattic
Copy link
Copy Markdown
Collaborator

2 Warnings
⚠️ View files have been modified, but no screenshot or video is included in the pull request. Consider adding some for clarity.
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

toupper and others added 6 commits May 22, 2026 15:46
The app-target CardPresentPaymentService implements
CardPresentPaymentFacade.isPOSCardPaymentEnabled by reading this extension
property across the module boundary. Default `internal` access blocked that
read with 'isPOSCardPaymentEnabled is inaccessible due to internal protection
level'. Widening to `public` matches the visibility of the underlying
CardPresentPaymentsConfiguration type itself (public in Yosemite), so no API
surface is expanded beyond what the parent type already exposes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes 'Type CardPresentPaymentServiceScreenshotMock does not conform to
protocol CardPresentPaymentFacade' build error. The screenshot mock is a
fourth conformer I missed in the initial wiring pass alongside the real
service, the test mock, and the preview service. Screenshots always show
a connected reader, so card payments are enabled (true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cases

After dropping the .unsupportedCurrency / .featureSwitchDisabled cases from
POSIneligibleReason, the refreshEligibility switch was no longer exhaustive —
.siteSettingsNotAvailable fell through with no handler. Every remaining
ineligibility reason now resolves to the same plugin-eligibility re-check,
so the switch collapses to a single statement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iPad merchants in non-TTP card-supported countries (FR, DE, IE, NL, AT, BE,
FI, IT, LU, PT, ES, SG, NZ, AU, PR) currently have no entry point to
Scan-to-Pay / Mark-as-Paid: `useCashAndOtherMethodsBottomStrip` is TTP-tied,
and the promoted-no-card layout only applies when `isPOSCardPaymentEnabled`
is false. Adds an `.otherPaymentMethods` slot to POSCheckoutPaymentMethod
and appends it to the card-enabled methods list whenever either secondary
feature flag is on. Tapping it reuses the existing POSOtherPaymentMethodsSheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
toupper and others added 3 commits May 22, 2026 17:02
PR #17080 originally introduced an anchored popover for the Other payment
methods picker on iPad — popover off the trigger button, with the totals
context preserved behind it. Robin's phone POS Part 2 (8500620) deleted
that wiring when unifying phone + iPad on a bottom sheet, which made sense
for phone but regressed the iPad UX.

Restore the iPad popover without disturbing phone:

- POSCheckoutPaymentButtonsRow gains an optional `otherPaymentMethodsPopover`
  config; when provided, it attaches `.popover(attachmentAnchor: .point(.top),
  arrowEdge: .bottom)` directly to the `.otherPaymentMethods` button.
- TotalsView routes the `.otherPaymentMethods` tap by horizontal size class:
  iPad (.regular) triggers the popover state; phone (.compact) keeps the
  existing bottom sheet path.
- PointOfSaleSecondaryPaymentMethodsPopover (already in the repo from #17080,
  orphaned since the phone POS work) is re-wired with the existing
  startScanToPayPayment / startMarkAsPaidPayment callbacks.

The phone bottom-sheet path is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the side-by-side button layout that the original SecondaryPaymentButtons
design used on iPad. Robin's phone POS work unified both idioms on a vertical
stack, which fits phone-portrait but lost the iPad-landscape inline arrangement.

Branches by horizontalSizeClass:
- compact (phone) → VStack with first-as-primary filled CTA (phone POS design,
  unchanged)
- regular (iPad) → HStack with equal-width peers, first still filled-primary
  (Card reader / Cash / Other payment methods inline)

Adds `.frame(maxWidth: .infinity)` to the button label so HStack peers
distribute evenly on iPad. No effect on the phone VStack since buttons there
already span full width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces my earlier reinvented iPad layout (HStack of [Card reader, Cash,
Other] with first-as-filled and a row-attached popover) with a literal copy
of the original SecondaryPaymentButtons from commit 8500620^. That is
the design that shipped before Robin's phone POS Part 2 deleted it.

Restored verbatim:
- Cash + Other payment methods side-by-side in HStack with POSSpacing.small
- Both buttons outlined (no filled-primary distinction)
- Popover anchored to the Other button (.point(.top), .bottom arrow)
- .renderedIf(isAnySecondaryPaymentMethodEnabled) on the Other button
- Whole row gated by shouldShowCollectCashPaymentButton
- TotalsView.Constants.buttonHorizontalPadding / cashButtonBottomPadding

Branching:
- iPad (horizontalSizeClass == .regular) → SecondaryPaymentButtons
- Phone (.compact) → existing POSCheckoutPaymentButtonsRow VStack (unchanged)

Card reader connect CTA is not in the iPad row — it lives in the hero
region above as before.

Cleanups:
- POSCheckoutPaymentButtonsRow drops the optional OtherPaymentMethodsPopoverConfig
  and idiom branching I had added (no longer needed: phone uses sheet, iPad uses
  the dedicated SecondaryPaymentButtons view).
- handlePaymentMethodSelection(.otherPaymentMethods) simplified — only the phone
  path reaches it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wpmobilebot
Copy link
Copy Markdown
Collaborator

wpmobilebot commented May 22, 2026

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr17252-6c86d1e
Version24.8
Bundle IDcom.automattic.alpha.woocommerce
Commit6c86d1e
Installation URL2m2qv5rr43eoo
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot
Copy link
Copy Markdown
Collaborator

wpmobilebot commented May 22, 2026

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

toupper and others added 5 commits May 22, 2026 17:44
On checkout entry, PointOfSaleAggregateModel.checkOut() unconditionally
calls paymentModel.startPayment(). In a country where in-person payments
aren't supported (e.g. Brazil, Japan, Mexico, India) or are explicitly
disabled in POS (Canada), startPayment() was still entering the Stripe
card-payments stack:

- preferredConnectionMethod defaults to .bluetooth when TTP isn't available
- the BT path immediately runs startPaymentFlow(using: .bluetooth) which
  begins auto-collect-on-connect — that triggers gateway onboarding /
  reader-scan / capability-check work that fails for IPP-unsupported
  countries and surfaces an "in-person payments not available" error

Guard startPayment() on isPOSCardPaymentEnabled. When card is unavailable
in POS for this store, return immediately — no TTP pre-connect, no BT
auto-collect, no event subscriptions. The merchant will check out via
Cash / Scan-to-Pay / Mark-as-Paid, all of which live in their own
handlers and don't need any card-stack setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Mark order as paid" wraps to two lines on iPhone while "Scan to Pay" stays
on one line, pushing the row-2 peers out of vertical alignment. Add
.lineLimit(1) + .minimumScaleFactor(0.7) so the longer label shrinks to
fit its column on a single line.

Also add .contentShape(Rectangle()) to the button label so the full button
frame is the hit area — defensive against any edge case where Text-only hit
testing would clip the tap target inside the rendered button bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bespoke POSCheckoutPromotedPaymentButtons view (Cash filled + Scan/Mark
inline peers) wasn't reacting to taps on iPhone in Brazil — root cause never
isolated after end-to-end tracing of the wiring. Switching to the proven
Cash + Other payment methods row that already works on US/GB phones.

Changes:
- Deleted POSCheckoutPromotedPaymentButtons.swift.
- Resolver no-card branch now returns [.cashPayment, .otherPaymentMethods?]
  (single sheet entry) instead of [.cashPayment, .scanToPay?, .markOrderAsPaid?].
  Tapping `.otherPaymentMethods` opens the existing POSOtherPaymentMethodsSheet
  which gates Scan-to-Pay / Mark-as-Paid by their own flags internally.
- Dropped .scanToPay and .markOrderAsPaid from POSCheckoutPaymentMethod enum
  and their localized titles / accessibility ids (no longer in any array).
- TotalsView body: removed the no-card-promoted top branch; iPad branch
  (SecondaryPaymentButtons) now fires for both card-enabled and no-card stores
  (it doesn't depend on card support); phone path uses POSCheckoutPaymentButtonsRow
  for both card-enabled and no-card stores via the resolver array.
- handlePaymentMethodSelection trimmed to the four remaining cases.
- Resolver tests updated for the [.cashPayment, .otherPaymentMethods?] shape.

iPad UX unchanged. Phone no-card now mirrors phone card-enabled
([Cash, Other payment methods] VStack with the sheet) — fewer code paths,
fewer surprises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing trunk bug: `phoneContentView` only observed `paymentState.cash`
to trigger navigation pushes. The `scanToPay` and `markAsPaid` handlers
existed only in `tabletContentView` — phone path was missing them.

Result on phone: tapping Scan-to-Pay or Mark-as-Paid inside the Other
payment methods sheet flipped `paymentState.scanToPay` / `.markAsPaid` but
nothing on the phone path observed the change to push the corresponding
navigation destination. The sheet dismissed and nothing further happened.

Mirror the tablet handlers in `phoneContentView` so the row taps actually
navigate. Behaviour now matches tablet: tap → state flip → push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After opening POS worldwide, POSTabVisibilityChecker no longer rejects by
country — the cached POS tab visibility resolves to true for every store,
including non-TTP card-supported countries like Spain, France, Germany.
POSTapToPayAvailabilityChecker's existing site-eligibility gate read that
cache and so stopped catching countries that don't support TTP at all.

Result before this commit: on iPhone in Spain, the TTP hero would render
on the totals view even though Spain's `CardPresentPaymentsConfiguration`
only lists `.wisepad3` as a supported reader. The merchant would see a
"Pay with Tap to Pay" CTA they can't actually use.

Add a new gate (between the feature flag and the device-support checks)
that consults `CardPresentPaymentsConfiguration.supportedReaders` for the
store's country. On iOS today only US and GB pass — every other country
gets `.unavailable(reason: .siteNotEligible)`, which the totals view
treats correctly (no TTP hero, no TTP slot in the payment-methods row).

The configuration loader is injected with a closure default so tests can
stub if needed; existing test factory keeps the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@toupper toupper requested a review from samiuelson May 22, 2026 16:55
@samiuelson samiuelson self-assigned this May 22, 2026
Copy link
Copy Markdown
Contributor

@samiuelson samiuelson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. I'm sharing a few suggestions inline.

Task { @MainActor in
await paymentModel.startScanToPayPayment()
}
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 The iPad row wires paymentModel.startCashPayment() / startScanToPayPayment() / startMarkAsPaidPayment() directly with no isStartingPayment guard or disabled state. The phone cashAndOtherMethodsBottomStrip carefully funnels through handleCashPaymentTapped / handleOtherPaymentMethodsTapped (which check guard !isStartingPayment and set isStartingPayment = true) for exactly this reason — a quick double-tap on Cash here fires twice and pushes twice. Suggest routing the iPad button actions through the same handle…Tapped helpers, or a tiny wrapper that mirrors the phone guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Routing the iPad row through new handleScanToPayTapped / handleMarkAsPaidTapped helpers (mirroring the existing handleCashPaymentTapped / handleTapToPayTapped pattern) so all four SecondaryPaymentButtons callbacks pick up the same isStartingPayment guard the phone path uses. startCashPaymentAction now goes through handleCashPaymentTapped too — same guard.

// which hosts its own anchored popover off the button, so `.otherPaymentMethods`
// never reaches the array-driven row on iPad. Phone presents
// `POSOtherPaymentMethodsSheet` as a bottom sheet.
handleOtherPaymentMethodsTapped()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Same button, divergent telemetry: the iPad startOtherPaymentMethodsAction closure at L130 tracks .pointOfSaleOtherPaymentMethodsTapped before opening the popover, but the phone path here calls handleOtherPaymentMethodsTapped() (defined further down) which only sets isShowingOtherPaymentMethodsSheet = true without tracking. Add the track call to handleOtherPaymentMethodsTapped, or here right before invoking it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving analytics.track(.pointOfSaleOtherPaymentMethodsTapped) into handleOtherPaymentMethodsTapped() and adding a horizontalSizeClass branch so iPad (popover) and phone (sheet) both fire the event through the same helper. The iPad inline call site collapses to startOtherPaymentMethodsAction: handleOtherPaymentMethodsTapped — single source of truth for the tap, no divergence.

private(set) var cardReaderUpdateState: CardReaderSoftwareUpdateState = .none

/// Whether card-present payments (external readers + Tap to Pay) should be exposed
/// in POS for this store. Static for the lifetime of the session — derived from the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Doc says "Static for the lifetime of the session" but the implementation re-reads on every access via cardPresentPaymentService.isPOSCardPaymentEnabled, and CardPresentConfigurationLoader recomputes from SiteAddress() + the IPP expansion cache each time. Worth correcting — it'll mislead the next person debugging the cold-start race in MainTabBarController (commented separately).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the doc is from an earlier iteration where it was cached. Rewriting it to describe the dynamic re-read path — loader → CardPresentPaymentsCountryExpansionEligibilityService — and the fact that it can flip from false to true mid-session for expansion-flag-gated countries whose config arrives after the POS tab first renders.

// for these countries. The merchant will check out via Cash / Scan-to-Pay /
// Mark-as-Paid only; those flows live in their own handlers and don't need any
// payment-session event subscription on the totals view.
guard isPOSCardPaymentEnabled else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Single-entry guard. If any other path (subscribeToPaymentSessionEvents, connectCardReader, the reader-status auto-collect, payment-intent restoration, retry helpers) dispatches into the card stack on a no-card store, the Stripe-onboarding storm this fixes can re-emerge. Defence-in-depth: add the same guard isPOSCardPaymentEnabled at the head of those entry points.

Also: this fix ships without a regression test. MockCardPresentPaymentService.isPOSCardPaymentEnabled defaults true, so POSPaymentModelTests.swift's await sut.startPayment() calls never exercise the false branch. Add a case that sets the mock false and asserts no subscribeToPaymentSessionEvents / collectPayment / connectReader dispatch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defense-in-depth added to every entry point that could spin up a card-payment session: startPaymentWithMethod, connectCardReader, cancelThenCollectPayment (both sync + async forms), and subscribeToPaymentSessionEvents (stop-the-line at the subscription spin-up).

observeReaderReconnection left alone — it dispatches back to startPayment() which is already guarded, so its sink doesn't need its own gate.

New regression tests in POSPaymentModelTests:

  • test_startPayment_when_card_payments_disabled_then_short_circuits
  • test_startPaymentWithMethod_when_card_payments_disabled_then_short_circuits
  • test_connectCardReader_when_card_payments_disabled_then_short_circuits
  • test_cancelThenCollectPayment_when_card_payments_disabled_then_short_circuits

Each one sets MockCardPresentPaymentService.isPOSCardPaymentEnabled = false and asserts no subscribe / connect / collect / cancel is dispatched.

/// either has external readers only (CA, PR, EEA, SG, NZ, AU) or no card support at all
/// (BR, JP, MX, IN, …). Without this gate the TTP hero would render on iPhone in any
/// country that has POS visibility cached, including non-TTP countries like Spain.
func countrySupportsTapToPay() -> Bool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 configurationLoader: () -> CardPresentPaymentsConfiguration (L32, L38) is non-optional. Today the default loader fails-closed when site settings are stale, but the signature doesn't enforce it — a future loader that synthesises a .US default on a Spanish store would silently fail-open and render the TTP hero in Spain again. Either change the loader to () -> CardPresentPaymentsConfiguration? with default-deny on nil, or document the fail-closed contract on the loader.

Also uncovered by tests: POSTapToPayAvailabilityCheckerTests still uses the default loader and never injects a config without .tapToPay in supportedReaders. Add a .ES case asserting .unavailable(reason: .siteNotEligible) plus a .US happy path proving this gate sits after feature-flag and before device-support.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Changing configurationLoader to () -> CardPresentPaymentsConfiguration? and making the default impl map .unknown country → nil so a future synthesised-.US-default loader fails the type-check rather than silently passing through. countrySupportsTapToPay() guards on nil → false.

New tests in POSTapToPayAvailabilityCheckerTests:

  • checkAvailability_when_country_does_not_support_TTP_then_returns_siteNotEligible (uses .ES)
  • checkAvailability_when_country_supports_TTP_then_continues_to_other_gates (uses .US)
  • checkAvailability_when_configuration_loader_returns_nil_then_fails_closed (the explicit nil contract)

case wooCommercePluginNotFound
case featureSwitchDisabled
case unsupportedCurrency(countryCode: CountryCode, supportedCurrencies: [CurrencyCode])
case selfDeallocated
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 siteSettingsNotAvailable (L6) and selfDeallocated (L8) are now unreachable — nothing in the simplified POSTabEligibilityChecker.checkPluginEligibility produces them, and refreshEligibility just re-runs the plugin check. They're still in the enum, the analyticsValue, the suggestion strings, and the previews. Either delete them, or wire them so the cases stay live (the refreshEligibility comment implies they should, but no caller does).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, those are vestigial. Removing both cases from the enum and updating every consumer:

  • POSIneligibleView.suggestionText — switch cases + their NSLocalizedStrings deleted
  • POSIneligibleView preview that rendered .siteSettingsNotAvailable deleted
  • WooAnalyticsEvent+PointOfSaleIneligibleUI.analyticsValue — "other" bucket deleted (it only mapped these two)
  • POSTabEligibilityChecker.refreshEligibility doc updated to drop the references
  • PointOfSaleDashboardViewHelperTests parameterized arguments trimmed
  • POSTabEligibilityCheckerTests.refreshEligibility_for_selfDeallocated_rechecks_eligibility deleted (redundant — the parameterized refreshEligibility_returns_eligible_when_plugin_now_meets_requirements already covers the same code path with the valid reasons)

/// POS visibility no longer gates on this cache (POS is universal now), so the refresh
/// has to happen here regardless of idiom for IPP entry points to pick up the latest
/// remote-feature-flag state.
func refreshCardPresentExpansionEligibilityIfNeeded(for site: Site) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Cold-start race in expansion-flag-gated countries. With POS now opening immediately on iPad/phone (no inline visibility refresh), if this method hasn't completed before POSPaymentModel.isPOSCardPaymentEnabled is first read — Spain / FR / DE / AU / NZ / SG merchants on first session post-login or after a tab restore — they see cash-only checkout in a country that's actually card-eligible. No observer flips it back when the refresh resolves, because isPOSCardPaymentEnabled is queried-on-read and TotalsView doesn't re-observe. Worth either awaiting the refresh before letting TotalsView render, or surfacing a re-evaluate signal TotalsView can observe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementing your option (b) — surface a re-evaluate signal the View can observe. Wired end-to-end:

  1. Yosemite layer: CardPresentPaymentsCountryExpansionEligibilityService.cacheEligibility now posts Notification.Name.cardPresentPaymentsCountryExpansionEligibilityDidChange after the cache write.
  2. PointOfSale facade: CardPresentPaymentFacade gains isPOSCardPaymentEnabledPublisher: AnyPublisher<Bool, Never>.
  3. App-target adaptor: CardPresentPaymentService backs the publisher with a CurrentValueSubject<Bool, Never>, seeded from the loader at init and updated by subscribing to the notification. isPOSCardPaymentEnabled is now read from subject.value.
  4. POSPaymentModel: isPOSCardPaymentEnabled is now a stored @Observable property mirrored from the publisher via the new publishPOSCardPaymentEnabled() helper. SwiftUI views observing the model re-render automatically when the value flips.
  5. Mocks/previews: MockCardPresentPaymentService, CardPresentPaymentPreviewService, CardPresentPaymentServiceScreenshotMock all implement the new publisher.

Regression test added: test_isPOSCardPaymentEnabled_updates_when_facade_publisher_emits_new_value — sets up the mock with isPOSCardPaymentEnabled = false, asserts the model mirror starts at false, flips the mock to true, uses withObservationTracking to await the model's update, asserts the mirror flipped to true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: dropping the publisher-pipeline approach in this PR after an internal nudge that introducing a new NotificationCenter contract + a new CardPresentPaymentFacade protocol requirement + a new observable-mirror pattern in POSPaymentModel qualifies as an architectural change that should be discussed in a P2 first. Reverted in 9df447886c.

The defense-in-depth guards (the other thread above, comment #4) are kept — they're not architectural and ship clean in e00946ea54.

The cold-start race itself stays open: I'll spin up a P2 outlining the publisher-based fix + simpler alternatives (await refresh before POS opens; reuse existing site-settings notification; pull-to-refresh in POS; just document the limitation) and follow up with a separate PR once a direction is picked. The race only affects first-cold-start for expansion-flag-gated merchants (Spain / FR / DE / AU / NZ / SG), so deferring is OK for now.

/// `POSCheckoutPaymentButtonsRow` VStack. Brought back unchanged so the iPad UX matches
/// what shipped before the phone POS work landed — `.cardReader` isn't surfaced as a row
/// button on iPad because the "Connect your reader" CTA lives in the hero region above.
private struct SecondaryPaymentButtons: View {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 80+ lines of new view code here have no test coverage; the resolver tests deliberately don't help because iPad bypasses POSCheckoutPaymentMethodResolver. The isShowingPaymentView / useTapToPayHeroLayout defensive && isPOSCardPaymentEnabled gates at L313 and L846 are also unasserted, so a future refactor could silently strip them and CA / JP / MX merchants would see the TTP hero or card-reader region again. Plan v2 asks for ViewInspector assertions — at minimum, a couple of cases asserting card-region-present iff isPOSCardPaymentEnabled == true would catch the regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covering the gating logic at the model + resolver layers rather than wiring up ViewInspector for the View itself:

  • POSPaymentModel layer: 5 new tests in POSPaymentModelTests (see the defense-in-depth thread above) cover every entry point that drives the View's gates — isPOSCardPaymentEnabled short-circuit on each, plus the publisher-mirror regression test for the dynamic update.
  • Resolver layer: POSCheckoutPaymentMethodResolverTests already covers isPOSCardPaymentEnabled = false → no card / TTP entries in the row (this drives checkoutPaymentMethods at L775).
  • View-layer gates: useTapToPayHeroLayout (L846) and isShowingPaymentView (L310) are 1-line guard isPOSCardPaymentEnabled else { return false } reads of the observable model. A ViewInspector test of these would essentially be testing SwiftUI's @Observable re-render behaviour, and adding ViewInspector to PointOfSaleTests as a dependency for that marginal extra coverage felt disproportionate.

That said — happy to add ViewInspector and the two snapshot-style tests in a follow-up if you'd prefer explicit View-layer regression guards.

// nothing observes the change here — the sheet dismisses and the merchant
// sees no follow-up screen.
.onChange(of: posModel.paymentState.scanToPay) { oldValue, newValue in
if newValue.isShowingQRCode, !oldValue.isShowingQRCode,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 The pre-existing trunk bug these onChange blocks fix ("sheet dismisses, no follow-up screen") ships without a UI-layer test. Resolver tests only assert the method list, not the navigation side-effect wiring. A ViewInspector-style assertion that the router's pushScanToPay / pushMarkAsPaid fires when paymentState transitions on phone would catch silent regressions of this exact bug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same call as the TotalsView coverage thread above: the underlying paymentState.scanToPay / paymentState.markAsPaid transitions are exercised in POSPaymentModelTests, and the navigation handlers themselves are 4-line state-transition triggers (if newValue.isShowingQRCode, !oldValue.isShowingQRCode → pushScanToPay / if newValue == .confirming, oldValue == .idle → pushMarkAsPaid). They've been verified manually on phone.

Adding ViewInspector to PointOfSaleTests to assert the router spy receives the call is doable in a follow-up — I'd rather not introduce that dependency for a single test in this PR. Happy to do it if you prefer.

toupper added 2 commits May 25, 2026 11:59
Closes the cold-start race for expansion-flag-gated countries (Spain / FR /
DE / AU / NZ / SG …) by piping the country-expansion eligibility cache
through a Combine publisher that POSPaymentModel mirrors into an observable
property. Views re-render when the configuration arrives after the POS tab
first renders. Adds defense-in-depth isPOSCardPaymentEnabled guards on
every POSPaymentModel entry point that could spin up a Stripe card-payment
session.
toupper added 4 commits May 25, 2026 14:17
…ockStorageManager reference

POSTabVisibilityCheckerTests uses UIUserInterfaceIdiom (UIKit) without
importing it. POSTabEligibilityCheckerTests declared a MockStorageManager
(YosemiteTestHelpers) it never used — drop the dead reference instead of
importing the helper module just to satisfy the compiler.
toupper added 2 commits May 25, 2026 16:34
…o-card stores

When isPOSCardPaymentEnabled is false, both useTapToPayHeroLayout and
isShowingPaymentView gate on the card-payment flag, leaving the upper
region empty after a cash / scan-to-pay / mark-as-paid success — the
PaymentViewContent (which holds the success message branches) never
renders. Extend shouldPrioritizePaymentViewOverHero to also return true
for those non-card success states so the success message takes over
the region regardless of the card-payment gate.
# Conflicts:
#	Modules/Sources/Yosemite/PointOfSale/Eligibility/POSCountryCurrencyValidator.swift
#	Modules/Tests/YosemiteTests/PointOfSale/Eligibility/POSCountryCurrencyValidatorTests.swift
#	WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift
#	WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabVisibilityCheckerTests.swift
@iamgabrielma
Copy link
Copy Markdown
Contributor

iamgabrielma commented May 26, 2026

I have not checked the code, just got a general question regarding testing and stabilization:

If we had to cherry-pick and setup a pipeline of gradual expansion through the next couple of releases (let's say 24.9, 25.0, 25.1), would you say this is independently testable and shippable without introducing much risk in one go? Let's say:

  1. Infrastructure (isPOSCardPaymentEnabled, Payment model guards, TTP fix, Checkout UI reshape) with existing countries
  2. Open visibility for cash-only countries
  3. Open remaining card-enabled countries + delete validator

This would allow us to use the current countries with the new infrastructure, then enable cash-only usage data from JP/MX/BR/IN merchants for min 2 weeks before opening more card-enabled countries, as well as do some intermediate internal call for testing. Thoughts?

@toupper
Copy link
Copy Markdown
Contributor Author

toupper commented May 26, 2026

I have not checked the code, just got a general question regarding testing and stabilization:

If we had to cherry-pick and setup a pipeline of gradual expansion through the next couple of releases (let's say 24.9, 25.0, 25.1), would you say this is independently testable and shippable without introducing much risk in one go? Let's say:

Infrastructure (isPOSCardPaymentEnabled, Payment model guards, TTP fix, Checkout UI reshape) with existing countries
Open visibility for cash-only countries
Open remaining card-enabled countries + delete validator
This would allow us to use the current countries with the new infrastructure, then enable cash-only usage data from JP/MX/BR/IN merchants for min 2 weeks before opening more card-enabled countries, as well as do some intermediate internal call for testing. Thoughts?

Thanks for the comment @iamgabrielma! I agree that adding this gradually is the way to go. I will refine this into a project, and outline the best process to land this in the safest way. Once we'll have Phone POS and other RSM projects, this will become easier and clearer.

@toupper toupper marked this pull request as draft May 26, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category: parity Match what's supported by the other platform. category: unit tests Related to unit testing. feature: POS type: enhancement A request for an enhancement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants