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