From ecde2fb8f05620889f45fea4410e9069b99340a5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 05:49:38 +0300 Subject: [PATCH] feat: Apple IAP integration with security hardening --- app/cabinet/routes/__init__.py | 12 + app/cabinet/routes/apple_iap.py | 267 +++++++++ app/cabinet/schemas/apple_iap.py | 23 + app/config.py | 41 ++ app/database/crud/apple_iap.py | 83 +++ app/database/models.py | 36 ++ app/external/apple_iap.py | 411 +++++++++++++ app/external/webhook_server.py | 338 +++++++++++ app/services/backup_service.py | 3 + .../versions/0068_add_apple_transactions.py | 50 ++ tests/external/test_apple_iap.py | 548 ++++++++++++++++++ 11 files changed, 1812 insertions(+) create mode 100644 app/cabinet/routes/apple_iap.py create mode 100644 app/cabinet/schemas/apple_iap.py create mode 100644 app/database/crud/apple_iap.py create mode 100644 app/external/apple_iap.py create mode 100644 migrations/alembic/versions/0068_add_apple_transactions.py create mode 100644 tests/external/test_apple_iap.py diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index a6d3515d..ee60a303 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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 diff --git a/app/cabinet/routes/apple_iap.py b/app/cabinet/routes/apple_iap.py new file mode 100644 index 00000000..ac317592 --- /dev/null +++ b/app/cabinet/routes/apple_iap.py @@ -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) diff --git a/app/cabinet/schemas/apple_iap.py b/app/cabinet/schemas/apple_iap.py new file mode 100644 index 00000000..ff65941a --- /dev/null +++ b/app/cabinet/schemas/apple_iap.py @@ -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 diff --git a/app/config.py b/app/config.py index 662c7bdd..db025767 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/database/crud/apple_iap.py b/app/database/crud/apple_iap.py new file mode 100644 index 00000000..b2c898a5 --- /dev/null +++ b/app/database/crud/apple_iap.py @@ -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 diff --git a/app/database/models.py b/app/database/models.py index 8e710ceb..22df6d4b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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'' +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'' + + class HeleketPayment(Base): __tablename__ = 'heleket_payments' diff --git a/app/external/apple_iap.py b/app/external/apple_iap.py new file mode 100644 index 00000000..240844e0 --- /dev/null +++ b/app/external/apple_iap.py @@ -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 diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0d0a46d9..969168db 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -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) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 2d3759d1..5a2a0d29 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -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', diff --git a/migrations/alembic/versions/0068_add_apple_transactions.py b/migrations/alembic/versions/0068_add_apple_transactions.py new file mode 100644 index 00000000..540df824 --- /dev/null +++ b/migrations/alembic/versions/0068_add_apple_transactions.py @@ -0,0 +1,50 @@ +"""add apple_transactions table + +Revision ID: 0068 +Revises: 0067 +Create Date: 2026-04-11 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0068' +down_revision: Union[str, None] = '0067' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'apple_transactions', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('transaction_id', sa.String(64), unique=True, nullable=False), + sa.Column('original_transaction_id', sa.String(64), nullable=True), + sa.Column('product_id', sa.String(128), nullable=False), + sa.Column('bundle_id', sa.String(255), nullable=False), + sa.Column('amount_kopeks', sa.Integer(), nullable=False), + sa.Column('environment', sa.String(16), nullable=False), + sa.Column('status', sa.String(50), server_default='verified'), + sa.Column('is_paid', sa.Boolean(), server_default=sa.text('true')), + sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('transaction_id_fk', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_index('ix_apple_transactions_transaction_id', 'apple_transactions', ['transaction_id']) + op.create_index('ix_apple_transactions_original_transaction_id', 'apple_transactions', ['original_transaction_id']) + op.create_index('ix_apple_transactions_user_id', 'apple_transactions', ['user_id']) + + +def downgrade() -> None: + op.drop_index('ix_apple_transactions_user_id', table_name='apple_transactions') + op.drop_index('ix_apple_transactions_original_transaction_id', table_name='apple_transactions') + op.drop_index('ix_apple_transactions_transaction_id', table_name='apple_transactions') + op.drop_table('apple_transactions') diff --git a/tests/external/test_apple_iap.py b/tests/external/test_apple_iap.py new file mode 100644 index 00000000..83610c1d --- /dev/null +++ b/tests/external/test_apple_iap.py @@ -0,0 +1,548 @@ +"""Tests for Apple In-App Purchase service and integration.""" + +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings +from app.external.apple_iap import AppleIAPService + + +@pytest.fixture +def anyio_backend() -> str: + return 'asyncio' + + +def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', 'TEST_KEY_ID', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', 'test-issuer-id', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_BUNDLE_ID', 'com.bitnet.vpnclient', raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False) + # Use a dummy key -- we won't actually sign in tests + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', 'dummy-key', raising=False) + monkeypatch.setattr( + settings, + 'APPLE_IAP_PRODUCTS', + json.dumps({ + 'com.bitnet.vpnclient.topup.100': 10_000, + 'com.bitnet.vpnclient.topup.300': 30_000, + 'com.bitnet.vpnclient.topup.500': 50_000, + 'com.bitnet.vpnclient.topup.1000': 100_000, + 'com.bitnet.vpnclient.topup.3000': 300_000, + }), + raising=False, + ) + + +# --------------------------------------------------------------------------- +# Product mapping +# --------------------------------------------------------------------------- + + +class TestProductMapping: + """Test product ID to kopeks mapping.""" + + def test_all_products_mapped(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert len(products) == 5 + + def test_product_100(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.100'] == 10_000 + + def test_product_300(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.300'] == 30_000 + + def test_product_500(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.500'] == 50_000 + + def test_product_1000(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.1000'] == 100_000 + + def test_product_3000(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert products['com.bitnet.vpnclient.topup.3000'] == 300_000 + + def test_unknown_product_not_in_map(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + products = settings.get_apple_iap_products() + assert 'com.bitnet.vpnclient.topup.999' not in products + + def test_invalid_json_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, 'APPLE_IAP_PRODUCTS', 'invalid-json', raising=False) + products = settings.get_apple_iap_products() + assert products == {} + + +# --------------------------------------------------------------------------- +# is_apple_iap_enabled() +# --------------------------------------------------------------------------- + + +class TestAppleIAPEnabled: + """Test is_apple_iap_enabled() helper.""" + + def test_enabled_with_all_params(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + assert settings.is_apple_iap_enabled() is True + + def test_disabled_when_flag_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', False, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_issuer_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_disabled_when_no_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', None, raising=False) + assert settings.is_apple_iap_enabled() is False + + def test_enabled_with_key_path_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False) + monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False) + assert settings.is_apple_iap_enabled() is True + + +# --------------------------------------------------------------------------- +# validate_transaction_info +# --------------------------------------------------------------------------- + + +class TestTransactionValidation: + """Test validate_transaction_info.""" + + def test_valid_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None # None means valid + + def test_wrong_bundle_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.other.app', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'Bundle ID' in result + + def test_wrong_product_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.500', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'Product ID' in result + + def test_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Auto-Renewable Subscription', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'type' in result + + def test_revoked_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'revocationDate': 1700000000000, + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is not None + assert 'revoked' in result.lower() + + def test_valid_without_revocation(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A transaction without revocationDate should be valid.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None + + +# --------------------------------------------------------------------------- +# Environment URL selection +# --------------------------------------------------------------------------- + + +class TestBaseUrl: + """Test environment URL selection.""" + + def test_production_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url('Production') + assert 'api.storekit.itunes.apple.com' in url + + def test_sandbox_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url('Sandbox') + assert 'api.storekit-sandbox.itunes.apple.com' in url + + def test_default_uses_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + url = service._get_base_url() + assert 'sandbox' in url # Fixture sets Sandbox + + +# --------------------------------------------------------------------------- +# _decode_jws_payload (raw decode, no verification) +# --------------------------------------------------------------------------- + + +class TestJWSPayloadDecoding: + """Test _decode_jws_payload.""" + + def test_decode_valid_jws(self) -> None: + service = AppleIAPService() + header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode() + payload_data = {'bundleId': 'com.test', 'productId': 'test.product'} + payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b'=').decode() + signature = base64.urlsafe_b64encode(b'fake-signature').rstrip(b'=').decode() + jws = f'{header}.{payload}.{signature}' + + result = service._decode_jws_payload(jws) + assert result is not None + assert result['bundleId'] == 'com.test' + assert result['productId'] == 'test.product' + + def test_decode_invalid_jws(self) -> None: + service = AppleIAPService() + result = service._decode_jws_payload('not-a-jws') + assert result is None + + def test_decode_empty_string(self) -> None: + service = AppleIAPService() + result = service._decode_jws_payload('') + assert result is None + + +# --------------------------------------------------------------------------- +# _verify_and_decode_jws (x5c + ES256 verification) +# --------------------------------------------------------------------------- + + +class TestVerifyAndDecodeJWS: + """Test _verify_and_decode_jws -- the full x5c chain + signature path.""" + + def test_rejects_bad_format(self) -> None: + service = AppleIAPService() + assert service._verify_and_decode_jws('only-two.parts') is None + + def test_rejects_missing_x5c(self) -> None: + service = AppleIAPService() + # Valid 3-part JWS but header has no x5c + header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode() + sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode() + assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None + + def test_rejects_empty_x5c(self) -> None: + service = AppleIAPService() + header = ( + base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode() + ) + payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode() + sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode() + assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None + + def test_verify_notification_delegates(self) -> None: + """verify_notification should delegate to _verify_and_decode_jws.""" + service = AppleIAPService() + service._verify_and_decode_jws = MagicMock(return_value={'test': True}) + result = service.verify_notification('signed.payload.jws') + service._verify_and_decode_jws.assert_called_once_with('signed.payload.jws') + assert result == {'test': True} + + +# --------------------------------------------------------------------------- +# verify_transaction with mocked HTTP +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio('asyncio') +class TestVerifyTransaction: + """Test verify_transaction with mocked _fetch_transaction.""" + + @staticmethod + def _ok_response(signed_info: str = 'header.payload.sig') -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {'signedTransactionInfo': signed_info} + return resp + + @staticmethod + def _error_response(status: int, text: str = '') -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.text = text + return resp + + async def test_successful_verification(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + txn_data = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'transactionId': '2000000123456789', + 'environment': 'Sandbox', + } + + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response())) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data) + + result = await service.verify_transaction('2000000123456789', 'Sandbox') + + assert result is not None + assert result['bundleId'] == 'com.bitnet.vpnclient' + assert result['transactionId'] == '2000000123456789' + + async def test_verification_with_jws_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + """If _verify_and_decode_jws returns None, verify_transaction returns None.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response())) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: None) + + result = await service.verify_transaction('2000000123456789', 'Sandbox') + assert result is None + + async def test_transaction_not_found_both_envs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """404 on primary triggers fallback; 404 on fallback returns None.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(404)) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('nonexistent', 'Sandbox') + assert result is None + # Should have been called twice (primary + fallback) + assert fetch_mock.call_count == 2 + + async def test_fallback_succeeds_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None: + """404 on primary, 200 on fallback -- should succeed.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + txn_data = {'bundleId': 'com.bitnet.vpnclient', 'type': 'Consumable'} + responses = [self._error_response(404), self._ok_response()] + fetch_mock = AsyncMock(side_effect=responses) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data) + + result = await service.verify_transaction('12345', 'Production') + assert result is not None + assert fetch_mock.call_count == 2 + + async def test_network_error_no_retry(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Network error (None response) should not retry.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=None) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('12345', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 1 # no fallback on network error + + async def test_5xx_no_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """5xx errors should not trigger fallback (only 4xx does).""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(500, 'Internal')) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('12345', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 1 + + async def test_rate_limit_triggers_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """429 is 4xx -> triggers fallback.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + fetch_mock = AsyncMock(return_value=self._error_response(429)) + monkeypatch.setattr(service, '_fetch_transaction', fetch_mock) + + result = await service.verify_transaction('123', 'Sandbox') + assert result is None + assert fetch_mock.call_count == 2 # primary + fallback + + async def test_no_signed_transaction_info(self, monkeypatch: pytest.MonkeyPatch) -> None: + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} # missing signedTransactionInfo + monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=resp)) + + result = await service.verify_transaction('123', 'Sandbox') + assert result is None + + +# --------------------------------------------------------------------------- +# Schema validation +# --------------------------------------------------------------------------- + + +class TestApplePurchaseRequestSchema: + """Test ApplePurchaseRequest pydantic validation.""" + + def test_valid_request(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + req = ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='2000000123456789', + ) + assert req.transaction_id == '2000000123456789' + + def test_rejects_non_numeric_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(Exception, match='digits'): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='abc-not-numeric', + ) + + def test_rejects_empty_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(Exception): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='', + ) + + def test_rejects_too_long_transaction_id(self) -> None: + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + with pytest.raises(Exception): + ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='1' * 65, + ) + + def test_no_environment_field(self) -> None: + """Schema should not accept environment -- it's server-side only.""" + from app.cabinet.schemas.apple_iap import ApplePurchaseRequest + + req = ApplePurchaseRequest( + product_id='com.bitnet.vpnclient.topup.100', + transaction_id='123', + ) + assert not hasattr(req, 'environment') + + +# --------------------------------------------------------------------------- +# Sandbox detection +# --------------------------------------------------------------------------- + + +class TestSandboxDetection: + """Test that sandbox transactions don't credit real balance.""" + + def test_sandbox_env_detected_from_txn_info(self, monkeypatch: pytest.MonkeyPatch) -> None: + """validate_transaction_info does not reject sandbox env -- that's handled at the route level.""" + _enable_apple_iap(monkeypatch) + service = AppleIAPService() + txn_info = { + 'bundleId': 'com.bitnet.vpnclient', + 'productId': 'com.bitnet.vpnclient.topup.100', + 'type': 'Consumable', + 'environment': 'Sandbox', + } + result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100') + assert result is None # validation passes -- sandbox check is higher up + + def test_production_txn_on_production_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Production environment in txn_info + Production config = proceed normally.""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False) + txn_info = {'environment': 'Production'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + assert is_sandbox is False + + def test_sandbox_txn_on_production_detected(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Sandbox environment in txn_info + Production config = sandbox detected.""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False) + txn_info = {'environment': 'Sandbox'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production' + assert should_skip_balance is True + + def test_sandbox_txn_on_sandbox_credits_normally(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Sandbox environment in txn_info + Sandbox config = credit normally (testing).""" + _enable_apple_iap(monkeypatch) + monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False) + txn_info = {'environment': 'Sandbox'} + is_sandbox = txn_info.get('environment') == 'Sandbox' + should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production' + assert should_skip_balance is False