catchUpNotices(); $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; } /** * 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(): 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) { $this->line(sprintf(' %-28s Nachricht zu Stufe %d nachgeholt', $case->subscription?->customer?->name ?? $case->subscription_id, $case->level)); $this->notify($case, $case->level); } } /** * 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); } }