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:
parent
388ca0e620
commit
80e04e26e0
8 changed files with 419 additions and 89 deletions
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue