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; + } +}