From 53832f49ce96667eb7894dfffcc8ce99f81e8261 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 24 Sep 2026 10:50:25 +0200 Subject: [PATCH] fix(stripe): cents everywhere, idempotent refunds, admin refunds, webhook ledger sync - Webhook handlers stored euros in cent columns (charge.succeeded, charge.refunded, refund.created, payment_intent.succeeded, the success redirect, dispute/invoice notes). All amounts are cents now; purchases get their own line amount as amount_paid. - One Stripe refund arrives as refund.created AND charge.refunded, in either order. New Order::applyStripeRefund() / applyStripeRefundedTotal() raise the order's Stripe refund total (meta.stripe_refunded, meta.stripe_refunds) to what Stripe confirmed, so nothing is counted twice. refund.created now finds Checkout orders by payment_intent. - New ShopService::refundOrder($order, $cents, $reason) refunds through the Stripe API (idempotency key per order/amount/state) and records it, plus refundableAmount(). Bind \Stripe\StripeClient to swap the client. - charge.succeeded remembers meta.stripe_charge_id; with shop.ledger.sync_on_webhook (default off) charge/refund/dispute webhooks upsert the balance transactions into stripe_transactions. Co-Authored-By: Claude Opus 5.5 (1M context) --- config/shop.php | 7 + src/Facades/Shop.php | 4 + .../Controllers/StripeCheckoutController.php | 3 +- .../Controllers/StripeWebhookController.php | 86 +++++-- src/Models/Order.php | 71 ++++++ src/Services/ShopService.php | 84 +++++++ tests/Unit/Stripe/StripeRefundTest.php | 227 ++++++++++++++++++ tests/Unit/Stripe/StripeWebhookOrderTest.php | 4 +- 8 files changed, 460 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/Stripe/StripeRefundTest.php diff --git a/config/shop.php b/config/shop.php index 3565ef6..d287632 100644 --- a/config/shop.php +++ b/config/shop.php @@ -75,6 +75,13 @@ return [ * that was already counted, they are not revenue or cost. */ 'ledger' => [ + /* + * Sync the charge's balance transactions into the ledger whenever a + * charge, refund or dispute webhook arrives (one Stripe API call each). + * Off by default: the ledger then only fills via shop:import-stripe-ledger. + */ + 'sync_on_webhook' => env('SHOP_LEDGER_SYNC_ON_WEBHOOK', false), + 'revenue_types' => [ 'charge', 'payment', diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index 331305e..87fade7 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -73,6 +73,10 @@ use Illuminate\Support\Facades\Facade; * @method static string recordBalanceTransaction($txn, $source = null) * @method static int syncLedgerForCharge(?string $chargeId) * + * Refunds: + * @method static object refundOrder(\Blax\Shop\Models\Order $order, int $amount, ?string $reason = null, array $params = []) + * @method static int refundableAmount(\Blax\Shop\Models\Order $order) + * * Cart Queries: * @method static \Illuminate\Database\Eloquent\Builder carts() * @method static \Illuminate\Database\Eloquent\Builder activeCarts() diff --git a/src/Http/Controllers/StripeCheckoutController.php b/src/Http/Controllers/StripeCheckoutController.php index 3426d52..9167ccb 100644 --- a/src/Http/Controllers/StripeCheckoutController.php +++ b/src/Http/Controllers/StripeCheckoutController.php @@ -92,7 +92,8 @@ class StripeCheckoutController $updateData['charge_id'] = $session->payment_intent; } if (in_array('amount_paid', $purchase->getFillable())) { - $updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents + // Cents, like `amount`: each purchase is paid its own line amount. + $updateData['amount_paid'] = $purchase->amount; } $purchase->update($updateData); diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php index f7c057f..d46e829 100644 --- a/src/Http/Controllers/StripeWebhookController.php +++ b/src/Http/Controllers/StripeWebhookController.php @@ -388,7 +388,9 @@ class StripeWebhookController ]; if (in_array('amount_paid', $purchase->getFillable())) { - $updateData['amount_paid'] = $charge->amount / 100; + // Cents, like `amount`. The charge covers the whole cart, so each + // purchase is paid its own line amount. + $updateData['amount_paid'] = $purchase->amount; } $purchase->update($updateData); @@ -400,12 +402,20 @@ class StripeWebhookController // Try to find related order via payment_intent $order = $this->findOrderByPaymentIntent($charge->payment_intent); - if ($order && ! $order->is_fully_paid) { - $amountPaid = (int) ($charge->amount / 100); - // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider) - $order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe'); + if ($order) { + // Remember the charge so ledger rows (source_id = ch_…) can be matched to the order. + if ($order->getMeta('stripe_charge_id') !== $charge->id) { + $order->updateMetaKey('stripe_charge_id', $charge->id); + } + + if (! $order->is_fully_paid) { + // Stripe amounts are cents, like every amount_* column. + $order->recordPayment((int) $charge->amount, $charge->id, 'stripe', 'stripe'); + } } + $this->syncLedger($charge->id); + return true; } @@ -450,19 +460,19 @@ class StripeWebhookController 'amount_refunded' => $charge->amount_refunded, ]); - // Find order and record refund - $order = $this->findOrderByPaymentIntent($charge->payment_intent); + // Find order and record refund. `amount_refunded` is the charge's running total + // in cents; only the part the order doesn't know yet is recorded. + $order = $this->findOrderByPaymentIntent($charge->payment_intent) + ?? $this->findOrderByChargeId($charge->id); if ($order) { - $refundAmount = (int) ($charge->amount_refunded / 100); - - // Only record refund if the amount changed - if ($refundAmount > 0 && $order->amount_refunded < $refundAmount) { - $newRefundAmount = $refundAmount - $order->amount_refunded; - // recordRefund(int $amount, ?string $reason) - $order->recordRefund($newRefundAmount, "Refund processed via Stripe (Charge: {$charge->id})"); - } + $order->applyStripeRefundedTotal( + (int) ($charge->amount_refunded ?? 0), + "Refund processed via Stripe (Charge: {$charge->id})" + ); } + $this->syncLedger($charge->id); + return true; } @@ -478,11 +488,13 @@ class StripeWebhookController 'reason' => $dispute->reason, ]); + $this->syncLedger($dispute->charge ?? null); + // Try to find order via the charge $order = $this->findOrderByChargeId($dispute->charge); if ($order) { $order->update(['status' => OrderStatus::ON_HOLD]); - $disputeAmount = ($dispute->amount ?? 0) / 100; + $disputeAmount = (int) ($dispute->amount ?? 0); $order->addNote( 'Payment dispute opened: '.($dispute->reason ?? 'Unknown reason'). " (Dispute: {$dispute->id}, Amount: ".Order::formatMoney($disputeAmount, $order->currency).')', @@ -503,6 +515,8 @@ class StripeWebhookController 'status' => $dispute->status, ]); + $this->syncLedger($dispute->charge ?? null); + $order = $this->findOrderByChargeId($dispute->charge); if ($order) { $outcome = $dispute->status === 'won' ? 'in your favor' : 'against you'; @@ -542,7 +556,8 @@ class StripeWebhookController ]; if (in_array('amount_paid', $purchase->getFillable())) { - $updateData['amount_paid'] = $paymentIntent->amount / 100; + // Cents, like `amount`: each purchase is paid its own line amount. + $updateData['amount_paid'] = $purchase->amount; } $purchase->update($updateData); @@ -606,13 +621,20 @@ class StripeWebhookController 'amount' => $refund->amount, ]); - $order = $this->findOrderByChargeId($refund->charge); + // Checkout orders keep the payment intent as payment_reference, so try that first. + $order = $this->findOrderByPaymentIntent($refund->payment_intent ?? null) + ?? $this->findOrderByChargeId($refund->charge ?? null); if ($order) { - $refundAmount = (int) ($refund->amount / 100); - // recordRefund(int $amount, ?string $reason) - $order->recordRefund($refundAmount, ($refund->reason ?? 'Refund created')." (Refund: {$refund->id})"); + // Cents; idempotent per refund id (see Order::applyStripeRefund()). + $order->applyStripeRefund( + (string) $refund->id, + (int) ($refund->amount ?? 0), + ($refund->reason ?? 'Refund created')." (Refund: {$refund->id})" + ); } + $this->syncLedger($refund->charge ?? null); + return true; } @@ -626,7 +648,8 @@ class StripeWebhookController 'status' => $refund->status, ]); - $order = $this->findOrderByChargeId($refund->charge); + $order = $this->findOrderByPaymentIntent($refund->payment_intent ?? null) + ?? $this->findOrderByChargeId($refund->charge ?? null); if ($order) { $order->addNote( "Refund status updated to: {$refund->status} (Refund: {$refund->id})", @@ -653,7 +676,7 @@ class StripeWebhookController if ($invoice->metadata->order_id ?? null) { $order = $this->orderModel()::find($invoice->metadata->order_id); if ($order) { - $amountPaid = ($invoice->amount_paid ?? 0) / 100; + $amountPaid = (int) ($invoice->amount_paid ?? 0); $order->addNote( 'Subscription invoice paid: '.Order::formatMoney($amountPaid, $order->currency)." (Invoice: {$invoice->id})", OrderNote::TYPE_PAYMENT @@ -701,6 +724,23 @@ class StripeWebhookController return config('shop.models.order', Order::class); } + /** + * Upsert the charge's balance transactions into the ledger when + * `shop.ledger.sync_on_webhook` is on. Never throws (see ShopService::syncLedgerForCharge()). + */ + protected function syncLedger(?string $chargeId): void + { + if (! config('shop.ledger.sync_on_webhook') || ! $chargeId) { + return; + } + + try { + app(\Blax\Shop\Services\ShopService::class)->syncLedgerForCharge($chargeId); + } catch (\Throwable $e) { + Log::warning('[shop:ledger] webhook ledger sync failed', ['charge' => $chargeId, 'error' => $e->getMessage()]); + } + } + /** * Find an order by payment intent ID */ diff --git a/src/Models/Order.php b/src/Models/Order.php index a615a9c..4d00387 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -481,6 +481,77 @@ class Order extends Model return $this; } + /** + * Apply one Stripe refund (its `re_…` id and amount in cents) to this order. + * + * Idempotent and order-independent together with {@see applyStripeRefundedTotal()}: + * Stripe announces one refund both as `refund.created` (this refund) and as + * `charge.refunded` (the charge's running total), in either order, and an admin + * refund made through {@see \Blax\Shop\Services\ShopService::refundOrder()} is + * recorded before either webhook arrives. Every path raises the order's Stripe + * refund total to what Stripe has confirmed so far, so nothing is counted twice. + * + * @return int the amount (cents) newly recorded, 0 when it was already known + */ + public function applyStripeRefund(string $refundId, int $amount, ?string $reason = null): int + { + return $this->raiseStripeRefundedTotal(null, $refundId, $amount, $reason); + } + + /** + * Apply a charge's cumulative `amount_refunded` (cents, from `charge.refunded`). + * + * @return int the amount (cents) newly recorded, 0 when it was already known + */ + public function applyStripeRefundedTotal(int $total, ?string $reason = null): int + { + return $this->raiseStripeRefundedTotal($total, null, null, $reason); + } + + /** + * Stripe refunds recorded on this order so far: `meta.stripe_refunded` (cents) and + * `meta.stripe_refunds` (`re_…` id → cents). Refunds recorded by hand + * ({@see recordRefund()}) are not part of it. + */ + protected function raiseStripeRefundedTotal(?int $total, ?string $refundId, ?int $refundAmount, ?string $reason): int + { + return DB::transaction(function () use ($total, $refundId, $refundAmount, $reason) { + if ($this->exists) { + $locked = static::query()->whereKey($this->getKey())->lockForUpdate()->first(); + if ($locked) { + $this->setRawAttributes($locked->getAttributes(), true); + } + } + + $meta = (array) ($this->meta ?? []); + $refunds = (array) ($meta['stripe_refunds'] ?? []); + + if ($refundId !== null) { + if (array_key_exists($refundId, $refunds)) { + return 0; + } + $refunds[$refundId] = max(0, (int) $refundAmount); + } + + $recorded = (int) ($meta['stripe_refunded'] ?? 0); + $target = max($recorded, (int) array_sum($refunds), (int) ($total ?? 0)); + $delta = $target - $recorded; + + $meta['stripe_refunds'] = (object) $refunds; + $meta['stripe_refunded'] = $target; + $this->meta = (object) $meta; + + if ($delta > 0) { + // recordRefund() saves the model, meta included. + $this->recordRefund($delta, $reason); + } else { + $this->save(); + } + + return $delta; + }); + } + // ========================================================================= // NOTES MANAGEMENT // ========================================================================= diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index e9cdd1f..c3a2463 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1238,4 +1238,88 @@ class ShopService return 0; } } + + // ========================================================================= + // REFUNDS + // ========================================================================= + + /** + * Refund (part of) a Stripe-paid order through the Stripe API and record it on the order. + * + * `$amount` is in cents and may not exceed what is still refundable + * (`amount_paid - amount_refunded`). The refund is recorded with + * {@see Order::applyStripeRefund()}, so the `refund.created` / `charge.refunded` + * webhooks that follow do not count it again. `$reason` is stored on the order + * note and in the refund's metadata; Stripe's own `reason` enum can be passed in + * `$params` (e.g. `['reason' => 'requested_by_customer']`). + * + * @param array $params extra Stripe refund params + * @return object the Stripe Refund + * + * @throws \InvalidArgumentException when the amount is not refundable + * @throws \RuntimeException when the order has no Stripe payment or Stripe is not configured + */ + public function refundOrder(Order $order, int $amount, ?string $reason = null, array $params = []): object + { + $reference = (string) ($order->payment_reference ?? ''); + $isIntent = str_starts_with($reference, 'pi_'); + + if (! $isIntent && ! str_starts_with($reference, 'ch_')) { + throw new \RuntimeException('This order has no Stripe payment to refund.'); + } + + $refundable = $this->refundableAmount($order); + if ($amount < 1 || $amount > $refundable) { + throw new \InvalidArgumentException("Refund amount must be between 1 and {$refundable} cents."); + } + + $metadata = array_filter([ + 'order_id' => (string) $order->getKey(), + 'order_number' => (string) $order->order_number, + 'reason' => $reason ? mb_substr($reason, 0, 500) : null, + ]); + + $refund = $this->stripeClient()->refunds->create( + array_merge([ + ($isIntent ? 'payment_intent' : 'charge') => $reference, + 'amount' => $amount, + 'metadata' => $metadata, + ], $params), + // Same order, same amount, same refund state → the same refund, so a + // double-click cannot refund twice. + ['idempotency_key' => 'shop-refund-'.$order->getKey().'-'.(int) $order->amount_refunded.'-'.$amount] + ); + + $order->applyStripeRefund( + (string) $refund->id, + (int) ($refund->amount ?? $amount), + $reason ?: "Refunded via Stripe (Refund: {$refund->id})" + ); + + return $refund; + } + + /** Cents that can still be refunded on the order. */ + public function refundableAmount(Order $order): int + { + return max(0, (int) $order->amount_paid - (int) $order->amount_refunded); + } + + /** + * The Stripe API client. Bind `\Stripe\StripeClient` (or any object exposing + * `->refunds->create()`) in the container to override it, e.g. in tests. + */ + protected function stripeClient(): object + { + if (app()->bound(\Stripe\StripeClient::class)) { + return app(\Stripe\StripeClient::class); + } + + $secret = config('services.stripe.secret') ?: config('cashier.secret') ?: env('STRIPE_SECRET'); + if (! $secret) { + throw new \RuntimeException('Stripe is not configured.'); + } + + return new \Stripe\StripeClient($secret); + } } diff --git a/tests/Unit/Stripe/StripeRefundTest.php b/tests/Unit/Stripe/StripeRefundTest.php new file mode 100644 index 0000000..7bbdb94 --- /dev/null +++ b/tests/Unit/Stripe/StripeRefundTest.php @@ -0,0 +1,227 @@ + true]); + $this->controller = new StripeWebhookController; + } + + protected function invoke(string $method, array $args = []) + { + $reflection = new ReflectionClass($this->controller); + $m = $reflection->getMethod($method); + $m->setAccessible(true); + + return $m->invokeArgs($this->controller, $args); + } + + /** A 100.00 order paid through a Checkout Session (payment_reference = pi_…). */ + protected function paidOrder(string $intent = 'pi_refund_test'): Order + { + $customer = User::factory()->create(); + $product = Product::factory()->withPrices(unit_amount: 10000)->create(['manage_stock' => false]); + $customer->addToCart($product); + $cart = $customer->checkoutCart(); + + $order = $cart->fresh()->order; + $order->recordPayment(10000, $intent, 'stripe', 'stripe'); + + return $order->fresh(); + } + + protected function refundEvent(string $id, int $amount, string $intent = 'pi_refund_test'): object + { + return (object) [ + 'id' => $id, + 'charge' => 'ch_refund_test', + 'payment_intent' => $intent, + 'amount' => $amount, + 'reason' => 'requested_by_customer', + 'status' => 'succeeded', + ]; + } + + protected function chargeEvent(int $amountRefunded, string $intent = 'pi_refund_test'): object + { + return (object) [ + 'id' => 'ch_refund_test', + 'payment_intent' => $intent, + 'amount' => 10000, + 'amount_refunded' => $amountRefunded, + ]; + } + + #[Test] + public function refund_created_finds_a_checkout_order_by_payment_intent_and_records_cents() + { + $order = $this->paidOrder(); + + $this->invoke('handleRefundCreated', [$this->refundEvent('re_1', 2500)]); + + $this->assertSame(2500, $order->fresh()->amount_refunded); + } + + #[Test] + public function refund_created_then_charge_refunded_counts_the_refund_once() + { + $order = $this->paidOrder(); + + $this->invoke('handleRefundCreated', [$this->refundEvent('re_1', 2500)]); + $this->invoke('handleChargeRefunded', [$this->chargeEvent(2500)]); + + $this->assertSame(2500, $order->fresh()->amount_refunded); + } + + #[Test] + public function charge_refunded_then_refund_created_counts_the_refund_once() + { + $order = $this->paidOrder(); + + $this->invoke('handleChargeRefunded', [$this->chargeEvent(2500)]); + $this->invoke('handleRefundCreated', [$this->refundEvent('re_1', 2500)]); + + $this->assertSame(2500, $order->fresh()->amount_refunded); + } + + #[Test] + public function repeated_webhooks_are_idempotent_and_a_second_refund_adds_up() + { + $order = $this->paidOrder(); + + $this->invoke('handleRefundCreated', [$this->refundEvent('re_1', 2500)]); + $this->invoke('handleRefundCreated', [$this->refundEvent('re_1', 2500)]); + $this->invoke('handleChargeRefunded', [$this->chargeEvent(2500)]); + $this->invoke('handleRefundCreated', [$this->refundEvent('re_2', 1000)]); + $this->invoke('handleChargeRefunded', [$this->chargeEvent(3500)]); + + $order = $order->fresh(); + $this->assertSame(3500, $order->amount_refunded); + $this->assertSame(2, $order->notes()->where('type', 'refund')->count()); + $this->assertNotSame(OrderStatus::REFUNDED, $order->status); + } + + #[Test] + public function a_full_refund_marks_the_order_refunded() + { + $order = $this->paidOrder(); + + $this->invoke('handleChargeRefunded', [$this->chargeEvent(10000)]); + + $order = $order->fresh(); + $this->assertSame(10000, $order->amount_refunded); + $this->assertSame(OrderStatus::REFUNDED, $order->status); + } + + #[Test] + public function charge_succeeded_records_payment_in_cents_and_remembers_the_charge() + { + $customer = User::factory()->create(); + $product = Product::factory()->withPrices(unit_amount: 10000)->create(['manage_stock' => false]); + $customer->addToCart($product); + $order = $customer->checkoutCart()->fresh()->order; + $order->update(['payment_reference' => 'pi_charge_test']); + + $this->invoke('handleChargeSucceeded', [(object) [ + 'id' => 'ch_charge_test', + 'payment_intent' => 'pi_charge_test', + 'amount' => 10000, + ]]); + + $order = $order->fresh(); + $this->assertSame(10000, $order->amount_paid); + $this->assertTrue($order->is_fully_paid); + $this->assertSame('ch_charge_test', $order->getMeta('stripe_charge_id')); + } + + #[Test] + public function refund_order_calls_stripe_and_the_following_webhooks_add_nothing() + { + $order = $this->paidOrder(); + + $fake = new class + { + public array $calls = []; + + public object $refunds; + + public function __construct() + { + $outer = $this; + $this->refunds = new class($outer) + { + public function __construct(private object $outer) {} + + public function create(array $params, array $opts = []): object + { + $this->outer->calls[] = [$params, $opts]; + + return (object) ['id' => 're_admin_1', 'amount' => $params['amount'], 'status' => 'succeeded']; + } + }; + } + }; + $this->app->instance(\Stripe\StripeClient::class, $fake); + + $refund = app(ShopService::class)->refundOrder($order, 4000, 'Damaged in transit'); + + $this->assertSame('re_admin_1', $refund->id); + $this->assertCount(1, $fake->calls); + [$params, $opts] = $fake->calls[0]; + $this->assertSame('pi_refund_test', $params['payment_intent']); + $this->assertSame(4000, $params['amount']); + $this->assertSame((string) $order->id, $params['metadata']['order_id']); + $this->assertStringStartsWith('shop-refund-', $opts['idempotency_key']); + $this->assertSame(4000, $order->fresh()->amount_refunded); + + // Stripe then announces the same refund twice. + $this->invoke('handleRefundCreated', [$this->refundEvent('re_admin_1', 4000)]); + $this->invoke('handleChargeRefunded', [$this->chargeEvent(4000)]); + + $this->assertSame(4000, $order->fresh()->amount_refunded); + } + + #[Test] + public function refund_order_rejects_amounts_above_what_is_refundable() + { + $order = $this->paidOrder(); + $order->recordRefund(9000, 'manual'); + + $this->expectException(\InvalidArgumentException::class); + app(ShopService::class)->refundOrder($order->fresh(), 2000); + } + + #[Test] + public function refund_order_rejects_orders_without_a_stripe_payment() + { + $order = $this->paidOrder('manual-transfer'); + + $this->expectException(\RuntimeException::class); + app(ShopService::class)->refundOrder($order, 1000); + } +} diff --git a/tests/Unit/Stripe/StripeWebhookOrderTest.php b/tests/Unit/Stripe/StripeWebhookOrderTest.php index e30a7fa..ecfe358 100644 --- a/tests/Unit/Stripe/StripeWebhookOrderTest.php +++ b/tests/Unit/Stripe/StripeWebhookOrderTest.php @@ -237,7 +237,7 @@ class StripeWebhookOrderTest extends TestCase $this->invokeMethod('handleChargeRefunded', [$charge]); $order->refresh(); - $this->assertEquals(50, $order->amount_refunded); + $this->assertEquals(5000, $order->amount_refunded); } #[Test] @@ -376,7 +376,7 @@ class StripeWebhookOrderTest extends TestCase $this->invokeMethod('handleRefundCreated', [$refund]); $order->refresh(); - $this->assertEquals(25, $order->amount_refunded); + $this->assertEquals(2500, $order->amount_refunded); } #[Test]