From 7c8528f48a628071ccfeee65ef266e0acc15ec3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 27 Jun 2026 13:59:46 +0200 Subject: [PATCH] feat(subscriptions): self-service refund for the pay-now variant Add a money-back path for pay_now users: within the refund window they can trigger a full refund of the upfront charge, which cancels the subscription immediately and revokes their bank connections (keeping imported data). A refunded_at column records it and blocks a second refund. Eligibility (pay_now variant, active subscription, inside the window, not yet refunded) is centralised in ExperimentOffer::canSelfRefund and surfaced on the billing screen so the frontend can show the button and deadline. --- app/Actions/Subscription/RefundSelfServe.php | 44 ++++++ .../Controllers/SubscriptionController.php | 33 ++++- ...add_refunded_at_to_subscriptions_table.php | 28 ++++ lang/es.json | 2 + routes/settings.php | 1 + tests/Feature/SelfServeRefundTest.php | 130 ++++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Subscription/RefundSelfServe.php create mode 100644 database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php create mode 100644 tests/Feature/SelfServeRefundTest.php diff --git a/app/Actions/Subscription/RefundSelfServe.php b/app/Actions/Subscription/RefundSelfServe.php new file mode 100644 index 00000000..052f30e5 --- /dev/null +++ b/app/Actions/Subscription/RefundSelfServe.php @@ -0,0 +1,44 @@ +subscription('default'); + + if ($subscription === null) { + return; + } + + $payment = $subscription->latestPayment(); + + if ($payment !== null) { + $user->refund($payment->id); + } + + $subscription->cancelNow(); + + DB::transaction(function () use ($subscription): void { + $subscription->forceFill(['refunded_at' => now()])->save(); + }); + + $user->bankingConnections()->get()->each(function ($connection): void { + $this->disconnect->handle($connection, deleteAccounts: false); + }); + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index c436e64f..b32f2695 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Actions\Subscription\RefundSelfServe; +use App\Features\SubscriptionExperiment; use App\Models\AccountBalance; use App\Models\User; use App\Models\UserLead; @@ -180,11 +182,40 @@ class SubscriptionController extends Controller return redirect()->route('dashboard'); } + $user = $request->user(); + $subscription = $user->subscription('default'); + return Inertia::render('settings/billing', [ - 'hasAiConsent' => $request->user()->hasActiveAiConsent(), + 'hasAiConsent' => $user->hasActiveAiConsent(), + 'refund' => [ + 'canSelfRefund' => $this->experimentOffer->canSelfRefund($user), + 'deadline' => $subscription !== null && $this->experimentOffer->variantFor($user) === SubscriptionExperiment::PAY_NOW + ? $this->experimentOffer->refundDeadlineFor($subscription)->toIso8601String() + : null, + ], ]); } + public function refund(Request $request): RedirectResponse + { + $user = $request->user(); + + if ($user->isDemoAccount()) { + return redirect()->route('settings.billing') + ->withErrors(['refund' => 'Refunds are not available on the demo account.']); + } + + if (! $this->experimentOffer->canSelfRefund($user)) { + return redirect()->route('settings.billing') + ->withErrors(['refund' => __('This subscription is no longer eligible for a self-service refund.')]); + } + + app(RefundSelfServe::class)->handle($user); + + return redirect()->route('settings.billing') + ->with('status', __('Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.')); + } + public function billingPortal(Request $request): RedirectResponse { if ($request->user()->isDemoAccount()) { diff --git a/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php b/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php new file mode 100644 index 00000000..ecde038d --- /dev/null +++ b/database/migrations/2026_06_27_114559_add_refunded_at_to_subscriptions_table.php @@ -0,0 +1,28 @@ +timestamp('refunded_at')->nullable()->after('ends_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table): void { + $table->dropColumn('refunded_at'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 2e54b37d..edbbe9d7 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,6 @@ { + "This subscription is no longer eligible for a self-service refund.": "Esta suscripción ya no es elegible para una devolución automática.", + "Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Te hemos devuelto el pago, cancelado la suscripción y desconectado tus cuentas bancarias.", "AI Categorization": "Categorización con IA", "Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.", "Allow AI categorization": "Permitir categorización con IA", diff --git a/routes/settings.php b/routes/settings.php index 9efff3f1..35fac42f 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -87,6 +87,7 @@ Route::middleware('auth')->group(function () { Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing'); Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal'); + Route::post('settings/billing/refund', [SubscriptionController::class, 'refund'])->name('settings.billing.refund'); Route::get('settings/delete-account', function (Request $request) { return Inertia::render('settings/delete-account', [ diff --git a/tests/Feature/SelfServeRefundTest.php b/tests/Feature/SelfServeRefundTest.php new file mode 100644 index 00000000..a89f9689 --- /dev/null +++ b/tests/Feature/SelfServeRefundTest.php @@ -0,0 +1,130 @@ + true, + 'subscriptions.experiment.started_at' => '2026-06-01', + 'subscriptions.experiment.pay_now_refund_window_days' => 3, + ]); + Carbon::setTestNow(CarbonImmutable::parse('2026-06-15 12:00:00')); +}); + +function payNowSubscriber(array $overrides = []): User +{ + $user = User::factory()->onboarded()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]); + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW); + + $user->subscriptions()->create(array_merge([ + 'type' => 'default', + 'stripe_id' => 'sub_paynow_'.fake()->unique()->numerify('######'), + 'stripe_status' => 'active', + 'stripe_price' => 'price_test', + 'created_at' => now(), + ], $overrides)); + + return $user; +} + +it('allows a self-refund inside the window for a pay-now subscriber', function () { + $user = payNowSubscriber(); + + expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeTrue(); +}); + +it('blocks a self-refund once the window has passed', function () { + $user = payNowSubscriber(['created_at' => now()->subDays(5)]); + + expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse(); +}); + +it('blocks a self-refund once already refunded', function () { + $user = payNowSubscriber(['refunded_at' => now()]); + + expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse(); +}); + +it('blocks a self-refund for non pay-now variants', function () { + $user = payNowSubscriber(); + Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::CONTROL); + + expect(app(ExperimentOffer::class)->canSelfRefund($user))->toBeFalse(); +}); + +it('runs the refund action when eligible and reports it on the billing screen', function () { + $user = payNowSubscriber(); + + $action = Mockery::mock(RefundSelfServe::class); + $action->shouldReceive('handle')->once(); + app()->instance(RefundSelfServe::class, $action); + + $this->actingAs($user) + ->post(route('settings.billing.refund')) + ->assertRedirect(route('settings.billing')) + ->assertSessionHas('status'); +}); + +it('rejects the refund request when not eligible', function () { + $user = payNowSubscriber(['created_at' => now()->subDays(5)]); + + $action = Mockery::mock(RefundSelfServe::class); + $action->shouldNotReceive('handle'); + app()->instance(RefundSelfServe::class, $action); + + $this->actingAs($user) + ->post(route('settings.billing.refund')) + ->assertRedirect(route('settings.billing')) + ->assertSessionHasErrors(['refund']); +}); + +it('refunds the charge, cancels the subscription and disconnects connections', function () { + $payment = new Payment(new PaymentIntent('pi_test_123')); + + $subscription = Mockery::mock(Subscription::class); + $subscription->shouldReceive('latestPayment')->once()->andReturn($payment); + $subscription->shouldReceive('cancelNow')->once(); + $subscription->shouldReceive('forceFill')->once() + ->with(Mockery::on(fn ($attrs) => array_key_exists('refunded_at', $attrs))) + ->andReturnSelf(); + $subscription->shouldReceive('save')->once(); + + $relation = Mockery::mock(HasMany::class); + $relation->shouldReceive('get')->once()->andReturn(collect([ + Mockery::mock(BankingConnection::class), + Mockery::mock(BankingConnection::class), + ])); + + $user = Mockery::mock(User::class)->shouldIgnoreMissing(); + $user->shouldReceive('subscription')->with('default')->andReturn($subscription); + $user->shouldReceive('refund')->once()->with('pi_test_123'); + $user->shouldReceive('bankingConnections')->andReturn($relation); + + $disconnect = Mockery::mock(DisconnectBankingConnection::class); + $disconnect->shouldReceive('handle')->twice(); + + (new RefundSelfServe($disconnect))->handle($user); +}); + +it('does nothing when there is no subscription to refund', function () { + $disconnect = Mockery::mock(DisconnectBankingConnection::class); + $disconnect->shouldNotReceive('handle'); + + $user = Mockery::mock(User::class)->shouldIgnoreMissing(); + $user->shouldReceive('subscription')->with('default')->andReturn(null); + $user->shouldNotReceive('refund'); + + expect(fn () => (new RefundSelfServe($disconnect))->handle($user))->not->toThrow(Exception::class); +});