feat: ads config
Laravel CI-CD / Tests Unitaires (push) Successful in 1m33s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 2m14s Details

This commit is contained in:
Leon Morival 2026-05-26 15:54:49 +02:00
parent b069730652
commit f35ea4ca7e
30 changed files with 1337 additions and 2 deletions

View File

@ -72,6 +72,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
OLLAMA_API_KEY= OLLAMA_API_KEY=
GEMINI_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_NAME="Daily Meal AI"
AI_MEAL_USER_EMAIL=ai@daily-meal.local AI_MEAL_USER_EMAIL=ai@daily-meal.local
AI_MEAL_IMAGE_PATH=meal-posts/ai-generated AI_MEAL_IMAGE_PATH=meal-posts/ai-generated

View File

@ -50,6 +50,7 @@ jobs:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }} MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_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_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
@ -62,6 +63,7 @@ jobs:
run: | run: |
set -eu set -eu
: "${GEMINI_API_KEY:?GEMINI_API_KEY secret is required}" : "${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_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID secret is required}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY secret is required}" : "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY secret is required}"
@ -82,6 +84,7 @@ jobs:
-e DB_PASSWORD \ -e DB_PASSWORD \
-e MEILISEARCH_KEY \ -e MEILISEARCH_KEY \
-e GEMINI_API_KEY \ -e GEMINI_API_KEY \
-e ADMOB_REWARDED_ANDROID_AD_UNIT_ID \
-e SSH_HOST \ -e SSH_HOST \
-e SSH_USER \ -e SSH_USER \
-e SSH_PRIVATE_KEY \ -e SSH_PRIVATE_KEY \
@ -127,6 +130,7 @@ jobs:
DB_PASSWORD=$DB_PASSWORD DB_PASSWORD=$DB_PASSWORD
MEILISEARCH_KEY=$MEILISEARCH_KEY MEILISEARCH_KEY=$MEILISEARCH_KEY
GEMINI_API_KEY=$GEMINI_API_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 SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY
STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID
STRAVA_CLIENT_SECRET=$STRAVA_CLIENT_SECRET STRAVA_CLIENT_SECRET=$STRAVA_CLIENT_SECRET

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum GenerationCreditTransactionType: string
{
case AD_REWARD = 'ad_reward';
case GENERATION_SPEND = 'generation_spend';
case PURCHASE = 'purchase';
case REFUND = 'refund';
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum RewardedAdSessionStatus: string
{
case CREATED = 'created';
case EXPIRED = 'expired';
case REJECTED = 'rejected';
case REWARDED = 'rewarded';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class InsufficientGenerationCredits extends RuntimeException
{
//
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class InvalidRewardedAdCallback extends RuntimeException
{
//
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class MissingRewardedAdUnit extends RuntimeException
{
//
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class RewardedAdUnavailable extends RuntimeException
{
//
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\InvalidRewardedAdCallback;
use App\Services\AdMobServerSideVerificationVerifier;
use App\Services\GenerationCreditService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Throwable;
class AdMobRewardedAdCallbackController extends Controller
{
public function __invoke(
Request $request,
AdMobServerSideVerificationVerifier $verifier,
GenerationCreditService $credits,
): Response {
try {
$verifier->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');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\MissingRewardedAdUnit;
use App\Exceptions\RewardedAdUnavailable;
use App\Http\Requests\RewardedAdSessionRequest;
use App\Models\RewardedAdSession;
use App\Services\GenerationCreditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GenerationCreditController extends Controller
{
public function show(Request $request, GenerationCreditService $credits): JsonResponse
{
return response()->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(),
];
}
}

View File

@ -6,8 +6,10 @@ use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\AiUsageType; use App\Enums\AiUsageType;
use App\Enums\IngredientUnit; use App\Enums\IngredientUnit;
use App\Enums\MealPostType; use App\Enums\MealPostType;
use App\Exceptions\InsufficientGenerationCredits;
use App\Http\Requests\MealImageAnalysisRequest; use App\Http\Requests\MealImageAnalysisRequest;
use App\Services\AiUsageRecorder; use App\Services\AiUsageRecorder;
use App\Services\GenerationCreditService;
use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -17,11 +19,22 @@ use Throwable;
class MealImageAnalysisController extends Controller 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'); $image = $request->file('image');
$input = $this->analysisInput($image); $input = $this->analysisInput($image);
try {
$spentCredit = $generationCredits->spendForGeneration($request->user());
} catch (InsufficientGenerationCredits $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 402);
}
try { try {
$response = MealImageAnalyzer::make()->prompt( $response = MealImageAnalyzer::make()->prompt(
$this->prompt(), $this->prompt(),
@ -30,6 +43,7 @@ class MealImageAnalysisController extends Controller
); );
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
$generationCredits->refundGenerationSpend($spentCredit);
$aiUsageRecorder->failed( $aiUsageRecorder->failed(
user: $request->user(), user: $request->user(),

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RewardedAdSessionRequest extends FormRequest
{
public function rules(): array
{
return [
'platform' => ['required', 'string', 'in:android,ios'],
];
}
public function authorize(): bool
{
return true;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GenerationCreditBalance extends Model
{
/** @use HasFactory<\Database\Factories\GenerationCreditBalanceFactory> */
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',
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\GenerationCreditTransactionType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class GenerationCreditTransaction extends Model
{
/** @use HasFactory<\Database\Factories\GenerationCreditTransactionFactory> */
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',
];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\RewardedAdSessionStatus;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class RewardedAdSession extends Model
{
/** @use HasFactory<\Database\Factories\RewardedAdSessionFactory> */
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',
];
}
}

View File

@ -115,6 +115,21 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
return $this->hasMany(AiUsage::class); 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 public function workouts(): HasMany
{ {
return $this->hasMany(WorkoutSessions::class); return $this->hasMany(WorkoutSessions::class);

View File

@ -136,6 +136,11 @@ class AppServiceProvider extends ServiceProvider
fn (Request $request): Limit => Limit::perMinute(30)->by($this->userRateLimitKey($request)) 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( RateLimiter::for(
'notifications', 'notifications',
fn (Request $request): Limit => Limit::perMinute(3)->by($this->userRateLimitKey($request)) fn (Request $request): Limit => Limit::perMinute(3)->by($this->userRateLimitKey($request))

View File

@ -0,0 +1,136 @@
<?php
namespace App\Services;
use App\Exceptions\InvalidRewardedAdCallback;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class AdMobServerSideVerificationVerifier
{
public function verify(Request $request): void
{
$queryString = (string) $request->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<string, string>
*/
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';
}
}

View File

@ -0,0 +1,364 @@
<?php
namespace App\Services;
use App\Enums\GenerationCreditTransactionType;
use App\Enums\RewardedAdSessionStatus;
use App\Exceptions\InsufficientGenerationCredits;
use App\Exceptions\InvalidRewardedAdCallback;
use App\Exceptions\MissingRewardedAdUnit;
use App\Exceptions\RewardedAdUnavailable;
use App\Models\GenerationCreditBalance;
use App\Models\GenerationCreditTransaction;
use App\Models\RewardedAdSession;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class GenerationCreditService
{
public function balance(User $user): int
{
return (int) (GenerationCreditBalance::query()
->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<int, mixed> $expectedAdUnits
* @return array<int, string>
*/
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<int, string>
*/
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');
}
}

13
config/admob.php Normal file
View File

@ -0,0 +1,13 @@
<?php
return [
'rewarded' => [
'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,
],
];

View File

@ -85,6 +85,7 @@ env:
- DB_PASSWORD - DB_PASSWORD
- MEILISEARCH_KEY - MEILISEARCH_KEY
- GEMINI_API_KEY - GEMINI_API_KEY
- ADMOB_REWARDED_ANDROID_AD_UNIT_ID
- STRAVA_CLIENT_ID - STRAVA_CLIENT_ID
- STRAVA_CLIENT_SECRET - STRAVA_CLIENT_SECRET
- RESEND_API_KEY - RESEND_API_KEY

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\GenerationCreditBalance;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<GenerationCreditBalance>
*/
class GenerationCreditBalanceFactory extends Factory
{
protected $model = GenerationCreditBalance::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'balance' => 1,
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Enums\GenerationCreditTransactionType;
use App\Models\GenerationCreditTransaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<GenerationCreditTransaction>
*/
class GenerationCreditTransactionFactory extends Factory
{
protected $model = GenerationCreditTransaction::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Enums\RewardedAdSessionStatus;
use App\Models\RewardedAdSession;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<RewardedAdSession>
*/
class RewardedAdSessionFactory extends Factory
{
protected $model = RewardedAdSession::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,68 @@
<?php
use App\Enums\RewardedAdSessionStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('generation_credit_balances', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -27,6 +27,11 @@ return [
'meal_image_analysis' => [ 'meal_image_analysis' => [
'failed' => 'Unable to analyze the image right now.', '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 todays ad credit.',
],
'legal_documents' => [ 'legal_documents' => [
'not_found' => 'Legal document not found.', 'not_found' => 'Legal document not found.',
], ],

View File

@ -27,6 +27,11 @@ return [
'meal_image_analysis' => [ 'meal_image_analysis' => [
'failed' => "Impossible d'analyser l'image pour le moment.", '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 nest pas encore configurée.',
'rewarded_ad_unavailable' => 'Tu as déjà obtenu ton crédit publicité aujourdhui.',
],
'legal_documents' => [ 'legal_documents' => [
'not_found' => 'Document légal introuvable.', 'not_found' => 'Document légal introuvable.',
], ],

View File

@ -1,7 +1,9 @@
<?php <?php
use App\Http\Controllers\AdMobRewardedAdCallbackController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\DeviceTokenController;
use App\Http\Controllers\GenerationCreditController;
use App\Http\Controllers\LegalDocumentController; use App\Http\Controllers\LegalDocumentController;
use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
@ -46,6 +48,9 @@ Route::get('/health', function () {
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
}); });
Route::get('admob/rewarded-ads/callback', AdMobRewardedAdCallbackController::class)
->name('admob.rewarded-ads.callback');
Route::prefix('legal-documents')->group(function (): void { Route::prefix('legal-documents')->group(function (): void {
Route::get('/', [LegalDocumentController::class, 'index'])->name('legal-documents.index'); Route::get('/', [LegalDocumentController::class, 'index'])->name('legal-documents.index');
Route::get('{slug}', [LegalDocumentController::class, 'show'])->name('legal-documents.show'); 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::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token');
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->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 // Workouts

View File

@ -0,0 +1,230 @@
<?php
use App\Enums\GenerationCreditTransactionType;
use App\Enums\RewardedAdSessionStatus;
use App\Models\GenerationCreditBalance;
use App\Models\GenerationCreditTransaction;
use App\Models\RewardedAdSession;
use App\Models\User;
use App\Services\AdMobServerSideVerificationVerifier;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config()->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'));
});

View File

@ -3,9 +3,12 @@
use App\Ai\Agents\MealImageAnalyzer; use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\AiUsageStatus; use App\Enums\AiUsageStatus;
use App\Enums\AiUsageType; use App\Enums\AiUsageType;
use App\Enums\GenerationCreditTransactionType;
use App\Enums\IngredientUnit; use App\Enums\IngredientUnit;
use App\Enums\MealPostType; use App\Enums\MealPostType;
use App\Models\AiUsage; use App\Models\AiUsage;
use App\Models\GenerationCreditBalance;
use App\Models\GenerationCreditTransaction;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -25,8 +28,24 @@ function fakeMealAnalysisImage(): UploadedFile
return new UploadedFile($path, 'meal.png', 'image/png', null, true); 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 () { it('analyzes a meal image into a draft', function () {
$user = User::factory()->create(); $user = User::factory()->create();
grantMealAnalysisCredit($user);
Sanctum::actingAs($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 () { it('records a failed meal image analysis attempt', function () {
$user = User::factory()->create(); $user = User::factory()->create();
grantMealAnalysisCredit($user);
Sanctum::actingAs($user); Sanctum::actingAs($user);
Exceptions::fake(); Exceptions::fake();
@ -139,6 +161,22 @@ it('records a failed meal image analysis attempt', function () {
]) ])
->and($usage->output)->toBeNull() ->and($usage->output)->toBeNull()
->and($usage->error_message)->toContain('Provider unavailable'); ->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 () { it('requires authentication to analyze a meal image', function () {