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\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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue