From f9d3fac9b768e6f61214cbed1d89eb643052bec4 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Fri, 2 Oct 2026 09:10:15 +0100 Subject: [PATCH] fix: keep the guest's contact email on the cart and order addresses Lunar rewrites an address row wholesale and the address forms never send an email, so saving an address after the contact step wiped the guest's email. Placed guest orders had no contact email on either address, leaving confirmations and the panel nothing to read. Address writes now carry the email over (payload, then the existing row, then the session), and completion stamps any order address still missing one from the session or the signed-in customer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Drivers/LunarCheckoutDriver.php | 49 ++++++++++++- tests/Feature/ExpressCollectFlowTest.php | 4 +- tests/Feature/GuestContactEmailTest.php | 87 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/GuestContactEmailTest.php diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index 42ad024..41b17f3 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -175,6 +175,8 @@ public function complete(CheckoutSession $session, ?string $confirmationToken = */ $order = $cart->completedOrder ?: $cart->draftOrder ?: $cart->createOrder(); + $this->stampContactEmail($session, $cart, $order); + /* * How the customer chose to pay, for the panel, mails and any * ERP feed (spec 0014 §D). The session records the handle at the @@ -388,7 +390,7 @@ public function storeShippingAddress(CheckoutSession $session, array $data): Car // when the new address still offers it. $previousOption = $cart->shippingAddress?->shipping_option; - $cart->setShippingAddress($this->toCartAddressData($data)); + $cart->setShippingAddress($this->withContactEmail($session, $cart->shippingAddress, $data)); if ($previousOption !== null) { $option = $this->shippingManifest->getOption($cart, $previousOption); @@ -415,7 +417,7 @@ public function storeBillingAddress(CheckoutSession $session, array $data): Cart { $cart = $this->operableCart($session); - $cart->setBillingAddress($this->toCartAddressData($data)); + $cart->setBillingAddress($this->withContactEmail($session, $cart->billingAddress, $data)); // Spec 0013 §F: a wallet sheet started in collect mode asks for no // shipping address, so this billing address is the only one the cart @@ -425,7 +427,7 @@ public function storeBillingAddress(CheckoutSession $session, array $data): Cart // no address and no collect option, and the hold is refused. The // address is the customer's own either way. if ($cart->shippingAddress === null && PickupPoints::fulfilment($cart) === PickupPoints::COLLECT) { - $cart->setShippingAddress($this->toCartAddressData($data)); + $cart->setShippingAddress($this->withContactEmail($session, null, $data)); $cart = $this->setFulfilment->execute($cart->refresh(), PickupPoints::COLLECT); } @@ -841,6 +843,47 @@ private function assertContactKnown(CheckoutSession $session, Cart $cart): void } } + /** + * The address payload with the customer's email carried over. Lunar + * rewrites the address row wholesale and the address forms never send an + * email, so without this the contact step's email is wiped. An email the + * payload carries (a wallet sheet's) wins, then the row's own, then the + * session's. + * + * @param array $data + * @return array + */ + private function withContactEmail(CheckoutSession $session, ?CartAddress $existing, array $data): array + { + $address = $this->toCartAddressData($data); + $email = $address['contact_email'] ?? $existing?->contact_email ?? $session->customer_email; + + if (is_string($email) && trim($email) !== '') { + $address['contact_email'] = $email; + } + + return $address; + } + + /** + * Give every address on the placed order an email to write to. The cart's + * rows can still lack one (contact given after the last address write, or + * a signed-in customer known only through the account), and the order is + * what confirmations and the panel read. + */ + private function stampContactEmail(CheckoutSession $session, Cart $cart, Order $order): void + { + $email = $session->customer_email ?? $cart->user?->email; + + if (! is_string($email) || trim($email) === '') { + return; + } + + $order->addresses() + ->where(fn ($query) => $query->whereNull('contact_email')->orWhere('contact_email', '')) + ->update(['contact_email' => $email]); + } + /** * Inverse of {@see toCheckoutAddress()}: the backend-neutral address * payload (spec 0010 §B) mapped onto Lunar's CartAddress columns. Absent diff --git a/tests/Feature/ExpressCollectFlowTest.php b/tests/Feature/ExpressCollectFlowTest.php index 126dad5..6c78221 100644 --- a/tests/Feature/ExpressCollectFlowTest.php +++ b/tests/Feature/ExpressCollectFlowTest.php @@ -192,5 +192,7 @@ function collectBasket(): Cart expect($session->refresh()->status)->toBeInstanceOf(Completed::class) ->and($line->meta['collect'])->toBeTrue() ->and($line->meta['pickup_point']['handle'])->toBe('dartford') - ->and($order->meta['fulfilment'])->toBe('collect'); + ->and($order->meta['fulfilment'])->toBe('collect') + ->and($order->shippingAddress->contact_email)->toBe('terry@example.com') + ->and($order->billingAddress->contact_email)->toBe('terry@example.com'); }); diff --git a/tests/Feature/GuestContactEmailTest.php b/tests/Feature/GuestContactEmailTest.php new file mode 100644 index 0000000..9c3496c --- /dev/null +++ b/tests/Feature/GuestContactEmailTest.php @@ -0,0 +1,87 @@ +addresses()->update(['contact_email' => null]); + $session = CheckoutCart::session($cart->refresh()); + + return [$cart, $session]; +} + +function addressPayload(): array +{ + return [ + 'first_name' => 'Terry', + 'last_name' => 'Sparks', + 'line1' => '1 Trade Counter Way', + 'city' => 'London', + 'postcode' => 'SE1 1AA', + 'country_code' => 'GB', + ]; +} + +it('keeps the contact email when the shipping address is saved after the contact step', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']) + ->assertRedirect(); + $this->post(route('lunar.checkout.shipping-address.store', $session->uuid), addressPayload()) + ->assertRedirect(); + + expect($cart->refresh()->shippingAddress->contact_email)->toBe('guest@example.com'); +}); + +it('puts the contact email on the billing address', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + $this->post(route('lunar.checkout.billing-address.store', $session->uuid), addressPayload()); + + expect($cart->refresh()->billingAddress->contact_email)->toBe('guest@example.com'); +}); + +it('prefers an email the address itself carries, as a wallet sheet sends', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + app(CheckoutDriver::class)->storeShippingAddress($session->refresh(), [ + ...addressPayload(), + 'email' => 'site@example.com', + ]); + + expect($cart->refresh()->shippingAddress->contact_email)->toBe('site@example.com'); +}); + +it('places a guest order whose addresses carry the contact email', function () { + app(PaymentMethodRegistry::class)->add(Offline::class); + [, $session] = guestCheckout(); + + // Only the session knows the email: no address step ran after contact. + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'fingerprint' => CheckoutCart::fingerprint($session->refresh()), + 'payment_method' => 'offline', + ])->assertSuccessful(); + + $order = Order::query()->findOrFail((int) $session->refresh()->order_reference); + + expect($order->shippingAddress->contact_email)->toBe('guest@example.com') + ->and($order->billingAddress->contact_email)->toBe('guest@example.com'); +});