mivita/tests/Feature/StripeInvoiceReferenceTest.php
Kevin 68d230d4e5 Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang
Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/):
Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard
weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein
Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die
Rechnung von Hand zu schreiben.

- SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und
  Rechnungsanschrift an die Session (customer statt customer_email); die ID
  liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname
  Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die
  Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter.
  DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges
  bleiben als Buchungsbeleg bestehen.
- SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die
  description (Bestellnummer, Referenz, Kundenname) - damit greift die
  Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam.
- SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per
  Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im
  Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie
  rueckwirkend ab dem Go-Live 2026-07-10 nach.
- SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten
  Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur
  paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft
  durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und
  laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu.
- S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht
  offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie
  zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe
  waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler.
- 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:04:28 +00:00

187 lines
6.2 KiB
PHP

<?php
use App\Jobs\SyncStripeInvoiceReference;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserInvoice;
use App\Models\UserShop;
use App\Services\Payment;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Mail;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
Mail::fake();
});
/**
* Phase SE3 (dev/2026-08-26-stripe/): Die Rechnungsnummer entsteht erst NACH
* der Zahlung und wird deshalb nachträglich an den PaymentIntent geschrieben.
*
* @return array{0: ShoppingOrder, 1: ShoppingPayment, 2: UserInvoice}
*/
function invoiceRefBootstrap(array $paymentOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shopOwner = User::forceCreate([
'email' => 'inv-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'Invoice Shop',
'slug' => 'inv-shop-'.uniqid(),
'domain' => 'inv-test.local',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $shopOwner->id,
'billing_firstname' => 'Rechnung',
'billing_lastname' => 'Kunde',
'billing_email' => 'inv-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $shopOwner->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'total' => 89.00,
'paid' => true,
'txaction' => 'paid',
'mode' => 'test',
]);
$payment = ShoppingPayment::create(array_merge([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'inv-ref-'.uniqid(),
'external_reference' => 'pi_invoice_'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'mode' => 'test',
], $paymentOverrides));
$invoice = UserInvoice::create([
'shopping_order_id' => $order->id,
'year' => 2026,
'month' => 8,
'date' => '26.08.2026',
'full_number' => '202657900',
'number' => 57900,
'filename' => '202657900-MIVITA-Rechnung.pdf',
'dir' => 'invoices/2026/08/',
'disk' => 'public',
]);
return [$order->fresh(), $payment, $invoice];
}
/**
* @param array<string, mixed> $existingMetadata
* @return array{0: StripeInvoiceReferenceService, 1: callable}
*/
function invoiceRefService(array $existingMetadata = []): array
{
$captured = [];
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->andReturnUsing(function ($method, $path, $params) use (&$captured, $existingMetadata) {
if ($method === 'post' && $params !== []) {
$captured = $params;
}
return PaymentIntent::constructFrom([
'id' => 'pi_invoice_x',
'object' => 'payment_intent',
'status' => 'succeeded',
'metadata' => $existingMetadata,
]);
});
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('isConfigured')->andReturn(true);
$wrapper->shouldReceive('client')->andReturn($client);
return [new StripeInvoiceReferenceService($wrapper), function () use (&$captured) {
return $captured;
}];
}
it('writes invoice number and date into the payment intent metadata', function () {
[, $payment, $invoice] = invoiceRefBootstrap();
[$service, $captured] = invoiceRefService([
'shopping_order_id' => '4711',
'reference' => 'mref123',
]);
expect($service->sync($payment, $invoice))->toBeTrue();
$params = $captured();
expect($params['metadata']['invoice_number'])->toBe('202657900')
->and($params['metadata']['invoice_date'])->toBe('2026-08-26')
->and($params['metadata']['shopping_order_id'])->toBe('4711')
->and($params['description'])->toContain('Rechnung 202657900');
});
it('does nothing when the invoice number is already set', function () {
[, $payment, $invoice] = invoiceRefBootstrap();
[$service, $captured] = invoiceRefService(['invoice_number' => '202657900']);
expect($service->sync($payment, $invoice))->toBeFalse()
->and($captured())->toBe([]);
});
it('skips payments that are not stripe or have no payment intent yet', function () {
[, $payonePayment, $invoice] = invoiceRefBootstrap(['payment_provider' => 'payone', 'external_reference' => null]);
[$service] = invoiceRefService();
expect($service->sync($payonePayment, $invoice))->toBeFalse();
[, $sessionOnly] = invoiceRefBootstrap(['external_reference' => 'cs_test_still_open']);
expect(StripeInvoiceReferenceService::isSyncable($sessionOnly))->toBeFalse();
});
it('dispatches the sync job from the paid path for stripe payments', function () {
Bus::fake();
[$order, $payment] = invoiceRefBootstrap();
Payment::paymentStatusPaidAction($order, true, $payment);
Bus::assertDispatched(SyncStripeInvoiceReference::class, function ($job) use ($payment) {
return $job->shoppingPayment->id === $payment->id
&& $job->userInvoice->full_number === '202657900';
});
});
it('does not dispatch the sync job for payone payments', function () {
Bus::fake();
[$order, $payment] = invoiceRefBootstrap([
'payment_provider' => 'payone',
'clearingtype' => 'vor',
'external_reference' => null,
]);
Payment::paymentStatusPaidAction($order, true, $payment);
Bus::assertNotDispatched(SyncStripeInvoiceReference::class);
});