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