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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-24 10:50:25 +02:00
parent 08e459959e
commit 53832f49ce
8 changed files with 460 additions and 26 deletions

View File

@ -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',

View File

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

View File

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

View File

@ -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
*/

View File

@ -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
// =========================================================================

View File

@ -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<string, mixed> $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);
}
}

View File

@ -0,0 +1,227 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Http\Controllers\StripeWebhookController;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Services\ShopService;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Workbench\App\Models\User;
/**
* Stripe refunds are announced twice (refund.created + charge.refunded, in any order)
* and may already have been recorded by an admin refund. Every path must land on the
* same amount_refunded, in cents.
*/
class StripeRefundTest extends TestCase
{
use RefreshDatabase;
protected StripeWebhookController $controller;
protected function setUp(): void
{
parent::setUp();
config(['shop.stripe.enabled' => 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);
}
}

View File

@ -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]