Mahnwesen: Fall, Zeitplan und Gebuehrenstufen
parent
93e848d3f9
commit
9304382f62
|
|
@ -281,6 +281,14 @@ class ApplyStripeBillingEvent
|
|||
// since paid back to past_due.
|
||||
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
|
||||
|
||||
// Der Mahnfall. Bis hierher endete es an dieser Stelle: `past_due`
|
||||
// gesetzt, Datensatz geschrieben, und niemand erfuhr davon — nicht der
|
||||
// Kunde, nicht der Betreiber. Je Rechnung genau einmal, siehe
|
||||
// OpenDunningCase.
|
||||
if ($invoiceId !== '') {
|
||||
app(OpenDunningCase::class)($subscription, $invoiceId);
|
||||
}
|
||||
|
||||
return $this->recordOnce(
|
||||
fn () => ($this->record)(
|
||||
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\DunningCase;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\DunningSchedule;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Der Fall, der entsteht, wenn eine Abbuchung zum ersten Mal scheitert.
|
||||
*
|
||||
* Hängt an ApplyStripeBillingEvent::invoicePaymentFailed(), das bis hierher
|
||||
* `past_due` setzte und schwieg. Der Kunde erfuhr davon erst, wenn seine Cloud
|
||||
* stand.
|
||||
*
|
||||
* JE RECHNUNG GENAU EINMAL. Stripe versucht dieselbe Rechnung mehrfach, und
|
||||
* jeder Versuch ist ein eigenes Ereignis — ohne diese Bedingung entstünden drei
|
||||
* Fälle für eine Schuld, mit drei Mahnläufen und dreifachen Gebühren. Die
|
||||
* Eindeutigkeit steht zusätzlich als Index in der Tabelle, damit zwei
|
||||
* gleichzeitig eintreffende Ereignisse sie nicht umgehen können.
|
||||
*
|
||||
* Eröffnet wird auf Stufe 0 — dem HINWEIS. Keine Mahnung, keine Gebühr: eine
|
||||
* abgelaufene Karte ist keine Zahlungsverweigerung.
|
||||
*/
|
||||
class OpenDunningCase
|
||||
{
|
||||
public function __invoke(Subscription $subscription, string $invoiceId): ?DunningCase
|
||||
{
|
||||
if ($invoiceId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
|
||||
return DunningCase::query()->firstOrCreate(
|
||||
['stripe_invoice_id' => $invoiceId],
|
||||
[
|
||||
'subscription_id' => $subscription->id,
|
||||
'level' => 0,
|
||||
'opened_at' => $now,
|
||||
// Die erste Mahnung, nicht der Hinweis: der geht sofort hinaus.
|
||||
'next_step_at' => $now->copy()->addDays(DunningSchedule::dayOfLevel(1)),
|
||||
'fee_invoice_ids' => [],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Billing\DunningSchedule;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Eine Rückstands-Episode: von der ersten gescheiterten Abbuchung bis zur
|
||||
* Begleichung oder zur Sperre.
|
||||
*/
|
||||
class DunningCase extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'subscription_id', 'stripe_invoice_id', 'level',
|
||||
'opened_at', 'next_step_at', 'settled_at', 'fee_invoice_ids', 'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'level' => 'integer',
|
||||
'opened_at' => 'datetime',
|
||||
'next_step_at' => 'datetime',
|
||||
'settled_at' => 'datetime',
|
||||
'fee_invoice_ids' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function subscription(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subscription::class);
|
||||
}
|
||||
|
||||
/** Läuft noch — weder beglichen noch von Hand geschlossen. */
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->settled_at === null;
|
||||
}
|
||||
|
||||
public function isSuspended(): bool
|
||||
{
|
||||
return $this->level >= DunningSchedule::SUSPENDED;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Support\Settings;
|
||||
|
||||
/**
|
||||
* Wann gemahnt wird und was es kostet — an einer Stelle, einstellbar.
|
||||
*
|
||||
* Fünf Stufen, gezählt in Tagen seit der ersten gescheiterten Abbuchung:
|
||||
*
|
||||
* 0 Hinweis „Ihre Abbuchung ist gescheitert, hier ist der Knopf"
|
||||
* 1 erste Mahnung
|
||||
* 2 zweite Mahnung ab hier Gebühr (Vorgabe)
|
||||
* 3 dritte Mahnung
|
||||
* 4 Cloud aus
|
||||
*
|
||||
* **Stufe 0 ist keine Mahnung.** Keine Gebühr, keine Frist im Rechtssinn — nur
|
||||
* die Nachricht, dass etwas schiefging. Eine abgelaufene Karte ist keine
|
||||
* Zahlungsverweigerung, und wer sie am selben Tag mahnt, verliert Kunden, die
|
||||
* zahlen wollten.
|
||||
*/
|
||||
final class DunningSchedule
|
||||
{
|
||||
public const DAYS = 'dunning.days';
|
||||
|
||||
public const FEE_FROM_LEVEL = 'dunning.fee_from_level';
|
||||
|
||||
public const FEES = 'dunning.fee_cents';
|
||||
|
||||
/** Die letzte Stufe: ab hier steht die Cloud. */
|
||||
public const SUSPENDED = 4;
|
||||
|
||||
/** @var array<int, int> Stufe => Tage seit dem ersten Fehlschlag */
|
||||
public const DEFAULT_DAYS = [0, 3, 10, 17, 24];
|
||||
|
||||
/** @var array<int, int> Stufe => Gebühr in Cent */
|
||||
public const DEFAULT_FEES = [2 => 500, 3 => 1000];
|
||||
|
||||
public const DEFAULT_FEE_FROM_LEVEL = 2;
|
||||
|
||||
/**
|
||||
* Die fünf Fristen, aufsteigend.
|
||||
*
|
||||
* Sortiert statt geglaubt: eine Reihenfolge, die rückwärts läuft, ist kein
|
||||
* Zeitplan — der Tageslauf spränge über Stufen, und die dritte Mahnung
|
||||
* stünde vor der ersten. Ein Formular kann das erzeugen, also wird es hier
|
||||
* abgefangen und nicht dort erhofft.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function days(): array
|
||||
{
|
||||
$stored = Settings::get(self::DAYS);
|
||||
|
||||
$days = is_array($stored) && count($stored) === count(self::DEFAULT_DAYS)
|
||||
? array_map('intval', array_values($stored))
|
||||
: self::DEFAULT_DAYS;
|
||||
|
||||
sort($days);
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
public static function dayOfLevel(int $level): int
|
||||
{
|
||||
return self::days()[$level] ?? self::DEFAULT_DAYS[$level] ?? 0;
|
||||
}
|
||||
|
||||
/** Ab welcher Stufe eine Gebühr anfällt. */
|
||||
public static function feeFromLevel(): int
|
||||
{
|
||||
$stored = Settings::get(self::FEE_FROM_LEVEL);
|
||||
|
||||
// Nie vor Stufe 1: Stufe 0 ist der Hinweis, und eine Gebühr darauf
|
||||
// wäre eine Mahngebühr ohne Mahnung.
|
||||
return is_numeric($stored) ? max(1, (int) $stored) : self::DEFAULT_FEE_FROM_LEVEL;
|
||||
}
|
||||
|
||||
/** Was diese Stufe kostet, in Cent. Null unterhalb der eingestellten Stufe. */
|
||||
public static function feeCents(int $level): int
|
||||
{
|
||||
if ($level < self::feeFromLevel()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stored = Settings::get(self::FEES);
|
||||
$fees = is_array($stored) ? $stored : self::DEFAULT_FEES;
|
||||
|
||||
return (int) ($fees[$level] ?? self::DEFAULT_FEES[$level] ?? 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Eine Zeile je RÜCKSTANDS-EPISODE, nicht je Vertrag.
|
||||
*
|
||||
* Eine Spalte an `subscriptions` wäre kürzer gewesen und falsch: ein Kunde
|
||||
* kann im März und im Juni in Rückstand geraten, und die zweite Episode
|
||||
* überschriebe die erste. Genau die will jemand sehen, wenn der Kunde anruft
|
||||
* und fragt, warum schon wieder Gebühren anfallen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dunning_cases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
/**
|
||||
* Die gescheiterte Rechnung. EINDEUTIG: Stripe versucht dieselbe
|
||||
* mehrfach, und jeder Versuch ist ein eigenes Ereignis — ohne
|
||||
* diesen Index entstünden drei Fälle für eine Schuld, mit drei
|
||||
* Mahnläufen und dreifachen Gebühren.
|
||||
*/
|
||||
$table->string('stripe_invoice_id')->unique();
|
||||
|
||||
// 0 Hinweis, 1–3 Mahnstufen, 4 gesperrt. Siehe DunningSchedule.
|
||||
$table->unsignedTinyInteger('level')->default(0);
|
||||
|
||||
$table->timestamp('opened_at');
|
||||
$table->timestamp('next_step_at')->nullable()->index();
|
||||
$table->timestamp('settled_at')->nullable()->index();
|
||||
|
||||
/**
|
||||
* Die ausgestellten Gebührenrechnungen, eine je Stufe.
|
||||
*
|
||||
* Als Liste am Fall und nicht als eigene Tabelle: sie werden nur
|
||||
* gemeinsam mit ihrem Fall gelesen, nie für sich, und eine Tabelle
|
||||
* mit höchstens drei Zeilen je Fall wäre ein Join ohne Gewinn.
|
||||
*/
|
||||
$table->json('fee_invoice_ids')->nullable();
|
||||
|
||||
/** Was ein Mensch eingegriffen hat — Frist verlängert, Fall geschlossen. */
|
||||
$table->text('note')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dunning_cases');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\ApplyStripeBillingEvent;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DunningCase;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\DunningSchedule;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Das Fundament des Mahnlaufs.
|
||||
*
|
||||
* Bis hierher setzte `invoicePaymentFailed()` den Vertrag auf `past_due` und
|
||||
* schwieg — keine Mail, keine Anzeige, keine Frist. Der Kunde erfuhr davon
|
||||
* erst, wenn seine Cloud stand.
|
||||
*
|
||||
* Der Zustand liegt in einer EIGENEN Tabelle, nicht als Spalte am Vertrag: ein
|
||||
* Kunde kann mehr als einmal in Rückstand geraten, und eine Spalte
|
||||
* überschriebe die vorige Episode — genau die will jemand sehen, wenn der
|
||||
* Kunde anruft und fragt, warum schon wieder Gebühren anfallen.
|
||||
*/
|
||||
function subscriptionInArrears(): Subscription
|
||||
{
|
||||
$customer = Customer::factory()->create(['stripe_customer_id' => 'cus_42']);
|
||||
|
||||
return Subscription::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'stripe_subscription_id' => 'sub_42',
|
||||
'stripe_status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Der Zeitplan --------------------------------------------------------
|
||||
|
||||
it('starts with the schedule the owner agreed', function () {
|
||||
expect(DunningSchedule::dayOfLevel(0))->toBe(0)
|
||||
->and(DunningSchedule::dayOfLevel(1))->toBe(3)
|
||||
->and(DunningSchedule::dayOfLevel(2))->toBe(10)
|
||||
->and(DunningSchedule::dayOfLevel(3))->toBe(17)
|
||||
->and(DunningSchedule::dayOfLevel(DunningSchedule::SUSPENDED))->toBe(24);
|
||||
});
|
||||
|
||||
it('charges nothing before the configured level, and the agreed amounts after', function () {
|
||||
// Tag 0 ist ein HINWEIS, keine Mahnung: eine abgelaufene Karte ist keine
|
||||
// Zahlungsverweigerung, und wer sie am selben Tag mit einer Gebühr
|
||||
// bedenkt, verliert Kunden, die zahlen wollten.
|
||||
expect(DunningSchedule::feeCents(0))->toBe(0)
|
||||
->and(DunningSchedule::feeCents(1))->toBe(0)
|
||||
->and(DunningSchedule::feeCents(2))->toBe(500)
|
||||
->and(DunningSchedule::feeCents(3))->toBe(1000);
|
||||
});
|
||||
|
||||
it('lets the console move the level a fee starts at', function () {
|
||||
Settings::set(DunningSchedule::FEE_FROM_LEVEL, 3);
|
||||
|
||||
expect(DunningSchedule::feeCents(2))->toBe(0)
|
||||
->and(DunningSchedule::feeCents(3))->toBe(1000);
|
||||
});
|
||||
|
||||
it('lets the console move the deadlines, and keeps them in order', function () {
|
||||
Settings::set(DunningSchedule::DAYS, [0, 5, 12, 20, 30]);
|
||||
|
||||
expect(DunningSchedule::dayOfLevel(1))->toBe(5)
|
||||
->and(DunningSchedule::dayOfLevel(DunningSchedule::SUSPENDED))->toBe(30);
|
||||
|
||||
// Eine Reihenfolge, die rückwärts läuft, ist kein Zeitplan. Geklemmt statt
|
||||
// gespeichert: sonst stünde die dritte Mahnung vor der ersten und der
|
||||
// Tageslauf spränge über Stufen.
|
||||
Settings::set(DunningSchedule::DAYS, [0, 20, 5, 12, 30]);
|
||||
|
||||
$tage = DunningSchedule::days();
|
||||
expect($tage)->toBe(array_values(collect($tage)->sort()->all()));
|
||||
});
|
||||
|
||||
// ---- Der Fall entsteht ---------------------------------------------------
|
||||
|
||||
it('opens a case the first time a payment fails', function () {
|
||||
$subscription = subscriptionInArrears();
|
||||
|
||||
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([
|
||||
'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => 1,
|
||||
]);
|
||||
|
||||
$case = DunningCase::query()->where('subscription_id', $subscription->id)->sole();
|
||||
|
||||
expect($case->level)->toBe(0)
|
||||
->and($case->stripe_invoice_id)->toBe('in_1')
|
||||
->and($case->settled_at)->toBeNull()
|
||||
// Tag 0 ist heute — der Hinweis geht sofort hinaus, die erste Mahnung
|
||||
// erst nach der ersten Frist.
|
||||
->and($case->next_step_at->isSameDay(Carbon::now()->addDays(3)))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not open a second case for the same invoice', function () {
|
||||
// Stripe versucht dieselbe Rechnung mehrfach, und jeder Versuch ist ein
|
||||
// eigenes Ereignis. Zwei Fälle für eine Schuld hiesse zwei Mahnläufe und
|
||||
// doppelte Gebühren.
|
||||
subscriptionInArrears();
|
||||
|
||||
foreach ([1, 2, 3] as $attempt) {
|
||||
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([
|
||||
'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => $attempt,
|
||||
]);
|
||||
}
|
||||
|
||||
expect(DunningCase::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('opens a fresh case for a later, different invoice', function () {
|
||||
// Der Grund für die eigene Tabelle: ein Kunde kann im März und im Juni in
|
||||
// Rückstand geraten, und beide Episoden gehören nachlesbar nebeneinander.
|
||||
$subscription = subscriptionInArrears();
|
||||
|
||||
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_1', 'subscription' => 'sub_42']);
|
||||
DunningCase::query()->first()->update(['settled_at' => Carbon::now()]);
|
||||
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_2', 'subscription' => 'sub_42']);
|
||||
|
||||
expect(DunningCase::query()->where('subscription_id', $subscription->id)->count())->toBe(2)
|
||||
->and(DunningCase::query()->whereNull('settled_at')->sole()->stripe_invoice_id)->toBe('in_2');
|
||||
});
|
||||
|
||||
it('leaves the contract alone when nothing can be resolved', function () {
|
||||
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_1', 'subscription' => 'sub_unbekannt']);
|
||||
|
||||
expect(DunningCase::query()->count())->toBe(0);
|
||||
});
|
||||
Loading…
Reference in New Issue