238 lines
9.5 KiB
PHP
238 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Actions\SuspendInstance;
|
|
use App\Models\DunningCase;
|
|
use App\Services\Billing\DunningMailer;
|
|
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';
|
|
|
|
/**
|
|
* Was in `fee_invoice_ids` steht, solange die Antwort von Stripe fehlt.
|
|
*
|
|
* Der Eintrag entsteht VOR dem Aufruf und verhindert damit eine zweite
|
|
* Buchung derselben Stufe — auch dann, wenn niemand mehr erfährt, welche
|
|
* Rechnung dabei entstanden ist.
|
|
*/
|
|
public const PENDING = 'pending';
|
|
|
|
public function handle(StripeClient $stripe): int
|
|
{
|
|
$now = Carbon::now();
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
|
|
// Zuerst nachholen, was nie hinausging. Ein Fall, dessen Mail
|
|
// scheiterte, rückte trotzdem weiter — der Kunde wurde gemahnt und
|
|
// später abgeschaltet, ohne es je zu erfahren. Das läuft VOR dem
|
|
// Weiterrücken, damit eine nachgeholte Nachricht nicht sofort von der
|
|
// nächsten Stufe überholt wird.
|
|
//
|
|
// Im Trockenlauf NICHT. Meine erste Fassung stellte diesen Aufruf vor
|
|
// die Abfrage auf --dry-run: ein Befehl, der „nur zeigt, was geschähe",
|
|
// verschickte damit Kundenmails und schrieb in die Datenbank.
|
|
$this->catchUpNotices($dryRun);
|
|
|
|
$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;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Nachrichten, die nie hinausgingen, nachreichen.
|
|
*
|
|
* `notified_levels` hält fest, welche Stufe dem Kunden wirklich mitgeteilt
|
|
* wurde. Ohne diese Liste liess sich eine verlorene Mail nicht einmal
|
|
* nachträglich feststellen: sie hinterlässt nirgends eine Spur.
|
|
*/
|
|
private function catchUpNotices(bool $dryRun): void
|
|
{
|
|
$offen = DunningCase::query()
|
|
->whereNull('settled_at')
|
|
->with('subscription.customer')
|
|
->get()
|
|
->filter(fn (DunningCase $case) => ! in_array($case->level, $case->notified_levels ?? [], true));
|
|
|
|
foreach ($offen as $case) {
|
|
$kunde = $case->subscription?->customer?->name ?? $case->subscription_id;
|
|
|
|
$this->line(sprintf(' %-28s Nachricht zu Stufe %d %s',
|
|
$kunde, $case->level, $dryRun ? 'wäre nachgeholt worden' : 'nachgeholt'));
|
|
|
|
if ($dryRun) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$this->notify($case, $case->level);
|
|
} catch (Throwable $e) {
|
|
// Dieselbe Zusicherung wie beim Weiterrücken: ein Fall, der
|
|
// stolpert, darf die übrigen nicht liegen lassen. Meine erste
|
|
// Fassung liess die Ausnahme durch und brach damit den ganzen
|
|
// Lauf ab — einschliesslich der fälligen Stufen danach.
|
|
$this->error(sprintf(' %-28s %s', $kunde, $e->getMessage()));
|
|
Log::error('Eine nachzuholende Mahnungs-Nachricht scheiterte', [
|
|
'case' => $case->id,
|
|
'level' => $case->level,
|
|
'exception' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die Nachricht zu einer Stufe, und der Vermerk darüber.
|
|
*
|
|
* Vermerkt wird erst NACH dem Einreihen: andersherum stünde in der Liste
|
|
* eine Nachricht, die nie einging, und niemand holte sie je nach.
|
|
*/
|
|
private function notify(DunningCase $case, int $level): void
|
|
{
|
|
$mailer = app(DunningMailer::class);
|
|
|
|
$level >= DunningSchedule::SUSPENDED
|
|
? $mailer->suspended($case)
|
|
: $mailer->level($case, $level);
|
|
|
|
$case->update([
|
|
'notified_levels' => array_values(array_unique(array_merge($case->notified_levels ?? [], [$level]))),
|
|
]);
|
|
}
|
|
|
|
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)) {
|
|
// Die Stufe wird VERMERKT, BEVOR Stripe gefragt wird
|
|
// (Codex P1). Stirbt der Prozess zwischen dem Anlegen und dem
|
|
// Speichern, sähe der nächste Lauf sonst eine unvermerkte
|
|
// Stufe und buchte dieselbe Mahngebühr ein zweites Mal. Fünf
|
|
// Euro doppelt sind wenig Geld und viel Vertrauensverlust —
|
|
// eine nicht verrechnete Gebühr ist der günstigere Ausgang.
|
|
$rechnungen[(string) $level] = self::PENDING;
|
|
$case->update(['fee_invoice_ids' => $rechnungen]);
|
|
|
|
$rechnungen[(string) $level] = $stripe->createFeeInvoice(
|
|
(string) $kunde,
|
|
$gebuehr,
|
|
'EUR',
|
|
__('dunning.fee_description', ['level' => $level]),
|
|
// Deckt zusätzlich den Wiederholungsversuch innerhalb von
|
|
// 24 Stunden ab: Stripe antwortet dann mit derselben
|
|
// Rechnung statt einer zweiten.
|
|
idempotencyKey: sprintf('clupilot-dunning-fee-%d-%d', $case->id, $level),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Die letzte Stufe schaltet ab. Nur die Clouds des betroffenen Kunden,
|
|
// und gelöscht wird nichts — siehe SuspendInstance.
|
|
// NUR die Cloud dieses Vertrags. Vorher traf es jede Cloud des
|
|
// Kunden — ein zweiter, bezahlter Vertrag stand mit, und das ist der
|
|
// Anruf, den man nicht will. Ausdrücklich so vereinbart: die Sperre
|
|
// trifft den betroffenen Vertrag, nicht das Konto.
|
|
if ($level >= DunningSchedule::SUSPENDED) {
|
|
$instance = $case->instance();
|
|
|
|
if ($instance !== null && $instance->suspended_at === null) {
|
|
app(SuspendInstance::class)($instance);
|
|
}
|
|
}
|
|
|
|
$gesperrt = $level >= DunningSchedule::SUSPENDED;
|
|
|
|
$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)),
|
|
]);
|
|
|
|
// Erst wenn der Zustand steht, dann die Nachricht. Andersherum stünde
|
|
// im Postfach des Kunden eine Sperre, die es nicht gibt, weil das
|
|
// Speichern danach scheiterte.
|
|
$this->notify($case->refresh(), $level);
|
|
}
|
|
}
|