From f1d1fb0f71fb99ca5b5e6a1c50e095bc30abb162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 27 Jun 2026 16:20:28 +0200 Subject: [PATCH] test(subscriptions): live Stripe-sandbox verifier for the refund flow A guarded stripe:verify-refund command that exercises the one path Pest can only mock: it creates a real immediately-charged subscription in the Stripe test environment, runs the actual RefundSelfServe, and confirms via the Stripe API that the charge was refunded and the subscription canceled. Refuses to run outside Stripe test keys / production. Verified: PASS end-to-end. --- .../Commands/VerifyRefundFlowCommand.php | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 app/Console/Commands/VerifyRefundFlowCommand.php diff --git a/app/Console/Commands/VerifyRefundFlowCommand.php b/app/Console/Commands/VerifyRefundFlowCommand.php new file mode 100644 index 00000000..1870adfa --- /dev/null +++ b/app/Console/Commands/VerifyRefundFlowCommand.php @@ -0,0 +1,105 @@ +isProduction() || ! str_starts_with((string) config('cashier.secret'), 'sk_test')) { + $this->error('Refusing to run: this command requires Stripe test keys and a non-production environment.'); + + return self::FAILURE; + } + + config(['subscriptions.tax_rates' => []]); + + $passed = true; + $check = function (string $label, bool $ok) use (&$passed): void { + $this->line(($ok ? 'PASS' : 'FAIL').' '.$label); + $passed = $passed && $ok; + }; + + $lookup = config('subscriptions.plans.monthly.stripe_lookup_key'); + $prices = Cashier::stripe()->prices->all(['lookup_keys' => [$lookup], 'limit' => 1]); + $priceId = $prices->data[0]->id ?? null; + + if ($priceId === null) { + $this->error("No Stripe price found for lookup key '{$lookup}'."); + + return self::FAILURE; + } + + $user = User::factory()->create([ + 'email' => 'refund-sandbox-'.uniqid().'@whisper.test', + 'created_at' => now(), + ]); + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW); + + try { + $user->newSubscription('default', $priceId)->create('pm_card_visa'); + + $subscription = $user->subscription('default'); + $check('subscription active after immediate charge', $subscription->active() && $subscription->stripe_status === 'active'); + $check('canSelfRefund is true before refund', $this->offer->canSelfRefund($user)); + + $paymentIntentId = $subscription->latestPayment()?->id; + $check('latestPayment() resolves a payment intent', $paymentIntentId !== null); + + app(RefundSelfServe::class)->handle($user->fresh()); + + $subscription = $user->subscription('default')->fresh(); + $check('refunded_at is stamped', $subscription->refunded_at !== null); + $check('subscription is canceled', $subscription->canceled()); + $check('canSelfRefund is false after refund', ! $this->offer->canSelfRefund($user->fresh())); + + $intent = Cashier::stripe()->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]); + $charge = $intent->latest_charge; + $check('Stripe charge shows a full refund', is_object($charge) && $charge->refunded === true); + $this->line(' amount_refunded='.($charge->amount_refunded ?? 'n/a')); + } catch (\Throwable $exception) { + $this->error($exception::class.': '.$exception->getMessage()); + $passed = false; + } finally { + try { + if ($user->hasStripeId()) { + Cashier::stripe()->customers->delete($user->stripe_id); + } + } catch (\Throwable) { + // best-effort sandbox cleanup + } + $user->forceDelete(); + } + + $this->newLine(); + $this->{$passed ? 'info' : 'error'}($passed ? 'Refund flow verified.' : 'Refund flow verification FAILED.'); + + return $passed ? self::SUCCESS : self::FAILURE; + } +}