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:
parent
f92a482256
commit
7c8528f48a
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue