121 lines
4 KiB
PHP
121 lines
4 KiB
PHP
<?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;
|
|
}
|
|
}
|