Mahnwesen: Sperre und Freigabe, zusammen gebaut
parent
9899152d95
commit
a9f15449c6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue