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:
parent
30fb031d12
commit
68d230d4e5
31 changed files with 3612 additions and 65 deletions
307
tests/Feature/ManualPaymentTest.php
Normal file
307
tests/Feature/ManualPaymentTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
187
tests/Feature/StripeInvoiceReferenceTest.php
Normal file
187
tests/Feature/StripeInvoiceReferenceTest.php
Normal 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);
|
||||
});
|
||||
226
tests/Feature/StripeSyncPaymentStatusTest.php
Normal file
226
tests/Feature/StripeSyncPaymentStatusTest.php
Normal 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();
|
||||
});
|
||||
123
tests/Unit/Services/StripeMetadataBuilderTest.php
Normal file
123
tests/Unit/Services/StripeMetadataBuilderTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue