- config/logging.php: Themen-Channels billing, ki, monitoring, market als daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default auf daily; Channel `testing` für die Suite. - Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und Kursdaten auf Log::channel() umgestellt. - phpunit.xml + TestCase: Suite schreibt ausschließlich nach testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration. - docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz wieder abgelegt, README-Index nachgezogen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
539 lines
20 KiB
PHP
539 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Models\CreditTopup;
|
|
use App\Models\Plan;
|
|
use App\Models\SinglePurchase;
|
|
use App\Models\User;
|
|
use App\Services\ActivityLog;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Laravel\Cashier\Cashier;
|
|
use Laravel\Cashier\Checkout;
|
|
use Laravel\Cashier\Exceptions\IncompletePayment;
|
|
use Laravel\Cashier\Subscription;
|
|
use Stripe\Exception\InvalidRequestException;
|
|
|
|
/**
|
|
* Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E).
|
|
*
|
|
* Hier passiert ausschließlich der Stripe-Aufruf — alle Guards (Tarif
|
|
* synchronisiert, kein Doppel-Abo, Preis konfiguriert) liegen im
|
|
* CheckoutController. So bleibt der Controller ohne Stripe-Anbindung
|
|
* testbar, indem dieser Service im Container gemockt wird.
|
|
*/
|
|
class StripeCheckoutService
|
|
{
|
|
public function __construct(
|
|
private readonly VatResolver $vatResolver,
|
|
private readonly VatIdValidationService $vatIdValidation,
|
|
private readonly ActivityLog $activityLog,
|
|
) {}
|
|
|
|
/**
|
|
* Verlaufs-Eintrag „zur Zahlung gegangen": wird von allen
|
|
* Checkout-Erzeugern aufgerufen, sobald die Stripe-Session steht und
|
|
* der User zu Stripe weitergeleitet wird. Der Abschluss (oder Abbruch)
|
|
* folgt separat über den Webhook („Zahlung"/„Abo").
|
|
*
|
|
* @param array<string, mixed> $meta
|
|
*/
|
|
public function recordCheckoutStarted(User $user, string $what, array $meta = []): void
|
|
{
|
|
$this->activityLog->record(
|
|
ActivityType::CheckoutStarted,
|
|
sprintf('%s (%s) ist zur Zahlung weitergeleitet — %s.', $user->name, $user->email, $what),
|
|
user: $user,
|
|
meta: $meta,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer
|
|
* ergänzt Stripe Tax automatisch (Cashier::calculateTaxes, Netto-Preise).
|
|
*/
|
|
public function forSubscription(User $user, Plan $plan, string $interval): Checkout
|
|
{
|
|
$priceId = $interval === 'yearly'
|
|
? $plan->stripe_price_id_yearly
|
|
: $plan->stripe_price_id_monthly;
|
|
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$checkout = $user
|
|
->newSubscription('default', $priceId)
|
|
->checkout($this->sessionOptions($user));
|
|
|
|
$this->recordCheckoutStarted($user, sprintf(
|
|
'Abo „%s" (%s)',
|
|
$plan->name,
|
|
$interval === 'yearly' ? 'jährlich' : 'monatlich',
|
|
), ['kind' => 'subscription', 'plan_id' => $plan->id, 'interval' => $interval]);
|
|
|
|
return $checkout;
|
|
}
|
|
|
|
/**
|
|
* Downgrade-Erkennung (QA-Befund 07.07.): Es darf nie eine negative
|
|
* Proration-Rechnung entstehen — eine Buchung ist nicht rückabwickelbar.
|
|
* Sofort gewechselt wird nur, wenn der Periodenpreis des Ziel-Tarifs
|
|
* mindestens den des aktuellen erreicht: Die Gutschrift für die
|
|
* Restlaufzeit beträgt höchstens den vollen aktuellen Periodenpreis,
|
|
* die Wechsel-Rechnung ist damit garantiert >= 0. Alles andere
|
|
* (günstigerer Tarif, Jahres- auf Monatsabo, unbekannter Alt-Preis)
|
|
* ist ein Downgrade und wird zum Periodenende vorgemerkt.
|
|
*/
|
|
public function isDowngrade(User $user, Plan $plan, string $interval): bool
|
|
{
|
|
$currentPriceId = $user->subscription()?->stripe_price;
|
|
|
|
if (! $currentPriceId) {
|
|
return true;
|
|
}
|
|
|
|
$currentPlan = Plan::query()
|
|
->where('stripe_price_id_monthly', $currentPriceId)
|
|
->orWhere('stripe_price_id_yearly', $currentPriceId)
|
|
->first();
|
|
|
|
if (! $currentPlan) {
|
|
return true;
|
|
}
|
|
|
|
$currentCents = $currentPlan->stripe_price_id_monthly === $currentPriceId
|
|
? (int) $currentPlan->monthly_price_cents
|
|
: (int) $currentPlan->yearly_price_cents;
|
|
|
|
$targetCents = $interval === 'yearly'
|
|
? (int) $plan->yearly_price_cents
|
|
: (int) $plan->monthly_price_cents;
|
|
|
|
return $targetCents < $currentCents;
|
|
}
|
|
|
|
/**
|
|
* Sofortiger Tarifwechsel nach oben (QA-Befund 07.07.) — ohne neuen
|
|
* Checkout: Stripe stellt das Abo sofort um und stellt die anteilige
|
|
* Differenz umgehend in Rechnung (swapAndInvoice). Cashier hebt dabei
|
|
* eine vorgemerkte Kündigung auf (cancel_at_period_end = false); eine
|
|
* bestehende Downgrade-Vormerkung wird vorher aufgelöst.
|
|
*
|
|
* @throws IncompletePayment wenn die Zahlung
|
|
* eine Bestätigung braucht (SCA)
|
|
*/
|
|
public function swapSubscription(User $user, Plan $plan, string $interval): void
|
|
{
|
|
$priceId = $interval === 'yearly'
|
|
? $plan->stripe_price_id_yearly
|
|
: $plan->stripe_price_id_monthly;
|
|
|
|
$subscription = $user->subscription('default');
|
|
|
|
$this->releaseScheduledChange($subscription);
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$subscription->swapAndInvoice($priceId);
|
|
}
|
|
|
|
/**
|
|
* Tarifwechsel nach unten: keine sofortige Umstellung, keine Gutschrift.
|
|
* Ein Stripe Subscription Schedule stellt das Abo zum Ende der bereits
|
|
* bezahlten Periode auf den neuen Preis um (proration_behavior none) —
|
|
* es entsteht nie ein negativer Rechnungsbetrag. Die Vormerkung wird
|
|
* lokal gespiegelt; der `customer.subscription.updated`-Webhook räumt
|
|
* sie nach Vollzug wieder ab (ProcessStripeWebhook).
|
|
*/
|
|
public function scheduleDowngrade(User $user, Plan $plan, string $interval): CarbonInterface
|
|
{
|
|
$priceId = $interval === 'yearly'
|
|
? $plan->stripe_price_id_yearly
|
|
: $plan->stripe_price_id_monthly;
|
|
|
|
$subscription = $user->subscription('default');
|
|
|
|
// Wer wechselt, bleibt Kunde: eine vorgemerkte Kündigung wird
|
|
// aufgehoben (das Bestätigungs-Modal kündigt das an).
|
|
if ($subscription->onGracePeriod()) {
|
|
$subscription->resume();
|
|
}
|
|
|
|
$stripe = Cashier::stripe();
|
|
|
|
$schedule = $subscription->stripe_schedule_id
|
|
? $stripe->subscriptionSchedules->retrieve($subscription->stripe_schedule_id)
|
|
: $stripe->subscriptionSchedules->create(['from_subscription' => $subscription->stripe_id]);
|
|
|
|
// Phase 1 = laufende Periode unverändert (Stripe verlangt sie beim
|
|
// Update identisch), Phase 2 = neuer Preis ab Periodenende.
|
|
$currentPhaseStart = (int) $schedule->phases[0]->start_date;
|
|
$currentPhaseEnd = (int) ($schedule->phases[0]->end_date
|
|
?? $subscription->currentPeriodEnd()?->getTimestamp());
|
|
|
|
$stripe->subscriptionSchedules->update($schedule->id, [
|
|
'end_behavior' => 'release',
|
|
'phases' => [
|
|
[
|
|
'items' => [['price' => $subscription->stripe_price, 'quantity' => $subscription->quantity ?? 1]],
|
|
'start_date' => $currentPhaseStart,
|
|
'end_date' => $currentPhaseEnd,
|
|
],
|
|
[
|
|
'items' => [['price' => $priceId, 'quantity' => $subscription->quantity ?? 1]],
|
|
'proration_behavior' => 'none',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$effectiveAt = Carbon::createFromTimestamp($currentPhaseEnd)->timezone(config('app.timezone'));
|
|
|
|
$subscription->forceFill([
|
|
'scheduled_stripe_price' => $priceId,
|
|
'scheduled_change_at' => $effectiveAt,
|
|
'stripe_schedule_id' => $schedule->id,
|
|
])->save();
|
|
|
|
return $effectiveAt;
|
|
}
|
|
|
|
/**
|
|
* Bestehende Downgrade-Vormerkung auflösen (release lässt das Abo
|
|
* unverändert weiterlaufen) — nötig, bevor Cashier das Abo direkt
|
|
* umstellen darf. Ein bereits aufgelöster Schedule ist kein Fehler.
|
|
*/
|
|
private function releaseScheduledChange(Subscription $subscription): void
|
|
{
|
|
if (! $subscription->stripe_schedule_id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
Cashier::stripe()->subscriptionSchedules->release($subscription->stripe_schedule_id);
|
|
} catch (InvalidRequestException $exception) {
|
|
Log::channel('billing')->warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [
|
|
'subscription_id' => $subscription->id,
|
|
'stripe_schedule_id' => $subscription->stripe_schedule_id,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$subscription->forceFill([
|
|
'scheduled_stripe_price' => null,
|
|
'scheduled_change_at' => null,
|
|
'stripe_schedule_id' => null,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* Stripe-Customer vor jedem Checkout auf den Stand der lokal gepflegten
|
|
* Rechnungsadresse bringen: Name/Firma und Adresse (stripeName/
|
|
* stripeAddress-Hooks am User) plus USt-ID. Ohne diesen Sync stand die
|
|
* Profil-Rechnungsadresse nur bei der Erstanlage am Customer und die im
|
|
* Checkout getippte Adresse landete auf der Rechnung (QA-Befund 07.07.).
|
|
* Fehler blockieren den Checkout nicht — Stripe erfasst die Adresse
|
|
* dann selbst im Checkout.
|
|
*/
|
|
private function syncCustomerFromBillingAddress(User $user): void
|
|
{
|
|
try {
|
|
$user->createOrGetStripeCustomer();
|
|
$user->syncStripeCustomerDetails();
|
|
} catch (\Throwable $exception) {
|
|
Log::channel('billing')->warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [
|
|
'user_id' => $user->id,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$this->syncTaxIdFromBillingAddress($user);
|
|
}
|
|
|
|
/**
|
|
* Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer
|
|
* übergeben (User-Panel-Restarbeiten, 12.06.2026) — Stripe Tax
|
|
* berücksichtigt sie dann ohne erneute Eingabe im Checkout.
|
|
*
|
|
* Vor jeder Buchung wird die ID zusätzlich per eVatR gegengeprüft
|
|
* (Befund 08.07.2026: USt-IDs laufen ab, Kunden bestellen sonst über
|
|
* eine veraltete ID steuerfrei): Eine definitiv abgelehnte ID geht
|
|
* nicht an Stripe, und nicht (mehr) passende eu_vat-Einträge am
|
|
* Customer werden entfernt — Stripe Tax rechnet damit wieder mit
|
|
* Steuer statt Reverse Charge. Stripe-Fehler blockieren den Checkout
|
|
* nicht: Stripe validiert die erfasste ID ohnehin selbst.
|
|
*/
|
|
private function syncTaxIdFromBillingAddress(User $user): void
|
|
{
|
|
// Ohne Profil-Rechnungsadresse erfasst Stripe die USt-ID selbst im
|
|
// Checkout (tax_id_collection) — dort nichts anfassen.
|
|
if (! $user->billingAddress) {
|
|
return;
|
|
}
|
|
|
|
$vatId = $this->verifiedVatIdForCheckout($user);
|
|
|
|
try {
|
|
$user->createOrGetStripeCustomer();
|
|
|
|
$alreadySet = false;
|
|
|
|
foreach ($user->taxIds() as $taxId) {
|
|
if ($taxId->type !== 'eu_vat') {
|
|
continue;
|
|
}
|
|
|
|
if ($vatId !== null && strtoupper((string) $taxId->value) === $vatId) {
|
|
$alreadySet = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Veraltete oder entfernte ID: Ohne diesen Schritt bliebe
|
|
// der alte Eintrag am Customer und die Buchung liefe
|
|
// weiter steuerfrei.
|
|
$user->deleteTaxId($taxId->id);
|
|
}
|
|
|
|
if ($vatId !== null && ! $alreadySet) {
|
|
$user->createTaxId('eu_vat', $vatId);
|
|
}
|
|
} catch (\Throwable $exception) {
|
|
Log::channel('billing')->warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [
|
|
'user_id' => $user->id,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die USt-ID, die Stripe für diese Buchung erhalten soll — normalisiert
|
|
* und per eVatR gegengeprüft (definitive Ergebnisse sind 6 h gecacht,
|
|
* siehe VatIdValidationService). Fail-open: Nur eine DEFINITIV
|
|
* abgelehnte ID wird zurückgehalten; ist die BZSt-API nicht erreichbar
|
|
* oder die ID deutsch (eVatR prüft nur ausländische EU-IDs), bleibt
|
|
* sie nutzbar — ein API-Ausfall darf Bestandskunden nicht plötzlich
|
|
* Steuer berechnen.
|
|
*/
|
|
private function verifiedVatIdForCheckout(User $user): ?string
|
|
{
|
|
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
|
|
|
|
if ($vatId === '') {
|
|
return null;
|
|
}
|
|
|
|
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
|
|
|
|
if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) {
|
|
return null;
|
|
}
|
|
|
|
$check = $this->vatIdValidation->check($vatId, (string) $user->billingAddress?->country_code);
|
|
|
|
if (! $check['status']->isUsable()) {
|
|
Log::channel('billing')->warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [
|
|
'user_id' => $user->id,
|
|
'vat_id' => $vatId,
|
|
'status' => $check['status']->value,
|
|
'message' => $check['message'],
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $vatId;
|
|
}
|
|
|
|
/**
|
|
* Gemeinsame Session-Optionen.
|
|
*
|
|
* `automatic_tax` muss hier explizit gesetzt werden:
|
|
* Cashier::calculateTaxes() greift nur bei direkter Abo-Erstellung,
|
|
* NICHT bei Checkout-Sessions — ohne das Flag zeigt Stripe nur den
|
|
* Nettopreis an (QA-Befund 07.07.). Voraussetzung im Stripe-Account:
|
|
* Stripe Tax mit Origin-Adresse und DE-Registrierung (WS-7).
|
|
*
|
|
* Liegt eine Profil-Rechnungsadresse vor (Normalfall, das Buchungs-Gate
|
|
* verlangt sie), darf der Checkout den Stripe-Customer NICHT verändern:
|
|
* Mit `customer_update: auto` schrieb Stripe die Wallet-Daten (Apple
|
|
* Pay: Kartenname + oft nur das Land) über die zuvor synchronisierte
|
|
* Profil-Adresse und die Rechnung trug den falschen Empfänger
|
|
* (QA-Befund 07.07.). Ohne `customer_update` bleibt der Customer
|
|
* unangetastet; Stripe Tax rechnet mit dessen (synchronisierter)
|
|
* Adresse, die USt-ID ist bereits per Sync hinterlegt.
|
|
*
|
|
* Nur ohne Profil-Adresse erfasst Stripe Adresse und USt-ID selbst im
|
|
* Checkout und speichert sie am Customer — Stripe Tax braucht zwingend
|
|
* eine Kundenadresse (`tax_id_collection` erzwingt dabei
|
|
* `customer_update.name = auto`).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sessionOptions(User $user): array
|
|
{
|
|
$options = [
|
|
'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']),
|
|
'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']),
|
|
'billing_address_collection' => 'auto',
|
|
'automatic_tax' => ['enabled' => true],
|
|
// Checkout-Seite in der Kontosprache statt Browser-Erkennung.
|
|
'locale' => $user->language === 'en' ? 'en' : 'de',
|
|
];
|
|
|
|
if ($user->stripeAddress() === null) {
|
|
$options['billing_address_collection'] = 'required';
|
|
$options['tax_id_collection'] = ['enabled' => true];
|
|
$options['customer_update'] = [
|
|
'address' => 'auto',
|
|
'name' => 'auto',
|
|
];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Line-Item für Ad-hoc-Beträge (ohne Stripe-Price). `tax_behavior`
|
|
* ist Pflicht, sobald `automatic_tax` aktiv ist — Cashiers
|
|
* checkoutCharge() setzt es nicht und ist darum hier ersetzt.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function adHocLineItem(int $priceCents, string $name): array
|
|
{
|
|
return [
|
|
'price_data' => [
|
|
'currency' => config('cashier.currency', 'eur'),
|
|
'product_data' => ['name' => $name],
|
|
'unit_amount' => $priceCents,
|
|
'tax_behavior' => 'exclusive',
|
|
],
|
|
'quantity' => 1,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung).
|
|
* Rücksprung auf die Buchungs-Seite.
|
|
*/
|
|
public function billingPortalUrl(User $user): string
|
|
{
|
|
return $user->billingPortalUrl(route('me.bookings.index'));
|
|
}
|
|
|
|
/**
|
|
* Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den
|
|
* Session-Metadaten schließt den Kreis: `checkout.session.completed`
|
|
* markiert den Kauf über ProcessStripeWebhook als bezahlt.
|
|
*/
|
|
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
|
|
{
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$checkout = $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
|
|
...$this->oneTimeSessionOptions($user),
|
|
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
|
]);
|
|
|
|
$this->recordCheckoutStarted($user, 'Einzel-Pressemitteilung', [
|
|
'kind' => 'single_pm',
|
|
'single_purchase_id' => $purchase->id,
|
|
]);
|
|
|
|
return $checkout;
|
|
}
|
|
|
|
/**
|
|
* Stripe-Checkout für eine Depublikation (T12). Ad-hoc-Charge über den
|
|
* konfigurierten Netto-Preis (wie Credit-Topups, keine Stripe-Price-ID);
|
|
* der Webhook depubliziert die PM nach Zahlungseingang.
|
|
*/
|
|
public function forDepublication(User $user, SinglePurchase $purchase): Checkout
|
|
{
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$checkout = $user->checkout(
|
|
[$this->adHocLineItem($purchase->price_cents, 'Depublikation einer Pressemitteilung')],
|
|
[
|
|
...$this->oneTimeSessionOptions($user),
|
|
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
|
],
|
|
);
|
|
|
|
$this->recordCheckoutStarted($user, 'Depublikation einer Pressemitteilung', [
|
|
'kind' => 'depublication',
|
|
'single_purchase_id' => $purchase->id,
|
|
]);
|
|
|
|
return $checkout;
|
|
}
|
|
|
|
/**
|
|
* Stripe-Checkout für eine kostenpflichtige Änderung (Meldeverfahren v2,
|
|
* Spur D). Ad-hoc-Charge wie die Depublikation; der Webhook übernimmt
|
|
* die Änderung nach Zahlungseingang (CorrectionService::applyPaid).
|
|
*/
|
|
public function forChange(User $user, SinglePurchase $purchase): Checkout
|
|
{
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$checkout = $user->checkout(
|
|
[$this->adHocLineItem($purchase->price_cents, 'Änderung einer veröffentlichten Pressemitteilung')],
|
|
[
|
|
...$this->oneTimeSessionOptions($user),
|
|
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
|
],
|
|
);
|
|
|
|
$this->recordCheckoutStarted($user, 'Änderung einer veröffentlichten Pressemitteilung', [
|
|
'kind' => 'press_release_change',
|
|
'single_purchase_id' => $purchase->id,
|
|
]);
|
|
|
|
return $checkout;
|
|
}
|
|
|
|
/**
|
|
* Stripe-Checkout für eine Wallet-Aufladung. Ad-hoc-Charge über den
|
|
* Netto-Paketpreis (Stripe Tax ergänzt die USt.); die `credit_topup_id`
|
|
* in den Metadaten schließt den Kreis im Webhook (Wallet-Gutschrift).
|
|
*/
|
|
public function forCreditTopup(User $user, CreditTopup $topup): Checkout
|
|
{
|
|
$this->syncCustomerFromBillingAddress($user);
|
|
|
|
$checkout = $user->checkout(
|
|
[$this->adHocLineItem($topup->price_cents, "Credit-Guthaben ({$topup->credits} Credits)")],
|
|
[
|
|
...$this->oneTimeSessionOptions($user),
|
|
'metadata' => ['credit_topup_id' => (string) $topup->id],
|
|
],
|
|
);
|
|
|
|
$this->recordCheckoutStarted($user, "Credit-Paket ({$topup->credits} Credits)", [
|
|
'kind' => 'credit_topup',
|
|
'credit_topup_id' => $topup->id,
|
|
]);
|
|
|
|
return $checkout;
|
|
}
|
|
|
|
/**
|
|
* Session-Optionen für Einmalkäufe (Payment-Mode: Einzel-PM, Depublikation,
|
|
* Credit-Topup). Anders als im Subscription-Mode erzeugt Stripe hier ohne
|
|
* `invoice_creation` keine Rechnung — und damit auch kein
|
|
* `invoice.payment_succeeded`, das die STR-Spiegelung anstoßen könnte.
|
|
* Im Subscription-Mode ist der Parameter unzulässig, daher nicht in
|
|
* sessionOptions() selbst.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function oneTimeSessionOptions(User $user): array
|
|
{
|
|
return [
|
|
...$this->sessionOptions($user),
|
|
'invoice_creation' => ['enabled' => true],
|
|
];
|
|
}
|
|
}
|