mivita/dev/2026-08-26-stripe/ENTWICKLUNGSPLAN-STRIPE-ERWEITERUNG.md
Kevin 68d230d4e5 Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang
Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/):
Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard
weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein
Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die
Rechnung von Hand zu schreiben.

- SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und
  Rechnungsanschrift an die Session (customer statt customer_email); die ID
  liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname
  Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die
  Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter.
  DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges
  bleiben als Buchungsbeleg bestehen.
- SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die
  description (Bestellnummer, Referenz, Kundenname) - damit greift die
  Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam.
- SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per
  Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im
  Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie
  rueckwirkend ab dem Go-Live 2026-07-10 nach.
- SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten
  Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur
  paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft
  durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und
  laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu.
- S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht
  offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie
  zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe
  waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler.
- 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:04:28 +00:00

48 KiB
Raw Blame History

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); customer_creation: 'always' (nur bei Abos, :101) 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). 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). 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). store_txaction setzt paid = true, erzeugt aber keine Rechnung, keine Punkte, keine Abo-Aktivierung, keine Mail (SalesController.php:371) — deshalb muss die Buchhaltung die Rechnung von Hand schreiben. SE4
5 Teilstornierungen & Gutschriften (Mail 1) createCancellation() storniert nur vollständig (InvoiceRepository.php:273). Stripe-Refunds sind nicht implementiert; charge.refunded wird nur protokolliert (StripeWebhookHandler.php:220). OrderWithdrawal kennt TYPE_PARTIAL + items, löst aber keine Erstattung aus. SE5
6 Berichte für die Buchhaltung (Mail 2 §3) Kein Code betroffen — Konfiguration im Stripe-Dashboard + Abstimmung, welche Metadaten-Spalten die Buchhaltung braucht (Rückkopplung auf SE2/SE3). SE6

Verifizierte Stripe-Fakten (Recherche 2026-08-26)

  • Dashboard-Suche findet Metadaten bereits heute (metadata:<wert>, metadata:key=wert oder der nackte Wert) sowie standardmäßig die description eines Objekts. → Anforderung 2 ist zu 80 % eine Frage der description, nicht der Metadaten.
  • Kundenname: zwei Wege, beide von Stripe bestätigt.
    • Option A — Customer-Objekt mit name anlegen und per customer an die Session hängen. Wirkt in Zahlungsliste und in Balance-/Payout-Reports (Spalten customer_name, customer_email).
    • Option B — name_collection[individual][enabled]=true in der Session: Stripe fragt den Namen im Checkout ab (zusätzliches Pflicht-/Optionalfeld, landet in collected_information.individual_name).
  • Metadaten-Limits: max. 50 Keys, Key ≤ 40 Zeichen, Wert ≤ 500 Zeichen, keine eckigen Klammern, keine sensiblen Daten.
  • customer und customer_email schließen sich gegenseitig aus; ebenso customer und customer_creation. Adress-/Namensrückschreibung aus dem Checkout braucht customer_update[name]=auto.

2. Phasen-Übersicht

Phase Titel Aufwand Priorität Abhängigkeit
SE1 Kundendaten (Name/Adresse) an Stripe übergeben 2–3 PT P1 –
SE2 Metadaten & Suchbarkeit vereinheitlichen (description, zentraler Builder) 1–2 PT P1 SE1 (gleicher Code-Pfad)
SE3 Rechnungsnummer an Stripe nachtragen (+ Backfill) 2–3 PT P1 SE2
SE4 Zahlungseingang manuell erfassen („Rechnung auf bezahlt setzen") 3–4 PT P1 – (unabhängig)
SE5 Teilstorno, Gutschrift & Stripe-Refund 8–12 PT P2 SE3 (Rechnungsbezug), Klärung §6
SE6 Stripe-Berichte & Buchhaltungs-Abstimmung 0,5 PT (org.) P2 SE2/SE3

Empfohlene Reihenfolge: SE1+SE2 in einem Rutsch (ein einziger Umbau von initiateCheckoutPayment), dann SE4 (größter Sofort-Nutzen für die Buchhaltung, kein Stripe-Risiko), dann SE3, dann SE6 (organisatorisch, parallel), zuletzt SE5.


3. Phase SE1 – Kundendaten an Stripe übergeben · Status: ERLEDIGT ✅ (2026-08-26)

Ziel

Jede Stripe-Transaktion trägt Name, E-Mail und (soweit vorhanden) Anschrift des Kunden — sichtbar in Zahlungsliste, Customer-Objekt und in den CSV-Berichten.

Entscheidung: Option A (Customer-Objekt), Option B nur als Ergänzung

Option A – Name mitgeben Option B – Name im Checkout abfragen
Kunde tippt nichts zusätzlich tippt Namen erneut (Conversion-Risiko)
Datenqualität exakt die Rechnungsanschrift aus unserem System frei eingegeben, kann abweichen
Reports customer_name/customer_email-Spalten gefüllt nur collected_information
Abos Customer wird ohnehin gebraucht (setup_future_usage) zusätzlich nötig

→ Option A. Option B (name_collection) wird nicht aktiviert, solange die Rechnungsanschrift im Checkout ohnehin vollständig erhoben wird. Aufnehmen in §6 als Rückfrage, falls die Buchhaltung den vom Kunden bei Stripe getippten Namen ausdrücklich will.

Aufgaben

# Aufgabe
SE1.1 Migration: shopping_users.stripe_customer_id (nullable, indiziert) — analog zu users/customers.
SE1.2 StripeCustomerService um customerIdForShoppingUser(ShoppingUser $u): string erweitern (idempotent, vorhandene ID nie überschreiben — Muster der bestehenden customerIdFor()-Methode). Name = billing_company bzw. billing_firstname + billing_lastname; zusätzlich email, address, phone, metadata.mivita_type = 'shopping_user'.
SE1.3 StripePaymentService::initiateCheckoutPayment(): customer_email ersetzen durch customer = Ergebnis aus SE1.2. customer_creation entfällt (mit customer nicht erlaubt). setup_future_usage bleibt unverändert für Abo/Recovery.
SE1.4 customer_update bewusst nicht setzen: Adressänderungen des Kunden bei Stripe dürfen unsere Rechnungsanschrift nicht überschreiben. Stattdessen: bei jedem Checkout Name/Adresse am Customer aktualisieren, wenn sie sich in unserem System geändert haben (customers->update, best effort, Fehler dürfen den Checkout nie kippen).
SE1.5 Bestands-Customer ohne Namen reparieren: Command stripe:backfill-customers [--dry-run] [--limit=] — geht über user_abos.stripe_customer_id und shopping_payments.stripe_customer_id und setzt fehlende name/address nach.
SE1.6 DSGVO (verbindlich, F4: „ja, muss dann sogar"): GdprDeletionService löscht heute nur die IDs am Abo (GdprDeletionService.php:258). Ergänzen um customers->delete() für alle bekannten Stripe-Customer-IDs des Betroffenen (User, Customer, ShoppingUser, UserAbo) inkl. Leeren der Spalten. Die Charges bleiben bei Stripe erhalten (10-jährige Aufbewahrungspflicht) — gelöscht wird nur das Customer-Objekt. Fehler beim Löschen werden protokolliert, brechen die Löschung aber nicht ab.
SE1.7 Fehlerpfad: schlägt die Customer-Anlage fehl, darf der Checkout nicht scheitern — Fallback auf das heutige customer_email + customer_creation und Warnung im stripe-Log.

Risiken

  • Customer-Wildwuchs: Ein Customer je ShoppingUser statt je Zahlung — durch die persistierte ID entsteht pro Kunde genau einer. Gastbestellungen legen weiterhin je neuem ShoppingUser einen an (systembedingt, wie heute schon bei Abos).
  • Gespeicherte Zahlungsmittel: Ein angehängter Customer führt bei Einmalzahlungen nicht zur Speicherung der Zahlart — das passiert nur mit setup_future_usage. Verhalten für Nicht-Abo-Kunden bleibt unverändert.

Tests

  • initiateCheckoutPayment übergibt customer (nicht customer_email), Customer trägt Namen (Client gemockt).
  • Zweiter Checkout desselben ShoppingUser legt keinen zweiten Customer an.
  • Customer-Anlage wirft → Session wird trotzdem erstellt (Fallback-Pfad), Warnung geloggt.
  • Abo-Checkout: setup_future_usage weiterhin gesetzt, customer_creation nicht mehr vorhanden.
  • Regression: tests/Feature/StripeCheckoutTest.php, StripeAboTest.php grün.

Akzeptanzkriterien

  • Im Stripe-Dashboard steht bei jeder neuen Zahlung der Kundenname in der Spalte „Kunde".
  • Balance-Report-Export enthält gefüllte customer_name/customer_email-Spalten.
  • Suche name:<Nachname> findet die Zahlung.

4. Phase SE2 – Metadaten & Suchbarkeit · Status: ERLEDIGT ✅ (2026-08-26)

Ziel

Buchhaltung findet jede Zahlung über Bestellnummer, Referenz oder (nach SE3) Rechnungsnummer — im Dashboard und im CSV-Export.

Aufgaben

# Aufgabe
SE2.1 Neue Klasse App\Services\Payment\Stripe\StripeMetadataBuilder — eine Quelle für alle Metadaten (heute dreimal Copy-Paste: Checkout, chargeAbo, Setup-Session). Baut das Set und kappt Werte defensiv auf 500 Zeichen.
SE2.2 Metadaten-Set (Session, PaymentIntent, SetupIntent, Refund, Customer): shopping_order_id, shopping_payment_id, reference, mode, order_type (shop/abo/abo_recovery/payment_link), user_abo_id (falls Abo), member_id/customer_number, customer_name, invoice_number (leer bis SE3).
SE2.3 description am PaymentIntent setzen — das ist das Feld, das die Dashboard-Zahlungsliste anzeigt und das die Standardsuche durchsucht: "Bestellung #<order_id> · <Referenz> · <Kundenname>". Über payment_intent_data[description] in der Session bzw. description in paymentIntents->create() (Abo-Abbuchung).
SE2.4 client_reference_id bleibt die Zahlungsreferenz (unverändert).
SE2.5 Prüfen, ob die Metadaten am Charge ankommen (Reports lesen teils den Charge). Falls nicht: beim charge.succeeded-Webhook charges->update() mit demselben Set nachziehen — erst nach Messung entscheiden, nicht auf Verdacht.

Tests

  • StripeMetadataBuilder als Unit-Test: vollständiges Set je Vorgangstyp, Kappung > 500 Zeichen, keine null-Werte.
  • Checkout- und Abo-Charge-Aufruf enthalten description und das komplette Metadaten-Set.

Akzeptanzkriterien

  • Dashboard-Suche nach der Bestellnummer (123), der Referenz (m68a…) und metadata:shopping_order_id=123 liefert je genau die richtige Zahlung.
  • In der Zahlungsliste steht statt „Bestellung " ein lesbarer Text mit Bestellnummer und Kundenname.

Umsetzungsstatus SE1 + SE2 (ERLEDIGT ✅, 2026-08-26)

Beide Phasen wurden gemeinsam umgesetzt — sie fassen denselben Code-Pfad an.

Neu

  • database/migrations/2026_08_26_120000_add_stripe_customer_id_to_shopping_users.php — Spalte shopping_users.stripe_customer_id (nullable, indiziert), migriert.
  • app/Services/Payment/Stripe/StripeMetadataBuilder.php — einzige Quelle für Metadaten und description; sanitize() wirft leere Werte weg und kappt auf 500 Zeichen.
  • app/Console/Commands/StripeBackfillCustomers.php (stripe:backfill-customers, --refresh, --dry-run, --limit=) — verknüpft Bestands-Customer (von Stripe namenlos angelegt) mit dem Kundendatensatz und trägt Name/Anschrift nach; --refresh schreibt bereits verknüpfte Customer neu (z. B. nach einer Änderung des Namensformats).
  • tests/Unit/Services/StripeMetadataBuilderTest.php (7 Tests).

Geändert

  • StripeCustomerService: neu customerIdForShoppingUser(), customerPayloadFor(), updateCustomer(), deleteCustomer(), customerNameFor() (Firma vor Personenname). Vorhandene IDs werden nie überschrieben, Stammdaten bei jedem Checkout aktualisiert.
  • StripePaymentService: Konstruktor nimmt zusätzlich den StripeCustomerService; die Session bekommt customer statt customer_email, customer_creation entfällt. payment_intent_data trägt jetzt eine description. Metadaten für Checkout, Abo-Abbuchung und Setup-Session kommen aus dem Builder. ensureAboStripeCustomer() nutzt denselben Payload (Adresse inklusive).
  • GdprDeletionService: sammelt vor der Transaktion alle Stripe-Customer-IDs (User, Kunden, Abos) ein und löscht sie nach dem Commit (stripe_customers_deleted im Summary); die Spalten werden geleert. Charges bleiben bei Stripe — Buchungsbeleg.
  • ShoppingUser: stripe_customer_id in $fillable + Docblock.

Abweichungen / Entscheidungen

  • Customer für ALLE Checkouts, nicht nur für Abos. Nur so steht der Name auch an Einmalzahlungen — genau der Fall aus Mail 1. Zahlungsmittel werden dadurch nicht gespeichert; das steuert weiterhin allein setup_future_usage.
  • customer_update bewusst nicht gesetzt (SE1.4): Adressänderungen, die der Kunde bei Stripe vornimmt, dürfen unsere Rechnungsanschrift nicht überschreiben. Die Richtung ist einseitig — wir schreiben unsere Stammdaten an den Customer.
  • Fallback-Pfad (SE1.7): Schlägt die Customer-Anlage fehl, läuft der Checkout mit customer_email (+ customer_creation bei Abos) weiter wie vor SE1. Getestet.
  • Namensformat „Vorname Nachname (Firma)" (Stakeholder 2026-08-26, nach der ersten Testzahlung): Zuerst hatte die Firma Vorrang — im System und in der Kommunikation läuft aber alles über den Personennamen, Firmennamen kennt kaum jemand. Reine Firmenkunden ohne Personennamen erscheinen weiterhin unter der Firma. stripe:backfill-customers --refresh schreibt bereits angelegte Customer auf das neue Format um (am Testkonto ausgeführt).
  • SE2.5 (Metadaten am Charge) noch offen — bewusst nicht auf Verdacht umgesetzt. Wird nach dem ersten CSV-Export (SE6.4) entschieden.
  • invoice_number ist im Metadaten-Set bereits vorgesehen, bleibt bis SE3 aber leer (die Rechnung entsteht erst nach der Zahlung).

Tests (2026-08-26): 80 Tests grün — StripeCheckoutTest (23, davon 6 neu für SE1/SE2), StripeAboTest (16, zwei Erwartungen an customer statt customer_creation angepasst), StripeWebhookHandlerTest, StripePaymentMethodChangeTest, StripeFoundationTest, GdprDeletionTest (9, davon 1 neu), StripeMetadataBuilderTest (7 neu). ./vendor/bin/pint gelaufen.

Befund aus der ersten Testzahlung (2026-08-26)

Session cs_test_a15riIP… / PaymentIntent pi_3U8fjVJpkSeWxYO21p3EiwY6, 31,73 EUR, Testmodus:

  • Session korrekt: customer = cus_V8xdNCPqCTbLvl, customer_email/customer_creation entfallen, vollständiges Metadaten-Set (shopping_order_id=61040, shopping_payment_id=58166, reference, mode=test, order_type=shop, customer_name).
  • Namensformat angepasst (siehe Abweichungen) — der Testkunde ist Firmenkunde und erschien zunächst nur als „adametz.media".
  • Webhook kam nicht an — Ursache: falsches Stripe-Konto am CLI. Die App arbeitet gegen acct_1TqsFKJpkSeWxYO2 („mivita care gmbh Sandbox"), das laufende stripe listen war noch am vorherigen Test-Konto angemeldet (erkennbar am Konto-Segment der Event-IDs: …JpkSeWxYO2… vs. …J8ldXin0nk…). Die Events existierten bei Stripe, wurden aber nie zugestellt — kein 400, kein Log-Eintrag. Merksatz für das Runbook (S7.5): stripe listen --api-key <sk_test des Kontos> verwenden und das beim Start ausgegebene whsec_… mit STRIPE_WEBHOOK_SECRET abgleichen; jedes Konto hat sein eigenes Secret. Verpasste Events mit stripe events resend <evt_id> nachliefern.
  • Bestätigt eine bekannte Lücke: Für Webhooks, die nie zugestellt wurden, gibt es bisher kein Reparaturwerkzeug — stripe:process-pending-events arbeitet nur auf gespeicherten Events. Der aktive API-Abgleich ist im Vorgänger-Plan als S6.4 (Rest) offen und sollte vorgezogen werden (stripe:sync-payment-status).

Offen vor Live-Schaltung: Sichtprüfung im Dashboard (Kundenname, description, Metadaten, die vier Suchen) und der CSV-Export (SE6.4), nachdem die Testzahlung über den Webhook durchgelaufen ist.


5. Phase SE3 – Rechnungsnummer an Stripe nachtragen · Status: ERLEDIGT ✅ (2026-08-26)

Ziel

Die MIVITA-Rechnungsnummer steht als Metadatum an der Stripe-Zahlung und kann als CSV-Spalte exportiert werden.

Kernproblem und Entscheidung

Die Rechnungsnummer wird atomar aus einem fortlaufenden Nummernkreis vergeben (Invoice::makeNextInvoiceNumber) 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) — 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) — 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). 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).
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 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.
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) — setzt txaction = 'cancelled', shipped = 10, storniert das komplette UserSalesVolume (:496 → SalesPointsVolume::cancelSalesPointsVolume).
  • Kein Stripe-Refund. charge.refunded landet im Default-Zweig des Webhooks und wird nur geloggt (StripeWebhookHandler.php:220).
  • OrderWithdrawal erfasst Widerrufe inkl. type = partial und items, aber der Admin kann daraus nur den Status pflegen (OrderWithdrawalController.php:33).
  • UserCredit ist die Provisionsgutschrift für Berater — fachlich etwas völlig anderes und nicht wiederverwendbar für Kundengutschriften.

Teilphasen

SE5.1 – Datenmodell & Betragsführung (2 PT)

# Aufgabe
a Neue Tabelle shopping_payment_refunds: shopping_payment_id, user_invoice_id (Gutschriftsrechnung), provider, external_reference (re_…), amount (Cent), reason, status (pending/succeeded/failed/canceled), created_by_user_id, Zeitstempel.
b shopping_payments.refunded_amount (Cent, Default 0) als schnell lesbarer Summenwert; Wahrheit bleibt die Refund-Tabelle.
c user_invoices: Kennzeichen für die Art des Vorgangs (document_type = invoice/cancellation/credit_note, cancelled_amount, reissued_from_id) — die bestehende cancellation/cancellation_id-Verknüpfung bleibt unverändert, damit Bestandsauswertungen und FileController weiter funktionieren.
d Neue Tabelle sales_volume_corrections (F6): shopping_order_id, user_invoice_id, points, amount, target_month/target_year, settled (bool), reason, created_by_user_id. Dokumentiert jede Punktekorrektur — auch die bewusst nicht durchgeführte.

SE5.2 – Gutschrift bzw. Storno + Neuausstellung (5–6 PT)

Umsetzung der beiden von F5 geforderten Wege (Fälle A und B, §9):

# Aufgabe
a Fall A — Gutschrift (InvoiceRepository::createCreditNote(array $items, array $request)): Positionsauswahl (Produkt + Menge) oder Freibetrag; Gutschriftsrechnung mit eigener Nummer aus dem Rechnungs-Nummernkreis, Vermerk „Produkt XY aus Rechnung <Nr.> gutgeschrieben". Originalrechnung bleibt unverändert gültig.
b Fall B — Storno + Neuausstellung (InvoiceRepository::cancelAndReissue(array $keptItems, array $request)): storniert die Originalrechnung über den bestehenden createCancellation()-Pfad und stellt anschließend eine neue Rechnung mit dem korrigierten Positionsumfang aus. Nur zulässig, solange die Bestellung nicht versendet ist (shipped in [0, 1]).
c PDF: beide Fälle über das bestehende Storno-Template mit eigenem Kopf (Gutschrift / Stornorechnung); deutsche Fassung immer, Kundensprache zusätzlich (Muster: makeCancellationPDF).
d Punktekorrektur: SalesPointsVolume::cancelSalesPointsVolume() storniert heute vollständig. Neue Variante für Teilbeträge (Punkte je stornierter Position, nicht anteilig am Gesamtbetrag). Kritisch, weil die Provisionsberechnung darauf aufsetzt — eigener Test-Block, Abgleich mit TreeCalcBotOptimized vor Freigabe.
e Stichtags-Logik (F6): Korrektur wird nur ausgeführt, wenn der Zielmonat noch nicht abgerechnet ist (Prüfung gegen die Monatsabrechnung business:store-optimized). Andernfalls bleiben die Punkte stehen. In beiden Fällen entsteht ein Satz in der neuen Tabelle sales_volume_corrections (Bestellung, Gutschrift, Punkte/Betrag, Zielmonat, settled, Grund, Admin) — bei settled = true mit dem Vermerk „nicht verrechnet".
f Bestellstatus: bei Teilstorno nicht cancelled setzen (das würde die Bestellung komplett aus Auswertungen nehmen). Stattdessen neuer Status partially_cancelled in Payment::$txaction_text/$txaction_color + Badge.
g DATEV: DatevExportService::collectCancellationLines() um Gutschrifts- und Teilstorno-Zeilen erweitern (DatevExportService.php:487). Achtung: collectCreditLines() sammelt Provisionsgutschriften an Berater — Kundengutschriften brauchen eine eigene Sammelmethode und eigene Konten.

SE5.3 – Stripe-Refund (2–3 PT)

# Aufgabe
a StripePaymentService::refund(ShoppingPayment $p, int $amountCents, string $reason, array $metadata): RefundResult — refunds->create() mit payment_intent, amount, Metadaten (SE2-Set + cancellation_invoice_number) und Idempotency-Key refund-<payment_id>-<invoice_id>. Voll- und Teilerstattung über denselben Weg.
b Guards: nur bei txaction = 'paid'; Summe aller Refunds ≤ shopping_payments.amount; Prüfung gegen refunded_amount vor dem API-Call.
c Webhook: charge.refunded und refund.updated/refund.failed aus dem Default-Zweig herausnehmen und verbuchen — shopping_payment_refunds.status pflegen, refunded_amount fortschreiben, PaymentIncident bei failed. Auch im Stripe-Dashboard ausgelöste Refunds landen so korrekt im System.
d PAYONE-Pendant: bei Vorkasse gibt es keinen API-Refund → Refund-Datensatz mit provider = 'payone', status = 'manual' und Hinweis „Rücküberweisung manuell durch die Buchhaltung".

SE5.4 – Admin-UI & Widerrufs-Verzahnung (2 PT)

# Aufgabe
a Modal „Storno / Gutschrift" an der Bestellung mit Wegwahl (Fall A Gutschrift / Fall B Storno + Neuausstellung, vorausgewählt nach shipped), Positionsauswahl mit Mengen, Betragsvorschau (brutto/netto/USt), Checkbox „Betrag über Stripe erstatten", Grund (Pflicht), Mailversand. Fall B ist bei bereits versendeter Ware gesperrt.
b Aus OrderWithdrawal heraus direkt in dieses Modal springen — items und type vorbelegt; nach Abschluss Widerruf auf done setzen.
c Bestell-Detailansicht zeigt Refund-Historie inkl. Stripe-Deep-Link (Helper stripeDashboardUrl() existiert bereits aus S7.4).

Tests

  • Teilstorno über 1 von 3 Positionen: Gutschriftsrechnung mit korrektem Betrag, Punkte nur für diese Position storniert, Bestellung bleibt in Auswertungen.
  • Refund > Restbetrag → abgelehnt, kein API-Call.
  • charge.refunded aus dem Stripe-Dashboard → System zieht nach, ohne Doppelbuchung.
  • Zwei parallele Refund-Anfragen → nur ein Stripe-Refund (Idempotency-Key).
  • Vollstorno-Regression: bestehende createCancellation-Tests unverändert grün.

Akzeptanzkriterien

  • Teilstorno inkl. Gutschriftsrechnung und Stripe-Rückerstattung in einem Vorgang, ohne manuelle Nacharbeit.
  • Punktestände und DATEV-Export stimmen nach Teilstorno rechnerisch (Prüfung an einem realen Altfall).

8. Phase SE6 – Stripe-Berichte & Buchhaltungs-Abstimmung · Status: OFFEN (organisatorisch)

Kein Entwicklungsaufwand, aber Voraussetzung dafür, dass SE1–SE3 den erhofften Nutzen bringen.

# Aufgabe Verantwortung
SE6.1 Bilanzbericht (Balance Report) und Auszahlungsabstimmung (Payout Reconciliation) im Dashboard unter Berichte → Planen auf wöchentlich stellen, Empfänger eintragen. Buchhaltung
SE6.2 Spaltenauswahl je Bericht festlegen — mindestens metadata[invoice_number], metadata[reference], metadata[shopping_order_id], customer_name, customer_email. Buchhaltung + Entwicklung
SE6.3 Rückmeldung, welche weiteren Felder gebraucht werden (Kostenstelle? Beraternummer? Abo-Kennzeichen?) → fließt zurück in SE2.2. Buchhaltung
SE6.4 Nach SE1–SE3: eine Testzahlung im Testmodus, Berichte exportieren und gegen die Erwartung prüfen; erst danach live bewerten. Entwicklung
SE6.5 Optional (P3): Command stripe:payout-report {payout_id} — Auszahlung auf dem Bankkonto den enthaltenen MIVITA-Rechnungsnummern zuordnen. Erst nach SE6.1–SE6.4 bewerten; die Stripe-Berichte decken das eventuell bereits ab. Entwicklung

9. Stakeholder-Entscheidungen (beantwortet 2026-08-26)

# Frage Antwort Konsequenz
F1 Name zusätzlich im Stripe-Checkout abfragen (Option B)? Nein — Name aus der Rechnungsanschrift übernehmen. SE1 bleibt bei Option A; name_collection wird nicht aktiviert.
F2 Welche Metadaten braucht die Buchhaltung zusätzlich? Keine — „die Daten passen für den Steuerberater". Metadaten-Set aus SE2.2 ist final; keine Zusatzfelder.
F3 Rechnungsnummern rückwirkend ab Go-Live nachtragen? Ja. SE3.4 (Backfill-Command) ist Pflichtteil, nicht optional.
F4 Stripe-Customer bei DSGVO-Löschung löschen? Ja — muss sogar. SE1.6 wird verbindlich: customers->delete() bei jeder Löschanfrage.
F5 Teilstorno: Gutschrift oder Storno + Neuausstellung? Beides — als zwei Optionen im UI. Bestimmt das Design von SE5.2, siehe unten.
F6 Provisionskorrektur bei Teilstorno? Nur im laufenden Monat; bereits abgerechnete Monate bleiben fix, werden aber nachvollziehbar protokolliert. SE5.2b bekommt eine Stichtags-Logik + Protokolltabelle.
F7 Altfall 288,50 €? Vorkasse über PAYONE, falscher Verwendungszweck. Kommt regelmäßig vor und lässt sich nicht verhindern (Hinweise stehen bereits fett/rot in Mail und System). SE4 ist damit kein Stripe-Thema, sondern der generische „Zahlungseingang manuell erfassen"-Pfad für Vorkasse-Bestellungen. Abnahmetest = eine Vorkasse-Bestellung.

Präzisierung zu F5 — zwei fachlich verschiedene Fälle

Fall Beispiel Ergebnis im System
A — Gutschrift auf bestehende Rechnung (nachgelagert) Kunde schickt ein Produkt zurück, Ware war bereits raus. Gutschriftsrechnung mit eigener Nummer und Vermerk „Produkt XY aus Rechnung <Nr.> gutgeschrieben". Die Originalrechnung bleibt gültig und unverändert.
B — Storno + Neuausstellung (vor Versand) Position ist nicht lieferbar und wird ausgetragen, Ware ist noch nicht raus. Stornorechnung über die Originalrechnung plus neue Rechnung mit dem korrigierten Betrag.

Das Storno-/Gutschrift-Modal in SE5.4a bietet daher beide Wege zur Auswahl; die Vorauswahl richtet sich nach dem Versandstatus (shipped): Ware raus → Fall A, Ware nicht raus → Fall B.

Präzisierung zu F6 — Provisionskorrektur mit Stichtag

  • Teilstorno im laufenden Monat → Punkte/Umsatz werden regulär korrigiert (die Monatsabrechnung business:store-optimized rechnet ohnehin neu).
  • Teilstorno für einen bereits abgerechneten Monat → Punkte bleiben unverändert stehen, die Provision wird nicht rückwirkend gekürzt.
  • In beiden Fällen entsteht ein Protokollsatz (neue Tabelle sales_volume_corrections: Bestellung, Gutschrift, Betrag/Punkte, Zielmonat, settled ja/nein, Grund, Admin). Bei settled = true trägt er den Vermerk „nicht verrechnet" — damit bleibt jede nicht durchgeführte Korrektur nachvollziehbar.

10. Risiken & Mitigation

Risiko Mitigation
Checkout bricht wegen fehlgeschlagener Customer-Anlage ab SE1.7: Fallback auf customer_email, Fehler nie durchreichen; E2E im Testmodus vor Live
Manuelle „Bezahlt"-Markierung erzeugt Rechnung für nicht eingegangenes Geld Pflichtfeld Beleg/Verwendungszweck, SysLog, Admin-Rolle, Anzeige in der Bestellhistorie
Doppelte Rechnung durch Race manuell ↔ Webhook lockForUpdate() + isInvoice()-Prüfung wie im bestehenden Paid-Pfad
Falsche Punktekorrektur bei Teilstorno → falsche Provisionen Eigene Testsuite, Abgleich gegen TreeCalcBotOptimized, Freigabe erst nach Prüfung eines Realfalls
Doppel-Refund bei Netzwerk-Retry Idempotency-Key je (Zahlung, Gutschrift) + Guard gegen refunded_amount
Refund im Stripe-Dashboard am System vorbei charge.refunded-Webhook verbucht auch dashboard-seitige Refunds (SE5.3c)
Metadaten-Limits (50 Keys / 500 Zeichen) Zentraler Builder kappt defensiv, Unit-Test
Scope-Kriechen Richtung Stripe Invoicing/Billing Architektur-Entscheidung des Vorgänger-Plans bleibt verbindlich: Stripe ist reiner Charge-Provider, Rechnungen bleiben im System

11. Teststrategie

Ebene Inhalt
Unit StripeMetadataBuilder (Set, Kappung), Betrags-/Punkteberechnung bei Teilstorno, Refund-Guards
Feature (Pest, SQLite) Checkout mit Customer, Rechnungsnummer-Sync, manueller Zahlungseingang (Rechnung + Punkte + Mail + Abo), Refund-Webhooks — Stripe-Client immer gemockt
Regression tests/Feature/StripeCheckoutTest.php, StripeAboTest.php, StripeWebhookHandlerTest.php, StripePaymentMethodChangeTest.php, StripeFoundationTest.php sowie die PAYONE-Suite müssen in jeder Phase unverändert grün sein
Manuell (Testmodus) Testzahlung → Name/Description/Metadaten im Dashboard prüfen; Berichte exportieren (SE6.4); Teil-Refund über Dashboard und über das System

12. Betroffene Dateien (Vorschau)

Neu

app/Services/Payment/Stripe/StripeMetadataBuilder.php            (SE2)
app/Jobs/SyncStripeInvoiceReference.php                          (SE3)
app/Console/Commands/StripeBackfillCustomers.php                 (SE1.5)
app/Console/Commands/StripeBackfillInvoiceMetadata.php           (SE3.4)
app/Services/ManualPaymentService.php                            (SE4)
app/Http/Requests/RecordManualPaymentRequest.php                 (SE4)
app/Models/ShoppingPaymentRefund.php                             (SE5.1)
database/migrations/*_add_stripe_customer_id_to_shopping_users   (SE1.1)
database/migrations/*_create_shopping_payment_refunds_table      (SE5.1)
database/migrations/*_add_refund_fields_to_shopping_payments     (SE5.1)
database/migrations/*_add_partial_cancellation_to_user_invoices  (SE5.1)

Anpassung

app/Services/Payment/Stripe/StripePaymentService.php   (SE1.3, SE2.3, SE5.3a)
app/Services/Payment/Stripe/StripeCustomerService.php  (SE1.2)
app/Services/Payment/Stripe/StripeWebhookHandler.php   (SE5.3c — charge.refunded/refund.*)
app/Services/Payment.php                               (SE3.2, SE5.2c — Status partially_cancelled)
app/Repositories/InvoiceRepository.php                 (SE5.2a)
app/Services/BusinessPlan/SalesPointsVolume.php        (SE5.2b — anteilige Stornierung)
app/Services/DatevExportService.php                    (SE5.2d)
app/Services/GdprDeletionService.php                   (SE1.6)
app/Http/Controllers/SalesController.php               (SE4.6, SE5.4a)
app/Http/Controllers/Admin/OrderWithdrawalController.php (SE5.4b)
resources/views/admin/sales/_detail.blade.php          (SE4.6, SE5.4a/c)
routes/domains/crm.php                                 (SE4.6, SE5.4)

13. Nächste Schritte

  1. §9 Offene Fragen klären ✅ beantwortet 2026-08-26 (§9).
  2. SE1 + SE2 umsetzen ✅ 2026-08-26 (Umsetzungsstatus in §4). Offen: die Testzahlung im Stripe-Testmodus und der Sichttest im Dashboard — der von Stripe empfohlene Weg (Mail 2, „Empfohlene nächste Schritte").
  3. SE6.1/SE6.2 parallel durch die Buchhaltung: Berichte planen, Spalten auswählen.
  4. SE3 umsetzen ✅ 2026-08-26 (Umsetzungsstatus in §5), inklusive des vorgezogenen S6.4 (stripe:sync-payment-status) als Sicherheitsnetz für verlorene Webhooks. Offen: Backfill produktiv fahren (erst --dry-run) und Berichte exportieren.
  5. SE4 umsetzen ✅ 2026-08-26 (Umsetzungsstatus in §6). Offen: Abnahme am realen Vorkasse-Altfall (F7) durch die Buchhaltung.
  6. SE5 umsetzen — beide Wege aus F5, Punktekorrektur mit Stichtag aus F6. ← als Nächstes