mivita/app/Http/Requests/Payment/RecordManualPaymentRequest.php
Kevin 68d230d4e5 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>
2026-08-28 09:04:28 +00:00

103 lines
3.5 KiB
PHP

<?php
namespace App\Http\Requests\Payment;
use App\Models\ShoppingPayment;
use App\Services\ManualPaymentService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
/**
* Erfassung eines Zahlungseingangs durch die Buchhaltung
* (Plan dev/2026-08-26-stripe/, Phase SE4).
*
* Der Verwendungszweck bzw. die Bankreferenz ist Pflicht — sie ist der Beleg,
* mit dem die Buchung später nachvollziehbar bleibt.
*/
class RecordManualPaymentRequest extends FormRequest
{
public function authorize(): bool
{
// Die Route liegt hinter der admin-Middleware.
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'id' => ['required', 'integer', 'exists:shopping_orders,id'],
'payment_id' => ['required', 'integer', 'exists:shopping_payments,id'],
'paid_at' => ['required', 'date', 'before_or_equal:today'],
'amount' => ['required', 'numeric', 'min:0.01', 'max:99999'],
'method' => ['required', Rule::in(array_keys(ManualPaymentService::methods()))],
'bank_reference' => ['required', 'string', 'max:255'],
'note' => ['nullable', 'string', 'max:1000'],
'send_mail' => ['nullable', 'boolean'],
'confirm_deviation' => ['nullable', 'boolean'],
'back' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'bank_reference.required' => __('payment.manual_error_reference_required'),
'paid_at.before_or_equal' => __('payment.manual_error_future_date'),
];
}
/**
* Ein abweichender Betrag ist erlaubt (Teilzahlung, Rundungsdifferenz),
* muss aber bewusst bestätigt werden — sonst wird aus einem Tippfehler
* still eine falsche Buchung.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$payment = ShoppingPayment::find($this->integer('payment_id'));
if (! $payment) {
return;
}
if ($this->amountCents() !== (int) $payment->amount && ! $this->boolean('confirm_deviation')) {
$validator->errors()->add('amount', __('payment.manual_error_amount_deviation', [
'expected' => number_format($payment->amount / 100, 2, ',', '.'),
]));
}
});
}
/**
* Erfasster Betrag in Cent (die Zahlungen werden in Cent geführt).
*/
public function amountCents(): int
{
return (int) round(((float) str_replace(',', '.', (string) $this->input('amount'))) * 100);
}
/**
* @return array{amount_cents: int, paid_at: string, method: string, bank_reference: string, note: string|null, send_mail: bool}
*/
public function toServiceData(): array
{
return [
'amount_cents' => $this->amountCents(),
'paid_at' => (string) $this->input('paid_at'),
'method' => (string) $this->input('method'),
'bank_reference' => trim((string) $this->input('bank_reference')),
'note' => $this->filled('note') ? trim((string) $this->input('note')) : null,
'send_mail' => $this->boolean('send_mail'),
];
}
}