diff --git a/docs/payments.md b/docs/payments.md index 31bdde6..d87f489 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -145,23 +145,20 @@ produced had it resolved synchronously. with no memory of the request that started the payment. Something has to persist enough to answer "which order/cart does gateway reference X belong to?" between the two calls. -**Read directly from `lunarphp/stripe`'s own source** (`StripePaymentType::authorize()`, -`ProcessStripeWebhook`, `WebhookController`) to see how Lunar itself solves this — confirmed -it does **not** stash a generic opaque blob. It writes the correlating ids as real, typed -columns on `Lunar\Stripe\Models\StripePaymentIntent` (`cart_id`, `order_id`) at the moment the -intent is created/first seen, then reads them back the same way when the webhook arrives: +The precedent for this originally came from reading `lunarphp/stripe`'s own source +(`StripePaymentType::authorize()`, `ProcessStripeWebhook`, `WebhookController`) — that package +solved this the same way, writing the correlating ids as real, typed columns on its own +`StripePaymentIntent` model rather than a generic opaque blob. **`lunarphp/stripe` has since +been removed from this project** in favour of depending on `stripe/stripe-php` directly (see +CHANGELOG.md) — `Modules\Core\Payment\Models\StripePaymentIntent` is now a first-party model +over the same table shape, kept for exactly the same reason. -```php -// ProcessStripeWebhook::handle() — falls back through two real lookups, -// neither of them a generic context blob: -$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart - ?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first(); -``` - -**`StripePaymentDriver` follows this exact precedent**: it reads `cart_id`/`order_id` out of -`$context` at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` -row (a table already owned by `lunarphp/stripe`, already shaped for exactly this), then reads -them back the same way in `handleCallback()`. No generic `context` json column, no new table. +**`StripePaymentDriver` follows this pattern**: it reads `cart_id`/`order_id` out of `$context` +at `pay()`/`authorize()` time and writes them onto its own `StripePaymentIntent` row (`src/ +Payment/Models/StripePaymentIntent.php`, table `stripe_payment_intents`), then reads them back +the same way in `handleCallback()`. No generic `context` json column beyond what that table +already carries (`context`, added for a different purpose — see that migration's own +docblock), no new table. ### This pattern is per-driver, not a shared table @@ -176,6 +173,35 @@ a shared generic one. --- +## Reconciliation — a charge that succeeds on Stripe but is never written locally + +This app never creates or reuses a Stripe **Customer** object — every PaymentIntent is a +one-off (`StripePaymentDriver::createAndConfirm()`'s own `$params` never includes a `customer` +key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate +choice, not an oversight: a Customer object only earns its keep if something actually needs it +(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across +orders) — none of which exist in this checkout flow today. Creating one anyway would just be +more PII sitting on a third party's servers for no functional benefit, and it would become +another cross-reference a future Payment privacy provider has to account for (detaching/ +deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature +needs it later (e.g. "save my card"), add it then, scoped to that feature. + +The gap this creates: with no Customer object and no other identifying field previously sent +to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB +(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and +`rememberIntent()`'s insert) would be **untraceable** back to a cart or order — nothing to +search Stripe's dashboard by except amount, timestamp, and card last-4. + +**Fix**: `createAndConfirm()` now sets `metadata: ['cart_id' => ..., 'order_id' => ...]` +(`array_filter()`-ed, since `order_id` isn't known yet at initial `pay()`/`authorize()` time — +same null-coalesce `rememberIntent()` already does) on every PaymentIntent. This is metadata +only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a +Customer object and does not change anything about how `handleCallback()`/webhook correlation +works (that still goes through `stripe_payment_intents`, per "Async resolution" above). It's +purely a recovery aid for the case where our own write never happened at all. + +--- + ## Explicitly out of scope for this pass - **`Checkout`/`Order` wiring** — how `Checkout` calls into `Payment`, how `Order`/`Checkout` diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index b304a37..159f512 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -115,6 +115,25 @@ class StripePaymentDriver implements $params['payment_method'] = $data['payment_method']; } + // Reconciliation safety net: this app never creates a Stripe Customer + // object and attaches no other identifying info to the PaymentIntent + // (see docs/payments.md "Reconciliation" for the full reasoning), so + // without this, a charge that succeeds on Stripe's side but is never + // written to our own DB (e.g. a DB outage at exactly the wrong + // moment) would be untraceable back to a cart/order — nothing to + // search Stripe's dashboard by except amount/time/card last-4. + // array_filter() drops order_id when it's not yet known (still null + // in $context at initial pay()/authorize() time — see + // rememberIntent()'s own null-coalesce for the same case). + $metadata = array_filter([ + 'cart_id' => $context['cart_id'] ?? null, + 'order_id' => $context['order_id'] ?? null, + ]); + + if ($metadata !== []) { + $params['metadata'] = $metadata; + } + try { $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); } catch (ApiErrorException $e) {