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
|
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
|
# Main cabinet router
|
||||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
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(multi_tariff_subscription_router)
|
||||||
router.include_router(balance_router)
|
router.include_router(balance_router)
|
||||||
router.include_router(referral_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(partner_application_router)
|
||||||
router.include_router(withdrawal_router)
|
router.include_router(withdrawal_router)
|
||||||
# Notifications router MUST be before tickets router to avoid route conflict
|
# 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_RETURN_URL: str | None = None
|
||||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
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 (paypear.ru)
|
||||||
PAYPEAR_ENABLED: bool = False
|
PAYPEAR_ENABLED: bool = False
|
||||||
PAYPEAR_SHOP_ID: str | None = None
|
PAYPEAR_SHOP_ID: str | None = None
|
||||||
@@ -2027,6 +2040,34 @@ class Settings(BaseSettings):
|
|||||||
def get_severpay_display_name_html(self) -> str:
|
def get_severpay_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_severpay_display_name())
|
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:
|
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
|
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'
|
KASSA_AI = 'kassa_ai'
|
||||||
RIOPAY = 'riopay'
|
RIOPAY = 'riopay'
|
||||||
SEVERPAY = 'severpay'
|
SEVERPAY = 'severpay'
|
||||||
|
APPLE_IAP = 'apple_iap'
|
||||||
PAYPEAR = 'paypear'
|
PAYPEAR = 'paypear'
|
||||||
ROLLYPAY = 'rollypay'
|
ROLLYPAY = 'rollypay'
|
||||||
OVERPAY = 'overpay'
|
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})>'
|
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):
|
class HeleketPayment(Base):
|
||||||
__tablename__ = 'heleket_payments'
|
__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)
|
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)
|
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||||
if settings.is_mulenpay_enabled():
|
if settings.is_mulenpay_enabled():
|
||||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
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)
|
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||||
if settings.is_freekassa_enabled():
|
if settings.is_freekassa_enabled():
|
||||||
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
|
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('Webhook сервер настроен:')
|
||||||
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
|
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)
|
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
|
||||||
if settings.is_freekassa_enabled():
|
if settings.is_freekassa_enabled():
|
||||||
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
|
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')
|
logger.info(' - Health check: GET /health')
|
||||||
|
|
||||||
return self.app
|
return self.app
|
||||||
@@ -491,3 +498,334 @@ class WebhookServer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
|
||||||
return web.Response(text='NO', status=500)
|
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,
|
AdminRole,
|
||||||
AdvertisingCampaign,
|
AdvertisingCampaign,
|
||||||
AdvertisingCampaignRegistration,
|
AdvertisingCampaignRegistration,
|
||||||
|
AppleTransaction,
|
||||||
AuraPayPayment,
|
AuraPayPayment,
|
||||||
BroadcastHistory,
|
BroadcastHistory,
|
||||||
ButtonClickLog,
|
ButtonClickLog,
|
||||||
@@ -204,6 +205,7 @@ class BackupService:
|
|||||||
RollyPayPayment,
|
RollyPayPayment,
|
||||||
OverpayPayment,
|
OverpayPayment,
|
||||||
AuraPayPayment,
|
AuraPayPayment,
|
||||||
|
AppleTransaction,
|
||||||
SavedPaymentMethod,
|
SavedPaymentMethod,
|
||||||
# --- Settings/content ---
|
# --- Settings/content ---
|
||||||
PaymentMethodConfig,
|
PaymentMethodConfig,
|
||||||
@@ -1515,6 +1517,7 @@ class BackupService:
|
|||||||
'rollypay_payments',
|
'rollypay_payments',
|
||||||
'overpay_payments',
|
'overpay_payments',
|
||||||
'aurapay_payments',
|
'aurapay_payments',
|
||||||
|
'apple_transactions',
|
||||||
'saved_payment_methods',
|
'saved_payment_methods',
|
||||||
# --- Content/config ---
|
# --- Content/config ---
|
||||||
'pinned_messages',
|
'pinned_messages',
|
||||||
|
|||||||
@@ -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')
|
||||||
Vendored
+548
@@ -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
|
||||||
Reference in New Issue
Block a user