- BillingController::index reads onTrial from Account::isOnTrial() (covers
generic trial without a Stripe subscription) and falls back to
account.trial_ends_at when no subscription exists. Vue page already had
the badge + 'Trial ends' UI wired — just needed the right props.
- Drop default trial_days from 8 to 7 for consistency with messaging.
`$account->stripe()` can throw `\Stripe\Exception\InvalidArgumentException`
(descends from PHP's `InvalidArgumentException`, not `ApiErrorException`)
when the Stripe key is missing/malformed. Since the conversion data is
purely for tracking, any failure must degrade silently — not break the
post-payment success page.
Wire `value`, `currency`, and `transaction_id` from Stripe Checkout Session
into the `purchase` dataLayer event so GTM can fire Google Ads Conversion
Tracking with accurate per-plan revenue and deduped transaction IDs.
- `BillingController::checkout` adds `{CHECKOUT_SESSION_ID}` to success_url
- `BillingController::processing` retrieves session lazily (closure prop,
so polling partial reloads don't re-hit Stripe) and exposes
`conversion` with `value`/`currency`/`transaction_id`
- `Processing.vue` forwards `conversion` to `trackPurchase`
- `useTracking.trackPurchase` pushes `conversion_value`,
`conversion_currency`, `conversion_transaction_id` to dataLayer +
PostHog
Drops the abort_if guard that blocked switching from a yearly billing
cadence to monthly. The product decision was reversed — users should
be free to move in either direction without going through support.
Removes the corresponding 'swap blocks yearly to monthly downgrade'
test.
Two issues from review:
1. BillingController::checkout was setting plan_id immediately after
creating the Stripe Checkout session, before the user actually paid.
If the user abandoned checkout, the account ended up with a plan it
never paid for. Plan activation is now driven exclusively by the
customer.subscription.created webhook, which fires only after a
successful payment.
2. The 'if (\$account->wasChanged('plan_id')) { ... }' guards around
forgetPlanFeatureCache() were tautological — Eloquent's update()
already short-circuits when nothing changed, and Pennant forget()
is idempotent, so an extra cache clear when the plan didn't move
is harmless. Removing the guards keeps the listener and swap path
readable.
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.
Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.
- 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.
- Block all billing/usage/subscribe routes in self-hosted mode (redirect to calendar)
- Hide billing_email field in account settings when self-hosted
- Migrate deprecated Facebook Page Insights metrics to new Media Views API (v25.0)
- Fix X analytics token refresh to use Basic Auth (matching XPublisher/ConnectionVerifier)
- Prevent duplicate social accounts by using updateOrCreate across all OAuth controllers
- 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
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
Critical:
- Fix EnsureUserSetupIsComplete middleware route name prefixes and
redirect Subscription step to subscribe page (not onboarding)
- Fix MCP session pollution: Auth::setUser() instead of Auth::login()
- Remove dead BillingController::addWorkspace/removeWorkspace methods
- Remove broken Workspace::pendingInvites() method
Security (IDOR):
- MediaController: add workspace ownership verification on all endpoints
- UpdatePostRequest: scope label_ids validation to current workspace
- UpdatePostRequest: scope platform IDs validation to current post
Security (other):
- Fix open redirect in login and registration (validate internal URLs)
- Add validation to API PostController store/update (was $request->all())
- Prevent Owner role assignment via updateRole endpoint
- Fix API post author attribution to use workspace owner
Authorization:
- PostController: use createPost policy instead of view for store/update/destroy
Logic:
- Post Status enum labels now use translation system instead of hardcoded Portuguese
- Workspace deletion cleans up current_workspace_id for all affected members
- StoreWorkspaceInviteRequest: replace Portuguese validation messages with __()
Rename onboarding:
- Step1.vue -> Role.vue, Step2.vue -> Connect.vue
- Controller methods: step1->role, storeStep1->storeRole, step2->connect, storeStep2->storeConnect
All 728 tests passing.
- Extract business logic from controllers into Action classes:
Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)