# 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**