235 lines
6.5 KiB
PHP
235 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Academy;
|
|
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Stripe\Exception\InvalidRequestException;
|
|
use Stripe\StripeClient;
|
|
|
|
final class AcademyBillingPlanService
|
|
{
|
|
public function enabled(): bool
|
|
{
|
|
return (bool) config('academy_billing.enabled', false);
|
|
}
|
|
|
|
public function subscriptionName(): string
|
|
{
|
|
return (string) config('academy_billing.subscription_name', 'academy');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
public function plans(): array
|
|
{
|
|
$plans = config('academy_billing.plans', []);
|
|
|
|
return is_array($plans) ? $plans : [];
|
|
}
|
|
|
|
public function normalizePlanKey(?string $planKey): string
|
|
{
|
|
return Str::of((string) $planKey)
|
|
->trim()
|
|
->lower()
|
|
->replace('-', '_')
|
|
->value();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function plan(?string $planKey): ?array
|
|
{
|
|
$normalized = $this->normalizePlanKey($planKey);
|
|
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
$plan = Arr::get($this->plans(), $normalized);
|
|
|
|
if (! is_array($plan)) {
|
|
return null;
|
|
}
|
|
|
|
$plan['key'] = $normalized;
|
|
$plan['tier'] = $this->normalizeTier((string) ($plan['tier'] ?? 'free'));
|
|
$plan['interval'] = Str::lower(trim((string) ($plan['interval'] ?? 'monthly')));
|
|
$plan['amount'] = trim((string) ($plan['amount'] ?? ''));
|
|
$plan['currency'] = Str::upper(trim((string) ($plan['currency'] ?? config('cashier.currency', 'EUR'))));
|
|
$plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? ''));
|
|
$plan['configured'] = $plan['stripe_price_id'] !== '';
|
|
$plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']);
|
|
$plan['remote_price_exists'] = $this->remotePriceExists($plan['stripe_price_id']);
|
|
$plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null;
|
|
|
|
return $plan;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function planForPriceId(?string $priceId): ?array
|
|
{
|
|
$priceId = trim((string) $priceId);
|
|
|
|
if ($priceId === '') {
|
|
return null;
|
|
}
|
|
|
|
foreach (array_keys($this->plans()) as $planKey) {
|
|
$plan = $this->plan((string) $planKey);
|
|
|
|
if ($plan !== null && ($plan['stripe_price_id'] ?? null) === $priceId) {
|
|
return $plan;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function missingPriceIds(?string $planKey = null): array
|
|
{
|
|
if ($planKey !== null) {
|
|
$plan = $this->plan($planKey);
|
|
|
|
return $plan !== null && ! ($plan['configured'] ?? false)
|
|
? [$this->normalizePlanKey($planKey)]
|
|
: [];
|
|
}
|
|
|
|
return collect(array_keys($this->plans()))
|
|
->filter(fn (string $key): bool => ! ((bool) ($this->plan($key)['configured'] ?? false)))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function assertConfigured(?string $planKey = null): void
|
|
{
|
|
if (app()->environment(['local', 'testing'])) {
|
|
return;
|
|
}
|
|
|
|
$missingPlans = $this->missingPriceIds($planKey);
|
|
|
|
if ($missingPlans === []) {
|
|
return;
|
|
}
|
|
|
|
throw new RuntimeException('Academy billing price IDs are missing for: '.implode(', ', $missingPlans));
|
|
}
|
|
|
|
public function normalizeTier(string $tier): string
|
|
{
|
|
return match (Str::lower(trim($tier))) {
|
|
'admin' => 'admin',
|
|
'pro' => 'pro',
|
|
'creator', 'premium' => 'creator',
|
|
default => 'free',
|
|
};
|
|
}
|
|
|
|
public function isValidPriceId(?string $priceId): bool
|
|
{
|
|
$priceId = trim((string) $priceId);
|
|
|
|
if ($priceId === '') {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1;
|
|
}
|
|
|
|
public function remotePriceExists(?string $priceId): ?bool
|
|
{
|
|
$priceId = trim((string) $priceId);
|
|
|
|
if ($priceId === '') {
|
|
return false;
|
|
}
|
|
|
|
// Avoid calling Stripe in local/testing environments — assume exists there.
|
|
if (app()->environment(['local', 'testing'])) {
|
|
return true;
|
|
}
|
|
|
|
$cacheKey = 'academy.remote_price_exists:'.md5($priceId);
|
|
|
|
return Cache::remember($cacheKey, 300, function () use ($priceId): ?bool {
|
|
try {
|
|
$secret = $this->stripeSecret();
|
|
|
|
if ($secret === null) {
|
|
return null;
|
|
}
|
|
|
|
$client = new StripeClient($secret);
|
|
$price = $client->prices->retrieve($priceId, []);
|
|
|
|
// If Stripe returned an object with an id, it exists. Also ensure product exists where possible.
|
|
if (is_object($price) && ! empty($price->id)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (InvalidRequestException $e) {
|
|
report($e);
|
|
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
// Auth, network, or transient Stripe failures should not make
|
|
// public pricing look fully misconfigured.
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
private function stripeSecret(): ?string
|
|
{
|
|
foreach ([config('cashier.secret'), env('STRIPE_SECRET')] as $candidate) {
|
|
if (! is_string($candidate)) {
|
|
continue;
|
|
}
|
|
|
|
$candidate = trim($candidate);
|
|
|
|
if ($candidate !== '') {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function missingRemotePriceIds(?string $planKey = null): array
|
|
{
|
|
if ($planKey !== null) {
|
|
$plan = $this->plan($planKey);
|
|
|
|
return $plan !== null && $this->remotePriceExists($plan['stripe_price_id'] ?? '') === false
|
|
? [$this->normalizePlanKey($planKey)]
|
|
: [];
|
|
}
|
|
|
|
return collect(array_keys($this->plans()))
|
|
->filter(fn (string $key): bool => $this->remotePriceExists($this->plan($key)['stripe_price_id'] ?? '') === false)
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|