feat: Apple IAP integration with security hardening
This commit is contained in:
@@ -69,6 +69,13 @@ from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
@@ -81,6 +88,11 @@ router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Apple In-App Purchase schemas for cabinet."""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ApplePurchaseRequest(BaseModel):
|
||||
"""Request to verify and credit an Apple IAP transaction."""
|
||||
|
||||
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
|
||||
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
|
||||
|
||||
@field_validator('transaction_id')
|
||||
@classmethod
|
||||
def transaction_id_must_be_numeric(cls, v: str) -> str:
|
||||
if not v.isdigit():
|
||||
raise ValueError('transaction_id must contain only digits')
|
||||
return v
|
||||
|
||||
|
||||
class ApplePurchaseResponse(BaseModel):
|
||||
"""Response indicating whether the purchase was successfully credited."""
|
||||
|
||||
success: bool
|
||||
@@ -616,6 +616,19 @@ class Settings(BaseSettings):
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
# Apple In-App Purchase
|
||||
APPLE_IAP_ENABLED: bool = False
|
||||
APPLE_IAP_KEY_ID: str | None = None
|
||||
APPLE_IAP_ISSUER_ID: str | None = None
|
||||
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
|
||||
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
|
||||
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
|
||||
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
|
||||
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
|
||||
APPLE_IAP_PRODUCTS: str = (
|
||||
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
|
||||
)
|
||||
|
||||
# PayPear (paypear.ru)
|
||||
PAYPEAR_ENABLED: bool = False
|
||||
PAYPEAR_SHOP_ID: str | None = None
|
||||
@@ -2027,6 +2040,34 @@ class Settings(BaseSettings):
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_apple_iap_enabled(self) -> bool:
|
||||
return (
|
||||
self.APPLE_IAP_ENABLED
|
||||
and self.APPLE_IAP_KEY_ID is not None
|
||||
and self.APPLE_IAP_ISSUER_ID is not None
|
||||
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
|
||||
)
|
||||
|
||||
def get_apple_iap_products(self) -> dict[str, int]:
|
||||
"""Return mapping of Apple product ID -> kopeks amount."""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
return _json.loads(self.APPLE_IAP_PRODUCTS)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_apple_iap_private_key(self) -> str | None:
|
||||
"""Return the .p8 private key contents."""
|
||||
if self.APPLE_IAP_PRIVATE_KEY:
|
||||
return self.APPLE_IAP_PRIVATE_KEY
|
||||
if self.APPLE_IAP_PRIVATE_KEY_PATH:
|
||||
try:
|
||||
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def is_paypear_enabled(self) -> bool:
|
||||
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AppleTransaction
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_apple_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_id: str,
|
||||
product_id: str,
|
||||
bundle_id: str,
|
||||
amount_kopeks: int,
|
||||
environment: str,
|
||||
original_transaction_id: str | None = None,
|
||||
transaction_id_fk: int | None = None,
|
||||
) -> AppleTransaction:
|
||||
apple_txn = AppleTransaction(
|
||||
user_id=user_id,
|
||||
transaction_id=transaction_id,
|
||||
original_transaction_id=original_transaction_id,
|
||||
product_id=product_id,
|
||||
bundle_id=bundle_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=environment,
|
||||
status='verified',
|
||||
is_paid=True,
|
||||
paid_at=datetime.now(UTC),
|
||||
transaction_id_fk=transaction_id_fk,
|
||||
)
|
||||
|
||||
db.add(apple_txn)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Создана Apple транзакция',
|
||||
transaction_id=transaction_id,
|
||||
product_id=product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
return apple_txn
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_apple_transaction_by_transaction_id_for_update(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> AppleTransaction | None:
|
||||
"""Get apple transaction with FOR UPDATE lock for safe concurrent access."""
|
||||
result = await db.execute(
|
||||
select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
|
||||
"""Mark an Apple transaction as refunded. Returns the transaction or None if not found."""
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id)
|
||||
if not apple_txn:
|
||||
return None
|
||||
|
||||
apple_txn.status = 'refunded'
|
||||
apple_txn.refunded_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(apple_txn)
|
||||
|
||||
logger.info(
|
||||
'Apple транзакция помечена как возврат',
|
||||
transaction_id=transaction_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return apple_txn
|
||||
@@ -162,6 +162,7 @@ class PaymentMethod(Enum):
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
APPLE_IAP = 'apple_iap'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
@@ -329,6 +330,41 @@ class CryptoBotPayment(Base):
|
||||
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
|
||||
|
||||
|
||||
class AppleTransaction(Base):
|
||||
__tablename__ = 'apple_transactions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
transaction_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
original_transaction_id = Column(String(64), nullable=True, index=True)
|
||||
product_id = Column(String(128), nullable=False)
|
||||
bundle_id = Column(String(255), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
environment = Column(String(16), nullable=False)
|
||||
|
||||
status = Column(String(50), default='verified')
|
||||
is_paid = Column(Boolean, default=True)
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
refunded_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship('User', backref='apple_transactions')
|
||||
transaction = relationship('Transaction', backref='apple_transaction')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = 'heleket_payments'
|
||||
|
||||
|
||||
Vendored
+411
@@ -0,0 +1,411 @@
|
||||
"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import structlog
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
|
||||
# https://www.apple.com/certificateauthority/
|
||||
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
|
||||
|
||||
# Apple WWDR Intermediate Certificate OID
|
||||
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
|
||||
|
||||
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
|
||||
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
|
||||
|
||||
|
||||
class AppleIAPService:
|
||||
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
|
||||
|
||||
def _get_base_url(self, environment: str | None = None) -> str:
|
||||
env = environment or settings.APPLE_IAP_ENVIRONMENT
|
||||
if env == 'Sandbox':
|
||||
return SANDBOX_BASE_URL
|
||||
return PRODUCTION_BASE_URL
|
||||
|
||||
def _generate_jwt(self) -> str:
|
||||
"""Generate a fresh ES256 JWT for App Store Server API authentication.
|
||||
|
||||
Apple recommends generating a new JWT for each request.
|
||||
"""
|
||||
private_key = settings.get_apple_iap_private_key()
|
||||
if not private_key:
|
||||
raise ValueError('Apple IAP private key is not configured')
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
'iss': settings.APPLE_IAP_ISSUER_ID,
|
||||
'iat': now,
|
||||
'exp': now + 3600,
|
||||
'aud': 'appstoreconnect-v1',
|
||||
'bid': settings.APPLE_IAP_BUNDLE_ID,
|
||||
}
|
||||
headers = {
|
||||
'alg': 'ES256',
|
||||
'kid': settings.APPLE_IAP_KEY_ID,
|
||||
'typ': 'JWT',
|
||||
}
|
||||
|
||||
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
|
||||
|
||||
async def _fetch_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
base_url: str,
|
||||
) -> httpx.Response | None:
|
||||
"""Send a GET request to Apple's transaction lookup endpoint."""
|
||||
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
return await client.get(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
async def verify_transaction(
|
||||
self,
|
||||
transaction_id: str,
|
||||
environment: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Verify a transaction with Apple's App Store Server API.
|
||||
|
||||
Follows Apple's recommendation: if the configured environment returns
|
||||
a 4xx error, retries against the opposite environment. This ensures
|
||||
Sandbox purchases made during App Review still verify when the server
|
||||
is configured for Production.
|
||||
"""
|
||||
primary_url = self._get_base_url(environment)
|
||||
# Determine fallback URL (opposite environment)
|
||||
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
|
||||
|
||||
for attempt_url in (primary_url, fallback_url):
|
||||
response = await self._fetch_transaction(transaction_id, attempt_url)
|
||||
if response is None:
|
||||
return None # network error -- don't retry
|
||||
|
||||
if response.status_code == 200:
|
||||
return self._parse_transaction_response(response, transaction_id)
|
||||
|
||||
# 4xx on primary -> retry on fallback per Apple docs
|
||||
if 400 <= response.status_code < 500 and attempt_url == primary_url:
|
||||
logger.info(
|
||||
'Apple API returned 4xx on primary env, retrying fallback',
|
||||
status=response.status_code,
|
||||
primary=attempt_url,
|
||||
fallback=fallback_url,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Log the final failure
|
||||
self._log_api_error(response, transaction_id)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _parse_transaction_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
transaction_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract and verify signedTransactionInfo from a 200 response."""
|
||||
data = response.json()
|
||||
signed_transaction_info = data.get('signedTransactionInfo')
|
||||
if signed_transaction_info:
|
||||
decoded = self._verify_and_decode_jws(signed_transaction_info)
|
||||
if decoded:
|
||||
return decoded
|
||||
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
|
||||
return None
|
||||
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
|
||||
if response.status_code == 404:
|
||||
logger.warning('Apple transaction not found', transaction_id=transaction_id)
|
||||
elif response.status_code == 401:
|
||||
logger.error('Apple API auth failed -- check key configuration')
|
||||
elif response.status_code == 429:
|
||||
logger.warning('Apple API rate limit exceeded')
|
||||
else:
|
||||
logger.error(
|
||||
'Apple API unexpected status',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
|
||||
"""Validate decoded transaction info fields.
|
||||
|
||||
Returns None if valid, or an error message string.
|
||||
"""
|
||||
bundle_id = txn_info.get('bundleId')
|
||||
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
|
||||
return f'Bundle ID mismatch: {bundle_id}'
|
||||
|
||||
product_id = txn_info.get('productId')
|
||||
if product_id != expected_product_id:
|
||||
return f'Product ID mismatch: {product_id} != {expected_product_id}'
|
||||
|
||||
txn_type = txn_info.get('type')
|
||||
if txn_type != 'Consumable':
|
||||
return f'Unexpected transaction type: {txn_type}'
|
||||
|
||||
if txn_info.get('revocationDate'):
|
||||
return f'Transaction was revoked at {txn_info["revocationDate"]}'
|
||||
|
||||
return None
|
||||
|
||||
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
|
||||
|
||||
Returns the decoded payload dict, or None if verification fails.
|
||||
Used for both outer notification payloads and inner signed data
|
||||
(signedTransactionInfo, signedRenewalInfo).
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
logger.warning('Invalid JWS format: expected 3 parts')
|
||||
return None
|
||||
|
||||
# Decode header to get x5c chain
|
||||
header_b64 = parts[0]
|
||||
padding = 4 - len(header_b64) % 4
|
||||
if padding != 4:
|
||||
header_b64 += '=' * padding
|
||||
header_json = base64.urlsafe_b64decode(header_b64)
|
||||
header = json.loads(header_json)
|
||||
|
||||
x5c_chain = header.get('x5c', [])
|
||||
if not x5c_chain:
|
||||
logger.warning('No x5c certificate chain in JWS header')
|
||||
return None
|
||||
|
||||
# Verify the certificate chain
|
||||
if not self._verify_x5c_chain(x5c_chain):
|
||||
logger.warning('x5c certificate chain verification failed')
|
||||
return None
|
||||
|
||||
# Verify the signature using the leaf certificate
|
||||
leaf_cert_der = base64.b64decode(x5c_chain[0])
|
||||
leaf_cert = load_der_x509_certificate(leaf_cert_der)
|
||||
public_key = leaf_cert.public_key()
|
||||
|
||||
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
|
||||
signature_b64 = parts[2]
|
||||
sig_padding = 4 - len(signature_b64) % 4
|
||||
if sig_padding != 4:
|
||||
signature_b64 += '=' * sig_padding
|
||||
signature = base64.urlsafe_b64decode(signature_b64)
|
||||
|
||||
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
|
||||
if len(signature) == 64:
|
||||
r = int.from_bytes(signature[:32], 'big')
|
||||
s = int.from_bytes(signature[32:], 'big')
|
||||
signature = asym_utils.encode_dss_signature(r, s)
|
||||
|
||||
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
|
||||
|
||||
# Signature valid -- decode payload
|
||||
return self._decode_jws_payload(jws_token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('JWS verification failed', error=str(e), exc_info=True)
|
||||
return None
|
||||
|
||||
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
|
||||
"""Verify and decode an App Store Server Notification V2 payload.
|
||||
|
||||
Verifies the JWS x5c certificate chain, then returns the decoded payload.
|
||||
Returns None if verification fails.
|
||||
"""
|
||||
return self._verify_and_decode_jws(signed_payload)
|
||||
|
||||
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
|
||||
"""Verify the x5c certificate chain ends with an Apple Root CA."""
|
||||
try:
|
||||
if len(x5c_chain) < 2:
|
||||
logger.warning('x5c chain too short', length=len(x5c_chain))
|
||||
return False
|
||||
|
||||
certs = []
|
||||
for cert_b64 in x5c_chain:
|
||||
cert_der = base64.b64decode(cert_b64)
|
||||
cert = load_der_x509_certificate(cert_der)
|
||||
certs.append(cert)
|
||||
|
||||
# Check certificate validity periods
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
for i, cert in enumerate(certs):
|
||||
if now < cert.not_valid_before_utc:
|
||||
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
|
||||
return False
|
||||
if now > cert.not_valid_after_utc:
|
||||
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
|
||||
return False
|
||||
|
||||
# Pin the root (last) certificate by SHA-256 fingerprint
|
||||
root_cert = certs[-1]
|
||||
root_fingerprint = root_cert.fingerprint(SHA256())
|
||||
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
|
||||
logger.warning(
|
||||
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
|
||||
got=root_fingerprint.hex(),
|
||||
)
|
||||
return False
|
||||
|
||||
# Verify each certificate is signed by the next one in the chain
|
||||
for i in range(len(certs) - 1):
|
||||
child = certs[i]
|
||||
parent = certs[i + 1]
|
||||
parent_public_key = parent.public_key()
|
||||
parent_public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
ec.ECDSA(child.signature_hash_algorithm),
|
||||
)
|
||||
|
||||
# FIX 3: Validate Apple WWDR intermediate OID
|
||||
# The intermediate cert (index 1) must contain the Apple WWDR OID
|
||||
# to ensure it is a genuine Apple WWDR intermediate certificate.
|
||||
if len(certs) >= 2:
|
||||
intermediate_cert = certs[1]
|
||||
try:
|
||||
# Check for the Apple WWDR OID in certificate extensions
|
||||
found_apple_oid = False
|
||||
for ext in intermediate_cert.extensions:
|
||||
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
|
||||
for policy in ext.value:
|
||||
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
|
||||
found_apple_oid = True
|
||||
break
|
||||
if found_apple_oid:
|
||||
break
|
||||
if not found_apple_oid:
|
||||
logger.warning(
|
||||
'Intermediate cert missing Apple WWDR OID',
|
||||
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
|
||||
)
|
||||
return False
|
||||
except x509.ExtensionNotFound:
|
||||
logger.warning('Intermediate cert has no certificate policies extension')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('x5c chain verification error', error=str(e))
|
||||
return False
|
||||
|
||||
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
|
||||
"""Decode the payload from a JWS token without signature verification.
|
||||
|
||||
Use only after the signature has already been verified.
|
||||
"""
|
||||
try:
|
||||
parts = jws_token.split('.')
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
payload_b64 = parts[1]
|
||||
# Add base64url padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += '=' * padding
|
||||
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||
return json.loads(payload_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Failed to decode JWS payload', error=str(e))
|
||||
return None
|
||||
|
||||
async def send_consumption_info(
|
||||
self,
|
||||
transaction_id: str,
|
||||
customer_consented: bool,
|
||||
consumption_status: int = 0,
|
||||
delivery_status: int = 0,
|
||||
lifetime_dollars_purchased: int = 0,
|
||||
lifetime_dollars_refunded: int = 0,
|
||||
platform: int = 1,
|
||||
play_time: int = 0,
|
||||
sample_content_provided: bool = False,
|
||||
user_status: int = 0,
|
||||
environment: str | None = None,
|
||||
refund_preference: int | None = None,
|
||||
) -> bool:
|
||||
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
|
||||
|
||||
Must be sent within 12 hours of receiving the notification.
|
||||
"""
|
||||
base_url = self._get_base_url(environment)
|
||||
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
|
||||
token = self._generate_jwt()
|
||||
|
||||
body: dict[str, Any] = {
|
||||
'customerConsented': customer_consented,
|
||||
'consumptionStatus': consumption_status,
|
||||
'deliveryStatus': delivery_status,
|
||||
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
|
||||
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
|
||||
'platform': platform,
|
||||
'playTime': play_time,
|
||||
'sampleContentProvided': sample_content_provided,
|
||||
'userStatus': user_status,
|
||||
}
|
||||
if refund_preference is not None:
|
||||
body['refundPreference'] = refund_preference
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
try:
|
||||
response = await client.put(
|
||||
url,
|
||||
json=body,
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error('Apple consumption API request failed', error=str(e))
|
||||
return False
|
||||
|
||||
if response.status_code == 202:
|
||||
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Apple consumption API error',
|
||||
status=response.status_code,
|
||||
body=response.text[:500],
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return False
|
||||
Vendored
+338
@@ -57,6 +57,9 @@ class WebhookServer:
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
@@ -64,6 +67,8 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_freekassa_enabled():
|
||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_apple_iap_enabled():
|
||||
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info('Webhook сервер настроен:')
|
||||
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
||||
@@ -76,6 +81,8 @@ class WebhookServer:
|
||||
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
||||
if settings.is_freekassa_enabled():
|
||||
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
||||
if settings.is_apple_iap_enabled():
|
||||
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
|
||||
logger.info(' - Health check: GET /health')
|
||||
|
||||
return self.app
|
||||
@@ -491,3 +498,334 @@ class WebhookServer:
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
||||
return web.Response(text='NO', status=500)
|
||||
|
||||
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
"""Handle Apple App Store Server Notifications V2."""
|
||||
try:
|
||||
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
|
||||
|
||||
raw_body = await request.read()
|
||||
if not raw_body:
|
||||
logger.warning('Пустой Apple IAP webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
try:
|
||||
body = json.loads(raw_body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
|
||||
return web.Response(status=400)
|
||||
|
||||
signed_payload = body.get('signedPayload')
|
||||
if not signed_payload:
|
||||
logger.warning('No signedPayload in Apple webhook')
|
||||
return web.Response(status=400)
|
||||
|
||||
# Verify and decode the notification
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
notification = apple_service.verify_notification(signed_payload)
|
||||
if not notification:
|
||||
logger.warning('Apple webhook signature verification failed')
|
||||
return web.Response(status=403)
|
||||
|
||||
notification_type = notification.get('notificationType', '')
|
||||
subtype = notification.get('subtype', '')
|
||||
|
||||
# Verify notification environment matches our config
|
||||
# FIX 11: removed dead initial assignment of expected_envs
|
||||
notif_env = notification.get('data', {}).get('environment', '')
|
||||
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
|
||||
else:
|
||||
expected_envs = {'Sandbox'}
|
||||
if notif_env and notif_env not in expected_envs:
|
||||
logger.warning(
|
||||
'Apple webhook environment mismatch',
|
||||
expected=settings.APPLE_IAP_ENVIRONMENT,
|
||||
received=notif_env,
|
||||
)
|
||||
return web.Response(status=200) # ACK but ignore
|
||||
|
||||
logger.info(
|
||||
'Apple notification received',
|
||||
notification_type=notification_type,
|
||||
subtype=subtype,
|
||||
environment=notif_env,
|
||||
)
|
||||
|
||||
# Handle notification types
|
||||
if notification_type == 'TEST':
|
||||
logger.info('Apple TEST notification received -- OK')
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND':
|
||||
await self._handle_apple_refund(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'REFUND_REVERSED':
|
||||
await self._handle_apple_refund_reversed(notification)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type == 'CONSUMPTION_REQUEST':
|
||||
await self._handle_apple_consumption_request(notification, apple_service)
|
||||
return web.Response(status=200)
|
||||
|
||||
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
|
||||
logger.info('Apple notification logged', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
logger.info('Unhandled Apple notification type', notification_type=notification_type)
|
||||
return web.Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
|
||||
return web.Response(status=500)
|
||||
|
||||
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
|
||||
"""Handle REFUND notification -- deduct credited balance."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND notification')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
get_apple_transaction_by_transaction_id,
|
||||
mark_apple_transaction_refunded,
|
||||
)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, lookup_id)
|
||||
if not apple_txn:
|
||||
# Try the other ID
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
original_transaction_id=original_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status == 'refunded':
|
||||
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND: ignoring sandbox refund on production',
|
||||
transaction_id=lookup_id,
|
||||
user_id=apple_txn.user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# FIX 6: Lock user row with FOR UPDATE before reading balance
|
||||
# to prevent race condition in min() balance cap calculation
|
||||
user = await lock_user_for_pricing(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Cap deduction to current balance to prevent negative balance
|
||||
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
|
||||
if refund_amount < apple_txn.amount_kopeks:
|
||||
logger.warning(
|
||||
'Apple REFUND: partial balance deduction (user already spent funds)',
|
||||
full_amount=apple_txn.amount_kopeks,
|
||||
deducted=refund_amount,
|
||||
user_balance=user.balance_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Disable active subscriptions -- funds were spent and refunded
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
get_active_subscriptions_by_user_id,
|
||||
)
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
for sub in active_subs:
|
||||
await deactivate_subscription(db, sub, commit=False)
|
||||
logger.warning(
|
||||
'Apple REFUND: disabled subscription due to insufficient balance',
|
||||
subscription_id=sub.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
await subtract_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=refund_amount,
|
||||
description=f'Возврат Apple IAP: {product_id}',
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND processed',
|
||||
transaction_id=apple_txn.transaction_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
|
||||
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
|
||||
return
|
||||
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
|
||||
apple_service = AppleIAPService()
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify REFUND_REVERSED transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
original_txn_id = str(txn_info.get('originalTransactionId') or '')
|
||||
product_id = txn_info.get('productId', '')
|
||||
|
||||
from app.database.crud.apple_iap import (
|
||||
get_apple_transaction_by_transaction_id_for_update,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod
|
||||
|
||||
lookup_id = original_txn_id or apple_txn_id
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# FIX 7: Use FOR UPDATE lock on apple_transactions row
|
||||
# before checking status to prevent idempotency race
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
|
||||
if not apple_txn:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
|
||||
|
||||
if not apple_txn:
|
||||
logger.warning(
|
||||
'Apple REFUND_REVERSED: transaction not found',
|
||||
transaction_id=apple_txn_id,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.status != 'refunded':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: transaction not in refunded state',
|
||||
transaction_id=lookup_id,
|
||||
status=apple_txn.status,
|
||||
)
|
||||
return
|
||||
|
||||
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED: ignoring sandbox on production',
|
||||
transaction_id=lookup_id,
|
||||
)
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, apple_txn.user_id)
|
||||
if not user:
|
||||
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
|
||||
return
|
||||
|
||||
# Re-credit the balance
|
||||
await add_user_balance(
|
||||
db=db,
|
||||
user=user,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
description=f'Отмена возврата Apple IAP: {product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
apple_txn.status = 'verified'
|
||||
apple_txn.refunded_at = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Apple REFUND_REVERSED processed -- balance re-credited',
|
||||
transaction_id=lookup_id,
|
||||
amount_kopeks=apple_txn.amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
|
||||
|
||||
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
|
||||
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
|
||||
try:
|
||||
data = notification.get('data', {})
|
||||
signed_txn_info = data.get('signedTransactionInfo')
|
||||
if not signed_txn_info:
|
||||
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
|
||||
return
|
||||
|
||||
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
|
||||
if not txn_info:
|
||||
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
|
||||
return
|
||||
|
||||
apple_txn_id = str(txn_info.get('transactionId') or '')
|
||||
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
|
||||
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
|
||||
|
||||
# Determine if balance was consumed (spent on subscriptions)
|
||||
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
|
||||
consumption_status = 0
|
||||
if apple_txn and apple_txn.status == 'verified':
|
||||
consumption_status = 3 # Balance was credited and likely spent
|
||||
|
||||
# customerConsented must be false -- we cannot prompt the user
|
||||
# in a server-to-server webhook. Apple accepts the response
|
||||
# regardless, but the consumption data weight may be lower.
|
||||
await apple_service.send_consumption_info(
|
||||
transaction_id=apple_txn_id,
|
||||
customer_consented=False,
|
||||
consumption_status=consumption_status,
|
||||
delivery_status=0, # 0 = delivered
|
||||
platform=1, # 1 = Apple
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.database.models import (
|
||||
AdminRole,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
AppleTransaction,
|
||||
AuraPayPayment,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
@@ -204,6 +205,7 @@ class BackupService:
|
||||
RollyPayPayment,
|
||||
OverpayPayment,
|
||||
AuraPayPayment,
|
||||
AppleTransaction,
|
||||
SavedPaymentMethod,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
@@ -1515,6 +1517,7 @@ class BackupService:
|
||||
'rollypay_payments',
|
||||
'overpay_payments',
|
||||
'aurapay_payments',
|
||||
'apple_transactions',
|
||||
'saved_payment_methods',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
|
||||
Reference in New Issue
Block a user