Mahnwesen: Tageslauf mit Gebuehrenrechnungen

main
nexxo 2026-07-31 22:13:52 +02:00
parent 29c2718235
commit 9899152d95
8 changed files with 386 additions and 0 deletions

View File

@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands;
use App\Models\DunningCase;
use App\Services\Billing\DunningSchedule;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Der Tageslauf des Mahnwesens: fällige Fälle eine Stufe weiter.
*
* Eine Stufe je Lauf, nie zwei. 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 für denselben Rückstand.
*
* **Die nächste Frist rechnet vom BEGINN des Falls, nicht von heute.** Sonst
* verschöbe sich der ganze Plan mit jedem Lauf, der einen Tag zu spät kommt:
* aus 24 Tagen bis zur Sperre würden unbemerkt dreissig, und der Kunde bekäme
* seine dritte Mahnung, wenn er längst mit einem Anwalt spricht.
*
* Ein Fall, der stolpert, hält die anderen nicht auf. Stripe kann für einen
* Kunden ausfallen, während es für alle anderen antwortet und ein Mahnlauf,
* der beim ersten Fehler abbricht, lässt genau die Fälle liegen, die am
* längsten offen sind.
*/
class AdvanceDunning extends Command
{
protected $signature = 'clupilot:advance-dunning {--dry-run : Nur zeigen, was geschähe}';
protected $description = 'Fällige Mahnfälle eine Stufe weiterschalten';
public function handle(StripeClient $stripe): int
{
$now = Carbon::now();
$faellig = DunningCase::query()
->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)),
]);
}
}

View File

@ -638,4 +638,26 @@ class FakeStripeClient implements StripeClient
return ['paid' => true, 'status' => 'paid', 'failure' => null];
}
/** @var array<int, array{customer: string, amount: int, currency: string, description: string, level: int|null}> */
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);
}
}

View File

@ -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'];
}
}

View File

@ -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;
}

5
lang/de/dunning.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
'fee_description' => ':level. Mahnung — Mahnspesen',
];

5
lang/en/dunning.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
'fee_description' => 'Reminder :level — dunning fee',
];

View File

@ -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

View File

@ -0,0 +1,171 @@
<?php
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
/**
* Der Tageslauf: fällige Fälle eine Stufe weiter, und ab der eingestellten
* Stufe eine Gebührenrechnung.
*
* Die Gebühr wird eine EIGENE Rechnung neben der gescheiterten. Eine
* Stripe-Abo-Rechnung ist beim Scheitern bereits finalisiert; nachträglich
* lässt sich nichts mehr darauf buchen. Die Alternative wäre gewesen, die
* ursprüngliche zu stornieren und neu auszustellen bei drei Mahnstufen also
* dreimal, und die Buchhaltung sähe drei Stornos für einen Vorgang.
*/
function dunningCaseAt(int $level, ?Carbon $due = null): DunningCase
{
$customer = Customer::factory()->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);
});