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.
This commit is contained in:
Víctor Falcón 2026-06-27 13:59:46 +02:00
parent f92a482256
commit 7c8528f48a
6 changed files with 237 additions and 1 deletions

View File

@ -0,0 +1,44 @@
<?php
namespace App\Actions\Subscription;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Self-service "money-back guarantee" for the pay_now experiment variant:
* refund the upfront charge, cancel the subscription immediately, and revoke
* the user's bank connections (keeping the data they already imported).
*
* Eligibility is enforced by the caller via ExperimentOffer::canSelfRefund().
*/
class RefundSelfServe
{
public function __construct(private DisconnectBankingConnection $disconnect) {}
public function handle(User $user): void
{
$subscription = $user->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);
});
}
}

View File

@ -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()) {

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -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",

View File

@ -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', [

View File

@ -0,0 +1,130 @@
<?php
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Subscriptions\ExperimentOffer;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Laravel\Cashier\Payment;
use Laravel\Cashier\Subscription;
use Laravel\Pennant\Feature;
use Stripe\PaymentIntent;
beforeEach(function () {
config([
'subscriptions.enabled' => 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);
});