This commit is contained in:
peaklabs-dev
2026-11-03 17:44:38 +01:00
13 changed files with 383 additions and 108 deletions
+3
View File
@@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked"
- Add the Cloudflare DNS record automatically, or open the **Configure DNS** dialog, also when you edit a domain to a hostname in a connected Cloudflare zone, for example when you change a generated domain to your own domain. Before, Coolify did this only when you added a new domain. This applies to the **Domains** pages, the application and service application settings, and the service application API
- Save deployments started through the MCP server as MCP deployments, not as API deployments. The deployment list shows them with the **MCP** source and filter, and the API and MCP deployment data have a new `is_mcp` field. The deployments to additional servers keep the source of the main deployment, and the audit log records actions that MCP tools do through the API as MCP events
- Save the update channel in **Settings > Updates** when you select **Preview** or **Nightly**. Before, the "Save changes" bar did not open for these channels, so the change was lost when you refreshed the page
- Save the healthcheck **Check type** when it is the only change. Before, the "Save changes" bar did not open when you changed only the check type, for example from **Container command** back to **HTTP request**
- Coolify Cloud: end a paid subscription that lost its link to Stripe when the customer cancels it. Before, it stayed active, and `cloud:sync-stripe-subscriptions` did not report it
## [v4.6.0] - 2026-11-02
+51 -27
View File
@@ -18,16 +18,16 @@ class SyncStripeSubscriptions
return ['error' => 'Not running on Cloud or Stripe not configured'];
}
$subscriptions = Subscription::whereNotNull('stripe_subscription_id')
->where('stripe_invoice_paid', true)
->get();
// Paid rows without a subscription ID are included: no webhook can end them.
$subscriptions = Subscription::where('stripe_invoice_paid', true)->get();
$stripe = app()->bound(StripeClient::class)
? app(StripeClient::class)
: new StripeClient(config('subscription.stripe_api_key'));
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
// Bulk fetch all valid subscriptions from Stripe (active + past_due)
$validStripeCustomers = $this->fetchValidStripeSubscriptions($stripe, $onProgress);
$validStripeIds = array_keys($validStripeCustomers);
// Find DB subscriptions not in the valid set
$staleSubscriptions = $subscriptions->filter(
@@ -47,27 +47,51 @@ class SyncStripeSubscriptions
foreach ($staleSubscriptions->values() as $index => $subscription) {
$onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount);
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
if (! $subscription->stripe_subscription_id) {
$customerId = $subscription->stripe_customer_id;
$stripeStatus = 'missing';
$liveSubscriptionId = $customerId ? array_search($customerId, $validStripeCustomers, true) : false;
usleep(100000); // 100ms rate limit delay
} catch (\Exception $e) {
$errors[] = [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
];
if ($liveSubscriptionId !== false && ! Subscription::where('stripe_subscription_id', $liveSubscriptionId)->exists()) {
$discrepancies[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'stripe_subscription_id' => $liveSubscriptionId,
'stripe_status' => $stripeStatus,
'resolution' => 'link_subscription',
];
continue;
if ($fix) {
$subscription->update(['stripe_subscription_id' => $liveSubscriptionId]);
$fixedCount++;
}
continue;
}
} else {
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
$customerId = $stripeSubscription->customer;
usleep(100000); // 100ms rate limit delay
} catch (\Exception $e) {
$errors[] = [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
];
continue;
}
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
continue;
}
}
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
continue;
}
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
$activeSub = $customerId ? $this->findActiveSubscriptionByEmail($stripe, $customerId) : null;
$validReplacement = Subscription::query()
->where('team_id', $subscription->team_id)
->where('id', '!=', $subscription->id)
@@ -81,7 +105,7 @@ class SyncStripeSubscriptions
'team_id' => $subscription->team_id,
'email' => $activeSub['email'],
'old_stripe_subscription_id' => $subscription->stripe_subscription_id,
'old_stripe_customer_id' => $stripeSubscription->customer,
'old_stripe_customer_id' => $customerId,
'new_stripe_subscription_id' => $activeSub['subscription_id'],
'new_stripe_customer_id' => $activeSub['customer_id'],
'new_status' => $activeSub['status'],
@@ -220,18 +244,18 @@ class SyncStripeSubscriptions
}
/**
* Bulk fetch all active and past_due subscription IDs from Stripe.
* Bulk fetch all active and past_due subscriptions from Stripe.
*
* @return array<string>
* @return array<string, ?string> Stripe customer ID by subscription ID
*/
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
private function fetchValidStripeSubscriptions(StripeClient $stripe, ?\Closure $onProgress = null): array
{
$validIds = [];
$fetched = 0;
foreach (self::VALID_STRIPE_STATUSES as $status) {
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
$validIds[] = $sub->id;
$validIds[$sub->id] = $sub->customer ?? null;
$fetched++;
if ($onProgress) {
@@ -19,7 +19,8 @@ class CloudFixSubscription extends Command
{--verify-all : Verify all active subscriptions against Stripe}
{--fix-verified : Fix discrepancies found during verification}
{--dry-run : Show what would be fixed without making changes}
{--one : Only fix the first found subscription}';
{--one : Only fix the first found subscription}
{--csv= : Path of the CSV file that --verify-all writes (default: storage/app/subscription-verification-<date>.csv)}';
/**
* The console command description.
@@ -348,7 +349,16 @@ class CloudFixSubscription extends Command
$this->info("Found {$totalCount} teams with active subscriptions in database");
$this->newLine();
$out = fopen('php://output', 'w');
$csvPath = $this->option('csv') ?: storage_path('app/subscription-verification-'.now()->format('Y-m-d-His').'.csv');
$out = fopen($csvPath, 'w');
if ($out === false) {
$this->error("Cannot write the CSV file: {$csvPath}");
return 1;
}
// Details for each team only with -v; otherwise a progress bar.
$progressBar = $this->output->isVerbose() ? null : $this->output->createProgressBar($totalCount);
// CSV header
fputcsv($out, [
@@ -391,20 +401,20 @@ class CloudFixSubscription extends Command
$action = 'none';
if (! $subscription->stripe_subscription_id) {
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
$this->detail("Team {$team->id}: Missing subscription ID, searching in Stripe...");
$foundResult = null;
$searchMethod = null;
// Search by customer ID
if ($subscription->stripe_customer_id) {
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
$this->detail(" → Searching by customer ID: {$subscription->stripe_customer_id}");
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
if ($foundResult) {
$searchMethod = $foundResult['method'];
}
} else {
$this->line(' → No customer ID available');
$this->detail(' → No customer ID available');
}
// Search by emails if not found
@@ -416,10 +426,10 @@ class CloudFixSubscription extends Command
// Update customer ID if different
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
if ($isDryRun) {
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
$this->detail(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
} elseif ($shouldFix) {
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
$this->detail(" ✓ Updated customer ID to {$foundResult['customer_id']}");
}
}
}
@@ -453,7 +463,7 @@ class CloudFixSubscription extends Command
]);
} else {
// Found subscription but it's canceled/expired - needs to be deactivated
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
$this->detail(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
@@ -472,7 +482,7 @@ class CloudFixSubscription extends Command
}
} else {
// No subscription found at all
$this->line(' → No subscription found');
$this->detail(' → No subscription found');
$stripeStatus = 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
@@ -493,7 +503,7 @@ class CloudFixSubscription extends Command
} else {
// First validate the subscription ID format
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
$this->detail(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
}
try {
@@ -516,7 +526,7 @@ class CloudFixSubscription extends Command
// Ensure past_due flag is set
if (! $subscription->stripe_past_due) {
if ($isDryRun) {
$this->info("Would set stripe_past_due=true for Team {$team->id}");
$this->detail("Would set stripe_past_due=true for Team {$team->id}");
} elseif ($shouldFix) {
$subscription->update(['stripe_past_due' => true]);
}
@@ -545,7 +555,7 @@ class CloudFixSubscription extends Command
]);
if ($isDryRun) {
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
$this->detail("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $stripeStatus);
$stats['fixed']++;
@@ -573,11 +583,11 @@ class CloudFixSubscription extends Command
}
} catch (InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
$this->detail(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
// Subscription doesn't exist, try to find by customer ID
$this->warn(" → Subscription not found, checking customer's subscriptions...");
$this->detail(" → Subscription not found, checking customer's subscriptions...");
$foundResult = null;
if ($subscription->stripe_customer_id) {
@@ -586,7 +596,7 @@ class CloudFixSubscription extends Command
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription with different ID
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
$this->detail(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
fputcsv($out, [
$team->id,
@@ -602,7 +612,7 @@ class CloudFixSubscription extends Command
]);
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
$this->detail(" → Would update subscription ID to {$foundResult['subscription']->id}");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundResult['subscription']->id,
@@ -610,7 +620,7 @@ class CloudFixSubscription extends Command
'stripe_past_due' => $foundResult['status'] === 'past_due',
]);
$stats['fixed']++;
$this->info(' → Updated subscription ID');
$this->detail(' → Updated subscription ID');
}
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
@@ -635,7 +645,7 @@ class CloudFixSubscription extends Command
} else {
// Other API error
$stats['errors']++;
$this->error(' → API Error - not marking as deleted');
$this->detail(' → API Error - not marking as deleted');
fputcsv($out, [
$team->id,
@@ -651,7 +661,7 @@ class CloudFixSubscription extends Command
]);
}
} catch (\Exception $e) {
$this->error(' → Unexpected error: '.$e->getMessage());
$this->detail(' → Unexpected error: '.$e->getMessage());
$stats['errors']++;
fputcsv($out, [
@@ -670,11 +680,13 @@ class CloudFixSubscription extends Command
}
$processedCount++;
if ($processedCount % 100 === 0) {
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
$progressBar?->advance();
if (! $progressBar && $processedCount % 100 === 0) {
$this->detail("Processed {$processedCount}/{$totalCount} subscriptions...");
}
}
$progressBar?->finish();
fclose($out);
// Print summary
@@ -710,9 +722,25 @@ class CloudFixSubscription extends Command
$this->comment('Run with --fix-verified to fix the discrepancies');
}
$this->newLine();
$this->info("Problem rows written to: {$csvPath}");
if (! $this->output->isVerbose()) {
$this->comment('Run with -v to see the details for each team.');
}
return 0;
}
/**
* Print a detail line of the verification only in verbose mode (-v).
*/
private function detail(string $message): void
{
if ($this->output->isVerbose()) {
$this->line($message);
}
}
/**
* Fix a subscription based on its status
*/
@@ -741,13 +769,13 @@ class CloudFixSubscription extends Command
'status' => 'all',
]);
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
$this->detail(' → Found '.count($subscriptions->data).' subscription(s) for customer');
// Look for active/past_due first
foreach ($subscriptions->data as $sub) {
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
$this->detail(" - Subscription {$sub->id}: status={$sub->status}");
if (in_array($sub->status, ['active', 'past_due'])) {
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
$this->detail(" ✓ Found active/past_due subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
}
@@ -756,14 +784,14 @@ class CloudFixSubscription extends Command
// If not requiring active and there are subscriptions, return first one
if (! $requireActive && count($subscriptions->data) > 0) {
$sub = $subscriptions->data[0];
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
$this->detail(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
}
return null;
} catch (\Exception $e) {
$this->error(' → Error searching by customer ID: '.$e->getMessage());
$this->detail(' → Error searching by customer ID: '.$e->getMessage());
return null;
}
@@ -774,10 +802,10 @@ class CloudFixSubscription extends Command
*/
private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');
$this->detail(' → Searching by team member emails...');
foreach ($emails as $email) {
$this->line(" → Checking email: {$email}");
$this->detail(" → Checking email: {$email}");
try {
$customers = $stripe->customers->all([
@@ -786,15 +814,15 @@ class CloudFixSubscription extends Command
]);
if (count($customers->data) === 0) {
$this->line(' - No customers found');
$this->detail(' - No customers found');
continue;
}
$this->line(' - Found '.count($customers->data).' customer(s)');
$this->detail(' - Found '.count($customers->data).' customer(s)');
foreach ($customers->data as $customer) {
$this->line(" - Checking customer {$customer->id}");
$this->detail(" - Checking customer {$customer->id}");
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
if ($result) {
@@ -805,7 +833,7 @@ class CloudFixSubscription extends Command
}
}
} catch (\Exception $e) {
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
$this->detail(" - Error searching for email {$email}: ".$e->getMessage());
}
}
@@ -818,11 +846,11 @@ class CloudFixSubscription extends Command
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
{
$stripeStatus = $foundSub->status;
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
$this->detail(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
// Only update if it's active or past_due
if (! in_array($stripeStatus, ['active', 'past_due'])) {
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
$this->detail(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
return [
'id' => $foundSub->id,
@@ -833,7 +861,7 @@ class CloudFixSubscription extends Command
}
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
$this->detail(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundSub->id,
@@ -841,7 +869,7 @@ class CloudFixSubscription extends Command
'stripe_past_due' => $stripeStatus === 'past_due',
]);
$stats['fixed']++;
$this->info(" → Updated subscription ID to {$foundSub->id}");
$this->detail(" → Updated subscription ID to {$foundSub->id}");
}
// Update stats
@@ -864,11 +892,11 @@ class CloudFixSubscription extends Command
if ($isDryRun) {
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
$this->warn(" → Would deactivate subscription - {$statusMsg}");
$this->detail(" → Would deactivate subscription - {$statusMsg}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $status);
$stats['fixed']++;
$this->info(' → Deactivated subscription');
$this->detail(' → Deactivated subscription');
}
return [
@@ -65,10 +65,11 @@ class SyncStripeSubscriptions extends Command
foreach ($result['discrepancies'] as $discrepancy) {
$this->line(" - Subscription ID: {$discrepancy['subscription_id']}");
$this->line(" Team ID: {$discrepancy['team_id']}");
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
$this->line(' Stripe ID: '.($discrepancy['stripe_subscription_id'] ?? 'none'));
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
$resolution = match ($discrepancy['resolution']) {
'delete_stale' => 'Delete stale local row',
'link_subscription' => 'Link the active Stripe subscription',
'manual_review' => 'Manual review required',
default => 'End local subscription',
};
+29 -10
View File
@@ -327,6 +327,9 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
'stripe_plan_id' => $planId,
'stripe_cancel_at_period_end' => $cancelAtPeriodEnd,
]);
if (! $subscription->stripe_subscription_id && $subscription->stripe_invoice_paid) {
VerifyStripeSubscriptionStatusJob::dispatch($subscription);
}
if ($status === 'paused' || $status === 'incomplete_expired') {
if ($subscription->stripe_subscription_id === $subscriptionId) {
$subscription->update([
@@ -377,18 +380,15 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$customerId = data_get($data, 'customer');
$subscriptionId = data_get($data, 'id');
$subscription = Subscription::where('stripe_customer_id', $customerId)->where('stripe_subscription_id', $subscriptionId)->first();
if ($subscription) {
$team = data_get($subscription, 'team');
if ($team) {
$team->subscriptionEnded($subscription);
} else {
// send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
} else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
if (! $subscription) {
$this->verifyUnlinkedPaidSubscription($customerId);
break;
}
$team = data_get($subscription, 'team');
if (! $team) {
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
$team->subscriptionEnded($subscription);
break;
default:
throw new \RuntimeException("Unhandled event type: {$type}");
@@ -397,4 +397,23 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
send_internal_notification('StripeProcessJob error: '.$e->getMessage());
}
}
/**
* A paid row without a Stripe subscription ID matches no webhook by ID. Verify it against Stripe.
*/
private function verifyUnlinkedPaidSubscription(?string $customerId): void
{
if (! $customerId) {
return;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)
->whereNull('stripe_subscription_id')
->where('stripe_invoice_paid', true)
->first();
if ($subscription) {
VerifyStripeSubscriptionStatusJob::dispatch($subscription);
}
}
}
+14 -12
View File
@@ -43,21 +43,23 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
return;
}
}
$invoices = $stripe->invoices->all([
'customer' => $subscription->stripe_customer_id,
'limit' => 3,
]);
// Only an invoice of this subscription proves payment, not one of an older subscription of the customer.
$invoices = $stripe->invoices->all([
'customer' => $subscription->stripe_customer_id,
'subscription' => $subscription->stripe_subscription_id,
'limit' => 3,
]);
foreach ($invoices->data as $invoice) {
if ($invoice->paid && $invoice->created > (time() - 3600)) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
foreach ($invoices->data as $invoice) {
if ($invoice->paid && $invoice->created > (time() - 3600)) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
return;
return;
}
}
}
} catch (\Exception $e) {
@@ -31,14 +31,18 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu
$this->subscription->stripe_customer_id) {
try {
$stripe = app(StripeClient::class);
$subscriptions = $stripe->subscriptions->all([
$subscriptions = collect($stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id,
'limit' => 1,
]);
'status' => 'all',
'limit' => 10,
])->data);
if ($subscriptions->data) {
$found = $subscriptions->first(fn ($sub) => in_array($sub->status, ['active', 'past_due'], true))
?? $subscriptions->first();
if ($found) {
$this->subscription->update([
'stripe_subscription_id' => $subscriptions->data[0]->id,
'stripe_subscription_id' => $found->id,
]);
}
} catch (\Exception $e) {
@@ -40,12 +40,12 @@
@endif
<div class="max-w-xs">
<x-forms.listbox id="healthCheckType" label="Check type" required live :options="[
<x-forms.listbox id="healthCheckType" label="Check type" required :options="[
['value' => 'http', 'label' => 'HTTP request'],
['value' => 'cmd', 'label' => 'Container command'],
]" :disabled="! $canUpdate" />
</div>
@if ($healthCheckType === 'http')
<div x-show="$wire.healthCheckType === 'http'" @if ($healthCheckType !== 'http') x-cloak @endif>
<x-application.settings-subsection id="healthcheck-request-section" title="HTTP request"
helper="Coolify sends this request from inside the container and evaluates the response.">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -77,7 +77,8 @@
helper="Leave empty when the response body does not need to contain specific text." />
</div>
</x-application.settings-subsection>
@else
</div>
<div x-show="$wire.healthCheckType !== 'http'" @if ($healthCheckType === 'http') x-cloak @endif>
<x-application.settings-subsection id="healthcheck-command-section" title="Container command"
helper="The command must exit with code 0 for the container to be considered healthy.">
<x-callout type="warning" title="Shell operators are not supported">
@@ -88,10 +89,10 @@
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
label="Command" placeholder="pg_isready -U postgres"
helper="Use a single executable command without shell expansion."
:required="$healthCheckType === 'cmd'" />
required x-bind:required="$wire.healthCheckType === 'cmd'" />
</div>
</x-application.settings-subsection>
@endif
</div>
<x-application.settings-subsection id="healthcheck-timing-section" title="Timing and retries"
helper="Control how quickly healthchecks start, repeat, and fail.">
@@ -18,7 +18,8 @@
</x-forms.button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="update_channel" label="Channel" live :options="[
{{-- Not live: a live update clears the dirty state, so the unsaved bar never opened for a channel change. --}}
<x-forms.listbox id="update_channel" label="Channel" :options="[
['value' => 'stable', 'label' => 'Stable', 'description' => 'Stable releases'],
['value' => 'preview', 'label' => 'Preview', 'description' => 'Betas and release candidates, then the stable release'],
['value' => 'nightly', 'label' => 'Nightly', 'description' => 'A build of the next version twice a day, then every release'],
@@ -26,27 +27,31 @@
]"
helper="The versions this instance upgrades to." />
@if ($update_channel === 'custom')
<div x-show="$wire.update_channel === 'custom'" @if ($update_channel !== 'custom') x-cloak @endif>
<x-forms.input required id="update_custom_tag" label="Image tag" placeholder="pr-1234"
x-bind:required="$wire.update_channel === 'custom'"
helper="A tag of ghcr.io/coollabsio/coolify, such as pr-1234 for the build of pull request 1234." />
@else
<div class="hidden lg:block"></div>
@endif
</div>
<div x-show="$wire.update_channel !== 'custom'" @if ($update_channel === 'custom') x-cloak @endif
class="hidden lg:block"></div>
@if ($update_channel === 'nightly')
<x-callout type="danger" title="Nightly builds can break this instance" class="lg:col-span-2">
<div x-show="$wire.update_channel === 'nightly'" @if ($update_channel !== 'nightly') x-cloak @endif
class="lg:col-span-2">
<x-callout type="danger" title="Nightly builds can break this instance">
Nightly builds are untested builds of the next version. Things can break at any time and
breaking changes are possible, including database changes a stable release does not expect.
Back up first and use nightly only on an instance you can rebuild. After switching back to stable,
the instance keeps getting the nightlies of its version until that version is released as stable.
</x-callout>
@elseif ($update_channel === 'custom')
<x-callout type="danger" title="A custom image can break this instance" class="lg:col-span-2">
</div>
<div x-show="$wire.update_channel === 'custom'" @if ($update_channel !== 'custom') x-cloak @endif
class="lg:col-span-2">
<x-callout type="danger" title="A custom image can break this instance">
The image installs only when you upgrade to it here, and automatic updates pause until you
switch back to a channel. Pull request builds are untested and can include database changes
a later release does not expect.
</x-callout>
@endif
</div>
<x-forms.input required id="update_check_frequency" label="Check frequency"
placeholder="0 * * * *"
@@ -577,3 +577,59 @@ describe('checkout.session.expired', function () {
expect($this->team->subscription()->first()->stripe_checkout_reminder_sent_at)->toBeNull();
});
});
describe('paid rows without a Stripe subscription ID', function () {
beforeEach(function () {
$this->unlinked = Subscription::create([
'team_id' => $this->team->id,
'stripe_customer_id' => 'cus_unlinked',
'stripe_subscription_id' => null,
'stripe_invoice_paid' => true,
]);
$this->stripeSubscriptions = Mockery::mock(SubscriptionService::class);
$stripe = Mockery::mock(StripeClient::class);
$stripe->subscriptions = $this->stripeSubscriptions;
$this->instance(StripeClient::class, $stripe);
});
test('a deleted event of the customer ends the row', function () {
$this->stripeSubscriptions->shouldReceive('all')->andReturn((object) ['data' => [
(object) ['id' => 'sub_canceled', 'status' => 'canceled'],
]]);
$this->stripeSubscriptions->shouldReceive('retrieve')->with('sub_canceled')->andReturn((object) [
'status' => 'canceled',
'cancel_at_period_end' => false,
]);
(new StripeProcessJob(['type' => 'customer.subscription.deleted', 'data' => ['object' => [
'customer' => 'cus_unlinked',
'id' => 'sub_canceled',
]]]))->handle();
expect($this->unlinked->fresh()->stripe_invoice_paid)->toBeFalsy()
->and($this->unlinked->fresh()->stripe_subscription_id)->toBeNull();
});
test('an updated event of the customer links the active subscription', function () {
$this->stripeSubscriptions->shouldReceive('all')->andReturn((object) ['data' => [
(object) ['id' => 'sub_old', 'status' => 'canceled'],
(object) ['id' => 'sub_live', 'status' => 'active'],
]]);
$this->stripeSubscriptions->shouldReceive('retrieve')->with('sub_live')->andReturn((object) [
'status' => 'active',
'cancel_at_period_end' => false,
]);
(new StripeProcessJob(['type' => 'customer.subscription.updated', 'data' => ['object' => [
'id' => 'sub_live',
'customer' => 'cus_unlinked',
'status' => 'active',
'cancel_at_period_end' => false,
'items' => ['data' => [['plan' => ['id' => 'price_monthly'], 'price' => ['lookup_key' => 'monthly']]]],
]]]))->handle();
expect($this->unlinked->fresh()->stripe_subscription_id)->toBe('sub_live')
->and($this->unlinked->fresh()->stripe_invoice_paid)->toBeTruthy();
});
});
@@ -0,0 +1,66 @@
<?php
use App\Jobs\SubscriptionInvoiceFailedJob;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\ApiConnectionException;
use Stripe\Service\InvoiceService;
use Stripe\Service\SubscriptionService;
use Stripe\Stripe;
use Stripe\StripeClient;
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');
// The failure email builds a portal session with the static Stripe API. Make it fail at once, without network.
$this->apiBase = Stripe::$apiBase;
Stripe::$apiBase = 'http://127.0.0.1:9';
$this->team = Team::factory()->create();
$this->subscription = Subscription::create([
'team_id' => $this->team->id,
'stripe_customer_id' => 'cus_failed',
'stripe_subscription_id' => 'sub_new',
'stripe_invoice_paid' => false,
]);
$this->invoices = Mockery::mock(InvoiceService::class);
$this->subscriptions = Mockery::mock(SubscriptionService::class);
$stripe = Mockery::mock(StripeClient::class);
$stripe->invoices = $this->invoices;
$stripe->subscriptions = $this->subscriptions;
$this->instance(StripeClient::class, $stripe);
$this->subscriptions->shouldReceive('retrieve')->with('sub_new')->andReturn((object) ['status' => 'incomplete']);
// A recent paid invoice of an older subscription of the same customer.
$this->invoices->shouldReceive('all')->andReturnUsing(fn (array $parameters) => (object) [
'data' => ($parameters['subscription'] ?? null) === null
? [(object) ['paid' => true, 'created' => time(), 'subscription' => 'sub_old']]
: [],
]);
});
afterEach(function () {
Stripe::$apiBase = $this->apiBase;
});
test('a paid invoice of another subscription does not mark the row as paid', function () {
// The job goes on to the failure email, which needs the Stripe portal.
expect(fn () => (new SubscriptionInvoiceFailedJob($this->team))->handle())->toThrow(ApiConnectionException::class);
expect($this->subscription->fresh()->stripe_invoice_paid)->toBeFalsy();
});
test('a row without a subscription ID is not marked as paid by customer invoices', function () {
$this->subscription->update(['stripe_subscription_id' => null]);
expect(fn () => (new SubscriptionInvoiceFailedJob($this->team->fresh()))->handle())->toThrow(ApiConnectionException::class);
expect($this->subscription->fresh()->stripe_invoice_paid)->toBeFalsy();
});
@@ -293,3 +293,45 @@ test('the terminal command passes the fix option to the reconciliation action',
->expectsOutput('Skipped for manual review: 1')
->assertSuccessful();
});
test('a paid row without a subscription ID is ended when the customer has no live Stripe subscription', function () {
$this->subscription->update(['stripe_subscription_id' => null]);
$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());
$customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) ['email' => null]);
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
expect($result['discrepancies'])->toHaveCount(1)
->and($result['discrepancies'][0]['resolution'])->toBe('end_subscription')
->and($this->subscription->fresh()->stripe_invoice_paid)->toBeFalsy();
});
test('a paid row without a subscription ID is linked to the live Stripe subscription of its customer', function () {
$this->subscription->update(['stripe_subscription_id' => null]);
$stripe = Mockery::mock(StripeClient::class);
$subscriptions = Mockery::mock(SubscriptionService::class);
$stripe->subscriptions = $subscriptions;
$subscriptions->shouldReceive('all')->with(['status' => 'active', 'limit' => 100])
->andReturn(stripeSubscriptionCollection([['id' => 'sub_live', 'customer' => 'cus_stale']]));
$subscriptions->shouldReceive('all')->with(['status' => 'past_due', 'limit' => 100])
->andReturn(stripeSubscriptionCollection());
$this->instance(StripeClient::class, $stripe);
$result = SyncStripeSubscriptions::run(fix: true);
expect($result['discrepancies'][0]['resolution'])->toBe('link_subscription')
->and($this->subscription->fresh()->stripe_subscription_id)->toBe('sub_live')
->and($this->subscription->fresh()->stripe_invoice_paid)->toBeTruthy();
});
@@ -100,3 +100,27 @@ test('subscriptionEnded is called for canceled status', function () {
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});
test('a row without a subscription ID is ended when the customer has only canceled subscriptions', function () {
$this->subscription->update(['stripe_subscription_id' => null]);
$mockStripe = Mockery::mock(StripeClient::class);
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
$mockStripe->subscriptions = $mockSubscriptions;
// Stripe lists canceled subscriptions only with status "all".
$mockSubscriptions->shouldReceive('all')
->with(Mockery::on(fn (array $parameters) => ($parameters['status'] ?? null) === 'all'))
->andReturn((object) ['data' => [(object) ['id' => 'sub_gone', 'status' => 'canceled']]]);
$mockSubscriptions->shouldReceive('retrieve')->with('sub_gone')->andReturn((object) [
'status' => 'canceled',
'cancel_at_period_end' => false,
]);
app()->bind(StripeClient::class, fn () => $mockStripe);
(new VerifyStripeSubscriptionStatusJob($this->subscription))->handle();
$this->subscription->refresh();
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
});