diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index b9aa16d..5ab131d 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']) !== '' ? trim($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..5971f47 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, trim($data['email'])); } return back(); diff --git a/tests/Feature/ContactInformationTest.php b/tests/Feature/ContactInformationTest.php index ef8a1a1..8c16d62 100644 --- a/tests/Feature/ContactInformationTest.php +++ b/tests/Feature/ContactInformationTest.php @@ -129,6 +129,30 @@ 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] = makeUserWithCustomer(); + + // 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); + + $this->postJson(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'swapped@example.test']) + ->assertStatus(409) + ->assertJsonPath('reason', 'frozen'); + + $session->refresh(); + $cart->refresh(); + + expect($session->customer_email)->toBe('paying@example.test') + ->and($session->customer_reference)->toBeNull() + ->and($cart->shippingAddress->contact_email)->toBeNull() + ->and($cart->billingAddress->contact_email)->toBeNull(); +}); + 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 9c3496c..47c36df 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,88 @@ 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('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']); + + $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); +});