Skip to content

chore: sync staging → main - #341

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

chore: sync staging → main#341
two-inc[bot] wants to merge 656 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 dgjlindsay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do not release until Doug says so

dgjlindsay and others added 29 commits August 23, 2026 08:58
ComposeOrder hands this provider $payment->getOrder() - the in-memory
object from the current placement transaction, never loaded through
OrderRepositoryInterface. Amasty's own OrderRepository plugin only
populates amextrafee_fee_amount/amextrafee_tax_amount on afterGet/
afterGetList, so it never ran on that object and the provider silently
returned no line - reproduced live on staging, still rejected by
checkout-api with the same net/tax mismatch this was meant to fix
(Linear ABN-489). Reloading by entity ID forces Amasty's plugin to run;
the order row is already persisted by the time ComposeOrder executes.
PHP requires an implementing class's parameter names to match the
interface exactly, not just types - $entity vs the interface's
$order was a fatal error at class-definition time.
A hand-written anonymous class implementing OrderRepositoryInterface
has to match the interface's parameter types/names exactly, which
differ across the Magento versions CI tests against and caused a
fatal error either way. createMock() generates a compatible stub via
reflection against whatever version is actually loaded.
This repo's unit test bootstrap stubs Magento\Sales\Model\Order as a
plain data bag (Test/Stubs/SalesModels.php) that does not implement
OrderInterface. repositoryReturning() was typed against the interface
while every call site passes the stub Order model - a TypeError on
every test that reached it.
The method already duck-types every caller via method_exists() rather
than depending on OrderExtensionInterface's real (codegen'd) shape, so
typing the return as that interface was stricter than the code
actually needs - and broke the tests, whose fake extension-attributes
objects have no reason to implement it either.
Reload order via repository so Amasty's extension attributes actually populate
Verified live on staging that PR #352's reload-via-repository fix
still didn't work - same net/tax mismatch, fresh trace ID. Root cause:
Magento\Sales\Model\Service\OrderService::place() calls $order->place()
(which runs payment authorize(), i.e. ComposeOrder) BEFORE
$orderRepository->save($order). The order ComposeOrder hands this
provider has no entity_id yet, so "reload it via the repository" always
returns nothing - there is no row to reload for anyone, Amasty's own
OrderRepository plugin included.

The order does have a quote_id, because the quote has already been
converted. Amasty's own OrderRepository plugin reads its quote-fee
table by quote_id for exactly this reason; this provider now does the
same lookup, isolated in AmastyExtraFeeQuoteReader since
amasty/module-extra-fee isn't a build dependency of this repo and the
resource-model class can't be type-hinted without breaking DI compile
for merchants who never installed it.

Linear ABN-489.
Read Amasty's fee by quote_id instead of reloading the order
Order::__construct() declared feeLineProviderPool as ?FeeLineProviderPool
= null. Magento's object manager auto-wiring does not construct an object
for a nullable-defaulted constructor parameter unless etc/di.xml names it
explicitly for the concrete class, which it never did for
ComposeOrder/ComposeCapture/ComposeRefund — so every real request built an
empty pool via the null-coalescing fallback, and every registered
FeeLineProviderInterface (including AmastyExtraFee) silently never ran.
Making the dependency required lets auto-wiring inject the real,
di.xml-configured pool.
fix(ABN-489): stop the Amasty fee provider pool being silently skipped
Replaces the standalone "<brand> Details" order-id row with a row in
Magento's standard Payment Information block, labelled "<brand> order
id" via BrandRegistryInterface. Every brand (base Two, ABN overlay,
future overlays) gets its own label for free through the existing
brand-resolution mechanism — no per-brand override needed. Invoice/
Credit Note download links stay in the existing section, which is
otherwise unaffected.

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

Magento copies the quote address's applied_taxes total data onto the
order's own extension attributes during quote-to-order conversion,
before Order::place() ever runs. Any total-collector extension that
registers its tax with Magento's tax engine (Amasty's Extra Fee
included) shows up there, so Order::findVerifiedResidualTaxRate() can
verify a taxed residual against a rate Magento itself already applied,
with no vendor-specific code and no entity_id needed.

This supersedes the AmastyExtraFee/AmastyExtraFeeQuoteReader provider
added in PR #353/#354 for reconciling Amasty's fee specifically — the
generic mechanism covers it (and any other extension that integrates
with Magento's tax engine the same way) without a per-vendor adapter.
FeeLineProviderInterface remains for the narrower case of an extension
that computes its own tax outside Magento's tax engine.
…liation

fix(ABN-489): reconcile third-party fee tax generically via applied_taxes
findVerifiedResidualTaxRate() only handled applied_taxes entries as
plain arrays. Confirmed live that this is only true briefly, right
after ToOrderConverter::afterConvert() sets it: QuoteManagement::
submitQuote() immediately re-merges that converted order into a fresh
one via DataObjectHelper::mergeDataObjects(), which rehydrates the
array into Magento\Tax\Model\Sales\Order\Tax objects -- the shape
ComposeOrder's $entity actually carries. The array-only check silently
returned null for every real order, so the mechanism never fired live
despite passing CI and unit tests (which only exercised the array
shape). Handles both shapes now, with tests pinning each.
…shape

fix(ABN-489): handle both applied_taxes shapes in tax reconciliation
FeeLineProviderPool's constructor docblock still described the inline
empty-pool default in Order.php that PR #354 removed. Also documents
that findVerifiedResidualTaxRate() matches a residual by amount, not
identity: the first applied rate whose implied tax reconciles the
residual wins, with no proof that rate specifically produced this
residual. Narrow in practice (would need two applied rates to coincide
on the same net/tax split), and the emitted gross/net/tax amounts stay
correct regardless -- only the reported tax_rate/tax_class_name label
could be wrong. Found by an independent adversarial review of #354/#356/#357.
docs(ABN-489): fix stale docblock and note rate-matching ambiguity
Aligns the Magento admin label with the cross-plugin name. The config
path stays `enable_tax_subtotals` — renaming it would need a data
migration for no behavioural gain, and WooCommerce/PrestaShop keep the
same key.

Adds the unit coverage the aggregation never had: the config gate, the
per-rate grouping, and the 6-dp rate precision Two's gross_amount
reconciliation depends on.
Four financial-correctness gaps against the WooCommerce/PrestaShop
behaviour:

- A payment term the buyer selected but the merchant no longer offers was
  silently swapped for the default at final placement. It now fails the
  same way the chip-click endpoint already did, through one shared
  availability check.
- The shipping line's tax_rate was computed as tax/net. It is now the rate
  the store's tax engine declared for that line, matching product lines.
- Every composed line's declared tax must follow from its own declared rate
  and net within 0.02 currency units, or the checkout is declined with a
  generic notice.
- A new "Default Shipping Tax Rate" setting covers a taxed shipping line
  the tax engine declares no rate for. Unset by default, and unset means
  refuse the order rather than assume a rate.
9.x is EOL. 10.5 is the latest line still compatible with the pinned
php:8.1-cli image (11.x requires PHP 8.2+). All 766 tests pass; 11
non-fatal deprecation notices surfaced, no failures.
…5503)

Batch 3 of the cross-platform parity work — admin and config-surface items.

- Sole-trader autofill is reached ONLY by clicking the chip. The
  email-driven prefetch and its auto-adopt are gone; the chip click mints
  tokens, resolves the buyer and either adopts them or opens signup.
- Address autofill is gated on "Autofill company address" alone, never on
  where the company-search control is mounted. A merchant running search
  in the payment tile previously got no autofill at all.
- The API key field verifies as it is typed (debounced) and, on save, a
  key the API definitively rejects aborts the save instead of replacing a
  working one.
- The fulfilment-status mapping says out loud that an empty mapping means
  Two is never notified of fulfilment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Batch 4 of the WooCommerce/PrestaShop parity sweep — checkout and admin UX.

- The selected payment term is cross-checked against the live term set
  immediately before submit, instead of posting a term the API will refuse.
- Four order-lifecycle events (created/completed/cancelled/refunded) are now
  dispatched for downstream integrations; the plugin previously only
  subscribed to core events.
- A missing FX rate for the surcharge withdraws the payment method and
  refuses placement, rather than throwing out of the totals collector and
  making checkout unrecoverable.
- Enabling percentage mode scans stored limits for terms the grid does not
  render, so a stale zero cap surfaces then rather than silently clamping the
  fee later.
- The minimum-order help text gets its own wording for a platform floor that
  cannot be projected into the store base currency.
- Company-search "unavailable" copy matches the sibling plugins.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Aligns the payment tile with WooCommerce and PrestaShop: one control with
Registered Organisation / Sole Trader / Enter manually as peer chips,
replacing a two-item tab bar plus a separately worded "My company is not on
the list" button that made manual entry reachable only by opening the search
picker and rejecting its results.

Presentation and routing only — each mode's own behaviour is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Encrypted::beforeSave() (the parent) has no return statement, so
`return parent::beforeSave();` used a void result. Split into a
statement call + bare return, matching the parent's own contract.
At order composition the order is not yet persisted — ComposeOrder runs
from Two::authorize() inside Order::place(), before the repository save —
so it has no entity id and no sales_order_tax_item rows. Reading the rate
through OrderTaxManagementInterface therefore always came back empty at
checkout, and every taxed-shipping order either fell back to the static
default rate or was refused outright.

Read the order's own item_applied_taxes extension attribute first, which
quote->order conversion populates in memory, and keep the persisted read
for the post-save consumers (capture, refund).
…WO-25503)

A flat 0.02 reconciliation tolerance assumes one rounding, on the line, and
tax computed on the same base as net_amount. Two supported Magento
configurations break both assumptions: the "Unit Price" algorithm rounds per
unit and sums, so the residual grows with quantity, and "Before Discount" tax
calculation taxes the undiscounted base. Both were common enough to decline
ordinary checkouts. Scale the tolerance with quantity and accept
net + discount as an alternative base.

Separately, the API key backend model verified a candidate against the
COMMITTED mode, so switching sandbox -> production and pasting the production
key in one save checked the new key against the old environment, took a 401
and failed the whole section save. Verify against the mode submitted in the
same request.
…e (TWO-25503)

Service\Order::$feeLineProviderPool and FeeLineProviderPool::$logRepository
are optional constructor arguments, which the object manager leaves at null,
so the registered Amasty Extra Fee provider never fired and the pool's own
rejections were never logged. Named in etc/di.xml, verified with
dev:di:info.

The order payload's payment term comes from additionalData while the
surcharge is priced off the session term /select-term writes, and nothing
checked the two agreed — a /select-term call that failed mid-flow could
place an order on one term carrying the other's fee. Cross-check them at
composition, and refuse a non-numeric term rather than letting it cast to
the default.

A second sole-trader chip click landing while the first lookup was still in
flight hit the dedupe key and resumed immediately on a lookup that had
minted nothing: no adoption, no popup, and no fallback link either. Return
the outstanding chain instead.
LifecycleEventDispatcher deduped on the entity id, which is null during
placement, so under multishipping every order after the first was deduped
away and two_payment_order_created never fired for them. Fall back to the
increment id.

Also: guard the fulfilled-order id read like its sibling key; re-key the
nb/nl/sv rows for the two reworded admin comments and add the API-key
spinner string; and correct comments that no longer describe what the code
does — the surcharge FX gate fails open and relies on the placement
backstop, openDropdown is threaded through rather than set here, and the
totals collector's throw came out of the calculator's own conversion.
…503)

Final adversarial review round found:
- ApiKeyCheck::getScope()/getScopeId() read the element's own form (the
  fieldset, which carries no scope) instead of the config Form block
  the renderer is actually bound to at render time — the live check
  silently verified against the default scope's environment regardless
  of which store view was open. Same false-confidence class the save
  guard was fixed for.
- Reconciliation log message reported the "before discount" residual
  against the wrong base.
- lookupSoleTrader() could hand an empty-email call an unrelated
  in-flight chain's promise.
dgjlindsay and others added 30 commits September 1, 2026 14:57
feat(TWO-25554): give the billing panel its own company number, sole-trader link and country ordering
The billing panel's capture outlived billing being a distinct address, so
every quote billing-address notification routed a returning buyer's saved
company into a panel the resolver does not read. Core's own checkbox is
what retires that capture.

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

The tile kept a visible, required company field on the one event that moves
the mount, because the renderer's revision counter was bumped only by
callers that remembered to. The harness's Knockout double now tracks
dependencies, so a binding's caching — and a missing notification — is
assertable at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A rebuilt address fieldset takes every autofill marker with it, so a later
country switch retracted nothing and left the previous country's address in
the buyer's form. Keyed on the form element and read only for the form the
calling panel passed in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rm there is

A checkout supplying its own address markup left the pick and the sole-trader
write-back with nowhere to land, silently. The destination is that single
form, established before the write and refused — audibly — when it cannot be.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A panel whose mount went away kept the field's key handlers, its popover and
its combobox attributes, so the control the buyer left stayed live under the
one they were using.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A scope-less caller was silently given the bind token, which a re-render
replaces — so the 429 backoff it earned expired with it. The panel now names
itself when its host does not, and the model refuses a search with neither.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ghten two pins

Jest ignores a second argument to `toBe()`, so nine rows carried a
description nothing would ever print. The address step's own pin now checks
an allowlist of the members it may reach for rather than the names of the
writers already gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix(TWO-25554): round-5 defects in the billing/shipping panel split
The ancestor walk had no ceiling, so a panel whose own form carries no
company_id read the other panel's — a cross-panel DOM read the panel split
exists to prevent. The walk now cannot leave the form the field belongs to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The number label and sole-trader link sat inside `.two-company-field-wrap`,
which the popover is positioned against, so chrome in its flow pushed the
open dropdown off the field. They are siblings after the wrapper, and a test
pins that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The injection matched only core's own billing-address component, so a
checkout substituting its own — or wrapping core's in a container — got no
company-number field and the feature silently did nothing there. Core's
`billingAddress` scope naming is accepted as well.

Also assert the payment subtree in the neither-container-present row, which
previously asserted nothing about its own subject.

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

fix(TWO-25554): scope each capture panel's DOM reads and chrome to its own form
Checkout Fields/Order Management/Diagnostics tab headings and every field
under Order Management/Diagnostics missing tax-class defaults, debug/SSL/
admin-controls and health-checklist labels had no CSV row in any locale,
matching the same gap in woocommerce-plugin (TWO-i18n).

Order Management/Diagnostics/Checkout Fields headings are ported from
prestashop-plugin's existing human translations; Enable debug logging and
Disable SSL verification are ported from woocommerce-plugin's. The rest is
machine-translated and unreviewed.
i18n: translate Checkout Fields/Order Management/Diagnostics labels
TWO-25498 re-introduced Title Case on captions that TWO-25326 had
sentence-cased, which also orphaned the sentence-case i18n rows still
carrying their human translations. Renames the catalogue keys with the
captions so those translations follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
45 rows per locale, mostly recovered from the sibling plugins' existing
human translations. Also corrects nb/sv "Health", which rendered as
bodily health, and a "brower" typo in the firewall-token help.

The new gate asserts every admin caption has a non-empty row in all
three locales and that no caption is Title Case — this is the second
round of the same defect, so it needs a check rather than another sweep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Checklist values, grid headers, dropdown options and validation
refusals reach the screen from PHP rather than a form definition, so
the XML-only gate would not have caught the round this PR fixes.
Concatenated msgids are joined the way Magento looks them up.

Also realigns prose that quotes a live label with that label's casing.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both assertions pinned the strings this PR corrects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: sentence-case admin captions and close the admin translation gaps
Base-plugin unit tests used a specific partner overlay's product name
as a payment-title fixture. Replaced with a generic placeholder — the
tests only verify title rendering, not any real brand string.
…-fixtures

Remove partner brand reference from test fixtures
Every buyer- and merchant-facing surcharge amount was rendered net
regardless of configuration, so a store set to show gross prices showed
a net surcharge beside gross everything else.

Net and tax were already snapshotted at each entity's creation time, so
this is display-only: Service\Order\SurchargeDisplay reads the store's
"Display Prices" switch for the cart and sales tiers and each renderer
picks accordingly. "Both" emits the paired excl/incl rows core uses for
Subtotal and Shipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per-term failure logging moved into TermSurchargePreview, and pin the
predicate core's TotalsReader uses to split a multi-row fetch().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: show the surcharge net or gross per the store's tax display setting
displayedTermSurcharges() picked gross whenever mode was not 'excl',
so Both mode (which shows both rows in the order-summary total) fed
gross into the single-value chip too. Only 'incl' should select
gross; 'excl' and 'both' now both resolve to net.
Fix: chip shows net in Both surcharge display mode
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.

2 participants