172 lines
6.1 KiB
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);
|
|
});
|