Mahnwesen: Sperre und Freigabe, zusammen gebaut

feat/hostnamen-vergabe
nexxo 2026-07-31 22:19:42 +02:00
parent 9899152d95
commit a9f15449c6
8 changed files with 411 additions and 1 deletions

View File

@ -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

View File

@ -0,0 +1,43 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Log;
/**
* Die Cloud nach der Begleichung wieder hochfahren.
*
* Das Gegenstück zu SuspendInstance, und bewusst zusammen mit ihm gebaut:
* getrennt gäbe es einen Weg hinunter und keinen zurück und der einzige
* Zustand, aus dem ein Kunde nicht selbst herausfindet, ist der, in dem seine
* Cloud steht.
*/
class ResumeInstance
{
public function __invoke(Instance $instance): void
{
if ($instance->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,
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Actions;
use App\Models\DunningCase;
use App\Models\Instance;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
/**
* Einen Mahnfall schliessen aber erst, wenn WIRKLICH alles bezahlt ist.
*
* Die Bedingung des Betreibers lautete „bis der Betrag + Mahngebühren beglichen
* ist". Wer den Rückstand zahlt und die Mahnspesen offen lässt, ist damit nicht
* fertig, und seine Cloud bleibt aus.
*
* Gefragt wird STRIPE, nicht der eigene Zustand: die offenen Rechnungen des
* Kunden sind die Wahrheit über das, was noch aussteht. Ein eigenes Feld
* „bezahlt" je Gebührenrechnung wäre eine zweite Buchhaltung, die mit der
* ersten auseinanderläuft und zwar genau dann, wenn eine Zahlung ausserhalb
* unseres Ablaufs eingeht.
*/
class SettleDunningCase
{
public function __invoke(DunningCase $case): bool
{
if (! $case->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;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Die Cloud eines Kunden abschalten, weil bezahlt werden muss.
*
* **Abschalten heisst abschalten.** Geordnet heruntergefahren, mit Zeitlimit,
* und NICHTS wird gelöscht: keine Platte angefasst, keine Instanz beendet, kein
* DNS-Eintrag entfernt, kein Backup verworfen. Alles kommt zurück, sobald
* bezahlt ist das ist der Unterschied zwischen einer Sperre und einer
* Kündigung, und er darf im Code nicht verschwimmen.
*
* Ein Zustand, kein Ereignis: ein zweiter Tageslauf oder ein neu gestarteter
* Arbeiterprozess fährt nichts ein zweites Mal herunter.
*/
class SuspendInstance
{
public function __invoke(Instance $instance): void
{
if ($instance->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,
]);
}
}

View File

@ -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,

View File

@ -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',

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Wann die Cloud wegen offener Zahlung abgeschaltet wurde.
*
* Eine eigene Marke und KEIN neuer `status`-Wert: `status` beschreibt, was aus
* der Bereitstellung geworden ist (active, failed, ended). Eine Sperre ist
* etwas anderes der Vertrag läuft, die Maschine steht, und alles kommt
* zurück, sobald bezahlt ist. Als Status hätte jede Abfrage, die `active`
* meint, still eine gesperrte Instanz mitgezählt oder verloren.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->timestamp('suspended_at')->nullable()->after('status')->index();
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('suspended_at');
});
}
};

View File

@ -0,0 +1,205 @@
<?php
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\ResumeInstance;
use App\Actions\SuspendInstance;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
/**
* Sperre und Freigabe, zusammen gebaut.
*
* Getrennt gebaut gäbe es einen Weg hinunter und keinen zurück und der
* einzige Zustand, aus dem ein Kunde nicht selbst herausfindet, ist der, in dem
* seine Cloud steht.
*
* **Abschalten heisst abschalten.** Geordnet, mit Zeitlimit, und NICHTS wird
* gelöscht: keine Platte angefasst, keine Instanz beendet, kein DNS-Eintrag
* entfernt. Der teuerste Test in dieser Datei ist der, der das festhält.
*/
function suspendableInstance(): Instance
{
$customer = Customer::factory()->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();
});