293 lines
12 KiB
PHP
293 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Actions\ApplyStripeBillingEvent;
|
|
use App\Actions\SuspendInstance;
|
|
use App\Mail\DunningNoticeMail;
|
|
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;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Fix-Runde zum Codex-Review vom 31.7.2026 — zwei P1, ein P2, und ein dritter
|
|
* Befund, den der Review nicht genannt hat: das ABSCHALTEN war genauso zu weit
|
|
* gefasst wie das Wiederhochfahren. Beide trafen jede Cloud des Kunden, obwohl
|
|
* ausdrücklich vereinbart war, dass nur der betroffene Vertrag betroffen ist.
|
|
*/
|
|
function customerWithTwoClouds(): array
|
|
{
|
|
$customer = Customer::factory()->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();
|
|
});
|
|
|
|
// ---- Zweite Fix-Runde: Regressionen der ersten --------------------------
|
|
|
|
it('sends nothing at all during a dry run', function () {
|
|
// Ein Befehl, der „nur zeigt, was geschähe", darf keine Kundenmail
|
|
// verschicken und nichts speichern. Der Nachhol-Lauf stand vor der
|
|
// Abfrage auf --dry-run.
|
|
['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()->subMinute(),
|
|
'fee_invoice_ids' => [], 'notified_levels' => [],
|
|
]);
|
|
|
|
app()->instance(StripeClient::class, new FakeStripeClient);
|
|
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
|
|
Mail::fake();
|
|
|
|
test()->artisan('clupilot:advance-dunning --dry-run')->assertSuccessful();
|
|
|
|
Mail::assertNothingQueued();
|
|
expect($case->fresh()->level)->toBe(1)
|
|
->and($case->fresh()->notified_levels)->toBe([]);
|
|
});
|
|
|
|
it('records the opening notice so it is not sent a second time', function () {
|
|
// OpenDunningCase verschickt Stufe 0 sofort, vermerkte sie aber nicht —
|
|
// der nächste Tageslauf hielt sie für verloren und schickte dieselbe Mail
|
|
// noch einmal.
|
|
Mail::fake();
|
|
customerWithTwoClouds();
|
|
|
|
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([
|
|
'id' => 'in_neu', 'subscription' => 'sub_schuldig',
|
|
]);
|
|
|
|
$case = DunningCase::query()->where('stripe_invoice_id', 'in_neu')->sole();
|
|
expect($case->notified_levels)->toBe([0]);
|
|
|
|
app()->instance(StripeClient::class, new FakeStripeClient);
|
|
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
|
|
|
|
test()->artisan('clupilot:advance-dunning');
|
|
|
|
Mail::assertQueued(DunningNoticeMail::class, 1);
|
|
});
|
|
|
|
it('does not let one broken case stop the catch-up for the others', function () {
|
|
// Dieselbe Zusicherung, die das Weiterrücken längst hat: ein Fall, der
|
|
// stolpert, darf die übrigen nicht liegen lassen.
|
|
['schuldig' => $schuldig, 'bezahlt' => $zweiter] = customerWithTwoClouds();
|
|
|
|
foreach ([[$schuldig, 'in_a'], [$zweiter, 'in_b']] as [$sub, $invoice]) {
|
|
DunningCase::query()->create([
|
|
'subscription_id' => $sub->id, 'stripe_invoice_id' => $invoice, '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);
|
|
|
|
// Der erste Fall wirft beim Vermerken, der zweite muss trotzdem laufen.
|
|
DunningCase::query()->orderBy('id')->first()->update(['stripe_invoice_id' => 'in_a']);
|
|
|
|
test()->artisan('clupilot:advance-dunning')->assertSuccessful();
|
|
|
|
// Beide sind vermerkt — keiner blieb liegen.
|
|
expect(DunningCase::query()->get()->every(fn ($c) => $c->notified_levels === [1]))->toBeTrue();
|
|
});
|