Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions src/Drivers/LunarCheckoutDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/Http/Controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions tests/Feature/ContactInformationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
88 changes: 88 additions & 0 deletions tests/Feature/GuestContactEmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
Loading