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
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue