From 286faa099020d627f8fe75eca654259a1a7f6673 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Tue, 20 Oct 2026 10:35:16 +0100 Subject: [PATCH 1/3] fix(checkout): stamp the contact email on the cart's existing addresses (EDW2-215) The contact step only wrote checkout_sessions.customer_email; the driver's storeContact, the one method that touches the cart rows, was never called. Addresses saved after the contact step already pick the email up (withContactEmail) and completion stamps the order (stampContactEmail), but rows saved before it stayed empty. A webhook-first payment creates the order from those rows inside the gateway's authorize(), before completion runs, so that order had no email unless the host app projected one. The controller now hands the email to the driver on both the guest and signed-in paths. The driver stamps it on the existing shipping and billing rows, replacing a previous contact-step email but keeping an email a row carries of its own (a wallet sheet's site contact). Co-Authored-By: Claude Opus 5.5 --- src/Drivers/LunarCheckoutDriver.php | 55 +++++++++++++-- src/Http/Controllers/CheckoutController.php | 16 +++-- tests/Feature/GuestContactEmailTest.php | 78 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index b9aa16d..ceed8a2 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -360,19 +360,46 @@ public function assertReadyForPayment(CheckoutSession $session, string $confirme |-------------------------------------------------------------------------- */ + /** + * The contact step's email onto the session and onto every cart address + * that already exists, so an order created from this cart carries it even + * when it is placed before completion runs (the gateway's authorize() on a + * webhook-first payment creates the order straight from the cart rows). + * Addresses saved later pick it up through withContactEmail(). + * + * A row keeps an email of its own (a wallet sheet's site contact): only + * rows with none, or with the email this step gave them last time, follow + * a change here. + */ public function storeContact(CheckoutSession $session, array $data): CartSnapshot { $cart = $this->operableCart($session); - if ($address = $cart->shippingAddress) { - $address->update([ - 'contact_email' => $data['email'] ?? $address->contact_email, - 'contact_phone' => $data['phone'] ?? $address->contact_phone, - ]); + $email = is_string($data['email'] ?? null) && trim($data['email']) !== '' ? $data['email'] : null; + $previous = $session->customer_email; + + foreach ([$cart->shippingAddress, $cart->billingAddress] as $address) { + if ($address === null) { + continue; + } + + $changes = []; + + if ($email !== null && $this->followsContactStep($address->contact_email, $previous)) { + $changes['contact_email'] = $email; + } + + if ($address->type === 'shipping' && ! empty($data['phone'])) { + $changes['contact_phone'] = $data['phone']; + } + + if ($changes !== []) { + $address->update($changes); + } } - if (! empty($data['email'])) { - $session->customer_email = $data['email']; + if ($email !== null) { + $session->customer_email = $email; $session->save(); } @@ -865,6 +892,20 @@ private function withContactEmail(CheckoutSession $session, ?CartAddress $existi return $address; } + /** + * Whether an address row's email came from the contact step (or it has + * none), so a new contact email should replace it. + */ + private function followsContactStep(?string $rowEmail, ?string $previousContactEmail): bool + { + if ($rowEmail === null || trim($rowEmail) === '') { + return true; + } + + return $previousContactEmail !== null + && strcasecmp(trim($rowEmail), trim($previousContactEmail)) === 0; + } + /** * 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 diff --git a/src/Http/Controllers/CheckoutController.php b/src/Http/Controllers/CheckoutController.php index 92ea8dc..b4bd69e 100644 --- a/src/Http/Controllers/CheckoutController.php +++ b/src/Http/Controllers/CheckoutController.php @@ -698,8 +698,9 @@ public function quoteShippingRates(Request $request, CheckoutSessionModel $sessi } /** - * Persist the contact email. Guest → customer_email on the model; signed-in - * → associate the customer (which also stores the email). Order attachment + * Persist the contact email through the driver, which also stamps it on + * the cart's existing addresses so the order carries it however it is + * placed. A signed-in customer is associated as well. Order attachment * itself rides on Lunar's cart↔user link, not this value. */ public function storeContact(Request $request, CheckoutSessionModel $session, CheckoutDriver $checkoutDriver): RedirectResponse @@ -710,15 +711,16 @@ public function storeContact(Request $request, CheckoutSessionModel $session, Ch $customerId = auth()->user()?->latestCustomer()?->id; - if ($customerId !== null) { - $checkoutDriver->associateCustomer($session, (string) $customerId, $data['email']); - } else { + if ($customerId === null) { // Once the session is pinned for payment the contact email is // part of what completion stamps on the order. $this->ensureWritable($session); + } - $session->customer_email = $data['email']; - $session->save(); + $checkoutDriver->storeContact($session, $data); + + if ($customerId !== null) { + $checkoutDriver->associateCustomer($session, (string) $customerId, $data['email']); } return back(); diff --git a/tests/Feature/GuestContactEmailTest.php b/tests/Feature/GuestContactEmailTest.php index 9c3496c..793515b 100644 --- a/tests/Feature/GuestContactEmailTest.php +++ b/tests/Feature/GuestContactEmailTest.php @@ -4,7 +4,10 @@ use Lunar\Checkout\Contracts\CheckoutDriver; use Lunar\Checkout\Contracts\PaymentMethodRegistry; use Lunar\Checkout\PaymentMethods\Offline; +use Lunar\Core\Facades\CartSession; +use Lunar\Core\Models\Customer; use Lunar\Core\Models\Order; +use Lunar\Tests\Checkout\Stubs\User; use Lunar\Tests\Checkout\TestCase; use Lunar\Tests\Checkout\Utils\CheckoutCart; @@ -85,3 +88,78 @@ function addressPayload(): array expect($order->shippingAddress->contact_email)->toBe('guest@example.com') ->and($order->billingAddress->contact_email)->toBe('guest@example.com'); }); + +/* + * EDW2-215: the contact step must stamp the cart rows that already exist. A + * webhook-first payment has the gateway create the order straight from the + * cart inside authorize(), before completion's stampContactEmail() runs, so + * the rows are the only place that order can get its email from. + */ +it('stamps the contact email on cart addresses saved before the contact step', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']) + ->assertRedirect(); + + $cart->refresh(); + + expect($cart->shippingAddress->contact_email)->toBe('guest@example.com') + ->and($cart->billingAddress->contact_email)->toBe('guest@example.com') + ->and($session->refresh()->customer_email)->toBe('guest@example.com'); +}); + +it('gives an order created straight from the cart the contact email', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + + // What a gateway's authorize() does on a webhook-first payment: no + // completion, so nothing but the cart rows supplies the email. + $order = $cart->refresh()->createOrder(); + + expect($order->shippingAddress->contact_email)->toBe('guest@example.com') + ->and($order->billingAddress->contact_email)->toBe('guest@example.com'); +}); + +it('replaces an earlier contact email when the customer corrects it', function () { + [$cart, $session] = guestCheckout(); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'typo@example.com']); + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + + $cart->refresh(); + + expect($cart->shippingAddress->contact_email)->toBe('guest@example.com') + ->and($cart->billingAddress->contact_email)->toBe('guest@example.com'); +}); + +it('keeps an email an address row carries of its own', function () { + [$cart, $session] = guestCheckout(); + $cart->shippingAddress->update(['contact_email' => 'site@example.com']); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + + $cart->refresh(); + + expect($cart->shippingAddress->contact_email)->toBe('site@example.com') + ->and($cart->billingAddress->contact_email)->toBe('guest@example.com'); +}); + +it('stamps the contact email for a signed-in customer too', function () { + [$cart, $session] = guestCheckout(); + CartSession::use($cart); + + $user = User::factory()->create(); + $customer = Customer::factory()->create(); + $user->customers()->attach($customer); + + $this->actingAs($user) + ->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'buyer@example.com']) + ->assertRedirect(); + + $cart->refresh(); + + expect($cart->shippingAddress->contact_email)->toBe('buyer@example.com') + ->and($cart->billingAddress->contact_email)->toBe('buyer@example.com') + ->and($session->refresh()->customer_reference)->toBe((string) $customer->id); +}); From 96545acd99ea0e8efecfcaecdceff3b34e322a0c Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Tue, 20 Oct 2026 10:41:07 +0100 Subject: [PATCH 2/3] fix(checkout): trim the stored contact email; cover review cases (EDW2-215) Review follow-up on #4: the email was checked trimmed but stored as typed, so the session and the rows relied on the host's TrimStrings. Adds a signed-in frozen-session test (409, nothing stamped, no association) and a case/whitespace test for the earlier-email match. Co-Authored-By: Claude Opus 5.5 --- src/Drivers/LunarCheckoutDriver.php | 2 +- tests/Feature/ContactInformationTest.php | 20 ++++++++++++++++++++ tests/Feature/GuestContactEmailTest.php | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index ceed8a2..5ab131d 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -375,7 +375,7 @@ public function storeContact(CheckoutSession $session, array $data): CartSnapsho { $cart = $this->operableCart($session); - $email = is_string($data['email'] ?? null) && trim($data['email']) !== '' ? $data['email'] : null; + $email = is_string($data['email'] ?? null) && trim($data['email']) !== '' ? trim($data['email']) : null; $previous = $session->customer_email; foreach ([$cart->shippingAddress, $cart->billingAddress] as $address) { diff --git a/tests/Feature/ContactInformationTest.php b/tests/Feature/ContactInformationTest.php index ef8a1a1..8cdace3 100644 --- a/tests/Feature/ContactInformationTest.php +++ b/tests/Feature/ContactInformationTest.php @@ -129,6 +129,26 @@ function makeUserWithCustomer(): array expect($session->fresh()->customer_email)->toBe('paying@example.test'); }); +it('refuses a signed-in email change once the session is pinned for payment', function () { + [$user, $customer] = makeUserWithCustomer(); + + $cart = routeTestCart(); + $session = app(CheckoutDriver::class)->createSession($cart); + $session->forceFill(['status' => PaymentProcessing::$name, 'customer_email' => 'paying@example.test'])->save(); + CartSession::use($cart); + $this->actingAs($user); + + $this->postJson(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'swapped@example.test']) + ->assertStatus(409) + ->assertJsonPath('reason', 'frozen'); + + $session->refresh(); + + expect($session->customer_email)->toBe('paying@example.test') + ->and($session->customer_reference)->toBeNull() + ->and($cart->refresh()->addresses->pluck('contact_email')->all())->not->toContain('swapped@example.test'); +}); + it('refuses a guest email change on a completed session', function () { $cart = routeTestCart(); $session = app(CheckoutDriver::class)->createSession($cart); diff --git a/tests/Feature/GuestContactEmailTest.php b/tests/Feature/GuestContactEmailTest.php index 793515b..47c36df 100644 --- a/tests/Feature/GuestContactEmailTest.php +++ b/tests/Feature/GuestContactEmailTest.php @@ -133,6 +133,16 @@ function addressPayload(): array ->and($cart->billingAddress->contact_email)->toBe('guest@example.com'); }); +it('treats a differently cased earlier contact email as the same one', function () { + [$cart, $session] = guestCheckout(); + $session->forceFill(['customer_email' => 'typo@example.com'])->save(); + $cart->shippingAddress->update(['contact_email' => ' Typo@Example.com ']); + + $this->post(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'guest@example.com']); + + expect($cart->refresh()->shippingAddress->contact_email)->toBe('guest@example.com'); +}); + it('keeps an email an address row carries of its own', function () { [$cart, $session] = guestCheckout(); $cart->shippingAddress->update(['contact_email' => 'site@example.com']); From abd0504370e8839a22a57646f178658e5f9f57c5 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Tue, 20 Oct 2026 10:42:46 +0100 Subject: [PATCH 3/3] test(checkout): give the frozen-session contact test real address rows (EDW2-215) Review follow-up: the cart had no addresses, so "nothing stamped" could not fail. It now uses an orderable cart with empty contact emails and asserts both rows stay empty; a stamp-before-gate mutation fails it. The signed-in path also passes the trimmed email to associateCustomer. Co-Authored-By: Claude Opus 5.5 --- src/Http/Controllers/CheckoutController.php | 2 +- tests/Feature/ContactInformationTest.php | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Http/Controllers/CheckoutController.php b/src/Http/Controllers/CheckoutController.php index b4bd69e..5971f47 100644 --- a/src/Http/Controllers/CheckoutController.php +++ b/src/Http/Controllers/CheckoutController.php @@ -720,7 +720,7 @@ public function storeContact(Request $request, CheckoutSessionModel $session, Ch $checkoutDriver->storeContact($session, $data); if ($customerId !== null) { - $checkoutDriver->associateCustomer($session, (string) $customerId, $data['email']); + $checkoutDriver->associateCustomer($session, (string) $customerId, trim($data['email'])); } return back(); diff --git a/tests/Feature/ContactInformationTest.php b/tests/Feature/ContactInformationTest.php index 8cdace3..8c16d62 100644 --- a/tests/Feature/ContactInformationTest.php +++ b/tests/Feature/ContactInformationTest.php @@ -130,10 +130,12 @@ function makeUserWithCustomer(): array }); it('refuses a signed-in email change once the session is pinned for payment', function () { - [$user, $customer] = makeUserWithCustomer(); + [$user] = makeUserWithCustomer(); - $cart = routeTestCart(); - $session = app(CheckoutDriver::class)->createSession($cart); + // A cart with real address rows, so a stamp would have somewhere to land. + $cart = CheckoutCart::orderable(); + $cart->addresses()->update(['contact_email' => null]); + $session = app(CheckoutDriver::class)->createSession($cart->refresh()); $session->forceFill(['status' => PaymentProcessing::$name, 'customer_email' => 'paying@example.test'])->save(); CartSession::use($cart); $this->actingAs($user); @@ -143,10 +145,12 @@ function makeUserWithCustomer(): array ->assertJsonPath('reason', 'frozen'); $session->refresh(); + $cart->refresh(); expect($session->customer_email)->toBe('paying@example.test') ->and($session->customer_reference)->toBeNull() - ->and($cart->refresh()->addresses->pluck('contact_email')->all())->not->toContain('swapped@example.test'); + ->and($cart->shippingAddress->contact_email)->toBeNull() + ->and($cart->billingAddress->contact_email)->toBeNull(); }); it('refuses a guest email change on a completed session', function () {