chore: sync staging → main - #341
Open
two-inc[bot] wants to merge 736 commits into
Open
Conversation
dgjlindsay
requested changes
Aug 14, 2026
dgjlindsay
left a comment
Contributor
There was a problem hiding this comment.
Do not release until Doug says so
…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
…e mount The search country now follows the country selector in the form the control is mounted in. The payment tile has no such selector, so there the quote's billing address still decides. Refs TWO-25461
Refs TWO-25461
The payment tile now reads the country selector in the form holding the buyer's invoice address — the billing form where core rendered one, else the shipping form. The quote's billing country is the last resort, for a checkout with no address form carrying a country selector at all. Refs TWO-25461
chore: sync main → staging
…pansion-1.1.18 chore(deps-dev): bump brace-expansion from 1.1.14 to 1.1.18
TWO-40. On a non-sole-trader country with company search out of the address step, "Registered company" is the only surviving chip - the mode the buyer is already in - so the row offers no choice. Counted in the shared panel, where the hidden class is applied, so a chip that is built and then hidden does not keep the row alive. The row is hidden rather than removed: the panel keeps its three children in order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… moved TWO-25503. The deferred close read only "is focus still inside the panel", which is false the moment a mode change hides the row holding the caret — sole-trader mode does exactly that, and the popover shut behind the signup popup. Focus landing on the body is not the buyer leaving. That also covers the scrollbar drag the pointer guard was built for, so the guard goes with it: forty lines whose only remaining job was putting the caret back afterwards, through an unconditional focus() on a row that is display:none in two of the three capture modes. The sole-trader case in company-search-return-to-search.test.js asserted synchronously and so passed against a deferred close either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host that re-renders by morphing its server markup over the live DOM deletes the wrapper the panel builds and keeps the field, so isBound() answered true for a control that was no longer on the page and nothing could tell that it had to rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel is as wide as the company field, which is half a column on a three-column checkout. Rows are ellipsised on one line rather than wrapping onto four with a horizontal scrollbar, the chips take PrestaShop's sizing so two share a row, and the matched substring stops rendering as a yellow highlighter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…its tick Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host that discards the wrapper but keeps the field re-binds through _attach's rebinding path, which skips _releaseWrap(), so the listeners on the panel that went with the wrapper were never taken back — six per re-render, each pinning the detached subtree it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 4. Clearing the held answer lived in forgetAdoptions(), which leaveSoleTraderMode() also calls, and nothing on that route re-runs the lookup — so a buyer who clicked before the answer landed, took the popup, then left the mode had the answer discarded and never looked up again for the life of the page. Leaving the mode says nothing about who the session identifies, so the clear now belongs only to the country change, whose registry the record genuinely no longer matches. Restores the same-tick popup assertion and drops five awaits that had been added to the launch path, which between them left nothing failing if a hop were reintroduced between the click and window.open(). The held answer is never revalidated; the docblock now says so and why that is accepted rather than leaving it to be inferred. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 5. An adoption clears the held answer but left the lookup memoised, and leaving sole-trader mode is the only point on that route that can arm another — so a silent adoption followed by Registered company or Manual entry left the chip falling to the popup with an empty company field, and the buyer was never looked up again for the life of the page. Round 4 fixed this for an answer still held; this covers the spent one. An answer still held is deliberately left alone rather than refreshed: the session stands behind it either way, and re-running only risks the next click landing ahead of the answer. Adds the two orderings the previous round's block did not pin — an answer arriving after the buyer left the mode, and leaving the mode after an adoption — the second of which is why this shipped green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 6, which found no behavioural defect. The docblock forbade the call leaving sole-trader mode now makes, so the function's stated contract contradicted its own caller and pointed the next reader at the shape the previous round fixed. It now names both callers and what survives each. Drops the availability half of the re-arm gate: leaving the mode is only reachable with the chip clickable, which needs availability true, and the one route that turns it false clears the identity first, so the gate was never evaluated with it false. Also drops an overclaim in ensureTokens' docblock — it neither runs nor awaits the lookup — pins the lookup count on the re-entry cases, and reverts three test signatures left async with nothing to await. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 7. Retiring the held answer sat inside onCountryChanged's first-resolution guard, but the mount observer can resolve availability — and so hold an answer — while `_lastCountry` is still empty, which is the sidebar boot that observer exists for. A real country change then took the first-resolution branch, kept the answer, found the lookup already memoised, and adopted a record belonging to the registry the buyer had just left. It is retired on every change now; the identity guard around it is untouched, since that one is about not discarding a company the same address already carried. Restores the availability half of the re-arm gate, removed last round as unreachable. It is reachable: refreshSoleTraderAvailability sets availability false and only then retires the mode, so leaving it would arm a lookup for a country whose registry offers no sole trader and hold an answer no chip can reach. Both halves now have a test behind them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 8, and the root cause the previous four rounds were each treating a symptom of. Retiring the held answer only ever cleared what had already arrived. The lookup's own continuation wrote unconditionally, so a request still out survived every retirement: a boot lookup landing after the buyer had signed up as a different trader put the replaced identity back, and the next chip click adopted it silently. The write is now generation-guarded — retiring the answer or adopting one bumps the generation, and a continuation whose generation has moved on writes nothing. That makes the invalidation call sites a safety net rather than the mechanism, which is why four rounds of adjusting them kept finding another ordering. Corrects two tests whose premise this inverts: an answer in flight when the buyer leaves the mode is now retired rather than adopted on re-entry, and what they get is the answer armed after they left. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 9, which found no behavioural defect. The DOM-level case for "Select a different sole trader" claimed to prove the link ignores a held answer, but the adoption that renders the link spends the answer, so there was none in front of it and routing the link through one left the test green. It now holds one deliberately, which is the only way to construct that state, and fails if the link consults it. Also drops an overstatement about a nameless record leaving no route forward: the link is offered on adoption alone, so one exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 10. The previous commit reached into `_autofillBuyer` and claimed it was the only way to put an answer in front of the link. Retiring the answer and re-arming the lookup does it through the flow's own API, and keeping the lookup count alongside the identity assertion means the one test now fails both if the link adopts the answer and if it goes asking for one — where each version so far caught only one of the two. Also restores a fixture's company types to the branch point: it was widened while the click path was asynchronous and the awaits that needed it are gone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arry them Review round 11. Two header claims were over-broad quantifiers: not every case pins the lookup count, and the silent-adoption cases after the first read one written field each rather than re-asserting a zero popup count. Both now say which cases carry which guard, so the notes can be trusted as a map of where a mutation would be caught. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review round 12. The clause claimed each later silent-adoption case reads one written field; they read a written surface each, of different kinds. The enumeration carried nothing the cases do not say themselves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Consult autofill before the sole-trader signup popup
findVerifiedResidualTaxRate() is documented to reconcile an order, an invoice or a credit memo, but returned early for anything that was not an order, so no invoice or credit-memo residual could ever reconcile. It now resolves either to its own order: the residual on both is a share of the same order-level charge at the same rate. It reads those rates from the order's applied_taxes extension attribute or, when that is empty, from the persisted tax rows. The attribute only exists on an order built by quote conversion or loaded through the repository; the admin invoice and credit-memo controllers use OrderFactory, so it is empty exactly where the merchant works and a TAXED charge could never reconcile there — only a zero-tax one. This is the same two-source read the shipping rate already does. Also drops a stale reference to a fee provider that does not exist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reconciling the refund payload is not enough on its own. A charge that reaches the grand total through a totals collector rather than a quote item belongs to no item and no shipping, so core's own collectors never carry it onto a credit memo: the refund totals omit it and there is nothing for the merchant to refund. Model\Total\Creditmemo\OtherCharges puts the order's residual back, prorated by refunded subtotal share and capped by what earlier memos took. Block\Sales\Total\OtherCharges and Model\Pdf\Total\OtherCharges render it as "Other charges" on every credit-memo surface — admin, buyer account, guest, both print views, the email and the PDF. Both take the residual from Service\Order\OtherChargesResolver, which runs the composition path's own getOtherChargesLineItem() over a new getKnownLineAmountsOrder() plus any registered provider's fee lines. Nothing here names or detects an extension: the residual is defined by what the grand total exceeds. It is gated on the order being a Two order, resolved by payment-method instance so brand overlays count, since a store-wide fee extension applies to every order. Every ceiling — the proration share, the tax allowance and the base grand-total ceiling validateForRefund() enforces — is applied by solving the NET at the charge's own rate, so the declared rate survives each clamp. A charge declared at any other rate is refused by ComposeRefund while the grand total still carries the money. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three company-capture-component strings had no i18n row in nb_NO/nl_NL/sv_SE: "Select a different sole trader", "Company search is unavailable right now. Please try again shortly.", and "Could not complete sole trader signup. Please try again." Machine-translated pending a human pass.
fix: add missing translations for sole-trader and search-error strings
fix: make an unitemized fee reconcilable and refundable on a credit memo
test: pin custom-header coverage on every outbound API call
The buyer identity comes from the session cookie, not the registry the checkout form currently targets, so it has nothing to do with which country is selected. Round 7 of PR #425 (18582f7) retired the held answer on every country change to close a real bug — a stale answer from a country the buyer had left getting adopted — but that fix assumed the answer was country-scoped, which it never was. Once the answer is held regardless of country, that scenario is no longer a bug: whichever answer is held is still a correct answer for the buyer's own session. leaveSoleTraderMode's own re-arm gate (round 7's other hunk, the `_identity.soleTraderAvailable()` check) is untouched — it decides whether to bother arming a fresh lookup at all when the current country's registry offers no sole trader, which is orthogonal to whether an already-held answer survives a country change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dependent fix(TWO-40): stop retiring the held buyer answer on country change
A fee applied at address/total level records its rate only in the order-level tax rows, which nothing in the plugin read, so a taxed residual it produced could not be itemized and the fee could not be refunded. All three of Magento's applied-tax sources are now merged rather than taken first-non-empty, so an order-level rate is still found when the item-level rows are populated but do not reconcile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The constructor assigned the new factory twice. The docblock enumerated the three sources the code already shows; it now carries only the non-obvious parts: where a total-collector fee.s rate lands, and why the sources are merged rather than first-non-empty.
The doc still described a two-source, first-non-empty read. It now describes the three sources and why all are read. The code docblock keeps the OrderFactory reason the admin screens need a persisted source, and the test asserts the item-level read is keyed by order id.
ABN-492: read order-level tax rows when resolving a residual's rate
… gate Delegated-authority mint and buyer autofill were re-derived from countryCode()'s per-country registry answer, so a change to the currently-selected billing/shipping country could suppress or re-arm the mint mid-checkout. Gate is now the merchant's own supported_buyer_countries restriction intersected with the registry's sole-trader-supported countries, resolved once at start() and never re-run on a country change. soleTraderAvailable() (the sole-trader CHIP's visibility) stays country-dependent — only the mint/autofill trigger moves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bifrost's sole-trader registry coverage is global, not merchant-specific, so gating the mint on a merchant buyer-country field added no value. Reverts the RecordProvider/soleTraderCountryRestriction plumbing and replaces the three-state gate with an unconditional prefetchBuyer() call from start() — mint and buyer autofill fire as soon as checkout is reached, full stop. The country-decoupling fix (never re-derived from countryCode()) stays. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TWO-25547/fix: mint sole-trader tokens off the merchant's own country gate
Bifrost's new /companies/v2/supported-countries route lets the merchant plugin disable (not hide) the search field on a country it cannot serve, instead of letting the buyer search and fail. Fails open on any error or on a host that has not wired the new option up.
…imit test Every anonymous webapi route needs a rate-limit ceiling, and this provider is the pin against etc/webapi.xml catching a route missing one.
Manual entry hands the field over as a plain typeable input that never reaches the registry search; setDisabled() was writing the native disabled flag onto it too on an unsupported country, silently blocking a mode that was never going to search in the first place.
Grey out company search on a country the registry does not cover
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated rolling sync PR opened by
.github/workflows/auto-pr.yml.Merges everything currently on
stagingintomain. Auto-updates as new commits land onstaging. Close manually if you need to skip a sync window.