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