mock(StripeInvoicePdf::class, function ($mock): void { $mock->shouldReceive('filename')->andReturn('Rechnung.pdf'); $mock->shouldReceive('contents')->andReturn('%PDF-1.4 test'); }); }); 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('stripe invoice ids are unique in the database while null remains repeatable', function () { Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique']); expect(fn () => Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique'])) ->toThrow(QueryException::class); Invoice::factory()->count(2)->create(['stripe_invoice_id' => null]); expect(Invoice::query()->whereNull('stripe_invoice_id')->count())->toBe(2); }); 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', 'payment_status' => 'paid', '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', 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], ])); expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original'); }); test('a mirrored invoice queues a payment confirmation mail to the customer', function () { Mail::fake(); $user = User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); Mail::assertQueued(BookingPaymentConfirmed::class, function (BookingPaymentConfirmed $mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasBcc(config('legal.contact_email')) && $mail->invoice->number === 'STR-00001'; }); }); test('the account confirmation carries the pdf by default and goes to the invoice email when enabled', function () { Mail::fake(); $user = User::factory()->create(['stripe_id' => 'cus_test_1', 'email' => 'max@example.com']); BillingAddress::factory()->create([ 'user_id' => $user->id, 'invoice_email' => 'buchhaltung@example.com', 'invoice_pdf_to_account' => true, 'invoice_pdf_to_invoice_email' => true, ]); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); Mail::assertQueuedCount(2); Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo('max@example.com') && $mail->attachPdf && ! $mail->forInvoiceRecipient); Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo('buchhaltung@example.com') && $mail->attachPdf && $mail->forInvoiceRecipient); }); test('a customer who switched the account pdf off gets the confirmation without attachment', function () { Mail::fake(); $user = User::factory()->create(['stripe_id' => 'cus_test_1']); BillingAddress::factory()->create(['user_id' => $user->id, 'invoice_pdf_to_account' => false]); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); Mail::assertQueuedCount(1); Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo($user->email) && ! $mail->attachPdf); }); test('a replayed invoice webhook does not queue a second confirmation mail', function () { Mail::fake(); $user = User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); Mail::assertQueuedCount(1); }); test('a skipped credit note does not queue a confirmation mail', function () { Mail::fake(); $user = User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [ 'subtotal' => -4900, 'tax' => 0, 'total' => -4900, ]))); Mail::assertNothingQueued(); }); test('an unpaid async checkout session is not fulfilled yet', function () { // PayPal/SEPA & Co.: `checkout.session.completed` kommt schon nach dem // Redirect mit payment_status=unpaid — erfüllt wird erst mit // `async_payment_succeeded`. $purchase = SinglePurchase::factory()->create(); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'checkout.session.completed', 'data' => [ 'object' => [ 'id' => 'cs_test_async', 'payment_intent' => 'pi_test_async', 'payment_status' => 'unpaid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], ])); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Pending); }); test('a succeeded async payment fulfills the purchase', function () { $purchase = SinglePurchase::factory()->create(); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'checkout.session.async_payment_succeeded', 'data' => [ 'object' => [ 'id' => 'cs_test_async', 'payment_intent' => 'pi_test_async', 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], ])); $fresh = $purchase->fresh(); expect($fresh->status)->toBe(SinglePurchaseStatus::Paid); expect($fresh->stripe_checkout_session_id)->toBe('cs_test_async'); }); test('a failed async payment leaves the purchase pending', function () { $purchase = SinglePurchase::factory()->create(); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'checkout.session.async_payment_failed', 'data' => [ 'object' => [ 'id' => 'cs_test_async', 'payment_status' => 'unpaid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], ])); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Pending); }); 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); }); 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'); }); test('a portal cancellation at period end is recorded in the activity log', function () { $user = User::factory()->create(['stripe_id' => 'cus_test_1']); $user->subscriptions()->create([ 'type' => 'default', 'stripe_id' => 'sub_cancel_1', 'stripe_status' => 'active', 'stripe_price' => 'price_x', 'quantity' => 1, ]); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'customer.subscription.updated', 'data' => [ 'object' => [ 'id' => 'sub_cancel_1', 'customer' => 'cus_test_1', 'cancel_at_period_end' => true, 'cancel_at' => now()->addMonth()->timestamp, ], 'previous_attributes' => ['cancel_at_period_end' => false], ], ])); $activityEvent = ActivityEvent::sole(); expect($activityEvent->type)->toBe(ActivityType::SubscriptionChange) ->and($activityEvent->user_id)->toBe($user->id) ->and($activityEvent->description)->toContain('Abo gekündigt') ->and($activityEvent->description)->toContain(now()->addMonth()->format('d.m.Y')); }); test('resuming a cancelled subscription is recorded in the activity log', function () { User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'customer.subscription.updated', 'data' => [ 'object' => [ 'id' => 'sub_cancel_1', 'customer' => 'cus_test_1', 'cancel_at_period_end' => false, ], 'previous_attributes' => ['cancel_at_period_end' => true], ], ])); expect(ActivityEvent::sole()->description)->toContain('Abo-Kündigung zurückgenommen'); }); test('a subscription update without cancellation transition records no activity', function () { User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'customer.subscription.updated', 'data' => [ 'object' => [ 'id' => 'sub_cancel_1', 'customer' => 'cus_test_1', 'cancel_at_period_end' => false, 'items' => ['data' => [['price' => ['id' => 'price_x']]]], ], 'previous_attributes' => ['items' => []], ], ])); expect(ActivityEvent::count())->toBe(0); }); test('a fully ended subscription is recorded in the activity log', function () { $user = User::factory()->create(['stripe_id' => 'cus_test_1']); app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ 'type' => 'customer.subscription.deleted', 'data' => ['object' => ['id' => 'sub_cancel_1', 'customer' => 'cus_test_1']], ])); $activityEvent = ActivityEvent::sole(); expect($activityEvent->description)->toContain('Abo beendet') ->and($activityEvent->user_id)->toBe($user->id); });