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\Subscription;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CancelSubscription
|
class CancelSubscription
|
||||||
|
|
@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false)
|
||||||
$this->isDryRun = $isDryRun;
|
$this->isDryRun = $isDryRun;
|
||||||
|
|
||||||
if (! $isDryRun && isCloud()) {
|
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();
|
$subscriptions = $this->getSubscriptionsPreview();
|
||||||
|
|
||||||
$verified = collect();
|
$verified = collect();
|
||||||
|
|
@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array
|
||||||
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
// Subscription doesn't exist in Stripe
|
// Subscription doesn't exist in Stripe
|
||||||
$notFound->push([
|
$notFound->push([
|
||||||
'subscription' => $subscription,
|
'subscription' => $subscription,
|
||||||
|
|
@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||||
|
|
||||||
// Update local record if exists
|
// Update local record if exists
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CancelSubscriptionAtPeriodEnd
|
class CancelSubscriptionAtPeriodEnd
|
||||||
|
|
@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
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}");
|
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
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());
|
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class RefundSubscription
|
class RefundSubscription
|
||||||
|
|
@ -13,7 +15,7 @@ class RefundSubscription
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
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 {
|
try {
|
||||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
$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.');
|
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);
|
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());
|
$daysSinceStart = (int) $startDate->diffInDays(now());
|
||||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
$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}");
|
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
return ['success' => true, 'error' => null];
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class ResumeSubscription
|
class ResumeSubscription
|
||||||
|
|
@ -11,7 +12,7 @@ class ResumeSubscription
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
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}");
|
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
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());
|
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
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 App\Models\Team;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CloudFixSubscription extends Command
|
class CloudFixSubscription extends Command
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
|
||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
|
|
||||||
if ($this->option('verify-all')) {
|
if ($this->option('verify-all')) {
|
||||||
return $this->verifyAllActiveSubscriptions($stripe);
|
return $this->verifyAllActiveSubscriptions($stripe);
|
||||||
|
|
@ -111,7 +113,7 @@ public function handle()
|
||||||
/**
|
/**
|
||||||
* Fix canceled subscriptions in the database
|
* Fix canceled subscriptions in the database
|
||||||
*/
|
*/
|
||||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
private function fixCanceledSubscriptions(StripeClient $stripe)
|
||||||
{
|
{
|
||||||
$isDryRun = $this->option('dry-run');
|
$isDryRun = $this->option('dry-run');
|
||||||
$checkOne = $this->option('one');
|
$checkOne = $this->option('one');
|
||||||
|
|
@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
if ($e->getStripeCode() === 'resource_missing') {
|
if ($e->getStripeCode() === 'resource_missing') {
|
||||||
$toFixCount++;
|
$toFixCount++;
|
||||||
|
|
||||||
|
|
@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
/**
|
/**
|
||||||
* Verify all active subscriptions against Stripe API
|
* Verify all active subscriptions against Stripe API
|
||||||
*/
|
*/
|
||||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
private function verifyAllActiveSubscriptions(StripeClient $stripe)
|
||||||
{
|
{
|
||||||
$isDryRun = $this->option('dry-run');
|
$isDryRun = $this->option('dry-run');
|
||||||
$shouldFix = $this->option('fix-verified');
|
$shouldFix = $this->option('fix-verified');
|
||||||
|
|
@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
$this->error(' → Error: '.$e->getMessage());
|
$this->error(' → Error: '.$e->getMessage());
|
||||||
|
|
||||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||||
|
|
@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status)
|
||||||
/**
|
/**
|
||||||
* Search for subscriptions by customer ID
|
* Search for subscriptions by customer ID
|
||||||
*/
|
*/
|
||||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$subscriptions = $stripe->subscriptions->all([
|
$subscriptions = $stripe->subscriptions->all([
|
||||||
|
|
@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu
|
||||||
/**
|
/**
|
||||||
* Search for subscriptions by team member emails
|
* 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...');
|
$this->line(' → Searching by team member emails...');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public function handle(): void
|
||||||
$data = data_get($this->event, 'data.object');
|
$data = data_get($this->event, 'data.object');
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
case 'radar.early_fraud_warning.created':
|
case 'radar.early_fraud_warning.created':
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$id = data_get($data, 'id');
|
$id = data_get($data, 'id');
|
||||||
$charge = data_get($data, 'charge');
|
$charge = data_get($data, 'charge');
|
||||||
if ($charge) {
|
if ($charge) {
|
||||||
|
|
@ -100,7 +100,7 @@ public function handle(): void
|
||||||
|
|
||||||
if ($subscription->stripe_subscription_id) {
|
if ($subscription->stripe_subscription_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||||
$subscription->stripe_subscription_id
|
$subscription->stripe_subscription_id
|
||||||
);
|
);
|
||||||
|
|
@ -166,7 +166,7 @@ public function handle(): void
|
||||||
// Verify payment status with Stripe API before sending failure notification
|
// Verify payment status with Stripe API before sending failure notification
|
||||||
if ($paymentIntentId) {
|
if ($paymentIntentId) {
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||||
|
|
||||||
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
|
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +28,7 @@ public function handle()
|
||||||
$subscription = $this->team->subscription;
|
$subscription = $this->team->subscription;
|
||||||
if ($subscription && $subscription->stripe_customer_id) {
|
if ($subscription && $subscription->stripe_customer_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
|
|
||||||
if ($subscription->stripe_subscription_id) {
|
if ($subscription->stripe_subscription_id) {
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array
|
||||||
->where('stripe_invoice_paid', true)
|
->where('stripe_invoice_paid', true)
|
||||||
->get();
|
->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)
|
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
||||||
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
$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
|
* @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 {
|
try {
|
||||||
$customer = $stripe->customers->retrieve($customerId);
|
$customer = $stripe->customers->retrieve($customerId);
|
||||||
|
|
@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str
|
||||||
*
|
*
|
||||||
* @return array<string>
|
* @return array<string>
|
||||||
*/
|
*/
|
||||||
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
|
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
|
||||||
{
|
{
|
||||||
$validIds = [];
|
$validIds = [];
|
||||||
$fetched = 0;
|
$fetched = 0;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +30,7 @@ public function handle(): void
|
||||||
if (! $this->subscription->stripe_subscription_id &&
|
if (! $this->subscription->stripe_subscription_id &&
|
||||||
$this->subscription->stripe_customer_id) {
|
$this->subscription->stripe_customer_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$subscriptions = $stripe->subscriptions->all([
|
$subscriptions = $stripe->subscriptions->all([
|
||||||
'customer' => $this->subscription->stripe_customer_id,
|
'customer' => $this->subscription->stripe_customer_id,
|
||||||
'limit' => 1,
|
'limit' => 1,
|
||||||
|
|
@ -50,7 +51,7 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||||
$this->subscription->stripe_subscription_id
|
$this->subscription->stripe_subscription_id
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class Actions extends Component
|
||||||
|
|
||||||
public bool $refundAlreadyUsed = false;
|
public bool $refundAlreadyUsed = false;
|
||||||
|
|
||||||
|
public bool $refundLatestPayment = false;
|
||||||
|
|
||||||
public string $billingInterval = 'monthly';
|
public string $billingInterval = 'monthly';
|
||||||
|
|
||||||
public ?string $nextBillingDate = null;
|
public ?string $nextBillingDate = null;
|
||||||
|
|
@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string
|
||||||
return 'Invalid password.';
|
return 'Invalid password.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = (new RefundSubscription)->execute(currentTeam());
|
$result = app(RefundSubscription::class)->execute(currentTeam());
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$this->dispatch('success', 'Subscription refunded successfully.');
|
$this->dispatch('success', 'Subscription refunded successfully.');
|
||||||
|
|
@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string
|
||||||
return true;
|
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)) {
|
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||||
return 'Invalid 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();
|
$team = currentTeam();
|
||||||
$subscription = $team->subscription;
|
$subscription = $team->subscription;
|
||||||
|
|
||||||
|
|
@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||||
|
|
||||||
$subscription->update([
|
$subscription->update([
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
|
|
@ -52,7 +53,7 @@ public function getStripeStatus()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$subscription = currentTeam()->subscription;
|
$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);
|
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
|
||||||
if ($customer) {
|
if ($customer) {
|
||||||
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
|
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Laravel\Telescope\TelescopeServiceProvider;
|
use Laravel\Telescope\TelescopeServiceProvider;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,8 @@ public function register(): void
|
||||||
if (App::isLocal()) {
|
if (App::isLocal()) {
|
||||||
$this->app->register(TelescopeServiceProvider::class);
|
$this->app->register(TelescopeServiceProvider::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,21 @@
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
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_snapshot TYPE text USING configuration_snapshot::text');
|
||||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
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_snapshot TYPE json USING configuration_snapshot::json');
|
||||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::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 }}"
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
confirmationLabel="Enter your team name to confirm"
|
confirmationLabel="Enter your team name to confirm"
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
||||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
@if ($isRefundEligible)
|
||||||
isErrorButton submitAction="cancelImmediately"
|
<div wire:key="cancel-immediately-refundable">
|
||||||
:actions="[
|
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||||
'Your subscription will be cancelled immediately.',
|
isErrorButton submitAction="cancelImmediately"
|
||||||
'All servers will be deactivated.',
|
:checkboxes="[
|
||||||
'No refund will be issued for the remaining period.',
|
[
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
'id' => 'refundLatestPayment',
|
||||||
confirmationLabel="Enter your team name to confirm"
|
'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
'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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
@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 --}}
|
{{-- Refund --}}
|
||||||
<section>
|
<section>
|
||||||
<h3 class="pb-2">Refund</h3>
|
<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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if ($refundCheckLoading)
|
@if ($refundCheckLoading)
|
||||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
<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">
|
<p class="mt-2 text-sm text-neutral-500">
|
||||||
@if ($refundCheckLoading)
|
@if ($refundCheckLoading)
|
||||||
Checking refund eligibility...
|
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.
|
Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
||||||
@elseif ($refundAlreadyUsed)
|
@elseif ($refundAlreadyUsed)
|
||||||
Refund already processed. Each team is eligible for one refund only.
|
Refund already processed. Each team is eligible for one refund only.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\Service\InvoiceService;
|
use Stripe\Service\InvoiceService;
|
||||||
use Stripe\Service\RefundService;
|
use Stripe\Service\RefundService;
|
||||||
use Stripe\Service\SubscriptionService;
|
use Stripe\Service\SubscriptionService;
|
||||||
|
|
@ -85,6 +86,28 @@
|
||||||
expect($result['current_period_end'])->toBe($periodEnd);
|
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 () {
|
test('returns ineligible when subscription is not active', function () {
|
||||||
$periodEnd = now()->addDays(25)->timestamp;
|
$periodEnd = now()->addDays(25)->timestamp;
|
||||||
$stripeSubscription = (object) [
|
$stripeSubscription = (object) [
|
||||||
|
|
@ -141,7 +164,7 @@
|
||||||
$this->mockSubscriptions
|
$this->mockSubscriptions
|
||||||
->shouldReceive('retrieve')
|
->shouldReceive('retrieve')
|
||||||
->with('sub_test_123')
|
->with('sub_test_123')
|
||||||
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
|
->andThrow(new InvalidRequestException('No such subscription'));
|
||||||
|
|
||||||
$action = new RefundSubscription($this->mockStripe);
|
$action = new RefundSubscription($this->mockStripe);
|
||||||
$result = $action->checkEligibility($this->team);
|
$result = $action->checkEligibility($this->team);
|
||||||
|
|
@ -269,6 +292,7 @@
|
||||||
$stripeSubscription = (object) [
|
$stripeSubscription = (object) [
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'start_date' => now()->subDays(10)->timestamp,
|
'start_date' => now()->subDays(10)->timestamp,
|
||||||
|
'current_period_end' => now()->addDays(20)->timestamp,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->mockSubscriptions
|
$this->mockSubscriptions
|
||||||
|
|
@ -298,7 +322,7 @@
|
||||||
$this->mockSubscriptions
|
$this->mockSubscriptions
|
||||||
->shouldReceive('cancel')
|
->shouldReceive('cancel')
|
||||||
->with('sub_test_123')
|
->with('sub_test_123')
|
||||||
->andThrow(new \Exception('Stripe cancel API error'));
|
->andThrow(new Exception('Stripe cancel API error'));
|
||||||
|
|
||||||
$action = new RefundSubscription($this->mockStripe);
|
$action = new RefundSubscription($this->mockStripe);
|
||||||
$result = $action->execute($this->team);
|
$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