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>
179 lines
5.9 KiB
PHP
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;
|
|
}
|
|
}
|