CluPilotCloud/app/Console/Commands/AdvanceDunning.php

144 lines
5.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Actions\SuspendInstance;
use App\Models\DunningCase;
use App\Models\Instance;
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';
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]),
);
}
}
// Die letzte Stufe schaltet ab. Nur die Clouds des betroffenen Kunden,
// und gelöscht wird nichts — siehe SuspendInstance.
if ($level >= DunningSchedule::SUSPENDED) {
foreach (Instance::query()
->where('customer_id', $case->subscription?->customer_id)
->whereNull('suspended_at')
->where('status', 'active')
->get() as $instance) {
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.
$mailer = app(DunningMailer::class);
$gesperrt ? $mailer->suspended($case) : $mailer->level($case, $level);
}
}