diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index ed6c2b7f..14506ed8 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -94,23 +94,17 @@ class CheckoutController extends Controller return $this->backToBookings(__('Dieser Tarif ist bereits Ihr aktueller Tarif.')); } - // Downgrade-Vorkehrung (QA-Befund 07.07.): Der Kontingent-Zähler - // läuft pro Kalendermonat — wer mehr PMs genutzt hat, als der - // Ziel-Tarif monatlich enthält, bekäme beim sofortigen Wechsel die - // anteilige Gutschrift für einen bereits konsumierten Monat. Solche - // Wechsel sind erst ab dem nächsten Monatsersten möglich. - $quotaUsed = (int) $user->press_release_quota_used_this_month; - - if ($quotaUsed > (int) $plan->press_release_quota) { - return $this->backToBookings(__('Ein Wechsel zu :plan ist erst ab dem :date möglich: Sie haben in diesem Monat bereits :used Pressemitteilungen genutzt — dieser Tarif enthält :quota pro Monat.', [ - 'plan' => $plan->name, - 'date' => now()->addMonth()->startOfMonth()->format('d.m.Y'), - 'used' => $quotaUsed, - 'quota' => (int) $plan->press_release_quota, - ])); - } - try { + // Downgrade-Vorkehrung (QA-Befund 07.07.): Ein günstigerer Tarif + // wird erst zum Ende der bezahlten Periode wirksam — es entsteht + // nie eine Gutschrift oder ein negativer Rechnungsbetrag. Nur + // Upgrades werden sofort umgestellt und anteilig nachberechnet. + if ($this->checkout->isDowngrade($user, $plan, $interval)) { + $this->checkout->scheduleDowngrade($user, $plan, $interval); + + return redirect()->route('me.bookings.index', ['checkout' => 'wechsel-geplant']); + } + $this->checkout->swapSubscription($user, $plan, $interval); } catch (IncompletePayment $exception) { return redirect()->route('cashier.payment', [ diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index c2f241bf..47de4c35 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -17,6 +17,7 @@ use App\Services\PressRelease\PressReleaseService; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; +use Laravel\Cashier\Subscription; /** * Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E). @@ -42,10 +43,46 @@ class ProcessStripeWebhook match ($event->payload['type'] ?? null) { 'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []), 'checkout.session.completed' => $this->fulfillCheckout($event->payload['data']['object'] ?? []), + 'customer.subscription.updated' => $this->clearAppliedScheduledChange($event->payload['data']['object'] ?? []), default => null, }; } + /** + * Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): Sobald der + * Stripe Subscription Schedule die neue Phase aktiviert, meldet + * `customer.subscription.updated` den neuen Preis — die lokale + * Vormerkung ist damit erledigt. Cashiers eigener Webhook-Handler + * aktualisiert `stripe_price` im selben Request. + * + * @param array $stripeSubscription + */ + private function clearAppliedScheduledChange(array $stripeSubscription): void + { + $stripeId = $stripeSubscription['id'] ?? null; + $price = $stripeSubscription['items']['data'][0]['price']['id'] ?? null; + + if (! $stripeId || ! $price) { + return; + } + + $cleared = Subscription::query() + ->where('stripe_id', $stripeId) + ->where('scheduled_stripe_price', $price) + ->update([ + 'scheduled_stripe_price' => null, + 'scheduled_change_at' => null, + 'stripe_schedule_id' => null, + ]); + + if ($cleared > 0) { + Log::info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [ + 'stripe_subscription_id' => $stripeId, + 'stripe_price' => $price, + ]); + } + } + /** * @param array $stripeInvoice */ diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 13d6f57b..45c5e522 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -6,9 +6,14 @@ use App\Models\CreditTopup; use App\Models\Plan; use App\Models\SinglePurchase; use App\Models\User; +use Carbon\CarbonInterface; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; +use Laravel\Cashier\Cashier; use Laravel\Cashier\Checkout; use Laravel\Cashier\Exceptions\IncompletePayment; +use Laravel\Cashier\Subscription; +use Stripe\Exception\InvalidRequestException; /** * Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E). @@ -40,10 +45,49 @@ class StripeCheckoutService } /** - * Tarifwechsel eines bestehenden Abos (QA-Befund 07.07.) — ohne neuen + * Downgrade-Erkennung (QA-Befund 07.07.): Es darf nie eine negative + * Proration-Rechnung entstehen — eine Buchung ist nicht rückabwickelbar. + * Sofort gewechselt wird nur, wenn der Periodenpreis des Ziel-Tarifs + * mindestens den des aktuellen erreicht: Die Gutschrift für die + * Restlaufzeit beträgt höchstens den vollen aktuellen Periodenpreis, + * die Wechsel-Rechnung ist damit garantiert >= 0. Alles andere + * (günstigerer Tarif, Jahres- auf Monatsabo, unbekannter Alt-Preis) + * ist ein Downgrade und wird zum Periodenende vorgemerkt. + */ + public function isDowngrade(User $user, Plan $plan, string $interval): bool + { + $currentPriceId = $user->subscription()?->stripe_price; + + if (! $currentPriceId) { + return true; + } + + $currentPlan = Plan::query() + ->where('stripe_price_id_monthly', $currentPriceId) + ->orWhere('stripe_price_id_yearly', $currentPriceId) + ->first(); + + if (! $currentPlan) { + return true; + } + + $currentCents = $currentPlan->stripe_price_id_monthly === $currentPriceId + ? (int) $currentPlan->monthly_price_cents + : (int) $currentPlan->yearly_price_cents; + + $targetCents = $interval === 'yearly' + ? (int) $plan->yearly_price_cents + : (int) $plan->monthly_price_cents; + + return $targetCents < $currentCents; + } + + /** + * Sofortiger Tarifwechsel nach oben (QA-Befund 07.07.) — ohne neuen * Checkout: Stripe stellt das Abo sofort um und stellt die anteilige * Differenz umgehend in Rechnung (swapAndInvoice). Cashier hebt dabei - * eine vorgemerkte Kündigung auf (cancel_at_period_end = false). + * eine vorgemerkte Kündigung auf (cancel_at_period_end = false); eine + * bestehende Downgrade-Vormerkung wird vorher aufgelöst. * * @throws IncompletePayment wenn die Zahlung * eine Bestätigung braucht (SCA) @@ -54,9 +98,100 @@ class StripeCheckoutService ? $plan->stripe_price_id_yearly : $plan->stripe_price_id_monthly; + $subscription = $user->subscription('default'); + + $this->releaseScheduledChange($subscription); $this->syncCustomerFromBillingAddress($user); - $user->subscription('default')->swapAndInvoice($priceId); + $subscription->swapAndInvoice($priceId); + } + + /** + * Tarifwechsel nach unten: keine sofortige Umstellung, keine Gutschrift. + * Ein Stripe Subscription Schedule stellt das Abo zum Ende der bereits + * bezahlten Periode auf den neuen Preis um (proration_behavior none) — + * es entsteht nie ein negativer Rechnungsbetrag. Die Vormerkung wird + * lokal gespiegelt; der `customer.subscription.updated`-Webhook räumt + * sie nach Vollzug wieder ab (ProcessStripeWebhook). + */ + public function scheduleDowngrade(User $user, Plan $plan, string $interval): CarbonInterface + { + $priceId = $interval === 'yearly' + ? $plan->stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + $subscription = $user->subscription('default'); + + // Wer wechselt, bleibt Kunde: eine vorgemerkte Kündigung wird + // aufgehoben (das Bestätigungs-Modal kündigt das an). + if ($subscription->onGracePeriod()) { + $subscription->resume(); + } + + $stripe = Cashier::stripe(); + + $schedule = $subscription->stripe_schedule_id + ? $stripe->subscriptionSchedules->retrieve($subscription->stripe_schedule_id) + : $stripe->subscriptionSchedules->create(['from_subscription' => $subscription->stripe_id]); + + // Phase 1 = laufende Periode unverändert (Stripe verlangt sie beim + // Update identisch), Phase 2 = neuer Preis ab Periodenende. + $currentPhaseStart = (int) $schedule->phases[0]->start_date; + $currentPhaseEnd = (int) ($schedule->phases[0]->end_date + ?? $subscription->currentPeriodEnd()?->getTimestamp()); + + $stripe->subscriptionSchedules->update($schedule->id, [ + 'end_behavior' => 'release', + 'phases' => [ + [ + 'items' => [['price' => $subscription->stripe_price, 'quantity' => $subscription->quantity ?? 1]], + 'start_date' => $currentPhaseStart, + 'end_date' => $currentPhaseEnd, + ], + [ + 'items' => [['price' => $priceId, 'quantity' => $subscription->quantity ?? 1]], + 'proration_behavior' => 'none', + ], + ], + ]); + + $effectiveAt = Carbon::createFromTimestamp($currentPhaseEnd)->timezone(config('app.timezone')); + + $subscription->forceFill([ + 'scheduled_stripe_price' => $priceId, + 'scheduled_change_at' => $effectiveAt, + 'stripe_schedule_id' => $schedule->id, + ])->save(); + + return $effectiveAt; + } + + /** + * Bestehende Downgrade-Vormerkung auflösen (release lässt das Abo + * unverändert weiterlaufen) — nötig, bevor Cashier das Abo direkt + * umstellen darf. Ein bereits aufgelöster Schedule ist kein Fehler. + */ + private function releaseScheduledChange(Subscription $subscription): void + { + if (! $subscription->stripe_schedule_id) { + return; + } + + try { + Cashier::stripe()->subscriptionSchedules->release($subscription->stripe_schedule_id); + } catch (InvalidRequestException $exception) { + Log::warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [ + 'subscription_id' => $subscription->id, + 'stripe_schedule_id' => $subscription->stripe_schedule_id, + 'error' => $exception->getMessage(), + ]); + } + + $subscription->forceFill([ + 'scheduled_stripe_price' => null, + 'scheduled_change_at' => null, + 'stripe_schedule_id' => null, + ])->save(); } /** diff --git a/database/migrations/2026_07_07_153200_add_scheduled_change_to_subscriptions_table.php b/database/migrations/2026_07_07_153200_add_scheduled_change_to_subscriptions_table.php new file mode 100644 index 00000000..24cd77dd --- /dev/null +++ b/database/migrations/2026_07_07_153200_add_scheduled_change_to_subscriptions_table.php @@ -0,0 +1,31 @@ +string('scheduled_stripe_price')->nullable()->after('stripe_price'); + $table->timestamp('scheduled_change_at')->nullable()->after('scheduled_stripe_price'); + $table->string('stripe_schedule_id')->nullable()->after('scheduled_change_at'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn(['scheduled_stripe_price', 'scheduled_change_at', 'stripe_schedule_id']); + }); + } +}; diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 8ae0bdc1..4fe0153b 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -13,6 +13,7 @@ use App\Models\UserPaymentOption; use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditTopupService; use App\Services\Billing\ExtraPmPurchaseService; +use App\Services\Billing\StripeCheckoutService; use App\Services\Billing\VatResolver; use Flux\Flux; use Livewire\Attributes\Layout; @@ -107,22 +108,20 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $subscription = $user->subscription(); $isSwap = $type === 'subscription' && (bool) $subscription?->valid(); - // Downgrade-Vorkehrung: Übersteigt der Monatsverbrauch das Kontingent - // des Ziel-Tarifs, ist der Wechsel bis zum Monatsersten gesperrt — - // das Modal erklärt das, der Checkout-Controller erzwingt es. - $targetQuota = $isSwap - ? Plan::query()->active()->where('slug', $ref)->value('press_release_quota') - : null; - $quotaBlocked = $targetQuota !== null - && (int) $user->press_release_quota_used_this_month > (int) $targetQuota; + // Downgrade-Erkennung: Günstigere Tarife werden erst zum Ende der + // bezahlten Periode wirksam (keine Gutschrift, kein negativer + // Betrag) — das Modal erklärt das, der Controller entscheidet + // verbindlich über dieselbe Service-Logik. + $isDowngrade = false; + if ($isSwap && ($targetPlan = Plan::query()->active()->where('slug', $ref)->first())) { + $isDowngrade = app(StripeCheckoutService::class) + ->isDowngrade($user, $targetPlan, $interval === 'yearly' ? 'yearly' : 'monthly'); + } $this->booking = [ 'is_swap' => $isSwap, + 'is_downgrade' => $isDowngrade, 'resumes_cancellation' => $isSwap && $subscription->onGracePeriod(), - 'quota_blocked' => $quotaBlocked, - 'quota_used' => (int) $user->press_release_quota_used_this_month, - 'quota_target' => (int) $targetQuota, - 'quota_available_from' => now()->addMonth()->startOfMonth()->format('d.m.Y'), 'type' => $type, 'ref' => $ref, 'title' => $title, @@ -258,6 +257,16 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly ? 'yearly' : 'monthly'; } + // Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): lokale + // Spiegelung der Stripe-Schedule-Vormerkung für die Anzeige. + $scheduledChangePlan = null; + if ($subscription?->scheduled_stripe_price) { + $scheduledChangePlan = Plan::query() + ->where('stripe_price_id_monthly', $subscription->scheduled_stripe_price) + ->orWhere('stripe_price_id_yearly', $subscription->scheduled_stripe_price) + ->first(); + } + return [ // Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch) // bzw. Hinweis aus den Checkout-Guards (Session-Flash). @@ -268,6 +277,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte 'currentPlan' => $currentPlan, 'currentInterval' => $currentInterval, 'subscription' => $subscription, + 'scheduledChangePlan' => $scheduledChangePlan, + 'scheduledChangeAt' => $subscription?->scheduled_change_at + ? \Illuminate\Support\Carbon::parse($subscription->scheduled_change_at) + : null, // Bestandstarife: laufende Legacy-Vereinbarungen (MAN-Kreis, // unbegrenzte PMs — Entscheidung 12.06.2026). @@ -331,6 +344,21 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Der neue Tarif gilt ab sofort. Bereits gezahlte Beträge wurden anteilig verrechnet — die Rechnung finden Sie unter Rechnungen.') }} + @elseif ($checkoutResult === 'wechsel-geplant') +
+ +
+ {{ __('Ihr Tarifwechsel ist vorgemerkt!') }} + @if ($scheduledChangeAt) + {{ __('Er wird am :date wirksam — bis dahin gilt Ihr aktueller Tarif unverändert, es entsteht keine weitere Zahlung.', ['date' => $scheduledChangeAt->format('d.m.Y')]) }} + @else + {{ __('Er wird zum Ende der laufenden Abrechnungsperiode wirksam — bis dahin gilt Ihr aktueller Tarif unverändert.') }} + @endif +
+
@elseif ($checkoutResult === 'abbruch')
+ {{ __('Tarifwechsel zu :plan am :date vorgemerkt — bis dahin gilt Ihr aktueller Tarif.', [ + 'plan' => $scheduledChangePlan->name, + 'date' => $scheduledChangeAt->format('d.m.Y'), + ]) }} +

+ @endif @elseif ($openPurchases->isNotEmpty())
{{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }} @@ -1032,9 +1068,13 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Die endgültige Steuer berechnet Stripe anhand Ihrer Rechnungsadresse. Die Abrechnung erfolgt sicher über Stripe.') }}

- @if (($booking['is_swap'] ?? false) && ! ($booking['quota_blocked'] ?? false)) + @if ($booking['is_swap'] ?? false)

- {{ __('Der Wechsel gilt sofort. Bereits gezahlte Beträge werden anteilig verrechnet: Ein Mehrbetrag wird umgehend in Rechnung gestellt, ein Guthaben verbleibt bei Stripe und mindert Ihre nächsten Rechnungen.') }} + @if ($booking['is_downgrade'] ?? false) + {{ __('Der Wechsel wird zum Ende der bereits bezahlten Abrechnungsperiode vorgemerkt. Bis dahin behalten Sie Ihren aktuellen Tarif — heute entsteht keine Zahlung, der neue Preis gilt ab der nächsten Abrechnung.') }} + @else + {{ __('Der Wechsel gilt sofort: Bereits gezahlte Beträge werden anteilig angerechnet, nur die Differenz wird umgehend in Rechnung gestellt.') }} + @endif @if ($booking['resumes_cancellation'] ?? false) {{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }} @endif @@ -1042,23 +1082,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endif

- @if ($booking['quota_blocked'] ?? false) - {{-- Downgrade gesperrt: Monatsverbrauch übersteigt das - Ziel-Kontingent — sonst gäbe es die anteilige - Gutschrift für einen bereits konsumierten Monat. --}} -
- {{ __('Ein Wechsel in diesen Tarif ist erst ab dem :date möglich: Sie haben in diesem Monat bereits :used Pressemitteilungen genutzt — dieser Tarif enthält :quota pro Monat.', [ - 'date' => $booking['quota_available_from'], - 'used' => $booking['quota_used'], - 'quota' => $booking['quota_target'], - ]) }} -
-
- - {{ __('Verstanden') }} - -
- @elseif ($booking['needs_address']) + @if ($booking['needs_address'])
{{ __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung.') }}
@@ -1100,12 +1124,12 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@csrf - {{ __('Kostenpflichtig wechseln') }} + {{ ($booking['is_downgrade'] ?? false) ? __('Tarifwechsel vormerken') : __('Kostenpflichtig wechseln') }}
@else - {{ __('Kostenpflichtig wechseln') }} + {{ ($booking['is_downgrade'] ?? false) ? __('Tarifwechsel vormerken') : __('Kostenpflichtig wechseln') }} @endif @elseif ($entrepreneurConfirmed) diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index d177990d..6be63e97 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -340,14 +340,18 @@ test('a subscriber sees swap buttons on other plans and the swap confirmation mo $user = bookingsTestCustomer(); BillingAddress::factory()->create(['user_id' => $user->id]); + // Preise gepinnt: Das Ziel ist teurer (Upgrade), damit das Modal + // deterministisch den Sofort-Wechsel zeigt. $currentPlan = Plan::factory()->create([ 'name' => 'Starter', 'slug' => 'starter', + 'monthly_price_cents' => 1900, 'stripe_price_id_monthly' => 'price_test_m_starter', ]); $targetPlan = Plan::factory()->create([ 'name' => 'Business', 'slug' => 'business', + 'monthly_price_cents' => 3900, 'stripe_price_id_monthly' => 'price_test_m_business', ]); subscribeUserToPlan($user, $currentPlan); @@ -382,19 +386,18 @@ test('a swap booking for a subscriber on grace period announces the revoked canc ->assertSee('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.'); }); -test('a swap to a plan below the current monthly usage is blocked in the modal', function () { +test('a swap to a cheaper plan is announced as a scheduled change at period end', function () { /** @var TestCase $this */ $user = bookingsTestCustomer(); BillingAddress::factory()->create(['user_id' => $user->id]); - $user->forceFill(['press_release_quota_used_this_month' => 12])->save(); $currentPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_quota1', - 'press_release_quota' => 20, + 'monthly_price_cents' => 5900, + 'stripe_price_id_monthly' => 'price_test_m_down1', ]); $targetPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_quota2', - 'press_release_quota' => 5, + 'monthly_price_cents' => 1900, + 'stripe_price_id_monthly' => 'price_test_m_down2', ]); subscribeUserToPlan($user, $currentPlan); @@ -402,7 +405,32 @@ test('a swap to a plan below the current monthly usage is blocked in the modal', LivewireVolt::test('customer.bookings') ->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly') - ->assertSet('booking.quota_blocked', true) - ->assertSee('Ein Wechsel in diesen Tarif ist erst ab dem') + ->assertSet('booking.is_downgrade', true) + ->assertSee('zum Ende der bereits bezahlten Abrechnungsperiode') + ->assertSee('Tarifwechsel vormerken') ->assertDontSee('Kostenpflichtig wechseln'); }); + +test('a scheduled plan change is shown on the current plan card', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_sched1']); + $targetPlan = Plan::factory()->create([ + 'name' => 'Kleiner Tarif', + 'stripe_price_id_monthly' => 'price_test_m_sched2', + ]); + subscribeUserToPlan($user, $currentPlan); + $user->subscriptions()->first()->forceFill([ + 'scheduled_stripe_price' => 'price_test_m_sched2', + 'scheduled_change_at' => now()->addDays(9), + 'stripe_schedule_id' => 'sub_sched_test', + ])->save(); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Tarifwechsel zu Kleiner Tarif am') + ->assertSee(now()->addDays(9)->format('d.m.Y')); +}); diff --git a/tests/Feature/Billing/CheckoutFlowTest.php b/tests/Feature/Billing/CheckoutFlowTest.php index 479c5410..32f1889a 100644 --- a/tests/Feature/Billing/CheckoutFlowTest.php +++ b/tests/Feature/Billing/CheckoutFlowTest.php @@ -249,6 +249,7 @@ test('a plan swap hands plan and interval to stripe and reports success', functi $targetPlan = Plan::factory()->create(['stripe_price_id_yearly' => 'price_test_y_swap4b']); $this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) { + $mock->shouldReceive('isDowngrade')->once()->andReturnFalse(); $mock->shouldReceive('swapSubscription') ->once() ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'yearly'); @@ -267,6 +268,7 @@ test('a failing plan swap reports a friendly notice instead of an error page', f $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap5b']); $this->mock(StripeCheckoutService::class, function ($mock) { + $mock->shouldReceive('isDowngrade')->once()->andReturnFalse(); $mock->shouldReceive('swapSubscription') ->once() ->andThrow(new RuntimeException('Stripe nicht erreichbar')); @@ -278,53 +280,77 @@ test('a failing plan swap reports a friendly notice instead of an error page', f ->assertSessionHas('checkout-notice'); }); -test('a plan swap is blocked when the monthly usage exceeds the target quota', function () { +test('a downgrade swap is scheduled for the end of the paid period instead of running immediately', function () { /** @var TestCase $this */ $user = checkoutTestCustomer(); - $user->forceFill(['press_release_quota_used_this_month' => 12])->save(); - - $currentPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_swap6a', - 'press_release_quota' => 20, - ]); + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap6a']); subscribeUserToPlan($user, $currentPlan); - $targetPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_swap6b', - 'press_release_quota' => 5, - ]); + $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap6b']); - $this->mock(StripeCheckoutService::class, function ($mock) { + $this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) { + $mock->shouldReceive('isDowngrade')->once()->andReturnTrue(); + $mock->shouldReceive('scheduleDowngrade') + ->once() + ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'monthly') + ->andReturn(now()->addDays(12)); $mock->shouldNotReceive('swapSubscription'); }); $this->actingAs($user) ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly'])) - ->assertRedirect(route('me.bookings.index')) - ->assertSessionHas('checkout-notice'); + ->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel-geplant'])); }); -test('a plan swap is allowed when the monthly usage still fits the target quota', function () { +test('the downgrade classification compares the period price of the booked interval', function ( + string $currentInterval, + int $currentMonthly, + int $currentYearly, + string $targetInterval, + int $targetMonthly, + int $targetYearly, + bool $expected, +) { /** @var TestCase $this */ $user = checkoutTestCustomer(); - $user->forceFill(['press_release_quota_used_this_month' => 5])->save(); $currentPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_swap7a', - 'press_release_quota' => 20, + 'monthly_price_cents' => $currentMonthly, + 'yearly_price_cents' => $currentYearly, + 'stripe_price_id_monthly' => 'price_test_m_cls_cur', + 'stripe_price_id_yearly' => 'price_test_y_cls_cur', ]); - subscribeUserToPlan($user, $currentPlan); + subscribeUserToPlan($user, $currentPlan, $currentInterval); + $targetPlan = Plan::factory()->create([ - 'stripe_price_id_monthly' => 'price_test_m_swap7b', - 'press_release_quota' => 5, + 'monthly_price_cents' => $targetMonthly, + 'yearly_price_cents' => $targetYearly, + 'stripe_price_id_monthly' => 'price_test_m_cls_tgt', + 'stripe_price_id_yearly' => 'price_test_y_cls_tgt', ]); - $this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) { - $mock->shouldReceive('swapSubscription') - ->once() - ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'monthly'); - }); + expect(app(StripeCheckoutService::class)->isDowngrade($user, $targetPlan, $targetInterval)) + ->toBe($expected); +})->with([ + 'günstigerer Monatstarif' => ['monthly', 5900, 59000, 'monthly', 1900, 19000, true], + 'teurerer Monatstarif' => ['monthly', 1900, 19000, 'monthly', 5900, 59000, false], + 'Monats- auf Jahresabo (Jahrespreis über Monatspreis)' => ['monthly', 5900, 59000, 'yearly', 1900, 19000, false], + 'Jahres- auf Monatsabo' => ['yearly', 1900, 19000, 'monthly', 5900, 59000, true], +]); - $this->actingAs($user) - ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly'])) - ->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel'])); +test('an unknown current price is treated as a downgrade and therefore never invoiced immediately', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + + // Abo mit Alt-Preis, der in keinem aktiven Tarif hinterlegt ist. + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_test_legacy_price', + 'stripe_status' => 'active', + 'stripe_price' => 'price_legacy_unknown', + 'quantity' => 1, + ]); + + $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_cls_unknown']); + + expect(app(StripeCheckoutService::class)->isDowngrade($user, $targetPlan, 'monthly'))->toBeTrue(); }); diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php index efb21558..cdfc0e49 100644 --- a/tests/Feature/Billing/StripeWebhookProcessingTest.php +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -186,3 +186,58 @@ test('a zero-total stripe invoice is skipped without an invoice', function () { expect(Invoice::count())->toBe(0); }); + +test('an applied scheduled downgrade is cleared from the local subscription', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + $subscription = $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_sched_1', + 'stripe_status' => 'active', + 'stripe_price' => 'price_old', + 'quantity' => 1, + ]); + $subscription->forceFill([ + 'scheduled_stripe_price' => 'price_new', + 'scheduled_change_at' => now()->addDays(3), + 'stripe_schedule_id' => 'sub_sched_x1', + ])->save(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'id' => 'sub_sched_1', + 'items' => ['data' => [['price' => ['id' => 'price_new']]]], + ]], + ])); + + $fresh = $subscription->fresh(); + expect($fresh->scheduled_stripe_price)->toBeNull(); + expect($fresh->scheduled_change_at)->toBeNull(); + expect($fresh->stripe_schedule_id)->toBeNull(); +}); + +test('a subscription update to a different price keeps the scheduled downgrade', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + $subscription = $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_sched_2', + 'stripe_status' => 'active', + 'stripe_price' => 'price_old', + 'quantity' => 1, + ]); + $subscription->forceFill([ + 'scheduled_stripe_price' => 'price_new', + 'scheduled_change_at' => now()->addDays(3), + 'stripe_schedule_id' => 'sub_sched_x2', + ])->save(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'id' => 'sub_sched_2', + 'items' => ['data' => [['price' => ['id' => 'price_old']]]], + ]], + ])); + + expect($subscription->fresh()->scheduled_stripe_price)->toBe('price_new'); +});