Downgrades ohne Gutschrift: Tarifwechsel nach unten erst zum Periodenende

QA-Entscheidung 07.07.: Eine Buchung ist nicht rückabwickelbar — es darf
nie eine negative Proration-Rechnung oder ein Stripe-Guthaben entstehen.

- StripeCheckoutService::isDowngrade: sofortiger Wechsel nur, wenn der
  Periodenpreis des Ziel-Tarifs den aktuellen erreicht (Wechsel-Rechnung
  damit garantiert >= 0); sonst Downgrade
- scheduleDowngrade: Stripe Subscription Schedule stellt das Abo zum Ende
  der bezahlten Periode um (proration_behavior none), hebt eine
  vorgemerkte Kündigung auf und spiegelt die Vormerkung lokal
  (neue Spalten scheduled_stripe_price/scheduled_change_at/
  stripe_schedule_id an subscriptions)
- Sofort-Upgrade löst eine bestehende Vormerkung vorher auf (release)
- Webhook customer.subscription.updated räumt die Vormerkung nach
  Vollzug ab
- Buchungs-Modal unterscheidet Upgrade (sofort, Differenz-Rechnung) und
  Downgrade (Vormerkung, keine Zahlung heute); Banner + Hinweis auf der
  Paket-Karte zeigen den vorgemerkten Wechsel mit Datum
- Kontingent-Sperre aus 4b7f6d3 entfernt — ohne Gutschrift gegenstandslos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 13:38:57 +00:00
parent 388ca0e620
commit 80e04e26e0
8 changed files with 419 additions and 89 deletions

View file

@ -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', [

View file

@ -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<string, mixed> $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<string, mixed> $stripeInvoice
*/

View file

@ -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();
}
/**

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Vorgemerkter Tarifwechsel (QA-Befund 07.07.): Downgrades werden nicht
* sofort ausgeführt (keine Proration-Gutschrift, kein negativer Betrag),
* sondern per Stripe Subscription Schedule zum Ende der laufenden
* Abrechnungsperiode vorgemerkt. Diese Spalten spiegeln die Vormerkung
* lokal für Anzeige und Aufräumen nach Vollzug (Webhook).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -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.') }}
</div>
</div>
@elseif ($checkoutResult === 'wechsel-geplant')
<div
class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">
<span
class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihr Tarifwechsel ist vorgemerkt!') }}</span>
@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
</div>
</div>
@elseif ($checkoutResult === 'abbruch')
<div
class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
@ -508,6 +536,14 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
{{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }}
</p>
@endif
@if ($scheduledChangePlan && $scheduledChangeAt)
<p class="text-[12px] text-[color:var(--color-warn,#b45309)] m-0">
{{ __('Tarifwechsel zu :plan am :date vorgemerkt — bis dahin gilt Ihr aktueller Tarif.', [
'plan' => $scheduledChangePlan->name,
'date' => $scheduledChangeAt->format('d.m.Y'),
]) }}
</p>
@endif
@elseif ($openPurchases->isNotEmpty())
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
{{ 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.') }}
</p>
@if (($booking['is_swap'] ?? false) && ! ($booking['quota_blocked'] ?? false))
@if ($booking['is_swap'] ?? false)
<p class="text-[11.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('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)
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }}</span>
@endif
@ -1042,23 +1082,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@endif
</div>
@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. --}}
<div class="px-4 py-3 rounded-[5px] border text-[13px] bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
{{ __('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'],
]) }}
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-black/10 dark:border-white/15">
<flux:modal.close>
<flux:button variant="filled">{{ __('Verstanden') }}</flux:button>
</flux:modal.close>
</div>
@elseif ($booking['needs_address'])
@if ($booking['needs_address'])
<div class="px-4 py-3 rounded-[5px] border text-[13px] bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
{{ __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung.') }}
</div>
@ -1100,12 +1124,12 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
<form method="POST" action="{{ $booking['url'] }}" class="m-0">
@csrf
<flux:button type="submit" variant="primary" icon="arrow-path">
{{ __('Kostenpflichtig wechseln') }}
{{ ($booking['is_downgrade'] ?? false) ? __('Tarifwechsel vormerken') : __('Kostenpflichtig wechseln') }}
</flux:button>
</form>
@else
<flux:button variant="primary" icon="arrow-path" disabled>
{{ __('Kostenpflichtig wechseln') }}
{{ ($booking['is_downgrade'] ?? false) ? __('Tarifwechsel vormerken') : __('Kostenpflichtig wechseln') }}
</flux:button>
@endif
@elseif ($entrepreneurConfirmed)

View file

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

View file

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

View file

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