From 9899152d951e777d55f1b40cf4ed9e1ca3f1e658 Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 22:13:52 +0200 Subject: [PATCH] Mahnwesen: Tageslauf mit Gebuehrenrechnungen --- app/Console/Commands/AdvanceDunning.php | 120 ++++++++++++++++ app/Services/Stripe/FakeStripeClient.php | 22 +++ app/Services/Stripe/HttpStripeClient.php | 34 +++++ app/Services/Stripe/StripeClient.php | 17 +++ lang/de/dunning.php | 5 + lang/en/dunning.php | 5 + routes/console.php | 12 ++ tests/Feature/Billing/DunningRunTest.php | 171 +++++++++++++++++++++++ 8 files changed, 386 insertions(+) create mode 100644 app/Console/Commands/AdvanceDunning.php create mode 100644 lang/de/dunning.php create mode 100644 lang/en/dunning.php create mode 100644 tests/Feature/Billing/DunningRunTest.php diff --git a/app/Console/Commands/AdvanceDunning.php b/app/Console/Commands/AdvanceDunning.php new file mode 100644 index 0000000..71931a4 --- /dev/null +++ b/app/Console/Commands/AdvanceDunning.php @@ -0,0 +1,120 @@ +whereNull('settled_at') + ->where('level', '<', DunningSchedule::SUSPENDED) + ->whereNotNull('next_step_at') + ->where('next_step_at', '<=', $now) + ->with('subscription.customer') + ->get(); + + if ($faellig->isEmpty()) { + $this->info('Kein Mahnfall ist fällig.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $gestolpert = 0; + + foreach ($faellig as $case) { + $stufe = $case->level + 1; + $kunde = $case->subscription?->customer?->name ?? $case->subscription_id; + + $this->line(sprintf(' %-28s Stufe %d → %d', $kunde, $case->level, $stufe)); + + if ($dryRun) { + continue; + } + + try { + $this->advance($case, $stufe, $stripe); + } catch (Throwable $e) { + // Laut, aber nicht abbrechend: ein Fall, der stolpert, darf die + // übrigen nicht liegen lassen. + $gestolpert++; + $this->error(sprintf(' %-28s %s', $kunde, $e->getMessage())); + Log::error('Ein Mahnfall liess sich nicht weiterschalten', [ + 'case' => $case->id, + 'level' => $stufe, + 'exception' => $e->getMessage(), + ]); + } + } + + if ($dryRun) { + $this->newLine(); + $this->comment(sprintf('Trockenlauf — %d Fall/Fälle wären weitergeschaltet worden.', $faellig->count())); + } + + return $gestolpert === 0 ? self::SUCCESS : self::FAILURE; + } + + private function advance(DunningCase $case, int $level, StripeClient $stripe): void + { + $gebuehr = DunningSchedule::feeCents($level); + $rechnungen = $case->fee_invoice_ids ?? []; + + // Je Stufe genau einmal. Ein zweiter Lauf am selben Tag, ein + // abgebrochener Lauf, ein Neustart des Arbeiterprozesses — die Gebühr + // darf nur einmal entstehen, und der Schlüssel ist die Stufe. + if ($gebuehr > 0 && ! array_key_exists((string) $level, $rechnungen)) { + $kunde = $case->subscription?->customer?->stripe_customer_id; + + if (filled($kunde)) { + $rechnungen[(string) $level] = $stripe->createFeeInvoice( + (string) $kunde, + $gebuehr, + 'EUR', + __('dunning.fee_description', ['level' => $level]), + ); + } + } + + $case->update([ + 'level' => $level, + 'fee_invoice_ids' => $rechnungen, + // Vom BEGINN gerechnet, nicht von heute — siehe Kopfkommentar. + 'next_step_at' => $level >= DunningSchedule::SUSPENDED + ? null + : $case->opened_at->copy()->addDays(DunningSchedule::dayOfLevel($level + 1)), + ]); + } +} diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index 8e61435..0a6d53d 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -638,4 +638,26 @@ class FakeStripeClient implements StripeClient return ['paid' => true, 'status' => 'paid', 'failure' => null]; } + + /** @var array */ + public array $feeInvoices = []; + + public function createFeeInvoice( + string $customerId, + int $amountCents, + string $currency, + string $description, + ): string { + $this->feeInvoices[] = [ + 'customer' => $customerId, + 'amount' => $amountCents, + 'currency' => $currency, + 'description' => $description, + // Aus der Beschreibung gezogen, damit ein Test die Stufe prüfen + // kann, ohne dass der Fake sie gesondert übergeben bekäme. + 'level' => preg_match('/(\d+)/', $description, $m) ? (int) $m[1] : null, + ]; + + return 'in_fee_'.count($this->feeInvoices); + } } diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index 1a09d85..8c311fd 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -651,4 +651,38 @@ class HttpStripeClient implements StripeClient 'failure' => (string) $response->json('error.message', ''), ]; } + + public function createFeeInvoice( + string $customerId, + int $amountCents, + string $currency, + string $description, + ): string { + // Erst der Posten, dann die Rechnung: Stripe sammelt offene Posten + // eines Kunden ein, wenn eine Rechnung entsteht. + $this->request() + ->asForm() + ->post($this->url('invoiceitems'), [ + 'customer' => $customerId, + 'amount' => $amountCents, + 'currency' => strtolower($currency), + 'description' => $description, + ]) + ->throw(); + + $invoice = $this->request() + ->asForm() + ->post($this->url('invoices'), [ + 'customer' => $customerId, + 'collection_method' => 'charge_automatically', + // Ohne das bliebe die Rechnung ein ENTWURF, und niemand zöge + // sie je ein — eine Mahngebühr, die nur in Stripe herumliegt. + 'auto_advance' => 'true', + 'description' => $description, + ]) + ->throw() + ->json(); + + return (string) $invoice['id']; + } } diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index 9d8509e..48dd2ea 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -380,4 +380,21 @@ interface StripeClient * @return array{paid: bool, status: string, failure: ?string} */ public function payInvoice(string $invoiceId): array; + + /** + * Eine einmalige Rechnung über einen Betrag, die Stripe selbst einzieht. + * + * Für Mahngebühren: eine gescheiterte Abo-Rechnung ist bereits finalisiert + * und nimmt keinen Posten mehr auf. Die Gebühr wird deshalb eine eigene + * Rechnung daneben — keine Stornierung, keine Rechnungsnummer wird + * angefasst, und jede Mahnstufe kann ihre eigene bekommen. + * + * @return string die Stripe-ID der Rechnung + */ + public function createFeeInvoice( + string $customerId, + int $amountCents, + string $currency, + string $description, + ): string; } diff --git a/lang/de/dunning.php b/lang/de/dunning.php new file mode 100644 index 0000000..4de3ef9 --- /dev/null +++ b/lang/de/dunning.php @@ -0,0 +1,5 @@ + ':level. Mahnung — Mahnspesen', +]; diff --git a/lang/en/dunning.php b/lang/en/dunning.php new file mode 100644 index 0000000..7dd90c1 --- /dev/null +++ b/lang/en/dunning.php @@ -0,0 +1,5 @@ + 'Reminder :level — dunning fee', +]; diff --git a/routes/console.php b/routes/console.php index e6df5ca..8664dd4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -107,6 +107,18 @@ Schedule::command('clupilot:archive-invoices') ->hourly() ->withoutOverlapping(); +// Der Mahnlauf. Einmal am Tag und zu einer festen Stunde, nicht stündlich: +// eine Stufe ist ein Tagesereignis, und ein stündlicher Lauf schaltete eine +// Frist, die um 00:30 fällig wird, dreiundzwanzig Stunden vor der, die um +// 23:30 fällig wird — bei gleichem Kalendertag. +// +// 09:00 Ortszeit, weil eine Mahnung, die nachts um drei ankommt, nach Automat +// aussieht und nicht nach einem Haus, mit dem man reden kann. +Schedule::command('clupilot:advance-dunning') + ->dailyAt('09:00') + ->timezone(config('app.display_timezone')) + ->withoutOverlapping(); + // Plan changes that landed here and never reached Stripe. The change is applied // to the contract and to the machine before Stripe is told, and it is not rolled // back when Stripe is away — so what is left is a customer being billed for a diff --git a/tests/Feature/Billing/DunningRunTest.php b/tests/Feature/Billing/DunningRunTest.php new file mode 100644 index 0000000..2b88ff3 --- /dev/null +++ b/tests/Feature/Billing/DunningRunTest.php @@ -0,0 +1,171 @@ +create(['stripe_customer_id' => 'cus_42']); + $subscription = Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'stripe_subscription_id' => 'sub_42', + 'stripe_status' => 'past_due', + ]); + + return DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_'.$level, + 'level' => $level, + 'opened_at' => Carbon::now()->subDays(DunningSchedule::dayOfLevel($level)), + 'next_step_at' => $due ?? Carbon::now()->subMinute(), + 'fee_invoice_ids' => [], + ]); +} + +// ---- Der Client-Aufruf --------------------------------------------------- + +it('creates a fee invoice that Stripe collects on its own', function () { + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + withStripeSecret(); + Http::preventStrayRequests(); + + Http::fake([ + 'api.stripe.com/v1/invoiceitems' => Http::response(['id' => 'ii_1']), + 'api.stripe.com/v1/invoices' => Http::response(['id' => 'in_fee_1']), + ]); + + $id = app(HttpStripeClient::class)->createFeeInvoice('cus_42', 500, 'EUR', '2. Mahnung'); + + expect($id)->toBe('in_fee_1'); + + // `auto_advance` ist der Punkt: ohne das bliebe die Rechnung ein Entwurf, + // und niemand zöge sie je ein. + Http::assertSent(fn ($request) => str_ends_with($request->url(), '/invoices') + && $request['auto_advance'] === 'true' + && $request['customer'] === 'cus_42'); + + Http::assertSent(fn ($request) => str_ends_with($request->url(), '/invoiceitems') + && (int) $request['amount'] === 500); +}); + +// ---- Der Tageslauf ------------------------------------------------------- + +it('moves a due case one level on', function () { + $case = dunningCaseAt(0); + app()->instance(StripeClient::class, new FakeStripeClient); + + $this->artisan('clupilot:advance-dunning')->assertSuccessful(); + + $case->refresh(); + + expect($case->level)->toBe(1) + // Die nächste Frist rechnet vom BEGINN, nicht von heute: sonst + // verschöbe sich der ganze Plan mit jedem Lauf, der einen Tag zu spät + // kommt, und aus 24 Tagen bis zur Sperre würden unbemerkt dreissig. + ->and($case->next_step_at->isSameDay( + $case->opened_at->copy()->addDays(DunningSchedule::dayOfLevel(2)) + ))->toBeTrue(); +}); + +it('does nothing to a case that is not due yet', function () { + $case = dunningCaseAt(1, Carbon::now()->addDays(3)); + app()->instance(StripeClient::class, new FakeStripeClient); + + $this->artisan('clupilot:advance-dunning')->assertSuccessful(); + + expect($case->fresh()->level)->toBe(1); +}); + +it('runs twice in a day without moving anything twice', function () { + // Der Zeitplaner ruft das täglich, und ein Betreiber ruft es von Hand + // dazwischen. Zwei Stufen an einem Tag wären zwei Mahnungen und zwei + // Gebühren. + $case = dunningCaseAt(0); + app()->instance(StripeClient::class, new FakeStripeClient); + + $this->artisan('clupilot:advance-dunning'); + $this->artisan('clupilot:advance-dunning'); + + expect($case->fresh()->level)->toBe(1); +}); + +it('mints a fee invoice from the configured level on', function () { + $case = dunningCaseAt(1); + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + $this->artisan('clupilot:advance-dunning')->assertSuccessful(); + + $case->refresh(); + + expect($case->level)->toBe(2) + ->and($fake->feeInvoices)->toHaveCount(1) + ->and($fake->feeInvoices[0]['amount'])->toBe(500) + ->and($case->fee_invoice_ids)->toHaveCount(1); +}); + +it('charges nothing for the notice or the first reminder', function () { + $case = dunningCaseAt(0); + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + $this->artisan('clupilot:advance-dunning'); + + expect($case->fresh()->level)->toBe(1) + ->and($fake->feeInvoices)->toBe([]); +}); + +it('never mints the same level\'s fee twice', function () { + // Ein zweiter Lauf am selben Tag, ein abgebrochener Lauf, ein Neustart des + // Arbeiterprozesses — die Gebühr darf je Stufe genau einmal entstehen. + $case = dunningCaseAt(1); + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + $this->artisan('clupilot:advance-dunning'); + $case->update(['next_step_at' => Carbon::now()->subMinute(), 'level' => 2]); + $this->artisan('clupilot:advance-dunning'); + + expect(collect($fake->feeInvoices)->where('level', 2))->toHaveCount(1); +}); + +it('leaves a settled case alone', function () { + $case = dunningCaseAt(1); + $case->update(['settled_at' => Carbon::now()]); + app()->instance(StripeClient::class, new FakeStripeClient); + + $this->artisan('clupilot:advance-dunning'); + + expect($case->fresh()->level)->toBe(1); +}); + +it('follows the configured schedule rather than a fixed one', function () { + Settings::set(DunningSchedule::DAYS, [0, 1, 2, 3, 4]); + Settings::set(DunningSchedule::FEES, [2 => 250, 3 => 750]); + + $case = dunningCaseAt(1); + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + $this->artisan('clupilot:advance-dunning'); + + expect($fake->feeInvoices[0]['amount'])->toBe(250); +});