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>
187 lines
6.2 KiB
PHP
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);
|
|
});
|