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\Actions\Billing;
|
|
|
|
|
|
|
|
|
|
use App\Models\Account;
|
2026-08-08 00:25:32 +00:00
|
|
|
use App\Support\Billing\ConfigureSubscriptionCheckout;
|
2026-09-01 02:01:37 +00:00
|
|
|
use Illuminate\Support\Str;
|
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 Inertia\Inertia;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
|
|
|
|
|
class StartSubscriptionCheckout
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Create a Stripe Checkout session for the given price and return an Inertia
|
2026-08-08 00:25:32 +00:00
|
|
|
* redirect to it. Quantity tracks the account's workspace count. Trial days,
|
|
|
|
|
* optional first-month coupon, and promotion codes come from cashier /
|
Carry signup attribution into the Stripe subscription (#322)
Every account arrives with the UTM parameters that brought it and the
onboarding answers it gave, but none of that reached Stripe. Revenue
lived in one system and the campaign that produced it in another, so
answering "which campaign is paying for itself" meant joining the two by
hand on email.
The account owner's five UTM parameters, persona, goals and referral
source now ride along as subscription metadata. Cashier's withMetadata()
puts them in subscription_data.metadata during Checkout, so they land on
the Stripe Subscription rather than the session: the session is gone in
24 hours, the subscription carries the attribution for as long as the
customer does, and it shows up on the subscription in the dashboard.
Stripe metadata holds strings, so the two enum-cast columns are unwrapped
to their backing value and goals -- a JSON column, the one list answer --
is joined with commas. Passing either through as-is fails the request.
array_filter drops the keys the user never filled in, since an absent key
reads the same in reporting and Stripe treats null as a delete.
The account may have no owner, so every read is null-safe and an account
without attribution simply sends no metadata.
2026-09-01 01:31:09 +00:00
|
|
|
* trypost billing env config via ConfigureSubscriptionCheckout. The owner's
|
2026-09-01 02:01:37 +00:00
|
|
|
* signup attribution -- UTM parameters and ad click IDs -- and onboarding
|
|
|
|
|
* answers ride along as subscription metadata, flattened to the strings
|
|
|
|
|
* Stripe stores and cut to the 500 characters it allows per value. Stripe
|
|
|
|
|
* rejects a longer value outright rather than truncating it, which would
|
|
|
|
|
* fail the whole checkout: https://docs.stripe.com/api/metadata
|
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
|
|
|
*/
|
|
|
|
|
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
|
|
|
|
|
{
|
|
|
|
|
$account->createOrGetStripeCustomer([
|
|
|
|
|
'email' => $account->stripeEmail(),
|
|
|
|
|
'name' => $account->stripeName(),
|
|
|
|
|
]);
|
|
|
|
|
|
Carry signup attribution into the Stripe subscription (#322)
Every account arrives with the UTM parameters that brought it and the
onboarding answers it gave, but none of that reached Stripe. Revenue
lived in one system and the campaign that produced it in another, so
answering "which campaign is paying for itself" meant joining the two by
hand on email.
The account owner's five UTM parameters, persona, goals and referral
source now ride along as subscription metadata. Cashier's withMetadata()
puts them in subscription_data.metadata during Checkout, so they land on
the Stripe Subscription rather than the session: the session is gone in
24 hours, the subscription carries the attribution for as long as the
customer does, and it shows up on the subscription in the dashboard.
Stripe metadata holds strings, so the two enum-cast columns are unwrapped
to their backing value and goals -- a JSON column, the one list answer --
is joined with commas. Passing either through as-is fails the request.
array_filter drops the keys the user never filled in, since an absent key
reads the same in reporting and Stripe treats null as a delete.
The account may have no owner, so every read is null-safe and an account
without attribution simply sends no metadata.
2026-09-01 01:31:09 +00:00
|
|
|
$owner = $account->owner;
|
|
|
|
|
|
2026-09-01 02:01:37 +00:00
|
|
|
$metadata = array_filter([
|
|
|
|
|
'utm_source' => $owner?->utm_source,
|
|
|
|
|
'utm_medium' => $owner?->utm_medium,
|
|
|
|
|
'utm_campaign' => $owner?->utm_campaign,
|
|
|
|
|
'utm_term' => $owner?->utm_term,
|
|
|
|
|
'utm_content' => $owner?->utm_content,
|
|
|
|
|
'gclid' => $owner?->gclid,
|
|
|
|
|
'fbclid' => $owner?->fbclid,
|
|
|
|
|
'li_fat_id' => $owner?->li_fat_id,
|
|
|
|
|
'ttclid' => $owner?->ttclid,
|
|
|
|
|
'rdt_cid' => $owner?->rdt_cid,
|
|
|
|
|
'epik' => $owner?->epik,
|
|
|
|
|
'persona' => $owner?->persona?->value,
|
|
|
|
|
'goals' => implode(',', $owner?->goals ?? []),
|
|
|
|
|
'referral_source' => $owner?->referral_source?->value,
|
|
|
|
|
]);
|
|
|
|
|
|
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 = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
Carry signup attribution into the Stripe subscription (#322)
Every account arrives with the UTM parameters that brought it and the
onboarding answers it gave, but none of that reached Stripe. Revenue
lived in one system and the campaign that produced it in another, so
answering "which campaign is paying for itself" meant joining the two by
hand on email.
The account owner's five UTM parameters, persona, goals and referral
source now ride along as subscription metadata. Cashier's withMetadata()
puts them in subscription_data.metadata during Checkout, so they land on
the Stripe Subscription rather than the session: the session is gone in
24 hours, the subscription carries the attribution for as long as the
customer does, and it shows up on the subscription in the dashboard.
Stripe metadata holds strings, so the two enum-cast columns are unwrapped
to their backing value and goals -- a JSON column, the one list answer --
is joined with commas. Passing either through as-is fails the request.
array_filter drops the keys the user never filled in, since an absent key
reads the same in reporting and Stripe treats null as a delete.
The account may have no owner, so every read is null-safe and an account
without attribution simply sends no metadata.
2026-09-01 01:31:09 +00:00
|
|
|
->quantity(max(1, $account->workspaces()->count()))
|
2026-09-01 02:01:37 +00:00
|
|
|
->withMetadata(array_map(
|
|
|
|
|
fn (string $value): string => Str::limit($value, 500, ''),
|
|
|
|
|
$metadata,
|
|
|
|
|
));
|
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
|
|
|
|
2026-08-08 00:25:32 +00:00
|
|
|
ConfigureSubscriptionCheckout::apply($subscription, $account);
|
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
|
|
|
|
|
|
|
|
$session = $subscription->checkout([
|
|
|
|
|
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
|
|
|
|
'cancel_url' => $cancelUrl,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return Inertia::location($session->url);
|
|
|
|
|
}
|
|
|
|
|
}
|