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 $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 */ 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); });