docs: add spec for workspace billing, plans and brands migration
This commit is contained in:
parent
450635f53e
commit
603c76fa93
4 changed files with 379 additions and 0 deletions
|
|
@ -0,0 +1,376 @@
|
|||
# Workspace Billing, Plans & Brands
|
||||
|
||||
## Overview
|
||||
|
||||
Move billing from User to Workspace. Each workspace has its own subscription tied to a plan. Introduce a `plans` table with 4 tiers (Starter, Plus, Pro, Max) that enforce limits on social accounts, members, brands, AI generation, and data retention. Introduce `brands` as a way to group social accounts within a workspace.
|
||||
|
||||
## Plans
|
||||
|
||||
| Limit | Starter ($19/$190) | Plus ($29/$290) | Pro ($49/$490) | Max ($99/$990) |
|
||||
|---|---|---|---|---|
|
||||
| Social accounts | 5 | 10 | 30 | 100 |
|
||||
| Members | 1 | 5 | 15 | 20 |
|
||||
| Brands | 0 | 5 | 15 | 50 |
|
||||
| AI Images/month | 50 | 150 | 500 | 2000 |
|
||||
| AI Videos/month | 10 | 30 | 100 | 500 |
|
||||
| Data retention (days) | 30 | 60 | 90 | 730 |
|
||||
| Monthly price | $19 | $29 | $49 | $99 |
|
||||
| Yearly price | $190 | $290 | $490 | $990 |
|
||||
|
||||
Trial period: 8 days (configurable via `CASHIER_TRIAL_DAYS`).
|
||||
|
||||
---
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New table: `plans`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid, PK | |
|
||||
| slug | string, unique | "starter", "plus", "pro", "max" |
|
||||
| name | string | "Starter", "Plus", "Pro", "Max" |
|
||||
| stripe_monthly_price_id | string, nullable | Stripe price ID for monthly billing |
|
||||
| stripe_yearly_price_id | string, nullable | Stripe price ID for yearly billing |
|
||||
| monthly_price | integer | In cents (1900, 2900, 4900, 9900) |
|
||||
| yearly_price | integer | In cents (19000, 29000, 49000, 99000) |
|
||||
| social_account_limit | integer | 5, 10, 30, 100 |
|
||||
| member_limit | integer | 1, 5, 15, 20 |
|
||||
| brand_limit | integer | 0, 5, 15, 50 |
|
||||
| ai_images_limit | integer | 50, 150, 500, 2000 |
|
||||
| ai_videos_limit | integer | 10, 30, 100, 500 |
|
||||
| data_retention_days | integer | 30, 60, 90, 730 |
|
||||
| sort | integer | Display order |
|
||||
| is_archived | boolean, default false | Hide from selection without deleting |
|
||||
| timestamps | | |
|
||||
|
||||
### New table: `brands`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid, PK | |
|
||||
| workspace_id | FK -> workspaces, cascade delete | |
|
||||
| name | string | |
|
||||
| timestamps | | |
|
||||
|
||||
### Modify table: `social_accounts`
|
||||
|
||||
Add `brand_id` (FK -> brands, nullable, set null on delete). Social accounts can optionally belong to a brand for grouping.
|
||||
|
||||
### Modify table: `workspaces`
|
||||
|
||||
Add columns (Cashier Billable fields + plan reference):
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| plan_id | FK -> plans, nullable, constrained | Current plan |
|
||||
| stripe_id | string, nullable, indexed | Stripe customer ID |
|
||||
| pm_type | string, nullable | Payment method type |
|
||||
| pm_last_four | string, nullable | Last 4 digits |
|
||||
| trial_ends_at | timestamp, nullable | |
|
||||
|
||||
### Modify table: `subscriptions`
|
||||
|
||||
Change FK from `user_id` to `workspace_id` (uuid, FK -> workspaces, cascade delete). Drop the old `user_id` column.
|
||||
|
||||
### Modify table: `subscription_items`
|
||||
|
||||
No changes needed (references `subscription_id` which remains the same).
|
||||
|
||||
### Modify table: `users`
|
||||
|
||||
Remove billing columns: `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`.
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
### Plan (new)
|
||||
|
||||
- UUID primary key
|
||||
- Fillable: slug, name, prices, all limits, sort, is_archived
|
||||
- Casts: is_archived (boolean), monthly_price (integer), yearly_price (integer)
|
||||
- Scopes: `active()` (where is_archived = false)
|
||||
- Relationship: `workspaces()` hasMany
|
||||
- Method: `formattedMonthlyPrice()`, `formattedYearlyPrice()`
|
||||
|
||||
### Brand (new)
|
||||
|
||||
- UUID primary key
|
||||
- Fillable: workspace_id, name
|
||||
- Relationship: `workspace()` belongsTo, `socialAccounts()` hasMany
|
||||
|
||||
### Workspace (modified)
|
||||
|
||||
- Add `Billable` trait from Laravel Cashier
|
||||
- Add to fillable: `plan_id`, `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`
|
||||
- New relationships: `plan()` belongsTo, `brands()` hasMany
|
||||
- New constant: `SUBSCRIPTION_NAME = 'default'`
|
||||
- Methods: `hasActiveSubscription()`, `isOnTrial()`, `stripeEmail()` (returns owner's email), `stripeName()` (returns workspace name)
|
||||
|
||||
### User (modified)
|
||||
|
||||
- Remove `Billable` trait
|
||||
- Remove `SUBSCRIPTION_NAME` constant
|
||||
- Remove `hasActiveSubscription()`, `hasEverSubscribed()` methods
|
||||
- Remove billing fields from fillable
|
||||
- Simplify `HasWorkspace` trait: remove `canCreateWorkspace()`, `incrementWorkspaceQuantity()`, `decrementWorkspaceQuantity()`, `syncWorkspaceQuantity()`
|
||||
|
||||
### SocialAccount (modified)
|
||||
|
||||
- Add `brand_id` to fillable
|
||||
- New relationship: `brand()` belongsTo (nullable)
|
||||
|
||||
---
|
||||
|
||||
## Cashier Configuration
|
||||
|
||||
### AppServiceProvider
|
||||
|
||||
```php
|
||||
Cashier::useCustomerModel(Workspace::class);
|
||||
```
|
||||
|
||||
### config/cashier.php
|
||||
|
||||
Remove the `plans` section (plans come from the database now). Keep Stripe keys, webhook, currency, trial_days, invoice settings.
|
||||
|
||||
---
|
||||
|
||||
## Middleware: EnsureSubscribed
|
||||
|
||||
Simplified logic:
|
||||
|
||||
1. If `config('trypost.self_hosted')` is true -> pass through
|
||||
2. Get `$user->currentWorkspace`
|
||||
3. If workspace has active subscription or is on trial -> pass through
|
||||
4. Redirect to `/subscribe`
|
||||
|
||||
No more checking the workspace owner's subscription — the workspace IS the subscriber.
|
||||
|
||||
---
|
||||
|
||||
## Controllers
|
||||
|
||||
### BillingController (modified)
|
||||
|
||||
All billing operations change from `$user` to `$workspace`:
|
||||
|
||||
- `subscribe()` — show plan selection page with all active plans
|
||||
- `checkout(Plan $plan)` — create Stripe Checkout for workspace with selected plan's price ID
|
||||
- `processing()` — check `$workspace->subscribed()` instead of `$user->subscribed()`
|
||||
- `index()` — show workspace subscription, invoices, payment method, current plan details with limits and usage
|
||||
- `portal()` — `$workspace->redirectToBillingPortal()`
|
||||
- `swap(Plan $plan)` — swap workspace subscription to a different plan
|
||||
|
||||
### OnboardingController (modified)
|
||||
|
||||
`storeConnect()` changes from `$user->newSubscription(...)` to `$workspace->newSubscription(...)`. The plan selection needs to happen during onboarding — default to Starter plan or let user choose.
|
||||
|
||||
### StripeEventListener (modified)
|
||||
|
||||
Change `User::where('stripe_id', ...)` to `Workspace::where('stripe_id', ...)`. Update `handleSubscriptionCreated` to work with workspace context (still updates user setup to Completed).
|
||||
|
||||
### BrandController (new)
|
||||
|
||||
Full CRUD:
|
||||
|
||||
- `index()` — list brands for current workspace with social account counts
|
||||
- `store(StoreBrandRequest)` — create brand (enforce plan limit via policy)
|
||||
- `update(UpdateBrandRequest, Brand)` — rename brand
|
||||
- `destroy(Brand)` — delete brand (social accounts get `brand_id = null`, not deleted)
|
||||
|
||||
### SocialAccountController (modified if exists)
|
||||
|
||||
Add ability to assign/unassign a social account to a brand.
|
||||
|
||||
---
|
||||
|
||||
## Policies
|
||||
|
||||
### BrandPolicy
|
||||
|
||||
- `viewAny(User, Workspace)` — user belongs to workspace
|
||||
- `create(User, Workspace)` — user can manage accounts AND `$workspace->brands()->count() < $workspace->plan->brand_limit`
|
||||
- `update(User, Workspace, Brand)` — user can manage accounts AND brand belongs to workspace
|
||||
- `delete(User, Workspace, Brand)` — same as update
|
||||
|
||||
### WorkspacePolicy (modified)
|
||||
|
||||
- `manageBilling(User, Workspace)` — only owner (unchanged)
|
||||
- `inviteMember(User, Workspace)` — enforce member limit: `$workspace->members()->count() < $workspace->plan->member_limit`
|
||||
|
||||
### SocialAccountPolicy (new or modified)
|
||||
|
||||
- Enforce social account limit on connect: `$workspace->socialAccounts()->count() < $workspace->plan->social_account_limit`
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
### Billing routes (modified)
|
||||
|
||||
```
|
||||
GET /subscribe BillingController@subscribe
|
||||
POST /billing/checkout/{plan} BillingController@checkout
|
||||
GET /billing/processing BillingController@processing
|
||||
GET /settings/billing BillingController@index
|
||||
GET /settings/billing/portal BillingController@portal
|
||||
POST /settings/billing/swap/{plan} BillingController@swap
|
||||
```
|
||||
|
||||
### Brand routes (new)
|
||||
|
||||
```
|
||||
GET /brands BrandController@index
|
||||
POST /brands BrandController@store
|
||||
PUT /brands/{brand} BrandController@update
|
||||
DELETE /brands/{brand} BrandController@destroy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend (Vue)
|
||||
|
||||
### Subscribe page (modified)
|
||||
|
||||
Show plan selection cards (4 plans) with monthly/yearly toggle instead of a single checkout button. Each card shows limits and price. User selects plan -> goes to Stripe Checkout.
|
||||
|
||||
### Billing settings page (modified)
|
||||
|
||||
Show current plan name, limits with usage bars (e.g., "3/5 social accounts"), subscription status, invoices, payment method. Add "Change Plan" button that shows plan comparison.
|
||||
|
||||
### Brands pages (new)
|
||||
|
||||
- **Brands list** — cards/list showing brands with social account count per brand. Create button (disabled if at limit with tooltip explaining).
|
||||
- **Brand create/edit** — simple form with name field.
|
||||
- **Social account assignment** — in the accounts page, ability to assign accounts to brands (dropdown or drag).
|
||||
|
||||
### Sidebar (modified)
|
||||
|
||||
Add "Brands" link in the sidebar navigation (only visible if plan allows brands, i.e., brand_limit > 0).
|
||||
|
||||
---
|
||||
|
||||
## Seeder: PlanSeeder
|
||||
|
||||
Creates the 4 plans:
|
||||
|
||||
```php
|
||||
[
|
||||
[
|
||||
'slug' => 'starter',
|
||||
'name' => 'Starter',
|
||||
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
|
||||
'monthly_price' => 1900,
|
||||
'yearly_price' => 19000,
|
||||
'social_account_limit' => 5,
|
||||
'member_limit' => 1,
|
||||
'brand_limit' => 0,
|
||||
'ai_images_limit' => 50,
|
||||
'ai_videos_limit' => 10,
|
||||
'data_retention_days' => 30,
|
||||
'sort' => 1,
|
||||
],
|
||||
[
|
||||
'slug' => 'plus',
|
||||
'name' => 'Plus',
|
||||
'monthly_price' => 2900,
|
||||
'yearly_price' => 29000,
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 5,
|
||||
'brand_limit' => 5,
|
||||
'ai_images_limit' => 150,
|
||||
'ai_videos_limit' => 30,
|
||||
'data_retention_days' => 60,
|
||||
'sort' => 2,
|
||||
],
|
||||
[
|
||||
'slug' => 'pro',
|
||||
'name' => 'Pro',
|
||||
'monthly_price' => 4900,
|
||||
'yearly_price' => 49000,
|
||||
'social_account_limit' => 30,
|
||||
'member_limit' => 15,
|
||||
'brand_limit' => 15,
|
||||
'ai_images_limit' => 500,
|
||||
'ai_videos_limit' => 100,
|
||||
'data_retention_days' => 90,
|
||||
'sort' => 3,
|
||||
],
|
||||
[
|
||||
'slug' => 'max',
|
||||
'name' => 'Max',
|
||||
'monthly_price' => 9900,
|
||||
'yearly_price' => 99000,
|
||||
'social_account_limit' => 100,
|
||||
'member_limit' => 20,
|
||||
'brand_limit' => 50,
|
||||
'ai_images_limit' => 2000,
|
||||
'ai_videos_limit' => 500,
|
||||
'data_retention_days' => 730,
|
||||
'sort' => 4,
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Plan tests
|
||||
- Plan seeder creates 4 plans with correct limits
|
||||
- Plan `active()` scope excludes archived plans
|
||||
- Plan formatted prices return correct values
|
||||
|
||||
### Brand CRUD tests
|
||||
- Create brand (success + limit enforcement)
|
||||
- List brands for workspace
|
||||
- Update brand name
|
||||
- Delete brand (social accounts get null brand_id)
|
||||
- Cannot create brand on Starter plan (limit = 0)
|
||||
- Cannot create brand beyond plan limit
|
||||
- Cannot access brands from another workspace
|
||||
|
||||
### Billing tests
|
||||
- Workspace checkout creates Stripe session
|
||||
- Workspace subscription grants access via middleware
|
||||
- No subscription redirects to /subscribe
|
||||
- Self-hosted mode bypasses subscription check
|
||||
- Plan swap updates workspace plan_id
|
||||
- Invoices returned for workspace
|
||||
- Only owner can manage billing
|
||||
|
||||
### Limit enforcement tests
|
||||
- Cannot connect social account beyond plan limit
|
||||
- Cannot invite member beyond plan limit
|
||||
- Cannot create brand beyond plan limit
|
||||
|
||||
### Migration tests
|
||||
- Workspace has billing columns after migration
|
||||
- User no longer has billing columns
|
||||
- Subscriptions reference workspace_id
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted mode
|
||||
|
||||
All plan limits and subscription checks are bypassed when `config('trypost.self_hosted')` is true. In self-hosted mode:
|
||||
|
||||
- No plans table interaction needed
|
||||
- No subscription checks
|
||||
- Unlimited social accounts, members, brands
|
||||
- Unlimited data retention
|
||||
- Brand feature is still available (just no limit)
|
||||
|
||||
---
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Onboarding flow steps (Role -> Connect -> Payment -> Complete)
|
||||
- Social account OAuth flows
|
||||
- Post creation and publishing
|
||||
- Analytics
|
||||
- Workspace creation flow
|
||||
- User authentication
|
||||
1
lang/php_en.json
Normal file
1
lang/php_en.json
Normal file
File diff suppressed because one or more lines are too long
1
lang/php_es.json
Normal file
1
lang/php_es.json
Normal file
File diff suppressed because one or more lines are too long
1
lang/php_pt-br.json
Normal file
1
lang/php_pt-br.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue