fix: self-host billing bypass, Facebook API metrics migration, X token refresh, and duplicate social accounts

- 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
This commit is contained in:
Paulo Castellano 2026-04-15 09:46:18 -03:00
parent ded1c998ec
commit a8bb44f0e5
20 changed files with 483 additions and 207 deletions

View file

@ -16,6 +16,10 @@ class BillingController extends Controller
{
public function subscribe(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
if ($account && $account->hasActiveSubscription()) {
@ -28,8 +32,12 @@ public function subscribe(Request $request): Response|RedirectResponse
]);
}
public function checkout(Request $request, Plan $plan): SymfonyResponse
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
@ -66,8 +74,12 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
return Inertia::location($checkoutSession->url);
}
public function processing(Request $request): Response
public function processing(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
return Inertia::render('billing/Processing', [
@ -75,8 +87,12 @@ public function processing(Request $request): Response
]);
}
public function index(Request $request): Response
public function index(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
@ -111,6 +127,10 @@ public function index(Request $request): Response
public function swap(Request $request, Plan $plan): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
@ -136,6 +156,10 @@ public function swap(Request $request, Plan $plan): RedirectResponse
public function portal(Request $request): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);

View file

@ -25,6 +25,7 @@ public function edit(Request $request): Response
'name' => $account->name,
'billing_email' => $account->billing_email,
],
'selfHosted' => config('trypost.self_hosted'),
]);
}
@ -32,19 +33,24 @@ public function update(Request $request): RedirectResponse
{
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$isSelfHosted = config('trypost.self_hosted');
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'billing_email' => ['required', 'email', 'max:255'],
'billing_email' => [$isSelfHosted ? 'nullable' : 'required', 'email', 'max:255'],
]);
$account = $request->user()->account;
$account->update([
'name' => data_get($validated, 'name'),
'billing_email' => data_get($validated, 'billing_email'),
]);
$data = ['name' => data_get($validated, 'name')];
if ($account->hasStripeId()) {
if (! $isSelfHosted) {
$data['billing_email'] = data_get($validated, 'billing_email');
}
$account->update($data);
if (! $isSelfHosted && $account->hasStripeId()) {
$account->updateStripeCustomer([
'name' => data_get($validated, 'name'),
'email' => data_get($validated, 'billing_email'),

View file

@ -11,6 +11,7 @@
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Http\Controllers\App\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -19,8 +20,12 @@
class UsageController extends Controller
{
public function index(Request $request): Response
public function index(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$account = $request->user()->account;

View file

@ -89,23 +89,28 @@ public function store(Request $request): View|RedirectResponse
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($data, 'did'),
'username' => data_get($data, 'handle'),
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
'avatar_url' => $avatarPath,
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
'status' => Status::Connected,
'meta' => [
'service' => $service,
'identifier' => $request->identifier,
'password' => encrypt($request->password),
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($data, 'did'),
],
]);
[
'username' => data_get($data, 'handle'),
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
'avatar_url' => $avatarPath,
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'service' => $service,
'identifier' => $request->identifier,
'password' => encrypt($request->password),
],
],
);
return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value);
} catch (\Exception $e) {

View file

@ -95,24 +95,29 @@ public function callback(Request $request): View|RedirectResponse
$page = $pages[0];
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($page, 'id'),
'username' => data_get($page, 'username', null),
'display_name' => data_get($page, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null, // Page tokens don't expire if user token is long-lived
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => data_get($page, 'id'),
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($page, 'id'),
],
]);
[
'username' => data_get($page, 'username', null),
'display_name' => data_get($page, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'page_id' => data_get($page, 'id'),
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
],
],
);
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
}
@ -225,24 +230,29 @@ public function select(Request $request): View
}
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($selectedPage, 'id'),
'username' => data_get($selectedPage, 'username') ?? null,
'display_name' => data_get($selectedPage, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'page_id' => data_get($selectedPage, 'id'),
'user_id' => data_get($oauthData, 'user_id'),
'user_token' => data_get($oauthData, 'user_token'),
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($selectedPage, 'id'),
],
]);
[
'username' => data_get($selectedPage, 'username') ?? null,
'display_name' => data_get($selectedPage, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'page_id' => data_get($selectedPage, 'id'),
'user_id' => data_get($oauthData, 'user_id'),
'user_token' => data_get($oauthData, 'user_token'),
],
],
);
session()->forget(['facebook_oauth', 'social_reconnect_id']);

View file

@ -78,22 +78,27 @@ public function callback(Request $request): View
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$tokenExpiresAt = now()->addSeconds($expiresIn);
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $tokenExpiresAt,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'account_type' => $socialUser->user['account_type'] ?? null,
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
],
]);
[
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $tokenExpiresAt,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'account_type' => $socialUser->user['account_type'] ?? null,
],
],
);
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value);
} catch (\Exception $e) {

View file

@ -218,10 +218,17 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
}
$account = $workspace->socialAccounts()->create(array_merge($accountData, [
'platform' => $this->platform->value,
'status' => Status::Connected,
]));
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($pageData, 'ig_id'),
],
array_merge($accountData, [
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
]),
);
$isOnboarding = session('social_connect_onboarding', false);

View file

@ -72,19 +72,24 @@ public function callback(Request $request): View
$avatarPath = uploadFromUrl($socialUser->getAvatar());
// Create new account
$account = $workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
],
[
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);

View file

@ -199,23 +199,28 @@ public function select(Request $request): View
}
}
// Create new account
$account = $workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $request->organization_id,
'username' => $request->organization_vanity_name,
'display_name' => $request->organization_name,
'avatar_url' => $avatarPath,
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
'status' => Status::Connected,
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
'admin_name' => $pendingData['name'],
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $request->organization_id,
],
]);
[
'username' => $request->organization_vanity_name,
'display_name' => $request->organization_name,
'avatar_url' => $avatarPath,
'access_token' => $pendingData['token'],
'refresh_token' => $pendingData['refresh_token'],
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'organization_id' => $request->organization_id,
'admin_user_id' => $pendingData['user_id'],
'admin_name' => $pendingData['name'],
],
],
);
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);

View file

@ -183,23 +183,28 @@ public function callback(Request $request): View
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($profile, 'id'),
'username' => data_get($profile, 'acct'),
'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'),
'avatar_url' => $avatarPath,
'access_token' => $accessToken,
'refresh_token' => null, // Mastodon tokens don't expire
'token_expires_at' => null,
'status' => Status::Connected,
'meta' => [
'instance' => $instance,
'client_id' => $clientId,
'client_secret' => $clientSecret,
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($profile, 'id'),
],
]);
[
'username' => data_get($profile, 'acct'),
'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'),
'avatar_url' => $avatarPath,
'access_token' => $accessToken,
'refresh_token' => null,
'token_expires_at' => null,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'instance' => $instance,
'client_id' => $clientId,
'client_secret' => $clientSecret,
],
],
);
$this->clearMastodonSession();

View file

@ -159,19 +159,24 @@ protected function handleCallback(
$avatarPath = uploadFromUrl($socialUser->getAvatar());
// Create new account
$workspace->socialAccounts()->create([
'platform' => $platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $platform->value,
'platform_user_id' => $socialUser->getId(),
],
[
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
return $this->popupCallback(true, 'Account connected!', $platform->value);
} catch (\Exception $e) {

View file

@ -136,19 +136,24 @@ public function callback(Request $request): View
$profile = $profileResponse->json();
$avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null));
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($profile, 'id'),
'username' => data_get($profile, 'username'),
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
]);
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($profile, 'id'),
],
[
'username' => data_get($profile, 'username'),
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
session()->forget(['threads_oauth_state', 'social_reconnect_id']);

View file

@ -75,19 +75,24 @@ public function callback(Request $request): View
$username = $socialUser->getNickname();
$avatarPath = uploadFromUrl($socialUser->getAvatar());
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
]);
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
],
[
'username' => $username,
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
session()->forget('social_reconnect_id');

View file

@ -80,23 +80,28 @@ public function callback(Request $request): View|RedirectResponse
$channel = $channels[0];
$avatarPath = uploadFromUrl(data_get($channel, 'thumbnail'));
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($channel, 'id'),
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
'display_name' => data_get($channel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'channel_id' => data_get($channel, 'id'),
'google_user_id' => $socialUser->getId(),
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($channel, 'id'),
],
]);
[
'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'),
'display_name' => data_get($channel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'channel_id' => data_get($channel, 'id'),
'google_user_id' => $socialUser->getId(),
],
],
);
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
}
@ -220,23 +225,28 @@ public function select(Request $request): View
}
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => data_get($selectedChannel, 'id'),
'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'),
'display_name' => data_get($selectedChannel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => data_get($oauthData, 'access_token'),
'refresh_token' => data_get($oauthData, 'refresh_token'),
'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'meta' => [
'channel_id' => data_get($selectedChannel, 'id'),
'google_user_id' => data_get($oauthData, 'user_id'),
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => $this->platform->value,
'platform_user_id' => data_get($selectedChannel, 'id'),
],
]);
[
'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'),
'display_name' => data_get($selectedChannel, 'title'),
'avatar_url' => $avatarPath,
'access_token' => data_get($oauthData, 'access_token'),
'refresh_token' => data_get($oauthData, 'refresh_token'),
'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null,
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'channel_id' => data_get($selectedChannel, 'id'),
'google_user_id' => data_get($oauthData, 'user_id'),
],
],
);
session()->forget(['youtube_oauth', 'social_reconnect_id']);

View file

@ -15,7 +15,7 @@ class FacebookAnalytics
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.facebook.com/v20.0';
private string $baseUrl = 'https://graph.facebook.com/v25.0';
private string $accessToken;
@ -38,7 +38,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$response = $this->getHttpClient()
->get("{$this->baseUrl}/{$account->platform_user_id}/insights", [
'metric' => 'page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views',
'metric' => 'page_total_media_view_unique,post_total_media_view_unique,page_post_engagements,page_daily_follows,page_media_view',
'period' => 'day',
'since' => $since->startOfDay()->unix(),
'until' => $until->endOfDay()->unix(),
@ -67,11 +67,11 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$total = collect($values)->sum('value');
$label = match ($name) {
'page_impressions_unique' => 'Page Impressions',
'page_posts_impressions_unique' => 'Posts Impressions',
'page_total_media_view_unique' => 'Page Reach',
'post_total_media_view_unique' => 'Posts Reach',
'page_post_engagements' => 'Posts Engagement',
'page_daily_follows' => 'Page Followers',
'page_video_views' => 'Video Views',
'page_media_view' => 'Page Views',
default => ucfirst(str_replace('_', ' ', $name)),
};

View file

@ -160,11 +160,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for X account');
}
$response = $this->socialHttp()->asForm()->post('https://api.x.com/2/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.x.client_id'),
]);
$response = $this->socialHttp()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->asForm()
->post('https://api.x.com/2/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]);

View file

@ -18,8 +18,9 @@ interface AccountData {
billing_email: string;
}
defineProps<{
const props = defineProps<{
account: AccountData;
selfHosted: boolean;
}>();
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
@ -54,7 +55,7 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<div v-if="!selfHosted" class="grid gap-2">
<Label for="billing-email">{{ $t('settings.account.billing_email') }}</Label>
<Input
id="billing-email"

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->account = Account::factory()->create();
$this->user = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('account edit shows account settings with billing email when not self hosted', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user)->get(route('app.account.edit'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Account', false)
->where('selfHosted', false)
->has('account.billing_email')
);
});
test('account edit shows self hosted flag when self hosted', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.account.edit'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Account', false)
->where('selfHosted', true)
);
});
test('account update does not require billing email when self hosted', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->put(route('app.account.update'), [
'name' => 'Updated Account',
]);
$response->assertRedirect();
$this->assertDatabaseHas('accounts', [
'id' => $this->account->id,
'name' => 'Updated Account',
]);
});
test('account update requires billing email when not self hosted', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user)->put(route('app.account.update'), [
'name' => 'Updated Account',
]);
$response->assertSessionHasErrors('billing_email');
});

View file

@ -45,6 +45,8 @@
});
test('subscribe redirects to billing index when account has active subscription', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
@ -57,6 +59,14 @@
$response->assertRedirect(route('app.billing.index'));
});
test('subscribe redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertRedirect(route('app.calendar'));
});
// Index tests
test('billing index requires authentication', function () {
$response = $this->get(route('app.billing.index'));
@ -65,6 +75,8 @@
});
test('billing index shows billing dashboard', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
@ -83,6 +95,14 @@
);
});
test('billing index redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.billing.index'));
$response->assertRedirect(route('app.calendar'));
});
// Processing tests
test('billing processing requires authentication', function () {
$response = $this->get(route('app.billing.processing'));
@ -91,6 +111,8 @@
});
test('billing processing shows processing page', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.billing.processing'));
$response->assertOk();
@ -100,6 +122,13 @@
);
});
test('billing processing redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.billing.processing'));
$response->assertRedirect(route('app.calendar'));
});
// Checkout tests
test('checkout requires authentication', function () {
@ -118,6 +147,8 @@
// Authorization tests
test('non-owner admin cannot access billing index', function () {
config(['trypost.self_hosted' => false]);
$admin = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
@ -136,6 +167,8 @@
});
test('member cannot access billing index', function () {
config(['trypost.self_hosted' => false]);
$member = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->account = Account::factory()->create();
$this->user = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('usage index redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.usage.index'));
$response->assertRedirect(route('app.calendar'));
});
test('usage index shows usage page when not self hosted', function () {
config(['trypost.self_hosted' => false]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user)->get(route('app.usage.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Usage', false)
->has('usage')
);
});