Mahnwesen: die sechs Kundenmails

main
nexxo 2026-07-31 22:31:04 +02:00
parent a9f15449c6
commit a64d41639b
16 changed files with 702 additions and 9 deletions

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Eigene Klasse und nicht eine weitere Stufe von DunningNoticeMail: hier ist
* etwas GESCHEHEN, nicht angekündigt. Der Text hat einen anderen Zweck, und
* eine fünfte Stufe im selben Rumpf lüde dazu ein, ihn wie eine Mahnung zu
* formulieren.
*/
class CloudResumedMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(
public string $name,
public int $amountCents,
public int $feeTotalCents,
public string $currency,
public string $billingUrl,
) {
$this->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');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Eigene Klasse und nicht eine weitere Stufe von DunningNoticeMail: hier ist
* etwas GESCHEHEN, nicht angekündigt. Der Text hat einen anderen Zweck, und
* eine fünfte Stufe im selben Rumpf lüde dazu ein, ihn wie eine Mahnung zu
* formulieren.
*/
class CloudSuspendedMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(
public string $name,
public int $amountCents,
public int $feeTotalCents,
public string $currency,
public string $billingUrl,
) {
$this->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');
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Der Hinweis und die drei Mahnungen eine Klasse, vier Texte.
*
* Vier Klassen wären vier Kopien desselben Rumpfs: derselbe Betrag, dieselbe
* Aufstellung, derselbe Knopf. Was sich zwischen den Stufen ändert, ist die
* SPRACHE, und die gehört ohnehin in die Sprachdateien `dunning_mail.subject_0`
* bis `_3`. Ein Kopierfehler zwischen vier Klassen wäre eine Mahnung mit dem
* Text der Vorstufe, und niemand merkt es, bevor sie draussen ist.
*
* **Stufe 0 ist keine Mahnung.** Kein Betreff mit „Mahnung", keine Gebühr, keine
* Frist im Rechtssinn nur die Nachricht, dass die Abbuchung nicht geklappt
* hat. Eine abgelaufene Karte ist keine Zahlungsverweigerung.
*/
class DunningNoticeMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
/**
* @param int $level 0 = Hinweis, 13 = Mahnstufe
* @param int $amountCents der offene Rechnungsbetrag
* @param int $feeCents die Mahnspesen DIESER Stufe, 0 wenn keine
* @param int $feeTotalCents alle bisher angefallenen Mahnspesen
*/
public function __construct(
public string $name,
public int $level,
public int $amountCents,
public int $feeCents,
public int $feeTotalCents,
public string $currency,
public string $billingUrl,
public ?string $suspendOn = null,
) {
$this->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');
}
}

View File

@ -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'),

View File

@ -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();

View File

@ -0,0 +1,114 @@
<?php
namespace App\Services\Billing;
use App\Mail\CloudResumedMail;
use App\Mail\CloudSuspendedMail;
use App\Mail\DunningNoticeMail;
use App\Models\DunningCase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Wer bei einem Mahnfall was erfährt an einer Stelle.
*
* Der Versand liegt nicht im Tageslauf und nicht in den Actions, weil er an
* DREI Stellen ausgelöst wird (Fall eröffnet, Stufe erreicht, Fall beglichen)
* und dreimal dieselbe Frage beantworten muss: an welche Adresse, mit welchem
* Betrag, mit welchen Gebühren.
*
* **Ein Versand darf nie den Vorgang kippen.** Ein Mailserver, der gerade nicht
* antwortet, darf keinen Fall daran hindern, weiterzurücken sonst bleibt der
* Kunde auf der Stufe stehen, auf der die Mail scheiterte, und die Sperre
* kommt nie. Deshalb wird geloggt und geschluckt; die Mail selbst ist ohnehin
* `ShouldQueue` und wird dort erneut versucht.
*/
class DunningMailer
{
public function level(DunningCase $case, int $level): void
{
$customer = $case->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(),
]);
}
}
}

View File

@ -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',

37
lang/de/dunning_mail.php Normal file
View File

@ -0,0 +1,37 @@
<?php
return [
'greeting' => '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.',
];

37
lang/en/dunning_mail.php Normal file
View File

@ -0,0 +1,37 @@
<?php
return [
'greeting' => '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.',
];

View File

@ -0,0 +1,27 @@
<x-mail.layout
:heading="__('dunning_mail.heading_resumed')"
:preheader="__('dunning_mail.preheader_resumed')"
:greeting="$name !== '' ? __('dunning_mail.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('dunning_mail.intro_resumed') }}</p>
</td></tr>
@if ($amountCents + $feeTotalCents > 0)
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">
{{ __('dunning_mail.outstanding', ['amount' => number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.').' '.$currency]) }}
</p>
</td></tr>
@endif
<tr><td style="padding:0 24px 32px 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $billingUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('dunning_mail.action_resumed') }}</a>
</td></tr>
</table>
</td></tr>
</x-mail.layout>

View File

@ -0,0 +1,27 @@
<x-mail.layout
:heading="__('dunning_mail.heading_suspended')"
:preheader="__('dunning_mail.preheader_suspended')"
:greeting="$name !== '' ? __('dunning_mail.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('dunning_mail.intro_suspended') }}</p>
</td></tr>
@if ($amountCents + $feeTotalCents > 0)
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">
{{ __('dunning_mail.outstanding', ['amount' => number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.').' '.$currency]) }}
</p>
</td></tr>
@endif
<tr><td style="padding:0 24px 32px 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $billingUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('dunning_mail.action_suspended') }}</a>
</td></tr>
</table>
</td></tr>
</x-mail.layout>

View File

@ -0,0 +1,64 @@
<x-mail.layout
:heading="__('dunning_mail.heading_'.$level)"
:preheader="__('dunning_mail.preheader_'.$level)"
:greeting="$name !== '' ? __('dunning_mail.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('dunning_mail.intro_'.$level) }}</p>
</td></tr>
{{-- 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. --}}
<tr><td style="padding:0 24px 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;border:1px solid #e9e9ee;border-radius:8px;">
<tr>
<td style="padding:12px 16px;font-size:14px;line-height:20px;color:#6b6b78;">{{ __('dunning_mail.amount_due') }}</td>
<td align="right" style="padding:12px 16px;font-size:14px;line-height:20px;color:#1a1a20;font-weight:600;">
{{ number_format($amountCents / 100, 2, ',', '.') }} {{ $currency }}
</td>
</tr>
@if ($feeTotalCents > 0)
<tr>
<td style="padding:0 16px 12px 16px;font-size:14px;line-height:20px;color:#6b6b78;">{{ __('dunning_mail.fees') }}</td>
<td align="right" style="padding:0 16px 12px 16px;font-size:14px;line-height:20px;color:#1a1a20;">
{{ number_format($feeTotalCents / 100, 2, ',', '.') }} {{ $currency }}
</td>
</tr>
<tr>
<td style="border-top:1px solid #e9e9ee;padding:12px 16px;font-size:14px;line-height:20px;color:#1a1a20;font-weight:600;">{{ __('dunning_mail.total') }}</td>
<td align="right" style="border-top:1px solid #e9e9ee;padding:12px 16px;font-size:14px;line-height:20px;color:#1a1a20;font-weight:600;">
{{ number_format(($amountCents + $feeTotalCents) / 100, 2, ',', '.') }} {{ $currency }}
</td>
</tr>
@endif
</table>
</td></tr>
{{-- 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. --}}
<tr><td style="padding:0 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $billingUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('dunning_mail.action') }}</a>
</td></tr>
</table>
</td></tr>
@if ($suspendOn !== null)
<tr><td style="padding:24px 24px 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
{{-- Was passiert, wenn nichts passiert mit Datum. Eine Drohung
ohne Termin ist keine Auskunft, und der Kunde soll rechnen
können statt zu raten. --}}
<p style="margin:0;font-size:14px;line-height:22px;color:#6b6b78;">{{ __('dunning_mail.suspend_warning', ['date' => $suspendOn]) }}</p>
</td></tr>
</table>
</td></tr>
@endif
</x-mail.layout>

View File

@ -0,0 +1,171 @@
<?php
use App\Actions\ApplyStripeBillingEvent;
use App\Mail\CloudResumedMail;
use App\Mail\CloudSuspendedMail;
use App\Mail\DunningNoticeMail;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
/**
* Ohne die Mails merkt der Kunde von alldem nichts. Der Mahnlauf wäre dann eine
* Maschine, die still Fristen zählt und am 24. Tag die Cloud abschaltet für
* den Kunden aus heiterem Himmel.
*/
function mailableCase(int $level = 0): DunningCase
{
$customer = Customer::factory()->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);
});