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
|
|
@ -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