Bug fix
- Surface the localized "network already connected" message on the
Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
callbacks: catch NetworkAlreadyConnectedException before the generic catch
so the conflict no longer falls through to a generic error + Log::error.
Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
(onboarding starts checkout directly); delete the now-dead DiscordWidget and
useFeatureAccess composable; prune orphaned i18n keys left by removing the
plan picker / upgrade dialog / count limits (billing.subscribe.*,
accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
the onboarding persona payload (labels come from i18n); remove
Persona::options()/label().
Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
141 lines
3.7 KiB
PHP
141 lines
3.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\Account;
|
|
use App\Models\AiUsageLog;
|
|
use Carbon\CarbonImmutable;
|
|
use Laravel\Cashier\Subscription;
|
|
|
|
/**
|
|
* Resolves an account's current AI credit cycle: the allotment it is entitled to
|
|
* and the time window usage is measured against. The window follows the Stripe
|
|
* billing cycle (monthly or yearly), anchored on the subscription date — so an
|
|
* annual subscriber receives twelve months of credits upfront and resets on
|
|
* their renewal date, while a monthly subscriber resets each anniversary day.
|
|
*/
|
|
class BillingCycle
|
|
{
|
|
/** @var array{0: CarbonImmutable, 1: CarbonImmutable}|null */
|
|
private ?array $window = null;
|
|
|
|
private ?Subscription $subscription = null;
|
|
|
|
private bool $subscriptionResolved = false;
|
|
|
|
private function __construct(private readonly Account $account) {}
|
|
|
|
public static function for(Account $account): self
|
|
{
|
|
return new self($account);
|
|
}
|
|
|
|
public function intervalMonths(): int
|
|
{
|
|
$subscription = $this->subscription();
|
|
$plan = $this->account->plan;
|
|
|
|
if ($subscription !== null
|
|
&& $plan?->stripe_yearly_price_id !== null
|
|
&& $subscription->stripe_price === $plan->stripe_yearly_price_id
|
|
) {
|
|
return 12;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
public function creditAllotment(): int
|
|
{
|
|
$base = (int) ($this->account->plan?->monthly_credits_limit ?? 0);
|
|
$months = $this->onTrial() ? 1 : $this->intervalMonths();
|
|
|
|
return $base * $this->account->workspaces()->count() * $months;
|
|
}
|
|
|
|
public function usedCredits(): int
|
|
{
|
|
return AiUsageLog::creditsUsedBetween(
|
|
(string) $this->account->id,
|
|
$this->periodStart(),
|
|
$this->periodEnd(),
|
|
);
|
|
}
|
|
|
|
public function periodStart(): CarbonImmutable
|
|
{
|
|
return $this->window()[0];
|
|
}
|
|
|
|
public function periodEnd(): CarbonImmutable
|
|
{
|
|
return $this->window()[1];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
|
|
*/
|
|
private function window(): array
|
|
{
|
|
return $this->window ??= $this->computeWindow();
|
|
}
|
|
|
|
/**
|
|
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
|
|
*/
|
|
private function computeWindow(): array
|
|
{
|
|
$subscription = $this->subscription();
|
|
|
|
if ($subscription !== null && $subscription->onTrial()) {
|
|
return [
|
|
CarbonImmutable::parse($subscription->created_at),
|
|
CarbonImmutable::parse($subscription->trial_ends_at),
|
|
];
|
|
}
|
|
|
|
$anchor = $this->anchor();
|
|
$now = CarbonImmutable::now();
|
|
$step = $this->intervalMonths();
|
|
|
|
$periods = 0;
|
|
|
|
while ($anchor->addMonthsNoOverflow(($periods + 1) * $step)->lessThanOrEqualTo($now)) {
|
|
$periods++;
|
|
}
|
|
|
|
return [
|
|
$anchor->addMonthsNoOverflow($periods * $step),
|
|
$anchor->addMonthsNoOverflow(($periods + 1) * $step),
|
|
];
|
|
}
|
|
|
|
private function anchor(): CarbonImmutable
|
|
{
|
|
$subscription = $this->subscription();
|
|
|
|
$anchor = $subscription?->trial_ends_at
|
|
?? $subscription?->created_at
|
|
?? $this->account->created_at
|
|
?? CarbonImmutable::now();
|
|
|
|
return CarbonImmutable::parse($anchor);
|
|
}
|
|
|
|
private function onTrial(): bool
|
|
{
|
|
return (bool) $this->subscription()?->onTrial();
|
|
}
|
|
|
|
private function subscription(): ?Subscription
|
|
{
|
|
if (! $this->subscriptionResolved) {
|
|
$this->subscription = $this->account->subscription(Account::SUBSCRIPTION_NAME);
|
|
$this->subscriptionResolved = true;
|
|
}
|
|
|
|
return $this->subscription;
|
|
}
|
|
}
|