Downgrade-Vorkehrungen: Kontingent-Sperre beim Tarifwechsel, Gutschriften nicht spiegeln
QA-Befund 07.07.: Der Wechsel zurück in einen kleineren Tarif erzeugte eine Proration-Gutschrift (negative Stripe-Rechnung) — die STR-Spiegelung crashte an den unsigned-Spalten (Webhook-500), und der Wechsel war trotz verbrauchtem Monatskontingent möglich. - ProcessStripeWebhook: Belege ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung) werden geloggt übersprungen statt gespiegelt - CheckoutController::swap: Wechsel gesperrt, wenn der Monatsverbrauch das Kontingent des Ziel-Tarifs übersteigt (möglich ab Monatserstem) - Buchungs-Modal: erklärt die Sperre inkl. Datum statt Bestätigungs- Button; Proration-Hinweis deckt jetzt auch die Guthaben-Richtung ab Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
090a893cc6
commit
4b7f6d3f0f
6 changed files with 165 additions and 3 deletions
|
|
@ -94,6 +94,22 @@ 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 {
|
||||
$this->checkout->swapSubscription($user, $plan, $interval);
|
||||
} catch (IncompletePayment $exception) {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,21 @@ class ProcessStripeWebhook
|
|||
?? collect($stripeInvoice['total_taxes'] ?? $stripeInvoice['total_tax_amounts'] ?? [])->sum('amount'));
|
||||
$total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax);
|
||||
|
||||
// Ein Tarifwechsel nach unten erzeugt eine Proration-Gutschrift:
|
||||
// Stripe meldet sie als "bezahlte" Rechnung mit negativem Betrag,
|
||||
// das Guthaben verbleibt als Kundenkonto-Saldo und mindert künftige
|
||||
// Rechnungen. In den STR-Rechnungsausgang gehört so ein Beleg nicht
|
||||
// (QA-Befund 07.07.: unsigned-Spalten → Webhook-500) — der Beleg
|
||||
// selbst bleibt bei Stripe einsehbar.
|
||||
if ($total <= 0) {
|
||||
Log::info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
'total_cents' => $total,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||
|
|
|
|||
|
|
@ -107,9 +107,22 @@ 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;
|
||||
|
||||
$this->booking = [
|
||||
'is_swap' => $isSwap,
|
||||
'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,
|
||||
|
|
@ -1019,9 +1032,9 @@ 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)
|
||||
@if (($booking['is_swap'] ?? false) && ! ($booking['quota_blocked'] ?? false))
|
||||
<p class="text-[11.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Der Wechsel gilt sofort: Bereits gezahlte Beträge werden anteilig gutgeschrieben, die Differenz wird umgehend in Rechnung gestellt.') }}
|
||||
{{ __('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['resumes_cancellation'] ?? false)
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }}</span>
|
||||
@endif
|
||||
|
|
@ -1029,7 +1042,23 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
@endif
|
||||
</div>
|
||||
|
||||
@if ($booking['needs_address'])
|
||||
@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'])
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -381,3 +381,28 @@ test('a swap booking for a subscriber on grace period announces the revoked canc
|
|||
->assertSet('booking.resumes_cancellation', true)
|
||||
->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 () {
|
||||
/** @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,
|
||||
]);
|
||||
$targetPlan = Plan::factory()->create([
|
||||
'stripe_price_id_monthly' => 'price_test_m_quota2',
|
||||
'press_release_quota' => 5,
|
||||
]);
|
||||
subscribeUserToPlan($user, $currentPlan);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
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')
|
||||
->assertDontSee('Kostenpflichtig wechseln');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -277,3 +277,54 @@ test('a failing plan swap reports a friendly notice instead of an error page', f
|
|||
->assertRedirect(route('me.bookings.index'))
|
||||
->assertSessionHas('checkout-notice');
|
||||
});
|
||||
|
||||
test('a plan swap is blocked when the monthly usage exceeds the target quota', 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,
|
||||
]);
|
||||
subscribeUserToPlan($user, $currentPlan);
|
||||
$targetPlan = Plan::factory()->create([
|
||||
'stripe_price_id_monthly' => 'price_test_m_swap6b',
|
||||
'press_release_quota' => 5,
|
||||
]);
|
||||
|
||||
$this->mock(StripeCheckoutService::class, function ($mock) {
|
||||
$mock->shouldNotReceive('swapSubscription');
|
||||
});
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly']))
|
||||
->assertRedirect(route('me.bookings.index'))
|
||||
->assertSessionHas('checkout-notice');
|
||||
});
|
||||
|
||||
test('a plan swap is allowed when the monthly usage still fits the target quota', function () {
|
||||
/** @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,
|
||||
]);
|
||||
subscribeUserToPlan($user, $currentPlan);
|
||||
$targetPlan = Plan::factory()->create([
|
||||
'stripe_price_id_monthly' => 'price_test_m_swap7b',
|
||||
'press_release_quota' => 5,
|
||||
]);
|
||||
|
||||
$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');
|
||||
});
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly']))
|
||||
->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel']));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -160,3 +160,29 @@ test('the listener is wired to the cashier webhook event', function () {
|
|||
ProcessStripeWebhook::class,
|
||||
);
|
||||
});
|
||||
|
||||
test('a proration credit with a negative total is skipped without an invoice', function () {
|
||||
// Downgrade-Gutschrift (QA-Befund 07.07.): negative Beträge sprengten
|
||||
// die unsigned-Spalten und beantworteten den Webhook mit 500.
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
|
||||
'subtotal' => -6992,
|
||||
'tax' => -1329,
|
||||
'total' => -8321,
|
||||
])));
|
||||
|
||||
expect(Invoice::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a zero-total stripe invoice is skipped without an invoice', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
|
||||
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
|
||||
'subtotal' => 0,
|
||||
'tax' => 0,
|
||||
'total' => 0,
|
||||
])));
|
||||
|
||||
expect(Invoice::count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue