From a64d41639b509ea60cc5ff3db711fe211ea2a24c Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 22:31:04 +0200 Subject: [PATCH] Mahnwesen: die sechs Kundenmails --- app/Actions/OpenDunningCase.php | 13 +- app/Actions/SettleDunningCase.php | 11 ++ app/Console/Commands/AdvanceDunning.php | 9 + app/Mail/CloudResumedMail.php | 43 +++++ app/Mail/CloudSuspendedMail.php | 43 +++++ app/Mail/DunningNoticeMail.php | 62 +++++++ app/Mail/InvoiceMail.php | 3 +- app/Mail/OrderConfirmationMail.php | 3 +- app/Services/Billing/DunningMailer.php | 114 ++++++++++++ app/Services/Mail/MailPreviews.php | 47 ++++- lang/de/dunning_mail.php | 37 ++++ lang/en/dunning_mail.php | 37 ++++ resources/views/mail/cloud-resumed.blade.php | 27 +++ .../views/mail/cloud-suspended.blade.php | 27 +++ resources/views/mail/dunning-notice.blade.php | 64 +++++++ tests/Feature/Billing/DunningMailTest.php | 171 ++++++++++++++++++ 16 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 app/Mail/CloudResumedMail.php create mode 100644 app/Mail/CloudSuspendedMail.php create mode 100644 app/Mail/DunningNoticeMail.php create mode 100644 app/Services/Billing/DunningMailer.php create mode 100644 lang/de/dunning_mail.php create mode 100644 lang/en/dunning_mail.php create mode 100644 resources/views/mail/cloud-resumed.blade.php create mode 100644 resources/views/mail/cloud-suspended.blade.php create mode 100644 resources/views/mail/dunning-notice.blade.php create mode 100644 tests/Feature/Billing/DunningMailTest.php diff --git a/app/Actions/OpenDunningCase.php b/app/Actions/OpenDunningCase.php index 0e55369..4eaf62b 100644 --- a/app/Actions/OpenDunningCase.php +++ b/app/Actions/OpenDunningCase.php @@ -4,6 +4,7 @@ namespace App\Actions; use App\Models\DunningCase; use App\Models\Subscription; +use App\Services\Billing\DunningMailer; use App\Services\Billing\DunningSchedule; use Illuminate\Support\Carbon; @@ -33,7 +34,7 @@ class OpenDunningCase $now = Carbon::now(); - return DunningCase::query()->firstOrCreate( + $case = DunningCase::query()->firstOrCreate( ['stripe_invoice_id' => $invoiceId], [ 'subscription_id' => $subscription->id, @@ -44,5 +45,15 @@ class OpenDunningCase 'fee_invoice_ids' => [], ], ); + + // Der Hinweis geht SOFORT hinaus, nicht erst mit dem Tageslauf: der + // Kunde soll von der gescheiterten Abbuchung erfahren, solange er noch + // weiss, wovon die Rede ist. Nur beim ERSTEN Mal — wasRecentlyCreated + // unterscheidet das Eröffnen vom Wiedersehen derselben Rechnung. + if ($case->wasRecentlyCreated) { + app(DunningMailer::class)->level($case->load('subscription.customer'), 0); + } + + return $case; } } diff --git a/app/Actions/SettleDunningCase.php b/app/Actions/SettleDunningCase.php index 16fe6d4..25ad1d0 100644 --- a/app/Actions/SettleDunningCase.php +++ b/app/Actions/SettleDunningCase.php @@ -4,6 +4,8 @@ namespace App\Actions; 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\Support\Carbon; @@ -44,6 +46,8 @@ class SettleDunningCase $case->update(['settled_at' => Carbon::now()]); + $stand = $case->level; + // Und die Cloud kommt zurück. Nur die des betroffenen Vertrags — // andere Verträge desselben Kunden waren nie betroffen. foreach (Instance::query() @@ -53,6 +57,13 @@ class SettleDunningCase app(ResumeInstance::class)($instance); } + // Nur wer wirklich stand, bekommt die Entwarnung. Wer auf Stufe 1 + // bezahlt hat, hat nie eine Sperre gesehen — ihm zu schreiben, seine + // Cloud laufe wieder, wäre eine Nachricht über etwas, das nie war. + if ($stand >= DunningSchedule::SUSPENDED) { + app(DunningMailer::class)->resumed($case); + } + return true; } } diff --git a/app/Console/Commands/AdvanceDunning.php b/app/Console/Commands/AdvanceDunning.php index 2dabcc1..bd2ad3a 100644 --- a/app/Console/Commands/AdvanceDunning.php +++ b/app/Console/Commands/AdvanceDunning.php @@ -5,6 +5,7 @@ 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; @@ -122,6 +123,8 @@ class AdvanceDunning extends Command } } + $gesperrt = $level >= DunningSchedule::SUSPENDED; + $case->update([ 'level' => $level, 'fee_invoice_ids' => $rechnungen, @@ -130,5 +133,11 @@ class AdvanceDunning extends Command ? 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); } } diff --git a/app/Mail/CloudResumedMail.php b/app/Mail/CloudResumedMail.php new file mode 100644 index 0000000..dfbb8d8 --- /dev/null +++ b/app/Mail/CloudResumedMail.php @@ -0,0 +1,43 @@ +mailer('cp_'.MailPurpose::BILLING); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_resumed')); + } + + public function content(): Content + { + return new Content(view: 'mail.cloud-resumed'); + } +} diff --git a/app/Mail/CloudSuspendedMail.php b/app/Mail/CloudSuspendedMail.php new file mode 100644 index 0000000..0a68420 --- /dev/null +++ b/app/Mail/CloudSuspendedMail.php @@ -0,0 +1,43 @@ +mailer('cp_'.MailPurpose::BILLING); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_suspended')); + } + + public function content(): Content + { + return new Content(view: 'mail.cloud-suspended'); + } +} diff --git a/app/Mail/DunningNoticeMail.php b/app/Mail/DunningNoticeMail.php new file mode 100644 index 0000000..f7d746e --- /dev/null +++ b/app/Mail/DunningNoticeMail.php @@ -0,0 +1,62 @@ +mailer('cp_'.MailPurpose::BILLING); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope( + MailPurpose::BILLING, + __('dunning_mail.subject_'.$this->level), + ); + } + + public function content(): Content + { + return new Content(view: 'mail.dunning-notice'); + } +} diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index bbe8d5d..189933e 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Invoice; +use App\Services\Billing\InvoiceMath; use App\Services\Billing\InvoiceRenderer; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -56,7 +57,7 @@ class InvoiceMail extends Mailable implements ShouldQueue 'number' => $this->invoice->number, 'issuedOn' => $this->invoice->issued_on?->local()->format('d.m.Y'), 'dueOn' => $this->invoice->due_on?->local()->format('d.m.Y'), - 'gross' => \App\Services\Billing\InvoiceMath::money((int) $this->invoice->gross_cents), + 'gross' => InvoiceMath::money((int) $this->invoice->gross_cents), 'currency' => $this->invoice->currency, 'paymentTerms' => (string) ($meta['payment_terms'] ?? ''), 'invoicesUrl' => route('invoices'), diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index 5a3f23e..7d64f5b 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -3,6 +3,7 @@ namespace App\Mail; use App\Mail\Concerns\SendsFromMailbox; +use App\Models\Datacenter; use App\Models\Order; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -72,7 +73,7 @@ class OrderConfirmationMail extends Mailable implements ShouldQueue private function location(): string { - $datacenter = \App\Models\Datacenter::query() + $datacenter = Datacenter::query() ->where('code', $this->order->datacenter) ->first(); diff --git a/app/Services/Billing/DunningMailer.php b/app/Services/Billing/DunningMailer.php new file mode 100644 index 0000000..a601af6 --- /dev/null +++ b/app/Services/Billing/DunningMailer.php @@ -0,0 +1,114 @@ +subscription?->customer; + $address = $customer?->email; + + if (blank($address)) { + return; + } + + $gebuehren = $this->feesSoFar($case); + + $this->send($address, new DunningNoticeMail( + name: (string) ($customer->name ?? ''), + level: $level, + amountCents: (int) ($case->subscription->price_cents ?? 0), + feeCents: DunningSchedule::feeCents($level), + feeTotalCents: $gebuehren, + currency: 'EUR', + billingUrl: route('billing'), + // Das Datum, an dem abgeschaltet wird — aber nur, solange es noch + // bevorsteht. Eine Drohung ohne Termin ist keine Auskunft, und ein + // Termin in der Vergangenheit wäre schlimmer als keiner. + suspendOn: $level < DunningSchedule::SUSPENDED + ? $case->opened_at->copy() + ->addDays(DunningSchedule::dayOfLevel(DunningSchedule::SUSPENDED)) + ->local()->isoFormat('D. MMMM YYYY') + : null, + )); + } + + public function suspended(DunningCase $case): void + { + $customer = $case->subscription?->customer; + + if (blank($customer?->email)) { + return; + } + + $this->send($customer->email, new CloudSuspendedMail( + name: (string) ($customer->name ?? ''), + amountCents: (int) ($case->subscription->price_cents ?? 0), + feeTotalCents: $this->feesSoFar($case), + currency: 'EUR', + billingUrl: route('billing'), + )); + } + + public function resumed(DunningCase $case): void + { + $customer = $case->subscription?->customer; + + if (blank($customer?->email)) { + return; + } + + $this->send($customer->email, new CloudResumedMail( + name: (string) ($customer->name ?? ''), + // Beglichen ist beglichen: keine Zahl mehr, die nach Restschuld + // aussieht. + amountCents: 0, + feeTotalCents: 0, + currency: 'EUR', + billingUrl: route('cloud'), + )); + } + + /** Alle bisher angefallenen Mahnspesen dieses Falls. */ + private function feesSoFar(DunningCase $case): int + { + return collect(array_keys($case->fee_invoice_ids ?? [])) + ->sum(fn ($level) => DunningSchedule::feeCents((int) $level)); + } + + private function send(string $address, object $mailable): void + { + try { + Mail::to($address)->queue($mailable); + } catch (Throwable $e) { + // Siehe Kopfkommentar: laut, aber nicht kippend. + Log::error('Eine Mahnungs-Mail liess sich nicht einreihen', [ + 'mailable' => $mailable::class, + 'exception' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 4d81734..e0b9081 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -2,14 +2,18 @@ namespace App\Services\Mail; +use App\Console\Commands\PruneDormantAccounts; +use App\Mail\CloudResumedMail; +use App\Mail\CloudSuspendedMail; +use App\Mail\Concerns\SendsFromMailbox; +use App\Mail\DormantAccountWarningMail; +use App\Mail\DunningNoticeMail; use App\Mail\InvoiceMail; use App\Mail\MaintenanceAnnouncementMail; use App\Mail\MaintenanceCancelledMail; use App\Mail\NewDeviceSignInMail; use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; -use App\Console\Commands\PruneDormantAccounts; -use App\Mail\DormantAccountWarningMail; use App\Mail\ResetPasswordMail; use App\Mail\VerifyEmailMail; use App\Models\Customer; @@ -18,7 +22,10 @@ use App\Models\MaintenanceWindow; use App\Models\Order; use App\Models\User; use App\Models\UserDevice; +use App\Services\Billing\DunningSchedule; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; use Illuminate\Support\Carbon; /** @@ -62,6 +69,16 @@ class MailPreviews 'new-device' => 'Anmeldung von einem neuen Gerät', 'dormant-warning' => 'Konto ohne Paket wird gelöscht', 'operator-message' => 'Nachricht aus der Konsole', + // Die sechs Texte des Mahnlaufs. Vorschaubar, weil sie die + // unangenehmsten sind, die dieses Haus verschickt — und die + // einzigen, die ein Kunde bekommt, wenn ohnehin gerade etwas + // schiefläuft. + 'dunning-0' => 'Zahlung fehlgeschlagen (Hinweis)', + 'dunning-1' => 'Zahlungserinnerung (1. Mahnung)', + 'dunning-2' => '2. Mahnung (mit Mahnspesen)', + 'dunning-3' => '3. Mahnung (letzte vor Abschaltung)', + 'cloud-suspended' => 'Cloud abgeschaltet', + 'cloud-resumed' => 'Cloud läuft wieder', ]; } @@ -95,6 +112,24 @@ class MailPreviews 'invoice' => new InvoiceMail($this->invoice($customer), $customer->name), 'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'), 'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE), + 'dunning-0', 'dunning-1', 'dunning-2', 'dunning-3' => new DunningNoticeMail( + name: $customer->name, + level: (int) substr($key, -1), + amountCents: 21480, + feeCents: DunningSchedule::feeCents((int) substr($key, -1)), + feeTotalCents: (int) substr($key, -1) >= 2 ? 500 : 0, + currency: 'EUR', + billingUrl: route('billing'), + suspendOn: now()->addDays(14)->local()->isoFormat('D. MMMM YYYY'), + ), + 'cloud-suspended' => new CloudSuspendedMail( + name: $customer->name, amountCents: 21480, feeTotalCents: 1500, + currency: 'EUR', billingUrl: route('billing'), + ), + 'cloud-resumed' => new CloudResumedMail( + name: $customer->name, amountCents: 0, feeTotalCents: 0, + currency: 'EUR', billingUrl: route('cloud'), + ), 'operator-message' => new OperatorMessageMail( $customer, 'Zur Datenübernahme', @@ -181,7 +216,7 @@ class MailPreviews { $mail = new class($customer) extends Mailable { - use \App\Mail\Concerns\SendsFromMailbox; + use SendsFromMailbox; public function __construct(public Customer $customer) { @@ -193,17 +228,17 @@ class MailPreviews $this->mailer('cp_'.MailPurpose::PROVISIONING); } - public function envelope(): \Illuminate\Mail\Mailables\Envelope + public function envelope(): Envelope { return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject')); } - public function content(): \Illuminate\Mail\Mailables\Content + public function content(): Content { // The same keys App\Notifications\CloudReady hands the view. A // preview that invented its own would render a mail nobody ever // receives, and drift from the real one without saying so. - return new \Illuminate\Mail\Mailables\Content(view: 'mail.cloud-ready', with: [ + return new Content(view: 'mail.cloud-ready', with: [ 'name' => $this->customer->name, 'url' => 'https://beispiel.clupilot.cloud', 'plan' => 'start', diff --git a/lang/de/dunning_mail.php b/lang/de/dunning_mail.php new file mode 100644 index 0000000..7cbcf9f --- /dev/null +++ b/lang/de/dunning_mail.php @@ -0,0 +1,37 @@ + 'Guten Tag :name,', + 'subject_0' => 'Ihre Abbuchung ist fehlgeschlagen', + 'heading_0' => 'Die Abbuchung hat nicht geklappt', + 'preheader_0' => 'Bitte prüfen Sie Ihr Zahlungsmittel — Ihre Cloud läuft weiter.', + 'intro_0' => 'Wir konnten den fälligen Betrag nicht einziehen. Das liegt meist an einer abgelaufenen oder gesperrten Karte. Ihre Cloud läuft unverändert weiter — bitte hinterlegen Sie ein gültiges Zahlungsmittel oder begleichen Sie den Betrag direkt.', + 'subject_1' => 'Zahlungserinnerung', + 'heading_1' => 'Zahlungserinnerung', + 'preheader_1' => 'Der Betrag ist weiterhin offen.', + 'intro_1' => 'Der fällige Betrag ist weiterhin offen. Bitte begleichen Sie ihn oder hinterlegen Sie ein anderes Zahlungsmittel. Ihre Cloud läuft weiter.', + 'subject_2' => '2. Mahnung', + 'heading_2' => 'Zweite Mahnung', + 'preheader_2' => 'Ab dieser Mahnung fallen Mahnspesen an.', + 'intro_2' => 'Trotz unserer Erinnerung ist der Betrag offen. Ab dieser Mahnung müssen wir Mahnspesen verrechnen. Ihre Cloud läuft weiterhin.', + 'subject_3' => '3. Mahnung — letzte vor der Abschaltung', + 'heading_3' => 'Dritte Mahnung', + 'preheader_3' => 'Die letzte Mahnung vor der Abschaltung.', + 'intro_3' => 'Dies ist die letzte Mahnung. Bleibt der Betrag offen, schalten wir Ihre Cloud ab. Sie wird dabei nicht gelöscht — sobald alles beglichen ist, läuft sie wieder.', + 'subject_suspended' => 'Ihre Cloud wurde abgeschaltet', + 'heading_suspended' => 'Ihre Cloud steht', + 'preheader_suspended' => 'Nichts wurde gelöscht — sie kommt zurück, sobald bezahlt ist.', + 'intro_suspended' => 'Wir haben Ihre Cloud abgeschaltet, weil der Betrag offen geblieben ist. Es wurde nichts gelöscht: Ihre Daten, Ihre Backups und Ihre Adresse sind unverändert vorhanden. Sobald alles beglichen ist, fährt die Cloud automatisch wieder hoch.', + 'action_suspended' => 'Jetzt begleichen', + 'subject_resumed' => 'Ihre Cloud läuft wieder', + 'heading_resumed' => 'Ihre Cloud läuft wieder', + 'preheader_resumed' => 'Zahlung eingegangen, alles wieder in Betrieb.', + 'intro_resumed' => 'Vielen Dank — der offene Betrag ist eingegangen und Ihre Cloud läuft wieder. Es kann ein paar Minuten dauern, bis alle Dienste darin erreichbar sind.', + 'action_resumed' => 'Zur Cloud', + 'amount_due' => 'Offener Rechnungsbetrag', + 'fees' => 'Mahnspesen', + 'total' => 'Gesamt', + 'outstanding' => 'Offen sind derzeit :amount.', + 'action' => 'Jetzt begleichen', + 'suspend_warning' => 'Bleibt der Betrag offen, schalten wir Ihre Cloud am :date ab. Gelöscht wird dabei nichts — sie kommt zurück, sobald bezahlt ist.', +]; diff --git a/lang/en/dunning_mail.php b/lang/en/dunning_mail.php new file mode 100644 index 0000000..d94805b --- /dev/null +++ b/lang/en/dunning_mail.php @@ -0,0 +1,37 @@ + 'Hello :name,', + 'subject_0' => 'Your payment did not go through', + 'heading_0' => 'The charge did not go through', + 'preheader_0' => 'Please check your payment method — your cloud keeps running.', + 'intro_0' => 'We could not collect the amount due. That is usually an expired or blocked card. Your cloud keeps running as normal — please add a valid payment method or settle the amount directly.', + 'subject_1' => 'Payment reminder', + 'heading_1' => 'Payment reminder', + 'preheader_1' => 'The amount is still outstanding.', + 'intro_1' => 'The amount due is still outstanding. Please settle it or add a different payment method. Your cloud keeps running.', + 'subject_2' => 'Second reminder', + 'heading_2' => 'Second reminder', + 'preheader_2' => 'A dunning fee applies from this reminder on.', + 'intro_2' => 'Despite our reminder the amount is still outstanding. From this reminder on we have to charge a dunning fee. Your cloud keeps running.', + 'subject_3' => 'Third reminder — the last before shutdown', + 'heading_3' => 'Third reminder', + 'preheader_3' => 'The last reminder before we shut the cloud down.', + 'intro_3' => 'This is the final reminder. If the amount stays outstanding we will shut your cloud down. Nothing is deleted — once everything is settled it runs again.', + 'subject_suspended' => 'Your cloud has been shut down', + 'heading_suspended' => 'Your cloud is stopped', + 'preheader_suspended' => 'Nothing was deleted — it comes back as soon as you pay.', + 'intro_suspended' => 'We have shut your cloud down because the amount stayed outstanding. Nothing was deleted: your data, your backups and your address are all still there. As soon as everything is settled the cloud starts again by itself.', + 'action_suspended' => 'Settle now', + 'subject_resumed' => 'Your cloud is running again', + 'heading_resumed' => 'Your cloud is running again', + 'preheader_resumed' => 'Payment received, everything back in service.', + 'intro_resumed' => 'Thank you — the outstanding amount has arrived and your cloud is running again. It can take a few minutes until every service inside it is reachable.', + 'action_resumed' => 'Go to the cloud', + 'amount_due' => 'Amount due', + 'fees' => 'Dunning fees', + 'total' => 'Total', + 'outstanding' => 'Currently outstanding: :amount.', + 'action' => 'Settle now', + 'suspend_warning' => 'If the amount stays outstanding we will shut your cloud down on :date. Nothing is deleted — it comes back as soon as you pay.', +]; diff --git a/resources/views/mail/cloud-resumed.blade.php b/resources/views/mail/cloud-resumed.blade.php new file mode 100644 index 0000000..f7468c8 --- /dev/null +++ b/resources/views/mail/cloud-resumed.blade.php @@ -0,0 +1,27 @@ + + + +

{{ __('dunning_mail.intro_resumed') }}

+ + +@if ($amountCents + $feeTotalCents > 0) + +

+ {{ __('dunning_mail.outstanding', ['amount' => number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.').' '.$currency]) }} +

+ +@endif + + + + +
+ {{ __('dunning_mail.action_resumed') }} +
+ + +
diff --git a/resources/views/mail/cloud-suspended.blade.php b/resources/views/mail/cloud-suspended.blade.php new file mode 100644 index 0000000..8e613a3 --- /dev/null +++ b/resources/views/mail/cloud-suspended.blade.php @@ -0,0 +1,27 @@ + + + +

{{ __('dunning_mail.intro_suspended') }}

+ + +@if ($amountCents + $feeTotalCents > 0) + +

+ {{ __('dunning_mail.outstanding', ['amount' => number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.').' '.$currency]) }} +

+ +@endif + + + + +
+ {{ __('dunning_mail.action_suspended') }} +
+ + +
diff --git a/resources/views/mail/dunning-notice.blade.php b/resources/views/mail/dunning-notice.blade.php new file mode 100644 index 0000000..f6dcc9f --- /dev/null +++ b/resources/views/mail/dunning-notice.blade.php @@ -0,0 +1,64 @@ + + + +

{{ __('dunning_mail.intro_'.$level) }}

+ + +{{-- Die Aufstellung. Rückstand und Mahnspesen GETRENNT ausgewiesen, nicht als + eine Summe: wer eine Zahl liest, die höher ist als seine Rechnung, hält + sie für einen Fehler und schreibt dem Support, statt zu zahlen. --}} + + + + + + + @if ($feeTotalCents > 0) + + + + + + + + + @endif +
{{ __('dunning_mail.amount_due') }} + {{ number_format($amountCents / 100, 2, ',', '.') }} {{ $currency }} +
{{ __('dunning_mail.fees') }} + {{ number_format($feeTotalCents / 100, 2, ',', '.') }} {{ $currency }} +
{{ __('dunning_mail.total') }} + {{ number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.') }} {{ $currency }} +
+ + +{{-- Ein Knopf, eine Handlung. Er führt auf die Abrechnungsseite, auf der seit + dieser Lieferung beides steht: offene Rechnungen begleichen UND die Karte + tauschen. Ohne die zweite Hälfte wäre die Mahnung eine Aufforderung an + jemanden, der die Ursache nicht abstellen kann. --}} + + + +
+ {{ __('dunning_mail.action') }} +
+ + +@if ($suspendOn !== null) + + + +
+ {{-- Was passiert, wenn nichts passiert — mit Datum. Eine Drohung + ohne Termin ist keine Auskunft, und der Kunde soll rechnen + können statt zu raten. --}} +

{{ __('dunning_mail.suspend_warning', ['date' => $suspendOn]) }}

+
+ +@endif + +
diff --git a/tests/Feature/Billing/DunningMailTest.php b/tests/Feature/Billing/DunningMailTest.php new file mode 100644 index 0000000..4b95a22 --- /dev/null +++ b/tests/Feature/Billing/DunningMailTest.php @@ -0,0 +1,171 @@ +create([ + 'name' => 'Berger GmbH', 'email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42', + ]); + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']); + Instance::factory()->create([ + 'customer_id' => $customer->id, 'order_id' => $order->id, + 'plan' => 'start', 'status' => 'active', 'vmid' => 101, + ]); + $subscription = Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'stripe_subscription_id' => 'sub_42', + 'stripe_status' => 'past_due', + 'price_cents' => 21480, + ]); + + return DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_rueckstand', + 'level' => $level, + 'opened_at' => Carbon::now()->subDays(DunningSchedule::dayOfLevel($level)), + 'next_step_at' => Carbon::now()->subMinute(), + 'fee_invoice_ids' => [], + ]); +} + +it('tells the customer at once when the charge fails', function () { + // Sofort, nicht erst mit dem Tageslauf: der Kunde soll davon erfahren, + // solange er noch weiss, wovon die Rede ist. + Mail::fake(); + + $customer = Customer::factory()->create(['email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42']); + Subscription::factory()->create([ + 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_42', 'price_cents' => 21480, + ]); + + app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([ + 'id' => 'in_1', 'subscription' => 'sub_42', + ]); + + Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 0 + && $mail->feeCents === 0 + && $mail->hasTo('berger@example.test')); +}); + +it('does not write again for a second failed attempt on the same invoice', function () { + Mail::fake(); + + $customer = Customer::factory()->create(['email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42']); + Subscription::factory()->create([ + 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_42', + ]); + + foreach ([1, 2, 3] as $attempt) { + app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([ + 'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => $attempt, + ]); + } + + Mail::assertQueued(DunningNoticeMail::class, 1); +}); + +it('sends a reminder with the fee spelled out separately', function () { + // Rückstand und Mahnspesen getrennt: wer eine Zahl liest, die höher ist als + // seine Rechnung, hält sie für einen Fehler und schreibt dem Support, + // statt zu zahlen. + Mail::fake(); + $case = mailableCase(1); + $case->update(['fee_invoice_ids' => []]); + app()->instance(StripeClient::class, new FakeStripeClient); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + + $this->artisan('clupilot:advance-dunning')->assertSuccessful(); + + Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 2 + && $mail->amountCents === 21480 + && $mail->feeTotalCents === 500); +}); + +it('names the shutdown date while it is still ahead', function () { + Mail::fake(); + mailableCase(0); + app()->instance(StripeClient::class, new FakeStripeClient); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + + $this->artisan('clupilot:advance-dunning'); + + Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 1 + && $mail->suspendOn !== null); +}); + +it('sends the shutdown notice, not a fourth reminder', function () { + Mail::fake(); + mailableCase(DunningSchedule::SUSPENDED - 1); + app()->instance(StripeClient::class, new FakeStripeClient); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + + $this->artisan('clupilot:advance-dunning'); + + Mail::assertQueued(CloudSuspendedMail::class, 1); + Mail::assertNotQueued(DunningNoticeMail::class); +}); + +it('sends the all-clear only to somebody whose cloud actually stood', function () { + Mail::fake(); + $case = mailableCase(DunningSchedule::SUSPENDED); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + app()->instance(StripeClient::class, new FakeStripeClient); + + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', + ]); + + expect($case->fresh()->settled_at)->not->toBeNull(); + Mail::assertQueued(CloudResumedMail::class, 1); +}); + +it('stays quiet about a cloud that never stood', function () { + // Wer auf Stufe 1 bezahlt, hat nie eine Sperre gesehen. Ihm zu schreiben, + // seine Cloud laufe wieder, wäre eine Nachricht über etwas, das nie war. + Mail::fake(); + mailableCase(1); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + app()->instance(StripeClient::class, new FakeStripeClient); + + app(ApplyStripeBillingEvent::class)->invoicePaid([ + 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', + ]); + + Mail::assertNotQueued(CloudResumedMail::class); +}); + +it('advances the case even when the mail cannot be sent', function () { + // Ein Mailserver, der gerade nicht antwortet, darf keinen Fall aufhalten — + // sonst bleibt der Kunde auf der Stufe stehen, auf der die Mail scheiterte, + // und die Sperre kommt nie. + $case = mailableCase(0); + app()->instance(StripeClient::class, new FakeStripeClient); + app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('kein Mailserver')); + + $this->artisan('clupilot:advance-dunning')->assertSuccessful(); + + expect($case->fresh()->level)->toBe(1); +});