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>
307 lines
11 KiB
PHP
307 lines
11 KiB
PHP
<?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);
|
|
});
|