fix(ucp): register the billing and shipping address UCP actually states - #161
Draft
Björn Meyer (BrocksiNet) wants to merge 2 commits into
Draft
Conversation
The plugin resolved ONE address, from the fulfillment destination, and registered
it as Shopware's `billingAddress` — passing no `shippingAddress` at all, so
Shopware defaulted shipping to billing. Correct when the two are the same, wrong
the moment an agent states them separately, which UCP can:
fulfillment.methods[].destinations[] -> shipping address
payment.instruments[].billing_address -> billing address
Both are a `postal_address`; `context.json` names both concepts in one sentence
("Higher-resolution data (shipping address, billing address) supersedes
context"). They simply live in different objects, and the plugin only ever read
the first one.
`CheckoutGuestAddressPayloadResolver::resolveAddresses()` now returns the pair,
each from the place the protocol defines for it. Either one alone still fills
both: Shopware cannot register a guest without a billing address, and a digital
cart has no destination to offer — which is the case that motivated this, since
for a cart with nothing to ship the instrument's billing address is the ONLY
address UCP has.
`resolve()` is kept as the single-address entry point, returning the billing
address, so callers that only need what gets registered do not have to know about
the pair.
The pair is threaded through the session metadata as a new `guestShippingAddress`
key, following how #152 added `paymentHandlerId` — a trailing optional parameter
on save/saveForCheckoutId and a getter beside `guestAddress()`. Sessions written
before this have no such key, so they read as "no distinct shipping address" and
behave exactly as before.
`shippingAddress` is sent to the register route only when the agent stated one
that differs from the billing address. Omitted, Shopware defaults shipping to
billing — the behaviour every existing session relies on.
Needs ucp-php-sdk 0.0.4: `PaymentInstrument::$billingAddress` did not exist
before it, and the SDK dropped the field in mapping, so the billing address was
unreachable no matter what an agent sent.
564 tests green. The store journey still reads 22 passed / 1 failed — it sends
only a destination, so it exercises the compatibility path (one address filling
both) and proves no regression; the separate-address path is unit-covered.
All four php-quality lanes failed on this file, and the reason is worth keeping:
:248 Offset 'city' on array{street: string, zipcode: string, city: string, …}
on left side of ?? always exists and is not nullable
`assertSame('Billing Street 2', $addresses['billing']['street'] ?? null)` narrows
$addresses['billing'] to non-null, so the NEXT line's `?? null` on the same array
is provably dead. Same pair for ['shipping']. The other `?? null` uses in this file
are the only assertion on their array, so they stay.
Dropping just the two flagged guards would leave one line guarding and the next
not, for reasons invisible to a reader. Asserting the pair is present once and then
reading it directly says what the test means: resolveAddresses() returned both.
Worth noting for next time: `composer phpstan` on the trunk lane reported zero
errors here while all four CI lanes flagged it, because the lane resolves trunk's
rule set and CI runs 6.7.11, 6.7.12, 8.1 and 8.2. Local clean is not CI clean.
Björn Meyer (BrocksiNet)
force-pushed
the
fix/align-shipping-and-billing-address-v2
branch
from
August 5, 2026 13:08
32aa2d0 to
1659d0b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why is this change necessary?
The plugin resolved one address, from the fulfillment destination, and registered it as Shopware's billing address — passing no
shippingAddressat all:Correct when the two are the same. Wrong the moment an agent states them separately, which UCP can:
fulfillment.methods[].destinations[]payment.instruments[].billing_addressBoth are a
postal_address, andcontext.jsonnames both concepts in one sentence — "Higher-resolution data (shipping address, billing address) supersedes context." They just live in different objects, and the plugin only ever read the first.The case that makes this more than tidiness: a digital cart has no fulfillment destination, so the instrument's billing address is the only address UCP offers for it. Before this, such a checkout had no address at all and completion refused. That is the valid half of Robin Schulte (@relativvv)'s #131 rationale — he was right that the address is used as the billing address and that a cart without shipping needs one; the container he read it from was off-spec, so the finding survived and the mechanism moved.
What does this change do?
CheckoutGuestAddressPayloadResolver::resolveAddresses()returns the pair, each from the place the protocol defines for it:Either one alone still fills both — Shopware cannot register a guest without a billing address, and a digital cart has no destination to offer.
resolve()is kept as the single-address entry point returning the billing address, so callers that only need what gets registered don't have to know about the pair.The pair threads through the session metadata as a new
guestShippingAddresskey, following exactly how #152 addedpaymentHandlerId— a trailing optional parameter onsave()/saveForCheckoutId()and a getter besideguestAddress(). Sessions written before this have no such key, so they read as "no distinct shipping address" and behave as before.shippingAddressreaches the register route only when the agent stated one that differs from billing. Omitted, Shopware defaults shipping to billing — which is what every existing session relies on, and until now the only possible outcome.How to test
New coverage: billing read from the payment instrument while shipping comes from the destination; a billing address alone filling both (the digital-cart case); a destination alone filling both (the compatibility case);
resolve()still returning billing; and — the one that pins the actual fix — the register route receiving bothbillingAddressandshippingAddresswhen they differ, and noshippingAddresskey when they don't.End to end on a local trunk lane, guest checkout:
Unchanged, which is the point: the journey sends only a destination, so it exercises the compatibility path and proves no regression. The separate-address path is unit-covered rather than journey-covered — see below.
A note on the PHPStan failure in the first push
Worth recording because it will catch someone else. All four
php-qualitylanes rejected a test file thatcomposer phpstanon a local trunk lane had passed:assertSame($x, $a['k'] ?? null)narrows$ato non-null, so a second?? nullon the same array on the next line is provably dead. Fixed by asserting the pair is present once and reading it directly, which also says what the test means.The general point:
bin/run.phpprefers the lane's PHPStan, and the lane is trunk, while CI covers 6.7.11 / 6.7.12 / 8.1 / 8.2 with different bundled rule sets. A clean local run means "clean on trunk", not "clean in CI."Not addressed here
functional/journeys.pysendspayment: {"method": "invoice"}, which is not a valid instrument at all. Making it send a spec-shapedpayment.instruments[]with abilling_addresswould exercise the new path end to end, and belongs inshopware/shopware-mcp-evals.order.getstill cannot read back a guest order — the one failing check, unrelated to addresses.ShopwareOrderGateway::requireContextToken()takes the incoming store-api token, but the guest branch needs the one the checkout session was stored under. Same root cause Robin Schulte (@relativvv) notes for thecheckout.completereplay path in fix: never report unpaid orders as completed; advertise delegated payment handlers #130. It now names itself (request: Customer is not logged in.) rather than answeringinternal, which is fix: log the exception the MCP layer refuses to show, and report its code #160 doing its job.first_name/last_nameare ignored.postal_addresscarries them, and the register route reuses the buyer's name for both addresses. Sending a parcel to a different recipient is a real case and wants its own change.