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'); +});