fix(stripe): take checkout totals (shipping, discount, tax) from the session
Shipping rates, promotion codes and tax chosen on the Stripe Checkout page only exist on the session. checkout.session.completed now copies amount_subtotal, total_details.amount_shipping/amount_discount/amount_tax and amount_total onto the (not yet paid) order before recording the payment, so the order's totals match what was charged and the order is settled. The chosen shipping rate id goes to meta.stripe_shipping_rate. The shipping address is also read from collected_information.shipping_details (Stripe API 2025-03-31+), falling back from shipping_details. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
53832f49ce
commit
637ad392ce
|
|
@ -167,6 +167,11 @@ class StripeWebhookController
|
|||
// order carries a buyer snapshot (invoicing, shipping labels, ...).
|
||||
$this->persistSessionAddresses($order, $session);
|
||||
|
||||
// Shipping, discounts and tax chosen/applied on the Checkout page are only
|
||||
// known to Stripe; take the order's totals from the session so what was
|
||||
// charged and what the order says agree (and the payment below settles it).
|
||||
$this->applySessionTotals($order, $session);
|
||||
|
||||
// Record payment on the order
|
||||
// Stripe provides amounts in cents, which matches our storage format
|
||||
$amountPaid = (int) ($session->amount_total ?? 0);
|
||||
|
|
@ -209,7 +214,8 @@ class StripeWebhookController
|
|||
protected function persistSessionAddresses(Order $order, $session): void
|
||||
{
|
||||
$customer = data_get($session, 'customer_details');
|
||||
$shipping = data_get($session, 'shipping_details');
|
||||
// Stripe API ≥ 2025-03-31 moved shipping_details under collected_information.
|
||||
$shipping = data_get($session, 'shipping_details') ?? data_get($session, 'collected_information.shipping_details');
|
||||
|
||||
$billing = $this->addressSnapshotFromStripe($customer);
|
||||
$shipped = $this->addressSnapshotFromStripe($shipping, $customer) ?? $billing;
|
||||
|
|
@ -724,6 +730,43 @@ class StripeWebhookController
|
|||
return config('shop.models.order', Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the Checkout Session's amounts onto a not-yet-paid order: subtotal,
|
||||
* shipping (`total_details.amount_shipping`), discount, tax and total, all in
|
||||
* cents. The chosen shipping rate id goes to `meta.stripe_shipping_rate`.
|
||||
* An order that is already paid, or a session without amounts, is left alone.
|
||||
*/
|
||||
protected function applySessionTotals(Order $order, $session): void
|
||||
{
|
||||
if ($order->paid_at || ! isset($session->amount_total)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$details = data_get($session, 'total_details');
|
||||
$updates = array_filter([
|
||||
'amount_subtotal' => isset($session->amount_subtotal) ? (int) $session->amount_subtotal : null,
|
||||
'amount_shipping' => data_get($details, 'amount_shipping') !== null ? (int) data_get($details, 'amount_shipping') : null,
|
||||
'amount_discount' => data_get($details, 'amount_discount') !== null ? (int) data_get($details, 'amount_discount') : null,
|
||||
'amount_tax' => data_get($details, 'amount_tax') !== null ? (int) data_get($details, 'amount_tax') : null,
|
||||
'amount_total' => (int) $session->amount_total,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
$rate = data_get($session, 'shipping_cost.shipping_rate');
|
||||
if (is_object($rate)) {
|
||||
$rate = $rate->id ?? null;
|
||||
}
|
||||
|
||||
$order->fill($updates);
|
||||
if ($rate) {
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$meta['stripe_shipping_rate'] = $rate;
|
||||
$order->meta = (object) $meta;
|
||||
}
|
||||
if ($order->isDirty()) {
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the charge's balance transactions into the ledger when
|
||||
* `shop.ledger.sync_on_webhook` is on. Never throws (see ShopService::syncLedgerForCharge()).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Unit;
|
||||
|
||||
use Blax\Shop\Enums\OrderStatus;
|
||||
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionClass;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Shipping, discounts and tax picked on the Stripe Checkout page end up on the
|
||||
* order, so what was charged and what the order says agree.
|
||||
*/
|
||||
class StripeCheckoutTotalsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['shop.stripe.enabled' => true]);
|
||||
}
|
||||
|
||||
protected function complete(object $session): void
|
||||
{
|
||||
$controller = new StripeWebhookController;
|
||||
$m = (new ReflectionClass($controller))->getMethod('handleCheckoutSessionCompleted');
|
||||
$m->setAccessible(true);
|
||||
$m->invoke($controller, $session);
|
||||
}
|
||||
|
||||
protected function cart(int $price = 2400)
|
||||
{
|
||||
$customer = User::factory()->create();
|
||||
$product = Product::factory()->withPrices(unit_amount: $price)->create(['manage_stock' => false]);
|
||||
$customer->addToCart($product);
|
||||
|
||||
return $customer->checkoutCart();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shipping_from_the_session_is_added_to_the_order_and_the_order_is_fully_paid()
|
||||
{
|
||||
$cart = $this->cart(2400);
|
||||
|
||||
$this->complete((object) [
|
||||
'id' => 'cs_ship',
|
||||
'payment_intent' => 'pi_ship',
|
||||
'metadata' => (object) ['cart_id' => $cart->id],
|
||||
'client_reference_id' => $cart->id,
|
||||
'amount_subtotal' => 2400,
|
||||
'amount_total' => 2890,
|
||||
'total_details' => (object) ['amount_shipping' => 490, 'amount_discount' => 0, 'amount_tax' => 0],
|
||||
'shipping_cost' => (object) ['shipping_rate' => 'shr_123'],
|
||||
'currency' => 'eur',
|
||||
'payment_status' => 'paid',
|
||||
'collected_information' => (object) ['shipping_details' => (object) [
|
||||
'name' => 'Ola Kowalska',
|
||||
'address' => (object) ['line1' => 'ul. Długa 5', 'postal_code' => '00-238', 'city' => 'Warszawa', 'country' => 'PL'],
|
||||
]],
|
||||
'customer_details' => (object) [
|
||||
'name' => 'Ola Kowalska',
|
||||
'email' => 'ola@example.test',
|
||||
'address' => (object) ['line1' => 'ul. Długa 5', 'postal_code' => '00-238', 'city' => 'Warszawa', 'country' => 'PL'],
|
||||
],
|
||||
]);
|
||||
|
||||
$order = $cart->fresh()->order;
|
||||
$this->assertSame(490, $order->amount_shipping);
|
||||
$this->assertSame(2400, $order->amount_subtotal);
|
||||
$this->assertSame(2890, $order->amount_total);
|
||||
$this->assertSame(2890, $order->amount_paid);
|
||||
$this->assertTrue($order->is_fully_paid);
|
||||
$this->assertNotNull($order->paid_at);
|
||||
$this->assertSame(OrderStatus::PROCESSING, $order->status);
|
||||
$this->assertSame('shr_123', $order->getMeta('stripe_shipping_rate'));
|
||||
// Newer Stripe API versions carry the shipping address under collected_information.
|
||||
$this->assertSame('Warszawa', $order->shipping_address->city);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_discount_code_lowers_the_total_and_still_settles_the_order()
|
||||
{
|
||||
$cart = $this->cart(5000);
|
||||
|
||||
$this->complete((object) [
|
||||
'id' => 'cs_disc',
|
||||
'payment_intent' => 'pi_disc',
|
||||
'metadata' => (object) ['cart_id' => $cart->id],
|
||||
'amount_subtotal' => 5000,
|
||||
'amount_total' => 4500,
|
||||
'total_details' => (object) ['amount_shipping' => 0, 'amount_discount' => 500, 'amount_tax' => 0],
|
||||
'currency' => 'eur',
|
||||
'payment_status' => 'paid',
|
||||
]);
|
||||
|
||||
$order = $cart->fresh()->order;
|
||||
$this->assertSame(500, $order->amount_discount);
|
||||
$this->assertSame(4500, $order->amount_total);
|
||||
$this->assertTrue($order->is_fully_paid);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue