diff --git a/src/Services/StripeCheckoutConfirmation.php b/src/Services/StripeCheckoutConfirmation.php index cd4bd6b..9db39d8 100644 --- a/src/Services/StripeCheckoutConfirmation.php +++ b/src/Services/StripeCheckoutConfirmation.php @@ -7,6 +7,7 @@ namespace Blax\Shop\Services; use Blax\Shop\Http\Controllers\StripeWebhookController; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Order; +use Illuminate\Support\Facades\Log; use Stripe\StripeClient; /** @@ -66,6 +67,10 @@ class StripeCheckoutConfirmation $cart = $cart->fresh(); + if ($status === self::PAID && $cart?->order) { + $this->linkCharge($cart->order, data_get($session, 'payment_intent')); + } + return [ 'status' => $status, '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). */ protected function retrieve(string $sessionId): object { diff --git a/tests/Unit/Stripe/StripeCheckoutReturnTest.php b/tests/Unit/Stripe/StripeCheckoutReturnTest.php index 65e511e..c014452 100644 --- a/tests/Unit/Stripe/StripeCheckoutReturnTest.php +++ b/tests/Unit/Stripe/StripeCheckoutReturnTest.php @@ -3,7 +3,6 @@ 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; @@ -99,9 +98,27 @@ class StripeCheckoutReturnTest extends TestCase { 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] public function a_second_checkout_attempt_updates_pending_purchases_to_the_edited_cart() {