CluPilotCloud/tests/Feature/Billing/DunningRunTest.php

172 lines
6.1 KiB
PHP

<?php
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
/**
* Der Tageslauf: fällige Fälle eine Stufe weiter, und ab der eingestellten
* Stufe eine Gebührenrechnung.
*
* Die Gebühr wird eine EIGENE Rechnung neben der gescheiterten. Eine
* Stripe-Abo-Rechnung ist beim Scheitern bereits finalisiert; nachträglich
* lässt sich nichts mehr darauf buchen. Die Alternative wäre gewesen, die
* ursprüngliche zu stornieren und neu auszustellen — bei drei Mahnstufen also
* dreimal, und die Buchhaltung sähe drei Stornos für einen Vorgang.
*/
function dunningCaseAt(int $level, ?Carbon $due = null): DunningCase
{
$customer = Customer::factory()->create(['stripe_customer_id' => 'cus_42']);
$subscription = Subscription::factory()->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_42',
'stripe_status' => 'past_due',
]);
return DunningCase::query()->create([
'subscription_id' => $subscription->id,
'stripe_invoice_id' => 'in_'.$level,
'level' => $level,
'opened_at' => Carbon::now()->subDays(DunningSchedule::dayOfLevel($level)),
'next_step_at' => $due ?? Carbon::now()->subMinute(),
'fee_invoice_ids' => [],
]);
}
// ---- Der Client-Aufruf ---------------------------------------------------
it('creates a fee invoice that Stripe collects on its own', function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
withStripeSecret();
Http::preventStrayRequests();
Http::fake([
'api.stripe.com/v1/invoiceitems' => Http::response(['id' => 'ii_1']),
'api.stripe.com/v1/invoices' => Http::response(['id' => 'in_fee_1']),
]);
$id = app(HttpStripeClient::class)->createFeeInvoice('cus_42', 500, 'EUR', '2. Mahnung');
expect($id)->toBe('in_fee_1');
// `auto_advance` ist der Punkt: ohne das bliebe die Rechnung ein Entwurf,
// und niemand zöge sie je ein.
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/invoices')
&& $request['auto_advance'] === 'true'
&& $request['customer'] === 'cus_42');
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/invoiceitems')
&& (int) $request['amount'] === 500);
});
// ---- Der Tageslauf -------------------------------------------------------
it('moves a due case one level on', function () {
$case = dunningCaseAt(0);
app()->instance(StripeClient::class, new FakeStripeClient);
$this->artisan('clupilot:advance-dunning')->assertSuccessful();
$case->refresh();
expect($case->level)->toBe(1)
// Die nächste Frist rechnet vom BEGINN, nicht von heute: sonst
// verschöbe sich der ganze Plan mit jedem Lauf, der einen Tag zu spät
// kommt, und aus 24 Tagen bis zur Sperre würden unbemerkt dreissig.
->and($case->next_step_at->isSameDay(
$case->opened_at->copy()->addDays(DunningSchedule::dayOfLevel(2))
))->toBeTrue();
});
it('does nothing to a case that is not due yet', function () {
$case = dunningCaseAt(1, Carbon::now()->addDays(3));
app()->instance(StripeClient::class, new FakeStripeClient);
$this->artisan('clupilot:advance-dunning')->assertSuccessful();
expect($case->fresh()->level)->toBe(1);
});
it('runs twice in a day without moving anything twice', function () {
// 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.
$case = dunningCaseAt(0);
app()->instance(StripeClient::class, new FakeStripeClient);
$this->artisan('clupilot:advance-dunning');
$this->artisan('clupilot:advance-dunning');
expect($case->fresh()->level)->toBe(1);
});
it('mints a fee invoice from the configured level on', function () {
$case = dunningCaseAt(1);
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
$this->artisan('clupilot:advance-dunning')->assertSuccessful();
$case->refresh();
expect($case->level)->toBe(2)
->and($fake->feeInvoices)->toHaveCount(1)
->and($fake->feeInvoices[0]['amount'])->toBe(500)
->and($case->fee_invoice_ids)->toHaveCount(1);
});
it('charges nothing for the notice or the first reminder', function () {
$case = dunningCaseAt(0);
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
$this->artisan('clupilot:advance-dunning');
expect($case->fresh()->level)->toBe(1)
->and($fake->feeInvoices)->toBe([]);
});
it('never mints the same level\'s fee twice', function () {
// Ein zweiter Lauf am selben Tag, ein abgebrochener Lauf, ein Neustart des
// Arbeiterprozesses — die Gebühr darf je Stufe genau einmal entstehen.
$case = dunningCaseAt(1);
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
$this->artisan('clupilot:advance-dunning');
$case->update(['next_step_at' => Carbon::now()->subMinute(), 'level' => 2]);
$this->artisan('clupilot:advance-dunning');
expect(collect($fake->feeInvoices)->where('level', 2))->toHaveCount(1);
});
it('leaves a settled case alone', function () {
$case = dunningCaseAt(1);
$case->update(['settled_at' => Carbon::now()]);
app()->instance(StripeClient::class, new FakeStripeClient);
$this->artisan('clupilot:advance-dunning');
expect($case->fresh()->level)->toBe(1);
});
it('follows the configured schedule rather than a fixed one', function () {
Settings::set(DunningSchedule::DAYS, [0, 1, 2, 3, 4]);
Settings::set(DunningSchedule::FEES, [2 => 250, 3 => 750]);
$case = dunningCaseAt(1);
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
$this->artisan('clupilot:advance-dunning');
expect($fake->feeInvoices[0]['amount'])->toBe(250);
});