213 lines
7.9 KiB
PHP
213 lines
7.9 KiB
PHP
<?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();
|
|
// Über die BESTELLUNG verknüpft, wie im Betrieb: OpenSubscription füllt
|
|
// `instance_id` nicht, `order_id` steht bei jedem Vertrag.
|
|
$subscription = Subscription::factory()->create([
|
|
'customer_id' => $instance->customer_id,
|
|
'order_id' => $instance->order_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,
|
|
'order_id' => $instance->order_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();
|
|
// Über die BESTELLUNG verknüpft, wie im Betrieb: OpenSubscription füllt
|
|
// `instance_id` nicht, `order_id` steht bei jedem Vertrag.
|
|
$subscription = Subscription::factory()->create([
|
|
'customer_id' => $instance->customer_id,
|
|
'order_id' => $instance->order_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();
|
|
});
|