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:
Fabian @ Blax Software 2026-09-24 11:30:55 +02:00
parent 53832f49ce
commit 637ad392ce
2 changed files with 150 additions and 1 deletions

View File

@ -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()).

View File

@ -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);
}
}