feat: transition AI usage from feature-based limits to a centralized monthly credit system with token tracking.
This commit is contained in:
parent
811eb71719
commit
afc47e9532
54 changed files with 469 additions and 374 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,6 @@
|
|||
|
||||
enum UsageType: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Template = 'template';
|
||||
case Text = 'text';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
47
app/Services/Ai/CreditCost.php
Normal file
47
app/Services/Ai/CreditCost.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
37
config/ai-credits.php
Normal 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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
22
tests/Unit/Features/MonthlyCreditsLimitTest.php
Normal file
22
tests/Unit/Features/MonthlyCreditsLimitTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue