Kunden koennen eigene veroeffentlichte PMs kostenpflichtig aus der oeffentlichen Ansicht nehmen: Panel auf der PM-Detailseite (inkl. T1neu-Unternehmer-Checkbox und Doppelausweis netto/brutto), Stripe- Ad-hoc-Checkout wie Credit-Topups, Erfuellung im Webhook — die PM wechselt auf archived + depublished_at, die oeffentliche URL zeigt dauerhaft einen Tombstone (410 Gone, ohne Inhalte, mit ehrlichem Drittarchive-Hinweis). Doppelbuchungs-Bremse pro PM; Status-Log mit Quelle "depublication". Gate: ohne BILLING_DEPUBLICATION_PRICE_CENTS bleibt der Weg deaktiviert und /melden zeigt die neutrale Preisformulierung [EXTERN: Preis festlegen, O6]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
163 lines
5.5 KiB
PHP
163 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\CreditTopup;
|
|
use App\Models\Plan;
|
|
use App\Models\SinglePurchase;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Laravel\Cashier\Checkout;
|
|
|
|
/**
|
|
* 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) {}
|
|
|
|
/**
|
|
* 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->syncTaxIdFromBillingAddress($user);
|
|
|
|
return $user
|
|
->newSubscription('default', $priceId)
|
|
->checkout($this->sessionOptions());
|
|
}
|
|
|
|
/**
|
|
* 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. Fehler
|
|
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
|
|
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
|
|
*/
|
|
private function syncTaxIdFromBillingAddress(User $user): void
|
|
{
|
|
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
|
|
|
|
if ($vatId === '') {
|
|
return;
|
|
}
|
|
|
|
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
|
|
|
|
if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$user->createOrGetStripeCustomer();
|
|
|
|
$alreadySet = collect($user->taxIds())
|
|
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
|
|
|
|
if (! $alreadySet) {
|
|
$user->createTaxId('eu_vat', $vatId);
|
|
}
|
|
} catch (\Throwable $exception) {
|
|
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
|
|
'user_id' => $user->id,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige
|
|
* Kundenadresse — die im Checkout erfasste Rechnungsadresse (und der
|
|
* Name, Pflicht bei USt-ID-Abfrage) wird darum am Stripe-Customer
|
|
* gespeichert (`customer_update: auto`).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sessionOptions(): array
|
|
{
|
|
return [
|
|
'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']),
|
|
'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']),
|
|
'billing_address_collection' => 'required',
|
|
'customer_update' => [
|
|
'address' => 'auto',
|
|
'name' => 'auto',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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->syncTaxIdFromBillingAddress($user);
|
|
|
|
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
|
|
...$this->sessionOptions(),
|
|
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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->syncTaxIdFromBillingAddress($user);
|
|
|
|
return $user->checkoutCharge(
|
|
$purchase->price_cents,
|
|
'Depublikation einer Pressemitteilung',
|
|
1,
|
|
[
|
|
...$this->sessionOptions(),
|
|
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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->syncTaxIdFromBillingAddress($user);
|
|
|
|
return $user->checkoutCharge(
|
|
$topup->price_cents,
|
|
"Credit-Guthaben ({$topup->credits} Credits)",
|
|
1,
|
|
[
|
|
...$this->sessionOptions(),
|
|
'metadata' => ['credit_topup_id' => (string) $topup->id],
|
|
],
|
|
);
|
|
}
|
|
}
|