diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa12d7..7500af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.27.2] - 2026-09-29 +### Fixed +- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent + on `(order_id, type, reference)`. A successful Stripe payment can legitimately + report `PaymentCaptured` twice for the same PaymentIntent (checkout's + synchronous capture via `pay()`, then the webhook confirming the same + outcome asynchronously via `handleCallback()`), both routing through + `resultFromIntent()`. With no dedupe check, this wrote two identical + `Transaction` rows for one real payment. Now returns the existing row + instead of creating a duplicate. + ## [0.27.1] - 2026-09-29 ### Added - Temp logger for Stripe webhook diff --git a/composer.json b/composer.json index 238dba6..594dcf7 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.27.1", + "version": "0.27.2", "autoload": { "psr-4": { "Modules\\Core\\": "src/" diff --git a/package.json b/package.json index 679bd63..7fbc5fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@boboko/core", - "version": "0.27.1", + "version": "0.27.2", "private": true, "type": "module", "description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.", diff --git a/src/Order/Services/TransactionRecorder.php b/src/Order/Services/TransactionRecorder.php index 423959a..2d75745 100644 --- a/src/Order/Services/TransactionRecorder.php +++ b/src/Order/Services/TransactionRecorder.php @@ -39,8 +39,26 @@ class TransactionRecorder * elsewhere in this codebase (see the old, now-removed * TransactionRecorder this replaces). */ + /** + * Idempotent on (order_id, type, reference): a successful payment + * outcome can legitimately be reported twice for the same gateway + * reference — e.g. Stripe's pay()/handleCallback() both call + * resultFromIntent() and both dispatch PaymentCaptured once a + * PaymentIntent reaches "succeeded" (checkout's synchronous capture, + * then the webhook confirming the same outcome asynchronously) — so + * this returns the existing row instead of writing a duplicate. + */ public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction { + $existing = $order->transactions() + ->where('type', $type) + ->where('reference', $result->reference) + ->first(); + + if ($existing !== null) { + return $existing; + } + return $order->transactions()->create([ 'success' => $result->status === PaymentResultStatus::Succeeded, 'type' => $type,