From 637ad392ce252ed7ec7a55eae898e4a3fc6a9e45 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 24 Sep 2026 11:30:55 +0200 Subject: [PATCH] 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) --- .../Controllers/StripeWebhookController.php | 45 +++++++- .../Unit/Stripe/StripeCheckoutTotalsTest.php | 106 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Stripe/StripeCheckoutTotalsTest.php diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php index d46e829..b5386cc 100644 --- a/src/Http/Controllers/StripeWebhookController.php +++ b/src/Http/Controllers/StripeWebhookController.php @@ -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()). diff --git a/tests/Unit/Stripe/StripeCheckoutTotalsTest.php b/tests/Unit/Stripe/StripeCheckoutTotalsTest.php new file mode 100644 index 0000000..68d7db3 --- /dev/null +++ b/tests/Unit/Stripe/StripeCheckoutTotalsTest.php @@ -0,0 +1,106 @@ + 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); + } +}