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>
48 KiB
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
ERLEDIGTsetzen (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=wertoder der nackte Wert) sowie standardmäßig diedescriptioneines Objekts. → Anforderung 2 ist zu 80 % eine Frage derdescription, nicht der Metadaten. - Kundenname: zwei Wege, beide von Stripe bestätigt.
- Option A — Customer-Objekt mit
nameanlegen und percustomeran die Session hängen. Wirkt in Zahlungsliste und in Balance-/Payout-Reports (Spaltencustomer_name,customer_email). - Option B —
name_collection[individual][enabled]=truein der Session: Stripe fragt den Namen im Checkout ab (zusätzliches Pflicht-/Optionalfeld, landet incollected_information.individual_name).
- Option A — Customer-Objekt mit
- Metadaten-Limits: max. 50 Keys, Key ≤ 40 Zeichen, Wert ≤ 500 Zeichen, keine eckigen Klammern, keine sensiblen Daten.
customerundcustomer_emailschließen sich gegenseitig aus; ebensocustomerundcustomer_creation. Adress-/Namensrückschreibung aus dem Checkout brauchtcustomer_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
ShoppingUserstatt je Zahlung — durch die persistierte ID entsteht pro Kunde genau einer. Gastbestellungen legen weiterhin je neuemShoppingUsereinen 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übergibtcustomer(nichtcustomer_email), Customer trägt Namen (Client gemockt).- Zweiter Checkout desselben
ShoppingUserlegt keinen zweiten Customer an. - Customer-Anlage wirft → Session wird trotzdem erstellt (Fallback-Pfad), Warnung geloggt.
- Abo-Checkout:
setup_future_usageweiterhin gesetzt,customer_creationnicht mehr vorhanden. - Regression:
tests/Feature/StripeCheckoutTest.php,StripeAboTest.phpgrü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
StripeMetadataBuilderals Unit-Test: vollständiges Set je Vorgangstyp, Kappung > 500 Zeichen, keinenull-Werte.- Checkout- und Abo-Charge-Aufruf enthalten
descriptionund das komplette Metadaten-Set.
Akzeptanzkriterien
- Dashboard-Suche nach der Bestellnummer (
123), der Referenz (m68a…) undmetadata:shopping_order_id=123liefert 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— Spalteshopping_users.stripe_customer_id(nullable, indiziert), migriert.app/Services/Payment/Stripe/StripeMetadataBuilder.php— einzige Quelle für Metadaten unddescription;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;--refreshschreibt bereits verknüpfte Customer neu (z. B. nach einer Änderung des Namensformats).tests/Unit/Services/StripeMetadataBuilderTest.php(7 Tests).
Geändert
StripeCustomerService: neucustomerIdForShoppingUser(),customerPayloadFor(),updateCustomer(),deleteCustomer(),customerNameFor()(Firma vor Personenname). Vorhandene IDs werden nie überschrieben, Stammdaten bei jedem Checkout aktualisiert.StripePaymentService: Konstruktor nimmt zusätzlich denStripeCustomerService; die Session bekommtcustomerstattcustomer_email,customer_creationentfällt.payment_intent_dataträgt jetzt einedescription. 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_deletedim Summary); die Spalten werden geleert. Charges bleiben bei Stripe — Buchungsbeleg.ShoppingUser:stripe_customer_idin$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_updatebewusst 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_creationbei 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 --refreshschreibt 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_numberist 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_creationentfallen, 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 laufendestripe listenwar 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 ausgegebenewhsec_…mitSTRIPE_WEBHOOK_SECRETabgleichen; jedes Konto hat sein eigenes Secret. Verpasste Events mitstripe 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-eventsarbeitet 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-runschreibt 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.targetakzeptiertpi_…,cs_…, die Zahlungsreferenz oder dieshopping_payment-ID. - Neu:
StripeWebhookHandler::syncFromApiObject()— öffentlicher Einstieg in die bestehendedispatchEvent()-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— schreibtmetadata.invoice_number+metadata.invoice_datean den PaymentIntent und ergänzt diedescriptionum 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-fromist 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 RechnungserstellungStripeInvoiceReferenceService::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)
- 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. store_txactionsetzttxaction+paid = trueund schreibt einePaymentTransaction, ruft aber nichtPayment::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,
UserSalesVolumegebucht, 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 mitlockForUpdate()auf der Bestellung und ruft anschließendPayment::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.phptests/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+ RoutePOST /admin/sales/payment/manual(admin_sales_payment_manual) hinter der bestehendenadmin-Middleware.resources/views/admin/sales/_detail.blade.php: Button „Zahlungseingang erfassen" an jeder Zahlung einer unbezahlten, nicht stornierten Bestellung — unabhängig vomclearingtype, 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 (Rechnungskauffnc/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 impayment-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 kompletteUserSalesVolume(:496 →SalesPointsVolume::cancelSalesPointsVolume). - Kein Stripe-Refund.
charge.refundedlandet im Default-Zweig des Webhooks und wird nur geloggt (StripeWebhookHandler.php:220). OrderWithdrawalerfasst Widerrufe inkl.type = partialunditems, aber der Admin kann daraus nur den Status pflegen (OrderWithdrawalController.php:33).UserCreditist 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.refundedaus 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-optimizedrechnet 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,settledja/nein, Grund, Admin). Beisettled = trueträ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
§9 Offene Fragen klären✅ beantwortet 2026-08-26 (§9).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").- SE6.1/SE6.2 parallel durch die Buchhaltung: Berichte planen, Spalten auswählen.
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.SE4 umsetzen✅ 2026-08-26 (Umsetzungsstatus in §6). Offen: Abnahme am realen Vorkasse-Altfall (F7) durch die Buchhaltung.- SE5 umsetzen — beide Wege aus F5, Punktekorrektur mit Stichtag aus F6. ← als Nächstes