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:
parent
1833ba7ce6
commit
61d2d17f52
17 changed files with 203 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
||||
@elseif ($refundAlreadyUsed)
|
||||
Refund already processed. Each team is eligible for one refund only.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
70
tests/Feature/Subscription/SubscriptionActionsTest.php
Normal file
70
tests/Feature/Subscription/SubscriptionActionsTest.php
Normal 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.');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue