CluPilotCloud/tests/Feature/Billing/DunningSuspensionTest.php

206 lines
7.5 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();
$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();
});