Admin-Zahlungsmodul: Zahlungs-Übersicht + Tarif-Verwaltung mit Stripe-Sync

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 13:54:53 +00:00
parent 8f3261d0b4
commit bda755fcf8
9 changed files with 1109 additions and 23 deletions

View file

@ -0,0 +1,121 @@
<?php
namespace App\Services\Billing;
use App\Models\Plan;
use Laravel\Cashier\Cashier;
use Stripe\StripeClient;
/**
* Spiegelt Tarif-Änderungen aus der Admin-Verwaltung sofort nach Stripe.
*
* Stripe-Preise sind unveränderlich: Eine Preisänderung legt deshalb einen
* neuen Price an (netto, `tax_behavior: exclusive` wie beim Sync-Command),
* deaktiviert den alten für neue Buchungen und schreibt die neue ID in
* `plans` zurück. Bestandsabos behalten bewusst ihren bisherigen Preis
* Stripe rechnet laufende Subscriptions auf dem alten Price-Objekt weiter.
*/
class StripePlanSyncService
{
public function isConfigured(): bool
{
return (bool) config('cashier.secret');
}
/**
* Gleicht einen lokal gespeicherten Tarif mit Stripe ab. `$changes`
* sind die tatsächlich geänderten Attribute (`Model::getChanges()`),
* damit nur die nötigen Stripe-Aufrufe passieren.
*
* @param array<string, mixed> $changes
*/
public function syncAfterUpdate(Plan $plan, array $changes): void
{
if (! $this->isConfigured()) {
return;
}
$stripe = Cashier::stripe();
if (! $plan->stripe_product_id) {
$this->createProductWithPrices($stripe, $plan);
return;
}
if (array_key_exists('name', $changes)) {
$stripe->products->update($plan->stripe_product_id, ['name' => $plan->name]);
}
if (array_key_exists('monthly_price_cents', $changes)) {
$plan->forceFill([
'stripe_price_id_monthly' => $this->rotatePrice(
$stripe,
$plan,
$plan->stripe_price_id_monthly,
$plan->monthly_price_cents,
'month',
),
])->save();
}
if (array_key_exists('yearly_price_cents', $changes)) {
$plan->forceFill([
'stripe_price_id_yearly' => $this->rotatePrice(
$stripe,
$plan,
$plan->stripe_price_id_yearly,
$plan->yearly_price_cents,
'year',
),
])->save();
}
}
/**
* Erstanlage für Tarife ohne Stripe-Verknüpfung gleiche Struktur wie
* `billing:sync-stripe-plans`, nur direkt aus der Admin-Oberfläche.
*/
private function createProductWithPrices(StripeClient $stripe, Plan $plan): void
{
$product = $stripe->products->create([
'name' => $plan->name,
'metadata' => ['plan_slug' => $plan->slug],
]);
$plan->forceFill([
'stripe_product_id' => $product->id,
'stripe_price_id_monthly' => $this->createPrice($stripe, $product->id, $plan, $plan->monthly_price_cents, 'month'),
'stripe_price_id_yearly' => $this->createPrice($stripe, $product->id, $plan, $plan->yearly_price_cents, 'year'),
])->save();
}
/**
* Neuen Preis anlegen und den bisherigen (falls vorhanden) für neue
* Buchungen deaktivieren. Gibt die neue Price-ID zurück.
*/
private function rotatePrice(StripeClient $stripe, Plan $plan, ?string $oldPriceId, int $unitAmount, string $interval): string
{
$newPriceId = $this->createPrice($stripe, (string) $plan->stripe_product_id, $plan, $unitAmount, $interval);
if ($oldPriceId) {
$stripe->prices->update($oldPriceId, ['active' => false]);
}
return $newPriceId;
}
private function createPrice(StripeClient $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([
'product' => $productId,
'currency' => strtolower($plan->currency),
'unit_amount' => $unitAmount,
'tax_behavior' => 'exclusive',
'recurring' => ['interval' => $interval],
'metadata' => ['plan_slug' => $plan->slug],
]);
return $price->id;
}
}