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>
This commit is contained in:
parent
c3dc7eb35e
commit
2e71173c66
31 changed files with 3612 additions and 65 deletions
135
app/Console/Commands/StripeBackfillCustomers.php
Normal file
135
app/Console/Commands/StripeBackfillCustomers.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Payment\Stripe\StripeCustomerService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Trägt Name und Rechnungsanschrift an bestehenden Stripe-Customern nach
|
||||
* (Plan dev/2026-08-26-stripe/, Phase SE1.5).
|
||||
*
|
||||
* Hintergrund: Vor SE1 legte Stripe die Customer für Abo-Zahlungen selbst an
|
||||
* (`customer_creation: 'always'`) — ohne Namen. In der Buchhaltung stand
|
||||
* deshalb nur die E-Mail. Dieses Command verknüpft die vorhandenen Customer
|
||||
* mit unseren Kundendatensätzen und aktualisiert sie.
|
||||
*
|
||||
* Mit `--refresh` werden stattdessen die bereits verknüpften Customer neu
|
||||
* beschrieben — nötig, wenn sich das Namensformat ändert.
|
||||
*/
|
||||
class StripeBackfillCustomers extends Command
|
||||
{
|
||||
protected $signature = 'stripe:backfill-customers
|
||||
{--refresh : Auch bereits verknüpfte Customer aktualisieren (z. B. nach einer Änderung des Namensformats)}
|
||||
{--dry-run : Nichts an Stripe senden, nur anzeigen was passieren würde}
|
||||
{--limit=0 : Maximale Anzahl bearbeiteter Kunden (0 = alle)}';
|
||||
|
||||
protected $description = 'Trägt Name/Anschrift an bestehenden Stripe-Customern nach (Phase SE1)';
|
||||
|
||||
public function handle(StripeClientWrapper $stripe, StripeCustomerService $customerService): int
|
||||
{
|
||||
if (! $stripe->isConfigured()) {
|
||||
$this->error('STRIPE_SECRET ist nicht gesetzt.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$refresh = (bool) $this->option('refresh');
|
||||
|
||||
$query = ShoppingUser::query()->orderBy('id');
|
||||
|
||||
if ($refresh) {
|
||||
// Bereits verknüpfte Kunden neu an Stripe schreiben.
|
||||
$query->whereNotNull('stripe_customer_id');
|
||||
} else {
|
||||
// Noch nicht verknüpfte Kunden, für die Stripe selbst einen
|
||||
// (namenlosen) Customer angelegt hat.
|
||||
$query->whereNull('stripe_customer_id')
|
||||
->whereHas('shopping_orders.shopping_payments', function ($q) {
|
||||
$q->where('payment_provider', 'stripe')->whereNotNull('stripe_customer_id');
|
||||
});
|
||||
}
|
||||
|
||||
$linked = 0;
|
||||
$updated = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById(100, function ($shoppingUsers) use (&$linked, &$updated, &$failed, $customerService, $dryRun, $limit, $refresh): bool {
|
||||
foreach ($shoppingUsers as $shoppingUser) {
|
||||
$customerId = $refresh
|
||||
? $shoppingUser->stripe_customer_id
|
||||
: $this->existingCustomerIdFor($shoppingUser);
|
||||
|
||||
if ($customerId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'#%d %s → %s',
|
||||
$shoppingUser->id,
|
||||
StripeCustomerService::customerNameFor($shoppingUser) ?? '(ohne Namen)',
|
||||
$customerId,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$linked++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $refresh) {
|
||||
$shoppingUser->forceFill(['stripe_customer_id' => $customerId])->save();
|
||||
}
|
||||
$linked++;
|
||||
$customerService->updateCustomer($customerId, $customerService->customerPayloadFor($shoppingUser));
|
||||
$updated++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(sprintf('Kunde #%d (%s): %s', $shoppingUser->id, $customerId, $e->getMessage()));
|
||||
}
|
||||
|
||||
if ($limit > 0 && $linked >= $limit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d %s, %d bei Stripe aktualisiert, %d fehlgeschlagen.',
|
||||
$dryRun ? 'Probelauf' : 'Fertig',
|
||||
$linked,
|
||||
$refresh ? 'geprüft' : 'verknüpft',
|
||||
$updated,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Stripe-Customer-ID, die Stripe bei einer früheren Zahlung dieses
|
||||
* Kunden selbst angelegt hat (jüngste zuerst).
|
||||
*/
|
||||
private function existingCustomerIdFor(ShoppingUser $shoppingUser): ?string
|
||||
{
|
||||
$orderIds = $shoppingUser->shopping_orders()->pluck('id');
|
||||
|
||||
if ($orderIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShoppingPayment::whereIn('shopping_order_id', $orderIds)
|
||||
->whereNotNull('stripe_customer_id')
|
||||
->latest('id')
|
||||
->value('stripe_customer_id');
|
||||
}
|
||||
}
|
||||
107
app/Console/Commands/StripeBackfillInvoiceMetadata.php
Normal file
107
app/Console/Commands/StripeBackfillInvoiceMetadata.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Trägt die MIVITA-Rechnungsnummer an bestehenden Stripe-Zahlungen nach
|
||||
* (Plan dev/2026-08-26-stripe/, Phase SE3.4).
|
||||
*
|
||||
* Stakeholder-Entscheid 2026-08-26 (F3): Die Zahlungen **seit dem Go-Live am
|
||||
* 2026-07-10** sollen rückwirkend ihre Rechnungsnummer bekommen, damit die
|
||||
* Buchhaltung auch die zurückliegenden Monate über die Berichte abstimmen kann.
|
||||
*/
|
||||
class StripeBackfillInvoiceMetadata extends Command
|
||||
{
|
||||
/** Stripe ist seit diesem Datum live (siehe dev/2026-07-08-stripe/). */
|
||||
private const GO_LIVE_DATE = '2026-07-10';
|
||||
|
||||
protected $signature = 'stripe:backfill-invoice-metadata
|
||||
{--from= : Startdatum (Y-m-d), Standard: Go-Live 2026-07-10}
|
||||
{--to= : Enddatum (Y-m-d), Standard: heute}
|
||||
{--dry-run : Nichts an Stripe senden, nur anzeigen was passieren würde}
|
||||
{--limit=0 : Maximale Anzahl bearbeiteter Zahlungen (0 = alle)}';
|
||||
|
||||
protected $description = 'Trägt Rechnungsnummern an bestehenden Stripe-Zahlungen nach (Phase SE3)';
|
||||
|
||||
public function handle(StripeClientWrapper $stripe, StripeInvoiceReferenceService $service): int
|
||||
{
|
||||
if (! $stripe->isConfigured()) {
|
||||
$this->error('STRIPE_SECRET ist nicht gesetzt.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$from = $this->option('from') ?: self::GO_LIVE_DATE;
|
||||
$to = $this->option('to');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$query = ShoppingPayment::where('payment_provider', 'stripe')
|
||||
->where('external_reference', 'LIKE', 'pi\_%')
|
||||
->where('created_at', '>=', $from.' 00:00:00')
|
||||
->whereHas('shopping_order.user_invoice')
|
||||
->with('shopping_order.user_invoice')
|
||||
->orderBy('id');
|
||||
|
||||
if ($to) {
|
||||
$query->where('created_at', '<=', $to.' 23:59:59');
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$this->info(sprintf('%d Stripe-Zahlungen mit Rechnung ab %s gefunden.', $total, $from));
|
||||
|
||||
$synced = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById(50, function ($payments) use (&$synced, &$skipped, &$failed, $service, $dryRun, $limit): bool {
|
||||
foreach ($payments as $payment) {
|
||||
$invoice = $payment->shopping_order?->user_invoice;
|
||||
if (! $invoice) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Zahlung #%-7d %s → Rechnung %s',
|
||||
$payment->id,
|
||||
$payment->external_reference,
|
||||
$invoice->full_number,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$synced++;
|
||||
} else {
|
||||
try {
|
||||
$service->sync($payment, $invoice) ? $synced++ : $skipped++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(' → '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($limit > 0 && ($synced + $skipped + $failed) >= $limit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d übertragen, %d übersprungen (schon gesetzt / keine Rechnung), %d fehlgeschlagen.',
|
||||
$dryRun ? 'Probelauf' : 'Fertig',
|
||||
$synced,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
223
app/Console/Commands/StripeSyncPaymentStatus.php
Normal file
223
app/Console/Commands/StripeSyncPaymentStatus.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Stripe\Checkout\Session;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
/**
|
||||
* Gleicht offene Stripe-Zahlungen aktiv gegen die Stripe-API ab und zieht den
|
||||
* Status nach (Plan dev/2026-07-08-stripe/, S6.4 — vorgezogen am 2026-08-26).
|
||||
*
|
||||
* Abgrenzung zu `stripe:process-pending-events`: Jenes arbeitet auf Events,
|
||||
* die bei uns GESPEICHERT sind (Webhook kam an, Verarbeitung scheiterte).
|
||||
* Dieses Command deckt den anderen Fall ab — der Webhook wurde **nie
|
||||
* zugestellt** (Listener am falschen Konto, Endpoint down, Stripe hat nach
|
||||
* ~3 Tagen Retry aufgegeben). Dann ist das Geld bei Stripe eingegangen,
|
||||
* während die Bestellung bei uns auf "offen" stehen bleibt.
|
||||
*
|
||||
* Die Seiteneffekte laufen über denselben Pfad wie ein echter Webhook
|
||||
* (StripeWebhookHandler) und sind idempotent — eine bereits bezahlte
|
||||
* Bestellung wird nicht erneut verarbeitet.
|
||||
*/
|
||||
class StripeSyncPaymentStatus extends Command
|
||||
{
|
||||
protected $signature = 'stripe:sync-payment-status
|
||||
{target? : PaymentIntent-ID (pi_…), Session-ID (cs_…), Zahlungsreferenz oder shopping_payment-ID}
|
||||
{--pending : Alle offenen Stripe-Zahlungen der letzten Tage prüfen}
|
||||
{--days=7 : Zeitfenster für --pending}
|
||||
{--dry-run : Nur anzeigen, was Stripe sagt — nichts verarbeiten}';
|
||||
|
||||
protected $description = 'Zieht den Status offener Stripe-Zahlungen aktiv über die API nach (verlorene Webhooks)';
|
||||
|
||||
public function handle(StripeClientWrapper $stripe, StripeWebhookHandler $webhookHandler): int
|
||||
{
|
||||
if (! $stripe->isConfigured()) {
|
||||
$this->error('STRIPE_SECRET ist nicht gesetzt.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payments = $this->resolvePayments();
|
||||
|
||||
if ($payments->isEmpty()) {
|
||||
$this->info('Keine passenden offenen Stripe-Zahlungen gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$synced = 0;
|
||||
$unchanged = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
try {
|
||||
$result = $this->syncPayment($stripe, $webhookHandler, $payment, $dryRun);
|
||||
$result ? $synced++ : $unchanged++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(sprintf('Zahlung #%d: %s', $payment->id, $e->getMessage()));
|
||||
Log::channel('stripe')->error('stripe:sync-payment-status fehlgeschlagen', [
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d nachgezogen, %d unverändert, %d fehlgeschlagen.',
|
||||
$dryRun ? 'Probelauf' : 'Fertig',
|
||||
$synced,
|
||||
$unchanged,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ShoppingPayment>
|
||||
*/
|
||||
private function resolvePayments(): Collection
|
||||
{
|
||||
$target = $this->argument('target');
|
||||
|
||||
if ($target) {
|
||||
$payment = ShoppingPayment::where('external_reference', $target)
|
||||
->orWhere('reference', $target)
|
||||
->orWhere('id', is_numeric($target) ? (int) $target : 0)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $payment) {
|
||||
$this->error("Keine Zahlung zu '{$target}' gefunden.");
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
return collect([$payment]);
|
||||
}
|
||||
|
||||
if (! $this->option('pending')) {
|
||||
$this->error('Bitte eine Zahlung angeben oder --pending nutzen.');
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ShoppingPayment::where('payment_provider', 'stripe')
|
||||
->whereNotNull('external_reference')
|
||||
->where('created_at', '>=', now()->subDays((int) $this->option('days')))
|
||||
// Abgelaufene Checkout-Sessions haben nie eine Zahlung getragen —
|
||||
// sie jede Stunde erneut bei Stripe abzufragen wäre reine Last.
|
||||
->where(function ($query) {
|
||||
$query->whereNull('status')->orWhere('status', '!=', 'expired');
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('txaction')->orWhereNotIn('txaction', ['paid', 'cancelled']);
|
||||
})
|
||||
->whereHas('shopping_order', function ($query) {
|
||||
$query->where('paid', false);
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function syncPayment(
|
||||
StripeClientWrapper $stripe,
|
||||
StripeWebhookHandler $webhookHandler,
|
||||
ShoppingPayment $payment,
|
||||
bool $dryRun,
|
||||
): bool {
|
||||
[$paymentIntent, $session] = $this->fetchStripeObjects($stripe, $payment);
|
||||
|
||||
if (! $paymentIntent) {
|
||||
$this->warn(sprintf(
|
||||
'Zahlung #%d (%s): kein PaymentIntent bei Stripe%s.',
|
||||
$payment->id,
|
||||
$payment->external_reference,
|
||||
$session ? ' — Session-Status: '.$session->status : '',
|
||||
));
|
||||
|
||||
if ($session && $session->status === 'expired' && ! $dryRun) {
|
||||
return $webhookHandler->syncFromApiObject('checkout.session.expired', $session->toArray(), 'api-sync');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$eventType = match ($paymentIntent->status) {
|
||||
'succeeded' => 'payment_intent.succeeded',
|
||||
'processing' => 'payment_intent.processing',
|
||||
'requires_payment_method', 'requires_action', 'requires_confirmation' => 'payment_intent.payment_failed',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$this->line(sprintf(
|
||||
'Zahlung #%d lokal: %-9s Stripe: %-22s %s',
|
||||
$payment->id,
|
||||
$payment->txaction ?: 'offen',
|
||||
$paymentIntent->status,
|
||||
$paymentIntent->id,
|
||||
));
|
||||
|
||||
if ($eventType === null) {
|
||||
$this->warn(" → Status '{$paymentIntent->status}' wird nicht verarbeitet.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment(" → würde als '{$eventType}' verarbeitet.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$handled = $webhookHandler->syncFromApiObject($eventType, $paymentIntent->toArray(), 'api-sync');
|
||||
|
||||
// Zahlart (Kartenmarke/letzte 4, PayPal …) kommt nur am Charge — nach
|
||||
// dem PaymentIntent nachziehen, damit external_reference bereits sitzt.
|
||||
$charge = $paymentIntent->latest_charge ?? null;
|
||||
if ($handled && $charge && is_object($charge)) {
|
||||
$webhookHandler->syncFromApiObject('charge.succeeded', $charge->toArray(), 'api-sync');
|
||||
}
|
||||
|
||||
$this->info(sprintf(' → verarbeitet als %s%s', $eventType, $handled ? '' : ' (keiner Zahlung zuordenbar)'));
|
||||
|
||||
return $handled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt PaymentIntent (und ggf. Session) zu einer Zahlung. `external_reference`
|
||||
* trägt je nach Zeitpunkt die Session- oder die PaymentIntent-ID.
|
||||
*
|
||||
* @return array{0: PaymentIntent|null, 1: Session|null}
|
||||
*/
|
||||
private function fetchStripeObjects(StripeClientWrapper $stripe, ShoppingPayment $payment): array
|
||||
{
|
||||
$reference = (string) $payment->external_reference;
|
||||
$client = $stripe->client();
|
||||
|
||||
if (str_starts_with($reference, 'pi_')) {
|
||||
return [$client->paymentIntents->retrieve($reference, ['expand' => ['latest_charge']]), null];
|
||||
}
|
||||
|
||||
if (str_starts_with($reference, 'cs_')) {
|
||||
$session = $client->checkout->sessions->retrieve($reference);
|
||||
$paymentIntentId = is_string($session->payment_intent) ? $session->payment_intent : ($session->payment_intent->id ?? null);
|
||||
|
||||
return [
|
||||
$paymentIntentId ? $client->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]) : null,
|
||||
$session,
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Unbekannte external_reference '{$reference}'.");
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,16 @@ class Kernel extends ConsoleKernel
|
|||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Stripe: offene Zahlungen aktiv gegen die API abgleichen. Fängt den Fall
|
||||
// ab, dass ein Webhook NIE zugestellt wurde (Endpoint down, falsches
|
||||
// Konto am Listener, Stripe gibt Retries nach ~3 Tagen auf) — dann läge
|
||||
// das Geld bei Stripe, während die Bestellung auf "offen" steht.
|
||||
// Läuft über denselben, idempotenten Paid-Pfad wie ein echter Webhook.
|
||||
$schedule->command('stripe:sync-payment-status --pending --days=3')
|
||||
->hourly()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
|
||||
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
|
||||
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:
|
||||
|
|
|
|||
12
app/Exceptions/ManualPaymentException.php
Normal file
12
app/Exceptions/ManualPaymentException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Wird geworfen, wenn ein Zahlungseingang nicht manuell erfasst werden kann
|
||||
* (Bestellung bereits bezahlt oder storniert, Zahlung gehört nicht zur
|
||||
* Bestellung).
|
||||
*/
|
||||
class ManualPaymentException extends Exception {}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\ManualPaymentException;
|
||||
use App\Http\Requests\Payment\RecordManualPaymentRequest;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
|
|
@ -11,7 +13,10 @@ use App\Repositories\InvoiceRepository;
|
|||
use App\Services\AboExternalCompletionService;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\ManualPaymentService;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Request;
|
||||
|
||||
class SalesController extends Controller
|
||||
|
|
@ -400,6 +405,43 @@ class SalesController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlungseingang manuell erfassen (Plan dev/2026-08-26-stripe/, SE4).
|
||||
*
|
||||
* Für Zahlungen, die kein Anbieter melden kann — vor allem Vorkasse mit
|
||||
* falschem Verwendungszweck. Löst denselben Paid-Pfad aus wie ein
|
||||
* Anbieter-Callback: Rechnung, Umsatz/Punkte, Abo-Aktivierung und Mail.
|
||||
*/
|
||||
public function recordManualPayment(
|
||||
RecordManualPaymentRequest $request,
|
||||
ManualPaymentService $manualPayments,
|
||||
): RedirectResponse {
|
||||
$shoppingOrder = ShoppingOrder::findOrFail($request->integer('id'));
|
||||
$shoppingPayment = ShoppingPayment::findOrFail($request->integer('payment_id'));
|
||||
$back = $request->input('back') ?: route('admin_sales_users_detail', [$shoppingOrder->id]);
|
||||
|
||||
try {
|
||||
$manualPayments->recordIncomingPayment(
|
||||
$shoppingOrder,
|
||||
$shoppingPayment,
|
||||
$request->toServiceData(),
|
||||
\Auth::user(),
|
||||
);
|
||||
|
||||
\Session()->flash('alert-success', __('payment.manual_success'));
|
||||
} catch (ManualPaymentException $e) {
|
||||
\Session()->flash('alert-error', $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
\Log::channel('payment')->error('Manueller Zahlungseingang fehlgeschlagen: '.$e->getMessage(), [
|
||||
'shopping_order_id' => $shoppingOrder->id,
|
||||
'shopping_payment_id' => $shoppingPayment->id,
|
||||
]);
|
||||
\Session()->flash('alert-error', __('payment.manual_error_general'));
|
||||
}
|
||||
|
||||
return redirect($back);
|
||||
}
|
||||
|
||||
/*
|
||||
Manuelle Rechnung erstellen.*/
|
||||
public function invoice()
|
||||
|
|
@ -419,6 +461,10 @@ class SalesController extends Controller
|
|||
$invoice_repo->createAndSalesVolume($data);
|
||||
}
|
||||
|
||||
// Rechnungsnummer an eine Stripe-Zahlung nachtragen (SE3)
|
||||
$shopping_order->refresh();
|
||||
StripeInvoiceReferenceService::dispatchForOrder($shopping_order);
|
||||
|
||||
if (isset($data['view']) && $data['view'] === 'sales_customer') {
|
||||
return redirect(route('admin_sales_customers_detail', [$shopping_order->id]));
|
||||
}
|
||||
|
|
|
|||
103
app/Http/Requests/Payment/RecordManualPaymentRequest.php
Normal file
103
app/Http/Requests/Payment/RecordManualPaymentRequest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Payment;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\ManualPaymentService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Erfassung eines Zahlungseingangs durch die Buchhaltung
|
||||
* (Plan dev/2026-08-26-stripe/, Phase SE4).
|
||||
*
|
||||
* Der Verwendungszweck bzw. die Bankreferenz ist Pflicht — sie ist der Beleg,
|
||||
* mit dem die Buchung später nachvollziehbar bleibt.
|
||||
*/
|
||||
class RecordManualPaymentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Die Route liegt hinter der admin-Middleware.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'integer', 'exists:shopping_orders,id'],
|
||||
'payment_id' => ['required', 'integer', 'exists:shopping_payments,id'],
|
||||
'paid_at' => ['required', 'date', 'before_or_equal:today'],
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:99999'],
|
||||
'method' => ['required', Rule::in(array_keys(ManualPaymentService::methods()))],
|
||||
'bank_reference' => ['required', 'string', 'max:255'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
'send_mail' => ['nullable', 'boolean'],
|
||||
'confirm_deviation' => ['nullable', 'boolean'],
|
||||
'back' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'bank_reference.required' => __('payment.manual_error_reference_required'),
|
||||
'paid_at.before_or_equal' => __('payment.manual_error_future_date'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein abweichender Betrag ist erlaubt (Teilzahlung, Rundungsdifferenz),
|
||||
* muss aber bewusst bestätigt werden — sonst wird aus einem Tippfehler
|
||||
* still eine falsche Buchung.
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment = ShoppingPayment::find($this->integer('payment_id'));
|
||||
if (! $payment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->amountCents() !== (int) $payment->amount && ! $this->boolean('confirm_deviation')) {
|
||||
$validator->errors()->add('amount', __('payment.manual_error_amount_deviation', [
|
||||
'expected' => number_format($payment->amount / 100, 2, ',', '.'),
|
||||
]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erfasster Betrag in Cent (die Zahlungen werden in Cent geführt).
|
||||
*/
|
||||
public function amountCents(): int
|
||||
{
|
||||
return (int) round(((float) str_replace(',', '.', (string) $this->input('amount'))) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{amount_cents: int, paid_at: string, method: string, bank_reference: string, note: string|null, send_mail: bool}
|
||||
*/
|
||||
public function toServiceData(): array
|
||||
{
|
||||
return [
|
||||
'amount_cents' => $this->amountCents(),
|
||||
'paid_at' => (string) $this->input('paid_at'),
|
||||
'method' => (string) $this->input('method'),
|
||||
'bank_reference' => trim((string) $this->input('bank_reference')),
|
||||
'note' => $this->filled('note') ? trim((string) $this->input('note')) : null,
|
||||
'send_mail' => $this->boolean('send_mail'),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Jobs/SyncStripeInvoiceReference.php
Normal file
48
app/Jobs/SyncStripeInvoiceReference.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
|
||||
use Illuminate\Bus\Queueable as BusQueueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Trägt die MIVITA-Rechnungsnummer am Stripe-PaymentIntent nach
|
||||
* (Plan dev/2026-08-26-stripe/, Phase SE3).
|
||||
*
|
||||
* Asynchron, weil der Auslöser im Webhook-Paid-Pfad sitzt: ein API-Call zu
|
||||
* Stripe darf die Antwortzeit des Webhooks nicht belasten und erst recht
|
||||
* nicht dazu führen, dass Stripe den Webhook als fehlgeschlagen wertet.
|
||||
*/
|
||||
class SyncStripeInvoiceReference implements ShouldQueue
|
||||
{
|
||||
use BusQueueable, Dispatchable, InteractsWithQueue, SerializesModels;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public ShoppingPayment $shoppingPayment,
|
||||
public UserInvoice $userInvoice,
|
||||
) {}
|
||||
|
||||
public function handle(StripeInvoiceReferenceService $service): void
|
||||
{
|
||||
$service->sync($this->shoppingPayment, $this->userInvoice);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $e): void
|
||||
{
|
||||
Log::channel('stripe')->error('Rechnungsnummer konnte nicht an Stripe übertragen werden: '.$e->getMessage(), [
|
||||
'shopping_payment_id' => $this->shoppingPayment->id,
|
||||
'user_invoice_id' => $this->userInvoice->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\ShoppingUser
|
||||
|
|
@ -22,6 +25,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int $billing_country_id
|
||||
* @property string|null $billing_phone
|
||||
* @property string|null $billing_email
|
||||
* @property string|null $stripe_customer_id
|
||||
* @property int $accepted_data_checkbox
|
||||
* @property int $same_as_billing
|
||||
* @property string|null $shipping_salutation
|
||||
|
|
@ -34,12 +38,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property string|null $shipping_city
|
||||
* @property int $shipping_country_id
|
||||
* @property string|null $shipping_phone
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $Shopping_orders
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Collection|ShoppingOrder[] $Shopping_orders
|
||||
* @property-read int|null $shopping_orders_count
|
||||
* @property-read \App\Models\Country $billing_country
|
||||
* @property-read \App\Models\Country $shipping_country
|
||||
* @property-read Country $billing_country
|
||||
* @property-read Country $shipping_country
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser newQuery()
|
||||
|
|
@ -73,8 +77,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereUpdatedAt($value)
|
||||
*
|
||||
* @property int|null $orders
|
||||
* @property-read \App\Models\ShoppingOrder $shopping_order
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders
|
||||
* @property-read ShoppingOrder $shopping_order
|
||||
* @property-read Collection|ShoppingOrder[] $shopping_orders
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereOrders($value)
|
||||
*
|
||||
|
|
@ -86,8 +90,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int|null $number
|
||||
* @property bool $is_like
|
||||
* @property array|null $notice
|
||||
* @property-read \App\User|null $auth_user
|
||||
* @property-read \App\User|null $member
|
||||
* @property-read User|null $auth_user
|
||||
* @property-read User|null $member
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereIsLike($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereMemberId($value)
|
||||
|
|
@ -104,7 +108,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderDate($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereWpOrderNumber($value)
|
||||
*
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property string|null $user_deleted_at
|
||||
*
|
||||
* @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingUser onlyTrashed()
|
||||
|
|
@ -172,6 +176,7 @@ class ShoppingUser extends Model
|
|||
'billing_country_id',
|
||||
'billing_phone',
|
||||
'billing_email',
|
||||
'stripe_customer_id',
|
||||
'faker_mail',
|
||||
'shipping_email',
|
||||
'accepted_data_checkbox',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Models\UserAccount;
|
|||
use App\Models\UserBusiness;
|
||||
use App\Models\UserShop;
|
||||
use App\Models\UserUpdateEmail;
|
||||
use App\Services\Payment\Stripe\StripeCustomerService;
|
||||
use App\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -27,6 +28,8 @@ use Illuminate\Support\Facades\Storage;
|
|||
* Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt.
|
||||
* - Provisions-Snapshots (user_businesses) behalten die Zahlen, die
|
||||
* denormalisierten Personendaten werden anonymisiert.
|
||||
* - Stripe-Customer-Objekte werden gelöscht (Phase SE1.6, dev/2026-08-26-stripe/);
|
||||
* die Charges bleiben bei Stripe erhalten — sie sind Buchungsbelege.
|
||||
*/
|
||||
class GdprDeletionService
|
||||
{
|
||||
|
|
@ -54,6 +57,13 @@ class GdprDeletionService
|
|||
|
||||
$originalEmail = $user->email;
|
||||
|
||||
// Vor der Transaktion einsammeln: die IDs sind danach genullt, und der
|
||||
// Stripe-Aufruf gehört nicht in eine offene DB-Transaktion.
|
||||
$stripeCustomerIds = $this->collectStripeCustomerIds(
|
||||
$user,
|
||||
ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get()
|
||||
);
|
||||
|
||||
$summary = DB::transaction(function () use ($user) {
|
||||
$summary = ['user_id' => $user->id];
|
||||
|
||||
|
|
@ -85,6 +95,8 @@ class GdprDeletionService
|
|||
return $summary;
|
||||
});
|
||||
|
||||
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
|
||||
|
||||
$this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
|
|
@ -114,6 +126,8 @@ class GdprDeletionService
|
|||
|
||||
$originalEmail = $shoppingUser->billing_email;
|
||||
|
||||
$stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows);
|
||||
|
||||
$summary = DB::transaction(function () use ($shoppingUser, $rows) {
|
||||
$summary = ['shopping_user_id' => $shoppingUser->id];
|
||||
$summary['abos_anonymized'] = 0;
|
||||
|
|
@ -125,6 +139,8 @@ class GdprDeletionService
|
|||
return $summary;
|
||||
});
|
||||
|
||||
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
|
||||
|
||||
$this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
|
|
@ -146,6 +162,7 @@ class GdprDeletionService
|
|||
|
||||
protected function anonymizeUserRow(User $user): void
|
||||
{
|
||||
$user->stripe_customer_id = null;
|
||||
$user->notes = null;
|
||||
$user->settings = null;
|
||||
$user->payment_methods = null;
|
||||
|
|
@ -306,6 +323,7 @@ class GdprDeletionService
|
|||
|
||||
protected function anonymizeShoppingUserContactData(ShoppingUser $row): void
|
||||
{
|
||||
$row->stripe_customer_id = null;
|
||||
$row->billing_phone = null;
|
||||
$row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid';
|
||||
$row->shipping_phone = null;
|
||||
|
|
@ -339,6 +357,69 @@ class GdprDeletionService
|
|||
$this->anonymizeShoppingUserContactData($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Stripe-Customer-IDs des Betroffenen einsammeln (Berater, Kunden-
|
||||
* Datensätze und deren Abos) — Phase SE1.6.
|
||||
*
|
||||
* @param Collection<int, ShoppingUser>|\Illuminate\Database\Eloquent\Collection<int, ShoppingUser> $shoppingUsers
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function collectStripeCustomerIds(?User $user, $shoppingUsers): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
if ($user && ! empty($user->stripe_customer_id)) {
|
||||
$ids[] = $user->stripe_customer_id;
|
||||
}
|
||||
|
||||
foreach ($shoppingUsers as $row) {
|
||||
if (! empty($row->stripe_customer_id)) {
|
||||
$ids[] = $row->stripe_customer_id;
|
||||
}
|
||||
}
|
||||
|
||||
$aboQuery = UserAbo::withTrashed()->whereNotNull('stripe_customer_id');
|
||||
$aboQuery->where(function ($query) use ($user, $shoppingUsers) {
|
||||
$query->whereIn('shopping_user_id', collect($shoppingUsers)->pluck('id')->all());
|
||||
if ($user) {
|
||||
$query->orWhere('user_id', $user->id);
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($aboQuery->pluck('stripe_customer_id') as $customerId) {
|
||||
$ids[] = $customerId;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe-Customer-Objekte löschen (Art. 17 DSGVO). Die zugehörigen Charges
|
||||
* bleiben bei Stripe bestehen (Aufbewahrungspflicht). Fehler werden
|
||||
* protokolliert, brechen die Löschung im System aber nicht ab — der
|
||||
* Datenbestand ist zu diesem Zeitpunkt bereits anonymisiert.
|
||||
*
|
||||
* @param array<int, string> $customerIds
|
||||
* @return int Anzahl der bei Stripe tatsächlich gelöschten Customer
|
||||
*/
|
||||
protected function deleteStripeCustomers(array $customerIds): int
|
||||
{
|
||||
if ($customerIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$service = app(StripeCustomerService::class);
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($customerIds as $customerId) {
|
||||
if ($service->deleteCustomer($customerId)) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten.
|
||||
*
|
||||
|
|
|
|||
177
app/Services/ManualPaymentService.php
Normal file
177
app/Services/ManualPaymentService.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ManualPaymentException;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Erfasst einen Zahlungseingang, den kein Zahlungsanbieter melden kann
|
||||
* (Plan dev/2026-08-26-stripe/, Phase SE4).
|
||||
*
|
||||
* Anlass (Buchhaltung, Mail 2026-08-26): Bei **Vorkasse über PAYONE** schreiben
|
||||
* Kunden regelmäßig einen falschen Verwendungszweck auf die Überweisung.
|
||||
* PAYONE kann den Eingang dann nicht automatisch zuordnen, und die Buchhaltung
|
||||
* musste die Rechnung bisher von Hand schreiben — der bestehende
|
||||
* Status-Button setzte nur `paid`, ohne Rechnung, Punkte, Abo-Aktivierung
|
||||
* oder Mail auszulösen.
|
||||
*
|
||||
* Dieser Service läuft durch **denselben** Paid-Pfad wie ein Anbieter-Callback
|
||||
* ({@see Payment::paymentStatusPaidAction}) und protokolliert den Vorgang
|
||||
* revisionssicher: Wer hat wann welchen Betrag mit welchem Beleg erfasst.
|
||||
*/
|
||||
class ManualPaymentService
|
||||
{
|
||||
public const METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
|
||||
public const METHOD_CASH = 'cash';
|
||||
|
||||
public const METHOD_OTHER = 'other';
|
||||
|
||||
/**
|
||||
* Zahlungswege für das Erfassungsformular.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function methods(): array
|
||||
{
|
||||
return [
|
||||
self::METHOD_BANK_TRANSFER => __('payment.manual_method_bank_transfer'),
|
||||
self::METHOD_CASH => __('payment.manual_method_cash'),
|
||||
self::METHOD_OTHER => __('payment.manual_method_other'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbucht den Zahlungseingang und löst alle Folgeschritte aus:
|
||||
* Rechnung, Umsatz/Punkte, Abo-Aktivierung und Bestätigungsmail.
|
||||
*
|
||||
* @param array{amount_cents: int, paid_at: string, method: string, bank_reference: string, note?: string|null, send_mail?: bool} $data
|
||||
*
|
||||
* @throws ManualPaymentException
|
||||
*/
|
||||
public function recordIncomingPayment(
|
||||
ShoppingOrder $shoppingOrder,
|
||||
ShoppingPayment $shoppingPayment,
|
||||
array $data,
|
||||
?User $admin = null,
|
||||
): PaymentTransaction {
|
||||
if ((int) $shoppingPayment->shopping_order_id !== (int) $shoppingOrder->id) {
|
||||
throw new ManualPaymentException(__('payment.manual_error_payment_mismatch'));
|
||||
}
|
||||
if ($shoppingOrder->paid) {
|
||||
throw new ManualPaymentException(__('payment.manual_error_already_paid'));
|
||||
}
|
||||
if (in_array($shoppingOrder->txaction, ['cancelled'], true)) {
|
||||
throw new ManualPaymentException(__('payment.manual_error_cancelled'));
|
||||
}
|
||||
|
||||
$sendLink = false;
|
||||
|
||||
$transaction = DB::transaction(function () use ($shoppingOrder, $shoppingPayment, $data, $admin, &$sendLink) {
|
||||
// Gegen Doppelklick und parallelen Anbieter-Callback absichern —
|
||||
// gleiches Muster wie im Stripe-/PAYONE-Webhook.
|
||||
$lockedOrder = ShoppingOrder::where('id', $shoppingOrder->id)->lockForUpdate()->first();
|
||||
|
||||
if ($lockedOrder->paid) {
|
||||
throw new ManualPaymentException(__('payment.manual_error_already_paid'));
|
||||
}
|
||||
|
||||
$transaction = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shoppingPayment->id,
|
||||
'provider' => 'manual',
|
||||
'request' => 'manual_payment',
|
||||
'txid' => 0,
|
||||
'userid' => 0,
|
||||
'status' => 'MANUAL',
|
||||
'txaction' => 'paid',
|
||||
'transmitted_data' => $this->auditPayload($shoppingPayment, $data, $admin),
|
||||
'mode' => $shoppingPayment->mode,
|
||||
]);
|
||||
|
||||
$shoppingPayment->txaction = 'paid';
|
||||
$shoppingPayment->save();
|
||||
$lockedOrder->txaction = 'paid';
|
||||
$lockedOrder->save();
|
||||
|
||||
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $shoppingPayment);
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
|
||||
if (! empty($data['send_mail'])) {
|
||||
Payment::paymentStatusSendMail($shoppingOrder->fresh(), $shoppingPayment, [
|
||||
'txaction' => 'paid',
|
||||
'mode' => $shoppingPayment->mode,
|
||||
'send_link' => $sendLink,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logAction($shoppingOrder, $shoppingPayment, $data, $admin);
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revisionsspur: Was wurde erfasst, von wem, und weicht der Betrag ab.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function auditPayload(ShoppingPayment $shoppingPayment, array $data, ?User $admin): array
|
||||
{
|
||||
$amountCents = (int) $data['amount_cents'];
|
||||
|
||||
return [
|
||||
'source' => 'admin_manual_payment',
|
||||
'amount_cents' => $amountCents,
|
||||
'expected_cents' => (int) $shoppingPayment->amount,
|
||||
'amount_matches' => $amountCents === (int) $shoppingPayment->amount,
|
||||
'paid_at' => $data['paid_at'],
|
||||
'method' => $data['method'],
|
||||
'bank_reference' => $data['bank_reference'],
|
||||
'note' => $data['note'] ?? null,
|
||||
'recorded_by_user_id' => $admin?->id,
|
||||
'recorded_by_email' => $admin?->email,
|
||||
'recorded_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function logAction(ShoppingOrder $shoppingOrder, ShoppingPayment $shoppingPayment, array $data, ?User $admin): void
|
||||
{
|
||||
$message = sprintf(
|
||||
'Zahlungseingang manuell erfasst: %s (Beleg: %s, Zahlungsweg: %s, Zahlung #%d)',
|
||||
Util::formatNumber(((int) $data['amount_cents']) / 100).' '.$shoppingPayment->currency,
|
||||
$data['bank_reference'],
|
||||
$data['method'],
|
||||
$shoppingPayment->id,
|
||||
);
|
||||
|
||||
try {
|
||||
SysLog::action('manual-payment', 'payment', 3)
|
||||
->setModel($shoppingOrder->id, ShoppingOrder::class)
|
||||
->setUserId($admin?->id)
|
||||
->setMessage($message)
|
||||
->save();
|
||||
} catch (\Throwable $e) {
|
||||
// Das Protokoll darf die Buchung nicht kippen — sie ist bereits committed.
|
||||
Log::channel('payment')->error('SysLog für manuellen Zahlungseingang fehlgeschlagen: '.$e->getMessage(), [
|
||||
'shopping_order_id' => $shoppingOrder->id,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::channel('payment')->info($message, [
|
||||
'shopping_order_id' => $shoppingOrder->id,
|
||||
'shopping_payment_id' => $shoppingPayment->id,
|
||||
'recorded_by_user_id' => $admin?->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ use App\Models\UserLevel;
|
|||
use App\Repositories\InvoiceRepository;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\Payment\Stripe\StripeInvoiceReferenceService;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
|
|
@ -328,6 +329,15 @@ class Payment
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Rechnungsnummer an die Stripe-Zahlung nachtragen (Plan
|
||||
* dev/2026-08-26-stripe/, SE3). Läuft asynchron und best effort —
|
||||
* eine bezahlte Bestellung bleibt bezahlt, auch wenn Stripe gerade
|
||||
* nicht erreichbar ist.
|
||||
*/
|
||||
$shopping_order->refresh();
|
||||
StripeInvoiceReferenceService::dispatchForOrder($shopping_order, $shopping_payment);
|
||||
}
|
||||
|
||||
return $send_link;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,19 @@
|
|||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
|
||||
* ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent:
|
||||
* eine vorhandene ID wird nie überschrieben.
|
||||
* ID (users.stripe_customer_id / customers.stripe_customer_id /
|
||||
* shopping_users.stripe_customer_id). Idempotent: eine vorhandene ID wird
|
||||
* nie überschrieben.
|
||||
*
|
||||
* Phase SE1 (dev/2026-08-26-stripe/): Der Checkout hängt den Customer an die
|
||||
* Session, damit Name und Anschrift in Stripe-Zahlungsliste und CSV-Berichten
|
||||
* stehen (Stripe-Empfehlung "Option A", Mail Mehmet Ergün 2026-08-26).
|
||||
*/
|
||||
class StripeCustomerService
|
||||
{
|
||||
|
|
@ -33,4 +40,139 @@ class StripeCustomerService
|
|||
|
||||
return $customer->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe-Customer des Endkunden aus dem Checkout — mit Rechnungsname und
|
||||
* -anschrift. Ein vorhandener Customer wird wiederverwendet und dabei mit
|
||||
* den aktuellen Rechnungsdaten aktualisiert (der Kunde kann seine Anschrift
|
||||
* zwischen zwei Bestellungen geändert haben).
|
||||
*/
|
||||
public function customerIdForShoppingUser(ShoppingUser $shoppingUser): string
|
||||
{
|
||||
$payload = $this->customerPayloadFor($shoppingUser);
|
||||
|
||||
if (! empty($shoppingUser->stripe_customer_id)) {
|
||||
$this->updateCustomer($shoppingUser->stripe_customer_id, $payload);
|
||||
|
||||
return $shoppingUser->stripe_customer_id;
|
||||
}
|
||||
|
||||
$customer = $this->stripe->client()->customers->create($payload);
|
||||
|
||||
$shoppingUser->forceFill(['stripe_customer_id' => $customer->id])->save();
|
||||
|
||||
return $customer->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert Name/Anschrift eines bestehenden Customers — best effort.
|
||||
* Ein Fehler hier darf einen laufenden Checkout niemals kippen; die
|
||||
* Zahlung funktioniert auch mit veralteten Stammdaten am Customer.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function updateCustomer(string $customerId, array $payload): void
|
||||
{
|
||||
try {
|
||||
$this->stripe->client()->customers->update($customerId, $payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('stripe')->warning('Stripe-Customer konnte nicht aktualisiert werden: '.$e->getMessage(), [
|
||||
'customer' => $customerId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Stripe-Customer (DSGVO Art. 17, Phase SE1.6). Die Charges
|
||||
* bleiben bei Stripe erhalten — sie unterliegen der Aufbewahrungspflicht.
|
||||
* Best effort: ein Fehler darf die Löschung im System nicht blockieren.
|
||||
*/
|
||||
public function deleteCustomer(string $customerId): bool
|
||||
{
|
||||
try {
|
||||
$this->stripe->client()->customers->delete($customerId);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('stripe')->warning('Stripe-Customer konnte nicht gelöscht werden: '.$e->getMessage(), [
|
||||
'customer' => $customerId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe-Customer-Payload aus den Rechnungsdaten eines Endkunden.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function customerPayloadFor(ShoppingUser $shoppingUser): array
|
||||
{
|
||||
$payload = [
|
||||
'name' => self::customerNameFor($shoppingUser),
|
||||
'email' => $shoppingUser->billing_email ?: null,
|
||||
'metadata' => [
|
||||
'mivita_type' => 'shopping_user',
|
||||
'mivita_id' => (string) $shoppingUser->id,
|
||||
],
|
||||
];
|
||||
|
||||
if ($shoppingUser->member_id) {
|
||||
$payload['metadata']['mivita_member_id'] = (string) $shoppingUser->member_id;
|
||||
}
|
||||
if ($shoppingUser->number) {
|
||||
$payload['metadata']['mivita_customer_number'] = (string) $shoppingUser->number;
|
||||
}
|
||||
if ($shoppingUser->billing_phone) {
|
||||
$payload['phone'] = $shoppingUser->billing_phone;
|
||||
}
|
||||
|
||||
$address = self::billingAddressFor($shoppingUser);
|
||||
if ($address !== null) {
|
||||
$payload['address'] = $address;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename für Stripe: "Vorname Nachname (Firma)".
|
||||
*
|
||||
* Der Personenname steht vorn, weil im System und in der Kommunikation
|
||||
* über den Namen gearbeitet wird — Firmennamen kennt kaum jemand
|
||||
* (Stakeholder-Entscheid 2026-08-26). Reine Firmenkunden ohne
|
||||
* Personennamen erscheinen unter der Firma.
|
||||
*/
|
||||
public static function customerNameFor(ShoppingUser $shoppingUser): ?string
|
||||
{
|
||||
$company = trim((string) $shoppingUser->billing_company);
|
||||
$person = trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? ''));
|
||||
|
||||
if ($person === '') {
|
||||
return $company !== '' ? $company : null;
|
||||
}
|
||||
|
||||
return $company !== '' ? $person.' ('.$company.')' : $person;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnungsanschrift im Stripe-Format; null, wenn keine Straße hinterlegt ist.
|
||||
*
|
||||
* @return array<string, string|null>|null
|
||||
*/
|
||||
private static function billingAddressFor(ShoppingUser $shoppingUser): ?array
|
||||
{
|
||||
if (empty($shoppingUser->billing_address)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'line1' => $shoppingUser->billing_address,
|
||||
'line2' => $shoppingUser->billing_address_2 ?: null,
|
||||
'postal_code' => $shoppingUser->billing_zipcode ?: null,
|
||||
'city' => $shoppingUser->billing_city ?: null,
|
||||
'country' => $shoppingUser->billing_country->code ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
app/Services/Payment/Stripe/StripeInvoiceReferenceService.php
Normal file
114
app/Services/Payment/Stripe/StripeInvoiceReferenceService.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
179
app/Services/Payment/Stripe/StripeMetadataBuilder.php
Normal file
179
app/Services/Payment/Stripe/StripeMetadataBuilder.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ 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;
|
||||
|
||||
|
|
@ -27,7 +28,10 @@ use Stripe\Exception\ApiErrorException;
|
|||
*/
|
||||
class StripePaymentService
|
||||
{
|
||||
public function __construct(private readonly StripeClientWrapper $stripe) {}
|
||||
public function __construct(
|
||||
private readonly StripeClientWrapper $stripe,
|
||||
private readonly StripeCustomerService $customerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
|
||||
|
|
@ -70,17 +74,12 @@ class StripePaymentService
|
|||
amountCents: $amountCents,
|
||||
);
|
||||
|
||||
$metadata = [
|
||||
'shopping_order_id' => (string) $shoppingOrder->id,
|
||||
'shopping_payment_id' => (string) $shoppingPayment->id,
|
||||
'reference' => $reference,
|
||||
'mode' => (string) $shoppingOrder->mode,
|
||||
];
|
||||
$metadata = StripeMetadataBuilder::forCheckout($shoppingOrder, $shoppingPayment, $shoppingUser, $reference);
|
||||
$description = StripeMetadataBuilder::description($shoppingOrder, $reference, $shoppingUser);
|
||||
|
||||
$sessionParams = [
|
||||
'mode' => 'payment',
|
||||
'client_reference_id' => $reference,
|
||||
'customer_email' => $shoppingUser->billing_email ?: null,
|
||||
'line_items' => [[
|
||||
'quantity' => 1,
|
||||
'price_data' => [
|
||||
|
|
@ -92,13 +91,28 @@ class StripePaymentService
|
|||
],
|
||||
]],
|
||||
'metadata' => $metadata,
|
||||
'payment_intent_data' => ['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['customer_creation'] = 'always';
|
||||
$sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session';
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +135,28 @@ class StripePaymentService
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -170,13 +206,7 @@ class StripePaymentService
|
|||
'stripe_payment_method_id' => $userAbo->stripe_payment_method_id,
|
||||
]);
|
||||
|
||||
$metadata = [
|
||||
'shopping_order_id' => (string) $shoppingOrder->id,
|
||||
'shopping_payment_id' => (string) $shoppingPayment->id,
|
||||
'user_abo_id' => (string) $userAbo->id,
|
||||
'reference' => (string) $shoppingPayment->reference,
|
||||
'mode' => (string) $shoppingOrder->mode,
|
||||
];
|
||||
$metadata = StripeMetadataBuilder::forAboCharge($userAbo, $shoppingOrder, $shoppingPayment, $shoppingUser);
|
||||
|
||||
try {
|
||||
$paymentIntent = $this->stripe->client()->paymentIntents->create([
|
||||
|
|
@ -187,6 +217,7 @@ class StripePaymentService
|
|||
'off_session' => true,
|
||||
'confirm' => true,
|
||||
'metadata' => $metadata,
|
||||
'description' => StripeMetadataBuilder::description($shoppingOrder, (string) $shoppingPayment->reference, $shoppingUser),
|
||||
], [
|
||||
'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id,
|
||||
]);
|
||||
|
|
@ -243,10 +274,7 @@ class StripePaymentService
|
|||
): Session {
|
||||
$customerId = $this->ensureAboStripeCustomer($userAbo);
|
||||
|
||||
$metadata = [
|
||||
'user_abo_id' => (string) $userAbo->id,
|
||||
'source' => (string) ($source ?? ''),
|
||||
];
|
||||
$metadata = StripeMetadataBuilder::forSetupSession($userAbo, $source);
|
||||
|
||||
return $this->stripe->client()->checkout->sessions->create([
|
||||
'mode' => 'setup',
|
||||
|
|
@ -274,13 +302,15 @@ class StripePaymentService
|
|||
}
|
||||
|
||||
$shoppingUser = $userAbo->shopping_user;
|
||||
$customer = $this->stripe->client()->customers->create([
|
||||
'email' => $userAbo->email ?: null,
|
||||
'name' => $shoppingUser
|
||||
? (trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? '')) ?: null)
|
||||
: null,
|
||||
'metadata' => ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id],
|
||||
]);
|
||||
|
||||
$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();
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,17 @@ class StripeWebhookHandler
|
|||
'setup_intent.succeeded',
|
||||
];
|
||||
|
||||
/**
|
||||
* Eventtypen, zu denen es regulaer keine Zahlung bei uns geben muss.
|
||||
* Ein abgebrochener Checkout laeuft bei Stripe ab, ohne dass jemals eine
|
||||
* Zahlung entstanden ist - das ist kein Fehlerfall.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const EVENT_TYPES_WITHOUT_PAYMENT = [
|
||||
'checkout.session.expired',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
|
||||
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
|
||||
|
|
@ -137,6 +148,27 @@ class StripeWebhookHandler
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet ein aktiv von der Stripe-API geholtes Objekt so, als wäre es
|
||||
* per Webhook gekommen (Plan S6.4, `stripe:sync-payment-status`).
|
||||
*
|
||||
* Nötig für Events, die NIE zugestellt wurden — dafür greift weder der
|
||||
* Stripe-Retry noch `stripe:process-pending-events`, das nur auf
|
||||
* gespeicherten Events arbeitet. Die Seiteneffekte sind dieselben und
|
||||
* bleiben idempotent: der Paid-Pfad prüft `paid` unter `lockForUpdate()`.
|
||||
*
|
||||
* @param array<string, mixed> $object Stripe-Objekt als Array
|
||||
* @return bool false = keiner Zahlung zuordenbar
|
||||
*/
|
||||
public function syncFromApiObject(string $eventType, array $object, string $source = 'api-sync'): bool
|
||||
{
|
||||
if (! in_array($eventType, self::HANDLED_TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->dispatchEvent($source, $eventType, $object);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $object
|
||||
* @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern
|
||||
|
|
@ -178,7 +210,14 @@ class StripeWebhookHandler
|
|||
return false;
|
||||
}
|
||||
|
||||
Log::channel('stripe')->warning(sprintf(
|
||||
/*
|
||||
* Abgebrochene Checkouts laufen bei Stripe irgendwann ab. Zu so einer
|
||||
* Session hat es nie eine Zahlung gegeben — das ist der Normalfall und
|
||||
* keine Warnung wert. Alle anderen Eventtypen setzen eine Zahlung voraus.
|
||||
*/
|
||||
$isExpectedWithoutPayment = in_array($eventType, self::EVENT_TYPES_WITHOUT_PAYMENT, true);
|
||||
|
||||
Log::channel('stripe')->{$isExpectedWithoutPayment ? 'info' : 'warning'}(sprintf(
|
||||
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
|
||||
$eventId,
|
||||
$eventType,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue