create(['name' => 'Berger GmbH', 'stripe_customer_id' => 'cus_42']); $machen = function (string $subId, int $vmid) use ($customer) { $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']); $instance = Instance::factory()->create([ 'customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => 'start', 'status' => 'active', 'vmid' => $vmid, ]); $subscription = Subscription::factory()->create([ 'customer_id' => $customer->id, 'order_id' => $order->id, 'instance_id' => $instance->id, 'stripe_subscription_id' => $subId, 'price_cents' => 4900, ]); return [$subscription, $instance]; }; [$schuldig, $schuldigeCloud] = $machen('sub_schuldig', 101); [$bezahlt, $bezahlteCloud] = $machen('sub_bezahlt', 102); return compact('customer', 'schuldig', 'schuldigeCloud', 'bezahlt', 'bezahlteCloud'); } // ---- P1 (2) und der ungenannte Befund: die Reichweite ------------------- it('shuts down only the cloud of the contract in arrears', function () { // Der ungenannte Befund. Ein Kunde mit zwei Verträgen, einer davon // überfällig — der zweite ist bezahlt und darf nicht mitstehen. Genau der // Anruf, den man nicht will. Mail::fake(); ['schuldig' => $schuldig, 'schuldigeCloud' => $aus, 'bezahlteCloud' => $laeuft] = customerWithTwoClouds(); DunningCase::query()->create([ 'subscription_id' => $schuldig->id, 'stripe_invoice_id' => 'in_1', 'level' => DunningSchedule::SUSPENDED - 1, 'opened_at' => Carbon::now()->subDays(17), 'next_step_at' => Carbon::now()->subMinute(), 'fee_invoice_ids' => [], ]); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); app()->instance(StripeClient::class, new FakeStripeClient); test()->artisan('clupilot:advance-dunning')->assertSuccessful(); expect($aus->fresh()->suspended_at)->not->toBeNull() ->and($laeuft->fresh()->suspended_at)->toBeNull(); }); it('does not bring a cloud back while another case still has it stopped', function () { // Codex P1: eine Zahlung auf Vertrag A holte jede gesperrte Cloud des // Kunden zurück — auch die, für die noch geschuldet wird. Mail::fake(); $w = customerWithTwoClouds(); foreach ([['schuldig', 'in_a'], ['bezahlt', 'in_b']] as [$key, $invoice]) { DunningCase::query()->create([ 'subscription_id' => $w[$key]->id, 'stripe_invoice_id' => $invoice, 'level' => DunningSchedule::SUSPENDED, 'opened_at' => Carbon::now()->subDays(24), 'next_step_at' => null, 'fee_invoice_ids' => [], ]); } app()->instance(ProxmoxClient::class, new FakeProxmoxClient); app(SuspendInstance::class)($w['schuldigeCloud']); app(SuspendInstance::class)($w['bezahlteCloud']); // Nur die Rechnung des einen Vertrags ist bezahlt. $stripe = new FakeStripeClient; $stripe->openInvoiceRows = [[ 'id' => 'in_a', 'number' => 'R-A', 'amount_due_cents' => 4900, 'currency' => 'EUR', 'created_at' => '1750000000', ]]; app()->instance(StripeClient::class, $stripe); app(ApplyStripeBillingEvent::class)->invoicePaid([ 'id' => 'in_b', 'subscription' => 'sub_bezahlt', 'billing_reason' => 'subscription_cycle', ]); expect($w['bezahlteCloud']->fresh()->suspended_at)->toBeNull() // Die andere bleibt aus — für sie wird noch geschuldet. ->and($w['schuldigeCloud']->fresh()->suspended_at)->not->toBeNull(); }); // ---- P1 (1): keine zweite Gebühr für dieselbe Stufe --------------------- it('records the level before it asks Stripe for the fee', function () { // Codex P1: Stripe legt die Rechnung an, der Prozess stirbt, das Speichern // kommt nie — und der nächste Lauf bucht dieselbe Mahnstufe ein zweites // Mal. Fünf Euro doppelt sind wenig Geld und viel Vertrauensverlust. Mail::fake(); ['schuldig' => $schuldig] = customerWithTwoClouds(); $case = DunningCase::query()->create([ 'subscription_id' => $schuldig->id, 'stripe_invoice_id' => 'in_1', 'level' => 1, 'opened_at' => Carbon::now()->subDays(10), 'next_step_at' => Carbon::now()->subMinute(), 'fee_invoice_ids' => [], ]); $stripe = new class extends FakeStripeClient { public function createFeeInvoice(string $customerId, int $amountCents, string $currency, string $description, ?string $idempotencyKey = null): string { parent::createFeeInvoice($customerId, $amountCents, $currency, $description, $idempotencyKey); // Genau der Absturz: Stripe hat angelegt, wir kommen nicht mehr // zum Speichern des Ergebnisses. throw new RuntimeException('Prozess gestorben nach dem Anlegen'); } }; app()->instance(StripeClient::class, $stripe); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); test()->artisan('clupilot:advance-dunning'); // Die Stufe ist vermerkt, obwohl der Lauf gestorben ist. expect(array_key_exists('2', $case->fresh()->fee_invoice_ids ?? []))->toBeTrue(); // Und ein zweiter Lauf bucht NICHT noch einmal. $case->fresh()->update(['next_step_at' => Carbon::now()->subMinute()]); $zweiter = new FakeStripeClient; app()->instance(StripeClient::class, $zweiter); test()->artisan('clupilot:advance-dunning'); expect($zweiter->feeInvoices)->toBe([]); }); it('sends an idempotency key with the fee so a same-day retry cannot double it', function () { Mail::fake(); ['schuldig' => $schuldig] = customerWithTwoClouds(); $case = DunningCase::query()->create([ 'subscription_id' => $schuldig->id, 'stripe_invoice_id' => 'in_1', 'level' => 1, 'opened_at' => Carbon::now()->subDays(10), 'next_step_at' => Carbon::now()->subMinute(), 'fee_invoice_ids' => [], ]); $stripe = new FakeStripeClient; app()->instance(StripeClient::class, $stripe); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); test()->artisan('clupilot:advance-dunning'); expect($stripe->feeInvoices[0]['idempotency_key'])->toBe('clupilot-dunning-fee-'.$case->id.'-2'); }); // ---- P2: eine verlorene Nachricht wird nachgeholt ----------------------- it('retries a notice that never made it out', function () { // Codex P2: der Fall rückte weiter, die Mail scheiterte, und kein späterer // Lauf holte sie nach — der Kunde wurde gemahnt, ohne es zu erfahren. ['schuldig' => $schuldig] = customerWithTwoClouds(); $case = DunningCase::query()->create([ 'subscription_id' => $schuldig->id, 'stripe_invoice_id' => 'in_1', 'level' => 1, 'opened_at' => Carbon::now()->subDays(3), 'next_step_at' => Carbon::now()->addDays(7), 'fee_invoice_ids' => [], 'notified_levels' => [], ]); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); Mail::fake(); test()->artisan('clupilot:advance-dunning')->assertSuccessful(); // Nicht fällig, aber die Nachricht zur AKTUELLEN Stufe fehlt — also wird // sie nachgeholt, ohne dass der Fall weiterrückt. Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 1); expect($case->fresh()->level)->toBe(1) ->and($case->fresh()->notified_levels)->toContain(1); }); it('does not write the same level twice once it went out', function () { ['schuldig' => $schuldig] = customerWithTwoClouds(); DunningCase::query()->create([ 'subscription_id' => $schuldig->id, 'stripe_invoice_id' => 'in_1', 'level' => 1, 'opened_at' => Carbon::now()->subDays(3), 'next_step_at' => Carbon::now()->addDays(7), 'fee_invoice_ids' => [], 'notified_levels' => [1], ]); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); Mail::fake(); test()->artisan('clupilot:advance-dunning'); Mail::assertNothingQueued(); });