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,135 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeCustomerService;
use Illuminate\Console\Command;
/**
* Trägt Name und Rechnungsanschrift an bestehenden Stripe-Customern nach
* (Plan dev/2026-08-26-stripe/, Phase SE1.5).
*
* Hintergrund: Vor SE1 legte Stripe die Customer für Abo-Zahlungen selbst an
* (`customer_creation: 'always'`) — ohne Namen. In der Buchhaltung stand
* deshalb nur die E-Mail. Dieses Command verknüpft die vorhandenen Customer
* mit unseren Kundendatensätzen und aktualisiert sie.
*
* Mit `--refresh` werden stattdessen die bereits verknüpften Customer neu
* beschrieben — nötig, wenn sich das Namensformat ändert.
*/
class StripeBackfillCustomers extends Command
{
protected $signature = 'stripe:backfill-customers
{--refresh : Auch bereits verknüpfte Customer aktualisieren (z. B. nach einer Änderung des Namensformats)}
{--dry-run : Nichts an Stripe senden, nur anzeigen was passieren würde}
{--limit=0 : Maximale Anzahl bearbeiteter Kunden (0 = alle)}';
protected $description = 'Trägt Name/Anschrift an bestehenden Stripe-Customern nach (Phase SE1)';
public function handle(StripeClientWrapper $stripe, StripeCustomerService $customerService): int
{
if (! $stripe->isConfigured()) {
$this->error('STRIPE_SECRET ist nicht gesetzt.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$refresh = (bool) $this->option('refresh');
$query = ShoppingUser::query()->orderBy('id');
if ($refresh) {
// Bereits verknüpfte Kunden neu an Stripe schreiben.
$query->whereNotNull('stripe_customer_id');
} else {
// Noch nicht verknüpfte Kunden, für die Stripe selbst einen
// (namenlosen) Customer angelegt hat.
$query->whereNull('stripe_customer_id')
->whereHas('shopping_orders.shopping_payments', function ($q) {
$q->where('payment_provider', 'stripe')->whereNotNull('stripe_customer_id');
});
}
$linked = 0;
$updated = 0;
$failed = 0;
$query->chunkById(100, function ($shoppingUsers) use (&$linked, &$updated, &$failed, $customerService, $dryRun, $limit, $refresh): bool {
foreach ($shoppingUsers as $shoppingUser) {
$customerId = $refresh
? $shoppingUser->stripe_customer_id
: $this->existingCustomerIdFor($shoppingUser);
if ($customerId === null) {
continue;
}
$this->line(sprintf(
'#%d %s → %s',
$shoppingUser->id,
StripeCustomerService::customerNameFor($shoppingUser) ?? '(ohne Namen)',
$customerId,
));
if ($dryRun) {
$linked++;
continue;
}
try {
if (! $refresh) {
$shoppingUser->forceFill(['stripe_customer_id' => $customerId])->save();
}
$linked++;
$customerService->updateCustomer($customerId, $customerService->customerPayloadFor($shoppingUser));
$updated++;
} catch (\Throwable $e) {
$failed++;
$this->error(sprintf('Kunde #%d (%s): %s', $shoppingUser->id, $customerId, $e->getMessage()));
}
if ($limit > 0 && $linked >= $limit) {
return false;
}
}
return true;
});
$this->info(sprintf(
'%s: %d %s, %d bei Stripe aktualisiert, %d fehlgeschlagen.',
$dryRun ? 'Probelauf' : 'Fertig',
$linked,
$refresh ? 'geprüft' : 'verknüpft',
$updated,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Die Stripe-Customer-ID, die Stripe bei einer früheren Zahlung dieses
* Kunden selbst angelegt hat (jüngste zuerst).
*/
private function existingCustomerIdFor(ShoppingUser $shoppingUser): ?string
{
$orderIds = $shoppingUser->shopping_orders()->pluck('id');
if ($orderIds->isEmpty()) {
return null;
}
return ShoppingPayment::whereIn('shopping_order_id', $orderIds)
->whereNotNull('stripe_customer_id')
->latest('id')
->value('stripe_customer_id');
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingPayment;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
use Illuminate\Console\Command;
/**
* Trägt die MIVITA-Rechnungsnummer an bestehenden Stripe-Zahlungen nach
* (Plan dev/2026-08-26-stripe/, Phase SE3.4).
*
* Stakeholder-Entscheid 2026-08-26 (F3): Die Zahlungen **seit dem Go-Live am
* 2026-07-10** sollen rückwirkend ihre Rechnungsnummer bekommen, damit die
* Buchhaltung auch die zurückliegenden Monate über die Berichte abstimmen kann.
*/
class StripeBackfillInvoiceMetadata extends Command
{
/** Stripe ist seit diesem Datum live (siehe dev/2026-07-08-stripe/). */
private const GO_LIVE_DATE = '2026-07-10';
protected $signature = 'stripe:backfill-invoice-metadata
{--from= : Startdatum (Y-m-d), Standard: Go-Live 2026-07-10}
{--to= : Enddatum (Y-m-d), Standard: heute}
{--dry-run : Nichts an Stripe senden, nur anzeigen was passieren würde}
{--limit=0 : Maximale Anzahl bearbeiteter Zahlungen (0 = alle)}';
protected $description = 'Trägt Rechnungsnummern an bestehenden Stripe-Zahlungen nach (Phase SE3)';
public function handle(StripeClientWrapper $stripe, StripeInvoiceReferenceService $service): int
{
if (! $stripe->isConfigured()) {
$this->error('STRIPE_SECRET ist nicht gesetzt.');
return self::FAILURE;
}
$from = $this->option('from') ?: self::GO_LIVE_DATE;
$to = $this->option('to');
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$query = ShoppingPayment::where('payment_provider', 'stripe')
->where('external_reference', 'LIKE', 'pi\_%')
->where('created_at', '>=', $from.' 00:00:00')
->whereHas('shopping_order.user_invoice')
->with('shopping_order.user_invoice')
->orderBy('id');
if ($to) {
$query->where('created_at', '<=', $to.' 23:59:59');
}
$total = $query->count();
$this->info(sprintf('%d Stripe-Zahlungen mit Rechnung ab %s gefunden.', $total, $from));
$synced = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(50, function ($payments) use (&$synced, &$skipped, &$failed, $service, $dryRun, $limit): bool {
foreach ($payments as $payment) {
$invoice = $payment->shopping_order?->user_invoice;
if (! $invoice) {
$skipped++;
continue;
}
$this->line(sprintf(
'Zahlung #%-7d %s → Rechnung %s',
$payment->id,
$payment->external_reference,
$invoice->full_number,
));
if ($dryRun) {
$synced++;
} else {
try {
$service->sync($payment, $invoice) ? $synced++ : $skipped++;
} catch (\Throwable $e) {
$failed++;
$this->error(' → '.$e->getMessage());
}
}
if ($limit > 0 && ($synced + $skipped + $failed) >= $limit) {
return false;
}
}
return true;
});
$this->info(sprintf(
'%s: %d übertragen, %d übersprungen (schon gesetzt / keine Rechnung), %d fehlgeschlagen.',
$dryRun ? 'Probelauf' : 'Fertig',
$synced,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,223 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingPayment;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session;
use Stripe\PaymentIntent;
/**
* Gleicht offene Stripe-Zahlungen aktiv gegen die Stripe-API ab und zieht den
* Status nach (Plan dev/2026-07-08-stripe/, S6.4 — vorgezogen am 2026-08-26).
*
* Abgrenzung zu `stripe:process-pending-events`: Jenes arbeitet auf Events,
* die bei uns GESPEICHERT sind (Webhook kam an, Verarbeitung scheiterte).
* Dieses Command deckt den anderen Fall ab — der Webhook wurde **nie
* zugestellt** (Listener am falschen Konto, Endpoint down, Stripe hat nach
* ~3 Tagen Retry aufgegeben). Dann ist das Geld bei Stripe eingegangen,
* während die Bestellung bei uns auf "offen" stehen bleibt.
*
* Die Seiteneffekte laufen über denselben Pfad wie ein echter Webhook
* (StripeWebhookHandler) und sind idempotent — eine bereits bezahlte
* Bestellung wird nicht erneut verarbeitet.
*/
class StripeSyncPaymentStatus extends Command
{
protected $signature = 'stripe:sync-payment-status
{target? : PaymentIntent-ID (pi_…), Session-ID (cs_…), Zahlungsreferenz oder shopping_payment-ID}
{--pending : Alle offenen Stripe-Zahlungen der letzten Tage prüfen}
{--days=7 : Zeitfenster für --pending}
{--dry-run : Nur anzeigen, was Stripe sagt — nichts verarbeiten}';
protected $description = 'Zieht den Status offener Stripe-Zahlungen aktiv über die API nach (verlorene Webhooks)';
public function handle(StripeClientWrapper $stripe, StripeWebhookHandler $webhookHandler): int
{
if (! $stripe->isConfigured()) {
$this->error('STRIPE_SECRET ist nicht gesetzt.');
return self::FAILURE;
}
$payments = $this->resolvePayments();
if ($payments->isEmpty()) {
$this->info('Keine passenden offenen Stripe-Zahlungen gefunden.');
return self::SUCCESS;
}
$dryRun = (bool) $this->option('dry-run');
$synced = 0;
$unchanged = 0;
$failed = 0;
foreach ($payments as $payment) {
try {
$result = $this->syncPayment($stripe, $webhookHandler, $payment, $dryRun);
$result ? $synced++ : $unchanged++;
} catch (\Throwable $e) {
$failed++;
$this->error(sprintf('Zahlung #%d: %s', $payment->id, $e->getMessage()));
Log::channel('stripe')->error('stripe:sync-payment-status fehlgeschlagen', [
'shopping_payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
}
}
$this->info(sprintf(
'%s: %d nachgezogen, %d unverändert, %d fehlgeschlagen.',
$dryRun ? 'Probelauf' : 'Fertig',
$synced,
$unchanged,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, ShoppingPayment>
*/
private function resolvePayments(): Collection
{
$target = $this->argument('target');
if ($target) {
$payment = ShoppingPayment::where('external_reference', $target)
->orWhere('reference', $target)
->orWhere('id', is_numeric($target) ? (int) $target : 0)
->latest('id')
->first();
if (! $payment) {
$this->error("Keine Zahlung zu '{$target}' gefunden.");
return collect();
}
return collect([$payment]);
}
if (! $this->option('pending')) {
$this->error('Bitte eine Zahlung angeben oder --pending nutzen.');
return collect();
}
return ShoppingPayment::where('payment_provider', 'stripe')
->whereNotNull('external_reference')
->where('created_at', '>=', now()->subDays((int) $this->option('days')))
// Abgelaufene Checkout-Sessions haben nie eine Zahlung getragen —
// sie jede Stunde erneut bei Stripe abzufragen wäre reine Last.
->where(function ($query) {
$query->whereNull('status')->orWhere('status', '!=', 'expired');
})
->where(function ($query) {
$query->whereNull('txaction')->orWhereNotIn('txaction', ['paid', 'cancelled']);
})
->whereHas('shopping_order', function ($query) {
$query->where('paid', false);
})
->orderBy('id')
->get();
}
private function syncPayment(
StripeClientWrapper $stripe,
StripeWebhookHandler $webhookHandler,
ShoppingPayment $payment,
bool $dryRun,
): bool {
[$paymentIntent, $session] = $this->fetchStripeObjects($stripe, $payment);
if (! $paymentIntent) {
$this->warn(sprintf(
'Zahlung #%d (%s): kein PaymentIntent bei Stripe%s.',
$payment->id,
$payment->external_reference,
$session ? ' — Session-Status: '.$session->status : '',
));
if ($session && $session->status === 'expired' && ! $dryRun) {
return $webhookHandler->syncFromApiObject('checkout.session.expired', $session->toArray(), 'api-sync');
}
return false;
}
$eventType = match ($paymentIntent->status) {
'succeeded' => 'payment_intent.succeeded',
'processing' => 'payment_intent.processing',
'requires_payment_method', 'requires_action', 'requires_confirmation' => 'payment_intent.payment_failed',
default => null,
};
$this->line(sprintf(
'Zahlung #%d lokal: %-9s Stripe: %-22s %s',
$payment->id,
$payment->txaction ?: 'offen',
$paymentIntent->status,
$paymentIntent->id,
));
if ($eventType === null) {
$this->warn(" → Status '{$paymentIntent->status}' wird nicht verarbeitet.");
return false;
}
if ($dryRun) {
$this->comment(" → würde als '{$eventType}' verarbeitet.");
return true;
}
$handled = $webhookHandler->syncFromApiObject($eventType, $paymentIntent->toArray(), 'api-sync');
// Zahlart (Kartenmarke/letzte 4, PayPal …) kommt nur am Charge — nach
// dem PaymentIntent nachziehen, damit external_reference bereits sitzt.
$charge = $paymentIntent->latest_charge ?? null;
if ($handled && $charge && is_object($charge)) {
$webhookHandler->syncFromApiObject('charge.succeeded', $charge->toArray(), 'api-sync');
}
$this->info(sprintf(' → verarbeitet als %s%s', $eventType, $handled ? '' : ' (keiner Zahlung zuordenbar)'));
return $handled;
}
/**
* Holt PaymentIntent (und ggf. Session) zu einer Zahlung. `external_reference`
* trägt je nach Zeitpunkt die Session- oder die PaymentIntent-ID.
*
* @return array{0: PaymentIntent|null, 1: Session|null}
*/
private function fetchStripeObjects(StripeClientWrapper $stripe, ShoppingPayment $payment): array
{
$reference = (string) $payment->external_reference;
$client = $stripe->client();
if (str_starts_with($reference, 'pi_')) {
return [$client->paymentIntents->retrieve($reference, ['expand' => ['latest_charge']]), null];
}
if (str_starts_with($reference, 'cs_')) {
$session = $client->checkout->sessions->retrieve($reference);
$paymentIntentId = is_string($session->payment_intent) ? $session->payment_intent : ($session->payment_intent->id ?? null);
return [
$paymentIntentId ? $client->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]) : null,
$session,
];
}
throw new \RuntimeException("Unbekannte external_reference '{$reference}'.");
}
}

View file

@ -59,6 +59,16 @@ class Kernel extends ConsoleKernel
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
// Stripe: offene Zahlungen aktiv gegen die API abgleichen. Fängt den Fall
// ab, dass ein Webhook NIE zugestellt wurde (Endpoint down, falsches
// Konto am Listener, Stripe gibt Retries nach ~3 Tagen auf) — dann läge
// das Geld bei Stripe, während die Bestellung auf "offen" steht.
// Läuft über denselben, idempotenten Paid-Pfad wie ein echter Webhook.
$schedule->command('stripe:sync-payment-status --pending --days=3')
->hourly()
->withoutOverlapping()
->runInBackground();
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster). // Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP // BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren: // im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:

View file

@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Wird geworfen, wenn ein Zahlungseingang nicht manuell erfasst werden kann
* (Bestellung bereits bezahlt oder storniert, Zahlung gehört nicht zur
* Bestellung).
*/
class ManualPaymentException extends Exception {}

View file

@ -2,6 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exceptions\ManualPaymentException;
use App\Http\Requests\Payment\RecordManualPaymentRequest;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
@ -11,7 +13,10 @@ use App\Repositories\InvoiceRepository;
use App\Services\AboExternalCompletionService; use App\Services\AboExternalCompletionService;
use App\Services\BusinessPlan\SalesPointsVolume; use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\CustomerPriority; use App\Services\CustomerPriority;
use App\Services\ManualPaymentService;
use App\Services\Payment; use App\Services\Payment;
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
use Illuminate\Http\RedirectResponse;
use Request; use Request;
class SalesController extends Controller class SalesController extends Controller
@ -400,6 +405,43 @@ class SalesController extends Controller
} }
} }
/**
* Zahlungseingang manuell erfassen (Plan dev/2026-08-26-stripe/, SE4).
*
* Für Zahlungen, die kein Anbieter melden kann — vor allem Vorkasse mit
* falschem Verwendungszweck. Löst denselben Paid-Pfad aus wie ein
* Anbieter-Callback: Rechnung, Umsatz/Punkte, Abo-Aktivierung und Mail.
*/
public function recordManualPayment(
RecordManualPaymentRequest $request,
ManualPaymentService $manualPayments,
): RedirectResponse {
$shoppingOrder = ShoppingOrder::findOrFail($request->integer('id'));
$shoppingPayment = ShoppingPayment::findOrFail($request->integer('payment_id'));
$back = $request->input('back') ?: route('admin_sales_users_detail', [$shoppingOrder->id]);
try {
$manualPayments->recordIncomingPayment(
$shoppingOrder,
$shoppingPayment,
$request->toServiceData(),
\Auth::user(),
);
\Session()->flash('alert-success', __('payment.manual_success'));
} catch (ManualPaymentException $e) {
\Session()->flash('alert-error', $e->getMessage());
} catch (\Throwable $e) {
\Log::channel('payment')->error('Manueller Zahlungseingang fehlgeschlagen: '.$e->getMessage(), [
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
]);
\Session()->flash('alert-error', __('payment.manual_error_general'));
}
return redirect($back);
}
/* /*
Manuelle Rechnung erstellen.*/ Manuelle Rechnung erstellen.*/
public function invoice() public function invoice()
@ -419,6 +461,10 @@ class SalesController extends Controller
$invoice_repo->createAndSalesVolume($data); $invoice_repo->createAndSalesVolume($data);
} }
// Rechnungsnummer an eine Stripe-Zahlung nachtragen (SE3)
$shopping_order->refresh();
StripeInvoiceReferenceService::dispatchForOrder($shopping_order);
if (isset($data['view']) && $data['view'] === 'sales_customer') { if (isset($data['view']) && $data['view'] === 'sales_customer') {
return redirect(route('admin_sales_customers_detail', [$shopping_order->id])); return redirect(route('admin_sales_customers_detail', [$shopping_order->id]));
} }

View file

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

View file

@ -0,0 +1,48 @@
<?php
namespace App\Jobs;
use App\Models\ShoppingPayment;
use App\Models\UserInvoice;
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
use Illuminate\Bus\Queueable as BusQueueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Trägt die MIVITA-Rechnungsnummer am Stripe-PaymentIntent nach
* (Plan dev/2026-08-26-stripe/, Phase SE3).
*
* Asynchron, weil der Auslöser im Webhook-Paid-Pfad sitzt: ein API-Call zu
* Stripe darf die Antwortzeit des Webhooks nicht belasten und erst recht
* nicht dazu führen, dass Stripe den Webhook als fehlgeschlagen wertet.
*/
class SyncStripeInvoiceReference implements ShouldQueue
{
use BusQueueable, Dispatchable, InteractsWithQueue, SerializesModels;
public $tries = 3;
public $timeout = 30;
public function __construct(
public ShoppingPayment $shoppingPayment,
public UserInvoice $userInvoice,
) {}
public function handle(StripeInvoiceReferenceService $service): void
{
$service->sync($this->shoppingPayment, $this->userInvoice);
}
public function failed(\Throwable $e): void
{
Log::channel('stripe')->error('Rechnungsnummer konnte nicht an Stripe übertragen werden: '.$e->getMessage(), [
'shopping_payment_id' => $this->shoppingPayment->id,
'user_invoice_id' => $this->userInvoice->id,
]);
}
}

View file

@ -3,8 +3,11 @@
namespace App\Models; namespace App\Models;
use App\Services\ShoppingUserService; use App\Services\ShoppingUserService;
use App\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
/** /**
* App\Models\ShoppingUser * App\Models\ShoppingUser
@ -22,6 +25,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $billing_country_id * @property int $billing_country_id
* @property string|null $billing_phone * @property string|null $billing_phone
* @property string|null $billing_email * @property string|null $billing_email
* @property string|null $stripe_customer_id
* @property int $accepted_data_checkbox * @property int $accepted_data_checkbox
* @property int $same_as_billing * @property int $same_as_billing
* @property string|null $shipping_salutation * @property string|null $shipping_salutation
@ -34,12 +38,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $shipping_city * @property string|null $shipping_city
* @property int $shipping_country_id * @property int $shipping_country_id
* @property string|null $shipping_phone * @property string|null $shipping_phone
* @property \Illuminate\Support\Carbon|null $created_at * @property Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $Shopping_orders * @property-read Collection|ShoppingOrder[] $Shopping_orders
* @property-read int|null $shopping_orders_count * @property-read int|null $shopping_orders_count
* @property-read \App\Models\Country $billing_country * @property-read Country $billing_country
* @property-read \App\Models\Country $shipping_country * @property-read Country $shipping_country
* *
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newQuery()
@ -73,8 +77,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereUpdatedAt($value)
* *
* @property int|null $orders * @property int|null $orders
* @property-read \App\Models\ShoppingOrder $shopping_order * @property-read ShoppingOrder $shopping_order
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders * @property-read Collection|ShoppingOrder[] $shopping_orders
* *
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereOrders($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereOrders($value)
* *
@ -86,8 +90,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $number * @property int|null $number
* @property bool $is_like * @property bool $is_like
* @property array|null $notice * @property array|null $notice
* @property-read \App\User|null $auth_user * @property-read User|null $auth_user
* @property-read \App\User|null $member * @property-read User|null $member
* *
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereIsLike($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereIsLike($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereMemberId($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereMemberId($value)
@ -104,7 +108,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderDate($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderDate($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderNumber($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderNumber($value)
* *
* @property \Illuminate\Support\Carbon|null $deleted_at * @property Carbon|null $deleted_at
* @property string|null $user_deleted_at * @property string|null $user_deleted_at
* *
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingUser onlyTrashed() * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingUser onlyTrashed()
@ -172,6 +176,7 @@ class ShoppingUser extends Model
'billing_country_id', 'billing_country_id',
'billing_phone', 'billing_phone',
'billing_email', 'billing_email',
'stripe_customer_id',
'faker_mail', 'faker_mail',
'shipping_email', 'shipping_email',
'accepted_data_checkbox', 'accepted_data_checkbox',

View file

@ -9,6 +9,7 @@ use App\Models\UserAccount;
use App\Models\UserBusiness; use App\Models\UserBusiness;
use App\Models\UserShop; use App\Models\UserShop;
use App\Models\UserUpdateEmail; use App\Models\UserUpdateEmail;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\User; use App\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -27,6 +28,8 @@ use Illuminate\Support\Facades\Storage;
* Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt. * Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt.
* - Provisions-Snapshots (user_businesses) behalten die Zahlen, die * - Provisions-Snapshots (user_businesses) behalten die Zahlen, die
* denormalisierten Personendaten werden anonymisiert. * denormalisierten Personendaten werden anonymisiert.
* - Stripe-Customer-Objekte werden gelöscht (Phase SE1.6, dev/2026-08-26-stripe/);
* die Charges bleiben bei Stripe erhalten — sie sind Buchungsbelege.
*/ */
class GdprDeletionService class GdprDeletionService
{ {
@ -54,6 +57,13 @@ class GdprDeletionService
$originalEmail = $user->email; $originalEmail = $user->email;
// Vor der Transaktion einsammeln: die IDs sind danach genullt, und der
// Stripe-Aufruf gehört nicht in eine offene DB-Transaktion.
$stripeCustomerIds = $this->collectStripeCustomerIds(
$user,
ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get()
);
$summary = DB::transaction(function () use ($user) { $summary = DB::transaction(function () use ($user) {
$summary = ['user_id' => $user->id]; $summary = ['user_id' => $user->id];
@ -85,6 +95,8 @@ class GdprDeletionService
return $summary; return $summary;
}); });
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
$this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary); $this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary);
return $summary; return $summary;
@ -114,6 +126,8 @@ class GdprDeletionService
$originalEmail = $shoppingUser->billing_email; $originalEmail = $shoppingUser->billing_email;
$stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows);
$summary = DB::transaction(function () use ($shoppingUser, $rows) { $summary = DB::transaction(function () use ($shoppingUser, $rows) {
$summary = ['shopping_user_id' => $shoppingUser->id]; $summary = ['shopping_user_id' => $shoppingUser->id];
$summary['abos_anonymized'] = 0; $summary['abos_anonymized'] = 0;
@ -125,6 +139,8 @@ class GdprDeletionService
return $summary; return $summary;
}); });
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
$this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary); $this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
return $summary; return $summary;
@ -146,6 +162,7 @@ class GdprDeletionService
protected function anonymizeUserRow(User $user): void protected function anonymizeUserRow(User $user): void
{ {
$user->stripe_customer_id = null;
$user->notes = null; $user->notes = null;
$user->settings = null; $user->settings = null;
$user->payment_methods = null; $user->payment_methods = null;
@ -306,6 +323,7 @@ class GdprDeletionService
protected function anonymizeShoppingUserContactData(ShoppingUser $row): void protected function anonymizeShoppingUserContactData(ShoppingUser $row): void
{ {
$row->stripe_customer_id = null;
$row->billing_phone = null; $row->billing_phone = null;
$row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid'; $row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid';
$row->shipping_phone = null; $row->shipping_phone = null;
@ -339,6 +357,69 @@ class GdprDeletionService
$this->anonymizeShoppingUserContactData($row); $this->anonymizeShoppingUserContactData($row);
} }
/**
* Alle Stripe-Customer-IDs des Betroffenen einsammeln (Berater, Kunden-
* Datensätze und deren Abos) — Phase SE1.6.
*
* @param Collection<int, ShoppingUser>|\Illuminate\Database\Eloquent\Collection<int, ShoppingUser> $shoppingUsers
* @return array<int, string>
*/
protected function collectStripeCustomerIds(?User $user, $shoppingUsers): array
{
$ids = [];
if ($user && ! empty($user->stripe_customer_id)) {
$ids[] = $user->stripe_customer_id;
}
foreach ($shoppingUsers as $row) {
if (! empty($row->stripe_customer_id)) {
$ids[] = $row->stripe_customer_id;
}
}
$aboQuery = UserAbo::withTrashed()->whereNotNull('stripe_customer_id');
$aboQuery->where(function ($query) use ($user, $shoppingUsers) {
$query->whereIn('shopping_user_id', collect($shoppingUsers)->pluck('id')->all());
if ($user) {
$query->orWhere('user_id', $user->id);
}
});
foreach ($aboQuery->pluck('stripe_customer_id') as $customerId) {
$ids[] = $customerId;
}
return array_values(array_unique(array_filter($ids)));
}
/**
* Stripe-Customer-Objekte löschen (Art. 17 DSGVO). Die zugehörigen Charges
* bleiben bei Stripe bestehen (Aufbewahrungspflicht). Fehler werden
* protokolliert, brechen die Löschung im System aber nicht ab — der
* Datenbestand ist zu diesem Zeitpunkt bereits anonymisiert.
*
* @param array<int, string> $customerIds
* @return int Anzahl der bei Stripe tatsächlich gelöschten Customer
*/
protected function deleteStripeCustomers(array $customerIds): int
{
if ($customerIds === []) {
return 0;
}
$service = app(StripeCustomerService::class);
$deleted = 0;
foreach ($customerIds as $customerId) {
if ($service->deleteCustomer($customerId)) {
$deleted++;
}
}
return $deleted;
}
/** /**
* Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten. * Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten.
* *

View file

@ -0,0 +1,177 @@
<?php
namespace App\Services;
use App\Exceptions\ManualPaymentException;
use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Erfasst einen Zahlungseingang, den kein Zahlungsanbieter melden kann
* (Plan dev/2026-08-26-stripe/, Phase SE4).
*
* Anlass (Buchhaltung, Mail 2026-08-26): Bei **Vorkasse über PAYONE** schreiben
* Kunden regelmäßig einen falschen Verwendungszweck auf die Überweisung.
* PAYONE kann den Eingang dann nicht automatisch zuordnen, und die Buchhaltung
* musste die Rechnung bisher von Hand schreiben — der bestehende
* Status-Button setzte nur `paid`, ohne Rechnung, Punkte, Abo-Aktivierung
* oder Mail auszulösen.
*
* Dieser Service läuft durch **denselben** Paid-Pfad wie ein Anbieter-Callback
* ({@see Payment::paymentStatusPaidAction}) und protokolliert den Vorgang
* revisionssicher: Wer hat wann welchen Betrag mit welchem Beleg erfasst.
*/
class ManualPaymentService
{
public const METHOD_BANK_TRANSFER = 'bank_transfer';
public const METHOD_CASH = 'cash';
public const METHOD_OTHER = 'other';
/**
* Zahlungswege für das Erfassungsformular.
*
* @return array<string, string>
*/
public static function methods(): array
{
return [
self::METHOD_BANK_TRANSFER => __('payment.manual_method_bank_transfer'),
self::METHOD_CASH => __('payment.manual_method_cash'),
self::METHOD_OTHER => __('payment.manual_method_other'),
];
}
/**
* Verbucht den Zahlungseingang und löst alle Folgeschritte aus:
* Rechnung, Umsatz/Punkte, Abo-Aktivierung und Bestätigungsmail.
*
* @param array{amount_cents: int, paid_at: string, method: string, bank_reference: string, note?: string|null, send_mail?: bool} $data
*
* @throws ManualPaymentException
*/
public function recordIncomingPayment(
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
array $data,
?User $admin = null,
): PaymentTransaction {
if ((int) $shoppingPayment->shopping_order_id !== (int) $shoppingOrder->id) {
throw new ManualPaymentException(__('payment.manual_error_payment_mismatch'));
}
if ($shoppingOrder->paid) {
throw new ManualPaymentException(__('payment.manual_error_already_paid'));
}
if (in_array($shoppingOrder->txaction, ['cancelled'], true)) {
throw new ManualPaymentException(__('payment.manual_error_cancelled'));
}
$sendLink = false;
$transaction = DB::transaction(function () use ($shoppingOrder, $shoppingPayment, $data, $admin, &$sendLink) {
// Gegen Doppelklick und parallelen Anbieter-Callback absichern —
// gleiches Muster wie im Stripe-/PAYONE-Webhook.
$lockedOrder = ShoppingOrder::where('id', $shoppingOrder->id)->lockForUpdate()->first();
if ($lockedOrder->paid) {
throw new ManualPaymentException(__('payment.manual_error_already_paid'));
}
$transaction = PaymentTransaction::create([
'shopping_payment_id' => $shoppingPayment->id,
'provider' => 'manual',
'request' => 'manual_payment',
'txid' => 0,
'userid' => 0,
'status' => 'MANUAL',
'txaction' => 'paid',
'transmitted_data' => $this->auditPayload($shoppingPayment, $data, $admin),
'mode' => $shoppingPayment->mode,
]);
$shoppingPayment->txaction = 'paid';
$shoppingPayment->save();
$lockedOrder->txaction = 'paid';
$lockedOrder->save();
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $shoppingPayment);
return $transaction;
});
if (! empty($data['send_mail'])) {
Payment::paymentStatusSendMail($shoppingOrder->fresh(), $shoppingPayment, [
'txaction' => 'paid',
'mode' => $shoppingPayment->mode,
'send_link' => $sendLink,
]);
}
$this->logAction($shoppingOrder, $shoppingPayment, $data, $admin);
return $transaction;
}
/**
* Revisionsspur: Was wurde erfasst, von wem, und weicht der Betrag ab.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function auditPayload(ShoppingPayment $shoppingPayment, array $data, ?User $admin): array
{
$amountCents = (int) $data['amount_cents'];
return [
'source' => 'admin_manual_payment',
'amount_cents' => $amountCents,
'expected_cents' => (int) $shoppingPayment->amount,
'amount_matches' => $amountCents === (int) $shoppingPayment->amount,
'paid_at' => $data['paid_at'],
'method' => $data['method'],
'bank_reference' => $data['bank_reference'],
'note' => $data['note'] ?? null,
'recorded_by_user_id' => $admin?->id,
'recorded_by_email' => $admin?->email,
'recorded_at' => now()->toDateTimeString(),
];
}
/**
* @param array<string, mixed> $data
*/
private function logAction(ShoppingOrder $shoppingOrder, ShoppingPayment $shoppingPayment, array $data, ?User $admin): void
{
$message = sprintf(
'Zahlungseingang manuell erfasst: %s (Beleg: %s, Zahlungsweg: %s, Zahlung #%d)',
Util::formatNumber(((int) $data['amount_cents']) / 100).' '.$shoppingPayment->currency,
$data['bank_reference'],
$data['method'],
$shoppingPayment->id,
);
try {
SysLog::action('manual-payment', 'payment', 3)
->setModel($shoppingOrder->id, ShoppingOrder::class)
->setUserId($admin?->id)
->setMessage($message)
->save();
} catch (\Throwable $e) {
// Das Protokoll darf die Buchung nicht kippen — sie ist bereits committed.
Log::channel('payment')->error('SysLog für manuellen Zahlungseingang fehlgeschlagen: '.$e->getMessage(), [
'shopping_order_id' => $shoppingOrder->id,
]);
}
Log::channel('payment')->info($message, [
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'recorded_by_user_id' => $admin?->id,
]);
}
}

View file

@ -11,6 +11,7 @@ use App\Models\UserLevel;
use App\Repositories\InvoiceRepository; use App\Repositories\InvoiceRepository;
use App\Services\BusinessPlan\SalesPointsVolume; use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker; use App\Services\Incentive\IncentiveTracker;
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
use App\User; use App\User;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@ -328,6 +329,15 @@ class Payment
]); ]);
} }
} }
/*
* Rechnungsnummer an die Stripe-Zahlung nachtragen (Plan
* dev/2026-08-26-stripe/, SE3). Läuft asynchron und best effort —
* eine bezahlte Bestellung bleibt bezahlt, auch wenn Stripe gerade
* nicht erreichbar ist.
*/
$shopping_order->refresh();
StripeInvoiceReferenceService::dispatchForOrder($shopping_order, $shopping_payment);
} }
return $send_link; return $send_link;

View file

@ -3,12 +3,19 @@
namespace App\Services\Payment\Stripe; namespace App\Services\Payment\Stripe;
use App\Models\Customer; use App\Models\Customer;
use App\Models\ShoppingUser;
use App\User; use App\User;
use Illuminate\Support\Facades\Log;
/** /**
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die * Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
* ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent: * ID (users.stripe_customer_id / customers.stripe_customer_id /
* eine vorhandene ID wird nie überschrieben. * shopping_users.stripe_customer_id). Idempotent: eine vorhandene ID wird
* nie überschrieben.
*
* Phase SE1 (dev/2026-08-26-stripe/): Der Checkout hängt den Customer an die
* Session, damit Name und Anschrift in Stripe-Zahlungsliste und CSV-Berichten
* stehen (Stripe-Empfehlung "Option A", Mail Mehmet Ergün 2026-08-26).
*/ */
class StripeCustomerService class StripeCustomerService
{ {
@ -33,4 +40,139 @@ class StripeCustomerService
return $customer->id; return $customer->id;
} }
/**
* Stripe-Customer des Endkunden aus dem Checkout — mit Rechnungsname und
* -anschrift. Ein vorhandener Customer wird wiederverwendet und dabei mit
* den aktuellen Rechnungsdaten aktualisiert (der Kunde kann seine Anschrift
* zwischen zwei Bestellungen geändert haben).
*/
public function customerIdForShoppingUser(ShoppingUser $shoppingUser): string
{
$payload = $this->customerPayloadFor($shoppingUser);
if (! empty($shoppingUser->stripe_customer_id)) {
$this->updateCustomer($shoppingUser->stripe_customer_id, $payload);
return $shoppingUser->stripe_customer_id;
}
$customer = $this->stripe->client()->customers->create($payload);
$shoppingUser->forceFill(['stripe_customer_id' => $customer->id])->save();
return $customer->id;
}
/**
* Aktualisiert Name/Anschrift eines bestehenden Customers — best effort.
* Ein Fehler hier darf einen laufenden Checkout niemals kippen; die
* Zahlung funktioniert auch mit veralteten Stammdaten am Customer.
*
* @param array<string, mixed> $payload
*/
public function updateCustomer(string $customerId, array $payload): void
{
try {
$this->stripe->client()->customers->update($customerId, $payload);
} catch (\Throwable $e) {
Log::channel('stripe')->warning('Stripe-Customer konnte nicht aktualisiert werden: '.$e->getMessage(), [
'customer' => $customerId,
]);
}
}
/**
* Löscht einen Stripe-Customer (DSGVO Art. 17, Phase SE1.6). Die Charges
* bleiben bei Stripe erhalten — sie unterliegen der Aufbewahrungspflicht.
* Best effort: ein Fehler darf die Löschung im System nicht blockieren.
*/
public function deleteCustomer(string $customerId): bool
{
try {
$this->stripe->client()->customers->delete($customerId);
return true;
} catch (\Throwable $e) {
Log::channel('stripe')->warning('Stripe-Customer konnte nicht gelöscht werden: '.$e->getMessage(), [
'customer' => $customerId,
]);
return false;
}
}
/**
* Stripe-Customer-Payload aus den Rechnungsdaten eines Endkunden.
*
* @return array<string, mixed>
*/
public function customerPayloadFor(ShoppingUser $shoppingUser): array
{
$payload = [
'name' => self::customerNameFor($shoppingUser),
'email' => $shoppingUser->billing_email ?: null,
'metadata' => [
'mivita_type' => 'shopping_user',
'mivita_id' => (string) $shoppingUser->id,
],
];
if ($shoppingUser->member_id) {
$payload['metadata']['mivita_member_id'] = (string) $shoppingUser->member_id;
}
if ($shoppingUser->number) {
$payload['metadata']['mivita_customer_number'] = (string) $shoppingUser->number;
}
if ($shoppingUser->billing_phone) {
$payload['phone'] = $shoppingUser->billing_phone;
}
$address = self::billingAddressFor($shoppingUser);
if ($address !== null) {
$payload['address'] = $address;
}
return $payload;
}
/**
* Anzeigename für Stripe: "Vorname Nachname (Firma)".
*
* Der Personenname steht vorn, weil im System und in der Kommunikation
* über den Namen gearbeitet wird — Firmennamen kennt kaum jemand
* (Stakeholder-Entscheid 2026-08-26). Reine Firmenkunden ohne
* Personennamen erscheinen unter der Firma.
*/
public static function customerNameFor(ShoppingUser $shoppingUser): ?string
{
$company = trim((string) $shoppingUser->billing_company);
$person = trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? ''));
if ($person === '') {
return $company !== '' ? $company : null;
}
return $company !== '' ? $person.' ('.$company.')' : $person;
}
/**
* Rechnungsanschrift im Stripe-Format; null, wenn keine Straße hinterlegt ist.
*
* @return array<string, string|null>|null
*/
private static function billingAddressFor(ShoppingUser $shoppingUser): ?array
{
if (empty($shoppingUser->billing_address)) {
return null;
}
return [
'line1' => $shoppingUser->billing_address,
'line2' => $shoppingUser->billing_address_2 ?: null,
'postal_code' => $shoppingUser->billing_zipcode ?: null,
'city' => $shoppingUser->billing_city ?: null,
'country' => $shoppingUser->billing_country->code ?? null,
];
}
} }

View file

@ -0,0 +1,114 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Jobs\SyncStripeInvoiceReference;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserInvoice;
use App\Services\Invoice;
use Illuminate\Support\Facades\Log;
/**
* Trägt die MIVITA-Rechnungsnummer nachträglich an der Stripe-Zahlung ein
* (Plan dev/2026-08-26-stripe/, Phase SE3).
*
* Warum nachträglich: Die Rechnungsnummer kommt aus einem fortlaufenden
* Nummernkreis ({@see Invoice::makeNextInvoiceNumber}) und wird
* erst NACH erfolgreicher Zahlung vergeben. Eine Vorab-Reservierung würde bei
* jedem abgebrochenen Checkout eine Lücke im Rechnungsnummernkreis erzeugen —
* das ist buchhalterisch (GoBD) nicht zulässig.
*
* Stripe erlaubt es, Metadaten und `description` eines PaymentIntents
* jederzeit zu ändern. Genau das passiert hier — damit die Buchhaltung die
* Zahlung im Dashboard über die Rechnungsnummer findet und die Nummer als
* Spalte in den Bilanz-/Auszahlungsberichten exportieren kann.
*/
class StripeInvoiceReferenceService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
/**
* Stößt die Übertragung für eine Bestellung an — ohne den Aufrufer zu
* gefährden. Wird aus dem Paid-Pfad und aus der manuellen
* Rechnungserstellung im Admin aufgerufen.
*/
public static function dispatchForOrder(ShoppingOrder $shoppingOrder, ?ShoppingPayment $shoppingPayment = null): void
{
try {
$userInvoice = $shoppingOrder->user_invoice;
if (! $userInvoice) {
return;
}
$payment = $shoppingPayment ?: $shoppingOrder->shopping_payments()
->where('payment_provider', 'stripe')
->latest('id')
->first();
if (! self::isSyncable($payment)) {
return;
}
SyncStripeInvoiceReference::dispatch($payment, $userInvoice);
} catch (\Throwable $e) {
// Der Paid-Pfad darf hieran niemals scheitern.
Log::channel('stripe')->warning('Rechnungsnummer-Sync konnte nicht eingeplant werden: '.$e->getMessage(), [
'shopping_order_id' => $shoppingOrder->id,
]);
}
}
/**
* Nur Stripe-Zahlungen mit bekanntem PaymentIntent lassen sich ergänzen.
* Solange nur die Session-ID (`cs_…`) bekannt ist, gibt es kein Objekt,
* an dem die Nummer dauerhaft sichtbar wäre.
*/
public static function isSyncable(?ShoppingPayment $payment): bool
{
return $payment !== null
&& $payment->payment_provider === 'stripe'
&& str_starts_with((string) $payment->external_reference, 'pi_');
}
/**
* Schreibt Rechnungsnummer und -datum in die Metadaten des PaymentIntents
* und ergänzt die `description` um die Rechnungsnummer.
*
* Idempotent: steht die Nummer schon dort, passiert nichts.
*/
public function sync(ShoppingPayment $shoppingPayment, UserInvoice $userInvoice): bool
{
if (! self::isSyncable($shoppingPayment)) {
return false;
}
$client = $this->stripe->client();
$paymentIntent = $client->paymentIntents->retrieve($shoppingPayment->external_reference);
$existing = $paymentIntent->metadata?->toArray() ?? [];
if (($existing['invoice_number'] ?? null) === $userInvoice->full_number) {
return false;
}
$order = $shoppingPayment->shopping_order;
$client->paymentIntents->update($shoppingPayment->external_reference, [
'metadata' => StripeMetadataBuilder::withInvoice($existing, $userInvoice),
'description' => StripeMetadataBuilder::description(
$order,
(string) $shoppingPayment->reference,
$order?->shopping_user,
),
]);
Log::channel('stripe')->info('Rechnungsnummer an Stripe übertragen.', [
'shopping_payment_id' => $shoppingPayment->id,
'payment_intent' => $shoppingPayment->external_reference,
'invoice_number' => $userInvoice->full_number,
]);
return true;
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserInvoice;
/**
* Einzige Quelle für die Metadaten und die `description`, die MIVITA an
* Stripe-Objekte hängt (Phase SE2, dev/2026-08-26-stripe/).
*
* Zweck (Mail Buchhaltung + Stripe 2026-08-26): Jede Zahlung muss im
* Stripe-Dashboard über Bestellnummer, Referenz oder Rechnungsnummer
* auffindbar und als CSV-Spalte exportierbar sein.
*
* - **Metadaten** sind über die Dashboard-Suche findbar (`metadata:<wert>`)
* und lassen sich als Spalten in Bilanz-/Auszahlungsberichte aufnehmen.
* - **`description`** ist das Feld, das die Zahlungsliste anzeigt und das die
* Standardsuche ohne Filter durchsucht.
*
* Stripe-Limits: max. 50 Schlüssel, Wert max. 500 Zeichen. Leere Werte werden
* entfernt, damit keine "null"-Strings in den Berichten landen.
*/
class StripeMetadataBuilder
{
public const MAX_VALUE_LENGTH = 500;
public const MAX_DESCRIPTION_LENGTH = 350;
public const TYPE_SHOP = 'shop';
public const TYPE_ABO = 'abo';
public const TYPE_ABO_RECOVERY = 'abo_recovery';
public const TYPE_ABO_CHARGE = 'abo_charge';
public const TYPE_PAYMENT_METHOD_CHANGE = 'payment_method_change';
/**
* Einmalzahlung im Checkout (Session + PaymentIntent).
*
* @return array<string, string>
*/
public static function forCheckout(
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
ShoppingUser $shoppingUser,
string $reference,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::orderType($shoppingOrder),
'user_abo_id' => $shoppingOrder->recovery_user_abo_id,
'customer_name' => StripeCustomerService::customerNameFor($shoppingUser),
'customer_number' => $shoppingUser->number,
'member_id' => $shoppingUser->member_id,
'invoice_number' => self::invoiceNumber($shoppingOrder),
]);
}
/**
* Wiederkehrende Abo-Abbuchung (off-session PaymentIntent).
*
* @return array<string, string>
*/
public static function forAboCharge(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
?ShoppingUser $shoppingUser,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $shoppingPayment->reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::TYPE_ABO_CHARGE,
'user_abo_id' => $userAbo->id,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
'customer_number' => $shoppingUser?->number,
'member_id' => $shoppingUser?->member_id,
]);
}
/**
* Setup-Session "Zahlungsart ändern". Die Schlüssel `user_abo_id` und
* `source` wertet der Webhook aus (StripeWebhookHandler) — sie dürfen
* nicht umbenannt werden.
*
* @return array<string, string>
*/
public static function forSetupSession(UserAbo $userAbo, ?string $source): array
{
$shoppingUser = $userAbo->shopping_user;
return self::sanitize([
'user_abo_id' => $userAbo->id,
'source' => $source,
'order_type' => self::TYPE_PAYMENT_METHOD_CHANGE,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
]);
}
/**
* Lesbarer Text für die Stripe-Zahlungsliste (und die Standardsuche).
*/
public static function description(ShoppingOrder $shoppingOrder, string $reference, ?ShoppingUser $shoppingUser): string
{
$parts = ['Bestellung #'.$shoppingOrder->id, $reference];
$name = $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null;
if ($name) {
$parts[] = $name;
}
$invoiceNumber = self::invoiceNumber($shoppingOrder);
if ($invoiceNumber) {
$parts[] = 'Rechnung '.$invoiceNumber;
}
return mb_substr(implode(' · ', $parts), 0, self::MAX_DESCRIPTION_LENGTH);
}
/**
* Ergänzt ein bestehendes Metadaten-Set um die Rechnungsdaten
* (Phase SE3: die Rechnungsnummer entsteht erst nach der Zahlung).
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function withInvoice(array $metadata, UserInvoice $userInvoice): array
{
return self::sanitize($metadata + [
'invoice_number' => $userInvoice->full_number,
'invoice_date' => $userInvoice->getDateRaw(),
]);
}
/**
* Wirft leere Werte weg, castet auf String und kappt auf das Stripe-Limit.
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function sanitize(array $metadata): array
{
$result = [];
foreach ($metadata as $key => $value) {
if ($value === null || $value === '' || $value === 0) {
continue;
}
$result[$key] = mb_substr((string) $value, 0, self::MAX_VALUE_LENGTH);
}
return $result;
}
private static function orderType(ShoppingOrder $shoppingOrder): string
{
if (! empty($shoppingOrder->recovery_user_abo_id)) {
return self::TYPE_ABO_RECOVERY;
}
return $shoppingOrder->is_abo ? self::TYPE_ABO : self::TYPE_SHOP;
}
private static function invoiceNumber(ShoppingOrder $shoppingOrder): ?string
{
return $shoppingOrder->user_invoice?->full_number;
}
}

View file

@ -11,6 +11,7 @@ use App\Services\CheckoutFunnelTracker;
use App\Services\Payment\DTOs\AboChargeResult; use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult; use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
use App\Services\Util; use App\Services\Util;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session; use Stripe\Checkout\Session;
use Stripe\Exception\ApiErrorException; use Stripe\Exception\ApiErrorException;
@ -27,7 +28,10 @@ use Stripe\Exception\ApiErrorException;
*/ */
class StripePaymentService class StripePaymentService
{ {
public function __construct(private readonly StripeClientWrapper $stripe) {} public function __construct(
private readonly StripeClientWrapper $stripe,
private readonly StripeCustomerService $customerService,
) {}
/** /**
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung. * Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
@ -70,17 +74,12 @@ class StripePaymentService
amountCents: $amountCents, amountCents: $amountCents,
); );
$metadata = [ $metadata = StripeMetadataBuilder::forCheckout($shoppingOrder, $shoppingPayment, $shoppingUser, $reference);
'shopping_order_id' => (string) $shoppingOrder->id, $description = StripeMetadataBuilder::description($shoppingOrder, $reference, $shoppingUser);
'shopping_payment_id' => (string) $shoppingPayment->id,
'reference' => $reference,
'mode' => (string) $shoppingOrder->mode,
];
$sessionParams = [ $sessionParams = [
'mode' => 'payment', 'mode' => 'payment',
'client_reference_id' => $reference, 'client_reference_id' => $reference,
'customer_email' => $shoppingUser->billing_email ?: null,
'line_items' => [[ 'line_items' => [[
'quantity' => 1, 'quantity' => 1,
'price_data' => [ 'price_data' => [
@ -92,13 +91,28 @@ class StripePaymentService
], ],
]], ]],
'metadata' => $metadata, 'metadata' => $metadata,
'payment_intent_data' => ['metadata' => $metadata], 'payment_intent_data' => [
'metadata' => $metadata,
'description' => $description,
],
'success_url' => $successUrl, 'success_url' => $successUrl,
'cancel_url' => $cancelUrl, 'cancel_url' => $cancelUrl,
]; ];
$customerId = $this->resolveCheckoutCustomer($shoppingUser);
if ($customerId !== null) {
$sessionParams['customer'] = $customerId;
} else {
// Fallback wie vor SE1: ohne Customer-Objekt bleibt wenigstens die
// E-Mail erhalten, und Abos bekommen ihren Customer von Stripe.
$sessionParams['customer_email'] = $shoppingUser->billing_email ?: null;
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
$sessionParams['customer_creation'] = 'always';
}
}
if ($this->requiresReusablePaymentMethod($shoppingOrder)) { if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
$sessionParams['customer_creation'] = 'always';
$sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session'; $sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session';
} }
@ -121,6 +135,28 @@ class StripePaymentService
); );
} }
/**
* Stripe-Customer für den Checkout (Phase SE1): trägt Rechnungsname und
* -anschrift, damit die Buchhaltung den Kunden in Zahlungsliste und
* CSV-Berichten sieht.
*
* Bewusst fehlertolerant: Ist Stripe beim Anlegen des Customers gerade
* nicht erreichbar, darf der Checkout NICHT scheitern — der Aufrufer
* fällt dann auf den bisherigen Weg (customer_email) zurück.
*/
private function resolveCheckoutCustomer(ShoppingUser $shoppingUser): ?string
{
try {
return $this->customerService->customerIdForShoppingUser($shoppingUser);
} catch (\Throwable $e) {
Log::channel('stripe')->warning('Stripe-Customer für Checkout nicht verfügbar — Fallback auf customer_email: '.$e->getMessage(), [
'shopping_user_id' => $shoppingUser->id,
]);
return null;
}
}
/** /**
* Abo-/Recovery-Bestellungen brauchen ein wiederverwendbares Zahlungsmittel * Abo-/Recovery-Bestellungen brauchen ein wiederverwendbares Zahlungsmittel
* (Erstzahlung eines neuen Abos bzw. Nachholzahlung, die das Abo auf * (Erstzahlung eines neuen Abos bzw. Nachholzahlung, die das Abo auf
@ -170,13 +206,7 @@ class StripePaymentService
'stripe_payment_method_id' => $userAbo->stripe_payment_method_id, 'stripe_payment_method_id' => $userAbo->stripe_payment_method_id,
]); ]);
$metadata = [ $metadata = StripeMetadataBuilder::forAboCharge($userAbo, $shoppingOrder, $shoppingPayment, $shoppingUser);
'shopping_order_id' => (string) $shoppingOrder->id,
'shopping_payment_id' => (string) $shoppingPayment->id,
'user_abo_id' => (string) $userAbo->id,
'reference' => (string) $shoppingPayment->reference,
'mode' => (string) $shoppingOrder->mode,
];
try { try {
$paymentIntent = $this->stripe->client()->paymentIntents->create([ $paymentIntent = $this->stripe->client()->paymentIntents->create([
@ -187,6 +217,7 @@ class StripePaymentService
'off_session' => true, 'off_session' => true,
'confirm' => true, 'confirm' => true,
'metadata' => $metadata, 'metadata' => $metadata,
'description' => StripeMetadataBuilder::description($shoppingOrder, (string) $shoppingPayment->reference, $shoppingUser),
], [ ], [
'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id, 'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id,
]); ]);
@ -243,10 +274,7 @@ class StripePaymentService
): Session { ): Session {
$customerId = $this->ensureAboStripeCustomer($userAbo); $customerId = $this->ensureAboStripeCustomer($userAbo);
$metadata = [ $metadata = StripeMetadataBuilder::forSetupSession($userAbo, $source);
'user_abo_id' => (string) $userAbo->id,
'source' => (string) ($source ?? ''),
];
return $this->stripe->client()->checkout->sessions->create([ return $this->stripe->client()->checkout->sessions->create([
'mode' => 'setup', 'mode' => 'setup',
@ -274,13 +302,15 @@ class StripePaymentService
} }
$shoppingUser = $userAbo->shopping_user; $shoppingUser = $userAbo->shopping_user;
$customer = $this->stripe->client()->customers->create([
'email' => $userAbo->email ?: null, $payload = $shoppingUser
'name' => $shoppingUser ? $this->customerService->customerPayloadFor($shoppingUser)
? (trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? '')) ?: null) : ['name' => null, 'metadata' => []];
: null, $payload['email'] = $userAbo->email ?: ($payload['email'] ?? null);
'metadata' => ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id], $payload['metadata'] = ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id]
]); + ($payload['metadata'] ?? []);
$customer = $this->stripe->client()->customers->create($payload);
$userAbo->forceFill(['stripe_customer_id' => $customer->id])->save(); $userAbo->forceFill(['stripe_customer_id' => $customer->id])->save();

View file

@ -39,6 +39,17 @@ class StripeWebhookHandler
'setup_intent.succeeded', 'setup_intent.succeeded',
]; ];
/**
* Eventtypen, zu denen es regulaer keine Zahlung bei uns geben muss.
* Ein abgebrochener Checkout laeuft bei Stripe ab, ohne dass jemals eine
* Zahlung entstanden ist - das ist kein Fehlerfall.
*
* @var string[]
*/
private const EVENT_TYPES_WITHOUT_PAYMENT = [
'checkout.session.expired',
];
/** /**
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten * Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren. * Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
@ -137,6 +148,27 @@ class StripeWebhookHandler
return true; return true;
} }
/**
* Verarbeitet ein aktiv von der Stripe-API geholtes Objekt so, als wäre es
* per Webhook gekommen (Plan S6.4, `stripe:sync-payment-status`).
*
* Nötig für Events, die NIE zugestellt wurden — dafür greift weder der
* Stripe-Retry noch `stripe:process-pending-events`, das nur auf
* gespeicherten Events arbeitet. Die Seiteneffekte sind dieselben und
* bleiben idempotent: der Paid-Pfad prüft `paid` unter `lockForUpdate()`.
*
* @param array<string, mixed> $object Stripe-Objekt als Array
* @return bool false = keiner Zahlung zuordenbar
*/
public function syncFromApiObject(string $eventType, array $object, string $source = 'api-sync'): bool
{
if (! in_array($eventType, self::HANDLED_TYPES, true)) {
return false;
}
return $this->dispatchEvent($source, $eventType, $object);
}
/** /**
* @param array<string, mixed> $object * @param array<string, mixed> $object
* @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern * @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern
@ -178,7 +210,14 @@ class StripeWebhookHandler
return false; return false;
} }
Log::channel('stripe')->warning(sprintf( /*
* Abgebrochene Checkouts laufen bei Stripe irgendwann ab. Zu so einer
* Session hat es nie eine Zahlung gegeben — das ist der Normalfall und
* keine Warnung wert. Alle anderen Eventtypen setzen eine Zahlung voraus.
*/
$isExpectedWithoutPayment = in_array($eventType, self::EVENT_TYPES_WITHOUT_PAYMENT, true);
Log::channel('stripe')->{$isExpectedWithoutPayment ? 'info' : 'warning'}(sprintf(
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)', 'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
$eventId, $eventId,
$eventType, $eventType,

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Stripe-Erweiterung Phase SE1 (dev/2026-08-26-stripe/):
* Der Checkout hängt künftig einen Stripe-Customer MIT Namen an die Session,
* damit Kundenname und Anschrift in Zahlungsliste und CSV-Berichten stehen.
* Die ID wird je Kunde einmal vergeben und wiederverwendet (Muster:
* users.stripe_customer_id / user_abos.stripe_customer_id aus Phase S1).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_users', function (Blueprint $table) {
if (! Schema::hasColumn('shopping_users', 'stripe_customer_id')) {
$table->string('stripe_customer_id')->nullable()->index()->after('billing_email');
}
});
}
public function down(): void
{
Schema::table('shopping_users', function (Blueprint $table) {
if (Schema::hasColumn('shopping_users', 'stripe_customer_id')) {
$table->dropColumn('stripe_customer_id');
}
});
}
};

View file

@ -0,0 +1,615 @@
# Entwicklungsplan: Stripe-Erweiterung (Buchhaltung, Zahlungseingänge, Storno/Gutschrift)
> **Status:** In Umsetzung — **SE1–SE4 ✅ und S6.4 (Vorgänger-Plan) ✅, alles 2026-08-26**; offen: SE5 (Teilstorno/Gutschrift) und SE6 (Berichte) · **Erstellt:** 2026-08-26 · **Grundlage:** `stripe-erweiterung.md` (Mail 1 Kundin/Buchhaltung, Mail 2 Mehmet Ergün, Stripe)
>
> **Begleitdokument:** `GO-LIVE-CHECKLISTE.md` (Rollout SE1–SE4 + S6.4 auf den Live-Server)
> **Vorgänger-Plan:** `dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md` (S0–S5 ✓, S7.4/S7.6 ✓, live seit 2026-07-10).
> Dieser Plan setzt die dort offen gebliebenen Punkte **S7.2 (Refunds)**, **S7.3 (Admin-Detailansichten)**
> und **S9 (Rückbuchungen & Stornierungen)** fort und ergänzt sie um die neuen Anforderungen
> aus den beiden Mails. Alte Phasennummern bleiben gültig, die neuen heißen **SE1–SE6**.
## Dokumentations-Hinweis (verbindlich, wie im Vorgänger-Plan)
- Phasenweise abarbeiten; **Umsetzungsstatus** je Phase auf `ERLEDIGT` setzen (Datum, Dateien, Begründung).
- Abweichungen und Verworfenes dokumentieren.
- Jede Änderung **programmatisch testen** (Pest), danach `./vendor/bin/pint`.
- PAYONE-Pfad (Vorkasse) bleibt in jeder Phase unverändert und grün.
---
## 1. Ausgangslage
Stripe ist seit 2026-07-10 live und wickelt das Tagesgeschäft ab (Einmalzahlungen + Abos).
PAYONE läuft nur noch als **Vorkasse** und für Bestandsabos.
Aus dem Stripe-Zoom-Call und der Buchhaltung kommen sechs Anforderungen. Die folgende Tabelle
ordnet jede Anforderung dem Ist-Zustand im Code zu:
| # | Anforderung (Quelle) | Ist-Zustand | Phase |
|---|----------------------|-------------|-------|
| 1 | **Kundenname fehlt in Stripe** (Mail 1 + Mail 2 §1) | `initiateCheckoutPayment()` übergibt nur `customer_email` ([StripePaymentService.php:83](app/Services/Payment/Stripe/StripePaymentService.php#L83)); `customer_creation: 'always'` (nur bei Abos, [:101](app/Services/Payment/Stripe/StripePaymentService.php#L101)) legt einen Customer **ohne Namen** an. Der vorhandene `StripeCustomerService` (setzt Namen) wird im Checkout **nicht** benutzt. | **SE1** |
| 2 | **Bestell-/Referenznummer suchbar machen** (Mail 1) | Metadaten `shopping_order_id`, `shopping_payment_id`, `reference`, `mode` liegen an Session + PaymentIntent ([:71–95](app/Services/Payment/Stripe/StripePaymentService.php#L71)). Kein `description` am PaymentIntent → in der Dashboard-Zahlungsliste steht keine lesbare Referenz. | **SE2** |
| 3 | **Rechnungsnummer an Stripe** (Mail 1 + Mail 2 §2) | Die Rechnungsnummer entsteht **erst nach** der Zahlung im Paid-Pfad (`Payment::paymentStatusPaidAction` → `InvoiceRepository::createAndSalesVolume`, [Payment.php:192](app/Services/Payment.php#L192)). Zum Zeitpunkt der Session-Erstellung existiert sie nicht. | **SE3** |
| 4 | **Rechnungen selbst auf „bezahlt" stellen** (Mail 1, Fall Mathis 288,50 € / 13.06.2026) | Der Status-Button erscheint nur bei `clearingtype = 'fnc'` **und** `onlinebanktransfertype = 'MIV'` ([_detail.blade.php:697](resources/views/admin/sales/_detail.blade.php#L697)). `store_txaction` setzt `paid = true`, erzeugt aber **keine Rechnung, keine Punkte, keine Abo-Aktivierung, keine Mail** ([SalesController.php:371](app/Http/Controllers/SalesController.php#L371)) — deshalb muss die Buchhaltung die Rechnung von Hand schreiben. | **SE4** |
| 5 | **Teilstornierungen & Gutschriften** (Mail 1) | `createCancellation()` storniert **nur vollständig** ([InvoiceRepository.php:273](app/Repositories/InvoiceRepository.php#L273)). Stripe-Refunds sind nicht implementiert; `charge.refunded` wird nur protokolliert ([StripeWebhookHandler.php:220](app/Services/Payment/Stripe/StripeWebhookHandler.php#L220)). `OrderWithdrawal` kennt `TYPE_PARTIAL` + `items`, löst aber keine Erstattung aus. | **SE5** |
| 6 | **Berichte für die Buchhaltung** (Mail 2 §3) | Kein Code betroffen — Konfiguration im Stripe-Dashboard + Abstimmung, welche Metadaten-Spalten die Buchhaltung braucht (Rückkopplung auf SE2/SE3). | **SE6** |
### Verifizierte Stripe-Fakten (Recherche 2026-08-26)
- **Dashboard-Suche** findet Metadaten bereits heute (`metadata:<wert>`, `metadata:key=wert` oder der
nackte Wert) sowie standardmäßig die **`description`** eines Objekts. → Anforderung 2 ist zu 80 %
eine Frage der `description`, nicht der Metadaten.
- **Kundenname:** zwei Wege, beide von Stripe bestätigt.
- *Option A* — Customer-Objekt mit `name` anlegen und per `customer` an die Session hängen.
Wirkt in Zahlungsliste **und** in Balance-/Payout-Reports (Spalten `customer_name`, `customer_email`).
- *Option B* — `name_collection[individual][enabled]=true` in der Session: Stripe fragt den Namen
im Checkout ab (zusätzliches Pflicht-/Optionalfeld, landet in `collected_information.individual_name`).
- **Metadaten-Limits:** max. 50 Keys, Key ≤ 40 Zeichen, Wert ≤ 500 Zeichen, keine eckigen Klammern,
keine sensiblen Daten.
- **`customer` und `customer_email` schließen sich gegenseitig aus**; ebenso `customer` und
`customer_creation`. Adress-/Namensrückschreibung aus dem Checkout braucht `customer_update[name]=auto`.
---
## 2. Phasen-Übersicht
| Phase | Titel | Aufwand | Priorität | Abhängigkeit |
|-------|-------|---------|-----------|--------------|
| **SE1** | Kundendaten (Name/Adresse) an Stripe übergeben | 2–3 PT | **P1** | – |
| **SE2** | Metadaten & Suchbarkeit vereinheitlichen (`description`, zentraler Builder) | 1–2 PT | **P1** | SE1 (gleicher Code-Pfad) |
| **SE3** | Rechnungsnummer an Stripe nachtragen (+ Backfill) | 2–3 PT | **P1** | SE2 |
| **SE4** | Zahlungseingang manuell erfassen („Rechnung auf bezahlt setzen") | 3–4 PT | **P1** | – (unabhängig) |
| **SE5** | Teilstorno, Gutschrift & Stripe-Refund | 8–12 PT | P2 | SE3 (Rechnungsbezug), Klärung §6 |
| **SE6** | Stripe-Berichte & Buchhaltungs-Abstimmung | 0,5 PT (org.) | P2 | SE2/SE3 |
*Empfohlene Reihenfolge:* **SE1+SE2 in einem Rutsch** (ein einziger Umbau von
`initiateCheckoutPayment`), dann **SE4** (größter Sofort-Nutzen für die Buchhaltung, kein Stripe-Risiko),
dann **SE3**, dann **SE6** (organisatorisch, parallel), zuletzt **SE5**.
---
## 3. Phase SE1 – Kundendaten an Stripe übergeben · Status: **ERLEDIGT ✅** (2026-08-26)
### Ziel
Jede Stripe-Transaktion trägt Name, E-Mail und (soweit vorhanden) Anschrift des Kunden — sichtbar
in Zahlungsliste, Customer-Objekt und in den CSV-Berichten.
### Entscheidung: **Option A** (Customer-Objekt), Option B nur als Ergänzung
| | Option A – Name mitgeben | Option B – Name im Checkout abfragen |
|---|---|---|
| Kunde | tippt nichts zusätzlich | tippt Namen erneut (Conversion-Risiko) |
| Datenqualität | exakt die Rechnungsanschrift aus unserem System | frei eingegeben, kann abweichen |
| Reports | `customer_name`/`customer_email`-Spalten gefüllt | nur `collected_information` |
| Abos | Customer wird ohnehin gebraucht (`setup_future_usage`) | zusätzlich nötig |
→ **Option A.** Option B (`name_collection`) wird **nicht** aktiviert, solange die Rechnungsanschrift
im Checkout ohnehin vollständig erhoben wird. Aufnehmen in §6 als Rückfrage, falls die Buchhaltung
den vom Kunden bei Stripe getippten Namen ausdrücklich will.
### Aufgaben
| # | Aufgabe |
|---|---------|
| SE1.1 | Migration: `shopping_users.stripe_customer_id` (nullable, indiziert) — analog zu `users`/`customers`. |
| SE1.2 | `StripeCustomerService` um `customerIdForShoppingUser(ShoppingUser $u): string` erweitern (idempotent, vorhandene ID nie überschreiben — Muster der bestehenden `customerIdFor()`-Methode). Name = `billing_company` bzw. `billing_firstname + billing_lastname`; zusätzlich `email`, `address`, `phone`, `metadata.mivita_type = 'shopping_user'`. |
| SE1.3 | `StripePaymentService::initiateCheckoutPayment()`: `customer_email` **ersetzen** durch `customer` = Ergebnis aus SE1.2. `customer_creation` entfällt (mit `customer` nicht erlaubt). `setup_future_usage` bleibt unverändert für Abo/Recovery. |
| SE1.4 | `customer_update` bewusst **nicht** setzen: Adressänderungen des Kunden bei Stripe dürfen unsere Rechnungsanschrift nicht überschreiben. Stattdessen: bei jedem Checkout Name/Adresse am Customer aktualisieren, wenn sie sich in unserem System geändert haben (`customers->update`, best effort, Fehler dürfen den Checkout nie kippen). |
| SE1.5 | Bestands-Customer ohne Namen reparieren: Command `stripe:backfill-customers [--dry-run] [--limit=]` — geht über `user_abos.stripe_customer_id` und `shopping_payments.stripe_customer_id` und setzt fehlende `name`/`address` nach. |
| SE1.6 | DSGVO **(verbindlich, F4: „ja, muss dann sogar")**: `GdprDeletionService` löscht heute nur die IDs am Abo ([GdprDeletionService.php:258](app/Services/GdprDeletionService.php#L258)). Ergänzen um `customers->delete()` für alle bekannten Stripe-Customer-IDs des Betroffenen (User, Customer, ShoppingUser, UserAbo) inkl. Leeren der Spalten. Die **Charges bleiben** bei Stripe erhalten (10-jährige Aufbewahrungspflicht) — gelöscht wird nur das Customer-Objekt. Fehler beim Löschen werden protokolliert, brechen die Löschung aber nicht ab. |
| SE1.7 | Fehlerpfad: schlägt die Customer-Anlage fehl, **darf der Checkout nicht scheitern** — Fallback auf das heutige `customer_email` + `customer_creation` und Warnung im `stripe`-Log. |
### Risiken
- **Customer-Wildwuchs:** Ein Customer je `ShoppingUser` statt je Zahlung — durch die persistierte ID
entsteht pro Kunde genau einer. Gastbestellungen legen weiterhin je neuem `ShoppingUser` einen an
(systembedingt, wie heute schon bei Abos).
- **Gespeicherte Zahlungsmittel:** Ein angehängter Customer führt bei Einmalzahlungen **nicht** zur
Speicherung der Zahlart — das passiert nur mit `setup_future_usage`. Verhalten für Nicht-Abo-Kunden
bleibt unverändert.
### Tests
- `initiateCheckoutPayment` übergibt `customer` (nicht `customer_email`), Customer trägt Namen (Client gemockt).
- Zweiter Checkout desselben `ShoppingUser` legt **keinen** zweiten Customer an.
- Customer-Anlage wirft → Session wird trotzdem erstellt (Fallback-Pfad), Warnung geloggt.
- Abo-Checkout: `setup_future_usage` weiterhin gesetzt, `customer_creation` nicht mehr vorhanden.
- Regression: `tests/Feature/StripeCheckoutTest.php`, `StripeAboTest.php` grün.
### Akzeptanzkriterien
- Im Stripe-Dashboard steht bei jeder neuen Zahlung der Kundenname in der Spalte „Kunde".
- Balance-Report-Export enthält gefüllte `customer_name`/`customer_email`-Spalten.
- Suche `name:<Nachname>` findet die Zahlung.
---
## 4. Phase SE2 – Metadaten & Suchbarkeit · Status: **ERLEDIGT ✅** (2026-08-26)
### Ziel
Buchhaltung findet jede Zahlung über Bestellnummer, Referenz oder (nach SE3) Rechnungsnummer —
im Dashboard **und** im CSV-Export.
### Aufgaben
| # | Aufgabe |
|---|---------|
| SE2.1 | Neue Klasse `App\Services\Payment\Stripe\StripeMetadataBuilder` — **eine** Quelle für alle Metadaten (heute dreimal Copy-Paste: Checkout, `chargeAbo`, Setup-Session). Baut das Set und kappt Werte defensiv auf 500 Zeichen. |
| SE2.2 | Metadaten-Set (Session, PaymentIntent, SetupIntent, Refund, Customer): `shopping_order_id`, `shopping_payment_id`, `reference`, `mode`, `order_type` (`shop`/`abo`/`abo_recovery`/`payment_link`), `user_abo_id` (falls Abo), `member_id`/`customer_number`, `customer_name`, `invoice_number` (leer bis SE3). |
| SE2.3 | **`description` am PaymentIntent** setzen — das ist das Feld, das die Dashboard-Zahlungsliste anzeigt und das die Standardsuche durchsucht: `"Bestellung #<order_id> · <Referenz> · <Kundenname>"`. Über `payment_intent_data[description]` in der Session bzw. `description` in `paymentIntents->create()` (Abo-Abbuchung). |
| SE2.4 | `client_reference_id` bleibt die Zahlungsreferenz (unverändert). |
| SE2.5 | Prüfen, ob die Metadaten am **Charge** ankommen (Reports lesen teils den Charge). Falls nicht: beim `charge.succeeded`-Webhook `charges->update()` mit demselben Set nachziehen — **erst nach Messung** entscheiden, nicht auf Verdacht. |
### Tests
- `StripeMetadataBuilder` als Unit-Test: vollständiges Set je Vorgangstyp, Kappung > 500 Zeichen, keine `null`-Werte.
- Checkout- und Abo-Charge-Aufruf enthalten `description` und das komplette Metadaten-Set.
### Akzeptanzkriterien
- Dashboard-Suche nach der Bestellnummer (`123`), der Referenz (`m68a…`) und `metadata:shopping_order_id=123` liefert je genau die richtige Zahlung.
- In der Zahlungsliste steht statt „Bestellung <Referenz>" ein lesbarer Text mit Bestellnummer und Kundenname.
---
### Umsetzungsstatus SE1 + SE2 (ERLEDIGT ✅, 2026-08-26)
Beide Phasen wurden gemeinsam umgesetzt — sie fassen denselben Code-Pfad an.
**Neu**
- `database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php` — Spalte
`shopping_users.stripe_customer_id` (nullable, indiziert), migriert.
- `app/Services/Payment/Stripe/StripeMetadataBuilder.php` — einzige Quelle für Metadaten und
`description`; `sanitize()` wirft leere Werte weg und kappt auf 500 Zeichen.
- `app/Console/Commands/StripeBackfillCustomers.php` (`stripe:backfill-customers`, `--refresh`,
`--dry-run`, `--limit=`) — verknüpft Bestands-Customer (von Stripe namenlos angelegt) mit dem
Kundendatensatz und trägt Name/Anschrift nach; `--refresh` schreibt bereits verknüpfte
Customer neu (z. B. nach einer Änderung des Namensformats).
- `tests/Unit/Services/StripeMetadataBuilderTest.php` (7 Tests).
**Geändert**
- `StripeCustomerService`: neu `customerIdForShoppingUser()`, `customerPayloadFor()`,
`updateCustomer()`, `deleteCustomer()`, `customerNameFor()` (Firma vor Personenname).
Vorhandene IDs werden nie überschrieben, Stammdaten bei jedem Checkout aktualisiert.
- `StripePaymentService`: Konstruktor nimmt zusätzlich den `StripeCustomerService`; die Session
bekommt `customer` statt `customer_email`, `customer_creation` entfällt. `payment_intent_data`
trägt jetzt eine `description`. Metadaten für Checkout, Abo-Abbuchung und Setup-Session kommen
aus dem Builder. `ensureAboStripeCustomer()` nutzt denselben Payload (Adresse inklusive).
- `GdprDeletionService`: sammelt vor der Transaktion alle Stripe-Customer-IDs (User, Kunden,
Abos) ein und löscht sie nach dem Commit (`stripe_customers_deleted` im Summary); die Spalten
werden geleert. Charges bleiben bei Stripe — Buchungsbeleg.
- `ShoppingUser`: `stripe_customer_id` in `$fillable` + Docblock.
**Abweichungen / Entscheidungen**
- **Customer für ALLE Checkouts**, nicht nur für Abos. Nur so steht der Name auch an
Einmalzahlungen — genau der Fall aus Mail 1. Zahlungsmittel werden dadurch nicht gespeichert;
das steuert weiterhin allein `setup_future_usage`.
- **`customer_update` bewusst nicht gesetzt** (SE1.4): Adressänderungen, die der Kunde bei Stripe
vornimmt, dürfen unsere Rechnungsanschrift nicht überschreiben. Die Richtung ist einseitig —
wir schreiben unsere Stammdaten an den Customer.
- **Fallback-Pfad** (SE1.7): Schlägt die Customer-Anlage fehl, läuft der Checkout mit
`customer_email` (+ `customer_creation` bei Abos) weiter wie vor SE1. Getestet.
- **Namensformat „Vorname Nachname (Firma)"** (Stakeholder 2026-08-26, nach der ersten
Testzahlung): Zuerst hatte die Firma Vorrang — im System und in der Kommunikation läuft
aber alles über den Personennamen, Firmennamen kennt kaum jemand. Reine Firmenkunden ohne
Personennamen erscheinen weiterhin unter der Firma. `stripe:backfill-customers --refresh`
schreibt bereits angelegte Customer auf das neue Format um (am Testkonto ausgeführt).
- **SE2.5 (Metadaten am Charge) noch offen** — bewusst nicht auf Verdacht umgesetzt. Wird nach
dem ersten CSV-Export (SE6.4) entschieden.
- `invoice_number` ist im Metadaten-Set bereits vorgesehen, bleibt bis SE3 aber leer (die
Rechnung entsteht erst nach der Zahlung).
**Tests (2026-08-26):** 80 Tests grün —
`StripeCheckoutTest` (23, davon 6 neu für SE1/SE2), `StripeAboTest` (16, zwei Erwartungen an
`customer` statt `customer_creation` angepasst), `StripeWebhookHandlerTest`,
`StripePaymentMethodChangeTest`, `StripeFoundationTest`, `GdprDeletionTest` (9, davon 1 neu),
`StripeMetadataBuilderTest` (7 neu). `./vendor/bin/pint` gelaufen.
### Befund aus der ersten Testzahlung (2026-08-26)
Session `cs_test_a15riIP…` / PaymentIntent `pi_3U8fjVJpkSeWxYO21p3EiwY6`, 31,73 EUR, Testmodus:
- **Session korrekt:** `customer = cus_V8xdNCPqCTbLvl`, `customer_email`/`customer_creation`
entfallen, vollständiges Metadaten-Set (`shopping_order_id=61040`, `shopping_payment_id=58166`,
`reference`, `mode=test`, `order_type=shop`, `customer_name`).
- **Namensformat angepasst** (siehe Abweichungen) — der Testkunde ist Firmenkunde und erschien
zunächst nur als „adametz.media".
- **Webhook kam nicht an — Ursache: falsches Stripe-Konto am CLI.** Die App arbeitet gegen
`acct_1TqsFKJpkSeWxYO2` („mivita care gmbh Sandbox"), das laufende `stripe listen` war noch
am vorherigen Test-Konto angemeldet (erkennbar am Konto-Segment der Event-IDs:
`…JpkSeWxYO2…` vs. `…J8ldXin0nk…`). Die Events existierten bei Stripe, wurden aber nie
zugestellt — kein 400, kein Log-Eintrag.
**Merksatz für das Runbook (S7.5):** `stripe listen --api-key <sk_test des Kontos>` verwenden
und das beim Start ausgegebene `whsec_…` mit `STRIPE_WEBHOOK_SECRET` abgleichen; jedes Konto
hat sein eigenes Secret. Verpasste Events mit `stripe events resend <evt_id>` nachliefern.
- **Bestätigt eine bekannte Lücke:** Für Webhooks, die nie zugestellt wurden, gibt es bisher
kein Reparaturwerkzeug — `stripe:process-pending-events` arbeitet nur auf **gespeicherten**
Events. Der aktive API-Abgleich ist im Vorgänger-Plan als **S6.4 (Rest)** offen und sollte
vorgezogen werden (`stripe:sync-payment-status`).
**Offen vor Live-Schaltung:** Sichtprüfung im Dashboard (Kundenname, `description`, Metadaten,
die vier Suchen) und der CSV-Export (SE6.4), nachdem die Testzahlung über den Webhook
durchgelaufen ist.
---
## 5. Phase SE3 – Rechnungsnummer an Stripe nachtragen · Status: **ERLEDIGT ✅** (2026-08-26)
### Ziel
Die MIVITA-Rechnungsnummer steht als Metadatum an der Stripe-Zahlung und kann als CSV-Spalte
exportiert werden.
### Kernproblem und Entscheidung
Die Rechnungsnummer wird **atomar aus einem fortlaufenden Nummernkreis** vergeben
([Invoice::makeNextInvoiceNumber](app/Services/Invoice.php#L21)) und erst **nach** erfolgreicher Zahlung
im Paid-Pfad erzeugt. Eine Vorab-Reservierung vor der Zahlung ist **ausgeschlossen**: abgebrochene
Checkouts würden Lücken im Rechnungsnummernkreis erzeugen (GoBD-Problem).
→ **Nachtragen statt Vorab-Vergabe.** Nach der Rechnungserstellung wird der PaymentIntent per
API aktualisiert (`metadata.invoice_number`, `metadata.invoice_date`, ergänzte `description`).
Metadaten sind auf PaymentIntents jederzeit änderbar.
### Aufgaben
| # | Aufgabe |
|---|---------|
| SE3.1 | Neuer Job `SyncStripeInvoiceReference` (`ShouldQueue`, Horizon): nimmt `ShoppingPayment` + `UserInvoice`, aktualisiert PaymentIntent-Metadaten und `description`. Idempotent, Fehler werden geloggt, nicht geworfen. |
| SE3.2 | Auslöser: in `Payment::paymentStatusPaidAction()` direkt nach erfolgreicher `createAndSalesVolume()` ([Payment.php:318–331](app/Services/Payment.php#L318)) — **nur** wenn `payment_provider === 'stripe'` und `external_reference` mit `pi_` beginnt. Der Aufruf läuft im bestehenden `try/catch`-Schutz der Rechnungserstellung; der Paid-Pfad darf nie an Stripe scheitern. |
| SE3.3 | Zweiter Auslöser: manuelle Rechnungserstellung im Admin (`SalesController@invoice`) und `InvoiceRepository::update()` — dieselbe Stelle wiederverwenden (ein Service, zwei Aufrufer). |
| SE3.4 | **Pflichtteil (F3: „ja")** — Backfill-Command `stripe:backfill-invoice-metadata [--from=] [--to=] [--dry-run]` für alle Stripe-Zahlungen seit **2026-07-10** (Live-Datum): Rechnungsnummer nachtragen, damit die Buchhaltung auch rückwirkend abstimmen kann. |
| SE3.5 | Stornorechnungen: bei Teil-/Vollstorno (SE5) zusätzlich `metadata.cancellation_invoice_number` setzen. |
### Tests
- Paid-Pfad mit Stripe-Zahlung → PaymentIntent-Update mit `invoice_number` (Client gemockt).
- Paid-Pfad mit PAYONE-Zahlung → **kein** Stripe-Aufruf.
- Stripe-API wirft → Bestellung bleibt bezahlt, Rechnung existiert, Warnung im Log.
- Backfill-Command: `--dry-run` schreibt nichts, ohne Flag genau ein Update je Zahlung.
### Akzeptanzkriterien
- Neue Zahlung: Rechnungsnummer binnen Sekunden am PaymentIntent sichtbar.
- Balance-Report mit Spalte `metadata[invoice_number]` exportierbar und gefüllt.
- Dashboard-Suche nach der Rechnungsnummer findet die Zahlung.
---
### Umsetzungsstatus S6.4 (vorgezogen aus dem Vorgänger-Plan, ERLEDIGT ✅, 2026-08-26)
Ausgelöst durch den Befund der ersten Testzahlung (§4): ein Webhook, der **nie zugestellt**
wurde, war bis dahin nicht reparierbar — `stripe:process-pending-events` arbeitet nur auf
gespeicherten Events. Im Livebetrieb (Stripe gibt Retries nach ~3 Tagen auf) bliebe eine
bezahlte Bestellung dauerhaft auf „offen" stehen.
- **Neu:** `app/Console/Commands/StripeSyncPaymentStatus.php`
(`stripe:sync-payment-status {target?} --pending --days= --dry-run`). Holt PaymentIntent
bzw. Session aktiv über die API und schickt sie durch denselben Pfad wie ein echter Webhook.
`target` akzeptiert `pi_…`, `cs_…`, die Zahlungsreferenz oder die `shopping_payment`-ID.
- **Neu:** `StripeWebhookHandler::syncFromApiObject()` — öffentlicher Einstieg in die
bestehende `dispatchEvent()`-Logik. Damit gelten Betragsabgleich, txaction-Priorität,
DB-Lock und Idempotenz unverändert; eine bereits bezahlte Bestellung wird nicht erneut
verarbeitet.
- **Scheduler:** stündlich `stripe:sync-payment-status --pending --days=3`
(`withoutOverlapping`, im Hintergrund) — das Sicherheitsnetz für verlorene Webhooks.
- **Am Realfall verifiziert:** Die Testzahlung 58166 (31,73 EUR, `pi_3U8fjVJpkSeWxYO21p3EiwY6`)
hing auf „offen"; nach dem Command: `txaction=paid`, Bestellung bezahlt, `carddata`
(Apple Pay / Amex …2003) aus dem Charge übernommen, Transaktionen vollständig protokolliert.
- **Tests:** `tests/Feature/StripeSyncPaymentStatusTest.php` (6 Tests: Recovery, Dry-Run,
keine Doppelverarbeitung, Betragsabweichung, `--pending`-Auswahl, fehlendes Argument).
---
### Umsetzungsstatus SE3 (ERLEDIGT ✅, 2026-08-26)
**Neu**
- `app/Services/Payment/Stripe/StripeInvoiceReferenceService.php` — schreibt
`metadata.invoice_number` + `metadata.invoice_date` an den PaymentIntent und ergänzt die
`description` um die Rechnungsnummer. Idempotent (steht die Nummer schon dort: kein Call).
- `app/Jobs/SyncStripeInvoiceReference.php` — asynchron über Horizon (`tries=3`), damit der
Webhook-Paid-Pfad nicht auf einen Stripe-API-Call wartet.
- `app/Console/Commands/StripeBackfillInvoiceMetadata.php`
(`stripe:backfill-invoice-metadata --from= --to= --dry-run --limit=`), Standard-`from` ist
der Go-Live **2026-07-10** (F3: rückwirkend gewünscht).
- `tests/Feature/StripeInvoiceReferenceTest.php` (5 Tests).
**Geändert**
- `Payment::paymentStatusPaidAction()` stößt nach der Rechnungserstellung
`StripeInvoiceReferenceService::dispatchForOrder()` an — innerhalb desselben
Fehlerschutzes; eine bezahlte Bestellung bleibt bezahlt, auch wenn Stripe klemmt.
- `SalesController@invoice` (manuelle Rechnung im Admin) nutzt denselben Einstieg.
**Abweichungen / Entscheidungen**
- **Kein Vorab-Reservieren der Rechnungsnummer.** Der Nummernkreis ist fortlaufend; jeder
abgebrochene Checkout würde eine Lücke erzeugen (GoBD). Deshalb Nachtragen per
Metadata-Update — Stripe erlaubt das jederzeit.
- **Nur Zahlungen mit `pi_…`-Referenz** werden ergänzt. Solange nur die Session-ID vorliegt,
gibt es kein dauerhaftes Objekt für die Nummer.
- **Auf dem Dev-System nicht live verifizierbar:** `InvoiceRepository::createAndSalesVolume()`
legt auf Testsystemen bewusst **keine** Rechnung an (`if (! Util::isTestSystem(true))`).
Der Pfad ist deshalb über Pest abgesichert; die Sichtprüfung muss auf einem System mit
echter Rechnungserzeugung erfolgen.
- ⚠️ **Backfill nur auf dem System ausführen, dessen Stripe-Konto die Zahlungen trägt.** Der
Probelauf auf dem Dev-System meldet 1.666 Zahlungen — die stammen aus dem **vorherigen**
Stripe-Konto (`…J8ldXin0nk…`) und wären mit dem aktuellen Sandbox-Key nicht auffindbar.
Produktiv also mit dem Produktions-Key laufen lassen, vorher `--dry-run`.
---
## 6. Phase SE4 – Zahlungseingang manuell erfassen · Status: **ERLEDIGT ✅** (2026-08-26)
> Direkte Antwort auf Mail 1: *„dass wir die Rechnungen selber auf bezahlt stellen können, wenn wir
> einen Zahlungseingang mit falschem Verwendungszweck haben (z. B. Mathis mit 288,50 € 13.06.2026) —
> hier mussten wir dann selber eine Rechnung schreiben, was echt umständlich ist."*
### Ziel
Ein Admin markiert eine offene Bestellung als bezahlt, und das System erledigt **alles Weitere
automatisch**: Rechnung erzeugen, Umsatz/Punkte buchen, Abo aktivieren, Bestätigungsmail versenden —
identisch zum Webhook-Pfad. Der Vorgang ist revisionssicher protokolliert.
**Fachlicher Rahmen (F7):** Betroffen sind **Vorkasse-Bestellungen über PAYONE**, bei denen der
Kunde einen falschen Verwendungszweck überweist und PAYONE die Zahlung deshalb nicht automatisch
zuordnen kann. Das lässt sich nicht verhindern (die Hinweise stehen bereits fett und rot in Mail
und System) — es geht ausschließlich darum, die **Nachbearbeitung** zu automatisieren.
### Ist-Zustand (Lücke)
1. Der Status-Button erscheint **nur** bei `clearingtype = 'fnc'` + `onlinebanktransfertype = 'MIV'`
([_detail.blade.php:697](resources/views/admin/sales/_detail.blade.php#L697)) — also **nicht** bei
Vorkasse (`vor`) und **nicht** bei Stripe (`stripe`). Genau dort passieren die Fälle.
2. `store_txaction` setzt `txaction` + `paid = true` und schreibt eine `PaymentTransaction`, ruft aber
**nicht** `Payment::paymentStatusPaidAction()` auf ([SalesController.php:371–396](app/Http/Controllers/SalesController.php#L371)).
Der Code-Kommentar dort („TODO can send MAIL") dokumentiert die Lücke seit Langem.
### Aufgaben
| # | Aufgabe |
|---|---------|
| SE4.1 | Neuer Service `App\Services\ManualPaymentService::recordIncomingPayment(ShoppingOrder $order, ShoppingPayment $payment, array $data, User $admin)`: schreibt eine `PaymentTransaction` (`request = 'manual_payment'`, `status = 'MANUAL'`, `txaction = 'paid'`) mit Audit-Daten und ruft dann `Payment::paymentStatusPaidAction($order, true, $payment)` + `Payment::paymentStatusSendMail()` auf. Alles in einer DB-Transaktion mit `lockForUpdate()` auf der Bestellung (Muster: [StripeWebhookHandler::markPaid](app/Services/Payment/Stripe/StripeWebhookHandler.php#L324)). |
| SE4.2 | Erfasste Felder (Form Request `RecordManualPaymentRequest`): **Zahlungsdatum** (Pflicht, Valuta), **Betrag** (Pflicht, vorbelegt mit dem Bestellbetrag), **Zahlungsweg** (Banküberweisung / Bar / Sonstiges), **Verwendungszweck / Bankreferenz** (Freitext, Pflicht — das ist der Beleg), **Notiz** (optional), **Rechnung per Mail senden** (Checkbox, Default an). |
| SE4.3 | Betragsabweichung sichtbar machen: weicht der erfasste Betrag vom Bestellbetrag ab, zeigt das Formular die Differenz und verlangt eine explizite Bestätigung („Teilzahlung / abweichender Betrag bewusst erfassen"). Die Abweichung wird in der `PaymentTransaction` gespeichert. **Keine** automatische Teilzahlungs-Logik in dieser Phase (→ SE5). |
| SE4.4 | Guards: nur Admin-Rolle (`middleware('admin')`); nicht möglich bei `$order->paid === true`; nicht möglich bei bereits stornierter Bestellung; Doppelklick-Schutz über die DB-Transaktion. |
| SE4.5 | Protokoll: `SysLog::action('manual-payment', 'shopping_order', …)` mit Admin-ID, Betrag, Beleg-Referenz. In der Bestell-Detailansicht als eigene Zeile in der Transaktionsliste sichtbar („Manuell erfasst von <Admin> am <Datum>, Beleg: <Referenz>"). |
| SE4.6 | UI: Button „Zahlungseingang erfassen" in [`_detail.blade.php`](resources/views/admin/sales/_detail.blade.php) an **jeder** unbezahlten Zahlung, unabhängig vom `clearingtype`; der bestehende `fnc/MIV`-Sonderpfad bleibt bis zur Ablösung erhalten und wird danach entfernt. Route `POST /admin/sales/payment/manual` in [routes/domains/crm.php](routes/domains/crm.php#L368). |
| SE4.7 | Klarstellung für die Buchhaltung (Doku + Hinweistext im Formular): Das Geld liegt in diesen Fällen auf dem **Bankkonto**, nicht bei Stripe. Der Vorgang taucht deshalb **nicht** in den Stripe-Berichten auf — er wird über die MIVITA-Rechnung und DATEV abgebildet. |
### Tests
- Manuelle Erfassung an einer Vorkasse-Bestellung → Rechnung existiert, `UserSalesVolume` gebucht, Mail versendet, `txaction = 'paid'`.
- Manuelle Erfassung an einer Abo-Bestellung → Abo aktiviert (`setAboActive`), keine Doppel-Anlage.
- Zweiter Aufruf auf derselben Bestellung → abgelehnt, keine zweite Rechnung.
- Nicht-Admin → 403.
- Abweichender Betrag ohne Bestätigung → Validierungsfehler.
### Akzeptanzkriterien
- Der Fall „Zahlungseingang mit falschem Verwendungszweck" ist in **einem** Admin-Formular erledigt; niemand schreibt mehr eine Rechnung von Hand.
- Jeder manuelle Zahlungseingang ist mit Admin, Datum, Betrag und Beleg nachvollziehbar.
---
### Umsetzungsstatus SE4 (ERLEDIGT ✅, 2026-08-26)
**Neu**
- `app/Services/ManualPaymentService.php` — verbucht den Zahlungseingang in einer DB-Transaktion
mit `lockForUpdate()` auf der Bestellung und ruft anschließend
`Payment::paymentStatusPaidAction()` auf: Rechnung, Umsatz/Punkte, Abo-Aktivierung.
Die Kundenmail geht erst **nach** dem Commit raus. Zahlungswege: Banküberweisung, Bar, Sonstiges.
- `app/Http/Requests/Payment/RecordManualPaymentRequest.php` — Pflichtfelder Zahlungsdatum
(nicht in der Zukunft), Betrag, Zahlungsweg und **Verwendungszweck/Bankreferenz**; deutsche
Dezimaleingabe (`288,50`) wird korrekt in Cent umgerechnet.
- `app/Exceptions/ManualPaymentException.php`
- `tests/Feature/ManualPaymentTest.php` (16 Tests: Paid-Pfad, Revisionsspur, Mailsteuerung,
Betragsabweichung, alle Guards, Abo-Bestellung, Doppelklick, Formular-Validierung).
- Übersetzungen in `resources/lang/{de,en}/payment.php` (`manual_*`).
**Geändert**
- `SalesController@recordManualPayment` + Route `POST /admin/sales/payment/manual`
(`admin_sales_payment_manual`) hinter der bestehenden `admin`-Middleware.
- `resources/views/admin/sales/_detail.blade.php`: Button **„Zahlungseingang erfassen"** an
jeder Zahlung einer **unbezahlten, nicht stornierten** Bestellung — unabhängig vom
`clearingtype`, also auch bei Vorkasse und Stripe. Dazu das Erfassungs-Modal und eine
Detailzeile in der Transaktionsliste, die Erfasser, Zahlungsdatum, Beleg und eine etwaige
Betragsabweichung ausweist.
**Abweichungen / Entscheidungen**
- **Der alte `store_txaction`-Pfad bleibt bestehen** (Rechnungskauf `fnc`/`MIV`). Er wird nicht
im selben Zug entfernt, damit die Umstellung risikofrei bleibt; Ablösung erst, wenn der neue
Weg im Alltag bestätigt ist.
- **Betragsabweichung wird zugelassen, aber erzwungen bestätigt** (SE4.3): Ohne den Haken
„Abweichenden Betrag bewusst erfassen" bricht die Validierung mit dem erwarteten Betrag ab —
aus einem Tippfehler soll keine stille Fehlbuchung werden. Der abweichende Betrag landet
samt Sollbetrag in der Revisionsspur. Eine echte Teilzahlungs-Logik bleibt SE5 vorbehalten.
- **Kein Eingriff bei Stripe.** Das Geld liegt auf dem Bankkonto, nicht beim Anbieter; der
Vorgang erscheint daher bewusst nicht in den Stripe-Berichten. Das steht als Hinweistext
im Formular (SE4.7).
- **Protokoll:** `SysLog::action('manual-payment', 'payment', 3)` — Level 3 löst wie bei anderen
Admin-Aktionen zusätzlich eine Info-Mail aus; das ist bei einer geldrelevanten Buchung
gewollt. Zusätzlich eine Zeile im `payment`-Log.
**Offen:** Abnahme am realen Altfall (F7: Vorkasse 288,50 € vom 13.06.2026) durch die
Buchhaltung.
---
## 7. Phase SE5 – Teilstorno, Gutschrift & Stripe-Refund · Status: **OFFEN** (größter Brocken)
> Mail 1: *„Dann hatten wir ja schon gesprochen wegen der Teilstornierungen und Gutschriften.
> Hier würde das auch einiges erleichtern, wenn das über das System laufen könnte."*
> Löst zugleich **S7.2** und **S9.2/S9.3** aus dem Vorgänger-Plan ab.
### Ziel
Eine (Teil-)Rückerstattung wird **einmal** im Admin erfasst und erzeugt daraus konsistent:
Gutschrifts-/Stornorechnung mit eigener Nummer, anteilige Punktekorrektur, Stripe-Refund und
den passenden DATEV-Export.
### Ist-Zustand
- Storno nur **vollständig** ([InvoiceRepository::createCancellation](app/Repositories/InvoiceRepository.php#L273)) — setzt `txaction = 'cancelled'`, `shipped = 10`, storniert das **komplette** `UserSalesVolume` ([:496](app/Repositories/InvoiceRepository.php#L496) → `SalesPointsVolume::cancelSalesPointsVolume`).
- Kein Stripe-Refund. `charge.refunded` landet im Default-Zweig des Webhooks und wird nur geloggt ([StripeWebhookHandler.php:220](app/Services/Payment/Stripe/StripeWebhookHandler.php#L220)).
- `OrderWithdrawal` erfasst Widerrufe inkl. `type = partial` und `items`, aber der Admin kann daraus nur den Status pflegen ([OrderWithdrawalController.php:33](app/Http/Controllers/Admin/OrderWithdrawalController.php#L33)).
- `UserCredit` ist die **Provisionsgutschrift für Berater** — fachlich etwas völlig anderes und **nicht** wiederverwendbar für Kundengutschriften.
### Teilphasen
#### SE5.1 – Datenmodell & Betragsführung (2 PT)
| # | Aufgabe |
|---|---------|
| a | Neue Tabelle `shopping_payment_refunds`: `shopping_payment_id`, `user_invoice_id` (Gutschriftsrechnung), `provider`, `external_reference` (`re_…`), `amount` (Cent), `reason`, `status` (`pending`/`succeeded`/`failed`/`canceled`), `created_by_user_id`, Zeitstempel. |
| b | `shopping_payments.refunded_amount` (Cent, Default 0) als schnell lesbarer Summenwert; Wahrheit bleibt die Refund-Tabelle. |
| c | `user_invoices`: Kennzeichen für die Art des Vorgangs (`document_type` = `invoice`/`cancellation`/`credit_note`, `cancelled_amount`, `reissued_from_id`) — die bestehende `cancellation`/`cancellation_id`-Verknüpfung bleibt unverändert, damit Bestandsauswertungen und `FileController` weiter funktionieren. |
| d | Neue Tabelle `sales_volume_corrections` (F6): `shopping_order_id`, `user_invoice_id`, `points`, `amount`, `target_month`/`target_year`, `settled` (bool), `reason`, `created_by_user_id`. Dokumentiert **jede** Punktekorrektur — auch die bewusst nicht durchgeführte. |
#### SE5.2 – Gutschrift bzw. Storno + Neuausstellung (5–6 PT)
Umsetzung der **beiden** von F5 geforderten Wege (Fälle A und B, §9):
| # | Aufgabe |
|---|---------|
| a | **Fall A — Gutschrift** (`InvoiceRepository::createCreditNote(array $items, array $request)`): Positionsauswahl (Produkt + Menge) oder Freibetrag; Gutschriftsrechnung mit eigener Nummer aus dem Rechnungs-Nummernkreis, Vermerk „Produkt XY aus Rechnung `<Nr.>` gutgeschrieben". Originalrechnung bleibt **unverändert gültig**. |
| b | **Fall B — Storno + Neuausstellung** (`InvoiceRepository::cancelAndReissue(array $keptItems, array $request)`): storniert die Originalrechnung über den bestehenden `createCancellation()`-Pfad und stellt anschließend eine **neue** Rechnung mit dem korrigierten Positionsumfang aus. Nur zulässig, solange die Bestellung nicht versendet ist (`shipped` in [0, 1]). |
| c | PDF: beide Fälle über das bestehende Storno-Template mit eigenem Kopf (`Gutschrift` / `Stornorechnung`); deutsche Fassung immer, Kundensprache zusätzlich (Muster: `makeCancellationPDF`). |
| d | **Punktekorrektur:** `SalesPointsVolume::cancelSalesPointsVolume()` storniert heute vollständig. Neue Variante für Teilbeträge (Punkte je stornierter Position, **nicht** anteilig am Gesamtbetrag). **Kritisch**, weil die Provisionsberechnung darauf aufsetzt — eigener Test-Block, Abgleich mit `TreeCalcBotOptimized` vor Freigabe. |
| e | **Stichtags-Logik (F6):** Korrektur wird nur ausgeführt, wenn der Zielmonat noch **nicht abgerechnet** ist (Prüfung gegen die Monatsabrechnung `business:store-optimized`). Andernfalls bleiben die Punkte stehen. In **beiden** Fällen entsteht ein Satz in der neuen Tabelle `sales_volume_corrections` (Bestellung, Gutschrift, Punkte/Betrag, Zielmonat, `settled`, Grund, Admin) — bei `settled = true` mit dem Vermerk „nicht verrechnet". |
| f | Bestellstatus: bei Teilstorno **nicht** `cancelled` setzen (das würde die Bestellung komplett aus Auswertungen nehmen). Stattdessen neuer Status `partially_cancelled` in `Payment::$txaction_text`/`$txaction_color` + Badge. |
| g | DATEV: `DatevExportService::collectCancellationLines()` um Gutschrifts- und Teilstorno-Zeilen erweitern ([DatevExportService.php:487](app/Services/DatevExportService.php#L487)). Achtung: `collectCreditLines()` sammelt **Provisionsgutschriften an Berater** — Kundengutschriften brauchen eine eigene Sammelmethode und eigene Konten. |
#### SE5.3 – Stripe-Refund (2–3 PT)
| # | Aufgabe |
|---|---------|
| a | `StripePaymentService::refund(ShoppingPayment $p, int $amountCents, string $reason, array $metadata): RefundResult` — `refunds->create()` mit `payment_intent`, `amount`, Metadaten (SE2-Set + `cancellation_invoice_number`) und **Idempotency-Key** `refund-<payment_id>-<invoice_id>`. Voll- und Teilerstattung über denselben Weg. |
| b | Guards: nur bei `txaction = 'paid'`; Summe aller Refunds ≤ `shopping_payments.amount`; Prüfung gegen `refunded_amount` **vor** dem API-Call. |
| c | Webhook: `charge.refunded` und `refund.updated`/`refund.failed` aus dem Default-Zweig herausnehmen und verbuchen — `shopping_payment_refunds.status` pflegen, `refunded_amount` fortschreiben, `PaymentIncident` bei `failed`. Auch **im Stripe-Dashboard ausgelöste** Refunds landen so korrekt im System. |
| d | PAYONE-Pendant: bei Vorkasse gibt es keinen API-Refund → Refund-Datensatz mit `provider = 'payone'`, `status = 'manual'` und Hinweis „Rücküberweisung manuell durch die Buchhaltung". |
#### SE5.4 – Admin-UI & Widerrufs-Verzahnung (2 PT)
| # | Aufgabe |
|---|---------|
| a | Modal „Storno / Gutschrift" an der Bestellung mit **Wegwahl** (Fall A Gutschrift / Fall B Storno + Neuausstellung, vorausgewählt nach `shipped`), Positionsauswahl mit Mengen, Betragsvorschau (brutto/netto/USt), Checkbox „Betrag über Stripe erstatten", Grund (Pflicht), Mailversand. Fall B ist bei bereits versendeter Ware gesperrt. |
| b | Aus `OrderWithdrawal` heraus direkt in dieses Modal springen — `items` und `type` vorbelegt; nach Abschluss Widerruf auf `done` setzen. |
| c | Bestell-Detailansicht zeigt Refund-Historie inkl. Stripe-Deep-Link (Helper `stripeDashboardUrl()` existiert bereits aus S7.4). |
### Tests
- Teilstorno über 1 von 3 Positionen: Gutschriftsrechnung mit korrektem Betrag, Punkte nur für diese Position storniert, Bestellung bleibt in Auswertungen.
- Refund > Restbetrag → abgelehnt, kein API-Call.
- `charge.refunded` aus dem Stripe-Dashboard → System zieht nach, ohne Doppelbuchung.
- Zwei parallele Refund-Anfragen → nur ein Stripe-Refund (Idempotency-Key).
- Vollstorno-Regression: bestehende `createCancellation`-Tests unverändert grün.
### Akzeptanzkriterien
- Teilstorno inkl. Gutschriftsrechnung und Stripe-Rückerstattung in einem Vorgang, ohne manuelle Nacharbeit.
- Punktestände und DATEV-Export stimmen nach Teilstorno rechnerisch (Prüfung an einem realen Altfall).
---
## 8. Phase SE6 – Stripe-Berichte & Buchhaltungs-Abstimmung · Status: **OFFEN** (organisatorisch)
Kein Entwicklungsaufwand, aber Voraussetzung dafür, dass SE1–SE3 den erhofften Nutzen bringen.
| # | Aufgabe | Verantwortung |
|---|---------|---------------|
| SE6.1 | **Bilanzbericht** (Balance Report) und **Auszahlungsabstimmung** (Payout Reconciliation) im Dashboard unter *Berichte → Planen* auf **wöchentlich** stellen, Empfänger eintragen. | Buchhaltung |
| SE6.2 | Spaltenauswahl je Bericht festlegen — mindestens `metadata[invoice_number]`, `metadata[reference]`, `metadata[shopping_order_id]`, `customer_name`, `customer_email`. | Buchhaltung + Entwicklung |
| SE6.3 | Rückmeldung, welche **weiteren** Felder gebraucht werden (Kostenstelle? Beraternummer? Abo-Kennzeichen?) → fließt zurück in SE2.2. | Buchhaltung |
| SE6.4 | Nach SE1–SE3: eine Testzahlung im **Testmodus**, Berichte exportieren und gegen die Erwartung prüfen; erst danach live bewerten. | Entwicklung |
| SE6.5 | Optional (P3): Command `stripe:payout-report {payout_id}` — Auszahlung auf dem Bankkonto den enthaltenen MIVITA-Rechnungsnummern zuordnen. Erst nach SE6.1–SE6.4 bewerten; die Stripe-Berichte decken das eventuell bereits ab. | Entwicklung |
---
## 9. Stakeholder-Entscheidungen (beantwortet 2026-08-26)
| # | Frage | **Antwort** | Konsequenz |
|---|-------|-------------|------------|
| F1 | Name zusätzlich im Stripe-Checkout abfragen (Option B)? | **Nein** — Name aus der Rechnungsanschrift übernehmen. | SE1 bleibt bei Option A; `name_collection` wird nicht aktiviert. |
| F2 | Welche Metadaten braucht die Buchhaltung zusätzlich? | **Keine** — „die Daten passen für den Steuerberater". | Metadaten-Set aus SE2.2 ist final; keine Zusatzfelder. |
| F3 | Rechnungsnummern rückwirkend ab Go-Live nachtragen? | **Ja.** | SE3.4 (Backfill-Command) ist Pflichtteil, nicht optional. |
| F4 | Stripe-Customer bei DSGVO-Löschung löschen? | **Ja — muss sogar.** | SE1.6 wird verbindlich: `customers->delete()` bei jeder Löschanfrage. |
| F5 | Teilstorno: Gutschrift **oder** Storno + Neuausstellung? | **Beides — als zwei Optionen im UI.** | Bestimmt das Design von SE5.2, siehe unten. |
| F6 | Provisionskorrektur bei Teilstorno? | **Nur im laufenden Monat**; bereits abgerechnete Monate bleiben fix, werden aber nachvollziehbar protokolliert. | SE5.2b bekommt eine Stichtags-Logik + Protokolltabelle. |
| F7 | Altfall 288,50 €? | **Vorkasse über PAYONE**, falscher Verwendungszweck. Kommt regelmäßig vor und lässt sich nicht verhindern (Hinweise stehen bereits fett/rot in Mail und System). | SE4 ist damit **kein** Stripe-Thema, sondern der generische „Zahlungseingang manuell erfassen"-Pfad für Vorkasse-Bestellungen. Abnahmetest = eine Vorkasse-Bestellung. |
### Präzisierung zu F5 — zwei fachlich verschiedene Fälle
| Fall | Beispiel | Ergebnis im System |
|------|----------|--------------------|
| **A — Gutschrift auf bestehende Rechnung** (nachgelagert) | Kunde schickt ein Produkt zurück, Ware war bereits raus. | **Gutschriftsrechnung** mit eigener Nummer und Vermerk „Produkt XY aus Rechnung `<Nr.>` gutgeschrieben". Die Originalrechnung bleibt gültig und unverändert. |
| **B — Storno + Neuausstellung** (vor Versand) | Position ist nicht lieferbar und wird ausgetragen, Ware ist noch **nicht** raus. | **Stornorechnung** über die Originalrechnung **plus** neue Rechnung mit dem korrigierten Betrag. |
Das Storno-/Gutschrift-Modal in SE5.4a bietet daher beide Wege zur Auswahl; die Vorauswahl
richtet sich nach dem Versandstatus (`shipped`): Ware raus → Fall A, Ware nicht raus → Fall B.
### Präzisierung zu F6 — Provisionskorrektur mit Stichtag
- Teilstorno im **laufenden Monat** → Punkte/Umsatz werden regulär korrigiert (die
Monatsabrechnung `business:store-optimized` rechnet ohnehin neu).
- Teilstorno für einen **bereits abgerechneten** Monat → Punkte bleiben unverändert stehen,
die Provision wird **nicht** rückwirkend gekürzt.
- In **beiden** Fällen entsteht ein Protokollsatz (neue Tabelle `sales_volume_corrections`:
Bestellung, Gutschrift, Betrag/Punkte, Zielmonat, `settled` ja/nein, Grund, Admin).
Bei `settled = true` trägt er den Vermerk „nicht verrechnet" — damit bleibt jede
nicht durchgeführte Korrektur nachvollziehbar.
## 10. Risiken & Mitigation
| Risiko | Mitigation |
|--------|------------|
| Checkout bricht wegen fehlgeschlagener Customer-Anlage ab | SE1.7: Fallback auf `customer_email`, Fehler nie durchreichen; E2E im Testmodus vor Live |
| Manuelle „Bezahlt"-Markierung erzeugt Rechnung für nicht eingegangenes Geld | Pflichtfeld Beleg/Verwendungszweck, SysLog, Admin-Rolle, Anzeige in der Bestellhistorie |
| Doppelte Rechnung durch Race manuell ↔ Webhook | `lockForUpdate()` + `isInvoice()`-Prüfung wie im bestehenden Paid-Pfad |
| Falsche Punktekorrektur bei Teilstorno → falsche Provisionen | Eigene Testsuite, Abgleich gegen `TreeCalcBotOptimized`, Freigabe erst nach Prüfung eines Realfalls |
| Doppel-Refund bei Netzwerk-Retry | Idempotency-Key je (Zahlung, Gutschrift) + Guard gegen `refunded_amount` |
| Refund im Stripe-Dashboard am System vorbei | `charge.refunded`-Webhook verbucht auch dashboard-seitige Refunds (SE5.3c) |
| Metadaten-Limits (50 Keys / 500 Zeichen) | Zentraler Builder kappt defensiv, Unit-Test |
| Scope-Kriechen Richtung Stripe Invoicing/Billing | Architektur-Entscheidung des Vorgänger-Plans bleibt verbindlich: Stripe ist **reiner Charge-Provider**, Rechnungen bleiben im System |
---
## 11. Teststrategie
| Ebene | Inhalt |
|-------|--------|
| Unit | `StripeMetadataBuilder` (Set, Kappung), Betrags-/Punkteberechnung bei Teilstorno, Refund-Guards |
| Feature (Pest, SQLite) | Checkout mit Customer, Rechnungsnummer-Sync, manueller Zahlungseingang (Rechnung + Punkte + Mail + Abo), Refund-Webhooks — Stripe-Client **immer gemockt** |
| Regression | `tests/Feature/StripeCheckoutTest.php`, `StripeAboTest.php`, `StripeWebhookHandlerTest.php`, `StripePaymentMethodChangeTest.php`, `StripeFoundationTest.php` sowie die PAYONE-Suite müssen in **jeder** Phase unverändert grün sein |
| Manuell (Testmodus) | Testzahlung → Name/Description/Metadaten im Dashboard prüfen; Berichte exportieren (SE6.4); Teil-Refund über Dashboard und über das System |
---
## 12. Betroffene Dateien (Vorschau)
### Neu
```
app/Services/Payment/Stripe/StripeMetadataBuilder.php (SE2)
app/Jobs/SyncStripeInvoiceReference.php (SE3)
app/Console/Commands/StripeBackfillCustomers.php (SE1.5)
app/Console/Commands/StripeBackfillInvoiceMetadata.php (SE3.4)
app/Services/ManualPaymentService.php (SE4)
app/Http/Requests/RecordManualPaymentRequest.php (SE4)
app/Models/ShoppingPaymentRefund.php (SE5.1)
database/migrations/*_add_stripe_customer_id_to_shopping_users (SE1.1)
database/migrations/*_create_shopping_payment_refunds_table (SE5.1)
database/migrations/*_add_refund_fields_to_shopping_payments (SE5.1)
database/migrations/*_add_partial_cancellation_to_user_invoices (SE5.1)
```
### Anpassung
```
app/Services/Payment/Stripe/StripePaymentService.php (SE1.3, SE2.3, SE5.3a)
app/Services/Payment/Stripe/StripeCustomerService.php (SE1.2)
app/Services/Payment/Stripe/StripeWebhookHandler.php (SE5.3c — charge.refunded/refund.*)
app/Services/Payment.php (SE3.2, SE5.2c — Status partially_cancelled)
app/Repositories/InvoiceRepository.php (SE5.2a)
app/Services/BusinessPlan/SalesPointsVolume.php (SE5.2b — anteilige Stornierung)
app/Services/DatevExportService.php (SE5.2d)
app/Services/GdprDeletionService.php (SE1.6)
app/Http/Controllers/SalesController.php (SE4.6, SE5.4a)
app/Http/Controllers/Admin/OrderWithdrawalController.php (SE5.4b)
resources/views/admin/sales/_detail.blade.php (SE4.6, SE5.4a/c)
routes/domains/crm.php (SE4.6, SE5.4)
```
---
## 13. Nächste Schritte
1. ~~§9 Offene Fragen klären~~ ✅ **beantwortet 2026-08-26** (§9).
2. ~~**SE1 + SE2 umsetzen**~~ ✅ **2026-08-26** (Umsetzungsstatus in §4). **Offen:** die
Testzahlung im Stripe-Testmodus und der Sichttest im Dashboard — der von Stripe empfohlene
Weg (Mail 2, „Empfohlene nächste Schritte").
3. **SE6.1/SE6.2 parallel** durch die Buchhaltung: Berichte planen, Spalten auswählen.
4. ~~**SE3 umsetzen**~~ ✅ **2026-08-26** (Umsetzungsstatus in §5), inklusive des
vorgezogenen **S6.4** (`stripe:sync-payment-status`) als Sicherheitsnetz für verlorene
Webhooks. **Offen:** Backfill produktiv fahren (erst `--dry-run`) und Berichte exportieren.
5. ~~**SE4 umsetzen**~~ ✅ **2026-08-26** (Umsetzungsstatus in §6). **Offen:** Abnahme am
realen Vorkasse-Altfall (F7) durch die Buchhaltung.
6. **SE5 umsetzen** — beide Wege aus F5, Punktekorrektur mit Stichtag aus F6. ← **als Nächstes**

View file

@ -0,0 +1,117 @@
# Go-Live-Checkliste: Stripe-Erweiterung SE1–SE4 + S6.4
> **Stand:** 2026-08-26 · **Gehört zu:** `ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md`
> **Umfang des Rollouts:** SE1 (Kundendaten), SE2 (Metadaten/`description`), SE3 (Rechnungsnummer),
> SE4 (Zahlungseingang manuell erfassen), S6.4 (`stripe:sync-payment-status`).
>
> **Risikoeinschätzung:** Die Änderungen sind additiv. Die einzige Stelle im Checkout mit
> Live-Risiko (SE1) hat einen automatischen Fallback auf das bisherige Verhalten. Die Migration
> fügt nur eine nullable Spalte hinzu und ist ohne Datenverlust rücknehmbar.
---
## 1. Vor dem Deployment
| ✓ | Punkt | Detail |
|---|-------|--------|
| ☐ | **Stripe-Live-Keys prüfen** | `STRIPE_SECRET` (`sk_live_…`), `STRIPE_KEY`, `STRIPE_MODE=live`. |
| ☐ | **Webhook-Endpoint im Stripe-Live-Dashboard** | Muss auf `https://api.<live-domain>/stripe/webhook` zeigen. `STRIPE_WEBHOOK_SECRET` ist das `whsec_…` **genau dieses** Endpoints. |
| ☐ | **Konto-Abgleich** | ⚠️ Der Fehler vom Testsystem: Key und Webhook-Quelle gehörten zu **verschiedenen Stripe-Konten** — die Events kamen deshalb nie an, ohne Fehler und ohne Log. Prüfen: das Konto im Dashboard, aus dem der Endpoint stammt, muss dasselbe sein, zu dem `STRIPE_SECRET` gehört. |
| ☐ | **`APP_INFO_TEST_MAIL`** | Bekommt ab jetzt bei **jedem** manuell erfassten Zahlungseingang eine Protokollmail (SysLog Level 3). Auf eine Adresse setzen, die tatsächlich jemand liest. |
## 2. Deployment
| ✓ | Schritt | Befehl |
|---|---------|--------|
| ☐ | Code ausrollen | (üblicher Weg) |
| ☐ | Autoloader | `composer install --no-dev --optimize-autoloader` — es gibt **neue Klassen** (Service, Job, Commands, Form Request, Exception). |
| ☐ | Migration | `php artisan migrate --force` → `2026_08_26_120000_add_stripe_customer_id_to_shopping_users` ✅ *(laut Rückmeldung bereits durchgelaufen)* |
| ☐ | **View-Cache leeren** | `php artisan view:clear` — `admin/sales/_detail.blade.php` hat sich geändert; ohne das erscheint der neue Button nicht. |
| ☐ | Config-/Route-Cache | `php artisan config:clear && php artisan route:clear`; falls produktiv gecacht wird, danach `config:cache` + `route:cache`. |
| ☐ | **Horizon neu starten** | `php artisan horizon:terminate` — **Pflicht.** Der Worker hält alten Code im Speicher und kennt den neuen Job `SyncStripeInvoiceReference` sonst nicht. |
| ☐ | Scheduler prüfen | `php artisan schedule:list` muss `stripe:sync-payment-status --pending --days=3` **stündlich** zeigen. Voraussetzung: der System-Cron ruft `schedule:run` minütlich auf. |
## 3. Direkt nach dem Deployment prüfen
| ✓ | Prüfung | Erwartung |
|---|---------|-----------|
| ☐ | **Eine echte Bestellung durchführen** (kleiner Betrag) | Checkout läuft normal, Zahlung geht durch. |
| ☐ | Stripe-Dashboard, diese Zahlung | Spalte **Kunde** gefüllt (`Vorname Nachname (Firma)`), **Beschreibung** = `Bestellung #… · <Referenz> · <Name>`, **Metadaten** vollständig. |
| ☐ | Webhook | Im Stripe-Dashboard unter *Entwickler → Webhooks* muss die Zustellung **2xx** zeigen. |
| ☐ | Rechnungsnummer | Wenige Sekunden nach der Zahlung steht `invoice_number` in den Metadaten (Horizon muss laufen). |
| ☐ | Kundenliste bei Stripe | Nach einer **zweiten** Bestellung desselben Kunden darf **kein zweiter** Customer entstehen. |
| ☐ | `storage/logs/stripe-*.log` | Keine Warnungen `Stripe-Customer für Checkout nicht verfügbar` — die würden auf den Fallback-Pfad hinweisen. |
| ☐ | Admin-Bestellansicht | Bei einer **unbezahlten** Bestellung erscheint der Button „Zahlungseingang erfassen". |
## 4. Backfills — Reihenfolge einhalten
> **Immer zuerst `--dry-run`.** Die Commands sprechen die Stripe-API an; ein Probelauf kostet
> nichts und zeigt exakt, was passieren würde.
```bash
# 1) Bestands-Customer verknüpfen und mit Name/Anschrift versehen
php artisan stripe:backfill-customers --dry-run
php artisan stripe:backfill-customers
# 2) Bereits verknüpfte Customer auf das Namensformat "Vorname Nachname (Firma)" bringen
php artisan stripe:backfill-customers --refresh --dry-run
php artisan stripe:backfill-customers --refresh
# 3) Rechnungsnummern rückwirkend ab Go-Live 2026-07-10 nachtragen (Stakeholder-Entscheid F3)
php artisan stripe:backfill-invoice-metadata --dry-run
php artisan stripe:backfill-invoice-metadata --limit=50 # erst eine kleine Charge
php artisan stripe:backfill-invoice-metadata # dann der Rest
```
**Wichtig:**
- Die Backfills müssen auf dem System laufen, dessen `STRIPE_SECRET` zu dem Konto gehört, in dem
die Zahlungen liegen. Auf dem Testsystem meldete der Probelauf 1.666 Zahlungen aus einem
**anderen** Konto — die wären dort nicht auffindbar gewesen.
- Schritt 3 macht zwei API-Calls je Zahlung (lesen + schreiben). Bei ~1.700 Zahlungen sind das
~3.400 Calls; unkritisch für Stripe, dauert aber einige Minuten. Deshalb die erste Charge mit
`--limit=50` und das Ergebnis im Dashboard ansehen.
- Alle drei Commands sind **wiederholbar**: bereits gesetzte Werte werden übersprungen.
## 5. Was sich für die Anwender ändert
| Wer | Änderung |
|-----|----------|
| **Buchhaltung** | Neuer Button **„Zahlungseingang erfassen"** an jeder unbezahlten Bestellung — auch bei Vorkasse. Damit entfällt das manuelle Schreiben der Rechnung: das System erzeugt Rechnung, Umsatz/Punkte und Bestätigungsmail. Verwendungszweck/Bankreferenz ist Pflichtfeld (Beleg). Ein abweichender Betrag muss aktiv bestätigt werden. |
| **Buchhaltung** | Stripe zeigt ab sofort Kundennamen; Zahlungen sind über Bestellnummer, Referenz und (nach dem Backfill) Rechnungsnummer suchbar. |
| **Wer DSGVO-Löschungen ausführt** | ⚠️ Eine Löschung entfernt jetzt **zusätzlich das Stripe-Customer-Objekt** — unwiderruflich. Die Zahlungen (Charges) bleiben bei Stripe als Buchungsbeleg erhalten. |
| **Support/Admin** | In der Transaktionsliste steht bei manuell erfassten Zahlungen, wer sie wann mit welchem Beleg gebucht hat. |
## 6. Laufende Beobachtung (erste Tage)
| Was | Wo | Worauf achten |
|-----|----|---------------|
| Stripe-Log | `storage/logs/stripe-*.log` | `Stripe-Customer für Checkout nicht verfügbar` (Fallback aktiv), `Stripe-Webhook abgewiesen (Signatur…)` (falsches Webhook-Secret) |
| Payment-Log | `storage/logs/payment-*.log` | Einträge `Zahlungseingang manuell erfasst` |
| Horizon | `/horizon` → *Failed Jobs* | `SyncStripeInvoiceReference` darf sich nicht stapeln |
| Stündlicher Abgleich | `stripe:sync-payment-status` | Meldet er regelmäßig „nachgezogen", kommen Webhooks nicht an → Endpoint prüfen |
| Payment-Dashboard | `/admin/payment-dashboard` | Zahlungsquote und Fehlerrate wie gewohnt |
## 7. Wenn etwas schiefgeht
| Symptom | Ursache | Sofortmaßnahme |
|---------|---------|----------------|
| Kein Kundenname bei Stripe | Customer-Anlage schlägt fehl (Fallback aktiv) | Im `stripe.log` die Warnung lesen; Checkout läuft normal weiter — kein Notfall |
| Bestellungen bleiben auf „offen", Geld ist bei Stripe da | Webhook kommt nicht an | `php artisan stripe:sync-payment-status --pending` sofort ausführen, danach Endpoint/Secret prüfen |
| Rechnungsnummer erscheint nicht bei Stripe | Horizon läuft nicht oder wurde nicht neu gestartet | `php artisan horizon:terminate`, danach `stripe:backfill-invoice-metadata` |
| Neuer Button fehlt in der Bestellansicht | View-Cache | `php artisan view:clear` |
| Doppelte Customer bei Stripe | `shopping_users.stripe_customer_id` wird nicht gespeichert | Spalte in der DB prüfen (Migration gelaufen?) |
**Rollback:** Vorherigen Stand deployen. Die Migration muss **nicht** zurückgenommen werden — die
Spalte ist nullable und stört den alten Code nicht. Bereits bei Stripe gesetzte Namen und
Metadaten bleiben erhalten und schaden nicht.
## 8. Noch offen nach diesem Rollout
- **SE6.1/SE6.2 (Buchhaltung):** Bilanzbericht und Auszahlungsabstimmung im Stripe-Dashboard auf
wöchentlich planen, Spalten inkl. `metadata[invoice_number]`, `metadata[reference]`,
`metadata[shopping_order_id]`, `customer_name`, `customer_email` auswählen.
- **SE4-Abnahme:** Den realen Altfall (Vorkasse 288,50 €, 13.06.2026) über den neuen Button
erfassen und das Ergebnis mit der bisher von Hand gebauten Rechnung vergleichen.
- **SE5:** Teilstorno, Gutschrift und Stripe-Refund — noch nicht umgesetzt.
- **Ablösung des alten `store_txaction`-Pfads** (Rechnungskauf `fnc`/`MIV`), sobald der neue Weg
im Alltag bestätigt ist.

View file

@ -0,0 +1,83 @@
Mail 1:
ich hatte gerade einen Zoom Call mit Stripe.
Leider übermittelt unser Programm keine Daten der Kunden an Stripe. Das bedeutet, dass ich auf der Abrechnung von Stripe leider keinerlei Daten der Kunden habe außer die Email.
Herr Ergün meinte dass man das bei uns im System freigeben muss. Ebenso wäre es hilfreich, wenn Stripe auch die generierte Rechnungsnummer hätte für die Zuteilung in der Buchhaltung.
Die Bestell und Referenznummer in die Suche einzupflegen wäre auch Mega hilfreich für uns.
Und dass wir die Rechnungen selber auf bezahlt stellen können wenn wir einen Zahlungseingang mit falschem Verwendungszweck haben ( z.B. Mathis mit 288,50€ 13.06.2026) hier mussten wir dann selber eine Rechnung schreiben was echt umständlich ist.
Dann hatten wir ja schon gesprochen wegen der Teilstornierungen und Gutschriften. Hier würde das auch einiges erleichtern wenn das über das System laufen könnte.
Mail 2;
vielen Dank für das Gespräch. Anbei fasse ich die besprochenen Punkte zusammen.
1. Kundennamen erfassen
Ich habe mir Ihren API-Aufruf angeschaut, um zu verstehen, wie Sie Ihre Checkout-Sitzungen erstellen. Aktuell wird der Kundenname in den Stripe-Transaktionen nicht angezeigt. Das liegt daran, dass der Name weder an Stripe weitergegeben noch im Checkout abgefragt wird. Eine Anpassung über das Dashboard ist hier leider nicht möglich, da Sie die Checkout-Sitzung per API erstellen. Es ist aber etwas, das Sie technisch kontrollieren und umsetzen können. Es gibt zwei Möglichkeiten:
Option A: Sie geben den Namen an Stripe weiter, so wie Sie es bereits mit der E-Mail-Adresse machen. Die E-Mail des Kunden wird im Bestellformular erfasst und dann an Stripe übergeben – genau das Gleiche können Sie auch mit dem Namen tun. Der Kunde muss den Namen dann nicht erneut im Checkout eingeben.
Option B: Sie lassen ein Feld „Kundenname / Voller Name" direkt im Checkout erscheinen, sodass der Kunde den Namen dort selbst eingibt. Das wird derzeit nicht gemacht, kann aber aktiviert werden.
Beide Optionen erfordern eine kleine Anpassung am API-Aufruf. Falls Sie oder jemand in Ihrem Team sich damit auskennt, hier die Dokumentationen:
Customers erstellen (für Option A): https://docs.stripe.com/api/customers/create
Name Collection im Checkout (für Option B): https://docs.stripe.com/payments/checkout/name-collection
Checkout Session erstellen – API-Referenz: https://docs.stripe.com/api/checkout/sessions/create
Ich empfehle, die Anpassung zunächst in Ihrer Testumgebung umzusetzen. Dort können Sie Testzahlungen durchführen und prüfen, ob der Kundenname in den Transaktionen und Berichten so auftaucht, wie Sie es benötigen, ohne Ihre aktuelle Live-Integration zu stören.
2. Metadaten für die Buchhaltung
Sie übergeben bereits Metadaten an Stripe, konkret reference, shopping_order_id und shopping_payment_id. Das ist genau der richtige Ansatz. Wie besprochen lassen sich diese Felder in den Berichten als Spalten für den CSV-Export auswählen. Die Frage ist: Welche zusätzlichen Informationen brauchen Sie in den Berichten, damit die Buchhaltung möglichst effizient läuft?
Alles, was als Metadaten übergeben wird, kann als eigene Spalte exportiert werden – z.B. Rechnungsnummer, Kostenstelle oder andere interne Referenzen. Da die technische Umsetzung bei Ihnen bereits steht, ist es eine Abstimmung mit Ihren Entwicklern, welche Felder noch ergänzt werden sollen.
Dokumentation:
Metadaten – Übersicht: https://docs.stripe.com/api/metadata
Checkout Session – Metadaten: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-metadata
Payment Intent – Metadaten: https://docs.stripe.com/api/payment_intents/create#create_payment_intent-metadata
3. Berichte
Zur Erinnerung – die zwei Berichte, die für Ihren Anwendungsfall relevant sind:
Bilanzbericht (https://docs.stripe.com/reports/balance) – Funktioniert wie ein Kontoauszug: zeigt alles, was Ihr Stripe-Saldo in einem Zeitraum verändert hat. Ideal für einen Gesamtüberblick über Umsätze und Bewegungen.
Auszahlungsabstimmung (https://docs.stripe.com/reports/payout-reconciliation) – Zeigt pro Auszahlung auf Ihr Bankkonto, welche Einzelzahlungen darin enthalten sind. Ideal, wenn Sie einen Bankeingang den zugrundeliegenden Transaktionen zuordnen möchten.
Beide Berichte können Sie wöchentlich automatisch per E-Mail erhalten (Dashboard → Berichte → Planen).
Empfohlene nächste Schritte:
Intern klären, welche Daten Sie in den Berichten benötigen
Mit Ihren Entwicklern besprechen: Kundennamen übergeben (Option A oder B) + ggf. weitere Metadaten ergänzen
Änderungen in der Testumgebung umsetzen und mit Testzahlungen validieren
Prüfen, ob der Kundenname und die Metadaten in den Berichten so auftauchen, wie Sie es benötigen
Nach erfolgreicher Prüfung live schalten
Falls Fragen aufkommen, von Ihnen oder von Ihrem Entwicklerteam, melden Sie sich jederzeit.
Beste Grüße,
Mehmet Ergün
On Wed, Jul 29, 2026 at 9:46 AM Mehmet Ergün <mehmete@stripe.com> wrote:
Hallo Frau Sechtenbeck,
das lässt sich gut lösen. Stripe bietet genau dafür eingebaute Berichte, die Sie auch wöchentlich automatisch erhalten können.
Zwei Berichte, die für Sie relevant sind:
1. Saldenbericht (Balance Report) -> https://docs.stripe.com/reports/balance
Funktioniert wie ein Kontoauszug für Ihr Stripe-Guthaben
Zeigt alles, was Ihr Stripe-Saldo in einem Zeitraum verändert hat: Zahlungseingänge, Rückerstattungen, Gebühren
Ideal wenn Sie: einen Gesamtüberblick über alle Umsätze/Bewegungen in einem bestimmten Zeitraum brauchen
2. Bericht zum Auszahlungsabgleich (Payout Reconciliation) -> https://docs.stripe.com/reports/payout-reconciliation
Zeigt Ihnen pro Auszahlung auf Ihr Bankkonto, welche einzelnen Zahlungen darin enthalten sind
Ideal wenn Sie: einen konkreten Betrag auf Ihrem Bankkonto den zugrundeliegenden Einzelzahlungen und Rechnungen zuordnen möchten
Wöchentlichen Bericht einrichten:
Im Stripe Dashboard auf „Berichte" gehen -> https://dashboard.stripe.com/reports
Den gewünschten Bericht auswählen
Obere auf „Planen" (Schedule) klicken
Frequenz auf „Wöchentlich" setzen
E-Mail-Empfänger eintragen – Sie bekommen dann automatisch eine Email Benachrichtung
Lassen Sie mich wissen, falls es noch Fragen gibt!

View file

@ -219,4 +219,30 @@ return [
'packstation_alert_location' => 'Standort der Packstation', 'packstation_alert_location' => 'Standort der Packstation',
'packstation_alert_not_home' => 'nicht Ihre Wohnadresse!', 'packstation_alert_not_home' => 'nicht Ihre Wohnadresse!',
'packstation_alert_footer' => 'Die Packstation-NUMMER ist 3-stellig (100-999, steht auf dem gelben Schild). Die DHL Postnummer ist 6-10-stellig und kommt ins separate Feld oben.', 'packstation_alert_footer' => 'Die Packstation-NUMMER ist 3-stellig (100-999, steht auf dem gelben Schild). Die DHL Postnummer ist 6-10-stellig und kommt ins separate Feld oben.',
// Zahlungseingang manuell erfassen (SE4, dev/2026-08-26-stripe/)
'manual_record_title' => 'Zahlungseingang erfassen',
'manual_record_button' => 'Zahlungseingang erfassen',
'manual_record_intro' => 'Für Zahlungen, die der Zahlungsanbieter nicht automatisch zuordnen konnte — z. B. eine Vorkasse-Überweisung mit falschem Verwendungszweck. Das System erstellt anschließend Rechnung, Umsatz/Punkte und die Bestätigungsmail wie bei einer regulären Zahlung.',
'manual_record_bank_hint' => 'Hinweis: Das Geld ist auf dem Bankkonto eingegangen, nicht beim Zahlungsanbieter. Der Vorgang taucht deshalb nicht in den Stripe-Berichten auf, sondern nur in Rechnung und DATEV-Export.',
'manual_paid_at' => 'Zahlungsdatum (Valuta)',
'manual_amount' => 'Erhaltener Betrag',
'manual_method' => 'Zahlungsweg',
'manual_method_bank_transfer' => 'Banküberweisung',
'manual_method_cash' => 'Bar',
'manual_method_other' => 'Sonstiges',
'manual_bank_reference' => 'Verwendungszweck / Bankreferenz',
'manual_bank_reference_help' => 'Pflichtfeld — dient als Beleg für die Buchung.',
'manual_note' => 'Notiz (optional)',
'manual_send_mail' => 'Bestätigungsmail an den Kunden senden',
'manual_confirm_deviation' => 'Abweichenden Betrag bewusst erfassen',
'manual_expected_amount' => 'Erwarteter Betrag',
'manual_recorded_by' => 'Manuell erfasst',
'manual_success' => 'Zahlungseingang wurde erfasst. Rechnung, Umsatz und Mail wurden ausgelöst.',
'manual_error_already_paid' => 'Diese Bestellung ist bereits als bezahlt erfasst.',
'manual_error_cancelled' => 'Diese Bestellung ist storniert und kann nicht als bezahlt erfasst werden.',
'manual_error_payment_mismatch' => 'Die Zahlung gehört nicht zu dieser Bestellung.',
'manual_error_reference_required' => 'Bitte den Verwendungszweck bzw. die Bankreferenz angeben — sie ist der Beleg für die Buchung.',
'manual_error_future_date' => 'Das Zahlungsdatum darf nicht in der Zukunft liegen.',
'manual_error_amount_deviation' => 'Der Betrag weicht vom Rechnungsbetrag (:expected) ab. Bitte die Abweichung bestätigen.',
'manual_error_general' => 'Der Zahlungseingang konnte nicht erfasst werden. Details stehen im Payment-Log.',
]; ];

View file

@ -215,4 +215,30 @@ return [
'checkout_maintenance' => 'Ordering is currently not possible due to technical difficulties. We will be back shortly — please try again later.', 'checkout_maintenance' => 'Ordering is currently not possible due to technical difficulties. We will be back shortly — please try again later.',
'abo_payments_frozen_title' => 'Subscription payments temporarily unavailable', 'abo_payments_frozen_title' => 'Subscription payments temporarily unavailable',
'abo_payments_frozen' => 'Subscription payments are currently not possible. We are working hard on a technical solution — please try again later. Your subscriptions and orders will not be lost.', 'abo_payments_frozen' => 'Subscription payments are currently not possible. We are working hard on a technical solution — please try again later. Your subscriptions and orders will not be lost.',
// Manually recorded incoming payment (SE4)
'manual_record_title' => 'Record incoming payment',
'manual_record_button' => 'Record incoming payment',
'manual_record_intro' => 'For payments the provider could not match automatically — for example a prepayment transfer with a wrong reference. The system then creates the invoice, sales volume and confirmation mail just like for a regular payment.',
'manual_record_bank_hint' => 'Note: the money arrived in the bank account, not at the payment provider. The transaction therefore does not show up in the Stripe reports, only in the invoice and the DATEV export.',
'manual_paid_at' => 'Payment date (value date)',
'manual_amount' => 'Amount received',
'manual_method' => 'Payment method',
'manual_method_bank_transfer' => 'Bank transfer',
'manual_method_cash' => 'Cash',
'manual_method_other' => 'Other',
'manual_bank_reference' => 'Payment reference',
'manual_bank_reference_help' => 'Required — serves as the record for this booking.',
'manual_note' => 'Note (optional)',
'manual_send_mail' => 'Send confirmation mail to the customer',
'manual_confirm_deviation' => 'Deliberately record a deviating amount',
'manual_expected_amount' => 'Expected amount',
'manual_recorded_by' => 'Recorded manually',
'manual_success' => 'Incoming payment recorded. Invoice, sales volume and mail have been triggered.',
'manual_error_already_paid' => 'This order is already recorded as paid.',
'manual_error_cancelled' => 'This order is cancelled and cannot be recorded as paid.',
'manual_error_payment_mismatch' => 'The payment does not belong to this order.',
'manual_error_reference_required' => 'Please provide the payment reference — it is the record for this booking.',
'manual_error_future_date' => 'The payment date cannot be in the future.',
'manual_error_amount_deviation' => 'The amount differs from the invoice amount (:expected). Please confirm the deviation.',
'manual_error_general' => 'The incoming payment could not be recorded. Details are in the payment log.',
]; ];

View file

@ -710,7 +710,19 @@
@endif @endif
</td> </td>
<td>{{ $shopping_payment->created_at->format('d.m.Y H:i') }}</td> <td>{{ $shopping_payment->created_at->format('d.m.Y H:i') }}</td>
<td>{{ $shopping_payment->reference }}</td> <td>
{{ $shopping_payment->reference }}
@if ($isAdmin && !$shopping_order->paid && $shopping_order->txaction !== 'cancelled')
<button type="button" class="btn btn-sm btn-outline-success mt-1"
data-toggle="modal" data-target="#modals-manual-payment"
data-payment_id="{{ $shopping_payment->id }}"
data-amount="{{ number_format($shopping_payment->amount / 100, 2, '.', '') }}"
data-payment_label="{{ $shopping_payment->getPaymentType() }} · {{ $shopping_payment->getPaymentAmount() }}">
<i class="fa fa-euro-sign"></i>
{{ __('payment.manual_record_button') }}
</button>
@endif
</td>
</tr> </tr>
@if ($isAdmin && $shopping_payment->payment_transactions) @if ($isAdmin && $shopping_payment->payment_transactions)
@php($ccount = 0) @php($ccount = 0)
@ -732,7 +744,26 @@
<td>{{ $payment_transaction->request }}</td> <td>{{ $payment_transaction->request }}</td>
<td>{{ $payment_transaction->status }}</td> <td>{{ $payment_transaction->status }}</td>
<td>{{ $payment_transaction->txaction }} <td>{{ $payment_transaction->txaction }}
{{ $payment_transaction->errormessage }}</td> {{ $payment_transaction->errormessage }}
@if ($payment_transaction->request === 'manual_payment' && is_array($payment_transaction->transmitted_data))
@php($manual = $payment_transaction->transmitted_data)
<div class="small text-muted">
<strong>{{ __('payment.manual_recorded_by') }}:</strong>
{{ $manual['recorded_by_email'] ?? '—' }},
{{ __('payment.manual_paid_at') }}:
{{ isset($manual['paid_at']) ? \Carbon::parse($manual['paid_at'])->format('d.m.Y') : '—' }},
{{ __('payment.manual_bank_reference') }}:
{{ $manual['bank_reference'] ?? '—' }}
@if (isset($manual['amount_matches']) && !$manual['amount_matches'])
<span class="badge badge-warning">
{{ number_format(($manual['amount_cents'] ?? 0) / 100, 2, ',', '.') }} €
({{ __('payment.manual_expected_amount') }}:
{{ number_format(($manual['expected_cents'] ?? 0) / 100, 2, ',', '.') }} €)
</span>
@endif
</div>
@endif
</td>
<td>{{ $payment_transaction->created_at->format('d.m.Y H:i') }} <td>{{ $payment_transaction->created_at->format('d.m.Y H:i') }}
</td> </td>
</tr> </tr>
@ -890,6 +921,96 @@
</div> </div>
</div> </div>
{{-- Zahlungseingang manuell erfassen (SE4, dev/2026-08-26-stripe/) --}}
<div class="modal fade" id="modals-manual-payment">
<div class="modal-dialog modal-lg">
<form class="modal-content form-prevent-multiple-submits"
action="{{ route('admin_sales_payment_manual') }}" method="post">
@csrf
<input type="hidden" name="id" value="{{ $shopping_order->id }}">
<input type="hidden" name="payment_id">
<input type="hidden" name="back" value="{{ url()->current() }}">
<div class="modal-header">
<h5 class="modal-title">{{ __('payment.manual_record_title') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="alert alert-info small">{{ __('payment.manual_record_intro') }}</div>
<p class="small text-muted mb-3">
{{ __('order.order') }} #{{ $shopping_order->id }} ·
<span class="js-manual-payment-label"></span>
</p>
<div class="row">
<div class="form-group col-sm-6">
<label class="form-label" for="manual_paid_at">{{ __('payment.manual_paid_at') }}</label>
<input type="date" class="form-control" id="manual_paid_at" name="paid_at"
max="{{ now()->format('Y-m-d') }}" value="{{ old('paid_at', now()->format('Y-m-d')) }}"
required>
</div>
<div class="form-group col-sm-6">
<label class="form-label" for="manual_amount">{{ __('payment.manual_amount') }}</label>
<div class="input-group">
<input type="number" step="0.01" min="0.01" class="form-control" id="manual_amount"
name="amount" value="{{ old('amount') }}" required>
<div class="input-group-append">
<span class="input-group-text">EUR</span>
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="manual_method">{{ __('payment.manual_method') }}</label>
<select class="custom-select" id="manual_method" name="method" required>
@foreach (\App\Services\ManualPaymentService::methods() as $key => $label)
<option value="{{ $key }}" @selected(old('method') === $key)>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="form-group">
<label class="form-label"
for="manual_bank_reference">{{ __('payment.manual_bank_reference') }}</label>
<input type="text" class="form-control" id="manual_bank_reference" name="bank_reference"
maxlength="255" value="{{ old('bank_reference') }}" required>
<small class="form-text text-muted">{{ __('payment.manual_bank_reference_help') }}</small>
</div>
<div class="form-group">
<label class="form-label" for="manual_note">{{ __('payment.manual_note') }}</label>
<textarea class="form-control" id="manual_note" name="note" rows="2"
maxlength="1000">{{ old('note') }}</textarea>
</div>
<div class="custom-control custom-checkbox mb-2">
<input type="checkbox" class="custom-control-input" id="manual_send_mail" name="send_mail"
value="1" checked>
<label class="custom-control-label"
for="manual_send_mail">{{ __('payment.manual_send_mail') }}</label>
</div>
<div class="custom-control custom-checkbox mb-3">
<input type="checkbox" class="custom-control-input" id="manual_confirm_deviation"
name="confirm_deviation" value="1">
<label class="custom-control-label"
for="manual_confirm_deviation">{{ __('payment.manual_confirm_deviation') }}</label>
</div>
<div class="alert alert-warning small mb-0">{{ __('payment.manual_record_bank_hint') }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit" class="btn btn-success button-prevent-multiple-submits">
<i class="spinner fa fa-spinner fa-spin"></i>
{{ __('payment.manual_record_button') }}
</button>
</div>
</form>
</div>
</div>
<div class="modal fade" id="modals-cancellation-invoice"> <div class="modal fade" id="modals-cancellation-invoice">
<div class="modal-dialog"> <div class="modal-dialog">
<form class="modal-content form-prevent-multiple-submits" <form class="modal-content form-prevent-multiple-submits"
@ -950,6 +1071,13 @@
<script> <script>
$(document).ready(function() { $(document).ready(function() {
$('#modals-manual-payment').on('show.bs.modal', function(event) {
var button = $(event.relatedTarget);
$(this).find("input[name='payment_id']").val(button.data('payment_id'));
$(this).find("input[name='amount']").val(button.data('amount'));
$(this).find('.js-manual-payment-label').text(button.data('payment_label') || '');
});
$('#modals-txaction').on('show.bs.modal', function(event) { $('#modals-txaction').on('show.bs.modal', function(event) {
var button = $(event.relatedTarget); var button = $(event.relatedTarget);
$(this).find(".modal-content input[name='payment_id']").val(button.data('payment_id')); $(this).find(".modal-content input[name='payment_id']").val(button.data('payment_id'));

View file

@ -366,6 +366,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/admin/sales/customers/datatable', 'SalesController@customersDatatable')->name('admin_sales_customers_datatable'); Route::get('/admin/sales/customers/datatable', 'SalesController@customersDatatable')->name('admin_sales_customers_datatable');
Route::post('/admin/sales/store', 'SalesController@store')->name('admin_sales_store'); Route::post('/admin/sales/store', 'SalesController@store')->name('admin_sales_store');
Route::post('/admin/sales/payment/manual', 'SalesController@recordManualPayment')->name('admin_sales_payment_manual');
Route::post('/admin/sales/invoice', 'SalesController@invoice')->name('admin_sales_invoice'); Route::post('/admin/sales/invoice', 'SalesController@invoice')->name('admin_sales_invoice');
Route::post('/admin/sales/invoice/cancellation', 'SalesController@invoiceCancellation')->name('admin_sales_invoice_cancellation'); Route::post('/admin/sales/invoice/cancellation', 'SalesController@invoiceCancellation')->name('admin_sales_invoice_cancellation');

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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session; use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\CardException; use Stripe\Exception\CardException;
use Stripe\PaymentIntent; use Stripe\PaymentIntent;
use Stripe\StripeClient; use Stripe\StripeClient;
@ -105,15 +106,26 @@ function stripeAboUserAbo(array $overrides = []): UserAbo
} }
/** /**
* Stripe-Client-Mock, der genau einen request()-Aufruf beantwortet und die * Stripe-Client-Mock, der genau einen fachlichen request()-Aufruf beantwortet
* Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest). * 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 function stripeAboMockClient(mixed $result, ?array &$captured = null): void
{ {
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]); $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') $expectation = $client->shouldReceive('request')
->once() ->once()
->withArgs(function (...$args) use (&$captured) { ->withArgs(function (...$args) use (&$captured) {
if (($args[1] ?? null) === '/v1/customers') {
return false;
}
$captured = $args; $captured = $args;
return true; return true;
@ -171,7 +183,10 @@ it('requests off-session saving and customer creation for abo checkout sessions'
$params = $captured[2]; $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'); ->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]; $params = $captured[2];
expect($params)->not->toHaveKey('customer_creation') expect($params)->not->toHaveKey('customer_creation')
->and($params['customer'])->toBe('cus_abo_checkout')
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage'); ->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\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData; use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Stripe\StripeClientWrapper; use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\Services\Payment\Stripe\StripePaymentService; use App\Services\Payment\Stripe\StripePaymentService;
use App\Services\Payment\Stripe\StripeWebhookHandler; use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User; use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session; use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\ApiConnectionException;
use Stripe\StripeClient; use Stripe\StripeClient;
use Tests\TestCase; use Tests\TestCase;
@ -114,6 +117,42 @@ function handleStripeCheckoutEvent(string $type, array $object, string $eventId)
app(StripeWebhookHandler::class)->handle($payload, stripeCheckoutSignature($payload)); 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) --- // --- Session-Erstellung (S3.2) ---
it('creates a checkout session with payment and initiation transaction', function () { 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(); $order->shopping_payments()->delete();
$capturedParams = null; $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'; $result = stripeCheckoutServiceWithMock($capturedParams)->initiateCheckoutPayment(
})
->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(
$shoppingUser, $shoppingUser,
$order, $order,
8900, 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(); ->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) --- // --- Paid-Pfad (S3.5) ---
it('marks the order paid and sends one mail on payment_intent succeeded', function () { 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');
});