feat: transition AI usage from feature-based limits to a centralized monthly credit system with token tracking.

This commit is contained in:
Paulo Castellano 2026-05-03 19:36:26 -03:00
parent 811eb71719
commit afc47e9532
54 changed files with 469 additions and 374 deletions

View file

@ -4,6 +4,7 @@
namespace App\Actions\Ai;
use App\Models\Workspace;
use App\Services\Brand\BrandAnalyzerRunner;
use App\Services\Brand\BrandMetadata;
use App\Services\Brand\HomepageMetaExtractor;
@ -19,7 +20,7 @@ public function __construct(
private readonly LogoColorExtractor $logoColors,
) {}
public function __invoke(string $url): BrandMetadata
public function __invoke(string $url, ?Workspace $workspace = null, ?string $userId = null): BrandMetadata
{
$url = $this->fetcher->normalizeUrl($url);
@ -53,7 +54,7 @@ public function __invoke(string $url): BrandMetadata
return $metadata;
}
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html));
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html), $workspace, $userId);
return $analysis === null
? $metadata

View file

@ -23,10 +23,16 @@ public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}
public function model(): string
{
return config('ai.default_text_model');
}
public function schema(JsonSchema $schema): array
{
return [

View file

@ -103,7 +103,13 @@ public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}
public function model(): string
{
return config('ai.default_text_model');
}
}

View file

@ -65,7 +65,13 @@ public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}
public function model(): string
{
return config('ai.default_text_model');
}
}

View file

@ -49,7 +49,13 @@ public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}
public function model(): string
{
return config('ai.default_text_model');
}
}

View file

@ -46,7 +46,13 @@ public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}
public function model(): string
{
return config('ai.default_text_model');
}
}

View file

@ -1,232 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\Media\Type;
use App\Models\Media;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Debug helper to iterate on the AI image template visuals without going through
* the full wizard + AI flow.
*
* php artisan ai:test-render --template=A --title="Some title" --body="Some body" --keywords="kitchen,team"
* php artisan ai:test-render --post=019deae5-... --template=A
*
* Outputs the storage path so you can open the file directly.
*/
class AiTestRender extends Command
{
protected $signature = 'ai:test-render
{--template=A : Template to render (A or B)}
{--title= : Slide title}
{--body= : Slide body}
{--keywords= : Comma-separated Unsplash keywords}
{--post= : Post UUID to re-render images for (replaces Post.media in place)}
{--workspace= : Workspace UUID (defaults to first)}
{--account= : Social account UUID (defaults to first connected on workspace)}
{--width=1080 : Canvas width in pixels}
{--height=1350 : Canvas height in pixels}';
protected $description = 'Render an AI image template with custom inputs (debug).';
public function handle(TemplateImageGenerator $generator): int
{
$workspace = $this->resolveWorkspace();
if (! $workspace) {
$this->error('No workspace found. Use --workspace=<uuid>.');
return self::FAILURE;
}
$socialAccount = $this->resolveSocialAccount($workspace);
if (! $socialAccount) {
$this->error('No social account on workspace. Use --account=<uuid> or connect one.');
return self::FAILURE;
}
if ($this->option('post')) {
return $this->rerenderPost($generator, $workspace, $socialAccount);
}
return $this->renderSingle($generator, $workspace, $socialAccount);
}
private function renderSingle(TemplateImageGenerator $generator, Workspace $workspace, SocialAccount $socialAccount): int
{
$template = strtoupper((string) $this->option('template'));
$title = (string) ($this->option('title') ?: 'Hello world');
$body = (string) ($this->option('body') ?: 'A short body that gives more context about the slide.');
$keywords = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('keywords'))))) ?: ['business'];
$this->info("Rendering Template {$template}...");
$this->line(" title: {$title}");
$this->line(" body: {$body}");
$this->line(' keywords: '.implode(', ', $keywords));
$path = $generator->render(
template: $template,
workspace: $workspace,
socialAccount: $socialAccount,
title: $title,
body: $body,
imageKeywords: $keywords,
width: (int) $this->option('width'),
height: (int) $this->option('height'),
);
if (! $path) {
$this->error('Render failed. Check Unsplash key + recent logs.');
return self::FAILURE;
}
$this->info('OK');
$this->line('storage path: '.$path);
$this->line('absolute: '.Storage::path($path));
$this->line('public url: '.Storage::url($path));
return self::SUCCESS;
}
private function rerenderPost(TemplateImageGenerator $generator, Workspace $workspace, SocialAccount $socialAccount): int
{
$postId = (string) $this->option('post');
$post = Post::query()->where('id', $postId)->first();
if (! $post) {
$this->error("Post {$postId} not found.");
return self::FAILURE;
}
if ($post->workspace_id !== $workspace->id) {
$this->error("Post {$postId} belongs to a different workspace.");
return self::FAILURE;
}
$template = strtoupper((string) ($this->option('template') ?: 'A'));
$media = $post->media ?? [];
if (empty($media)) {
$this->error('Post has no media items. Use direct mode (--title/--body/--keywords) instead.');
return self::FAILURE;
}
$this->info("Re-rendering {$template} for ".count($media).' slide(s)...');
$rendered = [];
$newMedia = [];
foreach ($media as $i => $item) {
$isClosing = (bool) data_get($item, 'meta.is_closing', false);
$width = (int) $this->option('width');
$height = (int) $this->option('height');
if ($isClosing) {
$this->line(" [{$i}] template=C (closing)");
$path = $generator->renderClosing(
workspace: $workspace,
socialAccount: $socialAccount,
width: $width,
height: $height,
);
} else {
$title = data_get($item, 'meta.slide_title') ?? 'Slide '.($i + 1);
$body = data_get($item, 'meta.slide_body') ?? ($post->content ?? '');
$keywords = data_get($item, 'meta.slide_keywords') ?: ['business'];
// First slide is always Template A; subsequent slides alternate.
$slotTemplate = $i === 0 ? 'A' : ($i % 2 === 0 ? 'A' : 'B');
$this->line(" [{$i}] template={$slotTemplate} title={$title}");
$path = $generator->render(
template: $slotTemplate,
workspace: $workspace,
socialAccount: $socialAccount,
title: $title,
body: $body,
imageKeywords: $keywords,
width: $width,
height: $height,
);
}
if ($path) {
$rendered[] = $path;
$this->line(' → '.$path);
$newMedia[] = $this->replaceMediaItem($workspace, $item, $path);
} else {
$this->warn(' → (failed)');
$newMedia[] = $item;
}
}
$post->media = $newMedia;
$post->save();
$this->info('Done. '.count($rendered).' image(s) replaced in post.');
foreach ($rendered as $p) {
$this->line(' '.Storage::url($p));
}
return self::SUCCESS;
}
/**
* Persist a new Media row pointing at $path and return a media-array shape
* matching the existing $original entry (preserves meta.slide_*).
*
* @param array<string, mixed> $original
* @return array<string, mixed>
*/
private function replaceMediaItem(Workspace $workspace, array $original, string $path): array
{
$media = new Media([
'collection' => 'ai-generated',
'type' => Type::Image,
'path' => $path,
'original_filename' => basename($path),
'mime_type' => 'image/webp',
'size' => Storage::size($path),
'order' => 0,
]);
$media->mediable_type = Workspace::class;
$media->mediable_id = $workspace->id;
$media->save();
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'meta' => data_get($original, 'meta', []),
];
}
private function resolveWorkspace(): ?Workspace
{
if ($id = $this->option('workspace')) {
return Workspace::query()->where('id', $id)->first();
}
return Workspace::query()->first();
}
private function resolveSocialAccount(Workspace $workspace): ?SocialAccount
{
if ($id = $this->option('account')) {
return $workspace->socialAccounts()->where('id', $id)->first();
}
return $workspace->socialAccounts()->first();
}
}

View file

@ -6,6 +6,6 @@
enum UsageType: string
{
case Image = 'image';
case Template = 'template';
case Text = 'text';
}

View file

@ -6,12 +6,12 @@
use App\Models\Account;
class AiImagesLimit
class MonthlyCreditsLimit
{
public string $name = 'ai-images-limit';
public string $name = 'monthly-credits-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->ai_images_limit ?? 50;
return $scope->plan?->monthly_credits_limit ?? 1000;
}
}

View file

@ -15,6 +15,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
@ -27,6 +28,11 @@ public function start(StartPostCreationRequest $request): JsonResponse
$this->authorize('createPost', $workspace);
$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED);
}
$socialAccountId = $request->input('social_account_id');
if ($socialAccountId) {

View file

@ -8,6 +8,7 @@
use App\Jobs\Ai\StreamPostContent;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
@ -21,6 +22,11 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
abort(Response::HTTP_FORBIDDEN);
}
$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED);
}
$generationId = (string) Str::uuid();
StreamPostContent::dispatch(

View file

@ -5,11 +5,11 @@
namespace App\Http\Controllers\App;
use App\Ai\Agents\PostContentReviewer;
use App\Enums\Ai\UsageType;
use App\Http\Requests\App\Ai\ReviewPostContentRequest;
use App\Models\Post;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
class PostAiReviewController extends Controller
@ -22,13 +22,20 @@ public function review(ReviewPostContentRequest $request, Post $post): JsonRespo
abort(Response::HTTP_FORBIDDEN);
}
$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED);
}
$agent = new PostContentReviewer(workspace: $workspace);
$result = $agent->prompt($request->string('content')->toString());
RecordAiUsage::record(
RecordAiUsage::recordText(
workspace: $workspace,
type: UsageType::Text,
promptTokens: $result->usage->promptTokens,
completionTokens: $result->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $request->user()->id,
postId: $post->id,
metadata: ['agent' => 'post_reviewer'],

View file

@ -4,9 +4,8 @@
namespace App\Http\Controllers\App\Settings;
use App\Enums\Ai\UsageType;
use App\Features\AiImagesLimit;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Http\Controllers\App\Controller;
@ -46,9 +45,8 @@ public function index(Request $request): Response|RedirectResponse
'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class),
'memberCount' => $totalMembers,
'memberLimit' => Feature::for($account)->value(MemberLimit::class),
'aiImagesUsed' => AiUsageLog::monthlyCount($account->id, UsageType::Image),
'aiImagesLimit' => Feature::for($account)->value(AiImagesLimit::class),
'aiTextUsed' => AiUsageLog::monthlyCount($account->id, UsageType::Text),
'creditsUsed' => AiUsageLog::monthlyCredits($account->id),
'monthlyCreditsLimit' => Feature::for($account)->value(MonthlyCreditsLimit::class),
],
]);
}

View file

@ -18,6 +18,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@ -85,8 +86,15 @@ public function autofillBrand(Request $request, AutofillBrand $autofill): JsonRe
'url' => ['required', 'string', 'max:255'],
]);
$user = $request->user();
$gate = Gate::inspect('useAi', $user->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], SymfonyResponse::HTTP_PAYMENT_REQUIRED);
}
try {
$metadata = $autofill(data_get($validated, 'url'));
$metadata = $autofill(data_get($validated, 'url'), $user->currentWorkspace, $user->id);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
}

View file

@ -5,7 +5,6 @@
namespace App\Jobs\Ai;
use App\Ai\Agents\PostContentStreamer;
use App\Enums\Ai\UsageType;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Broadcasting\PrivateChannel;
@ -42,12 +41,14 @@ public function handle(): void
$channel = new PrivateChannel("users.{$this->userId}.ai-gen.{$this->generationId}");
try {
$agent->broadcast($this->prompt, $channel, now: true);
$response = $agent->broadcast($this->prompt, $channel, now: true);
RecordAiUsage::record(
RecordAiUsage::recordText(
workspace: $workspace,
type: UsageType::Text,
promptTokens: $response->usage?->promptTokens ?? 0,
completionTokens: $response->usage?->completionTokens ?? 0,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_streamer'],
);

View file

@ -6,7 +6,6 @@
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\UsageType;
use App\Enums\PostPlatform\ContentType;
use App\Events\Ai\PostCreationReady;
use App\Models\SocialAccount;
@ -58,10 +57,12 @@ public function handle(): void
try {
$response = $agent->prompt($this->prompt);
RecordAiUsage::record(
RecordAiUsage::recordText(
workspace: $workspace,
type: UsageType::Text,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_generator', 'format' => $this->format],
);
@ -140,10 +141,12 @@ private function humanize(Workspace $workspace, array $structured, string $forma
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
$humanized = $response->structured ?? [];
RecordAiUsage::record(
RecordAiUsage::recordText(
workspace: $workspace,
type: UsageType::Text,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $this->userId,
metadata: ['agent' => 'post_humanizer', 'format' => $format],
);

View file

@ -4,8 +4,8 @@
namespace App\Models;
use App\Features\AiImagesLimit;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Traits\HasUsage;
@ -40,7 +40,7 @@ protected static function booted(): void
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
AiImagesLimit::class,
MonthlyCreditsLimit::class,
]);
}
});

View file

@ -25,6 +25,11 @@ class AiUsageLog extends Model
'post_id',
'type',
'provider',
'model',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'credits',
'metadata',
];
@ -32,6 +37,10 @@ protected function casts(): array
{
return [
'type' => UsageType::class,
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'credits' => 'integer',
'metadata' => 'array',
];
}
@ -41,11 +50,10 @@ public function account(): BelongsTo
return $this->belongsTo(Account::class);
}
public static function monthlyCount(string $accountId, UsageType $type): int
public static function monthlyCredits(string $accountId): int
{
return static::where('account_id', $accountId)
->where('type', $type)
return (int) static::where('account_id', $accountId)
->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
->count();
->sum('credits');
}
}

View file

@ -25,7 +25,7 @@ class Plan extends Model
'social_account_limit',
'member_limit',
'workspace_limit',
'ai_images_limit',
'monthly_credits_limit',
'sort',
'is_archived',
];
@ -38,7 +38,7 @@ protected function casts(): array
'social_account_limit' => 'integer',
'member_limit' => 'integer',
'workspace_limit' => 'integer',
'ai_images_limit' => 'integer',
'monthly_credits_limit' => 'integer',
'sort' => 'integer',
];
}

View file

@ -4,10 +4,11 @@
namespace App\Models\Traits;
use App\Features\AiImagesLimit;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\AiUsageLog;
use App\Models\Invite;
use Laravel\Pennant\Feature;
@ -21,7 +22,7 @@
trait HasUsage
{
/**
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int}
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, creditsUsed: int}
*/
public function usage(): array
{
@ -35,11 +36,12 @@ public function usage(): array
'pendingInviteCount' => Invite::where('account_id', $this->id)
->whereNull('accepted_at')
->count(),
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
];
}
/**
* @return array{workspaceLimit: int, socialAccountLimit: int, memberLimit: int, aiImagesLimit: int}
* @return array{workspaceLimit: int, socialAccountLimit: int, memberLimit: int, monthlyCreditsLimit: int}
*/
public function featureLimits(): array
{
@ -47,7 +49,7 @@ public function featureLimits(): array
'workspaceLimit' => Feature::for($this)->value(WorkspaceLimit::class),
'socialAccountLimit' => Feature::for($this)->value(SocialAccountLimit::class),
'memberLimit' => Feature::for($this)->value(MemberLimit::class),
'aiImagesLimit' => Feature::for($this)->value(AiImagesLimit::class),
'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class),
];
}
}

View file

@ -4,10 +4,13 @@
namespace App\Policies;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Laravel\Pennant\Feature;
class AccountPolicy
{
@ -21,6 +24,29 @@ public function manageBilling(User $user, Account $account): bool
return $user->id === $account->owner_id;
}
/**
* Authorize using AI features. Allows when the account still has monthly
* credits remaining. Manual post creation is unaffected — only AI calls
* are gated by this check.
*/
public function useAi(User $user, Account $account): Response
{
if (config('trypost.self_hosted')) {
return Response::allow();
}
$limit = (int) Feature::for($account)->value(MonthlyCreditsLimit::class);
$used = AiUsageLog::monthlyCredits($account->id);
if ($used >= $limit) {
return Response::deny(__('billing.flash.credits_exhausted', [
'limit' => (string) $limit,
]));
}
return Response::allow();
}
/**
* Authorize swapping the account's subscription to the given plan.
*

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\Ai;
/**
* Resolves the credit cost of an AI action from config/ai-credits.php.
*
* Text scales by tokens (ceil(total_tokens / tokens_per_credit)), while
* images and videos are flat per call because providers charge per output.
*/
final class CreditCost
{
public static function forText(int $totalTokens): int
{
$tokensPerCredit = (int) config('ai-credits.text.tokens_per_credit', 150);
if ($tokensPerCredit <= 0 || $totalTokens <= 0) {
return 0;
}
return (int) ceil($totalTokens / $tokensPerCredit);
}
public static function forImage(?string $model = null): int
{
$models = (array) config('ai-credits.image', []);
if ($model !== null && isset($models[$model])) {
return (int) $models[$model];
}
return (int) ($models['default'] ?? 0);
}
public static function forVideo(?string $model = null): int
{
$models = (array) config('ai-credits.video', []);
if ($model !== null && isset($models[$model])) {
return (int) $models[$model];
}
return (int) ($models['default'] ?? 0);
}
}

View file

@ -11,26 +11,93 @@
use Throwable;
/**
* Thin helper that writes a row to `workspace_ai_usages` whenever an AI
* action runs (image generation, LLM call, etc). Wraps the create in a
* try/catch so a tracking failure NEVER bubbles up and breaks the actual
* AI flow — at worst we miss a usage row and the user gets unblocked.
* Persists an AI usage row and debits credits from the account's monthly
* quota. Credits are billed at the account level (Workspace::account_id);
* workspace_id is recorded for analytics.
*
* Wraps the create in a try/catch so a tracking failure NEVER bubbles up
* and breaks the actual AI flow — at worst we miss a usage row and the
* user gets unblocked.
*/
final class RecordAiUsage
{
/**
* Record a usage entry. The Workspace carries the account_id implicitly,
* which is the FK that drives quotas (account-level billing).
* Record a usage entry for a text generation. Credits are computed from
* total_tokens via CreditCost::forText().
*
* @param array<string, mixed> $metadata Free-form context (model, format, agent, etc.)
* @param array<string, mixed> $metadata
*/
public static function record(
public static function recordText(
Workspace $workspace,
int $promptTokens,
int $completionTokens,
?string $provider = null,
?string $model = null,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
$totalTokens = $promptTokens + $completionTokens;
$credits = CreditCost::forText($totalTokens);
self::persist(
workspace: $workspace,
type: UsageType::Text,
credits: $credits,
provider: $provider,
model: $model,
promptTokens: $promptTokens,
completionTokens: $completionTokens,
totalTokens: $totalTokens,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* Record a usage entry for an image-template generation. Templates do not
* call an LLM (composed via Unsplash + branding) so we charge zero credits.
*
* @param array<string, mixed> $metadata
*/
public static function recordTemplate(
Workspace $workspace,
UsageType $type,
?string $provider = null,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
self::persist(
workspace: $workspace,
type: UsageType::Template,
credits: 0,
provider: $provider,
model: null,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* @param array<string, mixed> $metadata
*/
private static function persist(
Workspace $workspace,
UsageType $type,
int $credits,
?string $provider,
?string $model,
int $promptTokens,
int $completionTokens,
int $totalTokens,
?string $userId,
?string $postId,
array $metadata,
): void {
try {
AiUsageLog::create([
@ -40,6 +107,11 @@ public static function record(
'post_id' => $postId,
'type' => $type,
'provider' => $provider,
'model' => $model,
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => $totalTokens,
'credits' => $credits,
'metadata' => $metadata !== [] ? $metadata : null,
]);
} catch (Throwable $e) {

View file

@ -5,6 +5,8 @@
namespace App\Services\Brand;
use App\Ai\Agents\BrandAnalyzer;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
@ -23,7 +25,7 @@ public function isAvailable(): bool
};
}
public function analyze(string $bodyHtml): ?LlmBrandAnalysis
public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string $userId = null): ?LlmBrandAnalysis
{
$markdown = $this->htmlToMarkdown($bodyHtml);
@ -39,6 +41,21 @@ public function analyze(string $bodyHtml): ?LlmBrandAnalysis
return null;
}
// Brand analysis can run during onboarding before the user has a
// workspace; skip usage tracking in that case (the cost is small and
// one-shot). Other AI flows always carry a workspace.
if ($workspace !== null) {
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $userId,
metadata: ['agent' => 'brand_analyzer'],
);
}
return LlmBrandAnalysis::fromResponse($response);
}

View file

@ -4,7 +4,6 @@
namespace App\Services\Image;
use App\Enums\Ai\UsageType;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
@ -109,9 +108,8 @@ public function renderClosing(
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::record(
RecordAiUsage::recordTemplate(
workspace: $workspace,
type: UsageType::Image,
provider: 'internal',
metadata: [
'template' => 'C',
@ -162,9 +160,8 @@ public function render(
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::record(
RecordAiUsage::recordTemplate(
workspace: $workspace,
type: UsageType::Image,
provider: 'internal',
metadata: [
'template' => $template,

37
config/ai-credits.php Normal file
View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| AI Credit Pricing
|--------------------------------------------------------------------------
|
| Defines how AI usage is converted to credits debited from a workspace's
| monthly quota. Text scales by tokens; images and videos are flat per call
| because providers charge per output, not per token.
|
*/
'text' => [
// 1 credit = X tokens (sum of input + output).
// ceil(total_tokens / tokens_per_credit) is debited per call.
'tokens_per_credit' => 150,
],
'image' => [
// Credits charged per image generated, keyed by model id.
// Falls back to 'default' if the model is not listed.
'default' => 50,
'gpt-image-1.5' => 50,
'gpt-image-1.5-hd' => 100,
'flux-pro' => 30,
],
'video' => [
'default' => 500,
],
];

View file

@ -14,6 +14,7 @@
*/
'default' => env('AI_TEXT_PROVIDER', 'openai'),
'default_text_model' => env('AI_TEXT_MODEL', 'gpt-5.4'),
'default_for_images' => env('AI_IMAGE_PROVIDER', 'openai'),
'default_for_audio' => env('AI_AUDIO_PROVIDER', 'openai'),
'default_for_transcription' => env('AI_TRANSCRIPTION_PROVIDER', 'openai'),

View file

@ -20,22 +20,33 @@ public function definition(): array
'workspace_id' => Workspace::factory(),
'type' => fake()->randomElement(UsageType::cases()),
'provider' => fake()->randomElement(['openai', 'gemini', 'internal']),
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
'credits' => 0,
];
}
public function image(): static
public function template(): static
{
return $this->state(fn () => [
'type' => UsageType::Image,
'type' => UsageType::Template,
'provider' => 'internal',
]);
}
public function text(): static
public function text(int $credits = 10): static
{
$tokens = $credits * 150;
return $this->state(fn () => [
'type' => UsageType::Text,
'provider' => 'openai',
'model' => 'gpt-5.4',
'prompt_tokens' => intdiv($tokens, 2),
'completion_tokens' => intdiv($tokens, 2),
'total_tokens' => $tokens,
'credits' => $credits,
]);
}
}

View file

@ -28,7 +28,7 @@ public function definition(): array
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
'ai_images_limit' => fake()->randomElement([10, 50, 200, 1000]),
'monthly_credits_limit' => fake()->randomElement([1000, 2000, 5000, 15000]),
'sort' => 0,
'is_archived' => false,
];

View file

@ -19,7 +19,7 @@ public function up(): void
$table->integer('social_account_limit');
$table->integer('member_limit');
$table->integer('workspace_limit');
$table->integer('ai_images_limit');
$table->integer('monthly_credits_limit');
$table->integer('sort')->default(0);
$table->boolean('is_archived')->default(false);
$table->timestamps();

View file

@ -16,12 +16,18 @@ public function up(): void
$table->uuid('workspace_id');
$table->uuid('user_id')->nullable();
$table->uuid('post_id')->nullable();
$table->string('type'); // image, video, audio
$table->string('type'); // text, template
$table->string('provider')->nullable();
$table->string('model')->nullable();
$table->unsignedInteger('prompt_tokens')->default(0);
$table->unsignedInteger('completion_tokens')->default(0);
$table->unsignedInteger('total_tokens')->default(0);
$table->unsignedInteger('credits')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->foreign('account_id')->references('id')->on('accounts')->cascadeOnDelete();
$table->index(['account_id', 'created_at']);
$table->index(['account_id', 'type', 'created_at']);
});
}

View file

@ -24,7 +24,7 @@ public function run(): void
'social_account_limit' => 5,
'member_limit' => 1,
'workspace_limit' => 1,
'ai_images_limit' => 50,
'monthly_credits_limit' => 1000,
'sort' => 1,
],
[
@ -35,7 +35,7 @@ public function run(): void
'social_account_limit' => 10,
'member_limit' => 5,
'workspace_limit' => 5,
'ai_images_limit' => 150,
'monthly_credits_limit' => 2000,
'sort' => 2,
],
[
@ -46,7 +46,7 @@ public function run(): void
'social_account_limit' => 30,
'member_limit' => 15,
'workspace_limit' => 15,
'ai_images_limit' => 500,
'monthly_credits_limit' => 5000,
'sort' => 3,
],
[
@ -57,7 +57,7 @@ public function run(): void
'social_account_limit' => 100,
'member_limit' => 20,
'workspace_limit' => 50,
'ai_images_limit' => 2000,
'monthly_credits_limit' => 15000,
'sort' => 4,
],
];

View file

@ -48,7 +48,7 @@
'social_accounts' => ':count social accounts',
'workspaces' => ':count workspaces',
'members' => ':count team members',
'ai_images' => ':count AI images/mo',
'credits' => ':count AI credits/mo',
],
],
@ -89,6 +89,7 @@
'social_accounts' => 'Cannot switch to :plan: you have :count social accounts but the plan only allows :limit.',
'members' => 'Cannot switch to :plan: you have :count team members (including invites) but the plan only allows :limit.',
],
'credits_exhausted' => 'Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.',
],
'processing' => [

View file

@ -5,12 +5,11 @@
'section_account' => 'Account',
'section_account_description' => 'Quotas and limits for your :plan plan.',
'section_ai' => 'AI Generation',
'section_ai_description' => 'AI image and video generation usage for the current month.',
'section_ai' => 'AI Credits',
'section_ai_description' => 'Credits are debited as AI features are used. They reset on the first of every month.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Social Accounts',
'members' => 'Members',
'ai_images' => 'Images',
'ai_text' => 'Text generation',
'credits' => 'Credits',
];

View file

@ -48,7 +48,7 @@
'social_accounts' => ':count cuentas sociales',
'workspaces' => ':count workspaces',
'members' => ':count miembros del equipo',
'ai_images' => ':count imágenes IA/mes',
'credits' => ':count créditos IA/mes',
],
],
@ -89,6 +89,7 @@
'social_accounts' => 'No puedes cambiar a :plan: tienes :count cuentas sociales pero el plan solo permite :limit.',
'members' => 'No puedes cambiar a :plan: tienes :count miembros (incluyendo invitaciones) pero el plan solo permite :limit.',
],
'credits_exhausted' => 'Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.',
],
'processing' => [

View file

@ -5,12 +5,11 @@
'section_account' => 'Cuenta',
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
'section_ai' => 'Generación AI',
'section_ai_description' => 'Uso de generación de imágenes y videos AI del mes actual.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Cuentas Sociales',
'members' => 'Miembros',
'ai_images' => 'Imágenes',
'ai_text' => 'Generación de texto',
'credits' => 'Créditos',
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -48,7 +48,7 @@
'social_accounts' => ':count contas sociais',
'workspaces' => ':count workspaces',
'members' => ':count membros da equipe',
'ai_images' => ':count imagens IA/mês',
'credits' => ':count créditos IA/mês',
],
],
@ -89,6 +89,7 @@
'social_accounts' => 'Não é possível mudar para :plan: você tem :count contas sociais mas o plano só permite :limit.',
'members' => 'Não é possível mudar para :plan: você tem :count membros (incluindo convites) mas o plano só permite :limit.',
],
'credits_exhausted' => 'Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.',
],
'processing' => [

View file

@ -5,12 +5,11 @@
'section_account' => 'Conta',
'section_account_description' => 'Cotas e limites do seu plano :plan.',
'section_ai' => 'Geração AI',
'section_ai_description' => 'Uso de geração de imagens e vídeos AI do mês atual.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Contas Sociais',
'members' => 'Membros',
'ai_images' => 'Imagens',
'ai_text' => 'Geração de texto',
'credits' => 'Créditos',
];

View file

@ -20,7 +20,7 @@ interface Plan {
social_account_limit: number;
member_limit: number;
workspace_limit: number;
ai_images_limit: number;
monthly_credits_limit: number;
}
const { open, reason, closeUpgrade } = useUpgradeDialog();
@ -64,7 +64,7 @@ const features = (plan: Plan): string[] => [
trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }),
trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }),
trans('billing.subscribe.features.members', { count: String(plan.member_limit) }),
trans('billing.subscribe.features.ai_images', { count: String(plan.ai_images_limit) }),
trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
];
const downgradeBlocker = (plan: Plan): string | null => {

View file

@ -457,8 +457,7 @@ onUnmounted(() => unsubscribeEcho());
/>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" @click="retryGeneration">
<IconRefresh class="mr-1 size-4" />
<Button variant="outline" @click="retryGeneration">
{{ $t('posts.create.steps.retry') }}
</Button>
<Button :disabled="finalizing" @click="createPost">

View file

@ -12,13 +12,14 @@ interface Usage {
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
creditsUsed: number;
}
interface Features {
workspaceLimit: number;
socialAccountLimit: number;
memberLimit: number;
aiImagesLimit: number;
monthlyCreditsLimit: number;
}
export const useFeatureAccess = () => {
@ -32,7 +33,7 @@ export const useFeatureAccess = () => {
const workspaceLimit = computed(() => features.value?.workspaceLimit ?? 1);
const socialAccountLimit = computed(() => features.value?.socialAccountLimit ?? 1);
const memberLimit = computed(() => features.value?.memberLimit ?? 1);
const aiImagesLimit = computed(() => features.value?.aiImagesLimit ?? 0);
const monthlyCreditsLimit = computed(() => features.value?.monthlyCreditsLimit ?? 0);
const canCreateWorkspace = computed(() => {
if (isSelfHosted.value) return true;
@ -52,6 +53,12 @@ export const useFeatureAccess = () => {
return usage.value.memberCount + usage.value.pendingInviteCount < memberLimit.value;
});
const hasCreditsLeft = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.creditsUsed < monthlyCreditsLimit.value;
});
return {
plan,
usage,
@ -60,9 +67,10 @@ export const useFeatureAccess = () => {
workspaceLimit,
socialAccountLimit,
memberLimit,
aiImagesLimit,
monthlyCreditsLimit,
canCreateWorkspace,
canConnectSocialAccount,
canInviteMember,
hasCreditsLeft,
};
};

View file

@ -18,7 +18,7 @@ interface Plan {
social_account_limit: number;
member_limit: number;
workspace_limit: number;
ai_images_limit: number;
monthly_credits_limit: number;
}
const props = defineProps<{
@ -46,7 +46,7 @@ const features = (plan: Plan): string[] => [
trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }),
trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }),
trans('billing.subscribe.features.members', { count: String(plan.member_limit) }),
trans('billing.subscribe.features.ai_images', { count: String(plan.ai_images_limit) }),
trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
];
const isPopular = (plan: Plan): boolean => {

View file

@ -24,9 +24,8 @@ interface UsageData {
socialAccountLimit: number;
memberCount: number;
memberLimit: number;
aiImagesUsed: number;
aiImagesLimit: number;
aiTextUsed: number;
creditsUsed: number;
monthlyCreditsLimit: number;
}
defineProps<{
@ -94,14 +93,9 @@ const tabs = computed(() => [
<div class="divide-y">
<UsageMetricRow
:label="$t('usage.ai_images')"
:current="usage.aiImagesUsed"
:limit="usage.aiImagesLimit"
/>
<UsageMetricRow
:label="$t('usage.ai_text')"
:current="usage.aiTextUsed"
:label="$t('usage.credits')"
:current="usage.creditsUsed"
:limit="usage.monthlyCreditsLimit"
/>
</div>
</div>

View file

@ -2,62 +2,60 @@
declare(strict_types=1);
use App\Enums\Ai\UsageType;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Workspace;
test('monthly count returns correct count for account and type', function () {
test('monthly credits returns sum of credits for account this month', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->image()->count(3)->create([
AiUsageLog::factory()->text(credits: 5)->count(3)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
]);
AiUsageLog::factory()->text()->count(2)->create([
AiUsageLog::factory()->text(credits: 10)->count(2)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
]);
expect(AiUsageLog::monthlyCount($account->id, UsageType::Image))->toBe(3);
expect(AiUsageLog::monthlyCount($account->id, UsageType::Text))->toBe(2);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(35);
});
test('monthly count excludes logs from other months', function () {
test('monthly credits excludes logs from other months', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->image()->create([
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
]);
AiUsageLog::factory()->image()->create([
AiUsageLog::factory()->text(credits: 50)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => now()->subMonth(),
]);
expect(AiUsageLog::monthlyCount($account->id, UsageType::Image))->toBe(1);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
});
test('monthly count excludes logs from other accounts', function () {
test('monthly credits excludes logs from other accounts', function () {
$account = Account::factory()->create();
$otherAccount = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
$otherWorkspace = Workspace::factory()->create(['account_id' => $otherAccount->id]);
AiUsageLog::factory()->image()->create([
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
]);
AiUsageLog::factory()->image()->create([
AiUsageLog::factory()->text(credits: 100)->create([
'account_id' => $otherAccount->id,
'workspace_id' => $otherWorkspace->id,
]);
expect(AiUsageLog::monthlyCount($account->id, UsageType::Image))->toBe(1);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
});

View file

@ -2,8 +2,8 @@
declare(strict_types=1);
use App\Features\AiImagesLimit;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Account;
@ -20,7 +20,7 @@
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($starter->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($starter->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($starter->member_limit);
expect(Feature::for($account)->value(AiImagesLimit::class))->toBe($starter->ai_images_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($starter->monthly_credits_limit);
$account->update(['plan_id' => $plus->id]);
$account->load('plan');
@ -28,7 +28,7 @@
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit);
expect(Feature::for($account)->value(AiImagesLimit::class))->toBe($plus->ai_images_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit);
});
test('booted hook does not flush pennant when other fields change', function () {

View file

@ -36,6 +36,7 @@
'socialAccountCount' => 3,
'memberCount' => 3,
'pendingInviteCount' => 2,
'creditsUsed' => 0,
]);
});
@ -49,7 +50,7 @@
'workspaceLimit' => $plan->workspace_limit,
'socialAccountLimit' => $plan->social_account_limit,
'memberLimit' => $plan->member_limit,
'aiImagesLimit' => $plan->ai_images_limit,
'monthlyCreditsLimit' => $plan->monthly_credits_limit,
]);
});

View file

@ -17,7 +17,7 @@
->and($starter->social_account_limit)->toBe(5)
->and($starter->member_limit)->toBe(1)
->and($starter->workspace_limit)->toBe(1)
->and($starter->ai_images_limit)->toBe(50)
->and($starter->monthly_credits_limit)->toBe(1000)
->and($starter->sort)->toBe(1);
$max = Plan::where('slug', Slug::Max)->first();
@ -26,7 +26,7 @@
->and($max->social_account_limit)->toBe(100)
->and($max->member_limit)->toBe(20)
->and($max->workspace_limit)->toBe(50)
->and($max->ai_images_limit)->toBe(2000)
->and($max->monthly_credits_limit)->toBe(15000)
->and($max->sort)->toBe(4);
});

View file

@ -42,6 +42,6 @@
expect($plan->social_account_limit)->toBeInt()
->and($plan->member_limit)->toBeInt()
->and($plan->workspace_limit)->toBeInt()
->and($plan->ai_images_limit)->toBeInt()
->and($plan->monthly_credits_limit)->toBeInt()
->and($plan->sort)->toBeInt();
});

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Features\AiImagesLimit;
use App\Models\Account;
use App\Models\Plan;
test('returns plan ai images limit', function () {
$plan = new Plan(['ai_images_limit' => 200]);
$account = new Account;
$account->setRelation('plan', $plan);
expect((new AiImagesLimit)->resolve($account))->toBe(200);
});
test('falls back to 50 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
expect((new AiImagesLimit)->resolve($account))->toBe(50);
});

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\Plan;
test('returns plan monthly credits limit', function () {
$plan = new Plan(['monthly_credits_limit' => 5000]);
$account = new Account;
$account->setRelation('plan', $plan);
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(5000);
});
test('falls back to 1000 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(1000);
});

View file

@ -4,6 +4,7 @@
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Invite;
use App\Models\Plan;
use App\Models\SocialAccount;
@ -97,6 +98,48 @@
]));
});
test('useAi allows when account has credits remaining', function () {
config()->set('trypost.self_hosted', false);
$plan = Plan::where('slug', 'starter')->first();
$this->account->update(['plan_id' => $plan->id]);
$response = $this->policy->useAi($this->owner, $this->account->fresh());
expect($response->allowed())->toBeTrue();
});
test('useAi denies when monthly credits are exhausted', function () {
config()->set('trypost.self_hosted', false);
$plan = Plan::where('slug', 'starter')->first();
$this->account->update(['plan_id' => $plan->id]);
$workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
AiUsageLog::factory()->text(credits: $plan->monthly_credits_limit)->create([
'account_id' => $this->account->id,
'workspace_id' => $workspace->id,
]);
$response = $this->policy->useAi($this->owner, $this->account->fresh());
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.credits_exhausted', [
'limit' => (string) $plan->monthly_credits_limit,
]));
});
test('useAi always allows when self-hosted', function () {
config()->set('trypost.self_hosted', true);
$response = $this->policy->useAi($this->owner, $this->account);
expect($response->allowed())->toBeTrue();
});
test('swapPlan allows when usage equals target plan limit (boundary)', function () {
Workspace::factory()->create([
'account_id' => $this->account->id,