Skip to content

chore: sync staging → main - #341

Open
two-inc[bot] wants to merge 498 commits into
mainfrom
staging
Open

chore: sync staging → main#341
two-inc[bot] wants to merge 498 commits into
mainfrom
staging

Conversation

@two-inc

@two-inc two-inc Bot commented Aug 12, 2026

Copy link
Copy Markdown

Automated rolling sync PR opened by .github/workflows/auto-pr.yml.

Merges everything currently on staging into main. Auto-updates as new commits land on staging. Close manually if you need to skip a sync window.

dgjlindsay and others added 30 commits July 31, 2026 00:08
…al-entry row

Adversarial review round 1 (Han) found two real gaps:

- the observer created by attachManualEntryRow() was only disconnected
  on select2:close/dispose, not on the re-bind path enableCompanySearch()
  takes on every re-render — a re-render while the picker is open can
  replace the select2 instance without emitting select2:close first,
  orphaning the previous observer's detached results list.
- clearCompany() tore the select2 instance down synchronously from
  inside select2's own select2:selecting dispatch; deferred the
  teardown a tick so select2's own post-preventDefault bookkeeping for
  that event finishes first.

Tests pin both: the re-bind path now asserted to call
detachManualEntryObserver before re-init, and the selecting handler
asserted to defer clearCompany() via setTimeout rather than call it
inline.
…ield

Ports the reference company-search UX: the payment tile's company-name
field now shows the selected company's registry id in light-grey text to
its right, cleared with the companyId observable. Cosmetic only — getData()
keeps reading companyId() directly, nothing new writes it.

Also hides the address step's separate "Company Number" field purely via
CSS (new .two-company-id-hidden class), rather than flipping its
`visible` config to false: address-autocomplete.js resolves the field
through uiRegistry.get(), which a visible:false would remove from the
registry entirely. The DOM node and its submitted value are unaffected.
Review flagged that the comment described the address-step fix as
already landed; #305 is still open/unmerged. Reworded to reflect that
this PR tackles the payment-tile picker independently.
TWO-25279/feat(surcharge): require a merchant tax rule for the surcharge tax treatment
…entry

The "Search for company" link only re-bound the picker and hid itself,
leaving the buyer on a closed dropdown they had to click a second time
before they could type. It now asks the fresh widget to open, which runs
the existing `select2:open` handler and puts the caret in the search box.

The request is a one-shot local, not a component flag: the bind runs
inside a `$.async` MutationObserver that fires again on every re-render,
so a persistent flag would pop the dropdown open under a buyer who has
moved on. The initial render passes no option and is unchanged.

Test/Js/company-search-return-to-search.test.js drives the real journey
on both pickers and asserts the real DOM — an open container, one live
`.select2-search__field`, and `document.activeElement` being that input.
The select2 double throws on `open` without an instance, so opening
before the re-bind cannot pass. amd-harness gains an optional globals
argument so the module can see jsdom's document instead of the inert stub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A limit of exactly 0 survived the grid: validateValue only rejected
negatives, and only an empty cell deleted the config row. Zero is
never what an admin means by it.

The limit bounds the WHOLE fee line - the percentage part and the
fixed amount together, not the percentage alone - so a limit of 0
silently wipes a configured fixed amount too, with nothing in the
admin copy saying so. And the intent it gets mistaken for ("charge
nothing on this term") is already expressible directly, by entering 0
in the fixed and percentage cells.

So: refuse it at entry, and say what to do instead. An EMPTY limit
stays valid and still means "no limit" - absence and zero are
different values, and only the explicit zero is refused.

- Model/Config/Backend/SurchargeGrid: reject `limit` === 0 (the
  authority; reachable from the admin UI, from `config:set`, and from
  app:config:import alike).
- surcharge-grid.phtml / surcharge-grid.js: the same refusal in the
  browser, as a registered rule rather than Magento's own
  validate-greater-than-zero, which is locale-blind - parseFloat('0,5')
  is 0 for a Dutch admin and would reject a legitimate half-unit
  limit. Applied to rendered rows and to rows the JS adds live.
- Admin copy: state that a limit of 0 is not allowed, and - on
  fixed_and_percentage, the only mode where it is load-bearing - that
  the limit applies to the whole fee rather than the percentage alone.

Tests: the pre-existing SurchargeGridTestable stub reimplements the
save flow, so it cannot pin a validation RULE - breaking the
production check could not turn a stub-only test red. The three new
tests therefore invoke the production validateValue directly.
Verified by mutation: neutering the check reddens
testProductionValidatorRefusesAZeroLimit only; widening it to `>= 0`
reddens testProductionValidatorAcceptsAPositiveLimit. Three existing
fixtures carried `'limit' => '0'` incidentally and would have started
passing for the wrong reason - emptied.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, sv_SE)

Append-only, kept as its own commit so it rebases cleanly behind the
other i18n work in flight on this repo.

Four new rows per locale: the save-time error, the browser-side rule
message, and the two admin-copy sentences. Terminology follows the
rows already in each file - Limit = Grense / Limiet / Grans, Fixed
Amount = Fast belop / Vast bedrag / Fast belopp - rather than being
translated afresh.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-contradict the guidance

Two things, both fallout of refusing a zero limit at the admin
boundary.

1. `convertAmount()` sent its result unrounded. The API refuses
   monetary values finer than two decimal places rather than rounding
   them, so an FX conversion landing on >2dp (349 * 0.0872 = 30.4328)
   was rejected upstream and surfaced to the buyer as the generic
   "temporarily unavailable" error. Both `cap` and `surcharge` are now
   rounded, on the no-conversion path too - an admin can type more
   precision than the API accepts.

   Plain half-up rounding, deliberately. Sub-cent caps, away-from-zero
   rounding and zero-decimal currencies are all out of scope: the one
   value where the rounding direction would have mattered, an explicit
   cap of 0, is refused by the grid instead. A sub-cent cap rounding to
   0.00 is pinned by its own test so it reads as a decision rather than
   a surprise.

2. The prose merged with the TWO-25269 revert told the next reader not
   to add admin validation rejecting a typed zero - correct as a
   statement about the RUNTIME relay, wrong as guidance, and sitting in
   the first file an implementer of this change would open. Rewritten
   in both places (the inline comment and AGENTS.md) to hold both rules
   and say why they do not conflict: the runtime relays a zero cap
   faithfully because the API bounds the fee at zero rather than
   uncapping it, AND the admin refuses zero because a merchant wanting
   no fee says so with 0% and 0 fixed, while the sibling plugins were
   turning a zero cap into "absent" and relaying it genuinely uncapped.

The test that pinned the unrounded pass-through was pinning the bug -
and asserted nothing, so it was also risky-flagged. Replaced with two
that pin the rounding. Verified by mutation: dropping either round()
reddens exactly the test for that path.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt limits, non-numeric input

Adversarial review found one blocker and five real defects. Fixes:

BLOCKER - a limit of 0 stored while it was still legal made the whole
Two payment section UNSAVEABLE. The grid JS hides the Limit column for
a fixed-only surcharge type, but a hidden input still posts, and
client-side validation skips hidden fields, so the stored 0 arrived
server-side and threw - naming a cell the admin can neither see nor
clear. Now an inapplicable limit is DELETED rather than validated,
which also retires the legacy row instead of leaving it to resurface.
Same reasoning the funding-partner cap check already carries: failing
a save over an invisible cell is a dead end.

The type gate reads the POSTED surcharge type, not the stored one: type
and grid are saved in the same request, so the stored value is the
previous one and would misjudge the merchant switching type - which is
exactly the case that decides whether their save is blocked.

A SUB-CENT limit (0.001) passed validation and then became a hard cap
of 0.00 once rounded for the wire - the very outcome being refused,
one step later. The check is now round($value, 2) === 0.0, so the
rounding direction cannot decide whether a configured cap survives.
Both prose claims to that effect were false as written; corrected
rather than deleted, because the FX-conversion case genuinely remains
and is pinned as accepted.

NON-NUMERIC input had no server-side check at all. Cast to float,
'abc' is 0.0, so it was rejected as "a limit of 0 is not allowed" -
wrong and unactionable. validateValue() now takes the RAW string so it
can tell the two apart, with its own message.

A stale docblock paragraph directly contradicted the new one ("the
result is returned unrounded"); deleted. The MONEY_DECIMALS comment
claimed every monetary value is rounded, when gross_amount and
rounding.step are not; scoped to what it does.

The registered JS rule had three defects: $t() around a
`+`-concatenated literal, which Magento's phrase collector cannot
harvest, so the three new i18n rows were dead and the message would
have stayed English; the message resolved at define time, before the
dictionary is guaranteed registered; and an `if ($.validator && ...)`
guard that would silently skip registration while the rendered
data-validate attribute still named the rule, making jquery.validate
throw on submit and kill validation of the whole form. One unbroken
literal, lazy function, no guard.

Two of round 1's new tests were exercising the test stub rather than
production code - the exact can't-fail pattern this PR's own commit
message called out. Both moved onto the real methods via reflection.
Mutations verified: dropping is_numeric, inverting the type gate, and
reading the stored type instead of the posted one each redden exactly
one test.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and test the rule

Jest went red on the surcharge-grid smoke load: dropping the
`if ($.validator && ...)` guard exposed that the AMD harness's jQuery
mock carries neither `$.validator` nor `$.mage.parseNumber`, both of
which `mage/validation` provides in the browser. A jQuery without them
is a shape no browser presents, so the failure was testing the mock,
not the module - the guard had been hiding a harness gap as well as a
real defect. Mock fixed rather than guard restored.

The mock's parseNumber reproduces the comma-decimal behaviour
deliberately: parseFloat('0,5') is 0, and that difference is the entire
reason this rule exists instead of Magento's locale-blind
validate-greater-than-zero. A mock that used parseFloat would let the
locale bug through unnoticed.

With the registry mocked, the rule itself is now directly testable, so
it is tested rather than merely smoke-loaded: zero in every spelling,
sub-cent values, empty, comma decimals, non-numeric left to the
sibling rule, and the message being a lazily-resolved function.
Mutations verified - reverting the sub-cent rounding, and swapping
parseNumber for parseFloat, each redden one case.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p rather than delete, non-finite limits

Round 1's fix for the unsaveable-grid dead end had three problems of its
own, all found by round 2.

DELETING an inapplicable limit was wrong twice over. It discarded a
VALID limit on any save made while the surcharge was fixed-only or off -
a normal round trip, and one that kept the equally inapplicable
percentage cell. And at a non-default scope, deleting an override does
not retire a value at all: it re-exposes the parent's, so a legacy zero
at default scope produced an unsaveable loop at store scope - reject the
posted inherited zero, clear it, the override is deleted, the next
render shows the inherited zero again, and the error's "leave the limit
empty" was actively wrong there. The column-visibility gate now
suppresses only the ZERO RULE; the cell is stored either way, and a
legacy zero resurfaces when the column comes back into view, which is
where the admin can act on it.

The TYPE READ was unscoped. The config fallback is not an edge case: a
type field left on "Use Default Value" renders its select disabled, and
browsers do not submit disabled inputs, so nothing is posted for it -
which makes the fallback the normal path at a non-default scope, where
an unscoped read returns the DEFAULT scope's value. Default fixed +
website percentage would have silently deleted a store's real limits;
the reverse would have blocked its whole section save. Resolved at the
saving scope now, as the sibling render block already does.

is_numeric('1e400') is true and the cast is INF. Limit is the one column
with no upper bound, so INF was stored and then failed the pricing
request at serialisation time, a long way from the cause.

Test debt from round 1, which the review was right to call a recurrence:
the delete-path test exercised only the stub, so removing the production
wiring kept the suite green. The rule and the type gate are both pinned
against production code now, including the scoped fallback (via a
reflection-injected scope config), and the stub keeps only the flow it
is honest about. MONEY_DECIMALS is duplicated across the grid and the
calculator and the whole correctness argument depends on the two
agreeing, so a test now pins that.

Also: the Jest parseNumber mock only handled a single comma, so
'1.234,56' parsed as 1.234 - the mock diverging from the real parser in
exactly the locale behaviour it exists to cover, which is false
confidence rather than coverage. It implements last-separator-wins now,
with a test.

Copy: the zero-limit sentence loses its possessive apostrophe, matching
the sibling plugins. PrestaShop keys its translations on the md5 of the
BACKSLASH-ESCAPED source, so an apostrophe there silently yields a dead
key; sidestepping it in the shared wording beats getting the escaping
right in one plugin and wrong in the next.

Mutations verified: dropping the visibility gate, unscoping the fallback
read, dropping is_finite, and breaking MONEY_DECIMALS parity each redden
exactly the intended test.

Refs TWO-25289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pin the visibility wiring, de-stale the doc

Three round-3 findings.

1. The stored surcharge limit was cast blindly on read, so a value that
   never came through the admin grid decided the buyer's fee. A
   hand-edited row, a config:set or an import could store `abc`, which
   cast to a hard cap of 0.0 and suppressed the fee outright, or `-10`,
   which was relayed as a negative cap and refused upstream so the buyer
   saw a generic failure. Non-scalar, empty, whitespace-only,
   non-numeric, non-finite and negative now all resolve to NULL, i.e.
   absent, i.e. no cap. A genuine 0 is still relayed verbatim: a zero cap
   clamps the fee to zero, which is a different instruction from
   absence. Same shape as the sibling plugins.

2. Round 2's limit-column visibility wiring was untested. validateValue()
   defaults the flag to true, so deleting the argument at the call site —
   or dropping the term from the rule — compiled and left the suite green
   while reintroducing the failed-section-save regression round 2
   removed. Both directions are now pinned against the REAL afterSave(),
   which needed afterSave() and an explicit setData() on the test stubs
   before the production method was callable at all.

3. AGENTS.md still described the hidden Limit column as deleted rather
   than validated. Round 2 changed that to skipped-not-deleted, so the
   doc was instructing a future reader to reintroduce the regression.
   Corrected, with the reason deleting is wrong, and a new section for
   the read-path guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nostic, not the result

The array case asserted only that the limit read as absent, which held
with or without the guard: the string cast yields "Array", which is
non-numeric and lands on null anyway. The cast is a PHP warning rather
than an error, so the assertion that actually distinguishes the two is
that no diagnostic is raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…manual-entry-affordance

# Conflicts:
#	Test/Js/amd-harness.js
#	view/frontend/web/js/model/company-search.js
…-hint-magento

TWO-25288: grey inline company-id hint + CSS-hide address-step field
TWO-25289/fix(surcharge): refuse a limit of 0 in the grid, round relayed amounts to 2dp
…staleness

Round 2 adversarial review (Han, Vader, both independently) found that
the setTimeout(0) deferral added in the previous fix had no staleness
check: every other deferred/async call in this handler chain
(onSearching, onUnavailable, clearSearchChrome, attachManualEntryRow)
gates on the bind staying current, but the manual-entry teardown acted
unconditionally through `self`. A checkout re-render landing in the
zero-delay gap between the buyer's click and the timer firing would
tear down whatever widget is live NOW rather than no-op for the stale
bind, and the eagerly-resolved `$searchForCompany` reference from the
same gap could operate on a detached node.

Gates the deferred callback on companySearch.getSearchFieldContainer()
staying non-empty for the bind token, and moves `$searchForCompany`
resolution inside the deferred body instead of before it (Yoda,
round 2). New regression test proves reverting the staleness check
goes RED.
feat(TWO-25288): open and focus company search on return from manual entry
…ffordance

fix(company-search): move payment-tile manual-entry affordance inside the results listbox
TWO-25288. Four cases in company-search-return-to-search.test.js fail on
staging, on nobody's branch. The payment surface's manual-entry exit stopped
being a "#billing_enter_details_manually" link below the results and became a
cancellable row inside them, but that landed AFTER this file did, so the
helper's click found no node and every case that routes through manual entry
died in the fixture.

Drive the real route instead: trigger the preventable select2:selecting
pre-event with a sentinel row, then run the deferred teardown. Two things the
straightforward version of that gets wrong:

  - jest.useFakeTimers() cannot reach the deferral. The AMD harness copies the
    real setTimeout into its vm sandbox when the module loads, so a later timer
    swap patches a binding the module never sees. The fixture supplies a
    queueing setTimeout through extraGlobals and flushes it explicitly, which
    also keeps the defer visible rather than collapsing it to a synchronous
    call.
  - the teardown is gated on the bind still being current, which the default
    company-search mock answers with null. getSearchFieldContainer now reports
    a live bind, alongside the sentinel-aware isManualEntryOption the shipping
    surface already needed.

The flush reports how many callbacks it ran and the helper refuses a run that
deferred none, so a future regression that stops reaching the handler fails
loudly instead of asserting against a widget nothing asked to tear down.

Verified against origin/staging with only this file changed: 249/249, from
4 failing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Supersedes the removal of the payment tile's company-number input. The reason
it was removed holds — a hand-typed organisation number is not an accepted
source; it produced poor data quality and genuine buyers receiving invoices for
orders they never placed — but removing the field also stopped the buyer seeing
what the order would be invoiced against. Read-only keeps the first and
restores the second, on every capture mode.

The number field is back as a labelled input, `readonly` as a STATIC attribute
(there is no mode in which it may be typed), bound to `value: companyId`, and
deliberately carrying no `name` and no `required`:

  - no `name`, so it adds no second carrier for a value getData() reads off the
    observable. The observable stays the only thing that ships.
  - not `required`, because a read-only empty required field fails validation
    with no way for the buyer to satisfy it. Model/Two.php::authorize() remains
    the only enforcement of the number's presence, unchanged by this commit.
  - `readonly`, not `disabled`: a disabled control is dropped from the
    submitted form and skipped by jQuery Validation's `elements()`.

The name field's `readonly` is BOUND, to sole-trader mode only, and the
asymmetry is load-bearing. Sole trader is the one mode where that node is a
plain text box holding a captured name the buyer must not edit. In search mode
select2 replaces it with its own chrome; in manual-entry mode the buyer MUST be
able to type, and a static `readonly` would brick that mode outright.

clearCompany() now blanks `companyId()`. While the tile had no number field the
stale observable was invisible and was flagged as out of scope; with the number
displayed, leaving it set would show the buyer the ABANDONED company's registry
number, uneditable, beside a name they are being asked to retype — and submit
that number under the new name. `companyName()` is deliberately NOT cleared: it
is read after the call on the sole-trader path (getAutofillData() prefills the
signup popup from it) and by the intent-approved notice.

The grey inline hint that briefly stood in for the field is gone, markup and
CSS together — a labelled field supersedes it, and both would have shown the
buyer the same number twice. Its address-step half, the CSS-only hide, is
untouched and keeps its own pins.

Tests read the `value:` and `readonly:` binding targets out of the template and
evaluate them against a renderer driven through each real capture flow, so a
field bound to the wrong observable fails even with correct-looking markup.
Every read-only assertion in the first draft passed against a template with the
attribute DELETED, because `\breadonly\b` matches inside the class name
`two-company-id-readonly`; attribute matching is now anchored on whitespace.
Caught by mutation, and each of five mutations is confirmed to fail the suite:
dropping the static attribute (5), removing the field (6), making the name
statically read-only (2), unclearing companyId in clearCompany (1), and
rebinding the number to companyName (5).

npm run test:js: 21 suites, 253 tests, all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e escape hatch

Two real findings from an independent adversarial round on the diff.

MAJOR — the name field could be blank, `readonly` and `required` at once, which
is a validation error no buyer action clears. jQuery Validation enforces
`required` on a `[readonly]` field; its `elements()` skips `:disabled` only.
Keying `readonly` on sole-trader mode alone hit that: enterSoleTraderUi() blanks
the input, and the autofill only refills it when the prefetch matched a buyer —
fillCompanyData() early-returns unless BOTH name and number are non-empty. On
the unmatched branch the buyer is sent to signup and may abandon it, and any
autofill buyer with an empty organisation number lands there too. Before this
change the buyer could retype; the first draft took that away.

Now bound to isCompanyNameReadOnly(), which is `showSoleTrader() &&
!!companyId()` — lock only once a number has actually been captured, so every
state in which the field is empty is a state the buyer can type into. A plain
method rather than a computed: the template calls it inside the ko `attr`
binding, so ko tracks both observables as dependencies of that binding, there is
no subscription to dispose, and it exists on renderers the unit tests load
without booting.

MAJOR — the comments this change edited (and inherited) asserted that the
address step is "the sole hand-typed route" for the organisation number. It is
not a route at all: that field carries `two-company-id-hidden` from
Plugin/Model/Checkout/LayoutProcessorPlugin.php and `display: none` from the
stylesheet, unconditionally, with nothing anywhere removing the class. So no
buyer can hand-type an organisation number ANYWHERE in the plugin, and an
identifier-less company is a dead end refused by Model/Two.php::authorize().
Corrected in all three places rather than left as an escape hatch a future
reader would go looking for. Whether that field should be unhidden when
needsManualCompanyId() is a separate decision, not taken here.

MINOR — the manual-entry helper's flush guard accepted any queued callback, and
the injected setTimeout catches every timer the module sets (showErrorMessage's
dismissal is the other). Tightened to exactly one.

Two new cases pin the MAJOR directly: sole-trader mode entered with nothing
captured, and a captured company abandoned by the mode switch. Both assert the
field is NOT locked.

Mutation-verified, six mutants, all killed: name readonly keyed on mode alone
(2 — the bug above), always true (4), always false (2), static readonly dropped
from the number (5), clearCompany not clearing companyId (2), a `name` attribute
added to the number (1).

npm run test:js: 21 suites, 255 tests, all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pany-fields

feat(TWO-25288): show the captured company name and number read-only in the tile
Both the shipping-step and payment-step "Search for company" controls
rendered as a plain <div class="search_for_company"> with only a click
handler — no tabindex, role, or keydown binding, so keyboard users
could not reach or activate them at all.

Add role="button", tabindex="0", and Enter/Space keydown handling to
both address-autocomplete.js and gateway_method.js, matching the
pattern already correct on Hyvä (companyName.phtml) and shipped on
WooCommerce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt tile

gateway_method-csp-js.phtml renders "Company number:" and "Company
name:" via __() but these two exact strings (with the trailing
colon) had no rows in nb_NO/nl_NL/sv_SE, so they silently fell back
to English. Add both, reusing the wording already shipped for
"Company Number"/"Company Name" elsewhere in the same dictionaries
plus a trailing colon (matching the existing "Merchant ID:" pattern).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adversarial review (Vader) flagged that some assistive-tech/browser
combinations forward a synthetic click in addition to the Enter
keydown for a role="button" div (as opposed to a native <button>).
Without a guard, a second call to activateSearchForCompany() would
re-open a dropdown the buyer already opened. Early-return once the
control is hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix(a11y): keyboard-reachable Search for company control
…bels

fix(i18n): add missing Company number/name label translations
…country order

Three live bugs Doug found testing Luma checkout this morning (#30.x.11):

- Company search never got the CSS side of last night's WC/PS keyboard-
  reachability work: text-transform scoping, select2 vertical alignment, and
  a visible focus indicator on the "Search for company" control. PR #311
  already fixed the JS keyboard behaviour (role/tabindex/Enter-Space); the
  matching CSS never landed alongside it.

- The payment tile showed the captured company number as a second, separate
  read-only input. Canonical design across all four platforms (Doug's
  ruling): a plain text label immediately below the company-name field,
  right-aligned to it, visible only once a number has actually been
  captured.

- Country rendered after street/company. checkout_index_index.xml already
  declares country_id's sortOrder as a static 50, which does win over
  Magento core's EAV default — but company/street carry no override of
  their own, so their position depends entirely on the store's Customer
  Address Attributes admin configuration, and this staging store's config
  pushes them below 50. LayoutProcessorPlugin now reads company's actual
  resolved sortOrder back out of the array (after core's own merge) and
  forces country immediately before it, mirroring PrestaShop's
  CustomerAddressFormatter::moveFieldBefore('id_country', 'company') —
  dynamic relative positioning instead of a static number.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d a11y caption, id-fragile CSS

Three real defects from an independent adversarial round (Han/Vader/Yoda),
all confirmed before fixing rather than taken on faith:

- moveCountryBeforeCompany() anchored to company's sortOrder alone and
  rewrote country_id unconditionally on every render — the live bug was
  reported as "country after STREET", which the fix never checked, and an
  unconditional rewrite could push country past some other field even on a
  store that never had the bug. Now anchors to min(company, street) and only
  acts when country is not already ordered correctly; added an is_array
  guard on a malformed non-array country_id.

- The payment-tile label removed the entire "Company Number" caption along
  with the old input, leaving a bare number with no indication of what it
  was — a real regression for sighted buyers and assistive tech. Restored as
  a static caption span alongside the number span (no `for`/id association,
  since there's no control left to associate with).

- The address-step company-search dropdown CSS was scoped by
  `#select2-company-container`/`#select2-company-results`, assuming select2
  derives those ids from the field's own `id="company"`. Verified live: the
  actual id is a Magento-generated uid (`id="S0FR5JB"`), not "company" —
  those selectors were dead CSS. Re-scoped the selection box via the
  adjacent-sibling selector already used elsewhere (id-independent), and
  dropdown rows via `dropdownCssClass: 'two-company-search-dropdown'` set
  on both select2 inits — select2's own supported hook for this, immune to
  its id-generation fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dgjlindsay and others added 30 commits August 26, 2026 13:58
Retargets the five sole-trader suites from renderer delegates onto the
component-owned flow, and re-pins the behaviours carried over from the
previous attempt: tokens minted on availability rather than on click, a
synchronous click-to-window.open() path, the 30-minute refresh skipped
while a round trip is outstanding, autoselect=false on both re-signup
routes, a blocked popup's retry reusing the blocked launch's options,
abandonment deferring to the handshake, and the flight settling only
once the identity write has landed.

Two get stronger proofs than before. The absence of a passive buyer
probe is pinned in source — exactly one `fetchBuyer` call site, inside
the ACCEPTED branch — because it is a security property and a
behavioural fixture alone would not catch its return. The phone write is
proven against the real search model and a real form: applyAddress()
handed a record carrying a phone number leaves telephone untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
Both were live before the component took over the mount, and both died
because the new mount did not pass what the old call sites did.

The company field showed nothing when empty: select2 renders its own
display node, so the placeholder belongs to templateSelectionFallback
and not to the input underneath. The msgid was still sitting in three
catalogues with nothing referencing it.

The "Search for company" link was built, hidden, and never shown again —
manual entry is what used to reveal it. A buyer who has just typed into
that field is looking at it rather than at the chips, so the in-field
route back earns its place beside the chip that does the same thing.

Test suites for company search retargeted onto the single mount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
TWO-24867 was broken end to end: `_lastCountry` was written only inside
onCountryChanged(), so the first switch after page load read as the first
resolution and kept the company. A buyer could capture a GB organisation
number, move to Spain, and place the order with it.

The old renderer compared against an observable the quote seeded on load;
the component had no counterpart until now.

Remaining suites retargeted onto the single mount, and the control's
docblock no longer argues for the two-mount design it used to have.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
Country invalidation had exactly one trigger and it was the wrong one.
Only a `change` on an address-form select reached onCountryChanged(), and
it read the country through a shipping-first document scan. A saved
address in another country fired no event at all; a separate billing
country resolved to the unchanged shipping one and was dropped. Both left
a registry number standing under an address from a different registry,
which is the failure TWO-24867 exists to prevent. The quote now drives it
too, and the handler reads the select the buyer actually touched.

A tile replaced under an unchanged selector left the control bound to a
detached node. The node keeps its select2 data, so the control reports
itself bound and the mount refresh skipped — the buyer got a plain input
with no picker, permanently, because the selector never changes again.
The guard now compares node identity, which is the only thing that can
tell a replaced node from the one still on the page.

A sole trader with no registry number of their own inherited the number
of whatever company was captured before them, because the adoption write
was not authoritative. Their name went out attached to another company's
organisation number.

The lifecycle suite claimed to cover the second of these and did not: its
double reported itself bound independently of any node, so removing the
guard entirely left the suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
The previous commit stopped a numberless sole trader inheriting the
previous company's registry number, but left the mirror of it: the name
was written only when truthy, whatever the caller asked for, so a sole
trader with a number and no trading name of their own kept the earlier
company's NAME against their own number. The same mismatch, the other
field.

An authoritative write now replaces both halves, empty ones included —
which is what the callers already believed they were asking for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
…y-search

refactor(TWO-25503): page-level company-capture component
…l-company-search"

This reverts commit d73522f, reversing
changes made to ef76706.
Revert page-level company-capture component (TWO-25503)
`bind()` registered a fresh MutationObserver on every call and nothing
ever disconnected one, so observers accumulated for the life of the page.
Each observer's callback rebuilds select2, which mutates the DOM, which
re-fires every observer registered so far — on a checkout that re-binds
per totals change that compounds until the renderer stops responding.

One observer per selector is enough: it already re-fires when the node is
replaced, which is the case re-binding exists for. A caller asking to
re-bind while one is watching gets the widget rebuilt immediately rather
than waiting for the next mutation.

The harness's `$.async` stub ran the callback once and never again, so
stacking could not be expressed and no test could have caught this. It
now records registrations and replays them the way a mutation does.
Against the unfixed control the new suite fails five of eight, reporting
one observer per bind and ten re-initialisations for a single mutation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
fix(TWO-25503): register one $.async observer per selector
Re-lands the component reverted in #368, on top of the observer-leak fix
in #370, with the two changes that revert made necessary.

`isMountLive()` is gone. It existed because the control reported itself
bound off a node that could be detached, so a replaced tile left the
buyer a dead input. With one `$.async` observer per selector guaranteed,
that observer re-initialises on the replacement itself and the case is
handled a layer down. The guard is what re-bound per totals change and
stacked observers until the renderer stopped responding — it produced
three of the five defects the previous attempt's review found, so it is
removed rather than patched again.

`onCountryChanged()` returns early without a brand config. The boot hook
calls it on every address and totals change, on every checkout, and
`start()` bails leaving no flow to tell and no registry to ask — so on a
merchant with no Two-family method it made a registry call it had no
business making and then dereferenced a null flow.

Both are mutation-checked: without the guard the country change throws
`Cannot read properties of null (reading 'forgetAdoptions')`, and the
tile-replacement case now pins that re-binding is the control's job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
refactor(TWO-25503): page-level company-capture component
start() runs from the checkout sidebar, before the address form exists and
before the quote carries a billing address, so the mount selector and country
both resolved empty and nothing re-drove them. Watch for either candidate host
via $.async and mount when it arrives.

The harness now installs the $.async simulation over real jQuery too, so suites
using real DOM do not have to know a module waits for a node.
TWO-25503 mount company search when its host node appears
Measured on staging: the company input lands 44ms before the country select,
so the mount watcher asks for a country nothing can answer yet and sole-trader
availability early-returns. The select then arrives already holding the default,
so the `change` that would ask again never fires and the sole-trader chip stays
hidden for every buyer who keeps their default country.

Watch for the select itself and re-resolve through countryCode(), so an
appearing billing form cannot impose its own default over the quote.

The harness delivered every registration on fireAll(), letting one watcher stand
in for another and hiding a missing one; it now delivers a node once, as an
observer does.
…n-appear

TWO-25503 resolve the country once a form can answer for it
…lude

ABN-489: shadow .worktrees/ in docker install/test mounts
Replace the select2 picker with a self-owned panel anchored to the company
field, holding the query input, the results and the mode chips as one control.
select2 appends its dropdown to <body> and rewrites it on every open, so the
chips could only ever be a sibling it drew over — which is what hid them
exactly when the buyer opened the thing that offers them.

Matches PrestaShop's TwoCompanySearch structure.
…p note

The suites were written against select2's own DOM and its transport contract;
they now assert the same guarantees against the panel and searchCompanies().

Also fixes a real bug the chip move introduced: the blocked-popup fallback
note anchored itself after the chips, which now live inside the popover that
entering sole-trader mode closes — so the buyer's only route forward rendered
inside something they could not see. Anchored outside the popover instead.
- one $.async observer per selector EVER, not per selector transition, and the
  observer for an abandoned mount no longer drags the panel back to it
- a re-render no longer wipes a manually typed company name or re-arms the
  field as a search trigger while manual entry owns it
- leaving a mount unwraps it, so a second wrapper cannot clone the
  sole-trader fallback note
- manual entry writes through to Knockout, so the quote stops carrying the
  company the buyer just abandoned
- IME composition and paste reach the query field: seeding moved from keydown,
  which reports no printable key for either, to input
- combobox semantics moved onto the field the keyboard actually reaches
- closing aborts the search still on the wire
- "the search is down" is styled apart from "your company is not here" again
- per-panel result-row ids, so aria-activedescendant cannot resolve into a
  sibling panel
- destroy() is final against observers it cannot disconnect
…wice

The open/close/teardown/keyboard contract had no suite of its own — the
neighbouring ones each reach through the panel at something else — so a dozen
guarantees were passing vacuously. Each new case is pinned by a mutation that
turns the suite red.

The too-short hint now renders once: the query field's placeholder carries it
for an untouched field, and the message line takes over only when the buyer is
actually short of the threshold.

e2e drives the popover rather than the deleted select2 widget.
- the too-short hint reads "Enter 3 or more characters", matching PrestaShop
- the signup popup comes down when focus returns to the CHECKOUT page, and
  only then: focus leaving for a mail client to fetch an OTP leaves it up.
  Clicking the Sole trader chip is the one exception and raises it instead
- manual entry renders the "Search for company" link again, below the field
  and right-aligned — without it manual entry is a dead end
- clicking the company field lands the caret in the query box, rather than
  waiting for the first keystroke: the mousedown's own default action was
  taking focus straight back out
- the query row shows in registered-company mode alone; a sole trader is
  enrolled through the hosted signup and a manual name is typed into the
  company field, so a query box answers for neither
The chips have to stay on screen while the buyer is in the hosted signup,
or the only route back to the Sole trader chip is the company field — and
clicking that reads as "focus is back on checkout", which takes the signup
down a moment before they can reach the chip.

The popup now comes down only when focus returns to checkout AND settles
outside this control, so a click landing inside the popover is the buyer
reaching for the signup rather than away from it.
…panel

open() returned early when the panel was already up, which is exactly the
state coming back from sole-trader mode leaves: the popover stays open
behind the signup popup with the query row hidden, so the buyer landed on a
search box nothing had put the caret in.

It now re-syncs and re-focuses whether or not the panel was already open,
with the sync ordered first so the query row is showing before the caret is
asked to go there.
It was held open only so the Sole trader chip stayed reachable while the
hosted signup was up. Once an identity is adopted the company is in the
field and there is nothing left in the popover to act on.
TWO-25503: one unified company-capture popover
The Hyva extension carries its own parallel popover implementation, and that
duplication is why the two checkouts keep drifting apart. It cannot reuse this
one while the file depends on jQuery, mage/translate and a company-search
module that reaches Magento_Checkout's Knockout quote.

Pure refactor: same DOM, same order, same copy, same behaviour. Everything
platform-shaped is now injected - `search` (the six-member transport, which
Magento passes as the company-search module verbatim), `translate` and
`observe` - and the UMD tail lets a host with no RequireJS load the same
deployed file as a plain script.

Test changes are delivery-mechanism only, no assertion touched: jQuery's
.trigger() walks its own handler store and calls elem[type](), which does not
exist for `input` or `mousedown`, so those call sites now dispatch the same
event natively. The teardown suite counts document listeners by intercepting
addEventListener rather than reading jQuery's namespace store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
jQuery's `.empty()` dropped the handler data for everything it removed; the
vanilla port recorded only exact-target matches, so every chip rebuild - one
per totals change on a re-rendering checkout - left an entry behind for the
page's lifetime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JheYXJZ6LdrKmr2XfPwcEm
TWO-25503: extract the company popover into a framework-free module
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