From f35ea4ca7e32dba7263bfa355e347e622d7e3aa8 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 26 May 2026 15:54:49 +0200 Subject: [PATCH] feat: ads config --- .env.example | 3 + .gitea/workflows/deploy.yml | 4 + app/Enums/GenerationCreditTransactionType.php | 11 + app/Enums/RewardedAdSessionStatus.php | 11 + .../InsufficientGenerationCredits.php | 10 + app/Exceptions/InvalidRewardedAdCallback.php | 10 + app/Exceptions/MissingRewardedAdUnit.php | 10 + app/Exceptions/RewardedAdUnavailable.php | 10 + .../AdMobRewardedAdCallbackController.php | 34 ++ .../GenerationCreditController.php | 59 +++ .../MealImageAnalysisController.php | 18 +- .../Requests/RewardedAdSessionRequest.php | 20 + app/Models/GenerationCreditBalance.php | 36 ++ app/Models/GenerationCreditTransaction.php | 46 +++ app/Models/RewardedAdSession.php | 56 +++ app/Models/User.php | 15 + app/Providers/AppServiceProvider.php | 5 + .../AdMobServerSideVerificationVerifier.php | 136 +++++++ app/Services/GenerationCreditService.php | 364 ++++++++++++++++++ config/admob.php | 13 + config/deploy.yml | 1 + .../GenerationCreditBalanceFactory.php | 28 ++ .../GenerationCreditTransactionFactory.php | 45 +++ .../factories/RewardedAdSessionFactory.php | 39 ++ ...000001_create_generation_credit_tables.php | 68 ++++ lang/en/api.php | 5 + lang/fr/api.php | 5 + routes/api.php | 9 + tests/Feature/GenerationCreditsTest.php | 230 +++++++++++ tests/Feature/MealImageAnalysisTest.php | 38 ++ 30 files changed, 1337 insertions(+), 2 deletions(-) create mode 100644 app/Enums/GenerationCreditTransactionType.php create mode 100644 app/Enums/RewardedAdSessionStatus.php create mode 100644 app/Exceptions/InsufficientGenerationCredits.php create mode 100644 app/Exceptions/InvalidRewardedAdCallback.php create mode 100644 app/Exceptions/MissingRewardedAdUnit.php create mode 100644 app/Exceptions/RewardedAdUnavailable.php create mode 100644 app/Http/Controllers/AdMobRewardedAdCallbackController.php create mode 100644 app/Http/Controllers/GenerationCreditController.php create mode 100644 app/Http/Requests/RewardedAdSessionRequest.php create mode 100644 app/Models/GenerationCreditBalance.php create mode 100644 app/Models/GenerationCreditTransaction.php create mode 100644 app/Models/RewardedAdSession.php create mode 100644 app/Services/AdMobServerSideVerificationVerifier.php create mode 100644 app/Services/GenerationCreditService.php create mode 100644 config/admob.php create mode 100644 database/factories/GenerationCreditBalanceFactory.php create mode 100644 database/factories/GenerationCreditTransactionFactory.php create mode 100644 database/factories/RewardedAdSessionFactory.php create mode 100644 database/migrations/2026_05_26_000001_create_generation_credit_tables.php create mode 100644 tests/Feature/GenerationCreditsTest.php diff --git a/.env.example b/.env.example index be1220c..21e42c6 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=false OLLAMA_API_KEY= GEMINI_API_KEY= +ADMOB_REWARDED_ANDROID_AD_UNIT_ID= +ADMOB_REWARDED_IOS_AD_UNIT_ID= + AI_MEAL_USER_NAME="Daily Meal AI" AI_MEAL_USER_EMAIL=ai@daily-meal.local AI_MEAL_IMAGE_PATH=meal-posts/ai-generated diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 299db57..dc3a8de 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -50,6 +50,7 @@ jobs: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + ADMOB_REWARDED_ANDROID_AD_UNIT_ID: ${{ secrets.ADMOB_REWARDED_ANDROID_AD_UNIT_ID }} SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} @@ -62,6 +63,7 @@ jobs: run: | set -eu : "${GEMINI_API_KEY:?GEMINI_API_KEY secret is required}" + : "${ADMOB_REWARDED_ANDROID_AD_UNIT_ID:?ADMOB_REWARDED_ANDROID_AD_UNIT_ID secret is required}" : "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID secret is required}" : "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY secret is required}" @@ -82,6 +84,7 @@ jobs: -e DB_PASSWORD \ -e MEILISEARCH_KEY \ -e GEMINI_API_KEY \ + -e ADMOB_REWARDED_ANDROID_AD_UNIT_ID \ -e SSH_HOST \ -e SSH_USER \ -e SSH_PRIVATE_KEY \ @@ -127,6 +130,7 @@ jobs: DB_PASSWORD=$DB_PASSWORD MEILISEARCH_KEY=$MEILISEARCH_KEY GEMINI_API_KEY=$GEMINI_API_KEY + ADMOB_REWARDED_ANDROID_AD_UNIT_ID=$ADMOB_REWARDED_ANDROID_AD_UNIT_ID SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID STRAVA_CLIENT_SECRET=$STRAVA_CLIENT_SECRET diff --git a/app/Enums/GenerationCreditTransactionType.php b/app/Enums/GenerationCreditTransactionType.php new file mode 100644 index 0000000..115f790 --- /dev/null +++ b/app/Enums/GenerationCreditTransactionType.php @@ -0,0 +1,11 @@ +verify($request); + $credits->rewardFromAdMobCallback($request->query()); + } catch (InvalidRewardedAdCallback $exception) { + report($exception); + + return response('invalid rewarded ad callback', 400); + } catch (Throwable $exception) { + report($exception); + + return response('rewarded ad callback failed', 400); + } + + return response('ok'); + } +} diff --git a/app/Http/Controllers/GenerationCreditController.php b/app/Http/Controllers/GenerationCreditController.php new file mode 100644 index 0000000..fb378e3 --- /dev/null +++ b/app/Http/Controllers/GenerationCreditController.php @@ -0,0 +1,59 @@ +json([ + 'data' => $credits->summary($request->user()), + ]); + } + + public function storeRewardedAdSession( + RewardedAdSessionRequest $request, + GenerationCreditService $credits, + ): JsonResponse { + try { + $session = $credits->createRewardedAdSession( + $request->user(), + $request->validated('platform'), + ); + } catch (RewardedAdUnavailable $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } catch (MissingRewardedAdUnit $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 503); + } + + return response()->json([ + 'data' => $this->rewardedAdSessionPayload($session), + ], $session->wasRecentlyCreated ? 201 : 200); + } + + /** + * @return array{id: string, adUnitId: ?string, customData: string, userId: string, expiresAt: string} + */ + private function rewardedAdSessionPayload(RewardedAdSession $session): array + { + return [ + 'id' => $session->id, + 'adUnitId' => $session->ad_unit_id, + 'customData' => $session->custom_data, + 'userId' => $session->user_id, + 'expiresAt' => $session->expires_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/MealImageAnalysisController.php b/app/Http/Controllers/MealImageAnalysisController.php index 1c23824..6a3900a 100644 --- a/app/Http/Controllers/MealImageAnalysisController.php +++ b/app/Http/Controllers/MealImageAnalysisController.php @@ -6,8 +6,10 @@ use App\Ai\Agents\MealImageAnalyzer; use App\Enums\AiUsageType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; +use App\Exceptions\InsufficientGenerationCredits; use App\Http\Requests\MealImageAnalysisRequest; use App\Services\AiUsageRecorder; +use App\Services\GenerationCreditService; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\JsonResponse; use Illuminate\Http\UploadedFile; @@ -17,11 +19,22 @@ use Throwable; class MealImageAnalysisController extends Controller { - public function store(MealImageAnalysisRequest $request, AiUsageRecorder $aiUsageRecorder): JsonResponse - { + public function store( + MealImageAnalysisRequest $request, + AiUsageRecorder $aiUsageRecorder, + GenerationCreditService $generationCredits, + ): JsonResponse { $image = $request->file('image'); $input = $this->analysisInput($image); + try { + $spentCredit = $generationCredits->spendForGeneration($request->user()); + } catch (InsufficientGenerationCredits $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 402); + } + try { $response = MealImageAnalyzer::make()->prompt( $this->prompt(), @@ -30,6 +43,7 @@ class MealImageAnalysisController extends Controller ); } catch (Throwable $exception) { report($exception); + $generationCredits->refundGenerationSpend($spentCredit); $aiUsageRecorder->failed( user: $request->user(), diff --git a/app/Http/Requests/RewardedAdSessionRequest.php b/app/Http/Requests/RewardedAdSessionRequest.php new file mode 100644 index 0000000..1317869 --- /dev/null +++ b/app/Http/Requests/RewardedAdSessionRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'in:android,ios'], + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/app/Models/GenerationCreditBalance.php b/app/Models/GenerationCreditBalance.php new file mode 100644 index 0000000..fbf8a67 --- /dev/null +++ b/app/Models/GenerationCreditBalance.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + protected $primaryKey = 'user_id'; + + public $incrementing = false; + + protected $keyType = 'string'; + + protected $fillable = [ + 'user_id', + 'balance', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'balance' => 'integer', + ]; + } +} diff --git a/app/Models/GenerationCreditTransaction.php b/app/Models/GenerationCreditTransaction.php new file mode 100644 index 0000000..30d590d --- /dev/null +++ b/app/Models/GenerationCreditTransaction.php @@ -0,0 +1,46 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'amount', + 'type', + 'source_type', + 'source_id', + 'idempotency_key', + 'daily_reward_key', + 'metadata', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function source(): MorphTo + { + return $this->morphTo(); + } + + protected function casts(): array + { + return [ + 'amount' => 'integer', + 'type' => GenerationCreditTransactionType::class, + 'metadata' => 'array', + ]; + } +} diff --git a/app/Models/RewardedAdSession.php b/app/Models/RewardedAdSession.php new file mode 100644 index 0000000..74bbae1 --- /dev/null +++ b/app/Models/RewardedAdSession.php @@ -0,0 +1,56 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'custom_data', + 'status', + 'reward_date', + 'expires_at', + 'rewarded_at', + 'ad_unit_id', + 'admob_transaction_id', + 'reward_amount', + 'reward_item', + 'callback_payload', + ]; + + protected $attributes = [ + 'status' => RewardedAdSessionStatus::CREATED->value, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creditTransactions(): MorphMany + { + return $this->morphMany(GenerationCreditTransaction::class, 'source'); + } + + protected function casts(): array + { + return [ + 'status' => RewardedAdSessionStatus::class, + 'reward_date' => 'immutable_date', + 'expires_at' => 'datetime', + 'rewarded_at' => 'datetime', + 'reward_amount' => 'integer', + 'callback_payload' => 'array', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index fc40e35..2921c38 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -115,6 +115,21 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(AiUsage::class); } + public function generationCreditBalance(): HasOne + { + return $this->hasOne(GenerationCreditBalance::class); + } + + public function generationCreditTransactions(): HasMany + { + return $this->hasMany(GenerationCreditTransaction::class); + } + + public function rewardedAdSessions(): HasMany + { + return $this->hasMany(RewardedAdSession::class); + } + public function workouts(): HasMany { return $this->hasMany(WorkoutSessions::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e7302ad..3af5155 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -136,6 +136,11 @@ class AppServiceProvider extends ServiceProvider fn (Request $request): Limit => Limit::perMinute(30)->by($this->userRateLimitKey($request)) ); + RateLimiter::for( + 'rewarded-ad', + fn (Request $request): Limit => Limit::perMinute(10)->by($this->userRateLimitKey($request)) + ); + RateLimiter::for( 'notifications', fn (Request $request): Limit => Limit::perMinute(3)->by($this->userRateLimitKey($request)) diff --git a/app/Services/AdMobServerSideVerificationVerifier.php b/app/Services/AdMobServerSideVerificationVerifier.php new file mode 100644 index 0000000..ba998e9 --- /dev/null +++ b/app/Services/AdMobServerSideVerificationVerifier.php @@ -0,0 +1,136 @@ +server('QUERY_STRING', ''); + $signatureParameter = 'signature='; + $keyIdParameter = 'key_id='; + $signatureStart = strpos($queryString, $signatureParameter); + + if ($queryString === '' || $signatureStart === false || $signatureStart === 0) { + throw new InvalidRewardedAdCallback('Missing rewarded ad signature.'); + } + + $content = substr($queryString, 0, $signatureStart - 1); + $signatureAndKey = substr($queryString, $signatureStart); + $keyIdStart = strpos($signatureAndKey, $keyIdParameter); + + if ($keyIdStart === false) { + throw new InvalidRewardedAdCallback('Missing rewarded ad key id.'); + } + + $encodedSignature = substr( + $signatureAndKey, + strlen($signatureParameter), + $keyIdStart - strlen($signatureParameter) - 1, + ); + $keyId = strtok(substr($signatureAndKey, $keyIdStart + strlen($keyIdParameter)), '&'); + + if ($encodedSignature === '' || $keyId === false || $keyId === '') { + throw new InvalidRewardedAdCallback('Invalid rewarded ad signature parameters.'); + } + + $signature = $this->base64UrlDecode(rawurldecode($encodedSignature)); + $publicKey = $this->publicKey((string) $keyId); + + if (openssl_verify($content, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw new InvalidRewardedAdCallback('Invalid rewarded ad signature.'); + } + } + + private function publicKey(string $keyId): string + { + $publicKeys = $this->publicKeys(); + + if (! isset($publicKeys[$keyId])) { + Cache::forget($this->cacheKey()); + $publicKeys = $this->publicKeys(); + } + + if (! isset($publicKeys[$keyId])) { + throw new InvalidRewardedAdCallback('Unknown rewarded ad signature key.'); + } + + return $publicKeys[$keyId]; + } + + /** + * @return array + */ + private function publicKeys(): array + { + return Cache::remember( + $this->cacheKey(), + (int) config('admob.rewarded.public_keys_cache_seconds', 43200), + function (): array { + $response = Http::timeout(5) + ->acceptJson() + ->get((string) config('admob.rewarded.public_keys_url')); + + if (! $response->ok()) { + throw new InvalidRewardedAdCallback('Unable to fetch rewarded ad public keys.'); + } + + $keys = $response->json('keys'); + + if (! is_array($keys)) { + throw new InvalidRewardedAdCallback('Invalid rewarded ad public keys.'); + } + + $publicKeys = []; + + foreach ($keys as $key) { + if (! is_array($key) || ! isset($key['keyId'])) { + continue; + } + + $pem = $key['pem'] ?? null; + + if (! is_string($pem) || $pem === '') { + continue; + } + + $publicKeys[(string) $key['keyId']] = $pem; + } + + if ($publicKeys === []) { + throw new InvalidRewardedAdCallback('No rewarded ad public keys are available.'); + } + + return $publicKeys; + } + ); + } + + private function base64UrlDecode(string $value): string + { + $normalized = strtr($value, '-_', '+/'); + $padding = strlen($normalized) % 4; + + if ($padding > 0) { + $normalized .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode($normalized, true); + + if ($decoded === false) { + throw new InvalidRewardedAdCallback('Invalid rewarded ad signature encoding.'); + } + + return $decoded; + } + + private function cacheKey(): string + { + return 'admob:ssv-public-keys'; + } +} diff --git a/app/Services/GenerationCreditService.php b/app/Services/GenerationCreditService.php new file mode 100644 index 0000000..77fb506 --- /dev/null +++ b/app/Services/GenerationCreditService.php @@ -0,0 +1,364 @@ +whereKey($user->getKey()) + ->value('balance') ?? 0); + } + + /** + * @return array{balance: int, canWatchRewardedAd: bool, nextRewardedAdAt: ?string, rewardedAdConfigured: bool, serverTime: string} + */ + public function summary(User $user): array + { + $now = $this->now(); + $rewardedToday = $this->hasRewardedAdCreditForDate($user, $now->toDateString()); + $hasAdUnit = $this->hasAnyRewardedAdUnit(); + + return [ + 'balance' => $this->balance($user), + 'canWatchRewardedAd' => $hasAdUnit && ! $rewardedToday, + 'nextRewardedAdAt' => $rewardedToday + ? $now->addDay()->startOfDay()->utc()->toIso8601String() + : null, + 'rewardedAdConfigured' => $hasAdUnit, + 'serverTime' => $now->utc()->toIso8601String(), + ]; + } + + public function createRewardedAdSession(User $user, string $platform): RewardedAdSession + { + $now = $this->now(); + $rewardDate = $now->toDateString(); + + if ($this->hasRewardedAdCreditForDate($user, $rewardDate)) { + throw new RewardedAdUnavailable(__('api.generation_credits.rewarded_ad_unavailable')); + } + + $adUnitId = $this->rewardedAdUnitId($platform); + + if ($adUnitId === null) { + throw new MissingRewardedAdUnit(__('api.generation_credits.rewarded_ad_not_configured')); + } + + $existingSession = RewardedAdSession::query() + ->where('user_id', $user->getKey()) + ->where('status', RewardedAdSessionStatus::CREATED->value) + ->whereDate('reward_date', $rewardDate) + ->where('expires_at', '>', now()) + ->latest() + ->first(); + + if ($existingSession) { + return $existingSession; + } + + return RewardedAdSession::create([ + 'user_id' => $user->getKey(), + 'custom_data' => Str::random(64), + 'reward_date' => $rewardDate, + 'expires_at' => now()->addMinutes((int) config('admob.rewarded.session_ttl_minutes', 30)), + 'ad_unit_id' => $adUnitId, + ]); + } + + public function rewardFromAdMobCallback(array $payload): void + { + $customData = $this->requiredString($payload, 'custom_data'); + $transactionId = $this->requiredString($payload, 'transaction_id'); + $userId = $payload['user_id'] ?? null; + $adUnit = $payload['ad_unit'] ?? null; + $rewardAmount = isset($payload['reward_amount']) ? (int) $payload['reward_amount'] : null; + $rewardItem = isset($payload['reward_item']) ? (string) $payload['reward_item'] : null; + + $session = RewardedAdSession::query() + ->where('custom_data', $customData) + ->first(); + + if (! $session) { + throw new InvalidRewardedAdCallback('Unknown rewarded ad session.'); + } + + if (is_string($userId) && $userId !== '' && $userId !== $session->user_id) { + throw new InvalidRewardedAdCallback('Rewarded ad user mismatch.'); + } + + $this->validateAdUnit($adUnit); + $this->validateReward($rewardAmount, $rewardItem); + + DB::transaction(function () use ($session, $transactionId, $payload, $rewardAmount, $rewardItem): void { + $session = RewardedAdSession::query() + ->whereKey($session->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + if ($session->status === RewardedAdSessionStatus::REWARDED) { + return; + } + + if ($session->expires_at->isPast()) { + $session->update([ + 'status' => RewardedAdSessionStatus::EXPIRED, + 'callback_payload' => $payload, + 'admob_transaction_id' => $transactionId, + 'reward_amount' => $rewardAmount, + 'reward_item' => $rewardItem, + ]); + + return; + } + + if (GenerationCreditTransaction::query()->where('idempotency_key', $this->adRewardIdempotencyKey($transactionId))->exists()) { + return; + } + + $dailyRewardKey = $this->dailyRewardKey($session->reward_date->toDateString()); + + if (GenerationCreditTransaction::query() + ->where('user_id', $session->user_id) + ->where('daily_reward_key', $dailyRewardKey) + ->exists()) { + $session->update([ + 'status' => RewardedAdSessionStatus::REJECTED, + 'callback_payload' => $payload, + 'admob_transaction_id' => $transactionId, + 'reward_amount' => $rewardAmount, + 'reward_item' => $rewardItem, + ]); + + return; + } + + $balance = $this->balanceForUpdate($session->user); + $balance->balance += 1; + $balance->save(); + + GenerationCreditTransaction::create([ + 'user_id' => $session->user_id, + 'amount' => 1, + 'type' => GenerationCreditTransactionType::AD_REWARD, + 'source_type' => $session::class, + 'source_id' => $session->getKey(), + 'idempotency_key' => $this->adRewardIdempotencyKey($transactionId), + 'daily_reward_key' => $dailyRewardKey, + 'metadata' => [ + 'admob' => $payload, + ], + ]); + + $session->update([ + 'status' => RewardedAdSessionStatus::REWARDED, + 'rewarded_at' => now(), + 'callback_payload' => $payload, + 'admob_transaction_id' => $transactionId, + 'reward_amount' => $rewardAmount, + 'reward_item' => $rewardItem, + ]); + }); + } + + public function spendForGeneration(User $user): GenerationCreditTransaction + { + return DB::transaction(function () use ($user): GenerationCreditTransaction { + $balance = $this->balanceForUpdate($user); + + if ($balance->balance < 1) { + throw new InsufficientGenerationCredits(__('api.generation_credits.insufficient')); + } + + $balance->balance -= 1; + $balance->save(); + + return GenerationCreditTransaction::create([ + 'user_id' => $user->getKey(), + 'amount' => -1, + 'type' => GenerationCreditTransactionType::GENERATION_SPEND, + 'metadata' => [ + 'feature' => 'meal_image_analysis', + ], + ]); + }); + } + + public function refundGenerationSpend(GenerationCreditTransaction $spend): void + { + DB::transaction(function () use ($spend): void { + if (GenerationCreditTransaction::query()->where('idempotency_key', $this->refundIdempotencyKey($spend))->exists()) { + return; + } + + $balance = $this->balanceForUpdate($spend->user); + $balance->balance += abs($spend->amount); + $balance->save(); + + GenerationCreditTransaction::create([ + 'user_id' => $spend->user_id, + 'amount' => abs($spend->amount), + 'type' => GenerationCreditTransactionType::REFUND, + 'source_type' => $spend::class, + 'source_id' => $spend->getKey(), + 'idempotency_key' => $this->refundIdempotencyKey($spend), + 'metadata' => [ + 'feature' => 'meal_image_analysis', + ], + ]); + }); + } + + private function balanceForUpdate(User $user): GenerationCreditBalance + { + GenerationCreditBalance::query()->firstOrCreate([ + 'user_id' => $user->getKey(), + ], [ + 'balance' => 0, + ]); + + return GenerationCreditBalance::query() + ->whereKey($user->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function hasRewardedAdCreditForDate(User $user, string $date): bool + { + return GenerationCreditTransaction::query() + ->where('user_id', $user->getKey()) + ->where('daily_reward_key', $this->dailyRewardKey($date)) + ->exists(); + } + + private function dailyRewardKey(string $date): string + { + return 'ad:'.$date; + } + + private function rewardedAdUnitId(string $platform): ?string + { + $key = match ($platform) { + 'android' => 'android_ad_unit_id', + 'ios' => 'ios_ad_unit_id', + default => null, + }; + + if ($key === null) { + return null; + } + + $adUnitId = trim((string) config("admob.rewarded.{$key}", '')); + + return $adUnitId !== '' ? $adUnitId : null; + } + + private function hasAnyRewardedAdUnit(): bool + { + return $this->rewardedAdUnitId('android') !== null + || $this->rewardedAdUnitId('ios') !== null; + } + + private function validateAdUnit(mixed $adUnit): void + { + $expectedAdUnits = $this->expectedRewardedAdUnits(); + + if ($expectedAdUnits === []) { + return; + } + + if (! is_string($adUnit) || ! in_array($adUnit, $this->normalizedExpectedAdUnits($expectedAdUnits), true)) { + throw new InvalidRewardedAdCallback('Unexpected rewarded ad unit.'); + } + } + + /** + * @param array $expectedAdUnits + * @return array + */ + private function normalizedExpectedAdUnits(array $expectedAdUnits): array + { + $normalized = []; + + foreach ($expectedAdUnits as $expectedAdUnit) { + if (! is_string($expectedAdUnit) || trim($expectedAdUnit) === '') { + continue; + } + + $expectedAdUnit = trim($expectedAdUnit); + $normalized[] = $expectedAdUnit; + + if (str_contains($expectedAdUnit, '/')) { + $normalized[] = str($expectedAdUnit)->afterLast('/')->toString(); + } + } + + return array_values(array_unique($normalized)); + } + + /** + * @return array + */ + private function expectedRewardedAdUnits(): array + { + return array_values(array_filter([ + $this->rewardedAdUnitId('android'), + $this->rewardedAdUnitId('ios'), + ])); + } + + private function validateReward(?int $rewardAmount, ?string $rewardItem): void + { + $expectedRewardAmount = (int) config('admob.rewarded.reward_amount', 1); + $expectedRewardItem = trim((string) config('admob.rewarded.reward_item', '')); + + if ($rewardAmount !== null && $rewardAmount !== $expectedRewardAmount) { + throw new InvalidRewardedAdCallback('Unexpected rewarded ad amount.'); + } + + if ($expectedRewardItem !== '' && $rewardItem !== null && $rewardItem !== $expectedRewardItem) { + throw new InvalidRewardedAdCallback('Unexpected rewarded ad item.'); + } + } + + private function requiredString(array $payload, string $key): string + { + $value = $payload[$key] ?? null; + + if (! is_string($value) || $value === '') { + throw new InvalidRewardedAdCallback("Missing rewarded ad {$key}."); + } + + return $value; + } + + private function adRewardIdempotencyKey(string $transactionId): string + { + return 'admob:'.$transactionId; + } + + private function refundIdempotencyKey(GenerationCreditTransaction $spend): string + { + return 'refund:'.$spend->getKey(); + } + + private function now(): CarbonImmutable + { + return CarbonImmutable::now('UTC'); + } +} diff --git a/config/admob.php b/config/admob.php new file mode 100644 index 0000000..83ada95 --- /dev/null +++ b/config/admob.php @@ -0,0 +1,13 @@ + [ + 'android_ad_unit_id' => env('ADMOB_REWARDED_ANDROID_AD_UNIT_ID'), + 'ios_ad_unit_id' => env('ADMOB_REWARDED_IOS_AD_UNIT_ID'), + 'reward_amount' => 1, + 'reward_item' => 'generation_credit', + 'session_ttl_minutes' => 30, + 'public_keys_url' => 'https://www.gstatic.com/admob/reward/verifier-keys.json', + 'public_keys_cache_seconds' => 43200, + ], +]; diff --git a/config/deploy.yml b/config/deploy.yml index 362c3be..d592120 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -85,6 +85,7 @@ env: - DB_PASSWORD - MEILISEARCH_KEY - GEMINI_API_KEY + - ADMOB_REWARDED_ANDROID_AD_UNIT_ID - STRAVA_CLIENT_ID - STRAVA_CLIENT_SECRET - RESEND_API_KEY diff --git a/database/factories/GenerationCreditBalanceFactory.php b/database/factories/GenerationCreditBalanceFactory.php new file mode 100644 index 0000000..734bcfd --- /dev/null +++ b/database/factories/GenerationCreditBalanceFactory.php @@ -0,0 +1,28 @@ + + */ +class GenerationCreditBalanceFactory extends Factory +{ + protected $model = GenerationCreditBalance::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'balance' => 1, + ]; + } +} diff --git a/database/factories/GenerationCreditTransactionFactory.php b/database/factories/GenerationCreditTransactionFactory.php new file mode 100644 index 0000000..6662bdb --- /dev/null +++ b/database/factories/GenerationCreditTransactionFactory.php @@ -0,0 +1,45 @@ + + */ +class GenerationCreditTransactionFactory extends Factory +{ + protected $model = GenerationCreditTransaction::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'amount' => 1, + 'type' => GenerationCreditTransactionType::AD_REWARD, + 'source_type' => null, + 'source_id' => null, + 'idempotency_key' => fake()->uuid(), + 'daily_reward_key' => 'ad:'.now()->toDateString(), + 'metadata' => null, + ]; + } + + public function spend(): static + { + return $this->state(fn (array $attributes): array => [ + 'amount' => -1, + 'type' => GenerationCreditTransactionType::GENERATION_SPEND, + 'idempotency_key' => null, + 'daily_reward_key' => null, + ]); + } +} diff --git a/database/factories/RewardedAdSessionFactory.php b/database/factories/RewardedAdSessionFactory.php new file mode 100644 index 0000000..447f420 --- /dev/null +++ b/database/factories/RewardedAdSessionFactory.php @@ -0,0 +1,39 @@ + + */ +class RewardedAdSessionFactory extends Factory +{ + protected $model = RewardedAdSession::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'custom_data' => Str::random(64), + 'status' => RewardedAdSessionStatus::CREATED, + 'reward_date' => now()->toDateString(), + 'expires_at' => now()->addMinutes(30), + 'rewarded_at' => null, + 'ad_unit_id' => 'ca-app-pub-3940256099942544/5224354917', + 'admob_transaction_id' => null, + 'reward_amount' => null, + 'reward_item' => null, + 'callback_payload' => null, + ]; + } +} diff --git a/database/migrations/2026_05_26_000001_create_generation_credit_tables.php b/database/migrations/2026_05_26_000001_create_generation_credit_tables.php new file mode 100644 index 0000000..fdead38 --- /dev/null +++ b/database/migrations/2026_05_26_000001_create_generation_credit_tables.php @@ -0,0 +1,68 @@ +foreignUlid('user_id')->primary()->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('balance')->default(0); + $table->timestamps(); + }); + + Schema::create('generation_credit_transactions', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->integer('amount'); + $table->string('type', 32); + $table->string('source_type')->nullable(); + $table->string('source_id', 36)->nullable(); + $table->string('idempotency_key')->nullable()->unique(); + $table->string('daily_reward_key')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'daily_reward_key']); + $table->index(['user_id', 'created_at']); + $table->index(['type', 'created_at']); + $table->index(['source_type', 'source_id']); + }); + + Schema::create('rewarded_ad_sessions', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('custom_data', 128)->unique(); + $table->string('status', 32)->default(RewardedAdSessionStatus::CREATED->value); + $table->date('reward_date'); + $table->timestamp('expires_at'); + $table->timestamp('rewarded_at')->nullable(); + $table->string('ad_unit_id')->nullable(); + $table->string('admob_transaction_id')->nullable()->unique(); + $table->unsignedInteger('reward_amount')->nullable(); + $table->string('reward_item')->nullable(); + $table->json('callback_payload')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status', 'expires_at']); + $table->index(['user_id', 'reward_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('rewarded_ad_sessions'); + Schema::dropIfExists('generation_credit_transactions'); + Schema::dropIfExists('generation_credit_balances'); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index aad3be1..89a11b2 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -27,6 +27,11 @@ return [ 'meal_image_analysis' => [ 'failed' => 'Unable to analyze the image right now.', ], + 'generation_credits' => [ + 'insufficient' => 'Watch an ad to get one AI analysis.', + 'rewarded_ad_not_configured' => 'Rewarded ads are not configured yet.', + 'rewarded_ad_unavailable' => 'You already claimed today’s ad credit.', + ], 'legal_documents' => [ 'not_found' => 'Legal document not found.', ], diff --git a/lang/fr/api.php b/lang/fr/api.php index f76d6f5..203e411 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -27,6 +27,11 @@ return [ 'meal_image_analysis' => [ 'failed' => "Impossible d'analyser l'image pour le moment.", ], + 'generation_credits' => [ + 'insufficient' => 'Regarde une publicité pour obtenir une analyse IA.', + 'rewarded_ad_not_configured' => 'La publicité récompensée n’est pas encore configurée.', + 'rewarded_ad_unavailable' => 'Tu as déjà obtenu ton crédit publicité aujourd’hui.', + ], 'legal_documents' => [ 'not_found' => 'Document légal introuvable.', ], diff --git a/routes/api.php b/routes/api.php index 6e52671..1b6d5f1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,9 @@ json(['status' => 'ok']); }); +Route::get('admob/rewarded-ads/callback', AdMobRewardedAdCallbackController::class) + ->name('admob.rewarded-ads.callback'); + Route::prefix('legal-documents')->group(function (): void { Route::get('/', [LegalDocumentController::class, 'index'])->name('legal-documents.index'); Route::get('{slug}', [LegalDocumentController::class, 'show'])->name('legal-documents.show'); @@ -54,6 +59,10 @@ Route::prefix('legal-documents')->group(function (): void { Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token'); + Route::get('me/generation-credits', [GenerationCreditController::class, 'show'])->name('generation-credits.show'); + Route::post('me/rewarded-ad-sessions', [GenerationCreditController::class, 'storeRewardedAdSession']) + ->middleware('throttle:rewarded-ad') + ->name('rewarded-ad-sessions.store'); }); // Workouts diff --git a/tests/Feature/GenerationCreditsTest.php b/tests/Feature/GenerationCreditsTest.php new file mode 100644 index 0000000..042fdb0 --- /dev/null +++ b/tests/Feature/GenerationCreditsTest.php @@ -0,0 +1,230 @@ +set('admob.rewarded.android_ad_unit_id', 'ca-app-pub-test/android-rewarded'); + config()->set('admob.rewarded.ios_ad_unit_id', 'ca-app-pub-test/ios-rewarded'); + config()->set('admob.rewarded.reward_amount', 1); + config()->set('admob.rewarded.reward_item', 'generation_credit'); + + $this->app->instance( + AdMobServerSideVerificationVerifier::class, + new class extends AdMobServerSideVerificationVerifier + { + public function verify(Request $request): void + { + // + } + }, + ); +}); + +it('returns the generation credit status', function () { + $user = User::factory()->create(); + + GenerationCreditBalance::factory()->create([ + 'user_id' => $user->id, + 'balance' => 2, + ]); + + Sanctum::actingAs($user); + + $this + ->getJson('/api/me/generation-credits') + ->assertOk() + ->assertJsonPath('data.balance', 2) + ->assertJsonPath('data.canWatchRewardedAd', true) + ->assertJsonPath('data.rewardedAdConfigured', true); +}); + +it('creates a rewarded ad session for the current day', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $response = $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'android', + ]) + ->assertCreated() + ->assertJsonPath('data.adUnitId', 'ca-app-pub-test/android-rewarded') + ->assertJsonPath('data.userId', $user->id); + + $session = RewardedAdSession::query()->sole(); + + expect($session->custom_data)->toBe($response->json('data.customData')) + ->and($session->status)->toBe(RewardedAdSessionStatus::CREATED) + ->and($session->reward_date->toDateString())->toBe(now('UTC')->toDateString()); +}); + +it('reuses an active rewarded ad session', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $firstSessionId = $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'ios', + ]) + ->assertCreated() + ->json('data.id'); + + $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'ios', + ]) + ->assertOk() + ->assertJsonPath('data.id', $firstSessionId); + + expect(RewardedAdSession::query()->count())->toBe(1); +}); + +it('grants one generation credit from a verified rewarded ad callback', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $sessionPayload = $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'android', + ]) + ->assertCreated() + ->json('data'); + + $this + ->get('/api/admob/rewarded-ads/callback?'.http_build_query([ + 'ad_network' => '5450213213286189855', + 'ad_unit' => 'ca-app-pub-test/android-rewarded', + 'custom_data' => $sessionPayload['customData'], + 'reward_amount' => 1, + 'reward_item' => 'generation_credit', + 'timestamp' => now()->getTimestampMs(), + 'transaction_id' => 'reward-transaction-1', + 'user_id' => $user->id, + ])) + ->assertOk() + ->assertSee('ok'); + + $transaction = GenerationCreditTransaction::query()->sole(); + $session = RewardedAdSession::query()->sole(); + + expect($transaction->type)->toBe(GenerationCreditTransactionType::AD_REWARD) + ->and($transaction->amount)->toBe(1) + ->and($transaction->daily_reward_key)->toBe('ad:'.$session->reward_date->toDateString()) + ->and($session->status)->toBe(RewardedAdSessionStatus::REWARDED) + ->and(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(1); +}); + +it('accepts the numeric ad unit id returned by admob callbacks', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $sessionPayload = $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'android', + ]) + ->assertCreated() + ->json('data'); + + $this + ->get('/api/admob/rewarded-ads/callback?'.http_build_query([ + 'ad_unit' => 'android-rewarded', + 'custom_data' => $sessionPayload['customData'], + 'reward_amount' => 1, + 'reward_item' => 'generation_credit', + 'transaction_id' => 'reward-transaction-numeric-ad-unit', + 'user_id' => $user->id, + ])) + ->assertOk(); + + expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(1); +}); + +it('rejects unexpected rewarded ad units', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $sessionPayload = $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'android', + ]) + ->assertCreated() + ->json('data'); + + $this + ->get('/api/admob/rewarded-ads/callback?'.http_build_query([ + 'ad_unit' => 'unexpected-ad-unit', + 'custom_data' => $sessionPayload['customData'], + 'reward_amount' => 1, + 'reward_item' => 'generation_credit', + 'transaction_id' => 'reward-transaction-unexpected-ad-unit', + 'user_id' => $user->id, + ])) + ->assertBadRequest(); + + expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBeNull(); +}); + +it('does not grant more than one rewarded ad credit per day', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $firstSession = RewardedAdSession::factory()->create([ + 'user_id' => $user->id, + 'reward_date' => now('UTC')->toDateString(), + ]); + $secondSession = RewardedAdSession::factory()->create([ + 'user_id' => $user->id, + 'reward_date' => now('UTC')->toDateString(), + ]); + + foreach ([$firstSession, $secondSession] as $index => $session) { + $this + ->get('/api/admob/rewarded-ads/callback?'.http_build_query([ + 'ad_unit' => 'ca-app-pub-test/android-rewarded', + 'custom_data' => $session->custom_data, + 'reward_amount' => 1, + 'reward_item' => 'generation_credit', + 'transaction_id' => 'reward-transaction-'.$index, + 'user_id' => $user->id, + ])) + ->assertOk(); + } + + expect(GenerationCreditTransaction::query()->where('type', GenerationCreditTransactionType::AD_REWARD)->count())->toBe(1) + ->and(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(1) + ->and($secondSession->refresh()->status)->toBe(RewardedAdSessionStatus::REJECTED); +}); + +it('prevents new rewarded ad sessions after today reward is claimed', function () { + $user = User::factory()->create(); + + GenerationCreditTransaction::factory()->create([ + 'user_id' => $user->id, + 'daily_reward_key' => 'ad:'.now('UTC')->toDateString(), + ]); + + Sanctum::actingAs($user); + + $this + ->postJson('/api/me/rewarded-ad-sessions', [ + 'platform' => 'android', + ]) + ->assertUnprocessable() + ->assertJsonPath('message', __('api.generation_credits.rewarded_ad_unavailable')); +}); diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php index caa43a0..a106167 100644 --- a/tests/Feature/MealImageAnalysisTest.php +++ b/tests/Feature/MealImageAnalysisTest.php @@ -3,9 +3,12 @@ use App\Ai\Agents\MealImageAnalyzer; use App\Enums\AiUsageStatus; use App\Enums\AiUsageType; +use App\Enums\GenerationCreditTransactionType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Models\AiUsage; +use App\Models\GenerationCreditBalance; +use App\Models\GenerationCreditTransaction; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -25,8 +28,24 @@ function fakeMealAnalysisImage(): UploadedFile return new UploadedFile($path, 'meal.png', 'image/png', null, true); } +function grantMealAnalysisCredit(User $user): void +{ + GenerationCreditBalance::factory()->create([ + 'user_id' => $user->id, + 'balance' => 1, + ]); + + GenerationCreditTransaction::factory()->create([ + 'user_id' => $user->id, + 'amount' => 1, + 'type' => GenerationCreditTransactionType::AD_REWARD, + 'daily_reward_key' => 'ad:'.now()->toDateString(), + ]); +} + it('analyzes a meal image into a draft', function () { $user = User::factory()->create(); + grantMealAnalysisCredit($user); Sanctum::actingAs($user); @@ -106,10 +125,13 @@ it('analyzes a meal image into a draft', function () { ], ], ]); + + expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(0); }); it('records a failed meal image analysis attempt', function () { $user = User::factory()->create(); + grantMealAnalysisCredit($user); Sanctum::actingAs($user); Exceptions::fake(); @@ -139,6 +161,22 @@ it('records a failed meal image analysis attempt', function () { ]) ->and($usage->output)->toBeNull() ->and($usage->error_message)->toContain('Provider unavailable'); + + expect(GenerationCreditBalance::query()->whereKey($user->id)->value('balance'))->toBe(1); +}); + +it('requires a generation credit to analyze a meal image', function () { + Sanctum::actingAs(User::factory()->create()); + + $this + ->withHeader('Accept', 'application/json') + ->post('/api/meal-posts/analyze-image', [ + 'image' => fakeMealAnalysisImage(), + ]) + ->assertStatus(402) + ->assertJsonPath('message', __('api.generation_credits.insufficient')); + + expect(AiUsage::query()->count())->toBe(0); }); it('requires authentication to analyze a meal image', function () {