From 68d230d4e5f32ea0135f91db62f9bab5394d575a Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 28 Aug 2026 09:04:28 +0000 Subject: [PATCH] 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) --- .../Commands/StripeBackfillCustomers.php | 135 ++++ .../StripeBackfillInvoiceMetadata.php | 107 +++ .../Commands/StripeSyncPaymentStatus.php | 223 +++++++ app/Console/Kernel.php | 10 + app/Exceptions/ManualPaymentException.php | 12 + app/Http/Controllers/SalesController.php | 46 ++ .../Payment/RecordManualPaymentRequest.php | 103 +++ app/Jobs/SyncStripeInvoiceReference.php | 48 ++ app/Models/ShoppingUser.php | 25 +- app/Services/GdprDeletionService.php | 81 +++ app/Services/ManualPaymentService.php | 177 +++++ app/Services/Payment.php | 10 + .../Payment/Stripe/StripeCustomerService.php | 146 ++++- .../Stripe/StripeInvoiceReferenceService.php | 114 ++++ .../Payment/Stripe/StripeMetadataBuilder.php | 179 +++++ .../Payment/Stripe/StripePaymentService.php | 86 ++- .../Payment/Stripe/StripeWebhookHandler.php | 41 +- ...d_stripe_customer_id_to_shopping_users.php | 33 + .../ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md | 615 ++++++++++++++++++ dev/2026-08-26-stripe/GO-LIVE-CHECKLISTE.md | 117 ++++ dev/2026-08-26-stripe/stripe-erweiterung.md | 83 +++ resources/lang/de/payment.php | 26 + resources/lang/en/payment.php | 26 + resources/views/admin/sales/_detail.blade.php | 132 +++- routes/domains/crm.php | 1 + tests/Feature/ManualPaymentTest.php | 307 +++++++++ tests/Feature/StripeAboTest.php | 22 +- tests/Feature/StripeCheckoutTest.php | 236 ++++++- tests/Feature/StripeInvoiceReferenceTest.php | 187 ++++++ tests/Feature/StripeSyncPaymentStatusTest.php | 226 +++++++ .../Services/StripeMetadataBuilderTest.php | 123 ++++ 31 files changed, 3612 insertions(+), 65 deletions(-) create mode 100644 app/Console/Commands/StripeBackfillCustomers.php create mode 100644 app/Console/Commands/StripeBackfillInvoiceMetadata.php create mode 100644 app/Console/Commands/StripeSyncPaymentStatus.php create mode 100644 app/Exceptions/ManualPaymentException.php create mode 100644 app/Http/Requests/Payment/RecordManualPaymentRequest.php create mode 100644 app/Jobs/SyncStripeInvoiceReference.php create mode 100644 app/Services/ManualPaymentService.php create mode 100644 app/Services/Payment/Stripe/StripeInvoiceReferenceService.php create mode 100644 app/Services/Payment/Stripe/StripeMetadataBuilder.php create mode 100644 database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php create mode 100644 dev/2026-08-26-stripe/ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md create mode 100644 dev/2026-08-26-stripe/GO-LIVE-CHECKLISTE.md create mode 100644 dev/2026-08-26-stripe/stripe-erweiterung.md create mode 100644 tests/Feature/ManualPaymentTest.php create mode 100644 tests/Feature/StripeInvoiceReferenceTest.php create mode 100644 tests/Feature/StripeSyncPaymentStatusTest.php create mode 100644 tests/Unit/Services/StripeMetadataBuilderTest.php diff --git a/app/Console/Commands/StripeBackfillCustomers.php b/app/Console/Commands/StripeBackfillCustomers.php new file mode 100644 index 00000000..b3eac0b1 --- /dev/null +++ b/app/Console/Commands/StripeBackfillCustomers.php @@ -0,0 +1,135 @@ +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'); + } +} diff --git a/app/Console/Commands/StripeBackfillInvoiceMetadata.php b/app/Console/Commands/StripeBackfillInvoiceMetadata.php new file mode 100644 index 00000000..acbe086d --- /dev/null +++ b/app/Console/Commands/StripeBackfillInvoiceMetadata.php @@ -0,0 +1,107 @@ +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; + } +} diff --git a/app/Console/Commands/StripeSyncPaymentStatus.php b/app/Console/Commands/StripeSyncPaymentStatus.php new file mode 100644 index 00000000..406bf38c --- /dev/null +++ b/app/Console/Commands/StripeSyncPaymentStatus.php @@ -0,0 +1,223 @@ +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 + */ + 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}'."); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 0ee69b0b..db9b62d0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -59,6 +59,16 @@ class Kernel extends ConsoleKernel ->withoutOverlapping() ->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). // BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP // im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren: diff --git a/app/Exceptions/ManualPaymentException.php b/app/Exceptions/ManualPaymentException.php new file mode 100644 index 00000000..58bae40b --- /dev/null +++ b/app/Exceptions/ManualPaymentException.php @@ -0,0 +1,12 @@ +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.*/ public function invoice() @@ -419,6 +461,10 @@ class SalesController extends Controller $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') { return redirect(route('admin_sales_customers_detail', [$shopping_order->id])); } diff --git a/app/Http/Requests/Payment/RecordManualPaymentRequest.php b/app/Http/Requests/Payment/RecordManualPaymentRequest.php new file mode 100644 index 00000000..122b8b0b --- /dev/null +++ b/app/Http/Requests/Payment/RecordManualPaymentRequest.php @@ -0,0 +1,103 @@ +> + */ + 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 + */ + 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'), + ]; + } +} diff --git a/app/Jobs/SyncStripeInvoiceReference.php b/app/Jobs/SyncStripeInvoiceReference.php new file mode 100644 index 00000000..a6c30a61 --- /dev/null +++ b/app/Jobs/SyncStripeInvoiceReference.php @@ -0,0 +1,48 @@ +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, + ]); + } +} diff --git a/app/Models/ShoppingUser.php b/app/Models/ShoppingUser.php index 23c5bcbb..d734978b 100644 --- a/app/Models/ShoppingUser.php +++ b/app/Models/ShoppingUser.php @@ -3,8 +3,11 @@ namespace App\Models; use App\Services\ShoppingUserService; +use App\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; /** * App\Models\ShoppingUser @@ -22,6 +25,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int $billing_country_id * @property string|null $billing_phone * @property string|null $billing_email + * @property string|null $stripe_customer_id * @property int $accepted_data_checkbox * @property int $same_as_billing * @property string|null $shipping_salutation @@ -34,12 +38,12 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property string|null $shipping_city * @property int $shipping_country_id * @property string|null $shipping_phone - * @property \Illuminate\Support\Carbon|null $created_at - * @property \Illuminate\Support\Carbon|null $updated_at - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $Shopping_orders + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read Collection|ShoppingOrder[] $Shopping_orders * @property-read int|null $shopping_orders_count - * @property-read \App\Models\Country $billing_country - * @property-read \App\Models\Country $shipping_country + * @property-read Country $billing_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 newQuery() @@ -73,8 +77,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereUpdatedAt($value) * * @property int|null $orders - * @property-read \App\Models\ShoppingOrder $shopping_order - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders + * @property-read ShoppingOrder $shopping_order + * @property-read Collection|ShoppingOrder[] $shopping_orders * * @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 bool $is_like * @property array|null $notice - * @property-read \App\User|null $auth_user - * @property-read \App\User|null $member + * @property-read User|null $auth_user + * @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 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 whereWpOrderNumber($value) * - * @property \Illuminate\Support\Carbon|null $deleted_at + * @property Carbon|null $deleted_at * @property string|null $user_deleted_at * * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingUser onlyTrashed() @@ -172,6 +176,7 @@ class ShoppingUser extends Model 'billing_country_id', 'billing_phone', 'billing_email', + 'stripe_customer_id', 'faker_mail', 'shipping_email', 'accepted_data_checkbox', diff --git a/app/Services/GdprDeletionService.php b/app/Services/GdprDeletionService.php index 21173210..3a2bf3c7 100644 --- a/app/Services/GdprDeletionService.php +++ b/app/Services/GdprDeletionService.php @@ -9,6 +9,7 @@ use App\Models\UserAccount; use App\Models\UserBusiness; use App\Models\UserShop; use App\Models\UserUpdateEmail; +use App\Services\Payment\Stripe\StripeCustomerService; use App\User; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -27,6 +28,8 @@ use Illuminate\Support\Facades\Storage; * Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt. * - Provisions-Snapshots (user_businesses) behalten die Zahlen, die * 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 { @@ -54,6 +57,13 @@ class GdprDeletionService $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 = ['user_id' => $user->id]; @@ -85,6 +95,8 @@ class GdprDeletionService return $summary; }); + $summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds); + $this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary); return $summary; @@ -114,6 +126,8 @@ class GdprDeletionService $originalEmail = $shoppingUser->billing_email; + $stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows); + $summary = DB::transaction(function () use ($shoppingUser, $rows) { $summary = ['shopping_user_id' => $shoppingUser->id]; $summary['abos_anonymized'] = 0; @@ -125,6 +139,8 @@ class GdprDeletionService return $summary; }); + $summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds); + $this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary); return $summary; @@ -146,6 +162,7 @@ class GdprDeletionService protected function anonymizeUserRow(User $user): void { + $user->stripe_customer_id = null; $user->notes = null; $user->settings = null; $user->payment_methods = null; @@ -306,6 +323,7 @@ class GdprDeletionService protected function anonymizeShoppingUserContactData(ShoppingUser $row): void { + $row->stripe_customer_id = null; $row->billing_phone = null; $row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid'; $row->shipping_phone = null; @@ -339,6 +357,69 @@ class GdprDeletionService $this->anonymizeShoppingUserContactData($row); } + /** + * Alle Stripe-Customer-IDs des Betroffenen einsammeln (Berater, Kunden- + * Datensätze und deren Abos) — Phase SE1.6. + * + * @param Collection|\Illuminate\Database\Eloquent\Collection $shoppingUsers + * @return array + */ + 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 $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. * diff --git a/app/Services/ManualPaymentService.php b/app/Services/ManualPaymentService.php new file mode 100644 index 00000000..4916aa12 --- /dev/null +++ b/app/Services/ManualPaymentService.php @@ -0,0 +1,177 @@ + + */ + 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 $data + * @return array + */ + 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 $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, + ]); + } +} diff --git a/app/Services/Payment.php b/app/Services/Payment.php index 9c5fc3f5..eb42df9b 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -11,6 +11,7 @@ use App\Models\UserLevel; use App\Repositories\InvoiceRepository; use App\Services\BusinessPlan\SalesPointsVolume; use App\Services\Incentive\IncentiveTracker; +use App\Services\Payment\Stripe\StripeInvoiceReferenceService; use App\User; 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; diff --git a/app/Services/Payment/Stripe/StripeCustomerService.php b/app/Services/Payment/Stripe/StripeCustomerService.php index 0dae6cb7..d1729781 100644 --- a/app/Services/Payment/Stripe/StripeCustomerService.php +++ b/app/Services/Payment/Stripe/StripeCustomerService.php @@ -3,12 +3,19 @@ namespace App\Services\Payment\Stripe; use App\Models\Customer; +use App\Models\ShoppingUser; use App\User; +use Illuminate\Support\Facades\Log; /** * Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die - * ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent: - * eine vorhandene ID wird nie überschrieben. + * ID (users.stripe_customer_id / customers.stripe_customer_id / + * 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 { @@ -33,4 +40,139 @@ class StripeCustomerService 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 $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 + */ + 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|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, + ]; + } } diff --git a/app/Services/Payment/Stripe/StripeInvoiceReferenceService.php b/app/Services/Payment/Stripe/StripeInvoiceReferenceService.php new file mode 100644 index 00000000..f572eed5 --- /dev/null +++ b/app/Services/Payment/Stripe/StripeInvoiceReferenceService.php @@ -0,0 +1,114 @@ +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; + } +} diff --git a/app/Services/Payment/Stripe/StripeMetadataBuilder.php b/app/Services/Payment/Stripe/StripeMetadataBuilder.php new file mode 100644 index 00000000..2262d771 --- /dev/null +++ b/app/Services/Payment/Stripe/StripeMetadataBuilder.php @@ -0,0 +1,179 @@ +`) + * 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 + */ + 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 + */ + 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 + */ + 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 $metadata + * @return array + */ + 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 $metadata + * @return array + */ + 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; + } +} diff --git a/app/Services/Payment/Stripe/StripePaymentService.php b/app/Services/Payment/Stripe/StripePaymentService.php index 84cc31a1..6aa5d94d 100644 --- a/app/Services/Payment/Stripe/StripePaymentService.php +++ b/app/Services/Payment/Stripe/StripePaymentService.php @@ -11,6 +11,7 @@ use App\Services\CheckoutFunnelTracker; use App\Services\Payment\DTOs\AboChargeResult; use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult; use App\Services\Util; +use Illuminate\Support\Facades\Log; use Stripe\Checkout\Session; use Stripe\Exception\ApiErrorException; @@ -27,7 +28,10 @@ use Stripe\Exception\ApiErrorException; */ 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. @@ -70,17 +74,12 @@ class StripePaymentService amountCents: $amountCents, ); - $metadata = [ - 'shopping_order_id' => (string) $shoppingOrder->id, - 'shopping_payment_id' => (string) $shoppingPayment->id, - 'reference' => $reference, - 'mode' => (string) $shoppingOrder->mode, - ]; + $metadata = StripeMetadataBuilder::forCheckout($shoppingOrder, $shoppingPayment, $shoppingUser, $reference); + $description = StripeMetadataBuilder::description($shoppingOrder, $reference, $shoppingUser); $sessionParams = [ 'mode' => 'payment', 'client_reference_id' => $reference, - 'customer_email' => $shoppingUser->billing_email ?: null, 'line_items' => [[ 'quantity' => 1, 'price_data' => [ @@ -92,13 +91,28 @@ class StripePaymentService ], ]], 'metadata' => $metadata, - 'payment_intent_data' => ['metadata' => $metadata], + 'payment_intent_data' => [ + 'metadata' => $metadata, + 'description' => $description, + ], 'success_url' => $successUrl, '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)) { - $sessionParams['customer_creation'] = 'always'; $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 * (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, ]); - $metadata = [ - '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, - ]; + $metadata = StripeMetadataBuilder::forAboCharge($userAbo, $shoppingOrder, $shoppingPayment, $shoppingUser); try { $paymentIntent = $this->stripe->client()->paymentIntents->create([ @@ -187,6 +217,7 @@ class StripePaymentService 'off_session' => true, 'confirm' => true, 'metadata' => $metadata, + 'description' => StripeMetadataBuilder::description($shoppingOrder, (string) $shoppingPayment->reference, $shoppingUser), ], [ 'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id, ]); @@ -243,10 +274,7 @@ class StripePaymentService ): Session { $customerId = $this->ensureAboStripeCustomer($userAbo); - $metadata = [ - 'user_abo_id' => (string) $userAbo->id, - 'source' => (string) ($source ?? ''), - ]; + $metadata = StripeMetadataBuilder::forSetupSession($userAbo, $source); return $this->stripe->client()->checkout->sessions->create([ 'mode' => 'setup', @@ -274,13 +302,15 @@ class StripePaymentService } $shoppingUser = $userAbo->shopping_user; - $customer = $this->stripe->client()->customers->create([ - 'email' => $userAbo->email ?: null, - 'name' => $shoppingUser - ? (trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? '')) ?: null) - : null, - 'metadata' => ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id], - ]); + + $payload = $shoppingUser + ? $this->customerService->customerPayloadFor($shoppingUser) + : ['name' => null, 'metadata' => []]; + $payload['email'] = $userAbo->email ?: ($payload['email'] ?? null); + $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(); diff --git a/app/Services/Payment/Stripe/StripeWebhookHandler.php b/app/Services/Payment/Stripe/StripeWebhookHandler.php index ed2a6b2d..46ee2848 100644 --- a/app/Services/Payment/Stripe/StripeWebhookHandler.php +++ b/app/Services/Payment/Stripe/StripeWebhookHandler.php @@ -39,6 +39,17 @@ class StripeWebhookHandler '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 * Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren. @@ -137,6 +148,27 @@ class StripeWebhookHandler 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 $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 $object * @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern @@ -178,7 +210,14 @@ class StripeWebhookHandler 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)', $eventId, $eventType, diff --git a/database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php b/database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php new file mode 100644 index 00000000..7637779d --- /dev/null +++ b/database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php @@ -0,0 +1,33 @@ +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'); + } + }); + } +}; diff --git a/dev/2026-08-26-stripe/ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md b/dev/2026-08-26-stripe/ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md new file mode 100644 index 00000000..f0fcdad2 --- /dev/null +++ b/dev/2026-08-26-stripe/ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md @@ -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:`, `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:` 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 # · · "`. Ü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 " 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 ` verwenden + und das beim Start ausgegebene `whsec_…` mit `STRIPE_WEBHOOK_SECRET` abgleichen; jedes Konto + hat sein eigenes Secret. Verpasste Events mit `stripe events resend ` 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 am , Beleg: "). | +| 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 `` 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--`. 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 `` 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** diff --git a/dev/2026-08-26-stripe/GO-LIVE-CHECKLISTE.md b/dev/2026-08-26-stripe/GO-LIVE-CHECKLISTE.md new file mode 100644 index 00000000..bd3ecf25 --- /dev/null +++ b/dev/2026-08-26-stripe/GO-LIVE-CHECKLISTE.md @@ -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./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 #… · · `, **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. diff --git a/dev/2026-08-26-stripe/stripe-erweiterung.md b/dev/2026-08-26-stripe/stripe-erweiterung.md new file mode 100644 index 00000000..af355d54 --- /dev/null +++ b/dev/2026-08-26-stripe/stripe-erweiterung.md @@ -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 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! \ No newline at end of file diff --git a/resources/lang/de/payment.php b/resources/lang/de/payment.php index 1e251f67..7aadd8f7 100644 --- a/resources/lang/de/payment.php +++ b/resources/lang/de/payment.php @@ -219,4 +219,30 @@ return [ 'packstation_alert_location' => 'Standort der Packstation', '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.', + // 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.', ]; diff --git a/resources/lang/en/payment.php b/resources/lang/en/payment.php index e768ee18..d1686e56 100644 --- a/resources/lang/en/payment.php +++ b/resources/lang/en/payment.php @@ -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.', '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.', + // 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.', ]; diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index 1472d4a1..7a59db41 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -710,7 +710,19 @@ @endif {{ $shopping_payment->created_at->format('d.m.Y H:i') }} - {{ $shopping_payment->reference }} + + {{ $shopping_payment->reference }} + @if ($isAdmin && !$shopping_order->paid && $shopping_order->txaction !== 'cancelled') + + @endif + @if ($isAdmin && $shopping_payment->payment_transactions) @php($ccount = 0) @@ -732,7 +744,26 @@ {{ $payment_transaction->request }} {{ $payment_transaction->status }} {{ $payment_transaction->txaction }} - {{ $payment_transaction->errormessage }} + {{ $payment_transaction->errormessage }} + @if ($payment_transaction->request === 'manual_payment' && is_array($payment_transaction->transmitted_data)) + @php($manual = $payment_transaction->transmitted_data) +
+ {{ __('payment.manual_recorded_by') }}: + {{ $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']) + + {{ number_format(($manual['amount_cents'] ?? 0) / 100, 2, ',', '.') }} € + ({{ __('payment.manual_expected_amount') }}: + {{ number_format(($manual['expected_cents'] ?? 0) / 100, 2, ',', '.') }} €) + + @endif +
+ @endif + {{ $payment_transaction->created_at->format('d.m.Y H:i') }} @@ -890,6 +921,96 @@ + {{-- Zahlungseingang manuell erfassen (SE4, dev/2026-08-26-stripe/) --}} + +