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:
parent
08e459959e
commit
53832f49ce
|
|
@ -75,6 +75,13 @@ return [
|
||||||
* that was already counted, they are not revenue or cost.
|
* that was already counted, they are not revenue or cost.
|
||||||
*/
|
*/
|
||||||
'ledger' => [
|
'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' => [
|
'revenue_types' => [
|
||||||
'charge',
|
'charge',
|
||||||
'payment',
|
'payment',
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ use Illuminate\Support\Facades\Facade;
|
||||||
* @method static string recordBalanceTransaction($txn, $source = null)
|
* @method static string recordBalanceTransaction($txn, $source = null)
|
||||||
* @method static int syncLedgerForCharge(?string $chargeId)
|
* @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:
|
* Cart Queries:
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder carts()
|
* @method static \Illuminate\Database\Eloquent\Builder carts()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder activeCarts()
|
* @method static \Illuminate\Database\Eloquent\Builder activeCarts()
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,8 @@ class StripeCheckoutController
|
||||||
$updateData['charge_id'] = $session->payment_intent;
|
$updateData['charge_id'] = $session->payment_intent;
|
||||||
}
|
}
|
||||||
if (in_array('amount_paid', $purchase->getFillable())) {
|
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);
|
$purchase->update($updateData);
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,9 @@ class StripeWebhookController
|
||||||
];
|
];
|
||||||
|
|
||||||
if (in_array('amount_paid', $purchase->getFillable())) {
|
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);
|
$purchase->update($updateData);
|
||||||
|
|
@ -400,12 +402,20 @@ class StripeWebhookController
|
||||||
|
|
||||||
// Try to find related order via payment_intent
|
// Try to find related order via payment_intent
|
||||||
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
||||||
if ($order && ! $order->is_fully_paid) {
|
if ($order) {
|
||||||
$amountPaid = (int) ($charge->amount / 100);
|
// Remember the charge so ledger rows (source_id = ch_…) can be matched to the order.
|
||||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
if ($order->getMeta('stripe_charge_id') !== $charge->id) {
|
||||||
$order->recordPayment($amountPaid, $charge->id, 'stripe', 'stripe');
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,19 +460,19 @@ class StripeWebhookController
|
||||||
'amount_refunded' => $charge->amount_refunded,
|
'amount_refunded' => $charge->amount_refunded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Find order and record refund
|
// Find order and record refund. `amount_refunded` is the charge's running total
|
||||||
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
// 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) {
|
if ($order) {
|
||||||
$refundAmount = (int) ($charge->amount_refunded / 100);
|
$order->applyStripeRefundedTotal(
|
||||||
|
(int) ($charge->amount_refunded ?? 0),
|
||||||
// Only record refund if the amount changed
|
"Refund processed via Stripe (Charge: {$charge->id})"
|
||||||
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})");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->syncLedger($charge->id);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,11 +488,13 @@ class StripeWebhookController
|
||||||
'reason' => $dispute->reason,
|
'reason' => $dispute->reason,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncLedger($dispute->charge ?? null);
|
||||||
|
|
||||||
// Try to find order via the charge
|
// Try to find order via the charge
|
||||||
$order = $this->findOrderByChargeId($dispute->charge);
|
$order = $this->findOrderByChargeId($dispute->charge);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$order->update(['status' => OrderStatus::ON_HOLD]);
|
$order->update(['status' => OrderStatus::ON_HOLD]);
|
||||||
$disputeAmount = ($dispute->amount ?? 0) / 100;
|
$disputeAmount = (int) ($dispute->amount ?? 0);
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
'Payment dispute opened: '.($dispute->reason ?? 'Unknown reason').
|
'Payment dispute opened: '.($dispute->reason ?? 'Unknown reason').
|
||||||
" (Dispute: {$dispute->id}, Amount: ".Order::formatMoney($disputeAmount, $order->currency).')',
|
" (Dispute: {$dispute->id}, Amount: ".Order::formatMoney($disputeAmount, $order->currency).')',
|
||||||
|
|
@ -503,6 +515,8 @@ class StripeWebhookController
|
||||||
'status' => $dispute->status,
|
'status' => $dispute->status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->syncLedger($dispute->charge ?? null);
|
||||||
|
|
||||||
$order = $this->findOrderByChargeId($dispute->charge);
|
$order = $this->findOrderByChargeId($dispute->charge);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$outcome = $dispute->status === 'won' ? 'in your favor' : 'against you';
|
$outcome = $dispute->status === 'won' ? 'in your favor' : 'against you';
|
||||||
|
|
@ -542,7 +556,8 @@ class StripeWebhookController
|
||||||
];
|
];
|
||||||
|
|
||||||
if (in_array('amount_paid', $purchase->getFillable())) {
|
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);
|
$purchase->update($updateData);
|
||||||
|
|
@ -606,13 +621,20 @@ class StripeWebhookController
|
||||||
'amount' => $refund->amount,
|
'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) {
|
if ($order) {
|
||||||
$refundAmount = (int) ($refund->amount / 100);
|
// Cents; idempotent per refund id (see Order::applyStripeRefund()).
|
||||||
// recordRefund(int $amount, ?string $reason)
|
$order->applyStripeRefund(
|
||||||
$order->recordRefund($refundAmount, ($refund->reason ?? 'Refund created')." (Refund: {$refund->id})");
|
(string) $refund->id,
|
||||||
|
(int) ($refund->amount ?? 0),
|
||||||
|
($refund->reason ?? 'Refund created')." (Refund: {$refund->id})"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->syncLedger($refund->charge ?? null);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -626,7 +648,8 @@ class StripeWebhookController
|
||||||
'status' => $refund->status,
|
'status' => $refund->status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$order = $this->findOrderByChargeId($refund->charge);
|
$order = $this->findOrderByPaymentIntent($refund->payment_intent ?? null)
|
||||||
|
?? $this->findOrderByChargeId($refund->charge ?? null);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
"Refund status updated to: {$refund->status} (Refund: {$refund->id})",
|
"Refund status updated to: {$refund->status} (Refund: {$refund->id})",
|
||||||
|
|
@ -653,7 +676,7 @@ class StripeWebhookController
|
||||||
if ($invoice->metadata->order_id ?? null) {
|
if ($invoice->metadata->order_id ?? null) {
|
||||||
$order = $this->orderModel()::find($invoice->metadata->order_id);
|
$order = $this->orderModel()::find($invoice->metadata->order_id);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
$amountPaid = ($invoice->amount_paid ?? 0) / 100;
|
$amountPaid = (int) ($invoice->amount_paid ?? 0);
|
||||||
$order->addNote(
|
$order->addNote(
|
||||||
'Subscription invoice paid: '.Order::formatMoney($amountPaid, $order->currency)." (Invoice: {$invoice->id})",
|
'Subscription invoice paid: '.Order::formatMoney($amountPaid, $order->currency)." (Invoice: {$invoice->id})",
|
||||||
OrderNote::TYPE_PAYMENT
|
OrderNote::TYPE_PAYMENT
|
||||||
|
|
@ -701,6 +724,23 @@ class StripeWebhookController
|
||||||
return config('shop.models.order', Order::class);
|
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
|
* Find an order by payment intent ID
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,77 @@ class Order extends Model
|
||||||
return $this;
|
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
|
// NOTES MANAGEMENT
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1238,4 +1238,88 @@ class ShopService
|
||||||
return 0;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -237,7 +237,7 @@ class StripeWebhookOrderTest extends TestCase
|
||||||
$this->invokeMethod('handleChargeRefunded', [$charge]);
|
$this->invokeMethod('handleChargeRefunded', [$charge]);
|
||||||
|
|
||||||
$order->refresh();
|
$order->refresh();
|
||||||
$this->assertEquals(50, $order->amount_refunded);
|
$this->assertEquals(5000, $order->amount_refunded);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
@ -376,7 +376,7 @@ class StripeWebhookOrderTest extends TestCase
|
||||||
$this->invokeMethod('handleRefundCreated', [$refund]);
|
$this->invokeMethod('handleRefundCreated', [$refund]);
|
||||||
|
|
||||||
$order->refresh();
|
$order->refresh();
|
||||||
$this->assertEquals(25, $order->amount_refunded);
|
$this->assertEquals(2500, $order->amount_refunded);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue