fix(stripe): safe return from Checkout, retries and duplicate events
- checkoutSession() keeps pending purchases in step with the cart: a buyer
who cancels on Stripe and edits the cart pays for (and is invoiced) the
cart as it is, not the first attempt.
- checkout.session.completed is idempotent and locked per payment: a
redelivered event, a concurrent charge.succeeded or the success page
record the payment once. An unpaid (async) session creates the order but
records no payment until Stripe settles it.
- New StripeCheckoutConfirmation + WS cart.confirm {session_id}: the
success page settles a paid session through the webhook's code path, so
the order exists the moment the buyer is back.
- CartService::guest() never hands a converted cart back to its session.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f0084b7e4
commit
6aae653dac
|
|
@ -12,4 +12,5 @@ return [
|
||||||
'checkout_failed' => 'Der Checkout konnte nicht gestartet werden.',
|
'checkout_failed' => 'Der Checkout konnte nicht gestartet werden.',
|
||||||
'checkout_unavailable' => 'Der Checkout ist derzeit nicht verfügbar.',
|
'checkout_unavailable' => 'Der Checkout ist derzeit nicht verfügbar.',
|
||||||
'checkout_created' => 'Du wirst zur Zahlung weitergeleitet.',
|
'checkout_created' => 'Du wirst zur Zahlung weitergeleitet.',
|
||||||
|
'confirm_failed' => 'Wir konnten deine Zahlung gerade nicht prüfen. Bitte versuch es gleich noch einmal.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ return [
|
||||||
'checkout_failed' => 'Checkout could not be started.',
|
'checkout_failed' => 'Checkout could not be started.',
|
||||||
'checkout_unavailable' => 'Checkout is currently unavailable.',
|
'checkout_unavailable' => 'Checkout is currently unavailable.',
|
||||||
'checkout_created' => 'Redirecting to payment.',
|
'checkout_created' => 'Redirecting to payment.',
|
||||||
|
'confirm_failed' => 'We could not check your payment. Please try again in a moment.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ return [
|
||||||
'checkout_failed' => 'Nie udało się rozpocząć płatności.',
|
'checkout_failed' => 'Nie udało się rozpocząć płatności.',
|
||||||
'checkout_unavailable' => 'Płatność jest obecnie niedostępna.',
|
'checkout_unavailable' => 'Płatność jest obecnie niedostępna.',
|
||||||
'checkout_created' => 'Przekierowujemy do płatności.',
|
'checkout_created' => 'Przekierowujemy do płatności.',
|
||||||
|
'confirm_failed' => 'Nie udało się sprawdzić płatności. Spróbuj ponownie za chwilę.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use Blax\Shop\Models\OrderNote;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductPurchase;
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Stripe\Exception\SignatureVerificationException;
|
use Stripe\Exception\SignatureVerificationException;
|
||||||
use Stripe\Stripe;
|
use Stripe\Stripe;
|
||||||
|
|
@ -113,8 +114,25 @@ class StripeWebhookController
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fulfil a completed Checkout Session outside the webhook, e.g. when the
|
||||||
|
* buyer lands on the success page before Stripe's event does. Same code
|
||||||
|
* path (and lock) as `checkout.session.completed`, so whichever comes
|
||||||
|
* first creates the order and the other finds it done.
|
||||||
|
*/
|
||||||
|
public function completeCheckoutSession($session): bool
|
||||||
|
{
|
||||||
|
return $this->handleCheckoutSessionCompleted($session);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle checkout.session.completed event
|
* Handle checkout.session.completed event
|
||||||
|
*
|
||||||
|
* Idempotent: Stripe delivers events at least once, and the success page
|
||||||
|
* may fulfil the same session (completeCheckoutSession). Runs under a lock
|
||||||
|
* per payment, records the payment once, and only when Stripe reports the
|
||||||
|
* session paid (async methods complete `unpaid` first and settle later via
|
||||||
|
* checkout.session.async_payment_succeeded).
|
||||||
*/
|
*/
|
||||||
protected function handleCheckoutSessionCompleted($session): bool
|
protected function handleCheckoutSessionCompleted($session): bool
|
||||||
{
|
{
|
||||||
|
|
@ -126,13 +144,31 @@ class StripeWebhookController
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = Cart::find($cartId);
|
if (! Cart::find($cartId)) {
|
||||||
if (! $cart) {
|
|
||||||
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
|
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->withPaymentLock($session->payment_intent ?? "cart:{$cartId}", function () use ($cartId, $session) {
|
||||||
|
return $this->fulfilCheckoutSession(Cart::find($cartId), $session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run $callback while holding the lock for one payment, so concurrent
|
||||||
|
* deliveries (checkout.session.completed, charge.succeeded, the success
|
||||||
|
* page) never both create an order or record the same payment twice.
|
||||||
|
* A lock that can't be had in time throws, and Stripe retries the event.
|
||||||
|
*/
|
||||||
|
protected function withPaymentLock(string $key, callable $callback): mixed
|
||||||
|
{
|
||||||
|
return Cache::lock("shop:stripe-payment:{$key}", 60)->block(20, $callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** checkout.session.completed for a cart that exists; see handleCheckoutSessionCompleted(). */
|
||||||
|
protected function fulfilCheckoutSession(Cart $cart, $session): bool
|
||||||
|
{
|
||||||
// Only update if not already converted
|
// Only update if not already converted
|
||||||
if ($cart->status !== CartStatus::CONVERTED) {
|
if ($cart->status !== CartStatus::CONVERTED) {
|
||||||
$cart->update([
|
$cart->update([
|
||||||
|
|
@ -177,6 +213,22 @@ class StripeWebhookController
|
||||||
$amountPaid = (int) ($session->amount_total ?? 0);
|
$amountPaid = (int) ($session->amount_total ?? 0);
|
||||||
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
|
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
|
||||||
|
|
||||||
|
$order->refresh();
|
||||||
|
$settled = in_array($session->payment_status ?? 'paid', ['paid', 'no_payment_required'], true);
|
||||||
|
$recorded = $order->paid_at
|
||||||
|
|| ($session->payment_intent && $order->payment_reference === $session->payment_intent && $order->amount_paid > 0);
|
||||||
|
|
||||||
|
if (! $settled || $recorded) {
|
||||||
|
Log::info('Stripe checkout session: no payment to record', [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'payment_status' => $session->payment_status ?? null,
|
||||||
|
'already_recorded' => (bool) $recorded,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
||||||
$order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe');
|
$order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe');
|
||||||
|
|
||||||
|
|
@ -406,9 +458,14 @@ class StripeWebhookController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find related order via payment_intent
|
// Try to find related order via payment_intent. Under the payment lock: a
|
||||||
|
// checkout.session.completed for the same payment may be recording it right now.
|
||||||
|
$this->withPaymentLock($charge->payment_intent ?? "charge:{$charge->id}", function () use ($charge) {
|
||||||
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
$order = $this->findOrderByPaymentIntent($charge->payment_intent);
|
||||||
if ($order) {
|
if (! $order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Remember the charge so ledger rows (source_id = ch_…) can be matched to the order.
|
// Remember the charge so ledger rows (source_id = ch_…) can be matched to the order.
|
||||||
if ($order->getMeta('stripe_charge_id') !== $charge->id) {
|
if ($order->getMeta('stripe_charge_id') !== $charge->id) {
|
||||||
$order->updateMetaKey('stripe_charge_id', $charge->id);
|
$order->updateMetaKey('stripe_charge_id', $charge->id);
|
||||||
|
|
@ -418,7 +475,7 @@ class StripeWebhookController
|
||||||
// Stripe amounts are cents, like every amount_* column.
|
// Stripe amounts are cents, like every amount_* column.
|
||||||
$order->recordPayment((int) $charge->amount, $charge->id, 'stripe', 'stripe');
|
$order->recordPayment((int) $charge->amount, $charge->id, 'stripe', 'stripe');
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
$this->syncLedger($charge->id);
|
$this->syncLedger($charge->id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2430,11 +2430,38 @@ class Cart extends Model
|
||||||
// Validate cart before proceeding (doesn't convert it)
|
// Validate cart before proceeding (doesn't convert it)
|
||||||
$this->validateForCheckout();
|
$this->validateForCheckout();
|
||||||
|
|
||||||
// Create ProductPurchase records for each cart item
|
// Create ProductPurchase records for each cart item. A buyer who left an
|
||||||
|
// earlier session (cancel, back button) may have changed the cart since:
|
||||||
|
// pending purchases follow the lines as they are now, and the ones whose
|
||||||
|
// line is gone are dropped, so the order paid for is the cart on screen.
|
||||||
DB::transaction(function () {
|
DB::transaction(function () {
|
||||||
|
$this->unsetRelation('items');
|
||||||
|
$linked = $this->items->pluck('purchase_id')->filter()->all();
|
||||||
|
|
||||||
|
ProductPurchase::query()
|
||||||
|
->where('cart_id', $this->id)
|
||||||
|
->where('status', PurchaseStatus::PENDING)
|
||||||
|
->whereNotIn('id', $linked)
|
||||||
|
->delete();
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
foreach ($this->items as $item) {
|
||||||
// Skip if purchase already exists
|
$existing = $item->purchase_id ? ProductPurchase::find($item->purchase_id) : null;
|
||||||
if ($item->purchase_id) {
|
|
||||||
|
if ($existing && $existing->status === PurchaseStatus::PENDING) {
|
||||||
|
$existing->update([
|
||||||
|
'price_id' => $item->price_id,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'amount' => $item->subtotal,
|
||||||
|
'from' => $item->from,
|
||||||
|
'until' => $item->until,
|
||||||
|
'meta' => $item->meta,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if purchase already exists (and is past pending)
|
||||||
|
if ($existing) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,18 @@ class CartService
|
||||||
'customer_type' => null,
|
'customer_type' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// A cart that became an order keeps its row but lets go of the session
|
||||||
|
// (session_id is unique), so the guest shops on with an empty cart.
|
||||||
|
if ($cart->isConverted()) {
|
||||||
|
$cart->update(['session_id' => 'converted_'.$cart->getKey()]);
|
||||||
|
|
||||||
|
$cart = Cart::create([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'customer_id' => null,
|
||||||
|
'customer_type' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Store cart ID in session
|
// Store cart ID in session
|
||||||
Cart::setSession($cart);
|
Cart::setSession($cart);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Services;
|
||||||
|
|
||||||
|
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\Order;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the buyer's return from Stripe Checkout means for the shop.
|
||||||
|
*
|
||||||
|
* The success page knows the Checkout Session id (Stripe appends it to the
|
||||||
|
* success URL). confirm() asks Stripe for that session and, when it is paid,
|
||||||
|
* fulfils it through the webhook's own code path
|
||||||
|
* (StripeWebhookController::completeCheckoutSession, idempotent and locked),
|
||||||
|
* so the order exists the moment the buyer arrives, even if the webhook is
|
||||||
|
* slow or not configured. The session id is the credential: it is only known
|
||||||
|
* to whoever went through that checkout.
|
||||||
|
*
|
||||||
|
* Statuses:
|
||||||
|
* - `paid` payment settled, `order` is set
|
||||||
|
* - `processing` checkout finished, payment still settling (bank transfer, SEPA, …)
|
||||||
|
* - `open` the session was never completed (buyer came back without paying)
|
||||||
|
* - `expired` the session timed out; the cart can start a new checkout
|
||||||
|
*/
|
||||||
|
class StripeCheckoutConfirmation
|
||||||
|
{
|
||||||
|
public const PAID = 'paid';
|
||||||
|
|
||||||
|
public const PROCESSING = 'processing';
|
||||||
|
|
||||||
|
public const OPEN = 'open';
|
||||||
|
|
||||||
|
public const EXPIRED = 'expired';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, order: Order|null, cart: Cart|null}
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException the session doesn't belong to a cart of this shop
|
||||||
|
*/
|
||||||
|
public function confirm(string $sessionId): array
|
||||||
|
{
|
||||||
|
$session = $this->retrieve($sessionId);
|
||||||
|
|
||||||
|
$cartId = data_get($session, 'metadata.cart_id') ?? data_get($session, 'client_reference_id');
|
||||||
|
$cartModel = config('shop.models.cart', Cart::class);
|
||||||
|
$cart = $cartId ? $cartModel::find($cartId) : null;
|
||||||
|
|
||||||
|
if (! $cart) {
|
||||||
|
throw new \InvalidArgumentException('This checkout session does not belong to a cart of this shop.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = match (true) {
|
||||||
|
data_get($session, 'status') === 'expired' => self::EXPIRED,
|
||||||
|
data_get($session, 'status') !== 'complete' => self::OPEN,
|
||||||
|
in_array(data_get($session, 'payment_status'), ['paid', 'no_payment_required'], true) => self::PAID,
|
||||||
|
default => self::PROCESSING,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($status === self::PAID || $status === self::PROCESSING) {
|
||||||
|
app(StripeWebhookController::class)->completeCheckoutSession($session);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart = $cart->fresh();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'order' => $cart?->order,
|
||||||
|
'cart' => $cart,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Checkout Session from Stripe (overridable for tests). */
|
||||||
|
protected function retrieve(string $sessionId): object
|
||||||
|
{
|
||||||
|
return (new StripeClient((string) config('services.stripe.secret')))
|
||||||
|
->checkout->sessions->retrieve($sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ use Blax\Shop\Enums\CartStatus;
|
||||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
use Blax\Shop\Facades\Cart as CartFacade;
|
use Blax\Shop\Facades\Cart as CartFacade;
|
||||||
use Blax\Shop\Http\Resources\CartResource;
|
use Blax\Shop\Http\Resources\CartResource;
|
||||||
|
use Blax\Shop\Services\StripeCheckoutConfirmation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable storefront cart controller over the Blax WebSocket bridge.
|
* Reusable storefront cart controller over the Blax WebSocket bridge.
|
||||||
|
|
@ -172,6 +173,49 @@ class CartController extends \BlaxSoftware\LaravelWebSockets\Websocket\Controlle
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `cart.confirm {session_id}` → `{ status, order: {id, order_number, status}|null, cart }`
|
||||||
|
*
|
||||||
|
* The buyer is back from Stripe Checkout (the success URL carries
|
||||||
|
* `session_id`). Settles the session through the webhook's code path, see
|
||||||
|
* {@see StripeCheckoutConfirmation} for the statuses. `cart` is the cart
|
||||||
|
* this connection shops with now: a fresh one once the old cart became an
|
||||||
|
* order, so the client can swap its stored cart id right away.
|
||||||
|
*/
|
||||||
|
public function confirm()
|
||||||
|
{
|
||||||
|
$data = request()->validate([
|
||||||
|
'session_id' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = app(StripeCheckoutConfirmation::class)->confirm($data['session_id']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->error([
|
||||||
|
'message' => __('shop::cart.confirm_failed'),
|
||||||
|
'detail' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $result['order'];
|
||||||
|
|
||||||
|
// booted() adopted the client's cart before the payment was settled; a
|
||||||
|
// converted cart hands this connection a fresh one.
|
||||||
|
if ($this->cart->isConverted()) {
|
||||||
|
$this->cart = CartFacade::adopt(null, $this->connection->socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'status' => $result['status'],
|
||||||
|
'order' => $order ? [
|
||||||
|
'id' => $order->getKey(),
|
||||||
|
'order_number' => $order->order_number,
|
||||||
|
'status' => $order->status,
|
||||||
|
] : null,
|
||||||
|
'cart' => CartResource::make($this->cart->fresh()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the overloaded `cart_item_id` into a purchasable (CartItem's
|
* Resolve the overloaded `cart_item_id` into a purchasable (CartItem's
|
||||||
* purchasable, else a ProductPrice, else a Product).
|
* purchasable, else a ProductPrice, else a Product).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Stripe;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Facades\Cart as CartFacade;
|
||||||
|
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Services\StripeCheckoutConfirmation;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buyer's round trip through Stripe Checkout: leaving and coming back
|
||||||
|
* (cancel → edit cart → pay), the success page settling the session before
|
||||||
|
* the webhook does, and Stripe delivering the same event more than once.
|
||||||
|
*/
|
||||||
|
class StripeCheckoutReturnTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private string $apiBase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['shop.stripe.enabled' => true, 'services.stripe.secret' => 'sk_test_fake']);
|
||||||
|
|
||||||
|
// Any real Stripe call fails at once instead of reaching the network.
|
||||||
|
$this->apiBase = \Stripe\Stripe::$apiBase;
|
||||||
|
\Stripe\Stripe::$apiBase = 'http://127.0.0.1:9';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
\Stripe\Stripe::$apiBase = $this->apiBase;
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guestCartWith(Product $product, int $quantity): Cart
|
||||||
|
{
|
||||||
|
$cart = Cart::factory()->create(['session_id' => 'socket-A', 'customer_id' => null]);
|
||||||
|
$cart->addToCart($product, $quantity);
|
||||||
|
|
||||||
|
return $cart->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function product(int $price = 1000): Product
|
||||||
|
{
|
||||||
|
return Product::factory()->withPrices(unit_amount: $price)->create(['manage_stock' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripeSession(Cart $cart, array $overrides = []): object
|
||||||
|
{
|
||||||
|
return (object) array_merge([
|
||||||
|
'id' => 'cs_test_'.uniqid(),
|
||||||
|
'status' => 'complete',
|
||||||
|
'payment_intent' => 'pi_test_'.uniqid(),
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'client_reference_id' => $cart->id,
|
||||||
|
'amount_total' => (int) $cart->getTotal(),
|
||||||
|
'currency' => 'eur',
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completed(object $session): bool
|
||||||
|
{
|
||||||
|
$controller = new StripeWebhookController;
|
||||||
|
$method = (new ReflectionClass($controller))->getMethod('handleCheckoutSessionCompleted');
|
||||||
|
|
||||||
|
return $method->invoke($controller, $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run checkoutSession() up to the Stripe call (which fails here by design). */
|
||||||
|
private function startCheckout(Cart $cart): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$cart->fresh()->checkoutSession();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// The session itself isn't under test, the purchases written before it are.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function confirmation(object $session): StripeCheckoutConfirmation
|
||||||
|
{
|
||||||
|
return new class($session) extends StripeCheckoutConfirmation
|
||||||
|
{
|
||||||
|
public function __construct(private object $session) {}
|
||||||
|
|
||||||
|
protected function retrieve(string $sessionId): object
|
||||||
|
{
|
||||||
|
return $this->session;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_second_checkout_attempt_updates_pending_purchases_to_the_edited_cart()
|
||||||
|
{
|
||||||
|
$kept = $this->product(1000);
|
||||||
|
$dropped = $this->product(500);
|
||||||
|
|
||||||
|
$cart = $this->guestCartWith($kept, 1);
|
||||||
|
$cart->addToCart($dropped, 1);
|
||||||
|
|
||||||
|
$this->startCheckout($cart);
|
||||||
|
$this->assertSame(2, ProductPurchase::where('cart_id', $cart->id)->count());
|
||||||
|
|
||||||
|
// Back from Stripe without paying: one more of the first, the second removed.
|
||||||
|
$cart = $cart->fresh();
|
||||||
|
$cart->addToCart($kept, 2);
|
||||||
|
$cart->removeFromCart($cart->items()->where('purchasable_id', $dropped->id)->first(), 1);
|
||||||
|
|
||||||
|
$this->startCheckout($cart);
|
||||||
|
|
||||||
|
$purchases = ProductPurchase::where('cart_id', $cart->id)->get();
|
||||||
|
$this->assertCount(1, $purchases);
|
||||||
|
$this->assertSame($kept->id, $purchases->first()->purchasable_id);
|
||||||
|
$this->assertSame(3, $purchases->first()->quantity);
|
||||||
|
$this->assertSame(3000, $purchases->first()->amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_redelivered_completed_event_records_the_payment_once()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(1000), 2);
|
||||||
|
$session = $this->stripeSession($cart);
|
||||||
|
|
||||||
|
$this->assertTrue($this->completed($session));
|
||||||
|
$this->assertTrue($this->completed($session));
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertSame(2000, $order->amount_paid);
|
||||||
|
$this->assertNotNull($order->paid_at);
|
||||||
|
$this->assertSame(OrderStatus::PROCESSING, $order->status);
|
||||||
|
$this->assertSame(1, $order->notes()->where('content', 'like', 'Payment of%')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_unpaid_completed_session_creates_the_order_but_no_payment()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(1000), 1);
|
||||||
|
$session = $this->stripeSession($cart, ['payment_status' => 'unpaid']);
|
||||||
|
|
||||||
|
$this->completed($session);
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertNotNull($order);
|
||||||
|
$this->assertSame(0, $order->amount_paid);
|
||||||
|
$this->assertNull($order->paid_at);
|
||||||
|
$this->assertSame(OrderStatus::PENDING, $order->status);
|
||||||
|
|
||||||
|
// The async payment settles later: now it is recorded.
|
||||||
|
$this->completed($this->stripeSession($cart, ['id' => $session->id, 'payment_intent' => $session->payment_intent]));
|
||||||
|
|
||||||
|
$this->assertSame(1000, $order->fresh()->amount_paid);
|
||||||
|
$this->assertNotNull($order->fresh()->paid_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function confirming_a_paid_session_creates_the_order_before_the_webhook()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(1500), 1);
|
||||||
|
$session = $this->stripeSession($cart);
|
||||||
|
|
||||||
|
$result = $this->confirmation($session)->confirm($session->id);
|
||||||
|
|
||||||
|
$this->assertSame(StripeCheckoutConfirmation::PAID, $result['status']);
|
||||||
|
$this->assertNotNull($result['order']);
|
||||||
|
$this->assertSame(1500, $result['order']->amount_paid);
|
||||||
|
|
||||||
|
// The webhook arrives afterwards and finds the work done.
|
||||||
|
$this->completed($session);
|
||||||
|
|
||||||
|
$this->assertSame(1500, $result['order']->fresh()->amount_paid);
|
||||||
|
$this->assertSame(1, Cart::find($cart->id)->order()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function confirming_an_open_or_expired_session_leaves_the_cart_alone()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(), 1);
|
||||||
|
|
||||||
|
$open = $this->confirmation($this->stripeSession($cart, ['status' => 'open', 'payment_status' => 'unpaid']))->confirm('cs_x');
|
||||||
|
$expired = $this->confirmation($this->stripeSession($cart, ['status' => 'expired', 'payment_status' => 'unpaid']))->confirm('cs_x');
|
||||||
|
|
||||||
|
$this->assertSame(StripeCheckoutConfirmation::OPEN, $open['status']);
|
||||||
|
$this->assertSame(StripeCheckoutConfirmation::EXPIRED, $expired['status']);
|
||||||
|
$this->assertNull($cart->fresh()->order);
|
||||||
|
$this->assertFalse($cart->fresh()->isConverted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function confirming_a_session_of_an_unknown_cart_is_refused()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(), 1);
|
||||||
|
$session = $this->stripeSession($cart, ['metadata' => (object) ['cart_id' => 'nope'], 'client_reference_id' => null]);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$this->confirmation($session)->confirm($session->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_socket_that_paid_gets_a_fresh_cart()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(), 1);
|
||||||
|
$this->completed($this->stripeSession($cart));
|
||||||
|
|
||||||
|
$next = CartFacade::adopt($cart->id, 'socket-A');
|
||||||
|
|
||||||
|
$this->assertFalse($next->is($cart));
|
||||||
|
$this->assertFalse($next->isConverted());
|
||||||
|
$this->assertSame('socket-A', $next->session_id);
|
||||||
|
$this->assertSame(0, $next->items()->count());
|
||||||
|
$this->assertTrue($cart->fresh()->isConverted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_charge_event_after_checkout_does_not_pay_twice()
|
||||||
|
{
|
||||||
|
$cart = $this->guestCartWith($this->product(1000), 1);
|
||||||
|
$session = $this->stripeSession($cart);
|
||||||
|
$this->completed($session);
|
||||||
|
|
||||||
|
$controller = new StripeWebhookController;
|
||||||
|
$method = (new ReflectionClass($controller))->getMethod('handleChargeSucceeded');
|
||||||
|
$method->invoke($controller, (object) [
|
||||||
|
'id' => 'ch_test_1',
|
||||||
|
'payment_intent' => $session->payment_intent,
|
||||||
|
'amount' => 1000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
$this->assertSame(1000, $order->amount_paid);
|
||||||
|
$this->assertSame('ch_test_1', $order->getMeta('stripe_charge_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue