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>
This commit is contained in:
Kevin 2026-08-28 09:04:28 +00:00
parent 30fb031d12
commit 68d230d4e5
31 changed files with 3612 additions and 65 deletions

View file

@ -0,0 +1,307 @@
<?php
use App\Exceptions\ManualPaymentException;
use App\Http\Requests\Payment\RecordManualPaymentRequest;
use App\Mail\MailCheckout;
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserShop;
use App\Services\ManualPaymentService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
Mail::fake();
});
/**
* Phase SE4 (dev/2026-08-26-stripe/): Zahlungseingang manuell erfassen.
* Der reale Fall ist eine Vorkasse-Überweisung über PAYONE mit falschem
* Verwendungszweck, die PAYONE nicht zuordnen kann.
*
* @return array{0: ShoppingOrder, 1: ShoppingPayment, 2: User}
*/
function manualPaymentBootstrap(array $paymentOverrides = [], array $orderOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shopOwner = User::forceCreate([
'email' => 'mp-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$admin = User::forceCreate([
'email' => 'mp-admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 3,
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'Manual Shop',
'slug' => 'mp-shop-'.uniqid(),
'domain' => 'mp-test.local',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $shopOwner->id,
'billing_firstname' => 'Vorkasse',
'billing_lastname' => 'Kunde',
'billing_email' => 'mp-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$order = ShoppingOrder::create(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $shopOwner->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'total' => 288.50,
'paid' => false,
'txaction' => 'appointed',
'mode' => 'test',
], $orderOverrides));
$payment = ShoppingPayment::create(array_merge([
'shopping_order_id' => $order->id,
'payment_provider' => 'payone',
'clearingtype' => 'vor',
'reference' => 'mp-ref-'.uniqid(),
'amount' => 28850,
'currency' => 'EUR',
'mode' => 'test',
], $paymentOverrides));
return [$order->fresh(), $payment, $admin];
}
/**
* @return array{amount_cents: int, paid_at: string, method: string, bank_reference: string, note: string|null, send_mail: bool}
*/
function manualPaymentData(array $overrides = []): array
{
return array_merge([
'amount_cents' => 28850,
'paid_at' => '2026-06-13',
'method' => ManualPaymentService::METHOD_BANK_TRANSFER,
'bank_reference' => 'Überweisung Mathis, Verwendungszweck "Bestellung"',
'note' => null,
'send_mail' => true,
], $overrides);
}
it('marks the order paid and runs the full paid path', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
$transaction = app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(), $admin);
expect((bool) $order->fresh()->paid)->toBeTrue()
->and($order->fresh()->txaction)->toBe('paid')
->and($payment->fresh()->txaction)->toBe('paid')
->and($transaction->request)->toBe('manual_payment')
->and($transaction->provider)->toBe('manual')
->and($transaction->txaction)->toBe('paid');
});
it('stores a complete audit trail on the transaction', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
$transaction = app(ManualPaymentService::class)->recordIncomingPayment(
$order,
$payment,
manualPaymentData(['note' => 'Kunde hat telefonisch bestätigt']),
$admin,
);
$audit = $transaction->transmitted_data;
expect($audit['recorded_by_user_id'])->toBe($admin->id)
->and($audit['recorded_by_email'])->toBe($admin->email)
->and($audit['amount_cents'])->toBe(28850)
->and($audit['expected_cents'])->toBe(28850)
->and($audit['amount_matches'])->toBeTrue()
->and($audit['paid_at'])->toBe('2026-06-13')
->and($audit['bank_reference'])->toContain('Mathis')
->and($audit['note'])->toBe('Kunde hat telefonisch bestätigt');
});
it('sends the confirmation mail only when requested', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(['send_mail' => false]), $admin);
// MailSyS (Admin-Protokoll) darf laufen — die Kundenmail nicht.
Mail::assertNotSent(MailCheckout::class);
});
it('sends the confirmation mail to the customer when requested', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(['send_mail' => true]), $admin);
Mail::assertSent(MailCheckout::class);
});
it('records a deviating amount and flags it in the audit trail', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
$transaction = app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(['amount_cents' => 28000]), $admin);
expect($transaction->transmitted_data['amount_matches'])->toBeFalse()
->and($transaction->transmitted_data['amount_cents'])->toBe(28000)
->and($transaction->transmitted_data['expected_cents'])->toBe(28850);
});
it('refuses an order that is already paid', function () {
[$order, $payment, $admin] = manualPaymentBootstrap([], ['paid' => true, 'txaction' => 'paid']);
expect(fn () => app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(), $admin)
)->toThrow(ManualPaymentException::class);
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->count())->toBe(0);
});
it('refuses a cancelled order', function () {
[$order, $payment, $admin] = manualPaymentBootstrap([], ['txaction' => 'cancelled']);
expect(fn () => app(ManualPaymentService::class)
->recordIncomingPayment($order, $payment, manualPaymentData(), $admin)
)->toThrow(ManualPaymentException::class);
});
it('refuses a payment that belongs to a different order', function () {
[$order, , $admin] = manualPaymentBootstrap();
[, $foreignPayment] = manualPaymentBootstrap();
expect(fn () => app(ManualPaymentService::class)
->recordIncomingPayment($order, $foreignPayment, manualPaymentData(), $admin)
)->toThrow(ManualPaymentException::class);
});
it('activates the subscription when the order is an abo', function () {
[$order, $payment, $admin] = manualPaymentBootstrap(
['is_abo' => true, 'abo_interval' => 1],
['is_abo' => true, 'abo_interval' => 1],
);
app(ManualPaymentService::class)->recordIncomingPayment($order, $payment, manualPaymentData(), $admin);
// Der Paid-Pfad ist durchgelaufen (AboHelper::setAboActive inklusive) —
// entscheidend ist, dass die Bestellung dabei nicht hängen bleibt.
expect((bool) $order->fresh()->paid)->toBeTrue()
->and($order->fresh()->txaction)->toBe('paid');
});
it('does not create a second transaction on a repeated call', function () {
[$order, $payment, $admin] = manualPaymentBootstrap();
$service = app(ManualPaymentService::class);
$service->recordIncomingPayment($order, $payment, manualPaymentData(), $admin);
expect(fn () => $service->recordIncomingPayment($order->fresh(), $payment, manualPaymentData(), $admin))
->toThrow(ManualPaymentException::class);
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'manual_payment')->count())
->toBe(1);
});
// --- Formular-Validierung (SE4.2 / SE4.3) ---
/**
* @param array<string, mixed> $data
*/
function validateManualPaymentRequest(array $data): void
{
$request = RecordManualPaymentRequest::create('/admin/sales/payment/manual', 'POST', $data);
$request->setContainer(app())->setRedirector(app(Redirector::class));
$request->validateResolved();
}
/**
* @return array<string, mixed>
*/
function manualPaymentFormData(ShoppingOrder $order, ShoppingPayment $payment, array $overrides = []): array
{
return array_merge([
'id' => $order->id,
'payment_id' => $payment->id,
'paid_at' => '2026-06-13',
'amount' => '288.50',
'method' => ManualPaymentService::METHOD_BANK_TRANSFER,
'bank_reference' => 'Überweisung Mathis',
], $overrides);
}
it('accepts a matching amount', function () {
[$order, $payment] = manualPaymentBootstrap();
validateManualPaymentRequest(manualPaymentFormData($order, $payment));
})->throwsNoExceptions();
it('rejects a deviating amount without confirmation', function () {
[$order, $payment] = manualPaymentBootstrap();
try {
validateManualPaymentRequest(manualPaymentFormData($order, $payment, ['amount' => '280.00']));
$this->fail('Die Abweichung hätte abgelehnt werden müssen.');
} catch (ValidationException $e) {
expect($e->validator->errors()->has('amount'))->toBeTrue()
->and($e->validator->errors()->first('amount'))->toContain('288,50');
}
});
it('accepts a deviating amount once it is confirmed', function () {
[$order, $payment] = manualPaymentBootstrap();
validateManualPaymentRequest(manualPaymentFormData($order, $payment, [
'amount' => '280.00',
'confirm_deviation' => '1',
]));
})->throwsNoExceptions();
it('requires the bank reference', function () {
[$order, $payment] = manualPaymentBootstrap();
try {
validateManualPaymentRequest(manualPaymentFormData($order, $payment, ['bank_reference' => '']));
$this->fail('Der fehlende Verwendungszweck hätte abgelehnt werden müssen.');
} catch (ValidationException $e) {
expect($e->validator->errors()->has('bank_reference'))->toBeTrue();
}
});
it('rejects a payment date in the future', function () {
[$order, $payment] = manualPaymentBootstrap();
try {
validateManualPaymentRequest(manualPaymentFormData($order, $payment, [
'paid_at' => now()->addDay()->format('Y-m-d'),
]));
$this->fail('Das Datum in der Zukunft hätte abgelehnt werden müssen.');
} catch (ValidationException $e) {
expect($e->validator->errors()->has('paid_at'))->toBeTrue();
}
});
it('parses german decimal input', function () {
$request = RecordManualPaymentRequest::create('/x', 'POST', ['amount' => '288,50']);
expect($request->amountCents())->toBe(28850);
});

View file

@ -19,6 +19,7 @@ use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\CardException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
@ -105,15 +106,26 @@ function stripeAboUserAbo(array $overrides = []): UserAbo
}
/**
* Stripe-Client-Mock, der genau einen request()-Aufruf beantwortet und die
* Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
* Stripe-Client-Mock, der genau einen fachlichen request()-Aufruf beantwortet
* und die Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
*
* Die Customer-Anlage aus Phase SE1 wird transparent mitbeantwortet — sie ist
* Infrastruktur und nicht Gegenstand dieser Assertions.
*/
function stripeAboMockClient(mixed $result, ?array &$captured = null): void
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->withArgs(fn (...$args) => ($args[1] ?? null) === '/v1/customers')
->andReturn(Customer::constructFrom(['id' => 'cus_abo_checkout', 'object' => 'customer']));
$expectation = $client->shouldReceive('request')
->once()
->withArgs(function (...$args) use (&$captured) {
if (($args[1] ?? null) === '/v1/customers') {
return false;
}
$captured = $args;
return true;
@ -171,7 +183,10 @@ it('requests off-session saving and customer creation for abo checkout sessions'
$params = $captured[2];
expect($params['customer_creation'])->toBe('always')
// Seit SE1 hängt der Checkout einen benannten Customer an, statt Stripe
// einen namenlosen anlegen zu lassen (customer_creation).
expect($params['customer'])->toBe('cus_abo_checkout')
->and($params)->not->toHaveKey('customer_creation')
->and($params['payment_intent_data']['setup_future_usage'])->toBe('off_session');
});
@ -200,6 +215,7 @@ it('does not request off-session saving for one-time checkout sessions', functio
$params = $captured[2];
expect($params)->not->toHaveKey('customer_creation')
->and($params['customer'])->toBe('cus_abo_checkout')
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage');
});

View file

@ -18,12 +18,15 @@ use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\Services\Payment\Stripe\StripePaymentService;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\ApiConnectionException;
use Stripe\StripeClient;
use Tests\TestCase;
@ -114,6 +117,42 @@ function handleStripeCheckoutEvent(string $type, array $object, string $eventId)
app(StripeWebhookHandler::class)->handle($payload, stripeCheckoutSignature($payload));
}
/**
* Stripe-Client-Mock für die Session-Erstellung. Seit Phase SE1 legt der
* Service zuerst einen Customer an (Name/Anschrift für die Buchhaltung) und
* danach die Checkout Session.
*
* @param array<string, mixed>|null $sessionParams Referenz, gefüllt mit den Session-Parametern
* @param array<string, mixed>|null $customerParams Referenz, gefüllt mit den Customer-Parametern
*/
function stripeCheckoutServiceWithMock(&$sessionParams = null, &$customerParams = null): StripePaymentService
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->andReturnUsing(function ($method, $path, $params) use (&$sessionParams, &$customerParams) {
if ($path === '/v1/customers') {
$customerParams = $params;
return Customer::constructFrom(['id' => 'cus_test_se1', 'object' => 'customer']);
}
$sessionParams = $params;
return Session::constructFrom([
'id' => 'cs_test_abc',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_test_abc',
'status' => 'open',
'livemode' => false,
]);
});
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
return new StripePaymentService($wrapper, new StripeCustomerService($wrapper));
}
// --- Session-Erstellung (S3.2) ---
it('creates a checkout session with payment and initiation transaction', function () {
@ -121,26 +160,8 @@ it('creates a checkout session with payment and initiation transaction', functio
$order->shopping_payments()->delete();
$capturedParams = null;
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->once()
->withArgs(function ($method, $path, $params) use (&$capturedParams) {
$capturedParams = $params;
return $method === 'post' && $path === '/v1/checkout/sessions';
})
->andReturn(Session::constructFrom([
'id' => 'cs_test_abc',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_test_abc',
'status' => 'open',
'livemode' => false,
]));
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
$result = (new StripePaymentService($wrapper))->initiateCheckoutPayment(
$result = stripeCheckoutServiceWithMock($capturedParams)->initiateCheckoutPayment(
$shoppingUser,
$order,
8900,
@ -166,6 +187,183 @@ it('creates a checkout session with payment and initiation transaction', functio
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'checkout.session.created')->exists())->toBeTrue();
});
// --- Kundendaten & Metadaten (SE1/SE2, dev/2026-08-26-stripe/) ---
it('attaches a named stripe customer to the checkout session', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$shoppingUser->update([
'billing_firstname' => 'Erika',
'billing_lastname' => 'Mustermann',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
]);
$sessionParams = null;
$customerParams = null;
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
$shoppingUser, $order, 8900, 'EUR', 'mrefse1',
'https://checkout.test/success/mrefse1',
'https://checkout.test/cancel/mrefse1',
);
expect($customerParams['name'])->toBe('Erika Mustermann')
->and($customerParams['email'])->toBe($shoppingUser->billing_email)
->and($customerParams['address']['line1'])->toBe('Musterweg 1')
->and($customerParams['address']['postal_code'])->toBe('12345')
->and($customerParams['metadata']['mivita_type'])->toBe('shopping_user')
->and($sessionParams['customer'])->toBe('cus_test_se1')
->and($sessionParams)->not->toHaveKey('customer_email')
->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_test_se1');
});
it('reuses an existing stripe customer instead of creating a second one', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$shoppingUser->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
$sessionParams = null;
$customerParams = null;
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
$shoppingUser, $order, 8900, 'EUR', 'mrefse1b',
'https://checkout.test/success/mrefse1b',
'https://checkout.test/cancel/mrefse1b',
);
expect($sessionParams['customer'])->toBe('cus_existing')
->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_existing');
});
it('shows the person name first and the company in brackets', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$shoppingUser->update([
'billing_firstname' => 'Kevin',
'billing_lastname' => 'Adametz',
'billing_company' => 'adametz.media',
]);
$sessionParams = null;
$customerParams = null;
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
$shoppingUser, $order, 8900, 'EUR', 'mrefse1c',
'https://checkout.test/success/mrefse1c',
'https://checkout.test/cancel/mrefse1c',
);
expect($customerParams['name'])->toBe('Kevin Adametz (adametz.media)')
->and($sessionParams['metadata']['customer_name'])->toBe('Kevin Adametz (adametz.media)');
});
it('falls back to the company when no person name is stored', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$shoppingUser->update([
'billing_firstname' => null,
'billing_lastname' => null,
'billing_company' => 'Muster GmbH',
]);
$customerParams = null;
$sessionParams = null;
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
$shoppingUser, $order, 8900, 'EUR', 'mrefse1d',
'https://checkout.test/success/mrefse1d',
'https://checkout.test/cancel/mrefse1d',
);
expect($customerParams['name'])->toBe('Muster GmbH');
});
it('sends a searchable description and the full metadata set', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$sessionParams = null;
$result = stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment(
$shoppingUser, $order, 8900, 'EUR', 'mrefse2',
'https://checkout.test/success/mrefse2',
'https://checkout.test/cancel/mrefse2',
);
$metadata = $sessionParams['payment_intent_data']['metadata'];
expect($sessionParams['payment_intent_data']['description'])
->toContain('Bestellung #'.$order->id)
->toContain('mrefse2')
->toContain('Stripe Checkout')
->and($metadata['shopping_order_id'])->toBe((string) $order->id)
->and($metadata['shopping_payment_id'])->toBe((string) $result->shoppingPayment->id)
->and($metadata['reference'])->toBe('mrefse2')
->and($metadata['order_type'])->toBe('shop')
->and($metadata['mode'])->toBe('test');
});
it('marks abo orders as abo in the metadata and keeps setup_future_usage', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$order->update(['is_abo' => true, 'abo_interval' => 1]);
$sessionParams = null;
stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment(
$shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse2b',
'https://checkout.test/success/mrefse2b',
'https://checkout.test/cancel/mrefse2b',
);
expect($sessionParams['metadata']['order_type'])->toBe('abo')
->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session')
->and($sessionParams)->not->toHaveKey('customer_creation')
->and($sessionParams['customer'])->toBe('cus_test_se1');
});
it('falls back to customer_email when the stripe customer cannot be created', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$order->update(['is_abo' => true]);
$sessionParams = null;
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->andReturnUsing(function ($method, $path, $params) use (&$sessionParams) {
if ($path === '/v1/customers') {
throw new ApiConnectionException('Stripe nicht erreichbar');
}
$sessionParams = $params;
return Session::constructFrom([
'id' => 'cs_test_fallback',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_test_fallback',
'status' => 'open',
'livemode' => false,
]);
});
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
$result = (new StripePaymentService($wrapper, new StripeCustomerService($wrapper)))->initiateCheckoutPayment(
$shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse1f',
'https://checkout.test/success/mrefse1f',
'https://checkout.test/cancel/mrefse1f',
);
expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_fallback')
->and($sessionParams)->not->toHaveKey('customer')
->and($sessionParams['customer_email'])->toBe($shoppingUser->billing_email)
->and($sessionParams['customer_creation'])->toBe('always')
->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session');
});
// --- Paid-Pfad (S3.5) ---
it('marks the order paid and sends one mail on payment_intent succeeded', function () {

View file

@ -0,0 +1,187 @@
<?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);
});

View file

@ -0,0 +1,226 @@
<?php
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserShop;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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 S6.4 (dev/2026-07-08-stripe/, vorgezogen 2026-08-26): Webhook wurde
* NIE zugestellt — das Geld liegt bei Stripe, die Bestellung steht bei uns
* auf "offen". `stripe:sync-payment-status` holt den Status aktiv nach.
*
* @return array{0: ShoppingOrder, 1: ShoppingPayment}
*/
function syncStatusBootstrap(array $paymentOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shopOwner = User::forceCreate([
'email' => 'sync-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'Sync Shop',
'slug' => 'sync-shop-'.uniqid(),
'domain' => 'sync-test.local',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $shopOwner->id,
'billing_firstname' => 'Sync',
'billing_lastname' => 'Kunde',
'billing_email' => 'sync-'.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' => false,
'txaction' => null,
'mode' => 'test',
]);
$payment = ShoppingPayment::create(array_merge([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sync-ref-'.uniqid(),
'external_reference' => 'pi_sync_'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'mode' => 'test',
], $paymentOverrides));
return [$order, $payment];
}
function syncStatusMockStripe(array $paymentIntent): void
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')->andReturn(PaymentIntent::constructFrom($paymentIntent));
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('isConfigured')->andReturn(true);
$wrapper->shouldReceive('client')->andReturn($client);
app()->instance(StripeClientWrapper::class, $wrapper);
}
it('recovers a payment whose webhook never arrived', function () {
[$order, $payment] = syncStatusBootstrap();
syncStatusMockStripe([
'id' => $payment->external_reference,
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'amount_received' => 8900,
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
'latest_charge' => [
'id' => 'ch_sync_1',
'object' => 'charge',
'payment_intent' => $payment->external_reference,
'payment_method_details' => ['type' => 'card', 'card' => ['brand' => 'visa', 'last4' => '4242']],
],
]);
$this->artisan('stripe:sync-payment-status', ['target' => $payment->id])
->assertSuccessful();
expect((bool) $order->fresh()->paid)->toBeTrue()
->and($order->fresh()->txaction)->toBe('paid')
->and($payment->fresh()->txaction)->toBe('paid')
->and($payment->fresh()->carddata['last4'])->toBe('4242');
});
it('changes nothing in dry-run mode', function () {
[$order, $payment] = syncStatusBootstrap();
syncStatusMockStripe([
'id' => $payment->external_reference,
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'amount_received' => 8900,
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
]);
$this->artisan('stripe:sync-payment-status', ['target' => $payment->id, '--dry-run' => true])
->assertSuccessful();
expect((bool) $order->fresh()->paid)->toBeFalse()
->and($payment->fresh()->txaction)->toBeNull();
});
it('does not process an already paid order twice', function () {
[$order, $payment] = syncStatusBootstrap();
$order->update(['paid' => true, 'txaction' => 'paid']);
$payment->forceFill(['txaction' => 'paid'])->save();
syncStatusMockStripe([
'id' => $payment->external_reference,
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'amount_received' => 8900,
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
]);
$this->artisan('stripe:sync-payment-status', ['target' => $payment->id])
->assertSuccessful();
// Der Paid-Pfad läuft nicht erneut: keine zweite Bestätigungsmail.
Mail::assertNothingSent();
});
it('refuses a mismatching amount', function () {
[$order, $payment] = syncStatusBootstrap();
syncStatusMockStripe([
'id' => $payment->external_reference,
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 999,
'amount_received' => 999,
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
]);
$this->artisan('stripe:sync-payment-status', ['target' => $payment->id])
->assertSuccessful();
expect((bool) $order->fresh()->paid)->toBeFalse();
});
it('finds open payments with --pending and skips settled ones', function () {
[, $open] = syncStatusBootstrap();
[$paidOrder, $paidPayment] = syncStatusBootstrap();
$paidOrder->update(['paid' => true, 'txaction' => 'paid']);
$paidPayment->forceFill(['txaction' => 'paid'])->save();
syncStatusMockStripe([
'id' => $open->external_reference,
'object' => 'payment_intent',
'status' => 'processing',
'amount' => 8900,
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $open->id],
]);
$this->artisan('stripe:sync-payment-status', ['--pending' => true])
->expectsOutputToContain('Zahlung #'.$open->id)
->doesntExpectOutputToContain('Zahlung #'.$paidPayment->id)
->assertSuccessful();
expect($open->fresh()->txaction)->toBe('pending');
});
it('requires a target or --pending', function () {
$this->artisan('stripe:sync-payment-status')
->expectsOutputToContain('Bitte eine Zahlung angeben oder --pending nutzen.')
->assertSuccessful();
});
it('skips expired checkout sessions in --pending', function () {
[, $expired] = syncStatusBootstrap(['external_reference' => 'cs_test_expired_'.uniqid()]);
$expired->forceFill(['status' => 'expired'])->save();
syncStatusMockStripe([
'id' => 'pi_never_called',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'livemode' => false,
]);
$this->artisan('stripe:sync-payment-status', ['--pending' => true])
->expectsOutputToContain('Keine passenden offenen Stripe-Zahlungen gefunden.')
->assertSuccessful();
expect($expired->fresh()->txaction)->toBeNull();
});

View file

@ -0,0 +1,123 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\Payment\Stripe\StripeMetadataBuilder;
/**
* Phase SE2 (dev/2026-08-26-stripe/): Der Builder ist die einzige Quelle für
* die Metadaten und die description, die an Stripe gehen. Er arbeitet rein auf
* Modell-Attributen — kein DB-Zugriff, daher Unit-Test ohne RefreshDatabase.
*/
function metadataOrder(array $attributes = []): ShoppingOrder
{
$order = new ShoppingOrder(array_merge(['mode' => 'live'], $attributes));
$order->id = $attributes['id'] ?? 4711;
$order->setRelation('user_invoice', null);
return $order;
}
function metadataShoppingUser(array $attributes = []): ShoppingUser
{
$shoppingUser = new ShoppingUser(array_merge([
'billing_firstname' => 'Erika',
'billing_lastname' => 'Mustermann',
], $attributes));
$shoppingUser->id = 99;
return $shoppingUser;
}
function metadataPayment(): ShoppingPayment
{
$payment = new ShoppingPayment(['reference' => 'mref123']);
$payment->id = 555;
return $payment;
}
it('builds the full metadata set for a checkout', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['number' => 1234, 'member_id' => 77]),
'mref123',
);
expect($metadata)->toBe([
'shopping_order_id' => '4711',
'shopping_payment_id' => '555',
'reference' => 'mref123',
'mode' => 'live',
'order_type' => 'shop',
'customer_name' => 'Erika Mustermann',
'customer_number' => '1234',
'member_id' => '77',
]);
});
it('flags abo and recovery orders by order_type', function () {
expect(StripeMetadataBuilder::forCheckout(
metadataOrder(['is_abo' => true]), metadataPayment(), metadataShoppingUser(), 'r1'
)['order_type'])->toBe('abo');
$recovery = StripeMetadataBuilder::forCheckout(
metadataOrder(['recovery_user_abo_id' => 42]), metadataPayment(), metadataShoppingUser(), 'r2'
);
expect($recovery['order_type'])->toBe('abo_recovery')
->and($recovery['user_abo_id'])->toBe('42');
});
it('names the person first and the company in brackets', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['billing_company' => 'Muster GmbH']),
'mref123',
);
expect($metadata['customer_name'])->toBe('Erika Mustermann (Muster GmbH)');
});
it('uses the company alone when no person name is stored', function () {
$metadata = StripeMetadataBuilder::forCheckout(
metadataOrder(),
metadataPayment(),
metadataShoppingUser(['billing_firstname' => null, 'billing_lastname' => null, 'billing_company' => 'Muster GmbH']),
'mref123',
);
expect($metadata['customer_name'])->toBe('Muster GmbH');
});
it('drops empty values so no null strings reach the reports', function () {
$metadata = StripeMetadataBuilder::sanitize([
'a' => null,
'b' => '',
'c' => 0,
'd' => 'wert',
'e' => 12,
]);
expect($metadata)->toBe(['d' => 'wert', 'e' => '12']);
});
it('truncates values to the stripe metadata limit', function () {
$metadata = StripeMetadataBuilder::sanitize(['lang' => str_repeat('x', 700)]);
expect(mb_strlen($metadata['lang']))->toBe(StripeMetadataBuilder::MAX_VALUE_LENGTH);
});
it('builds a searchable description with order number, reference and name', function () {
$description = StripeMetadataBuilder::description(metadataOrder(), 'mref123', metadataShoppingUser());
expect($description)->toBe('Bestellung #4711 · mref123 · Erika Mustermann');
});
it('works without a shopping user', function () {
expect(StripeMetadataBuilder::description(metadataOrder(), 'mref123', null))
->toBe('Bestellung #4711 · mref123');
});