diff --git a/Test/Js/address-step-company-id-text.test.js b/Test/Js/address-step-company-id-text.test.js index 34ce99ab..a4d4061e 100644 --- a/Test/Js/address-step-company-id-text.test.js +++ b/Test/Js/address-step-company-id-text.test.js @@ -34,7 +34,8 @@ const { loadCompanySearchPanel, defaultMocks, brandConfigMock, - installAsyncSimulation + installAsyncSimulation, + quoteAddress } = require('./amd-harness'); const SEARCH = 'view/frontend/web/js/model/company-search.js'; @@ -82,7 +83,7 @@ function load() { {}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return { countryId: 'NO' }; }, + billingAddress: quoteAddress({ countryId: 'NO' }), isVirtual: function () { return false; } } ), diff --git a/Test/Js/amd-harness.js b/Test/Js/amd-harness.js index e95d9793..38594cb2 100644 --- a/Test/Js/amd-harness.js +++ b/Test/Js/amd-harness.js @@ -84,8 +84,11 @@ function defaultMocks() { 'domReady!': null, 'Magento_Checkout/js/view/payment/default': Component, 'Magento_Checkout/js/model/quote': { - shippingAddress: makeObservable({}), - billingAddress: makeObservable({}), + // One cache key for both: the quote is what answers "is billing a + // distinct address", so a double with no key at all cannot model + // either answer. Same key means billing IS shipping. + shippingAddress: quoteAddress(), + billingAddress: quoteAddress(), getTotals: function () { return makeObservable({}); }, getQuoteId: function () { return null; }, paymentMethod: makeObservable(null), @@ -310,6 +313,36 @@ function makeKnockoutMock() { }; } +/** The cache key both default quote addresses answer with: billing IS shipping. */ +const ONE_ADDRESS_KEY = 'one-address'; + +/** + * A quote address observable of the shape `company-capture.js` reads it in: a + * cache key it can be compared with the other address on, and a `subscribe` the + * predicate's invalidation is wired to. A double supplying neither cannot model + * "is billing a distinct address" at all, and throws where production asks. + * + * @param {object} [fields] address fields the spec itself needs + * @param {string} [cacheKey] defaults to the key shippingAddress also answers + * @returns {function} Knockout-shaped observable + */ +function quoteAddress(fields, cacheKey) { + return makeObservable(quoteAddressValue(fields, cacheKey)); +} + +/** + * The value inside a quoteAddress() observable, for a spec that writes a NEW + * address into one mid-test. + * + * @param {object} [fields] address fields the spec itself needs + * @param {string} [cacheKey] defaults to the key shippingAddress also answers + * @returns {object} + */ +function quoteAddressValue(fields, cacheKey) { + const key = cacheKey || ONE_ADDRESS_KEY; + return Object.assign({ getCacheKey: function () { return key; } }, fields || {}); +} + function makeObservable(initial) { let value = initial; const subscribers = []; @@ -854,6 +887,9 @@ function tagged(description, value) { module.exports = { tagged: tagged, + quoteAddress: quoteAddress, + quoteAddressValue: quoteAddressValue, + makeObservable: makeObservable, dispatchNative: dispatchNative, isProxyRoute: isProxyRoute, HARNESS_BASE_URL: HARNESS_BASE_URL, diff --git a/Test/Js/company-capture-billing-panel.test.js b/Test/Js/company-capture-billing-panel.test.js index 18970b21..1de854c2 100644 --- a/Test/Js/company-capture-billing-panel.test.js +++ b/Test/Js/company-capture-billing-panel.test.js @@ -14,7 +14,9 @@ const { loadAmdModule, loadCompanyCapture, defaultMocks, - brandConfigMock + brandConfigMock, + quoteAddress, + quoteAddressValue } = require('./amd-harness'); const ADDRESS_FORM = '#shipping-new-address-form'; @@ -23,6 +25,10 @@ const ADDRESS_COUNTRY = `${ADDRESS_FORM} select[name="country_id"]`; const BILLING_FORM = '[data-form="billing-new-address"]'; const BILLING_FIELD = `${BILLING_FORM} input[name="company"]`; const BILLING_COUNTRY = `${BILLING_FORM} select[name="country_id"]`; +const BILLING_TOGGLE = 'input[name="billing-address-same-as-shipping"]'; + +/** The cache key that makes the quote's billing address its own, not shipping's. */ +const DISTINCT_BILLING_KEY = 'billing-of-its-own'; /** * A minimal jQuery-shaped double over a fixed set of named nodes, each with a @@ -45,6 +51,7 @@ function makeDom() { let visible = true; let exists = true; let value = ''; + const props = {}; const delegated = []; const n = { get length() { @@ -58,6 +65,11 @@ function makeDom() { is: function (expr) { return expr === ':visible' ? visible : false; }, + prop: function (name, next) { + if (arguments.length < 2) return props[name]; + props[name] = next; + return n; + }, filter: function () { return visible ? n : { length: 0 }; }, @@ -84,6 +96,9 @@ function makeDom() { _setExists: function (v) { exists = v; }, + _setProp: function (name, v) { + props[name] = v; + }, _fireDelegated: function (event, selector) { delegated .filter(function (d) { return d.event === event && d.selector === selector; }) @@ -110,6 +125,9 @@ function makeDom() { setExists: function (selector, value) { node(selector)._setExists(value); }, + setChecked: function (selector, value) { + node(selector)._setProp('checked', value); + }, setCountry: function (selector, value) { node(selector).val(value); }, @@ -130,20 +148,29 @@ function makeDom() { /** * @param {object} [overrides] merged over the standard mocks - * @returns {object} `{ capture, dom }` + * @returns {object} `{ capture, dom, quote }` — the quote's two addresses share + * a cache key, so billing starts as shipping, matching the checked + * checkbox and the absent billing form below */ function load(overrides) { const dom = makeDom(); // Absent until made visible — matches core rendering no billing form at // all under "same as shipping" (checked, the default). dom.setVisible(BILLING_FIELD, false); + dom.setChecked(BILLING_TOGGLE, true); dom.setCountry(ADDRESS_COUNTRY, 'no'); dom.setCountry(BILLING_COUNTRY, 'gb'); + const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { + shippingAddress: quoteAddress(), + billingAddress: quoteAddress() + }); + const capture = loadCompanyCapture( Object.assign( { jquery: dom.$, + 'Magento_Checkout/js/model/quote': quote, 'Two_Gateway/js/model/brand-config': brandConfigMock({ isCompanySearchEnabled: true, checkoutApiUrl: 'https://api.example.test', @@ -154,7 +181,20 @@ function load(overrides) { ), { document: document, window: window } ); - return { capture: capture, dom: dom }; + return { capture: capture, dom: dom, quote: quote }; +} + +/** + * The buyer unchecks "my billing address is the same as shipping", core renders + * the billing fieldset, and the quote takes on a second address. + * + * @param {object} dom + * @param {object} quote + */ +function billingBecomesDistinct(dom, quote) { + dom.setChecked(BILLING_TOGGLE, false); + dom.setVisible(BILLING_FIELD, true); + quote.billingAddress(quoteAddressValue({}, DISTINCT_BILLING_KEY)); } describe('the billing panel only ever mounts at its own field', () => { @@ -166,8 +206,8 @@ describe('the billing panel only ever mounts at its own field', () => { }); test('visible (unchecked): billing mounts at its own field', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.billing.start(); expect(capture.billing.mountSelector()).toBe(BILLING_FIELD); @@ -176,8 +216,8 @@ describe('the billing panel only ever mounts at its own field', () => { test('present but hidden (re-checked after being unchecked): billing does not mount', () => { // TWO-25461's own finding, reused here: core can leave the billing // form in the DOM hidden rather than removing it. - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.billing.start(); expect(capture.billing.mountSelector()).toBe(BILLING_FIELD); @@ -190,8 +230,8 @@ describe('the billing panel only ever mounts at its own field', () => { describe('each panel reads ONLY its own address form\'s country — never a shared one', () => { test('billing reads the billing form\'s country, not shipping\'s, even though they differ', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.billing.start(); expect(capture.billing.countryCode()).toBe('gb'); @@ -199,8 +239,8 @@ describe('each panel reads ONLY its own address form\'s country — never a shar }); test('a shipping country change does not move billing\'s answer, and vice versa', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); @@ -224,10 +264,26 @@ describe('each panel reads ONLY its own address form\'s country — never a shar }); }); +describe('billingRoleIdentity() follows billingIsDistinct(), not the presence of a panel', () => { + test('a quote holding no billing address at all leaves shipping in the billing role', () => { + const { capture, dom, quote } = load(); + dom.setVisible(BILLING_FIELD, true); + dom.setChecked(BILLING_TOGGLE, false); + capture.shipping.start(); + capture.billing.start(); + capture.shipping.selectCompany({ text: 'Shipping Co', companyId: '111', lookupId: 'l1' }); + capture.billing.selectCompany({ text: 'Billing Co', companyId: '222', lookupId: 'l2' }); + + quote.billingAddress(null); + + expect(capture.billingRoleIdentity().companyId()).toBe('111'); + }); +}); + describe('the two panels\' captures are independent — a pick on one never reaches the other', () => { test('a registered pick on shipping leaves billing\'s own identity untouched', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); @@ -238,8 +294,8 @@ describe('the two panels\' captures are independent — a pick on one never reac }); test('a registered pick on billing leaves shipping\'s own identity untouched', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); @@ -262,13 +318,13 @@ describe('the resolved identity, end to end, follows the resolution rule live', }); test('billing distinct with a number: the resolved identity switches to billing\'s pick', () => { - const { capture, dom } = load(); + const { capture, dom, quote } = load(); capture.shipping.start(); capture.billing.start(); capture.shipping.selectCompany({ text: 'Shipping Co', companyId: '111', lookupId: 'l1' }); expect(capture.identity.companyId()).toBe('111'); - dom.setVisible(BILLING_FIELD, true); + billingBecomesDistinct(dom, quote); capture.billing.refreshMount(); capture.billing.selectCompany({ text: 'Billing Co', companyId: '222', lookupId: 'l2' }); @@ -276,8 +332,8 @@ describe('the resolved identity, end to end, follows the resolution rule live', }); test('billing distinct but manual entry: the resolved identity falls back to shipping', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); capture.shipping.selectCompany({ text: 'Shipping Co', companyId: '111', lookupId: 'l1' }); @@ -328,7 +384,7 @@ describe('a checkbox toggle mid-checkout supersedes the order-intent already in } test('unchecking mid-flow starts a fresh order-intent for billing\'s company, and the old company\'s stale response is dropped', () => { - const { capture, dom } = load(); + const { capture, dom, quote } = load(); capture.shipping.start(); capture.billing.start(); @@ -342,7 +398,7 @@ describe('a checkbox toggle mid-checkout supersedes the order-intent already in expect(requests[0].companyId).toBe('111'); // Billing becomes distinct, with its own company, mid-checkout. - dom.setVisible(BILLING_FIELD, true); + billingBecomesDistinct(dom, quote); capture.billing.refreshMount(); capture.billing.selectCompany({ text: 'Billing Co', companyId: '222', lookupId: 'l2' }); @@ -463,20 +519,20 @@ describe('the "same as shipping" checkbox toggle re-checks both panels\' mounts' // every call and would read as mounted even when refreshMount() was // never re-driven at all (the exact vacuous read this pins against). test('billing mounts once revealed, even though its field already existed hidden at boot', () => { - const { capture, dom } = load(); + const { capture, dom, quote } = load(); capture.start(); expect(capture.billing.panel()).toBeNull(); - dom.setVisible(BILLING_FIELD, true); + billingBecomesDistinct(dom, quote); dom.fireChange(BILLING_TOGGLE); expect(capture.billing.panel()).not.toBeNull(); }); test('unmounts again once re-hidden, same as an explicit refreshMount() already does', () => { - const { capture, dom } = load(); + const { capture, dom, quote } = load(); capture.start(); - dom.setVisible(BILLING_FIELD, true); + billingBecomesDistinct(dom, quote); dom.fireChange(BILLING_TOGGLE); expect(capture.billing.panel()).not.toBeNull(); @@ -495,9 +551,10 @@ describe('the "same as shipping" checkbox toggle re-checks both panels\' mounts' * otherwise — the only capture the resolver reads then, so seeding the billing * panel discards a saved company the buyer may not be able to re-search. * - * Distinctness is the live DOM answer, so a checkout whose billing fieldset is - * away at the moment the quote notifies seeds shipping. The "same as shipping" - * checkbox is what retires billing's own capture, and it is exercised here. + * Distinctness is the buyer's checkbox and the quote's own two addresses, so a + * checkout whose billing fieldset is away at the moment the quote notifies still + * seeds billing. The checkbox is what retires billing's own capture, and it is + * exercised here. */ describe('the quote\'s billing address seeds the panel owning the billing role', () => { const RENDERER = 'view/frontend/web/js/view/payment/method-renderer/gateway_method.js'; @@ -522,10 +579,10 @@ describe('the quote\'s billing address seeds the panel owning the billing role', * `capture.start()` — the checkbox listener that retires a stale billing * capture is wired there, so a per-component boot pins nothing about it. */ - function billingPicks(capture, dom, company) { - dom.setVisible(BILLING_FIELD, true); - capture.start(); - capture.billing.selectCompany({ text: company, companyId: '222', lookupId: 'l2' }); + function billingPicks(booted, company) { + billingBecomesDistinct(booted.dom, booted.quote); + booted.capture.start(); + booted.capture.billing.selectCompany({ text: company, companyId: '222', lookupId: 'l2' }); } /** What Fire's re-render (or a page that has not rendered one yet) leaves. */ @@ -534,18 +591,30 @@ describe('the quote\'s billing address seeds the panel owning the billing role', expect(capture.billing.mountSelector()).toBe(''); } - function billingQuoteAddress(company) { - return { + /** + * The address the quote notifies with. Also put ON the quote, which is what + * the predicate reads — an address handed to the renderer that the quote + * does not hold is a state no checkout reaches. + * + * @param {object} booted + * @param {string} company + * @returns {object} quote address + */ + function quoteNotifiesBilling(booted, company) { + const address = quoteAddressValue({ company: company, telephone: '+47 123 45 678', customAttributes: [{ attribute_code: 'company_id', value: '222' }] - }; + }, booted.quote.billingAddress().getCacheKey()); + booted.quote.billingAddress(address); + return address; } /** Core's own checkbox, re-checked: billing is shipping again. */ - function sameAsShippingAgain(capture, dom) { - dom.setVisible(BILLING_FIELD, false); - dom.fireChange('input[name="billing-address-same-as-shipping"]'); + function sameAsShippingAgain(booted) { + booted.dom.setVisible(BILLING_FIELD, false); + booted.dom.setChecked(BILLING_TOGGLE, true); + booted.dom.fireChange(BILLING_TOGGLE); } /** @@ -561,25 +630,86 @@ describe('the quote\'s billing address seeds the panel owning the billing role', }); } - test('through the quote\'s billing address, with the fieldset away it seeds SHIPPING', () => { - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + test('with the fieldset transiently away, a distinct billing address still seeds BILLING', async () => { + // A third-party re-render takes the fieldset away for a moment while + // neither the checkbox nor the quote has changed; routing on what is on + // screen puts billing's company in the shipping panel's own field + // (TWO-25554). + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom } = booted; + const renderer = loadRenderer(capture, dom); + billingFieldsetAway(dom, capture); + + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Saved Billing Co')); + await flushCapture(); + + expect(capture.billing.identity().companyName()).toBe('Saved Billing Co'); + expect(capture.billing.identity().companyId()).toBe('222'); + expect(capture.shipping.identity().companyName()).toBe(''); + expect(capture.shipping.identity().companyId()).toBe(''); + }); + + test('with the fieldset transiently away the resolver still reads BILLING', async () => { + // The seed and the resolver answer off ONE predicate, so the identity + // the seed lands on is the identity downstream reads. Split, this is the + // shape that stranded the company on a panel nobody reads. + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom } = booted; const renderer = loadRenderer(capture, dom); billingFieldsetAway(dom, capture); - renderer.updateBillingAddress(billingQuoteAddress('Billing Co')); + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Saved Billing Co')); + await flushCapture(); + + expect(capture.identity.companyName()).toBe('Saved Billing Co'); + expect(capture.identity.companyId()).toBe('222'); + }); + + test('a returning buyer with no billing company field is still offered the saved company', async () => { + // A saved distinct billing address on a checkout that renders no billing + // company field at all: the seed lands on billing and the resolver reads + // billing, so the tile and order-intent see the company (TWO-25554). + const booted = load(); + const { capture, dom, quote } = booted; + dom.setChecked(BILLING_TOGGLE, false); + dom.setExists(BILLING_FIELD, false); + quote.billingAddress(quoteAddressValue({}, DISTINCT_BILLING_KEY)); + capture.start(); + expect(capture.billing.mountSelector()).toBe(''); + const renderer = loadRenderer(capture, dom); - expect(capture.shipping.identity().companyName()).toBe('Billing Co'); + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Saved Billing Co')); + await flushCapture(); + + expect(capture.identity.companyName()).toBe('Saved Billing Co'); + expect(capture.identity.companyId()).toBe('222'); + expect(capture.shipping.identity().companyName()).toBe(''); + }); + + test('a billing address the quote says IS the shipping address seeds SHIPPING', () => { + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom, quote } = booted; + const renderer = loadRenderer(capture, dom); + billingFieldsetAway(dom, capture); + quote.billingAddress(quoteAddressValue()); + + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Saved Co')); + + expect(capture.shipping.identity().companyName()).toBe('Saved Co'); expect(capture.shipping.identity().companyId()).toBe('222'); }); test('re-checking "same as shipping" retires the billing panel\'s own capture', async () => { - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture } = booted; capture.billing.identity().soleTraderAdopted(true); capture.billing.identity().captureMode('soletrader'); - sameAsShippingAgain(capture, dom); + sameAsShippingAgain(booted); // Synchronously, in the checkbox handler itself. A later availability // resolution retires an adoption too, for its own reason, and asserting @@ -594,18 +724,35 @@ describe('the quote\'s billing address seeds the panel owning the billing role', expect(capture.billing.identity().soleTraderAdopted()).toBe(false); }); + test('the checkbox retires the capture before the quote has dropped its second address', () => { + // The checkbox is the buyer saying so, and core updates the quote after + // it. Reading the quote alone leaves the retired panel still winning the + // resolution for as long as that lag lasts. + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, quote } = booted; + expect(capture.identity.companyName()).toBe('Billing Co'); + + sameAsShippingAgain(booted); + + expect(quote.billingAddress().getCacheKey()).toBe(DISTINCT_BILLING_KEY); + expect(capture.identity.companyName()).toBe(''); + }); + test('after that re-check the returning buyer\'s saved company seeds SHIPPING, not billing', () => { // A saved shipping address carries the company as a custom attribute // and reaches the panels only through the quote's billing address. A // billing capture still standing after the re-check routes that seed to // a panel the resolver does not read, and the tile and order-intent // then show nothing at all. - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom, quote } = booted; const renderer = loadRenderer(capture, dom); - sameAsShippingAgain(capture, dom); + sameAsShippingAgain(booted); + quote.billingAddress(quoteAddressValue()); - renderer.updateBillingAddress(billingQuoteAddress('Saved Shipping Co')); + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Saved Shipping Co')); expect(capture.shipping.identity().companyName()).toBe('Saved Shipping Co'); expect(capture.shipping.identity().companyId()).toBe('222'); @@ -617,8 +764,9 @@ describe('the quote\'s billing address seeds the panel owning the billing role', // view/address-autocomplete.js, off the SHIPPING identity — so a row in // it is the shipping step's by construction, and is how a reload // restores it. - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom } = booted; const renderer = loadRenderer(capture, dom); billingFieldsetAway(dom, capture); @@ -632,12 +780,13 @@ describe('the quote\'s billing address seeds the panel owning the billing role', }); test('the telephone on that same billing address still travels', () => { - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom } = booted; const renderer = loadRenderer(capture, dom); billingFieldsetAway(dom, capture); - renderer.updateBillingAddress(billingQuoteAddress('Billing Co')); + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Billing Co')); expect(renderer.telephone()).toBe('+47123 45 678'); }); @@ -646,12 +795,13 @@ describe('the quote\'s billing address seeds the panel owning the billing role', // Billing is not a distinct address here, so the shipping identity is // the only capture the resolver reads: seeding the billing panel would // discard a saved company the buyer cannot re-search (TWO-25554). - const { capture, dom } = load(); + const booted = load(); + const { capture, dom } = booted; capture.shipping.start(); capture.billing.start(); const renderer = loadRenderer(capture, dom); - renderer.updateBillingAddress(billingQuoteAddress('Some Other Co')); + renderer.updateBillingAddress(quoteNotifiesBilling(booted, 'Some Other Co')); expect(capture.shipping.identity().companyName()).toBe('Some Other Co'); expect(capture.shipping.identity().companyId()).toBe('222'); @@ -661,8 +811,9 @@ describe('the quote\'s billing address seeds the panel owning the billing role', test('the shipping step\'s own company still restores from the section while a billing panel is mounted', () => { // The section is how a reload restores the shipping company, and a // buyer with a distinct billing address must not lose that. - const { capture, dom } = load(); - billingPicks(capture, dom, 'Billing Co'); + const booted = load(); + billingPicks(booted, 'Billing Co'); + const { capture, dom } = booted; const renderer = loadRenderer(capture, dom); renderer.applyCompanyData({ companyName: 'Shipping Co', companyId: '111' }); @@ -725,8 +876,8 @@ describe('a resolved-company change starts a check WITHOUT writing the shipping } test('a billing-only pick leaves the shipping identity empty', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); loadRendererWithIntent(capture, dom); @@ -739,8 +890,8 @@ describe('a resolved-company change starts a check WITHOUT writing the shipping }); test('and still starts the check for the company that actually resolved', () => { - const { capture, dom } = load(); - dom.setVisible(BILLING_FIELD, true); + const { capture, dom, quote } = load(); + billingBecomesDistinct(dom, quote); capture.shipping.start(); capture.billing.start(); const { requests } = loadRendererWithIntent(capture, dom); diff --git a/Test/Js/company-capture-component-lifecycle.test.js b/Test/Js/company-capture-component-lifecycle.test.js index 0f50690d..6747c8f1 100644 --- a/Test/Js/company-capture-component-lifecycle.test.js +++ b/Test/Js/company-capture-component-lifecycle.test.js @@ -32,7 +32,9 @@ const { loadCompanySearchPanel, brandConfigMock, defaultMocks, - installAsyncSimulation + installAsyncSimulation, + quoteAddress, + makeObservable } = require('./amd-harness'); const CONTROLLER = 'view/frontend/web/js/model/company-capture-component.js'; @@ -175,9 +177,9 @@ function load(options) { // `deferCountry` reproduces a guest checkout at boot: the // quote carries no address yet, so the country is only // readable once a form exists to read it from. - billingAddress: function () { - return opts.deferCountry ? null : { countryId: 'GB' }; - } + billingAddress: opts.deferCountry + ? makeObservable(null) + : quoteAddress({ countryId: 'GB' }) } ), 'Two_Gateway/js/model/company-search': companySearchMock diff --git a/Test/Js/company-capture-signup-prefill.test.js b/Test/Js/company-capture-signup-prefill.test.js index 7de0901f..d437f1d4 100644 --- a/Test/Js/company-capture-signup-prefill.test.js +++ b/Test/Js/company-capture-signup-prefill.test.js @@ -11,7 +11,25 @@ 'use strict'; const $ = require('jquery'); -const { loadCompanyCapture, brandConfigMock, defaultMocks } = require('./amd-harness'); +const { + loadCompanyCapture, + brandConfigMock, + defaultMocks, + quoteAddress, + makeObservable +} = require('./amd-harness'); + +/** + * The quote's billing address as an observable carrying a cache key, so the + * capture adapter can compare it with the shipping address the default quote + * double also holds. + * + * @param {?object} address what the spec wants the quote to hold + * @returns {function} Knockout-shaped observable + */ +function quoteObservable(address) { + return address === null ? makeObservable(null) : quoteAddress(address); +} /** * @returns {object} the shipping panel — signupPrefill() carries the company of @@ -28,7 +46,7 @@ function load(billingAddress, guestEmail) { {}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return billingAddress; }, + billingAddress: quoteObservable(billingAddress), guestEmail: guestEmail } ) @@ -49,7 +67,7 @@ function loadBoth(billingAddress) { 'Magento_Checkout/js/model/quote': Object.assign( {}, defaultMocks()['Magento_Checkout/js/model/quote'], - { billingAddress: function () { return billingAddress; } } + { billingAddress: quoteObservable(billingAddress) } ) }); return capture; diff --git a/Test/Js/company-field-display-scope.test.js b/Test/Js/company-field-display-scope.test.js index a1207e45..b54f56b6 100644 --- a/Test/Js/company-field-display-scope.test.js +++ b/Test/Js/company-field-display-scope.test.js @@ -280,7 +280,8 @@ describe('a pick on one panel never paints the other panel\'s field', () => { renderer.updateBillingAddress({ company: 'Billing Co', telephone: '+47 123 45 678', - customAttributes: [{ attribute_code: 'company_id', value: '222' }] + customAttributes: [{ attribute_code: 'company_id', value: '222' }], + getCacheKey: function () { return 'billing-of-its-own'; } }); } diff --git a/Test/Js/company-panel-chrome.test.js b/Test/Js/company-panel-chrome.test.js index 79d2e0b2..dbd3f561 100644 --- a/Test/Js/company-panel-chrome.test.js +++ b/Test/Js/company-panel-chrome.test.js @@ -23,7 +23,8 @@ const { defaultMocks, brandConfigMock, installAsyncSimulation, - tagged + tagged, + quoteAddress } = require('./amd-harness'); const SEARCH = 'view/frontend/web/js/model/company-search.js'; @@ -146,7 +147,7 @@ function boot(options) { {}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return { countryId: 'GB' }; }, + billingAddress: quoteAddress({ countryId: 'GB' }, 'billing'), isVirtual: function () { return false; } } ), diff --git a/Test/Js/company-panel-independence.test.js b/Test/Js/company-panel-independence.test.js index 6375094b..6ee0bc1a 100644 --- a/Test/Js/company-panel-independence.test.js +++ b/Test/Js/company-panel-independence.test.js @@ -28,7 +28,10 @@ const { defaultMocks, brandConfigMock, installAsyncSimulation, - tagged + tagged, + quoteAddress, + quoteAddressValue, + makeObservable } = require('./amd-harness'); const SEARCH = 'view/frontend/web/js/model/company-search.js'; @@ -123,7 +126,8 @@ function renderCheckout(options) { * Both panels booted over the real modules, plus the real address step. * * @param {object} [options] `{ shippingForm, billingForm, shippingCountry, - * billingCountry, billingHidden, isVirtual, quoteBillingAddress }` + * billingCountry, billingHidden, isVirtual, quoteBillingAddress, + * quoteShippingAddress }` * @returns {object} `{ capture, search, panels, identities, addressStep, mocks }` */ function boot(options) { @@ -151,14 +155,14 @@ function boot(options) { const search = loadAmdModule(SEARCH, { jquery: $ }, GLOBALS); search.clearResultCache(); + // No shipping address unless a spec asks for one: the quote then holds + // billing alone, which no shipping address can be the same as. const quote = Object.assign( {}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { - return opts.quoteBillingAddress || { countryId: 'GB' }; - }, - shippingAddress: function () { return null; }, + billingAddress: quoteAddress(opts.quoteBillingAddress || { countryId: 'GB' }), + shippingAddress: makeObservable(opts.quoteShippingAddress || null), isVirtual: function () { return !!opts.isVirtual; } } ); @@ -540,6 +544,80 @@ describe('the billing panel\'s own writes have their own destination', () => { }); }); +/* + * TWO-25554: core renders one "same as shipping" checkbox per payment-method + * renderer, each with its own default. Read page-wide, whichever renderer the + * checkout output first answered for the buyer — so an inactive method's box, + * still at core's checked default, said billing was shipping while the buyer + * had unchecked the box they could actually see. + */ +describe('only the ACTIVE payment method\'s "same as shipping" checkbox is read', () => { + const ACTIVE_METHOD = 'two_payment'; + + /** @returns {string} which panel speaks for the quote's billing address */ + function billingRole(booted) { + return booted.capture.billingRoleIdentity() === booted.identities.billing + ? 'billing' + : 'shipping'; + } + + /** + * An inactive method's checkbox at core's checked default, output BEFORE the + * active method's — which is what a page-wide read lands on. + */ + function addInactiveMethodToggle() { + const container = document.querySelector('.checkout-billing-address'); + const box = document.createElement('input'); + box.type = 'checkbox'; + box.id = 'billing-address-same-as-shipping-checkmo'; + box.name = 'billing-address-same-as-shipping'; + box.checked = true; + container.insertBefore(box, container.firstChild); + } + + test('an inactive method\'s checked box does not answer for the active method', () => { + const booted = boot(); + booted.quote.paymentMethod({ method: ACTIVE_METHOD }); + // The buyer's own box, on the method they are looking at: unchecked. + expect(billingRole(booted)).toBe('billing'); + + addInactiveMethodToggle(); + + expect(billingRole(booted)).toBe('billing'); + }); + + test('the resolved company still follows the billing panel through the extra box', () => { + const booted = boot(); + booted.quote.paymentMethod({ method: ACTIVE_METHOD }); + addInactiveMethodToggle(); + + picks(booted.panels.shipping, COMPANIES.shipping); + picks(booted.panels.billing, COMPANIES.billing); + + expect(booted.capture.identity.companyId()).toBe(COMPANIES.billing.companyId); + }); + + test('the ACTIVE method\'s own box is still obeyed when the buyer checks it', () => { + const booted = boot(); + booted.quote.paymentMethod({ method: ACTIVE_METHOD }); + addInactiveMethodToggle(); + + document.querySelector(`#billing-address-same-as-shipping-${ACTIVE_METHOD}`).checked = true; + + expect(billingRole(booted)).toBe('shipping'); + }); + + test('with no method selected, no box is attributable and the quote answers alone', () => { + const booted = boot(); + addInactiveMethodToggle(); + expect(booted.quote.paymentMethod()).toBeNull(); + + // The quote holds a billing address and no shipping one, so billing is + // an address of its own whatever the unattributable boxes say. + expect(billingRole(booted)).toBe('billing'); + }); +}); + /* * TWO-25554: what a panel autofilled is recorded per IDENTITY. One page-wide * record is replaced wholesale by whichever panel writes last, so the first @@ -582,6 +660,48 @@ describe('each panel\'s record of what it autofilled is its own', () => { }); }); +/* + * TWO-25554: core can select a billing address without the checkbox moving — + * a saved-address pick, or a virtual cart taking one on — and the quote is the + * predicate's other input, so the resolver subscribes to both quote addresses. + */ +describe('a quote address change re-resolves with no checkbox event', () => { + const DISTINCT_KEY = 'billing-of-its-own'; + + /** Both panels captured, billing not yet a distinct address. */ + function bothCaptured() { + const booted = boot({ quoteShippingAddress: quoteAddressValue({ countryId: 'GB' }) }); + picks(booted.panels.shipping, COMPANIES.shipping); + picks(booted.panels.billing, COMPANIES.billing); + expect(booted.capture.identity.companyId()).toBe(COMPANIES.shipping.companyId); + return booted; + } + + test.each([ + ['billingAddress', 'the quote taking on a billing address of its own'], + ['shippingAddress', 'the quote losing the shipping address billing matched'] + ])('%s notifying re-resolves the company (%s)', (which, description) => { + const booted = bothCaptured(); + + // A DISTINCT value: re-writing what the observable already holds + // notifies nothing, and a stale resolution would satisfy this. + if (which === 'billingAddress') { + booted.quote.billingAddress(quoteAddressValue({ countryId: 'GB' }, DISTINCT_KEY)); + } else { + booted.quote.shippingAddress(null); + } + + expect(tagged(description, booted.capture.identity.companyId())) + .toEqual(tagged(description, COMPANIES.billing.companyId)); + // Nothing touched the checkbox — it is still unchecked and still the + // only one on the page. + expect(tagged(description, $('input[name="billing-address-same-as-shipping"]').length)) + .toEqual(tagged(description, 1)); + expect(tagged(description, $('input[name="billing-address-same-as-shipping"]').prop('checked'))) + .toEqual(tagged(description, false)); + }); +}); + describe('the quote\'s billing address belongs to the billing panel', () => { const SAVED = { countryId: 'GB', @@ -614,11 +734,10 @@ describe('the quote\'s billing address belongs to the billing panel', () => { expect(renderer.telephone()).toBe('+4420 7946 0000'); }); - test('a virtual cart with no billing form at all seeds the SHIPPING identity', () => { - // The buyer's only address, and no billing company field is rendered for - // it — so the resolver reads the shipping capture, and seeding the - // billing panel there loses a saved company outright: a `TWO:` or - // sole-trader identity cannot be recovered by searching (TWO-25554). + test('a virtual cart with no billing form rendered still offers the company back', () => { + // The seed and the resolver answer off ONE predicate, so a company that + // lands on billing is a company downstream reads — without painting it + // into the shipping panel's own field (TWO-25554). const booted = boot({ isVirtual: true, shippingForm: false, @@ -629,8 +748,26 @@ describe('the quote\'s billing address belongs to the billing panel', () => { renderer.updateBillingAddress(SAVED); + expect(booted.identities.billing.companyId()).toBe('555'); + expect(booted.capture.identity.companyId()).toBe('555'); + expect(booted.capture.identity.companyName()).toBe('Saved Billing Co'); + expect(booted.identities.shipping.companyId()).toBe(''); + expect(booted.identities.shipping.companyName()).toBe(''); + }); + + test('a billing address the quote says IS the shipping address seeds SHIPPING', () => { + // Billing is not a distinct address, so the shipping identity is the + // only capture the resolver reads: seeding billing discards the company. + const booted = boot({ + billingForm: false, + quoteBillingAddress: SAVED, + quoteShippingAddress: { getCacheKey: function () { return 'billing'; } } + }); + const renderer = bootRenderer(booted); + + renderer.updateBillingAddress(SAVED); + expect(booted.identities.shipping.companyId()).toBe('555'); - expect(booted.identities.shipping.companyName()).toBe('Saved Billing Co'); expect(booted.capture.identity.companyId()).toBe('555'); expect(booted.identities.billing.companyId()).toBe(''); }); diff --git a/Test/Js/company-search-address-lookup.test.js b/Test/Js/company-search-address-lookup.test.js index 9371d7bb..fa1a8c77 100644 --- a/Test/Js/company-search-address-lookup.test.js +++ b/Test/Js/company-search-address-lookup.test.js @@ -21,7 +21,8 @@ const { isProxyRoute, proxyEnvelope, HARNESS_BASE_URL, - tagged + tagged, + quoteAddress } = require('./amd-harness'); const IDENTITY = 'view/frontend/web/js/model/company-identity.js'; @@ -365,7 +366,7 @@ function loadMountedComponent(configOverride, present) { 'Magento_Checkout/js/model/quote': Object.assign( {}, defaultMocks()['Magento_Checkout/js/model/quote'], - { billingAddress: function () { return { countryId: 'GB' }; } } + { billingAddress: quoteAddress({ countryId: 'GB' }) } ) }).shipping; component.start(); diff --git a/Test/Js/company-search-country-switch.test.js b/Test/Js/company-search-country-switch.test.js index dc408c78..68381799 100644 --- a/Test/Js/company-search-country-switch.test.js +++ b/Test/Js/company-search-country-switch.test.js @@ -32,7 +32,15 @@ 'use strict'; const jq = require('jquery'); -const { loadAmdModule, defaultMocks, loadCompanyCapture, brandConfigMock } = require('./amd-harness'); +const { + loadAmdModule, + defaultMocks, + loadCompanyCapture, + brandConfigMock, + quoteAddress, + quoteAddressValue, + makeObservable +} = require('./amd-harness'); const MODEL = 'view/frontend/web/js/model/company-search.js'; const ADDRESS_STEP = 'view/frontend/web/js/view/address-autocomplete.js'; @@ -371,11 +379,12 @@ function loadCaptureComponent(options) { this.forgetAdoptions = function () { calls.forgotten += 1; }; } - let billing = 'billingCountry' in opts ? opts.billingCountry : 'GB'; + const billing = 'billingCountry' in opts ? opts.billingCountry : 'GB'; + const billingAddress = billing === null + ? makeObservable(null) + : quoteAddress({ countryId: billing }); const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { - return billing === null ? null : { countryId: billing }; - }, + billingAddress: billingAddress, isVirtual: function () { return false; } }); @@ -412,7 +421,9 @@ function loadCaptureComponent(options) { component: component, identity: component.identity(), calls: calls, - setBillingCountry: function (iso) { billing = iso; } + setBillingCountry: function (iso) { + billingAddress(iso === null ? null : quoteAddressValue({ countryId: iso })); + } }; } @@ -431,7 +442,7 @@ function loadRenderer(billingCountry) { // such checkout renders. dom.node('#shipping-new-address-form input[name="company"]').length = 0; const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return { countryId: billingCountry }; } + billingAddress: quoteAddress({ countryId: billingCountry }) }); const renderer = loadAmdModule(RENDERER, { jquery: dom.$, diff --git a/Test/Js/company-search-tile-country-sourcing.test.js b/Test/Js/company-search-tile-country-sourcing.test.js index 721a7760..c40c9533 100644 --- a/Test/Js/company-search-tile-country-sourcing.test.js +++ b/Test/Js/company-search-tile-country-sourcing.test.js @@ -33,7 +33,9 @@ const { defaultMocks, loadCompanyCapture, brandConfigMock, - tagged + tagged, + quoteAddress, + makeObservable } = require('./amd-harness'); const IDENTITY = 'view/frontend/web/js/model/company-identity.js'; @@ -90,9 +92,9 @@ function load(options) { const billing = 'billingCountry' in opts ? opts.billingCountry : null; const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { - return billing === null ? null : { countryId: billing }; - }, + billingAddress: billing === null + ? makeObservable(null) + : quoteAddress({ countryId: billing }), isVirtual: function () { return !!opts.isVirtual; } }); diff --git a/Test/Js/gateway-method-company-selection.test.js b/Test/Js/gateway-method-company-selection.test.js index 89abb49a..54fe3422 100644 --- a/Test/Js/gateway-method-company-selection.test.js +++ b/Test/Js/gateway-method-company-selection.test.js @@ -24,7 +24,13 @@ 'use strict'; -const { loadAmdModule, defaultMocks, loadCompanyCapture, brandConfigMock } = require('./amd-harness'); +const { + loadAmdModule, + defaultMocks, + loadCompanyCapture, + brandConfigMock, + quoteAddress +} = require('./amd-harness'); const RENDERER = 'view/frontend/web/js/view/payment/method-renderer/gateway_method.js'; const IDENTITY = 'view/frontend/web/js/model/company-identity.js'; @@ -190,7 +196,7 @@ function loadRenderer() { const companySearch = loadAmdModule(SEARCH, { jquery: dom.$ }); const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return { countryId: 'GB' }; } + billingAddress: quoteAddress({ countryId: 'GB' }) }); const shared = { jquery: dom.$, @@ -343,7 +349,7 @@ describe('a company picked on the shipping step reaches the payment step', () => const billingAddress = observable(address); const shippingAddress = observable(address); const intents = []; - const renderer = loadAmdModule(RENDERER, { + const mocks = { jquery: dom.$, 'Two_Gateway/js/model/company-identity': identity, 'Magento_Customer/js/customer-data': { @@ -366,13 +372,20 @@ describe('a company picked on the shipping step reaches the payment step', () => shippingMethod: observable({ carrier_code: 'freeshipping' }), isVirtual: () => false } - }); + }; + // The same capture instance the renderer reads, so a spec can see WHICH + // panel's identity a seed landed on — the resolved observables alone + // read the same either way whenever the other panel holds no number. + const capture = loadCompanyCapture(mocks); + const renderer = loadAmdModule(RENDERER, Object.assign({}, mocks, { + 'Two_Gateway/js/model/company-capture': capture + })); renderer.isOrderIntentEnabled = true; renderer.placeOrderIntent = function () { intents.push(renderer.companyId()); return { always: () => ({ done: () => ({ fail: () => {} }) }) }; }; - return { renderer, sections, dom, billingAddress, shippingAddress, intents }; + return { renderer, sections, dom, billingAddress, shippingAddress, intents, capture }; } test('the companyData subscription clears the previous company id', () => { @@ -472,27 +485,49 @@ describe('a company picked on the shipping step reaches the payment step', () => }); test('the same company on the BILLING address alone still seeds the billing role', () => { - // No billing panel is mounted on this checkout, so billing is not a - // distinct address and the shipping identity is the only capture the + // The quote's own key, matching its shipping address: billing is not a + // distinct address, so the shipping identity is the only capture the // resolver reads — which is what the resolved observables show - // (TWO-25554). Where the billing panel IS mounted the seed stops there: - // company-capture-billing-panel.test.js. - const { renderer, billingAddress } = loadWithSections({}); + // (TWO-25554). + const { renderer, billingAddress, capture } = loadWithSections({}); renderer.fillCustomerData(); billingAddress({ - getCacheKey: () => 'k3', + getCacheKey: () => 'k', countryId: 'GB', telephone: '+47 123 45 678', company: 'Billing Example Ltd', customAttributes: [{ attribute_code: 'company_id', value: '87654321' }] }); + expect(capture.shipping.identity().companyId()).toBe('87654321'); + expect(capture.billing.identity().companyId()).toBe(''); expect(renderer.companyName()).toBe('Billing Example Ltd'); expect(renderer.companyId()).toBe('87654321'); expect(renderer.telephone()).toBe('+47123 45 678'); }); + + test('a DISTINCT billing address seeds the BILLING panel, and resolves from it', () => { + // Its own key, so the quote holds two addresses. The seed lands on the + // billing identity and the resolver reads that same identity, so the + // company reaches the tile instead of being stranded (TWO-25554). + const { renderer, billingAddress, capture } = loadWithSections({}); + + renderer.fillCustomerData(); + + billingAddress({ + getCacheKey: () => 'billing-of-its-own', + countryId: 'GB', + company: 'Distinct Billing Ltd', + customAttributes: [{ attribute_code: 'company_id', value: '11223344' }] + }); + + expect(capture.billing.identity().companyId()).toBe('11223344'); + expect(capture.shipping.identity().companyId()).toBe(''); + expect(renderer.companyName()).toBe('Distinct Billing Ltd'); + expect(renderer.companyId()).toBe('11223344'); + }); }); describe('the shipping step agrees with the payment step', () => { diff --git a/Test/Js/gateway-method-order-intent-proxy.test.js b/Test/Js/gateway-method-order-intent-proxy.test.js index a8e3723c..12608e23 100644 --- a/Test/Js/gateway-method-order-intent-proxy.test.js +++ b/Test/Js/gateway-method-order-intent-proxy.test.js @@ -9,7 +9,13 @@ 'use strict'; const jq = require('jquery'); -const { loadAmdModule, defaultMocks, proxyEnvelope, HARNESS_BASE_URL } = require('./amd-harness'); +const { + loadAmdModule, + defaultMocks, + proxyEnvelope, + HARNESS_BASE_URL, + quoteAddress +} = require('./amd-harness'); const RENDERER = 'view/frontend/web/js/view/payment/method-renderer/gateway_method.js'; // The module's own last-resort copy, and a server message deliberately UNLIKE @@ -45,9 +51,8 @@ function loadRenderer() { }; const quote = { getTotals: function () { return function () { return totals; }; }, - billingAddress: function () { - return { countryId: 'NO', firstname: 'Ola', lastname: 'Nordmann' }; - }, + shippingAddress: quoteAddress(), + billingAddress: quoteAddress({ countryId: 'NO', firstname: 'Ola', lastname: 'Nordmann' }), getItems: function () { return []; } }; diff --git a/Test/Js/gateway-method-order-intent-request-body.test.js b/Test/Js/gateway-method-order-intent-request-body.test.js index a7b367fa..5bda9129 100644 --- a/Test/Js/gateway-method-order-intent-request-body.test.js +++ b/Test/Js/gateway-method-order-intent-request-body.test.js @@ -21,7 +21,7 @@ 'use strict'; -const { loadAmdModule, defaultMocks } = require('./amd-harness'); +const { loadAmdModule, defaultMocks, quoteAddress } = require('./amd-harness'); const RENDERER = 'view/frontend/web/js/view/payment/method-renderer/gateway_method.js'; @@ -71,13 +71,12 @@ function loadRenderer() { const quote = { getTotals: function () { return function () { return totals; }; }, - billingAddress: function () { - return { - countryId: 'NO', - firstname: 'Ola', - lastname: 'Nordmann' - }; - }, + shippingAddress: quoteAddress(), + billingAddress: quoteAddress({ + countryId: 'NO', + firstname: 'Ola', + lastname: 'Nordmann' + }), getItems: function () { return [ { diff --git a/Test/Js/gateway-method-sole-trader-popup.test.js b/Test/Js/gateway-method-sole-trader-popup.test.js index e1b5d700..875704cf 100644 --- a/Test/Js/gateway-method-sole-trader-popup.test.js +++ b/Test/Js/gateway-method-sole-trader-popup.test.js @@ -37,7 +37,9 @@ const { defaultMocks, loadCompanySearchPanel, dispatchNative, - brandConfigMock + brandConfigMock, + quoteAddress, + makeObservable } = require('./amd-harness'); const IDENTITY = 'view/frontend/web/js/model/company-identity.js'; @@ -89,9 +91,10 @@ function makeEnv(options) { }; const quote = Object.assign({}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { - return 'billingAddress' in opts ? opts.billingAddress : { countryId: 'GB' }; - }, + billingAddress: (function () { + const address = 'billingAddress' in opts ? opts.billingAddress : { countryId: 'GB' }; + return address ? quoteAddress(address) : makeObservable(address); + })(), getQuoteId: function () { return 'cart-1'; }, isVirtual: function () { return false; } }); diff --git a/Test/Js/gateway-method-sole-trader-select-different.test.js b/Test/Js/gateway-method-sole-trader-select-different.test.js index 69f104f0..d2b012d6 100644 --- a/Test/Js/gateway-method-sole-trader-select-different.test.js +++ b/Test/Js/gateway-method-sole-trader-select-different.test.js @@ -25,7 +25,8 @@ const { defaultMocks, loadCompanySearchPanel, dispatchNative, - brandConfigMock + brandConfigMock, + quoteAddress } = require('./amd-harness'); const SOLE_TRADER = 'view/frontend/web/js/model/sole-trader.js'; @@ -57,7 +58,7 @@ function makeEnv() { {}, defaultMocks()['Magento_Checkout/js/model/quote'], { - billingAddress: function () { return { countryId: 'GB' }; }, + billingAddress: quoteAddress({ countryId: 'GB' }), getQuoteId: function () { return 'cart-1'; }, isVirtual: function () { return false; } } diff --git a/Test/Js/tile-company-readonly-fields.test.js b/Test/Js/tile-company-readonly-fields.test.js index 9870045e..e35d86e5 100644 --- a/Test/Js/tile-company-readonly-fields.test.js +++ b/Test/Js/tile-company-readonly-fields.test.js @@ -54,7 +54,13 @@ const fs = require('fs'); const path = require('path'); -const { loadAmdModule, defaultMocks, loadCompanyCapture, brandConfigMock } = require('./amd-harness'); +const { + loadAmdModule, + defaultMocks, + loadCompanyCapture, + brandConfigMock, + quoteAddress +} = require('./amd-harness'); const RENDERER = 'view/frontend/web/js/view/payment/method-renderer/gateway_method.js'; const IDENTITY = 'view/frontend/web/js/model/company-identity.js'; @@ -423,7 +429,7 @@ function loadTile() { 'Magento_Checkout/js/model/quote': Object.assign( {}, defaultMocks()['Magento_Checkout/js/model/quote'], - { billingAddress: function () { return { countryId: 'GB' }; } } + { billingAddress: quoteAddress({ countryId: 'GB' }) } ) }) ); diff --git a/view/frontend/web/js/model/company-capture.js b/view/frontend/web/js/model/company-capture.js index 4e0086dc..fdf26e8d 100644 --- a/view/frontend/web/js/model/company-capture.js +++ b/view/frontend/web/js/model/company-capture.js @@ -65,9 +65,16 @@ define([ /** The country select inside that SAME billing form — never a shared one. */ const BILLING_COUNTRY_SELECTOR = `${BILLING_FORM_ROOT} select[name="country_id"]`; - /** "My billing and shipping address are the same" — core's own checkbox. */ + /** + * "My billing and shipping address are the same" — core's own checkbox, one + * per payment-method renderer. Bare, so a delegated listener hears every + * one of them; see activeBillingToggle() for READING one. + */ const BILLING_TOGGLE_SELECTOR = 'input[name="billing-address-same-as-shipping"]'; + /** Core's own per-renderer id for that checkbox, less the method code. */ + const BILLING_TOGGLE_ID_PREFIX = 'billing-address-same-as-shipping-'; + /** @see soleAddressForm — what makes a container an address form. */ const ADDRESS_STREET_SELECTOR = 'input[name="street[0]"]'; @@ -88,22 +95,66 @@ define([ /** * Present AND visible — never merely present. Core leaves the billing form * in the DOM hidden once "same as shipping" is re-checked, and a hidden - * field is neither a live mount nor a distinct address. + * field is not a live mount. * - * `.is` is feature-detected: jQuery-shaped test doubles model presence - * only, and presence is the best answer available for those. - * - * @param {object} $field a jQuery(-shaped) set + * @param {object} $field a jQuery set * @returns {boolean} */ function isVisible($field) { if (!$field.length) return false; - return typeof $field.is === 'function' ? $field.is(':visible') : true; + return $field.is(':visible'); } - /** Is billing currently a distinct address from shipping? @returns {boolean} */ + /** + * Is billing currently a distinct address from shipping? The single + * authority — the resolver and billingRoleIdentity() both read this one. + * + * The buyer's checkbox and the quote, never whether the billing fieldset is + * on screen: a third-party re-render detaches that fieldset for an instant + * while neither the buyer's intent nor the quote has changed (TWO-25554). + * + * @returns {boolean} + */ function billingIsDistinct() { - return isVisible($(BILLING_FIELD_SELECTOR)); + const $toggle = activeBillingToggle(); + if ($toggle && $toggle.length && $toggle.prop('checked')) return false; + return quoteHoldsDistinctBillingAddress(); + } + + /** + * The one "same as shipping" checkbox that speaks for the buyer. + * + * Core renders one per payment-method renderer, each with its own default, + * so a page-wide read is answered by whichever the checkout output first — + * an inactive method's box as readily as the active one (TWO-25554). One + * box is unambiguous whatever its id; past that the active method's own is + * found by core's id convention, and a checkout that renders several and + * abandons that convention leaves the quote as the honest source. + * + * @returns {?object} jQuery set — empty when several boxes are rendered + * and the active method's own is absent; `null` when several are + * rendered and no payment method is selected + */ + function activeBillingToggle() { + const $all = $(BILLING_TOGGLE_SELECTOR); + if ($all.length < 2) return $all; + const selected = quote.paymentMethod(); + const code = selected && selected.method; + return code ? $(`#${BILLING_TOGGLE_ID_PREFIX}${code}`) : null; + } + + /** + * No shipping address at all — a virtual cart — leaves billing as the only + * address the quote holds, which no shipping address can be the same as. + * + * @returns {boolean} + */ + function quoteHoldsDistinctBillingAddress() { + const billingAddress = quote.billingAddress(); + if (!billingAddress) return false; + const shippingAddress = quote.shippingAddress(); + if (!shippingAddress) return true; + return shippingAddress.getCacheKey() != billingAddress.getCacheKey(); } /** @@ -434,7 +485,6 @@ define([ tileFieldSelector: '', fieldExists: function (selector) { if (!selector) return false; - // See isVisible() and billingIsDistinct() above. return isVisible($(selector)); }, getAdjacentCountry: function () { @@ -484,6 +534,10 @@ define([ // once for that — this only covers a LATER DOM appearance of the // billing form/checkbox that the initial recompute() ran before. $.async(BILLING_FIELD_SELECTOR, onChange); + // Core can select a billing address without the checkbox moving, + // and the quote is the predicate's other input. + quote.billingAddress.subscribe(onChange); + quote.shippingAddress.subscribe(onChange); } }); diff --git a/view/frontend/web/js/model/company-source-resolver.js b/view/frontend/web/js/model/company-source-resolver.js index 04d77bb8..9a84b2eb 100644 --- a/view/frontend/web/js/model/company-source-resolver.js +++ b/view/frontend/web/js/model/company-source-resolver.js @@ -39,8 +39,8 @@ * @param {object} options.resolved the identity downstream consumers read * @param {function(): boolean} options.billingIsDistinct whether billing * is currently a distinct address from shipping (core's "my - * billing address is the same as shipping" unchecked and a - * billing form rendered) + * billing address is the same as shipping" unchecked and the quote + * holding a billing address that is not its shipping one) * @param {function(function())} [options.watchBillingToggle] report every * time billingIsDistinct()'s answer could have changed */