Enable scheduled ClickHouse backups across the job, API, and UI, and guard unsupported database types via isBackupSolutionAvailable(). Convert Stripe subscription sync from a job to an action with clearer discrepancy resolution, and add cloud:export-users plus cloud:cleanup-unverified-users with tests.
296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Actions\Stripe\SyncStripeSubscriptions;
|
|
use App\Models\Server;
|
|
use App\Models\Subscription;
|
|
use App\Models\Team;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Stripe\Collection as StripeCollection;
|
|
use Stripe\Service\CustomerService;
|
|
use Stripe\Service\SubscriptionService;
|
|
use Stripe\StripeClient;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->set('constants.coolify.self_hosted', false);
|
|
config()->set('subscription.provider', 'stripe');
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->subscription = Subscription::create([
|
|
'team_id' => $this->team->id,
|
|
'stripe_subscription_id' => 'sub_stale',
|
|
'stripe_customer_id' => 'cus_stale',
|
|
'stripe_invoice_paid' => true,
|
|
]);
|
|
});
|
|
|
|
afterEach(function () {
|
|
SyncStripeSubscriptions::clearFake();
|
|
});
|
|
|
|
function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection
|
|
{
|
|
return StripeCollection::constructFrom([
|
|
'data' => $subscriptions,
|
|
'has_more' => false,
|
|
'url' => '/v1/subscriptions',
|
|
]);
|
|
}
|
|
|
|
test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () {
|
|
$server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'unreachable_count' => 0,
|
|
'unreachable_notification_sent' => false,
|
|
]);
|
|
|
|
$stripe = Mockery::mock(StripeClient::class);
|
|
$subscriptions = Mockery::mock(SubscriptionService::class);
|
|
$customers = Mockery::mock(CustomerService::class);
|
|
$stripe->subscriptions = $subscriptions;
|
|
$stripe->customers = $customers;
|
|
|
|
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
|
|
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
|
'status' => 'unpaid',
|
|
'customer' => 'cus_stale',
|
|
]);
|
|
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
|
|
|
|
$this->instance(StripeClient::class, $stripe);
|
|
|
|
$progress = [];
|
|
|
|
SyncStripeSubscriptions::run(
|
|
fix: true,
|
|
onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void {
|
|
$progress[] = [$stage, $current, $total];
|
|
},
|
|
);
|
|
|
|
$this->subscription->refresh();
|
|
$server->refresh();
|
|
|
|
expect($this->subscription->stripe_invoice_paid)->toBeFalsy()
|
|
->and($this->subscription->stripe_subscription_id)->toBeNull()
|
|
->and($server->unreachable_count)->toBe(3)
|
|
->and($server->unreachable_notification_sent)->toBeTrue()
|
|
->and($progress)->toContain(['checking', 0, 1])
|
|
->and($progress)->toContain(['checking', 1, 1]);
|
|
});
|
|
|
|
test('a same-email subscription not linked to the team is left for manual review', function () {
|
|
$stripe = Mockery::mock(StripeClient::class);
|
|
$subscriptions = Mockery::mock(SubscriptionService::class);
|
|
$customers = Mockery::mock(CustomerService::class);
|
|
$stripe->subscriptions = $subscriptions;
|
|
$stripe->customers = $customers;
|
|
|
|
$subscriptions->shouldReceive('all')
|
|
->with(Mockery::on(fn (array $parameters) => isset($parameters['status'])))
|
|
->twice()
|
|
->andReturn(stripeSubscriptionCollection());
|
|
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
|
'status' => 'canceled',
|
|
'customer' => 'cus_stale',
|
|
]);
|
|
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
|
|
'email' => 'customer@example.com',
|
|
]);
|
|
$customers->shouldReceive('all')->andReturn((object) [
|
|
'data' => [(object) ['id' => 'cus_active']],
|
|
]);
|
|
$subscriptions->shouldReceive('all')->with([
|
|
'customer' => 'cus_active',
|
|
'limit' => 10,
|
|
])->andReturn((object) [
|
|
'data' => [(object) [
|
|
'id' => 'sub_active_elsewhere',
|
|
'status' => 'active',
|
|
]],
|
|
]);
|
|
|
|
$this->instance(StripeClient::class, $stripe);
|
|
|
|
$result = SyncStripeSubscriptions::run(fix: true);
|
|
|
|
$this->subscription->refresh();
|
|
|
|
expect($result['resubscribed'])->toHaveCount(1)
|
|
->and($result['discrepancies'])->toHaveCount(1)
|
|
->and($result['discrepancies'][0]['resolution'])->toBe('manual_review')
|
|
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
|
|
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
|
|
});
|
|
|
|
test('a stale local subscription is deleted when its team has a valid replacement', function () {
|
|
$replacement = Subscription::create([
|
|
'team_id' => $this->team->id,
|
|
'stripe_subscription_id' => 'sub_active_elsewhere',
|
|
'stripe_customer_id' => 'cus_active',
|
|
'stripe_invoice_paid' => true,
|
|
]);
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$server->settings->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
]);
|
|
|
|
$stripe = Mockery::mock(StripeClient::class);
|
|
$subscriptions = Mockery::mock(SubscriptionService::class);
|
|
$customers = Mockery::mock(CustomerService::class);
|
|
$stripe->subscriptions = $subscriptions;
|
|
$stripe->customers = $customers;
|
|
|
|
$subscriptions->shouldReceive('all')
|
|
->with(['status' => 'active', 'limit' => 100])
|
|
->andReturn(stripeSubscriptionCollection([
|
|
['id' => 'sub_active_elsewhere'],
|
|
]));
|
|
$subscriptions->shouldReceive('all')
|
|
->with(['status' => 'past_due', 'limit' => 100])
|
|
->andReturn(stripeSubscriptionCollection());
|
|
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
|
'status' => 'canceled',
|
|
'customer' => 'cus_stale',
|
|
]);
|
|
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [
|
|
'email' => 'customer@example.com',
|
|
]);
|
|
$customers->shouldReceive('all')->andReturn((object) [
|
|
'data' => [(object) ['id' => 'cus_active']],
|
|
]);
|
|
$subscriptions->shouldReceive('all')->with([
|
|
'customer' => 'cus_active',
|
|
'limit' => 10,
|
|
])->andReturn((object) [
|
|
'data' => [(object) [
|
|
'id' => 'sub_active_elsewhere',
|
|
'status' => 'active',
|
|
]],
|
|
]);
|
|
|
|
$this->instance(StripeClient::class, $stripe);
|
|
|
|
$result = SyncStripeSubscriptions::run(fix: true);
|
|
|
|
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
|
|
->and($this->subscription->fresh())->toBeNull()
|
|
->and($replacement->fresh())->not->toBeNull()
|
|
->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy()
|
|
->and($server->settings->fresh()->is_reachable)->toBeTruthy()
|
|
->and($server->settings->fresh()->is_usable)->toBeTruthy();
|
|
});
|
|
|
|
test('a stale duplicate is deleted when its team already has an inactive subscription row', function () {
|
|
$inactiveSubscription = Subscription::create([
|
|
'team_id' => $this->team->id,
|
|
'stripe_customer_id' => 'cus_stale',
|
|
'stripe_invoice_paid' => false,
|
|
]);
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$server->settings->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
]);
|
|
|
|
$stripe = Mockery::mock(StripeClient::class);
|
|
$subscriptions = Mockery::mock(SubscriptionService::class);
|
|
$customers = Mockery::mock(CustomerService::class);
|
|
$stripe->subscriptions = $subscriptions;
|
|
$stripe->customers = $customers;
|
|
|
|
$subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection());
|
|
$subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [
|
|
'status' => 'canceled',
|
|
'customer' => 'cus_stale',
|
|
]);
|
|
$customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable'));
|
|
|
|
$this->instance(StripeClient::class, $stripe);
|
|
|
|
$result = SyncStripeSubscriptions::run(fix: true);
|
|
|
|
expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale')
|
|
->and($this->subscription->fresh())->toBeNull()
|
|
->and($inactiveSubscription->fresh())->not->toBeNull()
|
|
->and($server->settings->fresh()->is_reachable)->toBeFalsy()
|
|
->and($server->settings->fresh()->is_usable)->toBeFalsy();
|
|
});
|
|
|
|
test('valid Stripe subscriptions remain active', function (string $status) {
|
|
$stripe = Mockery::mock(StripeClient::class);
|
|
$subscriptions = Mockery::mock(SubscriptionService::class);
|
|
$stripe->subscriptions = $subscriptions;
|
|
|
|
$subscriptions->shouldReceive('all')
|
|
->with(['status' => $status, 'limit' => 100])
|
|
->andReturn(stripeSubscriptionCollection([
|
|
['id' => 'sub_stale'],
|
|
]));
|
|
$subscriptions->shouldReceive('all')
|
|
->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100])
|
|
->andReturn(stripeSubscriptionCollection());
|
|
$subscriptions->shouldNotReceive('retrieve');
|
|
|
|
$this->instance(StripeClient::class, $stripe);
|
|
|
|
$result = SyncStripeSubscriptions::run(fix: true);
|
|
|
|
$this->subscription->refresh();
|
|
|
|
expect($result['discrepancies'])->toBeEmpty()
|
|
->and($this->subscription->stripe_invoice_paid)->toBeTruthy()
|
|
->and($this->subscription->stripe_subscription_id)->toBe('sub_stale');
|
|
})->with(['active', 'past_due']);
|
|
|
|
test('the terminal command runs the reconciliation action synchronously', function () {
|
|
SyncStripeSubscriptions::shouldRun()
|
|
->once()
|
|
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure)
|
|
->andReturnUsing(function (bool $fix, Closure $onProgress): array {
|
|
$onProgress('checking', 2, 10);
|
|
|
|
return [
|
|
'total_checked' => 0,
|
|
'discrepancies' => [],
|
|
'resubscribed' => [],
|
|
'errors' => [],
|
|
'fixed' => false,
|
|
];
|
|
});
|
|
|
|
$this->artisan('cloud:sync-stripe-subscriptions')
|
|
->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10')
|
|
->expectsOutput('Total subscriptions checked: 0')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
test('the terminal command passes the fix option to the reconciliation action', function () {
|
|
SyncStripeSubscriptions::shouldRun()
|
|
->once()
|
|
->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure)
|
|
->andReturn([
|
|
'total_checked' => 1,
|
|
'discrepancies' => [[
|
|
'subscription_id' => 1,
|
|
'team_id' => 2,
|
|
'stripe_subscription_id' => 'sub_stale',
|
|
'stripe_status' => 'canceled',
|
|
'resolution' => 'manual_review',
|
|
]],
|
|
'resubscribed' => [],
|
|
'errors' => [],
|
|
'fixed' => true,
|
|
'fixed_count' => 0,
|
|
'manual_review_count' => 1,
|
|
]);
|
|
|
|
$this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true])
|
|
->expectsOutput('Total subscriptions checked: 1')
|
|
->expectsOutput(' Resolution: Manual review required')
|
|
->expectsOutput('Automatic corrections applied: 0')
|
|
->expectsOutput('Skipped for manual review: 1')
|
|
->assertSuccessful();
|
|
});
|