feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
|
|
|
<?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
|
|
|
|
|
{
|
2026-06-22 00:28:47 +00:00
|
|
|
/** @var array{0: CarbonImmutable, 1: CarbonImmutable}|null */
|
|
|
|
|
private ?array $window = null;
|
|
|
|
|
|
|
|
|
|
private ?Subscription $subscription = null;
|
|
|
|
|
|
|
|
|
|
private bool $subscriptionResolved = false;
|
|
|
|
|
|
feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
|
|
|
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
|
2026-06-22 00:28:47 +00:00
|
|
|
{
|
|
|
|
|
return $this->window ??= $this->computeWindow();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
|
|
|
|
|
*/
|
|
|
|
|
private function computeWindow(): array
|
feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
|
|
|
{
|
|
|
|
|
$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
|
|
|
|
|
{
|
2026-06-22 00:28:47 +00:00
|
|
|
if (! $this->subscriptionResolved) {
|
|
|
|
|
$this->subscription = $this->account->subscription(Account::SUBSCRIPTION_NAME);
|
|
|
|
|
$this->subscriptionResolved = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->subscription;
|
feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
|
|
|
}
|
|
|
|
|
}
|