presseportale/tests/Feature/Billing/StripeWebhookProcessingTest.php
Kevin Adametz 4b7f6d3f0f 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>
2026-07-07 13:22:30 +00:00

188 lines
6.5 KiB
PHP

<?php
use App\Enums\InvoiceStatus;
use App\Enums\SinglePurchaseStatus;
use App\Listeners\ProcessStripeWebhook;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;
function stripeInvoicePayload(User $user, array $overrides = []): array
{
return [
'type' => 'invoice.payment_succeeded',
'data' => [
'object' => array_replace_recursive([
'id' => 'in_test_123',
'customer' => $user->stripe_id,
'subtotal' => 4900,
'tax' => 931,
'total' => 5831,
'currency' => 'eur',
'customer_name' => 'Alpha GmbH',
'customer_address' => [
'line1' => 'Beispielweg 1',
'line2' => null,
'postal_code' => '10115',
'city' => 'Berlin',
'country' => 'DE',
],
], $overrides),
],
];
}
test('a paid stripe invoice is mirrored into the STR circle', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user)));
$invoice = Invoice::sole();
expect($invoice->number)->toBe('STR-00001');
expect($invoice->user_id)->toBe($user->id);
expect($invoice->status)->toBe(InvoiceStatus::Paid);
expect($invoice->amount_cents)->toBe(4900);
expect($invoice->tax_cents)->toBe(931);
expect($invoice->total_cents)->toBe(5831);
expect($invoice->stripe_invoice_id)->toBe('in_test_123');
expect($invoice->invoiceBillingAddress->city)->toBe('Berlin');
expect($invoice->invoiceBillingAddress->name)->toBe('Alpha GmbH');
});
test('a current-api invoice without top-level tax reads total_taxes', function () {
// Stripe-API ab 2025: kein `tax`-Feld mehr, Steuer steht in `total_taxes`
// (QA-Befund 07.07.: USt. wurde als 0 gespiegelt).
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
'tax' => null,
'subtotal' => 2900,
'total' => 3451,
'total_taxes' => [['amount' => 551, 'taxable_amount' => 2900, 'tax_behavior' => 'exclusive']],
])));
$invoice = Invoice::sole();
expect($invoice->amount_cents)->toBe(2900);
expect($invoice->tax_cents)->toBe(551);
expect($invoice->total_cents)->toBe(3451);
expect($invoice->is_netto)->toBeFalse();
});
test('duplicate webhook deliveries do not create a second invoice', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
$listener = app(ProcessStripeWebhook::class);
$listener->handle(new WebhookReceived(stripeInvoicePayload($user)));
$listener->handle(new WebhookReceived(stripeInvoicePayload($user)));
expect(Invoice::count())->toBe(1);
});
test('the snapshot falls back to the local billing address including vat id', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
BillingAddress::factory()->create([
'user_id' => $user->id,
'name' => 'Lokal GmbH',
'country_code' => 'AT',
'vat_id' => 'ATU12345678',
]);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
'customer_name' => null,
'customer_address' => null,
])));
$snapshot = Invoice::sole()->invoiceBillingAddress;
expect($snapshot->name)->toBe('Lokal GmbH');
expect($snapshot->country_code)->toBe('AT');
expect($snapshot->vat_id)->toBe('ATU12345678');
});
test('an unknown stripe customer is skipped without an invoice', function () {
User::factory()->create(['stripe_id' => 'cus_other']);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload(
new User(['name' => 'Fremd']),
['customer' => 'cus_unknown'],
)));
expect(Invoice::count())->toBe(0);
});
test('checkout completion marks the referenced single purchase as paid', function () {
$purchase = SinglePurchase::factory()->create();
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'id' => 'cs_test_1',
'payment_intent' => 'pi_test_1',
'metadata' => ['single_purchase_id' => (string) $purchase->id],
],
],
]));
$fresh = $purchase->fresh();
expect($fresh->status)->toBe(SinglePurchaseStatus::Paid);
expect($fresh->paid_at)->not->toBeNull();
expect($fresh->stripe_checkout_session_id)->toBe('cs_test_1');
expect($fresh->stripe_payment_intent_id)->toBe('pi_test_1');
});
test('an already paid purchase is not touched again', function () {
$purchase = SinglePurchase::factory()->paid()->create([
'stripe_payment_intent_id' => 'pi_original',
]);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'id' => 'cs_test_2',
'payment_intent' => 'pi_new',
'metadata' => ['single_purchase_id' => (string) $purchase->id],
],
],
]));
expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original');
});
test('the listener is wired to the cashier webhook event', function () {
Event::fake();
Event::assertListening(
WebhookReceived::class,
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);
});