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(); });