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:
parent
a999156c08
commit
16fdf33f20
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue