268 lines
9.9 KiB
Python
268 lines
9.9 KiB
Python
"""Apple In-App Purchase cabinet route."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.apple_iap import (
|
|
create_apple_transaction,
|
|
)
|
|
from app.database.crud.transaction import create_transaction as create_trans
|
|
from app.database.crud.user import lock_user_for_update
|
|
from app.database.models import PaymentMethod, TransactionType, User
|
|
from app.external.apple_iap import AppleIAPService
|
|
from app.utils.user_utils import format_referrer_info
|
|
|
|
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
|
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter(tags=['Cabinet Apple IAP'])
|
|
|
|
|
|
def get_apple_iap_service() -> AppleIAPService:
|
|
return AppleIAPService()
|
|
|
|
|
|
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
|
|
async def apple_purchase(
|
|
request: ApplePurchaseRequest,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
|
|
):
|
|
"""Verify an Apple In-App Purchase and credit the user's balance.
|
|
|
|
The iOS app calls this endpoint after a successful StoreKit transaction.
|
|
If the backend returns success=false, the iOS app will NOT finish the
|
|
transaction and will retry on next launch.
|
|
"""
|
|
if not settings.is_apple_iap_enabled():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Apple In-App Purchase is not enabled',
|
|
)
|
|
|
|
# Validate product ID
|
|
products = settings.get_apple_iap_products()
|
|
if request.product_id not in products:
|
|
logger.warning(
|
|
'Unknown Apple product ID',
|
|
product_id=request.product_id,
|
|
user_id=user.id,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Unknown product ID',
|
|
)
|
|
|
|
amount_kopeks = products[request.product_id]
|
|
|
|
# Verify transaction with Apple Server API (no DB lock needed).
|
|
# verify_transaction automatically falls back Sandbox<->Production.
|
|
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
|
|
if not txn_info:
|
|
logger.warning(
|
|
'Apple transaction verification failed',
|
|
transaction_id=request.transaction_id,
|
|
user_id=user.id,
|
|
)
|
|
return ApplePurchaseResponse(success=False)
|
|
|
|
# Validate transaction fields
|
|
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
|
|
if validation_error:
|
|
logger.warning(
|
|
'Apple transaction validation failed',
|
|
error=validation_error,
|
|
transaction_id=request.transaction_id,
|
|
user_id=user.id,
|
|
)
|
|
return ApplePurchaseResponse(success=False)
|
|
|
|
# FIX 4: appAccountToken is mandatory -- reject if missing
|
|
app_account_token = txn_info.get('appAccountToken')
|
|
if not app_account_token:
|
|
logger.warning(
|
|
'Apple appAccountToken missing -- rejecting transaction',
|
|
transaction_id=request.transaction_id,
|
|
user_id=user.id,
|
|
)
|
|
return ApplePurchaseResponse(success=False)
|
|
|
|
if app_account_token != str(user.id):
|
|
logger.warning(
|
|
'Apple appAccountToken mismatch -- possible replay',
|
|
expected=str(user.id),
|
|
received=app_account_token,
|
|
transaction_id=request.transaction_id,
|
|
user_id=user.id,
|
|
)
|
|
return ApplePurchaseResponse(success=False)
|
|
|
|
# Detect sandbox transactions -- store actual environment from Apple's response
|
|
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
|
is_sandbox = actual_environment == 'Sandbox'
|
|
|
|
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
|
# Sandbox transaction on a production server (e.g. App Review).
|
|
# Record it for audit but do NOT credit real balance.
|
|
logger.info(
|
|
'Apple sandbox transaction on production -- storing without balance credit',
|
|
transaction_id=request.transaction_id,
|
|
product_id=request.product_id,
|
|
user_id=user.id,
|
|
)
|
|
try:
|
|
async with db.begin_nested():
|
|
await create_apple_transaction(
|
|
db=db,
|
|
user_id=user.id,
|
|
transaction_id=request.transaction_id,
|
|
original_transaction_id=txn_info.get('originalTransactionId'),
|
|
product_id=request.product_id,
|
|
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
|
amount_kopeks=amount_kopeks,
|
|
environment='Sandbox',
|
|
)
|
|
except IntegrityError:
|
|
pass # already stored
|
|
await db.commit()
|
|
return ApplePurchaseResponse(success=True)
|
|
|
|
# Atomically insert transaction record -- unique constraint on transaction_id
|
|
# prevents double-spend even under concurrent requests.
|
|
apple_txn = None
|
|
try:
|
|
async with db.begin_nested():
|
|
apple_txn = await create_apple_transaction(
|
|
db=db,
|
|
user_id=user.id,
|
|
transaction_id=request.transaction_id,
|
|
original_transaction_id=txn_info.get('originalTransactionId'),
|
|
product_id=request.product_id,
|
|
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
|
amount_kopeks=amount_kopeks,
|
|
environment=actual_environment,
|
|
)
|
|
except IntegrityError:
|
|
logger.info(
|
|
'Apple transaction already processed (idempotent)',
|
|
transaction_id=request.transaction_id,
|
|
user_id=user.id,
|
|
)
|
|
return ApplePurchaseResponse(success=True)
|
|
|
|
# Create financial transaction record
|
|
transaction = await create_trans(
|
|
db=db,
|
|
user_id=user.id,
|
|
type=TransactionType.DEPOSIT,
|
|
amount_kopeks=amount_kopeks,
|
|
description=f'Пополнение через Apple IAP: {request.product_id}',
|
|
payment_method=PaymentMethod.APPLE_IAP,
|
|
external_id=request.transaction_id,
|
|
is_completed=True,
|
|
commit=False,
|
|
)
|
|
|
|
# FIX 9: Link AppleTransaction to financial Transaction via FK
|
|
if apple_txn and transaction:
|
|
apple_txn.transaction_id_fk = transaction.id
|
|
apple_txn.updated_at = datetime.now(UTC)
|
|
|
|
# Lock user row and credit balance
|
|
user = await lock_user_for_update(db, user)
|
|
old_balance = user.balance_kopeks
|
|
was_first_topup = not user.has_made_first_topup
|
|
|
|
user.balance_kopeks += amount_kopeks
|
|
# FIX 10: Update user.updated_at when modifying balance
|
|
user.updated_at = datetime.now(UTC)
|
|
|
|
promo_group = user.get_primary_promo_group()
|
|
subscription = getattr(user, 'subscription', None)
|
|
referrer_info = format_referrer_info(user)
|
|
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
|
|
|
await db.commit()
|
|
|
|
# --- Post-payment side-effects (after atomic commit) ---
|
|
|
|
from app.database.crud.transaction import emit_transaction_side_effects
|
|
|
|
try:
|
|
await emit_transaction_side_effects(
|
|
db,
|
|
transaction,
|
|
amount_kopeks=amount_kopeks,
|
|
user_id=user.id,
|
|
type=TransactionType.DEPOSIT,
|
|
payment_method=PaymentMethod.APPLE_IAP,
|
|
external_id=request.transaction_id,
|
|
)
|
|
except Exception as error:
|
|
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
|
|
|
|
try:
|
|
from app.services.referral_service import process_referral_topup
|
|
|
|
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
|
|
except Exception as error:
|
|
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
|
|
|
|
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
|
user.has_made_first_topup = True
|
|
await db.commit()
|
|
|
|
await db.refresh(user)
|
|
|
|
# Admin notification + cart auto-purchase
|
|
try:
|
|
from app.bot_factory import create_bot
|
|
|
|
bot = create_bot()
|
|
try:
|
|
from app.services.admin_notification_service import AdminNotificationService
|
|
|
|
notification_service = AdminNotificationService(bot)
|
|
await notification_service.send_balance_topup_notification(
|
|
user,
|
|
transaction,
|
|
old_balance,
|
|
topup_status=topup_status,
|
|
referrer_info=referrer_info,
|
|
subscription=subscription,
|
|
promo_group=promo_group,
|
|
db=db,
|
|
)
|
|
except Exception as error:
|
|
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
|
|
|
|
try:
|
|
from app.services.payment.common import send_cart_notification_after_topup
|
|
|
|
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
|
|
except Exception as error:
|
|
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
|
|
finally:
|
|
await bot.session.close()
|
|
except Exception as error:
|
|
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
|
|
|
|
logger.info(
|
|
'Apple IAP purchase credited',
|
|
transaction_id=request.transaction_id,
|
|
product_id=request.product_id,
|
|
amount_kopeks=amount_kopeks,
|
|
user_id=user.id,
|
|
)
|
|
|
|
return ApplePurchaseResponse(success=True)
|