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'); } }