feat(stripe): checkout confirmation links the charge and syncs the ledger

What charge.succeeded does, for shops that get no webhooks (local dev, a
broken endpoint): the order remembers its charge id and the charge's balance
transactions reach the ledger. Idempotent; never fails the confirmation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-25 12:32:54 +02:00
parent a999156c08
commit 16fdf33f20
2 changed files with 71 additions and 1 deletions

View File

@ -7,6 +7,7 @@ namespace Blax\Shop\Services;
use Blax\Shop\Http\Controllers\StripeWebhookController; use Blax\Shop\Http\Controllers\StripeWebhookController;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order; use Blax\Shop\Models\Order;
use Illuminate\Support\Facades\Log;
use Stripe\StripeClient; use Stripe\StripeClient;
/** /**
@ -66,6 +67,10 @@ class StripeCheckoutConfirmation
$cart = $cart->fresh(); $cart = $cart->fresh();
if ($status === self::PAID && $cart?->order) {
$this->linkCharge($cart->order, data_get($session, 'payment_intent'));
}
return [ return [
'status' => $status, 'status' => $status,
'order' => $cart?->order, 'order' => $cart?->order,
@ -73,6 +78,54 @@ class StripeCheckoutConfirmation
]; ];
} }
/**
* What charge.succeeded does, for a shop that gets no webhooks (local dev,
* a misconfigured endpoint): remember the charge on the order and pull its
* balance transactions into the ledger (when shop.ledger.sync_on_webhook).
* Idempotent, and never fails the confirmation.
*/
protected function linkCharge(Order $order, mixed $paymentIntent): void
{
$paymentIntentId = is_object($paymentIntent) ? ($paymentIntent->id ?? null) : $paymentIntent;
if (! is_string($paymentIntentId) || ! str_starts_with($paymentIntentId, 'pi_')) {
return;
}
try {
$chargeId = $order->getMeta('stripe_charge_id') ?: $this->latestCharge($paymentIntentId);
if (! is_string($chargeId) || $chargeId === '') {
return;
}
if ($order->getMeta('stripe_charge_id') !== $chargeId) {
$order->updateMetaKey('stripe_charge_id', $chargeId);
}
if (config('shop.ledger.sync_on_webhook')) {
app(ShopService::class)->syncLedgerForCharge($chargeId);
}
} catch (\Throwable $e) {
Log::warning('[shop] checkout confirmation could not link the charge', [
'order_id' => $order->getKey(),
'payment_intent' => $paymentIntentId,
'error' => $e->getMessage(),
]);
}
}
/** The PaymentIntent's charge id (overridable for tests). */
protected function latestCharge(string $paymentIntentId): ?string
{
$intent = (new StripeClient((string) config('services.stripe.secret')))
->paymentIntents->retrieve($paymentIntentId);
$charge = $intent->latest_charge ?? null;
return is_object($charge) ? ($charge->id ?? null) : $charge;
}
/** The Checkout Session from Stripe (overridable for tests). */ /** The Checkout Session from Stripe (overridable for tests). */
protected function retrieve(string $sessionId): object protected function retrieve(string $sessionId): object
{ {

View File

@ -3,7 +3,6 @@
namespace Blax\Shop\Tests\Unit\Stripe; namespace Blax\Shop\Tests\Unit\Stripe;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Facades\Cart as CartFacade; use Blax\Shop\Facades\Cart as CartFacade;
use Blax\Shop\Http\Controllers\StripeWebhookController; use Blax\Shop\Http\Controllers\StripeWebhookController;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
@ -99,9 +98,27 @@ class StripeCheckoutReturnTest extends TestCase
{ {
return $this->session; return $this->session;
} }
protected function latestCharge(string $paymentIntentId): ?string
{
return 'ch_for_'.$paymentIntentId;
}
}; };
} }
#[Test]
public function confirming_a_paid_session_remembers_the_charge_like_the_charge_webhook()
{
config(['shop.ledger.sync_on_webhook' => false]);
$cart = $this->guestCartWith($this->product(1000), 1);
$session = $this->stripeSession($cart);
$order = $this->confirmation($session)->confirm($session->id)['order'];
$this->assertSame('ch_for_'.$session->payment_intent, $order->fresh()->getMeta('stripe_charge_id'));
}
#[Test] #[Test]
public function a_second_checkout_attempt_updates_pending_purchases_to_the_edited_cart() public function a_second_checkout_attempt_updates_pending_purchases_to_the_edited_cart()
{ {