feat(subscription): add Stripe action controls

Add subscription action handling and tests for cancel, resume, and refund flows while resolving StripeClient through the container.
This commit is contained in:
Andras Bacsai 2026-06-12 15:54:21 +02:00
parent 1833ba7ce6
commit 61d2d17f52
17 changed files with 203 additions and 46 deletions

View file

@ -5,6 +5,7 @@
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Collection;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CancelSubscription
@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false)
$this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = app(StripeClient::class);
}
}
@ -64,7 +65,7 @@ public function verifySubscriptionsInStripe(): array
];
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$subscriptions = $this->getSubscriptionsPreview();
$verified = collect();
@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array
'reason' => "Status in Stripe: {$stripeSubscription->status}",
]);
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
// Subscription doesn't exist in Stripe
$notFound->push([
'subscription' => $subscription,
@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool
return false;
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd
@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@ -47,7 +48,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,8 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Carbon\Carbon;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class RefundSubscription
@ -13,7 +15,7 @@ class RefundSubscription
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@ -39,7 +41,7 @@ public function checkEligibility(Team $team): array
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.');
}
@ -49,7 +51,7 @@ public function checkEligibility(Team $team): array
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
$startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
@ -130,7 +132,7 @@ public function execute(Team $team): array
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -3,6 +3,7 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class ResumeSubscription
@ -11,7 +12,7 @@ class ResumeSubscription
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@ -43,7 +44,7 @@ public function execute(Team $team): array
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];

View file

@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**

View file

@ -4,6 +4,8 @@
use App\Models\Team;
use Illuminate\Console\Command;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CloudFixSubscription extends Command
{
@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe);
@ -111,7 +113,7 @@ public function handle()
/**
* Fix canceled subscriptions in the database
*/
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
private function fixCanceledSubscriptions(StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$checkOne = $this->option('one');
@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
break;
}
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++;
@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
/**
* Verify all active subscriptions against Stripe API
*/
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
private function verifyAllActiveSubscriptions(StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified');
@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
break;
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status)
/**
* Search for subscriptions by customer ID
*/
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
{
try {
$subscriptions = $stripe->subscriptions->all([
@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu
/**
* Search for subscriptions by team member emails
*/
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');

View file

@ -36,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@ -100,7 +100,7 @@ public function handle(): void
if ($subscription->stripe_subscription_id) {
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@ -166,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {

View file

@ -10,6 +10,7 @@
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
{
@ -27,7 +28,7 @@ public function handle()
$subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{
@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array
->where('stripe_invoice_paid', true)
->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@ -123,7 +124,7 @@ public function handle(?\Closure $onProgress = null): array
*
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
*/
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
{
try {
$customer = $stripe->customers->retrieve($customerId);
@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str
*
* @return array<string>
*/
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
{
$validIds = [];
$fetched = 0;

View file

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{
@ -29,7 +30,7 @@ public function handle(): void
if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id,
'limit' => 1,
@ -50,7 +51,7 @@ public function handle(): void
}
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id
);

View file

@ -32,6 +32,8 @@ class Actions extends Component
public bool $refundAlreadyUsed = false;
public bool $refundLatestPayment = false;
public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null;
@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string
return 'Invalid password.';
}
$result = (new RefundSubscription)->execute(currentTeam());
$result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) {
$this->dispatch('success', 'Subscription refunded successfully.');
@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string
return true;
}
public function cancelImmediately(string $password): bool|string
public function cancelImmediately(string $password, array $selectedActions = []): bool|string
{
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
return 'Invalid password.';
}
if (in_array('refundLatestPayment', $selectedActions, true)) {
// Eligibility is re-validated server-side inside RefundSubscription::execute()
$result = app(RefundSubscription::class)->execute(currentTeam());
if ($result['success']) {
$this->dispatch('success', 'Subscription refunded and cancelled successfully.');
$this->redirect(route('subscription.index'), navigate: true);
return true;
}
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
return true;
}
$team = currentTeam();
$subscription = $team->subscription;
@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string
}
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
$subscription->update([

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings;
use App\Providers\RouteServiceProvider;
use Livewire\Component;
use Stripe\StripeClient;
class Index extends Component
{
@ -52,7 +53,7 @@ public function getStripeStatus()
{
try {
$subscription = currentTeam()->subscription;
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
if ($customer) {
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);

View file

@ -11,6 +11,7 @@
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
use Laravel\Telescope\TelescopeServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
{
@ -19,6 +20,8 @@ public function register(): void
if (App::isLocal()) {
$this->app->register(TelescopeServiceProvider::class);
}
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
}
public function boot(): void

View file

@ -11,12 +11,21 @@
*/
public function up(): void
{
// SQLite (testing) uses type affinity, so json columns already accept text
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
}

View file

@ -230,15 +230,37 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
isErrorButton submitAction="cancelImmediately"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
'No refund will be issued for the remaining period.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
@if ($isRefundEligible)
<div wire:key="cancel-immediately-refundable">
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
isErrorButton submitAction="cancelImmediately"
:checkboxes="[
[
'id' => 'refundLatestPayment',
'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
'default_warning' => 'No refund will be issued for the remaining period.',
],
]"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
</div>
@else
<div wire:key="cancel-immediately-standard">
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
isErrorButton submitAction="cancelImmediately"
:actions="[
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
'No refund will be issued for the remaining period.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
</div>
@endif
@endif
</div>
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
@ -249,7 +271,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
{{-- Refund --}}
<section>
<h3 class="pb-2">Refund</h3>
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end))
@if ($refundCheckLoading || $isRefundEligible)
<div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading)
<x-forms.button disabled>Request Full Refund</x-forms.button>
@ -269,7 +291,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<p class="mt-2 text-sm text-neutral-500">
@if ($refundCheckLoading)
Checking refund eligibility...
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
@elseif ($isRefundEligible)
Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
@elseif ($refundAlreadyUsed)
Refund already processed. Each team is eligible for one refund only.

View file

@ -5,6 +5,7 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService;
@ -85,6 +86,28 @@
expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns eligible when subscription is set to cancel at period end', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
$periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
});
test('returns ineligible when subscription is not active', function () {
$periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
@ -141,7 +164,7 @@
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
->andThrow(new InvalidRequestException('No such subscription'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
@ -269,6 +292,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@ -298,7 +322,7 @@
$this->mockSubscriptions
->shouldReceive('cancel')
->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error'));
->andThrow(new Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);

View file

@ -0,0 +1,70 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Livewire\Subscription\Actions;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_123',
'stripe_customer_id' => 'cus_test_123',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_123',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('cancelImmediately with refund option', function () {
test('refunds and cancels via RefundSubscription when refund checkbox is selected', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('success')
->assertRedirect(route('subscription.index'));
});
test('dispatches error when refund fails', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => false, 'error' => 'No paid invoice found to refund.']);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('error');
});
test('rejects invalid password before refunding', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldNotReceive('execute');
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'wrong-password', ['refundLatestPayment'])
->assertReturned('Invalid password.');
});
});