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>
114 lines
4.1 KiB
PHP
114 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payment\Stripe;
|
|
|
|
use App\Jobs\SyncStripeInvoiceReference;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\UserInvoice;
|
|
use App\Services\Invoice;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Trägt die MIVITA-Rechnungsnummer nachträglich an der Stripe-Zahlung ein
|
|
* (Plan dev/2026-08-26-stripe/, Phase SE3).
|
|
*
|
|
* Warum nachträglich: Die Rechnungsnummer kommt aus einem fortlaufenden
|
|
* Nummernkreis ({@see Invoice::makeNextInvoiceNumber}) und wird
|
|
* erst NACH erfolgreicher Zahlung vergeben. Eine Vorab-Reservierung würde bei
|
|
* jedem abgebrochenen Checkout eine Lücke im Rechnungsnummernkreis erzeugen —
|
|
* das ist buchhalterisch (GoBD) nicht zulässig.
|
|
*
|
|
* Stripe erlaubt es, Metadaten und `description` eines PaymentIntents
|
|
* jederzeit zu ändern. Genau das passiert hier — damit die Buchhaltung die
|
|
* Zahlung im Dashboard über die Rechnungsnummer findet und die Nummer als
|
|
* Spalte in den Bilanz-/Auszahlungsberichten exportieren kann.
|
|
*/
|
|
class StripeInvoiceReferenceService
|
|
{
|
|
public function __construct(private readonly StripeClientWrapper $stripe) {}
|
|
|
|
/**
|
|
* Stößt die Übertragung für eine Bestellung an — ohne den Aufrufer zu
|
|
* gefährden. Wird aus dem Paid-Pfad und aus der manuellen
|
|
* Rechnungserstellung im Admin aufgerufen.
|
|
*/
|
|
public static function dispatchForOrder(ShoppingOrder $shoppingOrder, ?ShoppingPayment $shoppingPayment = null): void
|
|
{
|
|
try {
|
|
$userInvoice = $shoppingOrder->user_invoice;
|
|
if (! $userInvoice) {
|
|
return;
|
|
}
|
|
|
|
$payment = $shoppingPayment ?: $shoppingOrder->shopping_payments()
|
|
->where('payment_provider', 'stripe')
|
|
->latest('id')
|
|
->first();
|
|
|
|
if (! self::isSyncable($payment)) {
|
|
return;
|
|
}
|
|
|
|
SyncStripeInvoiceReference::dispatch($payment, $userInvoice);
|
|
} catch (\Throwable $e) {
|
|
// Der Paid-Pfad darf hieran niemals scheitern.
|
|
Log::channel('stripe')->warning('Rechnungsnummer-Sync konnte nicht eingeplant werden: '.$e->getMessage(), [
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nur Stripe-Zahlungen mit bekanntem PaymentIntent lassen sich ergänzen.
|
|
* Solange nur die Session-ID (`cs_…`) bekannt ist, gibt es kein Objekt,
|
|
* an dem die Nummer dauerhaft sichtbar wäre.
|
|
*/
|
|
public static function isSyncable(?ShoppingPayment $payment): bool
|
|
{
|
|
return $payment !== null
|
|
&& $payment->payment_provider === 'stripe'
|
|
&& str_starts_with((string) $payment->external_reference, 'pi_');
|
|
}
|
|
|
|
/**
|
|
* Schreibt Rechnungsnummer und -datum in die Metadaten des PaymentIntents
|
|
* und ergänzt die `description` um die Rechnungsnummer.
|
|
*
|
|
* Idempotent: steht die Nummer schon dort, passiert nichts.
|
|
*/
|
|
public function sync(ShoppingPayment $shoppingPayment, UserInvoice $userInvoice): bool
|
|
{
|
|
if (! self::isSyncable($shoppingPayment)) {
|
|
return false;
|
|
}
|
|
|
|
$client = $this->stripe->client();
|
|
$paymentIntent = $client->paymentIntents->retrieve($shoppingPayment->external_reference);
|
|
|
|
$existing = $paymentIntent->metadata?->toArray() ?? [];
|
|
|
|
if (($existing['invoice_number'] ?? null) === $userInvoice->full_number) {
|
|
return false;
|
|
}
|
|
|
|
$order = $shoppingPayment->shopping_order;
|
|
|
|
$client->paymentIntents->update($shoppingPayment->external_reference, [
|
|
'metadata' => StripeMetadataBuilder::withInvoice($existing, $userInvoice),
|
|
'description' => StripeMetadataBuilder::description(
|
|
$order,
|
|
(string) $shoppingPayment->reference,
|
|
$order?->shopping_user,
|
|
),
|
|
]);
|
|
|
|
Log::channel('stripe')->info('Rechnungsnummer an Stripe übertragen.', [
|
|
'shopping_payment_id' => $shoppingPayment->id,
|
|
'payment_intent' => $shoppingPayment->external_reference,
|
|
'invoice_number' => $userInvoice->full_number,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
}
|