Skip to content

fix(ucp): register the billing and shipping address UCP actually states - #161

Draft
Björn Meyer (BrocksiNet) wants to merge 2 commits into
feat/ucp-payment-method-negotiationfrom
fix/align-shipping-and-billing-address-v2
Draft

fix(ucp): register the billing and shipping address UCP actually states#161
Björn Meyer (BrocksiNet) wants to merge 2 commits into
feat/ucp-payment-method-negotiationfrom
fix/align-shipping-and-billing-address-v2

Conversation

@BrocksiNet

@BrocksiNet Björn Meyer (BrocksiNet) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on #152 (Lukas Rump (@lukasrump)) — base is feat/ucp-payment-method-negotiation, which I've brought up to date with main. The dependency is real, not just file overlap: this threads guestShippingAddress alongside the paymentHandlerId parameter #152 introduces, so on main alone the signatures would not compile. Retarget once #152 lands.

Rebuilt as a single commit after #159 and #160 merged. It previously carried a merge of the #159 branch; #159 landed as squash d20a87d7, so git saw the same 66 test lines arriving from two unrelated histories and reported a conflict. Cherry-picking onto the updated base drops the duplicate — there was nothing to resolve by hand.

SDK 0.0.4 is released, so PaymentInstrument::$billingAddress is available from Packagist and this is no longer blocked.

Why is this change necessary?

The plugin resolved one address, from the fulfillment destination, and registered it as Shopware's billing address — passing no shippingAddress at all:

'billingAddress' => [ … $address … ],   // ← resolved from the SHIPPING destination
                                        // ← no shippingAddress key, so Shopware defaults it to billing

Correct when the two are the same. Wrong the moment an agent states them separately, which UCP can:

UCP source Shopware target
fulfillment.methods[].destinations[] shipping address
payment.instruments[].billing_address billing address

Both are a postal_address, and context.json names 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:

billing  = payment.instruments[].billing_address ?? shipping
shipping = fulfillment destination              ?? billing

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 guestShippingAddress key, following exactly 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 as before.

shippingAddress reaches 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

composer test    # 569 tests, green
composer cs
composer phpstan

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 both billingAddress and shippingAddress when they differ, and no shippingAddress key when they don't.

End to end on a local trunk lane, guest checkout:

Results: 22 passed, 1 failed, 0 skipped / 23 total

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-quality lanes rejected a test file that composer phpstan on a local trunk lane had passed:

Offset 'city' on array{street: string, …} on left side of ?? always exists and is not nullable

assertSame($x, $a['k'] ?? null) narrows $a to non-null, so a second ?? null on 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.php prefers 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

  • The journey does not yet send a billing address. functional/journeys.py sends payment: {"method": "invoice"}, which is not a valid instrument at all. Making it send a spec-shaped payment.instruments[] with a billing_address would exercise the new path end to end, and belongs in shopware/shopware-mcp-evals.
  • order.get still 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 the checkout.complete replay 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 answering internal, which is fix: log the exception the MCP layer refuses to show, and report its code #160 doing its job.
  • Address first_name / last_name are ignored. postal_address carries 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.

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.
@BrocksiNet
Björn Meyer (BrocksiNet) force-pushed the fix/align-shipping-and-billing-address-v2 branch from 32aa2d0 to 1659d0b Compare August 5, 2026 13:08
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