2026-04-15 01:22:04 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
2026-05-03 19:52:28 +00:00
|
|
|
use App\Models\Traits\HasUsage;
|
2026-05-14 22:55:28 +00:00
|
|
|
use Carbon\CarbonInterface;
|
2026-04-15 01:22:04 +00:00
|
|
|
use Database\Factories\AccountFactory;
|
|
|
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-04-15 01:22:04 +00:00
|
|
|
use Laravel\Cashier\Billable;
|
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
|
|
|
use Throwable;
|
2026-04-15 01:22:04 +00:00
|
|
|
|
|
|
|
|
class Account extends Model
|
|
|
|
|
{
|
|
|
|
|
/** @use HasFactory<AccountFactory> */
|
2026-05-03 19:52:28 +00:00
|
|
|
use Billable, HasFactory, HasUsage, HasUuids;
|
2026-04-15 01:22:04 +00:00
|
|
|
|
|
|
|
|
public const SUBSCRIPTION_NAME = 'default';
|
|
|
|
|
|
2026-05-19 22:00:57 +00:00
|
|
|
/**
|
|
|
|
|
* Redis/cache key for aggregated post counts across the account's workspaces.
|
|
|
|
|
* Invalidated by the PostHog usage sync job before re-reading aggregates for analytics.
|
|
|
|
|
*/
|
|
|
|
|
public static function postsCountCacheKey(string $accountId): string
|
|
|
|
|
{
|
|
|
|
|
return "account:{$accountId}:posts_count";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:22:04 +00:00
|
|
|
protected $fillable = [
|
|
|
|
|
'owner_id',
|
|
|
|
|
'name',
|
feat: redesign billing, onboarding, sidebar, and settings architecture
- Sidebar reorganized: Workspace group (connections, hashtags, labels,
API keys, settings) and Account group (settings, usage, billing)
- Account group only visible to owner and hidden in self-hosted mode
- Onboarding simplified: role -> account (connect socials) -> completed
-> redirect to /subscribe. Removed Subscription setup step.
- Subscribe page redesigned with 4 plan cards, monthly/yearly toggle,
trial info, and per-plan features list
- Billing page redesigned following Sendkit layout (sections with
sidebar labels)
- Processing page uses usePoll with immediate watch for subscription
activation
- Cancel URL redirects directly to /subscribe
- Account settings page with name and billing_email (syncs with Stripe)
- Usage page with ring meters for all plan limits
- Settings layout tabs only for user pages (profile, password,
notifications). Workspace/API keys/billing are standalone pages.
- GoogleAuthButton extracted as reusable component
- WorkspaceRole TypeScript enum for type-safe role checks in frontend
- Trial period changed to 7 days
- Fixed onboarding loop when user confirms email
- All 1101 tests passing
2026-04-15 03:33:38 +00:00
|
|
|
'billing_email',
|
2026-04-15 01:22:04 +00:00
|
|
|
'plan_id',
|
2026-05-14 22:37:46 +00:00
|
|
|
'trial_ends_at',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
protected $casts = [
|
|
|
|
|
'trial_ends_at' => 'datetime',
|
2026-04-15 01:22:04 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public function owner(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(User::class, 'owner_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function plan(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(Plan::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function users(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(User::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function workspaces(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Workspace::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function invites(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Invite::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hasActiveSubscription(): bool
|
|
|
|
|
{
|
|
|
|
|
if (config('trypost.self_hosted')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
|
|
|
|
}
|
|
|
|
|
|
MCP: workspace settings, viewer read access, and token access (#241)
* Add workspace MCP settings and token access controls.
Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring,
and workspace.token:mcp gating so assistants can connect without pulling
in welcome/onboarding from the parent epic.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Type MCP client config shapes instead of string checks.
Encode http/config-root on each advanced client and tighten primary
client ids so snippet generation does not branch on magic strings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish MCP settings follow-ups from review.
Translate Ukrainian MCP copy, deep-link ChatGPT into connector
creation, drop an unused asset and revoke arg, and assert PATs are
rejected on the MCP endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP connected clients, revoke scope, and OAuth consent.
List recoverable sessions with live refresh tokens, revoke only PATs,
throttle registration alone, and block viewers from authorizing MCP.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify MCP OAuth route throttling to a single middleware group.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow workspace viewers read-only MCP access with web policy writes.
Mirror the web app: MCP connects on view + OAuth mcp:use, write tools
enforce createPost/update/delete/manageAccounts/manageTeam, and demotion
to Viewer keeps grants. Cover role denials, consent, and disconnect.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP tool authz with shared workspace helpers.
Route ApiKey tools through AuthorizesMcpTool, fail closed on null user
or policy argument, and resolve the current workspace before mutating.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant string casts on validated request data.
Enum::from and validated() fields are already strings, so the casts
add noise without changing behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Show only the current user's MCP connections in settings.
Match API keys privacy: list and disconnect your own OAuth clients,
not teammates' across the account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant is_string guard before UpdatePostTool find.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined.
* Refresh MCP connected clients with Inertia usePoll.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bump laravel/mcp to 0.9.1 and add the TryPost server icon.
Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop no-op ReflectionClass import in TryPostServerTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
|
|
|
/**
|
|
|
|
|
* Whether the account may use the app (active subscription, or a generic
|
|
|
|
|
* trial when REQUIRE_CARD_FOR_TRIAL is disabled).
|
|
|
|
|
*/
|
|
|
|
|
public function hasAppAccess(): bool
|
|
|
|
|
{
|
|
|
|
|
if (config('trypost.self_hosted')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
|
|
|
|
|
|
|
|
|
return $this->subscribed(self::SUBSCRIPTION_NAME)
|
|
|
|
|
|| (! $requiresCardForTrial && $this->isOnTrial());
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Align the Stripe subscription quantity with the number of workspaces the
|
|
|
|
|
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
|
|
|
|
|
* or when there is no active subscription (e.g. during onboarding).
|
|
|
|
|
*/
|
|
|
|
|
public function syncWorkspaceQuantity(): void
|
|
|
|
|
{
|
|
|
|
|
if (config('trypost.self_hosted')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
|
|
|
|
|
|
|
|
|
|
if (! $subscription || ! $subscription->active()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$subscription->updateQuantity($this->workspaces()->count());
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
Log::warning('Failed to sync workspace quantity to Stripe', [
|
|
|
|
|
'account_id' => $this->id,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:46:17 +00:00
|
|
|
public function isPastDue(): bool
|
|
|
|
|
{
|
|
|
|
|
if (config('trypost.self_hosted')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->pastDue();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:22:04 +00:00
|
|
|
public function isOnTrial(): bool
|
|
|
|
|
{
|
2026-05-21 13:02:38 +00:00
|
|
|
if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 22:37:46 +00:00
|
|
|
return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial();
|
2026-04-15 01:22:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 22:55:28 +00:00
|
|
|
public function activeTrialEndsAt(): ?CarbonInterface
|
|
|
|
|
{
|
|
|
|
|
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
|
|
|
|
|
|
2026-05-21 12:49:44 +00:00
|
|
|
if (! $subscription?->onTrial()) {
|
2026-05-21 13:02:38 +00:00
|
|
|
if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) {
|
|
|
|
|
return $this->trial_ends_at;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 12:49:44 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $subscription->trial_ends_at;
|
2026-05-14 22:55:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
refactor: redesign billing settings + upgrade dialog with indies aesthetic
- Billing settings page restructured around the design system: dropped
the 280px-label / 1fr-content split for stacked HeadingSmall sections;
current plan rendered as a hero card with display-font name, price,
amber sticker tile, and an inline primary "Change plan" CTA; payment
method consolidated into a single sticker row with a violet card-icon
tile and the manage CTA on the same line; empty payment-method state
handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
rounded-full CTA pill, sticker check icons, "Everything in {plan}"
copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
with rotating amber save-months badge. While a request is in flight
every plan button is disabled and the active one shows IconLoader2
spinner.
- Account model gains `displayablePaymentMethod()` returning the
card array used by the UI. Resolves the customer-level default first,
falls back to the first attached payment method (Stripe Checkout trials
anchor the card to the subscription rather than the customer, so the
customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
in en/pt-BR/es.
2026-05-06 20:00:25 +00:00
|
|
|
* Returns the displayable card for the billing UI. Falls back to the first
|
|
|
|
|
* attached payment method when the customer has no `invoice_settings.default_payment_method`
|
|
|
|
|
* (Stripe Checkout trials anchor the card to the subscription, not the customer).
|
|
|
|
|
*
|
|
|
|
|
* @return array{brand: string, last4: string, exp_month: int, exp_year: int}|null
|
|
|
|
|
*/
|
|
|
|
|
public function displayablePaymentMethod(): ?array
|
|
|
|
|
{
|
|
|
|
|
$paymentMethod = $this->defaultPaymentMethod() ?? $this->paymentMethods()->first();
|
|
|
|
|
$card = $paymentMethod?->card;
|
|
|
|
|
|
|
|
|
|
if (! $card) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'brand' => $card->brand,
|
|
|
|
|
'last4' => $card->last4,
|
|
|
|
|
'exp_month' => $card->exp_month,
|
|
|
|
|
'exp_year' => $card->exp_year,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:22:04 +00:00
|
|
|
public function stripeEmail(): string
|
|
|
|
|
{
|
feat: redesign billing, onboarding, sidebar, and settings architecture
- Sidebar reorganized: Workspace group (connections, hashtags, labels,
API keys, settings) and Account group (settings, usage, billing)
- Account group only visible to owner and hidden in self-hosted mode
- Onboarding simplified: role -> account (connect socials) -> completed
-> redirect to /subscribe. Removed Subscription setup step.
- Subscribe page redesigned with 4 plan cards, monthly/yearly toggle,
trial info, and per-plan features list
- Billing page redesigned following Sendkit layout (sections with
sidebar labels)
- Processing page uses usePoll with immediate watch for subscription
activation
- Cancel URL redirects directly to /subscribe
- Account settings page with name and billing_email (syncs with Stripe)
- Usage page with ring meters for all plan limits
- Settings layout tabs only for user pages (profile, password,
notifications). Workspace/API keys/billing are standalone pages.
- GoogleAuthButton extracted as reusable component
- WorkspaceRole TypeScript enum for type-safe role checks in frontend
- Trial period changed to 7 days
- Fixed onboarding loop when user confirms email
- All 1101 tests passing
2026-04-15 03:33:38 +00:00
|
|
|
return $this->billing_email ?? $this->owner?->email ?? '';
|
2026-04-15 01:22:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function stripeName(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->name;
|
|
|
|
|
}
|
|
|
|
|
}
|