Skip to content

TWO-24739: Parity stack → staging (brand config, terms, offset, sole trader, rounding) + review fixes#329

Merged
dgjlindsay merged 31 commits into
stagingfrom
staging-integration
Jun 23, 2026
Merged

TWO-24739: Parity stack → staging (brand config, terms, offset, sole trader, rounding) + review fixes#329
dgjlindsay merged 31 commits into
stagingfrom
staging-integration

Conversation

@dgjlindsay

Copy link
Copy Markdown

Integrates the full WooCommerce parity stack onto staging for deploy + Chrome sanity test (per Doug). This is the staging integration; the individual PRs (#319#328) remain open against main for later promotion.

Contents (merged bottom-up):

Pre-merge adversarial review fixes (commit on top, TWO-24739/fix):

  • Nonce-guard all four new wc-ajax endpoints; server-side is_available(country) gate on token minting (gated on the posted country to avoid a debounce-lag false-block).
  • Reject non-2xx fee quotes; cap quote/registry/token calls below the 30s default.
  • Sole-trader registry errors no longer poison the per-session cache.
  • Enforce rounding-step option-list membership on save (+ unit test).
  • UTF-8-safe base64 prefill; esc_url; i18n text-domain fixes; drop stale country copy.

Tests green on PHP 7.4 + 8.2; php-lint clean. Two adversarial review rounds → clean.

(opened by Claude)

dgjlindsay and others added 30 commits June 10, 2026 22:05
…_update

Removes the four order-sync REST endpoints (twoinc_list_out_of_sync_order_ids,
twoinc_sync_order_state, twoinc_get_plugin_configs, twoinc_get_order_info),
the unauthenticated twoinc_plugin_status_checking endpoint (exposed plugin
version publicly), their auth helper auth_rest_request, and the
before_order_update save_post hook long marked @todo for removal.

The backend caller (sync_woocom_order_status batch job) is removed in
checkout-api#12260; its CronJob triggers in two-app-values#1674. The
status_to_states map was only used by the removed handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its only caller was the get_order_info sync endpoint removed in the
previous commit (adversarial review finding).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract brand identity from hardcoded class constants into a brand
config file so partner editions (ABN AMRO etc.) can overlay the base
plugin instead of forking it.

- brands/two.php: default brand config (provider, product name, URLs,
  gateway id, logo). Keys are added with their consumers.
- WC_Twoinc_Brand: loader. Overlay plugins supply their own brand file
  via the twoinc_brand_file filter (merged over Two defaults);
  TWO_BRAND_CODE env var selects an in-plugin brand file for local
  dev, confined to brands/ via basename().
- Constants (PROVIDER, PRODUCT_NAME, ...) stay for backward
  compatibility; all internal reads go through the brand config.
  Gateway id and meta keys unchanged.
- Extension hooks at every ABN divergence point:
  - twoinc_checkout_fields (checkout form fields, priority 25 after
    base mutations)
  - twoinc_order_payload (create-order body; covers extra fields and
    country-specific tax_subtotals)
  - twoinc_confirmation_url (overlay confirmation route without
    duplicating process_payment)
  - twoinc_payment_terms_line (per-brand terms/surcharge line items,
    payload draft passed for context)
  Legacy two_order_create filter kept.
- get_locale() audit (ticket scope): behaviour is correct — the full
  locale is what production sends today for the invoice PDF lang param
  and Accept-Language — the docblock claiming "short locale" was
  wrong; fixed.
- tests/unit: new dependency-free harness (TinyAssert + minimal WP
  stubs) covering loader defaults, overlay merge, env-var confinement,
  constants/config parity, gateway-id pin, and all four hooks; wired
  into CI via unit-tests.yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings (adversarial round 1):

- Route every hardcoded 'woocommerce-gateway-tillit' literal through
  $this->id / the brand config (settings option keys, api_key field
  name, deactivation cleanup, payment-method identity checks, section
  links). The brand layer advertised gateway_id as overridable while
  seven sites ignored it — an overlay changing the id would have
  broken settings save, order matching and fulfilment webhooks.
- Migrate the missed $this::MERCHANT_SIGNUP_URL read (the one site
  the mechanical sweep skipped) so an overlay's signup URL applies.
- Apply twoinc_payment_terms_line and twoinc_order_payload in
  compose_twoinc_edit_order too: a brand line item added at creation
  was silently dropped from every edit PUT body, which would also
  desync the change-detection hash both composers feed.
- Document the hook contracts where the overlay author will look:
  WP-style filter docblocks at each apply_filters (args, full-array
  contract for the line-items filter, firing order, draft-vs-final
  body distinction) and a real compose_twoinc_order docblock.
- WC_Twoinc_Brand: state the timing contract (overlay filter must
  register by plugins_loaded); env override falls through to the
  filter when its candidate file is missing so a stale dev env var
  cannot silently disable an installed overlay; reset() marked
  @internal test-only.
- Constants block documents the BC-freeze; repeated product-name
  reads hoisted to locals at the two noisy sites; brands/two.php
  policy wording now covers the BC-mirror keys.
- Tests: edit-path hook symmetry, legacy two_order_create ordering
  vs twoinc_order_payload, non-array brand file degradation. CI
  matrix now runs PHP 7.4 + 8.2 (12/12 green on both).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fork-divergence inventory found five things the brand layer (C1)
cannot yet express that the ABN overlay needs. Add them to the base so
the overlay stays thin:

- Brand meta prefix: order meta (_twoinc_order_reference, _twoinc_
  merchant_id, _twoinc_order_state, _twoinc_req_body_hash,
  twoinc_order_id), user meta (twoinc_company_id etc.), confirmation
  request params and the confirmation nonce action all derive from the
  brand's meta_prefix via WC_Twoinc_Brand::meta_key()/prefixed_name().
  Live ABN stores hold data under _abn_* — the prefix is load-bearing
  for existing orders, so the overlay sets meta_prefix=abn and inherits
  its installed base's data. Two default 'twoinc': zero behaviour
  change. The _tillit_* legacy fallback reads stay literal (Two-only
  history). HTML form input names and $_POST keys stay literal
  (transient, not data-bearing).
- Brand availability gate: config-driven woocommerce_available_payment_
  gateways filter (front-end only, inclusive minimum, currency +
  billing-country match) mirroring the ABN fork's EUR 250/EUR/NL gate
  semantics. Two brand sets no gate.
- twoinc_payment_validation_error filter: a brand overlay vetoes
  process_payment with a buyer-facing error (ABN's required terms
  checkbox) without overriding the method.
- twoinc_payment_description filter: overlays replace the payment-box
  copy wholesale (ABN ships its own bullet list).
- supported_buyer_countries brand key feeding the checkout JS config
  (ABN is NL-only).

Tests: prefix derivation under an overlay brand, confirmation-URL
params following the prefix, gate absent/unmet/exact-minimum, and the
validation veto. 18/18 green on PHP 7.4 + 8.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings (adversarial round 1):

- Log (WC logger, once per request) when the availability gate removes
  the gateway, with the failing basket and the gate criteria — removing
  a payment method is invisible to the merchant and a config typo would
  otherwise read as the gateway vanishing (same lesson as the Magento
  MinimumOrderGate review).
- Guard the gate against a truthy-but-partial availability_gate shape:
  missing criteria means config bug, not a basket decision — leave the
  gateway available rather than judging with absent keys.
- Document the registration deadline on twoinc_payment_description
  (applied once at gateway construction; register by plugins_loaded,
  same contract as twoinc_brand_file).
- Test the confirmation READ side: is_confirmation_page() must detect
  params under the brand's prefix and ignore another brand's — the
  read side is the half that strands in-flight orders if it drifts
  from the write side.

Reviewer-verified (no change needed): fork-created ABN orders fulfil
end-to-end under base+overlay; the prefix sweep is complete repo-wide;
the Two-brand path is byte-identical; rollback is safe. Known C2 note:
an overlay terms-line filter that doesn't reproduce the fork body
byte-for-byte costs one benign self-healing re-sync PUT per
pre-cutover order on first edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… about-block seam

Two more divergences the overlay inventory surfaced once the gateway
id became brand-driven:

- twoinc.js and admin.js hardcoded 'woocommerce-gateway-tillit' in 17
  selectors (payment-method radio, payment box, settings field ids) —
  under an overlay's gateway id none of the checkout or settings JS
  would bind. Both now read the id from their localized config objects
  (window.twoinc.gateway_id / twoinc_admin.gateway_id).
- twoinc_about_html filter: the about block inside the payment-box
  subtitle is the piece of the description the ABN edition actually
  replaces (its own bullet list, no link). Narrower seam than
  rebuilding the whole description; twoinc_payment_description also
  now passes the gateway instance for get_option reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The overlay review's deleted-functionality audit found one regression:
init_form_fields hardcoded the Two title default, so a FRESH install of
a brand overlay would show 'Business invoice - %s days' instead of the
brand's phrasing (the ABN fork defaulted to 'Achteraf betalen - Bestel
op factuur'). Existing stores are unaffected (saved titles win). The
default now comes from the title_default brand key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compose stack (wordpress + wpcli provisioner + mariadb) already
existed — the ticket's 'no structured dev environment' premise
predates it. What was actually missing:

- .env.example documenting the TWO_* dev variables; docker compose
  reads .env natively and the WOOCOM_* provisioning knobs are now
  parameterised with their previous values as defaults.
- TWO_BRAND_CODE passthrough to the wordpress and wpcli containers so
  the brand layer's dev override (TWO-24744) works in the harness.
- dev/configure: idempotent wp-cli script applying TWO_API_KEY /
  TWO_API_BASE_URL onto the gateway settings; wired into first
  provision and exposed as 'make configure'.
- wpcli.sh derives the settings option key from the gateway id instead
  of hardcoding it (a brand-override dev run would have written the
  wrong option row).
- Makefile dev targets: install/run/configure/logs/stop/clean/
  test-unit/test/format. test-unit runs the tests/unit harness from
  TWO-24744 in a php:8.2 container (the ticket's 'establish PHPUnit
  baseline' is superseded by that suite).
- README quick-start updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings (single-reviewer round):

- BLOCKER: parameterising WOOCOM_PLUGIN_CONFIG_JSON defaulted CI's
  compose to the local-dev config — the gateway under e2e test would
  seed http://portal.localhost/api with the dummy key while the
  Playwright harness polls staging. The workflow now writes a .env
  pinning the rendered staging config before compose up.
- The wpcli option-key comment overstated brand-following: it's a
  static fallback matching the two brand; overlay runs need
  TWO_GATEWAY_ID set explicitly.
- Prettier on README.

Reviewer empirically verified the load-bearing claims in the pinned
images: mod_php getenv() sees container env (no PassEnv needed), and
wp-cli 2.11 supports 'wp option patch'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'get' is not a patch action (insert/update/delete only) — the debug
echo at the end of dev/configure errored and set -e killed the wpcli
bootstrap, which CI's e2e job caught. The getter is 'wp option pluck',
now also guarded so a missing subkey cannot kill provisioning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combined-chain round 2 (security + post-fix + coherence + cross-plugin
panels):

- README e2e setup now pins the staging config before compose up — the
  harness PR changed the compose default to the local config and CI got
  the fix but the human instructions didn't (a developer following the
  README would have run staging-keyed tests against a local-config
  store).
- .env.example ships a placeholder API key instead of the real-looking
  committed sandbox key (rotation of the historical key is a separate
  team action — it remains in git history and docker/config/local.json).
- Brand-file loader: filter-supplied paths must be real .php files
  inside the plugins/mu-plugins tree (defence in depth on the require
  sink; co-resident plugins are trusted, this just keeps uploads and
  other writable paths out of reach).
- esc_url on the brand signup link, matching the icon's pattern.
- brands/two.php: merchant_signup_url renamed to the cross-plugin
  canonical sign_up_url (recorded convention; renamed now while the
  file is fresh rather than deferring the agreed rename another cycle).
  The WC_Twoinc::MERCHANT_SIGNUP_URL constant keeps its BC spelling.
- brands/two.php docblock covers the test-asserted 'code' key; README
  compose spelling unified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The funding partner's server-side risk rule (risk-engine
funding_partner_rules) compares NET order value against the EUR 250
threshold; the gate compared the gross cart total, so a ~EUR 250-gross
basket (net ~EUR 207 at 21% VAT) showed the method and was then
declined at credit check. The gate now subtracts the cart tax. The
fork's gross compare was itself misaligned with the risk rule, so this
deliberately goes beyond fork parity (confirmed by Doug; risk rule is
the source of truth — its missing FX conversion is ABN-446). Gate log
line reports the net value; brands/two.php documents the semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…declines

Mirror of the Magento change: when a rejected order's net value is
below the brand's minimum or within 5% above it, append 'Note: <brand>
requires a minimum order value of <amount> <currency> excluding tax.'
to the decline message (checkout banner + merchant order note).
Same-currency baskets only — WooCommerce has no FX rate source, and
the gate already restricts the method to the gate currency anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror of the Magento changes (Doug's minimum-order review):

- availability_gate is a four-part config: amount + currency + basis
  (net|gross, explicit) + billing countries. The gate compares the
  basket on the declared basis.
- Decline hint keyed primarily on the API's machine-readable
  decline_reason (ORDER_BELOW_MIN_INVOICE_AMOUNT), strict
  below-minimum fallback — the 5% band is gone (Doug: accept the
  corner case). Wording: 'Minimum order value is <symbol><amount>
  excluding|including tax.' in the order currency.
- Merchant-set Minimum Order Value setting: dynamic description shows
  the platform minimum it must exceed; validate_..._field rejects
  values at or below the floor; the gate enforces platform AND
  merchant minima (merchant minimum rides the platform currency/basis
  when one exists, else store currency, gross; foreign-currency
  baskets fail open on the merchant's own bar — no FX source in WC).
- Fixed three sprintf formats I'd written as '%1\$s' in single-quoted
  strings — \$ is only an escape in double quotes, so the literal
  backslash reached sprintf (ValueError on PHP 8.2; 7.4 was silently
  lenient).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merchant-set Minimum Order Value is now interpreted in the STORE
currency (the saved woocommerce_currency option, not the active
multicurrency display currency) rather than the platform minimum's
currency:

- Settings description shows the platform floor with its currency
  symbol; when the store currency differs the floor is shown in its
  native currency only — WooCommerce has no FX source until TWO-24776,
  so no converted figure can honestly be displayed.
- Validation compares against the floor only when the store currency
  matches the platform minimum's; otherwise the numeric floor check is
  skipped on save and both minima are enforced independently at
  checkout (gate fails open on the merchant's own bar cross-currency,
  platform gate still applies).
- New unit test covers the cross-currency skip; bootstrap gains
  get_option/get_woocommerce_currency_symbol stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Buyer-selectable invoice terms at checkout (chip per term, Magento
parity) plus optional pass-through of the service fee as a buyer-facing
line, computed by POST /v1/pricing/order/fee - the plugin does no fee
arithmetic, it posts the offset settings as a buyer_fee_share block and
charges exactly the backend's answer (simple / partial / incremental
reference-terms variants).

Architecture per TWO-24751's checkout-type separation requirement: all
business logic (term resolution, selection validation, fee quoting,
cart fee, payload terms) lives in WC_Twoinc_Payment_Terms; twoinc.js
renders chips from the wc-ajax endpoints and posts selections back, so
the Gutenberg port (TWO-24767) only needs a new presentation layer.

Term availability resolves brand config (available_terms, new key:
14/30/60/90 for Two) intersected with the merchant's admin subset,
behind a single seam method - the planned backend term-availability
surface replaces only that method (see the TWO-24751 design-constraint
comment; no scattered term-grid reads).

The fee enters the cart as a native WC fee (taxed by the store's tax
handling, keeping net + tax = gross internally consistent - same
posture as Magento's store-configured surcharge tax rate) and flows
into the order payload through the existing fee line-item path. The
selected term + offered set ride the create-order payload as
terms/available_terms.

Failed fee quotes are fail-soft: chips render without labels, checkout
never blocks. Single-term sets render one non-interactive chip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace all references to a specific partner brand name with generic
"brand overlay" terminology in comments across the plugin.
WC_Twoinc_Payment_Terms::fetch_term_fee calls $gateway->make_request()
from outside the gateway class; with the method private that path fatals
the moment a fee quote runs against the real gateway (the unit-test stub
masked it by declaring its own public override).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Buyers in countries where Two supports sole traders (per the new
GET /registry/v1/supported-company-types/<ISO> endpoint, TWO-24753) can
switch the checkout to sole-trader mode, register or log in through
Two's hosted signup popup, and have their TWO:ST organization number
autofilled from GET /autofill/v1/buyer/current. The order payload is
unchanged — the backend derives the company type from the org-number
prefix (TWO-24749 spike), mirroring the Magento reference flow.

Two gates decide visibility, both server-side: the registry endpoint's
country answer (session-cached for its Cache-Control max-age, failing
soft to registered-business only) and a new enable_sole_trader admin
toggle (default off). Business logic lives in WC_Twoinc_Sole_Trader
with two wc-ajax endpoints; twoinc.js renders only (TWO-24767 posture).
Delegation + autofill tokens are minted server-side with the merchant
API key and read from the two-delegated-authority-token response header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The registry endpoint no longer advertises REGISTERED_BUSINESS —
registered businesses need no registry enrollment, so the response now
lists only enrollable types and an empty list is a valid answer meaning
registered-business-only checkout. Empty/error fallbacks therefore
resolve to an empty list instead of coercing to [REGISTERED_BUSINESS],
and the now-unused constant is dropped. Behaviour is unchanged: the
sole trader option shows iff SOLE_TRADER is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the Magento buyer-surcharge-rounding feature to WooCommerce. The
merchant picks a rounding basis (None / Up / Down / Standard) and a
rounding step from a brand-driven dropdown; the plugin relays
buyer_fee_share.rounding = {step, basis} to POST /v1/pricing/order/fee.
The backend does the arithmetic — the plugin only relays the contract,
mirroring Magento's Service/Order/SurchargeCalculator.

- brands/two.php: available_rounding_steps brand key (default
  0.10/0.50/1.00/5.00/10.00), the only source for the step dropdown.
  An overlay narrows the set (cf. available_terms).
- WC_Twoinc::get_rounding_step_options(): canonical 2dp option list so
  the stored value round-trips, mirroring the Magento RoundingStep
  source model. Two new select fields in the offset-pricing section.
- WC_Twoinc_Payment_Terms: read the options in get_offset_settings();
  build_rounding() emits the block, omitting it for a None/unmapped
  basis or a non-positive step (the API requires both keys and rejects
  step <= 0).
- tests/unit: build_buyer_fee_share rounding cases (basis->API map,
  None/zero-step omission, rides alongside reference terms, ignored
  when offset disabled).

Stacked on doug/TWO-24751-terms-chips (carries the buyer_fee_share
builder + brand-config layer). Rebases onto staging once TWO-24751
merges. i18n .pot regeneration deferred to the Lokalise flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-merge review fixes across the brand-config/terms/offset/sole-trader/
rounding stack before deploying to the staging shop:

- AJAX CSRF: nonce-guard all four new wc-ajax endpoints (two_term_fees,
  two_select_term, two_sole_trader_availability, two_sole_trader_tokens)
  with a shared `twoinc_checkout` nonce; localise it into both JS configs
  and send it on every call. The token-mint endpoint additionally gates
  on server-side is_available(billing country) so it can't mint
  write-scoped delegated tokens as an open oracle.
- Fee quote: reject non-2xx responses in fetch_term_fee (a 4xx/5xx body
  carrying buyer_fee_share could otherwise be charged), and cap the quote
  at 10s (down from the 30s default) since it sits on the checkout render
  path and is fail-soft.
- Sole trader: distinguish a registry error from a genuine empty list so a
  transient blip no longer poisons the per-session cache (fetch returns
  null on error; only real responses are cached). Registry/token calls
  capped at 8s.
- Rounding step: enforce option-list membership on save
  (validate_surcharge_rounding_step_field) so the brand-driven narrowing
  is enforced, not cosmetic. Covered by a new unit test.
- JS: UTF-8-safe base64 of the autofill prefill (btoa threw on non-Latin1
  names/cities).
- esc_url() the brand about link; add the text domain to the
  Download-invoice/credit-note strings; drop the stale "UK and US" from
  the sole-trader admin copy (countries are resolved dynamically).
- make_request gains an optional $timeout (default 30, behaviour-preserving).

Tests green on PHP 7.4 and 8.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions

Copy link
Copy Markdown

🖌 Pre-commit success 🏆

Details
Downloading virtualenv (4.3MiB)
 Downloaded virtualenv
Installed 11 packages in 17ms
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-prettier.
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-prettier:prettier@3.1.0.
[INFO] Initializing environment for https://github.com/two-inc/git-hooks.
[INFO] Installing environment for https://github.com/pre-commit/mirrors-prettier.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
prettier.................................................................Passed

Exit code: 0

Author ✍️@dgjlindsay

@dgjlindsay dgjlindsay merged commit 217cf47 into staging Jun 23, 2026
4 checks passed
@dgjlindsay dgjlindsay deleted the staging-integration branch June 23, 2026 16:42
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