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