Kundenwunsch 06.10.2026: Ein Kunde kam über die Stripe-Rechnungsseite nicht an seine Rechnung und fragte nach Belegen und einer Rechnungs-E-Mail. - Profil ▸ Rechnungsadresse ▸ Rechnungsversand: Schalter „Rechnungs-PDF an die Konto-E-Mail" (Standard an), optionale „E-Mail für Rechnungen" mit Schalter „Rechnungen als PDF an diese Adresse senden" (Standard an). Neue Spalten an billing_addresses (klein, ohne FULLTEXT). - InvoiceDelivery entscheidet Empfänger und Anhang; gleiche Adresse wie das Konto ergibt eine Mail mit PDF. - Zahlungsbestätigung (STR) und Bestandskunden-Rechnung/-Erinnerung (MAN) folgen der Einstellung; die Fassung an die Rechnungs-E-Mail nennt das Konto, ohne Blindkopie und ohne Login-Button. - StripeInvoicePdf holt das Stripe-PDF einmal und legt es lokal ab (storage/app/private/invoices/stripe); Mailversand wiederholt bei Stripe-Ausfall (5 Versuche). - Rechnungsseite: „PDF öffnen" aus pressekonto, „Beleg bei Stripe" für den Zahlungsbeleg auf der Stripe-Seite. Suite 2.340 grün, einer übersprungen; Larastan und Pint ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
482 lines
18 KiB
PHP
482 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\InvoiceStatus;
|
|
use App\Enums\SinglePurchaseStatus;
|
|
use App\Listeners\ProcessStripeWebhook;
|
|
use App\Mail\BookingPaymentConfirmed;
|
|
use App\Models\ActivityEvent;
|
|
use App\Models\BillingAddress;
|
|
use App\Models\Invoice;
|
|
use App\Models\SinglePurchase;
|
|
use App\Models\User;
|
|
use App\Services\Billing\StripeInvoicePdf;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Laravel\Cashier\Events\WebhookReceived;
|
|
|
|
// Mail-Assertions bereiten die Mail samt Anhängen vor — das Stripe-PDF
|
|
// käme sonst live von Stripe.
|
|
beforeEach(function (): void {
|
|
$this->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);
|
|
});
|