From a9f15449c65180b6817a42c6932492b85c89e100 Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 22:19:42 +0200 Subject: [PATCH] Mahnwesen: Sperre und Freigabe, zusammen gebaut --- app/Actions/ApplyStripeBillingEvent.php | 11 + app/Actions/ResumeInstance.php | 43 ++++ app/Actions/SettleDunningCase.php | 58 +++++ app/Actions/SuspendInstance.php | 47 ++++ app/Console/Commands/AdvanceDunning.php | 14 ++ app/Models/Instance.php | 3 +- ...1_160000_add_suspended_at_to_instances.php | 31 +++ .../Feature/Billing/DunningSuspensionTest.php | 205 ++++++++++++++++++ 8 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 app/Actions/ResumeInstance.php create mode 100644 app/Actions/SettleDunningCase.php create mode 100644 app/Actions/SuspendInstance.php create mode 100644 database/migrations/2026_07_31_160000_add_suspended_at_to_instances.php create mode 100644 tests/Feature/Billing/DunningSuspensionTest.php diff --git a/app/Actions/ApplyStripeBillingEvent.php b/app/Actions/ApplyStripeBillingEvent.php index 11441fe..f1f0fd5 100644 --- a/app/Actions/ApplyStripeBillingEvent.php +++ b/app/Actions/ApplyStripeBillingEvent.php @@ -2,6 +2,7 @@ namespace App\Actions; +use App\Models\DunningCase; use App\Models\StripePendingEvent; use App\Models\Subscription; use App\Models\SubscriptionRecord; @@ -68,6 +69,16 @@ class ApplyStripeBillingEvent return null; } + // Ein Mahnfall dieses Vertrags kann damit erledigt sein — geprüft + // wird aber gegen Stripes offene Rechnungen, nicht gegen diese eine: + // die Mahnspesen liegen auf eigenen Rechnungen daneben. + foreach (DunningCase::query() + ->where('subscription_id', $subscription->id) + ->whereNull('settled_at') + ->get() as $case) { + app(SettleDunningCase::class)($case); + } + $reason = (string) ($invoice['billing_reason'] ?? ''); // The checkout's own invoice is not a renewal — it is the purchase, and diff --git a/app/Actions/ResumeInstance.php b/app/Actions/ResumeInstance.php new file mode 100644 index 0000000..6320c6d --- /dev/null +++ b/app/Actions/ResumeInstance.php @@ -0,0 +1,43 @@ +suspended_at === null) { + return; + } + + $vmid = $instance->vmid; + $node = $instance->host?->node ?? 'pve'; + + if ($vmid !== null) { + app(ProxmoxClient::class)->startVm($node, (int) $vmid); + } + + // Die Marke fällt, NACHDEM gestartet wurde. Andersherum sähe ein + // gleichzeitiger Tageslauf eine nicht gesperrte Instanz, die noch + // steht — und ein Neustart, der scheitert, hinterliesse eine Cloud, + // die niemand mehr als gesperrt erkennt. + $instance->update(['suspended_at' => null]); + + Log::info('Cloud nach Begleichung wieder hochgefahren', [ + 'instance' => $instance->uuid, + 'vmid' => $vmid, + ]); + } +} diff --git a/app/Actions/SettleDunningCase.php b/app/Actions/SettleDunningCase.php new file mode 100644 index 0000000..16fe6d4 --- /dev/null +++ b/app/Actions/SettleDunningCase.php @@ -0,0 +1,58 @@ +isOpen()) { + return false; + } + + $customerId = $case->subscription?->customer?->stripe_customer_id; + + if (blank($customerId)) { + return false; + } + + $offen = array_column(app(StripeClient::class)->openInvoices((string) $customerId), 'id'); + $unsere = array_merge([$case->stripe_invoice_id], array_values($case->fee_invoice_ids ?? [])); + + // Steht noch eine davon offen, ist der Fall nicht zu. + if (array_intersect($unsere, $offen) !== []) { + return false; + } + + $case->update(['settled_at' => Carbon::now()]); + + // Und die Cloud kommt zurück. Nur die des betroffenen Vertrags — + // andere Verträge desselben Kunden waren nie betroffen. + foreach (Instance::query() + ->where('customer_id', $case->subscription?->customer_id) + ->whereNotNull('suspended_at') + ->get() as $instance) { + app(ResumeInstance::class)($instance); + } + + return true; + } +} diff --git a/app/Actions/SuspendInstance.php b/app/Actions/SuspendInstance.php new file mode 100644 index 0000000..cb84f56 --- /dev/null +++ b/app/Actions/SuspendInstance.php @@ -0,0 +1,47 @@ +suspended_at !== null) { + return; + } + + $vmid = $instance->vmid; + $node = $instance->host?->node ?? 'pve'; + + if ($vmid !== null) { + // Geordnet, nicht abgewürgt: dem Kunden gehören die Daten darin, + // und ein hartes Ausschalten riskiert ein beschädigtes Dateisystem + // für einen Rückstand von ein paar Euro. + app(ProxmoxClient::class)->shutdownVm($node, (int) $vmid, 120); + } + + $instance->update(['suspended_at' => Carbon::now()]); + + Log::info('Cloud wegen offener Zahlung abgeschaltet', [ + 'instance' => $instance->uuid, + 'vmid' => $vmid, + ]); + } +} diff --git a/app/Console/Commands/AdvanceDunning.php b/app/Console/Commands/AdvanceDunning.php index 71931a4..2dabcc1 100644 --- a/app/Console/Commands/AdvanceDunning.php +++ b/app/Console/Commands/AdvanceDunning.php @@ -2,7 +2,9 @@ namespace App\Console\Commands; +use App\Actions\SuspendInstance; use App\Models\DunningCase; +use App\Models\Instance; use App\Services\Billing\DunningSchedule; use App\Services\Stripe\StripeClient; use Illuminate\Console\Command; @@ -108,6 +110,18 @@ class AdvanceDunning extends Command } } + // Die letzte Stufe schaltet ab. Nur die Clouds des betroffenen Kunden, + // und gelöscht wird nichts — siehe SuspendInstance. + if ($level >= DunningSchedule::SUSPENDED) { + foreach (Instance::query() + ->where('customer_id', $case->subscription?->customer_id) + ->whereNull('suspended_at') + ->where('status', 'active') + ->get() as $instance) { + app(SuspendInstance::class)($instance); + } + } + $case->update([ 'level' => $level, 'fee_invoice_ids' => $rechnungen, diff --git a/app/Models/Instance.php b/app/Models/Instance.php index a165a53..a567e84 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -21,7 +21,7 @@ class Instance extends Model 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'quota_applied_gb', 'traffic_addons', 'disk_gb', 'ram_mb', 'cores', 'restart_required_since', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at', - 'route_written', 'routed_hostnames', 'routed_backend', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', + 'route_written', 'routed_hostnames', 'routed_backend', 'cert_ok', 'status', 'suspended_at', 'cancel_requested_at', 'service_ends_at', 'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures', ]; @@ -37,6 +37,7 @@ class Instance extends Model // nc_admin_ref and Host::api_token_ref, for the same reason. 'admin_password' => 'encrypted', 'credentials_acknowledged_at' => 'datetime', + 'suspended_at' => 'datetime', 'domain_verified_at' => 'datetime', 'domain_checked_at' => 'datetime', 'domain_failures' => 'integer', diff --git a/database/migrations/2026_07_31_160000_add_suspended_at_to_instances.php b/database/migrations/2026_07_31_160000_add_suspended_at_to_instances.php new file mode 100644 index 0000000..8512639 --- /dev/null +++ b/database/migrations/2026_07_31_160000_add_suspended_at_to_instances.php @@ -0,0 +1,31 @@ +timestamp('suspended_at')->nullable()->after('status')->index(); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn('suspended_at'); + }); + } +}; diff --git a/tests/Feature/Billing/DunningSuspensionTest.php b/tests/Feature/Billing/DunningSuspensionTest.php new file mode 100644 index 0000000..05d0214 --- /dev/null +++ b/tests/Feature/Billing/DunningSuspensionTest.php @@ -0,0 +1,205 @@ +create(['stripe_customer_id' => 'cus_42']); + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']); + + return Instance::factory()->create([ + 'customer_id' => $customer->id, 'order_id' => $order->id, + 'plan' => 'start', 'status' => 'active', 'vmid' => 101, + ]); +} + +// ---- Die Sperre ---------------------------------------------------------- + +it('shuts the machine down without deleting anything', function () { + $instance = suspendableInstance(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(SuspendInstance::class)($instance); + + $instance->refresh(); + + expect($instance->suspended_at)->not->toBeNull() + // Geordnet heruntergefahren, nicht abgewürgt. + ->and($pve->shutdownCalls)->toHaveCount(1) + ->and($pve->shutdownCalls[0]['vmid'])->toBe(101) + // Und alles, was den Kunden ausmacht, steht noch. + ->and($instance->status)->toBe('active') + ->and(Instance::query()->whereKey($instance->id)->exists())->toBeTrue() + ->and($instance->vmid)->toBe(101); +}); + +it('does not shut a machine down twice', function () { + // Ein zweiter Tageslauf, ein Neustart des Arbeiterprozesses: die Sperre + // ist ein Zustand, kein Ereignis. + $instance = suspendableInstance(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(SuspendInstance::class)($instance); + app(SuspendInstance::class)($instance->fresh()); + + expect($pve->shutdownCalls)->toHaveCount(1); +}); + +// ---- Die Freigabe -------------------------------------------------------- + +it('starts the machine again and clears the mark', function () { + $instance = suspendableInstance(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(SuspendInstance::class)($instance); + app(ResumeInstance::class)($instance->fresh()); + + expect($instance->fresh()->suspended_at)->toBeNull() + ->and($pve->runningVmids)->toContain(101); +}); + +// ---- Der Fall schliesst sich ------------------------------------------- + +function suspendedCase(): DunningCase +{ + $instance = suspendableInstance(); + $subscription = Subscription::factory()->create([ + 'customer_id' => $instance->customer_id, + 'stripe_subscription_id' => 'sub_42', + 'stripe_status' => 'past_due', + ]); + + return DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_rueckstand', + 'level' => DunningSchedule::SUSPENDED, + 'opened_at' => Carbon::now()->subDays(24), + 'next_step_at' => null, + 'fee_invoice_ids' => ['2' => 'in_fee_2', '3' => 'in_fee_3'], + ]); +} + +it('closes the case and boots the machine when everything is paid', function () { + $case = suspendedCase(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + app(SuspendInstance::class)(Instance::query()->sole()); + + // Stripe meldet nichts mehr als offen. + $stripe = new FakeStripeClient; + $stripe->openInvoiceRows = []; + app()->instance(StripeClient::class, $stripe); + + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', + ]); + + expect($case->fresh()->settled_at)->not->toBeNull() + ->and(Instance::query()->sole()->suspended_at)->toBeNull(); +}); + +it('keeps the case open while the fee is still unpaid', function () { + // Der Kern der Bedingung „bis Betrag PLUS Gebühren beglichen sind". Wer den + // Rückstand zahlt und die Mahnspesen offen lässt, ist nicht fertig — und + // seine Cloud bleibt aus. + $case = suspendedCase(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + app(SuspendInstance::class)(Instance::query()->sole()); + + $stripe = new FakeStripeClient; + $stripe->openInvoiceRows = [[ + 'id' => 'in_fee_3', 'number' => 'R-3', 'amount_due_cents' => 1000, + 'currency' => 'EUR', 'created_at' => '1750000000', + ]]; + app()->instance(StripeClient::class, $stripe); + + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', + ]); + + expect($case->fresh()->settled_at)->toBeNull() + ->and(Instance::query()->sole()->suspended_at)->not->toBeNull(); +}); + +it('settles a case that never got as far as a suspension', function () { + $instance = suspendableInstance(); + $subscription = Subscription::factory()->create([ + 'customer_id' => $instance->customer_id, + 'stripe_subscription_id' => 'sub_42', + ]); + $case = DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_rueckstand', + 'level' => 1, + 'opened_at' => Carbon::now()->subDays(3), + 'next_step_at' => Carbon::now()->addDays(7), + 'fee_invoice_ids' => [], + ]); + + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + app()->instance(StripeClient::class, new FakeStripeClient); + + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', + ]); + + expect($case->fresh()->settled_at)->not->toBeNull(); +}); + +it('shuts the cloud down when the last level is reached', function () { + // Stufe 4 stand bis hierher nur im Zeitplan. Ohne diesen Anschluss wäre + // die Sperre eine Zahl in einer Spalte, und die Cloud liefe weiter. + $instance = suspendableInstance(); + $subscription = Subscription::factory()->create([ + 'customer_id' => $instance->customer_id, + 'stripe_subscription_id' => 'sub_42', + 'stripe_status' => 'past_due', + ]); + DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_rueckstand', + 'level' => DunningSchedule::SUSPENDED - 1, + 'opened_at' => Carbon::now()->subDays(17), + 'next_step_at' => Carbon::now()->subMinute(), + 'fee_invoice_ids' => [], + ]); + + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + app()->instance(StripeClient::class, new FakeStripeClient); + + test()->artisan('clupilot:advance-dunning')->assertSuccessful(); + + expect($instance->fresh()->suspended_at)->not->toBeNull() + ->and($pve->shutdownCalls)->toHaveCount(1) + // Und wieder: nichts gelöscht. + ->and(Instance::query()->whereKey($instance->id)->exists())->toBeTrue(); +});