Skip to content

feat(soletrader): write the buyer's registered address on adoption (TWO-25461) - #344

Merged
dgjlindsay merged 7 commits into
stagingfrom
doug/two-25461-magento-soletrader-writeback
Aug 19, 2026
Merged

feat(soletrader): write the buyer's registered address on adoption (TWO-25461)#344
dgjlindsay merged 7 commits into
stagingfrom
doug/two-25461-magento-soletrader-writeback

Conversation

@dgjlindsay

Copy link
Copy Markdown
Contributor

Closes the two remaining TWO-25461 gaps from the sole-trader porting guide: §1(a.3) country resolution and §5 address write-back.

§1(a.3) — the tile resolves the billing-role country

The payment tile has no address fields of its own, so the country it searches and gates sole-trader availability on is whichever address plays the billing/invoice role. It was resolving through three shipping-biased feeds instead: a customer-data section written only from the shipping form, updateAddress() off whichever quote address fired last, and a DOM fallback whose first selector is #shipping-new-address-form. With a shipping country differing from the billing one, any of them can answer wrong.

searchCountryCode() now consults quote.billingAddress() first — the same source the autofill payload, getData() and placeOrderIntent() already read, so this is one resolution reused rather than a fourth mirror — and keeps every existing feed as a fallback for the states where the quote has no billing address yet (which is what the Fire Checkout fix in TWO-25326 added them for).

Country only feeds an availability-gate lookup and the search URL; there is no security dimension to it, as already established for the sibling plugins.

§5 — the write-back actually writes the address

fetchBuyer() (/autofill/v1/buyer/current) has always answered with the buyer's address beside their organisation number and company name. All three call sites read the two identity fields and discarded the address, so a buyer who had just enrolled was asked to retype an address the plugin was holding. Those sites — the passive prefetch, the chip click against an already-prefetched buyer, and the popup's post-signup ACCEPTED message — now share one adoptSoleTraderBuyer().

  • Not gated on either address-lookup switch, per §5: both are legitimately off wherever company search is not mounted in the address area, which is exactly where the sole-trader entry point lives. applyAddress() carries no gate of its own (it lives one level up in lookupCompanyAddress()), so calling it directly is the bypass.
  • Address before identity: fillCompanyData() is the order-intent trigger and placeOrderIntent() reads quote.billingAddress(). No new intent trigger is introduced — the trigger and its _orderIntentInFlightFor guard are untouched, so a replayed adoption starts no second intent.
  • A sole trader with no trading name still gets their address: fillCompanyData() refuses a nameless pair, and the address write is deliberately outside that refusal.

Field routing, shared not special-cased

applyAddress() wrote city, postcode and street line 1 only, off street_address alone. It now routes per §2.6, for a company-detail response and a buyer record alike:

  • building/apartment present → line 1 (joined most-specific-first), street moves to line 2;
  • neither → street takes line 1, line 2 left untouched rather than blanked;
  • no de-duplication between the lines even when identical;
  • region → the region <select> when an option matches on name or code, else a visible free-text region field, else appended to the city with a comma;
  • the country is never written — the server discards a company whose country disagrees with the checkout address's.

Line 2 and both region controls join revertAutofilledAddress()'s field list, so a country switch can still take back everything the write can reach. change now fires per field rather than once for a combined selector, from the same statement as the value and its provenance marker.

Tests

npx jest --config Test/Js/jest.config.js34 suites, 465 tests, all passing.

Two new suites, parameterised: company-search-address-field-routing.test.js (routing table + region table + revert coverage, against real jsdom markup so the option-label match is genuinely exercised) and gateway-method-sole-trader-address-writeback.test.js (all three adoption paths, the switch-off cases, awkward buyer records, re-entrancy). company-search-tile-country-sourcing.test.js gains a billing-vs-shipping table for §1(a.3).

…WO-25461)

§1(a.3): the payment tile resolved its country through three
shipping-biased feeds — a customer-data section written from the
shipping form, whichever quote address fired last, and a DOM fallback
that tries the shipping form first. The tile has no address fields of
its own, so its country is the BILLING/invoice-role address's.
searchCountryCode() now reads quote.billingAddress() first — the same
source the autofill payload, getData() and placeOrderIntent() already
use — and keeps the existing feeds as fallbacks.

§5: fetchBuyer() has always returned the buyer's address beside their
organisation number and company name, and all three call sites read the
two identity fields and discarded the address, so a buyer who had just
enrolled retyped an address the plugin was holding. The three sites now
share one adoptSoleTraderBuyer(), which writes the address and then the
identity. Deliberately NOT gated on either address-lookup switch: both
are legitimately off wherever company search is not mounted in the
address area, which is exactly where the sole-trader entry point lives.

applyAddress() gains the field routing the write-back needs, shared with
an ordinary company-search selection rather than special-cased:
building/apartment take line 1 and move the street to line 2, a street
alone takes line 1 and leaves line 2 untouched, no de-duplication
between the lines, and a region goes to the region select (matched on
option name or code), else a visible free-text region field, else onto
the city with a comma. Line 2 and the region join the revert's field
list so a country switch can still take back everything the write
reaches. The country is never written.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No critical issues found. Frontend-only change (JS models/view + tests, no PHP/DI/schema touched); the deliberate bypass of the address-lookup switches on the sole-trader write-back is well-documented and pinned by a test that asserts the flags are never read on that path. Field routing, region matching, and revert-list symmetry are covered by thorough parameterized tests.

Ready for human approval ✅

…(TWO-25461)

Adversarial review round 1 findings on the write-back.

Scope. applyAddress()'s selectors were document-wide, which was tolerable
while a buyer's own search pick was the only trigger and is not now that
a passive prefetch fires it unattended: the payment step keeps the
shipping form in the DOM and renders a billing form per payment method,
all with the same field names, so one write reached every one of them —
and a region id resolved against one form's option list was written into
all of the others, where it is not an option at all. applyAddress() now
takes an optional scope, and the tile passes the billing-role form
(§1(a.3)) while the address-area picker keeps its existing behaviour.

Replays. All three adoption paths can fire for the same buyer, and a
replay overwrote a correction the buyer had since made. The address is
now written once per identity, re-armed when sole-trader mode is left —
which also now takes the address back, the missing half of a discard that
already dropped the identity. The prefetch chain carries a generation and
its own minted token, so a chain superseded by an email edit neither
records its buyer nor acts on it. The popup's message listener is
detached on dispose(), so a re-rendered checkout does not stack one live
writer per render. A throw in the address write can no longer take the
identity fill with it.

Payload handling. A key the payload omits is no longer written as an
empty string — that blanked fields the buyer had filled — while an
explicit empty string still is. Conversely a field this module wrote for
a previous selection is retracted rather than left standing, so an
address cannot end up assembled from two companies. On a shop configured
for one street line the locator and street are joined onto it instead of
the street being written to a field that does not exist. `change` fires
only once every value has landed, so no listener sees the city without
its appended region. Which region control is in play is decided by
whether the select carries real options rather than by CSS visibility,
which also answers "hidden" for a form that is merely on the other step.

The return-value plumbing is gone: it was always truthy, so nothing it
reported was real. Tests grew the cases those defects needed, the spy
jQuery double gained a real attribute store (an inert attr() getter made
every marker assertion vacuous), and two brittle source-greps were
replaced by behavioural cases — the switches are now read through
throwing getters, which covers a gate added in any helper.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No critical issues found on the current head (d91b9d3), which supersedes the commit the last review covered.

The second commit ("scope the address write and make it survive replays") addresses exactly the class of issues that would have been critical here: applyAddress()'s writes were document-wide and could land in the wrong address form (a region id resolved for one form's option list written into another form where it isn't an option), and a replayed adoption could overwrite a buyer's correction. Both are now fixed — the write is scoped to the billing-role form, gated on a once-per-identity guard, and the prefetch race is handled with a generation counter plus its own minted token so a superseded chain can't record or act on a stale buyer. The popup listener is detached on dispose(), closing a listener-stacking leak on re-rendered checkouts (Amasty/Fire Checkout).

Frontend-only change (JS models/view + tests, no PHP/DI/schema touched). The deliberate bypass of the two address-lookup switches on the sole-trader write-back is well-documented and pinned by a test that asserts the flags are never even read on that path. Field routing, region matching, and revert-list symmetry are covered by thorough parameterized tests against real jsdom markup.

Ready for human approval ✅

…e (TWO-25461)

Adversarial review round 2, on the round-1 fixes themselves.

The revert read the marker off a SET and cleared every element in it.
jQuery answers .attr()/.val() from the first match only, so with the
write now scoped to the billing form, the shipping form's unmarked field
decided the answer for every field — the revert on leaving sole-trader
mode, and the one on a country change, both silently did nothing on the
payment step. It also meant one form's marker could authorise clearing a
line the buyer had typed in another. Now checked and cleared per element.

Scope selection: `#billing-new-address-form` went through jQuery's
getElementById fast path and returned at most ONE element, so the loop
meant to pick the selected payment method's form among several could
never see them. Attribute selector instead. Role now beats visibility
too — a hidden billing form is still the invoice address — with
visibility only choosing between forms matching the same selector, and
the shipping form kept as the last resort because core renders no billing
form at all when billing and shipping are the same.

The once-per-identity guard was armed before the write, so a throw or a
checkout with no address form yet consumed the single chance and the
address was never written. It is now recorded only after a write actually
happens, keyed on the email where a buyer has no organisation number, and
held at module scope: a per-instance record is re-armed by the very
re-render it exists to survive.

A completed signup now supersedes a prefetch still in flight — its
pre-auth answer would disagree with the identity the buyer just
authenticated and revert both the mode and the address.

The tile's ordinary company-search selection is scoped the same way as
the write-back; it had the same document-wide reach for the same reason.
A region is written to the free-text control only when that control is
enabled, since Magento disables the one the country does not use and
drops its value on post. And the region is appended to the city only when
this payload is writing the city too: appending to a city the buyer typed
marked their own text as ours, which the next revert would then delete.

Tests: the round-1 mutation survivors are all covered now. Supersession
is exercised after the tokens are minted rather than before, so each
guard is reached individually instead of the first one hiding the rest;
the buyer lookup's token threading runs the real fetchBuyer; and the
"nothing sees a half-written form" invariant is asserted by snapshotting
every field at each change rather than by checking the event sequence,
which could not tell the two apart. The comment that justified it with a
now-impossible city case is corrected.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No critical issues found on the current head (36a5862), which supersedes d91b9d3 covered by the last review.

The third commit ("per-element revert, role-first scope, retryable write") fixes exactly the class of bug that would have been critical: revertFields() was checking the autofill marker on the first element of a jQuery set, so once the write was scoped to the billing form, an unmarked shipping-form field decided the answer for the whole set — silently no-opping the revert on both a country switch and on leaving sole-trader mode, and letting one form's marker license clearing a field in another. It's now checked and cleared per element. billingRoleFormRoot()'s selector also moved from #id to an attribute selector — the bare #id form goes through jQuery's getElementById fast path and returns at most one node, silently defeating the loop meant to pick the right form among several billing forms (one per payment method) sharing that id. The once-per-identity write guard is now recorded only after writeSoleTraderAddress() actually returns true, so a throw or a checkout with no address form yet no longer permanently consumes the single write attempt.

Frontend-only change (JS models/view + tests, no PHP/DI/schema touched). The module-scope adoptedSoleTraderIds Set (replacing the old per-instance flag) is a deliberate fix for Amasty/Fire Checkout re-rendering the renderer instance — well-reasoned and consistent with registeredOrganisationMode() clearing it wholesale on mode exit. Field routing, region matching, and the revert/scope symmetry are all covered by thorough parameterized tests against real jsdom markup, including the per-element regression this commit fixes.

Ready for human approval ✅

…461)

Adversarial review round 3.

The billing-form selector matched nothing. Core renders the billing
fieldset with `data-form="billing-new-address"` and no id at all — there
is no `billing-new-address-form` in core on any version, and core's own
tests select it by that attribute — so the scope resolution fell through
to the shipping form every time. The sole-trader write-back landed in the
hidden shipping form on the exact flow it exists for (guest, physical
cart, billing different from shipping), and the tile's ordinary
company-search autofill had been narrowed from document-wide to a form
the buyer was not looking at. Verified against Magento_Checkout's
billing-address/form.html and shipping-address/form.html in a 2.4.6
install; the id is kept after it for third-party checkouts that render
one, and the test fixtures now use core's markup rather than markup the
previous fixtures invented.

The region rule tested `disabled`, which core never sets: its region
element HIDES the control the country does not use (Magento_Ui region.js
hideRegion → setVisible / toggleInput), so the check was inert and the
comment justifying it was wrong on both halves. Back to a visibility test
on the free-text control, with the select's option count — the thing the
country actually drives — still deciding which control is in play.

The once-per-identity record is now keyed per brand: a checkout offering
two Two-family brands renders a tile and a billing form for each, and an
adoption in one says nothing about the other. It is also re-armed on a
country change, where the address step reverts the address it describes.
@dgjlindsay

Copy link
Copy Markdown
Contributor Author

Self-review summary (TWO-25461 — Magento sole-trader country resolution + address write-back)

This branch already carries three rounds of adversarial self-review, each fixing a real defect found in the round before it (see commit messages: selector matched nothing in core markup, revert cleared the wrong form via .attr()'s first-match semantics, once-per-identity guard armed before the write consumed the only chance on a throw). Picking up where that left off — ran a fourth round against the current diff and the sole-trader porting guide (§1(a.3) country resolution, §5 write-back, §2.6 field routing).

Checked and satisfied:

  • searchCountryCode() now reads quote.billingAddress() first, matching the source getData()/placeOrderIntent() already use, with existing feeds retained as fallback.
  • billingRoleFormRoot() matches core's actual data-form="billing-new-address" markup (verified against Magento_Checkout's billing-address/form.html), role-first over visibility, shipping form as last resort for the same-as-shipping case.
  • Write-back is scoped per-form (attribute selector, not #id, so it reaches the selected payment method's form specifically), field routing shared with ordinary company-search selection (no sole-trader special case), country never written.
  • Once-per-identity guard is keyed per brand, re-armed on country change and on leaving sole-trader mode, recorded only after a successful write (a throw doesn't consume the chance).
  • Revert is per-element (not per-set), so one form's marker can't decide another form's field.
  • Prefetch generation guard correctly supersedes a stale in-flight chain on an email edit; popup ACCEPTED path explicitly bumps the generation before resolving so a stale prefetch answer can't undo a completed signup.
  • Popup message listener is detached in dispose(), preventing a listener stack across re-renders (Amasty/Fire Checkout rebuild the payment method list on totals changes).

Verification this round:

  • Full JS suite: 34 suites / 489 tests, all green (npx jest --config Test/Js/jest.config.js).
  • CI on the PR: all 28 checks green (Jest, PHPUnit ×4, PHPStan ×7, DI compile ×7, Codesniffer, Lint ×4, upgrade smoke, version bump, Claude code-review) — reverified immediately before this comment, not relying on an earlier snapshot.

No further findings. Clean round.

(review by Claude)

…ento-soletrader-writeback

# Conflicts:
#	Test/Js/company-search-address-lookup.test.js
#	view/frontend/web/js/model/company-search.js
…ento-soletrader-writeback

# Conflicts:
#	view/frontend/web/js/view/payment/method-renderer/gateway_method.js

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No critical issues found on the current head (b83b583), which supersedes 36a5862 covered by the last bot review.

The intervening commit (9ee8ac7, "match the billing form core actually renders") fixes two real defects rather than adding new surface: ADDRESS_FORM_ROOT_SELECTORS now matches [data-form="billing-new-address"] — the attribute core's own billing-address/form.html actually renders — ahead of the #billing-new-address-form id that doesn't exist anywhere in core, so billingRoleFormRoot() would previously have fallen through to the shipping form on stock Luma/core checkouts. And the free-text region control's applicability check moved from .prop('disabled') to .is(':visible'), matching how Magento_Ui's region.js actually hides the unused region control (visibility, not the disabled attribute) — the old check would have silently accepted a hidden field as "in play" and written a region value the buyer could never see. Both are pinned by the updated company-search-address-field-routing.test.js, which also now models the real data-form fieldset markup instead of a synthetic id.

Everything else in this diff (scoped write-back, per-element revert, once-per-identity guard now keyed per-brand, prefetch generation guard, popup listener detach on dispose()) is unchanged from what the last three reviews already covered and approved. Frontend-only change (JS models/view + tests, no PHP/DI/schema touched).

No unresolved review threads on this PR.

Ready for human approval ✅

@dgjlindsay

Copy link
Copy Markdown
Contributor Author

Self-review round 4 — confirmed clean, merged staging's PHPStan fix

Picking up the interrupted round 4 above: CI had gone red on this PR after that comment was posted, but the failure (Unexpected item 'parameters › excludes_analyse' across every PHPStan matrix cell) was unrelated to this branch — staging's phpstan.neon used a config key a newer PHPStan version now hard-rejects, fixed on staging in PR #347 (renames to excludePaths, adds dev/*).

Merged current origin/staging into this branch (clean merge, only picked up the phpstan.neon fix and unrelated dev/ tooling — no conflicts with this PR's files) and re-verified:

  • Re-read the diff against the porting guide's §1(a.3) (country resolution: billingRoleCountryCode() reads quote.billingAddress(), matching what getData()/placeOrderIntent() already use), §5 (write-back: single writeSoleTraderAddress()/adoptSoleTraderBuyer() path, not gated on the address-lookup switches, once-per-identity guard recorded only after a successful write, re-armed on country change and mode exit), and §2.6 (field routing: building/apartment → line 1 with street on line 2, single-street-line shops join onto line 1, region best-effort name→id match) — all consistent, no drift from the round-3 diff.
  • CI on the merge commit: all 28 checks green, including every PHPStan matrix cell.

No new findings. Clean round.

(review by Claude)

@dgjlindsay
dgjlindsay merged commit 49e42d0 into staging Aug 19, 2026
28 checks passed
@dgjlindsay
dgjlindsay deleted the doug/two-25461-magento-soletrader-writeback branch August 19, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant