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>
431 lines
17 KiB
PHP
431 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payment\Stripe;
|
|
|
|
use App\Enums\PaymentProvider;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserAbo;
|
|
use App\Services\CheckoutFunnelTracker;
|
|
use App\Services\Payment\DTOs\AboChargeResult;
|
|
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
|
|
use App\Services\Util;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Stripe\Checkout\Session;
|
|
use Stripe\Exception\ApiErrorException;
|
|
|
|
/**
|
|
* Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.).
|
|
*
|
|
* Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow,
|
|
* Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe,
|
|
* "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler).
|
|
*
|
|
* Abos (Phase S4): Die Erstzahlung speichert das Zahlungsmittel
|
|
* (setup_future_usage=off_session, Customer wird von Stripe angelegt);
|
|
* Folgeabbuchungen laufen als off-session PaymentIntent über chargeAbo().
|
|
*/
|
|
class StripePaymentService
|
|
{
|
|
public function __construct(
|
|
private readonly StripeClientWrapper $stripe,
|
|
private readonly StripeCustomerService $customerService,
|
|
) {}
|
|
|
|
/**
|
|
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
|
|
* Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind
|
|
* domain-abhängig und gehören nicht in die Service-Schicht).
|
|
*
|
|
* Abo-/Recovery-Bestellungen speichern das Zahlungsmittel für künftige
|
|
* off-session Abbuchungen: Stripe legt einen Customer an und blendet
|
|
* automatisch nur wiederverwendbare Zahlarten ein (Dynamic Payment
|
|
* Methods filtern nach setup_future_usage — löst N1, Plan S4.1).
|
|
*/
|
|
public function initiateCheckoutPayment(
|
|
ShoppingUser $shoppingUser,
|
|
ShoppingOrder $shoppingOrder,
|
|
int $amountCents,
|
|
string $currency,
|
|
string $reference,
|
|
string $successUrl,
|
|
string $cancelUrl,
|
|
): StripeCheckoutSessionResult {
|
|
$shoppingPayment = ShoppingPayment::create([
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
'payment_provider' => PaymentProvider::Stripe->value,
|
|
'clearingtype' => 'stripe',
|
|
'reference' => $reference,
|
|
'amount' => $amountCents,
|
|
'currency' => $currency,
|
|
'is_abo' => (bool) $shoppingOrder->is_abo,
|
|
'abo_interval' => $shoppingOrder->abo_interval ?? 0,
|
|
'identifier' => Util::getUserShopIdentifier(),
|
|
'mode' => $shoppingOrder->mode,
|
|
]);
|
|
|
|
CheckoutFunnelTracker::initiatedPayment(
|
|
shoppingUserId: $shoppingUser->id,
|
|
shoppingOrderId: $shoppingOrder->id,
|
|
shoppingPaymentId: $shoppingPayment->id,
|
|
consultantUserId: $shoppingUser->auth_user_id ?? null,
|
|
paymentMethod: 'stripe',
|
|
amountCents: $amountCents,
|
|
);
|
|
|
|
$metadata = StripeMetadataBuilder::forCheckout($shoppingOrder, $shoppingPayment, $shoppingUser, $reference);
|
|
$description = StripeMetadataBuilder::description($shoppingOrder, $reference, $shoppingUser);
|
|
|
|
$sessionParams = [
|
|
'mode' => 'payment',
|
|
'client_reference_id' => $reference,
|
|
'line_items' => [[
|
|
'quantity' => 1,
|
|
'price_data' => [
|
|
'currency' => strtolower($currency),
|
|
'unit_amount' => $amountCents,
|
|
'product_data' => [
|
|
'name' => __('payment.stripe_order_label', ['reference' => $reference]),
|
|
],
|
|
],
|
|
]],
|
|
'metadata' => $metadata,
|
|
'payment_intent_data' => [
|
|
'metadata' => $metadata,
|
|
'description' => $description,
|
|
],
|
|
'success_url' => $successUrl,
|
|
'cancel_url' => $cancelUrl,
|
|
];
|
|
|
|
$customerId = $this->resolveCheckoutCustomer($shoppingUser);
|
|
|
|
if ($customerId !== null) {
|
|
$sessionParams['customer'] = $customerId;
|
|
} else {
|
|
// Fallback wie vor SE1: ohne Customer-Objekt bleibt wenigstens die
|
|
// E-Mail erhalten, und Abos bekommen ihren Customer von Stripe.
|
|
$sessionParams['customer_email'] = $shoppingUser->billing_email ?: null;
|
|
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
|
|
$sessionParams['customer_creation'] = 'always';
|
|
}
|
|
}
|
|
|
|
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
|
|
$sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session';
|
|
}
|
|
|
|
$session = $this->stripe->client()->checkout->sessions->create($sessionParams);
|
|
|
|
$shoppingPayment->external_reference = $session->id;
|
|
$shoppingPayment->save();
|
|
|
|
StripeTransactionRecorder::recordFromStripeObject(
|
|
$shoppingPayment,
|
|
'checkout.session.created',
|
|
$session->toArray(),
|
|
);
|
|
|
|
return new StripeCheckoutSessionResult(
|
|
sessionId: $session->id,
|
|
url: (string) $session->url,
|
|
reference: $reference,
|
|
shoppingPayment: $shoppingPayment,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stripe-Customer für den Checkout (Phase SE1): trägt Rechnungsname und
|
|
* -anschrift, damit die Buchhaltung den Kunden in Zahlungsliste und
|
|
* CSV-Berichten sieht.
|
|
*
|
|
* Bewusst fehlertolerant: Ist Stripe beim Anlegen des Customers gerade
|
|
* nicht erreichbar, darf der Checkout NICHT scheitern — der Aufrufer
|
|
* fällt dann auf den bisherigen Weg (customer_email) zurück.
|
|
*/
|
|
private function resolveCheckoutCustomer(ShoppingUser $shoppingUser): ?string
|
|
{
|
|
try {
|
|
return $this->customerService->customerIdForShoppingUser($shoppingUser);
|
|
} catch (\Throwable $e) {
|
|
Log::channel('stripe')->warning('Stripe-Customer für Checkout nicht verfügbar — Fallback auf customer_email: '.$e->getMessage(), [
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abo-/Recovery-Bestellungen brauchen ein wiederverwendbares Zahlungsmittel
|
|
* (Erstzahlung eines neuen Abos bzw. Nachholzahlung, die das Abo auf
|
|
* Stripe umzieht — §7.2 Nr. 3).
|
|
*/
|
|
private function requiresReusablePaymentMethod(ShoppingOrder $shoppingOrder): bool
|
|
{
|
|
return (bool) $shoppingOrder->is_abo || ! empty($shoppingOrder->recovery_user_abo_id);
|
|
}
|
|
|
|
/**
|
|
* Wiederkehrende Abo-Abbuchung (Plan S4.3): off-session PaymentIntent mit
|
|
* dem am Abo gespeicherten Customer/PaymentMethod. Der Idempotency-Key
|
|
* pro Abo+Bestellung schützt gegen Doppel-Abbuchung bei Netzwerk-Retries.
|
|
* "Bezahlt" (Order, Rechnung, Mails) setzt auch hier erst der Webhook.
|
|
*/
|
|
public function chargeAbo(
|
|
UserAbo $userAbo,
|
|
ShoppingOrder $shoppingOrder,
|
|
ShoppingUser $shoppingUser,
|
|
int $amountCents,
|
|
string $currency,
|
|
): AboChargeResult {
|
|
if (empty($userAbo->stripe_customer_id) || empty($userAbo->stripe_payment_method_id)) {
|
|
return new AboChargeResult(
|
|
provider: PaymentProvider::Stripe,
|
|
success: false,
|
|
status: 'ERROR',
|
|
rawResponse: [],
|
|
errorMessage: 'Stripe-Abo ohne Customer-/PaymentMethod-ID (user_abo '.$userAbo->id.')',
|
|
customerMessage: __('payment.stripe_abo_payment_method_missing'),
|
|
);
|
|
}
|
|
|
|
$shoppingPayment = ShoppingPayment::create([
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
'payment_provider' => PaymentProvider::Stripe->value,
|
|
'clearingtype' => 'stripe',
|
|
'reference' => substr(uniqid('m', false), 0, 16),
|
|
'amount' => $amountCents,
|
|
'currency' => $currency,
|
|
'is_abo' => true,
|
|
'abo_interval' => $userAbo->abo_interval ?? 0,
|
|
'mode' => $shoppingOrder->mode,
|
|
'user_abo_id' => $userAbo->id,
|
|
'stripe_customer_id' => $userAbo->stripe_customer_id,
|
|
'stripe_payment_method_id' => $userAbo->stripe_payment_method_id,
|
|
]);
|
|
|
|
$metadata = StripeMetadataBuilder::forAboCharge($userAbo, $shoppingOrder, $shoppingPayment, $shoppingUser);
|
|
|
|
try {
|
|
$paymentIntent = $this->stripe->client()->paymentIntents->create([
|
|
'amount' => $amountCents,
|
|
'currency' => strtolower($currency),
|
|
'customer' => $userAbo->stripe_customer_id,
|
|
'payment_method' => $userAbo->stripe_payment_method_id,
|
|
'off_session' => true,
|
|
'confirm' => true,
|
|
'metadata' => $metadata,
|
|
'description' => StripeMetadataBuilder::description($shoppingOrder, (string) $shoppingPayment->reference, $shoppingUser),
|
|
], [
|
|
'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id,
|
|
]);
|
|
} catch (ApiErrorException $e) {
|
|
// Deckt auch CardException ab (Declines wie card_declined,
|
|
// insufficient_funds, authentication_required bei off_session).
|
|
return $this->aboChargeError($shoppingPayment, $e);
|
|
}
|
|
|
|
$shoppingPayment->external_reference = $paymentIntent->id;
|
|
$shoppingPayment->save();
|
|
|
|
$piArray = $paymentIntent->toArray();
|
|
StripeTransactionRecorder::recordFromStripeObject($shoppingPayment, 'authorization', $piArray);
|
|
|
|
[$status, $errorMessage, $customerMessage] = match ($paymentIntent->status) {
|
|
'succeeded' => ['APPROVED', null, null],
|
|
'processing' => ['PENDING', null, null],
|
|
'requires_action', 'requires_confirmation' => [
|
|
'ERROR',
|
|
'authentication_required (PaymentIntent '.$paymentIntent->id.')',
|
|
__('payment.stripe_abo_authentication_required'),
|
|
],
|
|
default => [
|
|
'ERROR',
|
|
'Unerwarteter PaymentIntent-Status "'.$paymentIntent->status.'" ('.$paymentIntent->id.')',
|
|
__('payment.payment_error_reasons.general'),
|
|
],
|
|
};
|
|
|
|
return new AboChargeResult(
|
|
provider: PaymentProvider::Stripe,
|
|
success: $status === 'APPROVED',
|
|
status: $status,
|
|
rawResponse: StripeTransactionRecorder::redactPayload($piArray),
|
|
transactionId: $paymentIntent->id,
|
|
errorMessage: $errorMessage,
|
|
customerMessage: $customerMessage,
|
|
shoppingPayment: $shoppingPayment,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Zahlungsart ändern" (Plan S5.1): Hosted Checkout Session im Setup-Modus —
|
|
* der Abo-Inhaber wählt bei Stripe frei eine neue, wiederverwendbare
|
|
* Zahlart; es wird NICHTS abgebucht. Verankert wird die Methode nach
|
|
* Erfolg über den Return-Pfad bzw. den setup_intent.succeeded-Webhook.
|
|
*/
|
|
public function createPaymentMethodChangeSession(
|
|
UserAbo $userAbo,
|
|
string $successUrl,
|
|
string $cancelUrl,
|
|
?string $source = null,
|
|
): Session {
|
|
$customerId = $this->ensureAboStripeCustomer($userAbo);
|
|
|
|
$metadata = StripeMetadataBuilder::forSetupSession($userAbo, $source);
|
|
|
|
return $this->stripe->client()->checkout->sessions->create([
|
|
'mode' => 'setup',
|
|
// Pflicht im Setup-Modus mit dynamischen Zahlarten: Währung der
|
|
// künftigen Abbuchungen (Abos laufen fest in EUR, Plan §1 E).
|
|
'currency' => 'eur',
|
|
'customer' => $customerId,
|
|
'metadata' => $metadata,
|
|
'setup_intent_data' => ['metadata' => $metadata],
|
|
'success_url' => $successUrl,
|
|
'cancel_url' => $cancelUrl,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Stripe-Customer des Abos — bei PAYONE-Bestandsabos wird er hier erstmalig
|
|
* angelegt und sofort am Abo persistiert (wiederverwendbar bei erneutem
|
|
* Versuch; der Provider des Abos bleibt unverändert, bis die neue
|
|
* Zahlungsart tatsächlich verankert ist).
|
|
*/
|
|
private function ensureAboStripeCustomer(UserAbo $userAbo): string
|
|
{
|
|
if (! empty($userAbo->stripe_customer_id)) {
|
|
return $userAbo->stripe_customer_id;
|
|
}
|
|
|
|
$shoppingUser = $userAbo->shopping_user;
|
|
|
|
$payload = $shoppingUser
|
|
? $this->customerService->customerPayloadFor($shoppingUser)
|
|
: ['name' => null, 'metadata' => []];
|
|
$payload['email'] = $userAbo->email ?: ($payload['email'] ?? null);
|
|
$payload['metadata'] = ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id]
|
|
+ ($payload['metadata'] ?? []);
|
|
|
|
$customer = $this->stripe->client()->customers->create($payload);
|
|
|
|
$userAbo->forceFill(['stripe_customer_id' => $customer->id])->save();
|
|
|
|
return $customer->id;
|
|
}
|
|
|
|
/**
|
|
* Anzeige-Summary einer PaymentMethod (Struktur wie
|
|
* StripeWebhookHandler::paymentMethodSummary, aber aus dem
|
|
* PaymentMethod-Objekt statt aus Charge-Details). Best effort — null,
|
|
* wenn die API nicht erreichbar ist.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function retrievePaymentMethodSummary(string $paymentMethodId): ?array
|
|
{
|
|
try {
|
|
$paymentMethod = $this->stripe->client()->paymentMethods->retrieve($paymentMethodId);
|
|
} catch (\Throwable $e) {
|
|
\Log::channel('stripe')->warning('PaymentMethod-Summary nicht abrufbar: '.$e->getMessage(), [
|
|
'payment_method' => $paymentMethodId,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$summary = ['type' => $paymentMethod->type];
|
|
|
|
if ($paymentMethod->type === 'card' && $paymentMethod->card) {
|
|
$summary['brand'] = $paymentMethod->card->brand ?? null;
|
|
$summary['last4'] = $paymentMethod->card->last4 ?? null;
|
|
$summary['wallet'] = $paymentMethod->card->wallet->type ?? null;
|
|
$summary['exp'] = isset($paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
|
|
? sprintf('%02d/%d', $paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
|
|
: null;
|
|
}
|
|
|
|
if ($paymentMethod->type === 'sepa_debit' && $paymentMethod->sepa_debit) {
|
|
$summary['last4'] = $paymentMethod->sepa_debit->last4 ?? null;
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* Setup-Session für den Return-Pfad nachladen (inkl. SetupIntent).
|
|
*/
|
|
public function retrieveSetupSession(string $sessionId): Session
|
|
{
|
|
return $this->stripe->client()->checkout->sessions->retrieve($sessionId, [
|
|
'expand' => ['setup_intent'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Alte PaymentMethod nach erfolgreichem Wechsel am Customer lösen —
|
|
* best effort, ein Fehler darf den Wechsel nie kippen.
|
|
*/
|
|
public function detachPaymentMethod(string $paymentMethodId): void
|
|
{
|
|
try {
|
|
$this->stripe->client()->paymentMethods->detach($paymentMethodId);
|
|
} catch (\Throwable $e) {
|
|
\Log::channel('stripe')->warning('Alte PaymentMethod konnte nicht detached werden: '.$e->getMessage(), [
|
|
'payment_method' => $paymentMethodId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fehler-Mapping (Plan S4.5): Stripe-Declines werden protokolliert und als
|
|
* ERROR-Ergebnis zurückgegeben — der Cron setzt damit denselben
|
|
* Abo-Fehlerstatus (3) wie bei PAYONE-Declines inkl. Pay-now-Mail.
|
|
*/
|
|
private function aboChargeError(ShoppingPayment $shoppingPayment, ApiErrorException $e): AboChargeResult
|
|
{
|
|
$stripeError = $e->getError();
|
|
$errorObject = $e->getJsonBody()['error'] ?? ['message' => $e->getMessage()];
|
|
|
|
if (! empty($stripeError?->payment_intent)) {
|
|
$shoppingPayment->external_reference = $stripeError->payment_intent->id;
|
|
$shoppingPayment->save();
|
|
}
|
|
|
|
StripeTransactionRecorder::recordFromStripeObject($shoppingPayment, 'authorization', [
|
|
'id' => $stripeError?->payment_intent->id ?? '',
|
|
'status' => 'failed',
|
|
'last_payment_error' => [
|
|
'code' => $stripeError?->code ?? $e->getStripeCode(),
|
|
'message' => $stripeError?->message ?? $e->getMessage(),
|
|
],
|
|
'decline_code' => $stripeError?->decline_code ?? null,
|
|
]);
|
|
|
|
return new AboChargeResult(
|
|
provider: PaymentProvider::Stripe,
|
|
success: false,
|
|
status: 'ERROR',
|
|
rawResponse: is_array($errorObject) ? $errorObject : [],
|
|
transactionId: $stripeError?->payment_intent->id ?? '',
|
|
errorMessage: trim(($stripeError?->code ?? (string) $e->getStripeCode()).': '.($stripeError?->message ?? $e->getMessage()), ': '),
|
|
customerMessage: $this->customerMessageForDecline($stripeError?->code ?? (string) $e->getStripeCode()),
|
|
shoppingPayment: $shoppingPayment,
|
|
);
|
|
}
|
|
|
|
private function customerMessageForDecline(?string $code): string
|
|
{
|
|
return match ($code) {
|
|
'expired_card' => __('payment.payment_error_reasons.card_expired'),
|
|
'card_declined' => __('payment.payment_error_reasons.card_declined'),
|
|
'insufficient_funds' => __('payment.payment_error_reasons.insufficient_funds'),
|
|
'incorrect_cvc' => __('payment.payment_error_reasons.cvv_invalid'),
|
|
'authentication_required' => __('payment.stripe_abo_authentication_required'),
|
|
default => __('payment.payment_error_reasons.general'),
|
|
};
|
|
}
|
|
}
|