Mahnwesen: Tageslauf mit Gebuehrenrechnungen
parent
29c2718235
commit
9899152d95
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'fee_description' => ':level. Mahnung — Mahnspesen',
|
||||
];
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'fee_description' => 'Reminder :level — dunning fee',
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue