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

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

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

179 lines
5.9 KiB
PHP

<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserInvoice;
/**
* Einzige Quelle für die Metadaten und die `description`, die MIVITA an
* Stripe-Objekte hängt (Phase SE2, dev/2026-08-26-stripe/).
*
* Zweck (Mail Buchhaltung + Stripe 2026-08-26): Jede Zahlung muss im
* Stripe-Dashboard über Bestellnummer, Referenz oder Rechnungsnummer
* auffindbar und als CSV-Spalte exportierbar sein.
*
* - **Metadaten** sind über die Dashboard-Suche findbar (`metadata:<wert>`)
* und lassen sich als Spalten in Bilanz-/Auszahlungsberichte aufnehmen.
* - **`description`** ist das Feld, das die Zahlungsliste anzeigt und das die
* Standardsuche ohne Filter durchsucht.
*
* Stripe-Limits: max. 50 Schlüssel, Wert max. 500 Zeichen. Leere Werte werden
* entfernt, damit keine "null"-Strings in den Berichten landen.
*/
class StripeMetadataBuilder
{
public const MAX_VALUE_LENGTH = 500;
public const MAX_DESCRIPTION_LENGTH = 350;
public const TYPE_SHOP = 'shop';
public const TYPE_ABO = 'abo';
public const TYPE_ABO_RECOVERY = 'abo_recovery';
public const TYPE_ABO_CHARGE = 'abo_charge';
public const TYPE_PAYMENT_METHOD_CHANGE = 'payment_method_change';
/**
* Einmalzahlung im Checkout (Session + PaymentIntent).
*
* @return array<string, string>
*/
public static function forCheckout(
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
ShoppingUser $shoppingUser,
string $reference,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::orderType($shoppingOrder),
'user_abo_id' => $shoppingOrder->recovery_user_abo_id,
'customer_name' => StripeCustomerService::customerNameFor($shoppingUser),
'customer_number' => $shoppingUser->number,
'member_id' => $shoppingUser->member_id,
'invoice_number' => self::invoiceNumber($shoppingOrder),
]);
}
/**
* Wiederkehrende Abo-Abbuchung (off-session PaymentIntent).
*
* @return array<string, string>
*/
public static function forAboCharge(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
?ShoppingUser $shoppingUser,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $shoppingPayment->reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::TYPE_ABO_CHARGE,
'user_abo_id' => $userAbo->id,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
'customer_number' => $shoppingUser?->number,
'member_id' => $shoppingUser?->member_id,
]);
}
/**
* Setup-Session "Zahlungsart ändern". Die Schlüssel `user_abo_id` und
* `source` wertet der Webhook aus (StripeWebhookHandler) — sie dürfen
* nicht umbenannt werden.
*
* @return array<string, string>
*/
public static function forSetupSession(UserAbo $userAbo, ?string $source): array
{
$shoppingUser = $userAbo->shopping_user;
return self::sanitize([
'user_abo_id' => $userAbo->id,
'source' => $source,
'order_type' => self::TYPE_PAYMENT_METHOD_CHANGE,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
]);
}
/**
* Lesbarer Text für die Stripe-Zahlungsliste (und die Standardsuche).
*/
public static function description(ShoppingOrder $shoppingOrder, string $reference, ?ShoppingUser $shoppingUser): string
{
$parts = ['Bestellung #'.$shoppingOrder->id, $reference];
$name = $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null;
if ($name) {
$parts[] = $name;
}
$invoiceNumber = self::invoiceNumber($shoppingOrder);
if ($invoiceNumber) {
$parts[] = 'Rechnung '.$invoiceNumber;
}
return mb_substr(implode(' · ', $parts), 0, self::MAX_DESCRIPTION_LENGTH);
}
/**
* Ergänzt ein bestehendes Metadaten-Set um die Rechnungsdaten
* (Phase SE3: die Rechnungsnummer entsteht erst nach der Zahlung).
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function withInvoice(array $metadata, UserInvoice $userInvoice): array
{
return self::sanitize($metadata + [
'invoice_number' => $userInvoice->full_number,
'invoice_date' => $userInvoice->getDateRaw(),
]);
}
/**
* Wirft leere Werte weg, castet auf String und kappt auf das Stripe-Limit.
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function sanitize(array $metadata): array
{
$result = [];
foreach ($metadata as $key => $value) {
if ($value === null || $value === '' || $value === 0) {
continue;
}
$result[$key] = mb_substr((string) $value, 0, self::MAX_VALUE_LENGTH);
}
return $result;
}
private static function orderType(ShoppingOrder $shoppingOrder): string
{
if (! empty($shoppingOrder->recovery_user_abo_id)) {
return self::TYPE_ABO_RECOVERY;
}
return $shoppingOrder->is_abo ? self::TYPE_ABO : self::TYPE_SHOP;
}
private static function invoiceNumber(ShoppingOrder $shoppingOrder): ?string
{
return $shoppingOrder->user_invoice?->full_number;
}
}