from __future__ import annotations from dataclasses import dataclass from uuid import UUID, uuid4 import pytest from core.http.errors import ApiProblemError from models.apple_iap_transaction import AppleIapTransaction from models.register_bonus_claims import RegisterBonusClaims from schemas.domain.points import ApplyPointsChangeCommand from v1.payments.apple_verifier import VerificationError, VerifiedTransaction from v1.payments.schemas import VerifyTransactionRequest from v1.payments.service import PaymentService @dataclass class _FakeAccount: balance: int = 0 frozen_balance: int = 0 lifetime_earned: int = 0 lifetime_spent: int = 0 version: int = 0 class _FakePaymentRepository: def __init__(self, *, existing_transaction: AppleIapTransaction | None = None) -> None: self.account = _FakeAccount() self.existing_transaction = existing_transaction self.inserted_transactions: list[AppleIapTransaction] = [] self.claim: RegisterBonusClaims | None = None self.claim_starter_pack_called: bool = False self.commit_count = 0 async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount: return self.account async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: return self.existing_transaction async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: self.inserted_transactions.append(transaction) async def get_register_bonus_claim(self, *, email_hash: str) -> RegisterBonusClaims | None: return self.claim async def upsert_register_bonus_claim_for_starter_pack( self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID ) -> RegisterBonusClaims: self.claim_starter_pack_called = True if self.claim is None: self.claim = RegisterBonusClaims( email_hash=email_hash, user_email_snapshot=user_email_snapshot, first_user_id_snapshot=first_user_id_snapshot, grant_event_id="starter_pack_purchase:test", has_purchased_starter_pack=True, ) else: self.claim.has_purchased_starter_pack = True return self.claim async def commit(self) -> None: self.commit_count += 1 class _FakePointsRepository: def __init__(self) -> None: self.appended_ledger: list[ApplyPointsChangeCommand] = [] async def append_ledger(self, *, command: ApplyPointsChangeCommand, balance_after: int) -> None: self.appended_ledger.append(command) class _FakeVerifier: def __init__(self, *, result: VerifiedTransaction | VerificationError) -> None: self._result = result def verify_signed_transaction( self, signed_transaction_info: str, *, expected_bundle_id: str, expected_product_id: str, expected_environment: str, ) -> VerifiedTransaction | VerificationError: del signed_transaction_info, expected_bundle_id, expected_product_id del expected_environment return self._result def _make_verified_transaction( *, transaction_id: str = "2000000123456789", product_id: str = "com.meeyao.qianwen.starter_pack", environment: str = "Sandbox", ) -> VerifiedTransaction: return VerifiedTransaction( transaction_id=transaction_id, original_transaction_id=transaction_id, web_order_line_item_id=None, bundle_id="com.meeyao.qianwen", product_id=product_id, purchase_date=1700000000000, revocation_date=None, environment=environment, app_account_token=None, raw_payload={}, ) class TestPaymentServiceProductNotFound: @pytest.mark.asyncio async def test_raises_product_not_found(self) -> None: service = PaymentService( payment_repo=_FakePaymentRepository(), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( productCode="nonexistent_pack", appStoreProductId="com.meeyao.qianwen.nonexistent", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) with pytest.raises(ApiProblemError) as exc_info: await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert exc_info.value.code == "PAYMENT_PRODUCT_NOT_FOUND" class TestPaymentServiceProductMismatch: @pytest.mark.asyncio async def test_raises_product_mismatch_when_ids_differ(self) -> None: service = PaymentService( payment_repo=_FakePaymentRepository(), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.wrong_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) with pytest.raises(ApiProblemError) as exc_info: await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert exc_info.value.code == "PAYMENT_PRODUCT_MISMATCH" class TestPaymentServiceVerificationFailed: @pytest.mark.asyncio async def test_raises_when_verifier_returns_error(self) -> None: service = PaymentService( payment_repo=_FakePaymentRepository(), points_repo=_FakePointsRepository(), verifier=_FakeVerifier( result=VerificationError( code="PAYMENT_TRANSACTION_INVALID", detail="bad signature", ) ), ) request = VerifyTransactionRequest( productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) with pytest.raises(ApiProblemError) as exc_info: await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert exc_info.value.code == "PAYMENT_TRANSACTION_INVALID" class TestPaymentServiceAlreadyGranted: @pytest.mark.asyncio async def test_returns_already_granted_for_same_user(self) -> None: user_id = uuid4() existing = AppleIapTransaction( id=uuid4(), user_id=user_id, product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000123456789", original_transaction_id="2000000123456789", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="granted", credits=100, signed_transaction_info="fake", apple_payload_json={}, ledger_event_id="payment.apple_iap:2000000123456789", ) service = PaymentService( payment_repo=_FakePaymentRepository(existing_transaction=existing), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) result = await service.verify_and_grant( user_id=user_id, user_email="test@example.com", request=request, ) assert result.status == "already_granted" assert result.credits_added == 0 class TestPaymentServiceTransactionConflict: @pytest.mark.asyncio async def test_raises_conflict_for_different_user(self) -> None: existing = AppleIapTransaction( id=uuid4(), user_id=uuid4(), product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000123456789", original_transaction_id="2000000123456789", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="granted", credits=100, signed_transaction_info="fake", apple_payload_json={}, ledger_event_id="payment.apple_iap:2000000123456789", ) service = PaymentService( payment_repo=_FakePaymentRepository(existing_transaction=existing), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) with pytest.raises(ApiProblemError) as exc_info: await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert exc_info.value.code == "PAYMENT_TRANSACTION_CONFLICT" class TestPaymentServiceSuccessfulGrant: @pytest.mark.asyncio async def test_grants_credits_for_new_transaction(self) -> None: payment_repo = _FakePaymentRepository() points_repo = _FakePointsRepository() service = PaymentService( payment_repo=payment_repo, points_repo=points_repo, verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) result = await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert result.status == "granted" assert result.credits_added == 100 assert result.new_balance == 100 assert result.ledger_event_id == "payment.apple_iap:2000000123456789" assert len(points_repo.appended_ledger) == 1 assert len(payment_repo.inserted_transactions) == 1 class TestPaymentServiceStarterPackIneligible: @pytest.mark.asyncio async def test_raises_when_starter_pack_already_purchased(self) -> None: claim = RegisterBonusClaims( email_hash="fake_hash", user_email_snapshot="test@example.com", first_user_id_snapshot=uuid4(), grant_event_id="register.bonus:test", has_purchased_starter_pack=True, ) payment_repo = _FakePaymentRepository() payment_repo.claim = claim service = PaymentService( payment_repo=payment_repo, points_repo=_FakePointsRepository(), verifier=_FakeVerifier( result=_make_verified_transaction( product_id="com.meeyao.qianwen.new_user_pack" ) ), ) request = VerifyTransactionRequest( productCode="new_user_pack", appStoreProductId="com.meeyao.qianwen.new_user_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) with pytest.raises(ApiProblemError) as exc_info: await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert exc_info.value.code == "PAYMENT_STARTER_PACK_INELIGIBLE" class TestPaymentServiceStarterPackSuccess: @pytest.mark.asyncio async def test_grants_starter_pack_and_updates_claim(self) -> None: payment_repo = _FakePaymentRepository() service = PaymentService( payment_repo=payment_repo, points_repo=_FakePointsRepository(), verifier=_FakeVerifier( result=_make_verified_transaction( product_id="com.meeyao.qianwen.new_user_pack" ) ), ) request = VerifyTransactionRequest( productCode="new_user_pack", appStoreProductId="com.meeyao.qianwen.new_user_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) result = await service.verify_and_grant( user_id=uuid4(), user_email="test@example.com", request=request, ) assert result.status == "granted" assert result.credits_added == 60 assert payment_repo.claim_starter_pack_called class _FakeAccountForRefund: def __init__(self, balance: int = 100, lifetime_earned: int = 100) -> None: self.balance: int = balance self.frozen_balance: int = 0 self.lifetime_earned: int = lifetime_earned self.lifetime_spent: int = 0 self.version: int = 1 class _FakePaymentRepoForRefund: def __init__( self, *, transaction: AppleIapTransaction | None = None, account: _FakeAccountForRefund | None = None, ) -> None: self._transaction = transaction self.account = account or _FakeAccountForRefund() self.inserted_transactions: list[AppleIapTransaction] = [] self.commit_count = 0 async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: return self._transaction async def get_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: return self.account async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: return self.account async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: self.inserted_transactions.append(transaction) async def get_register_bonus_claim(self, *, email_hash: str) -> None: return None async def upsert_register_bonus_claim_for_starter_pack( self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID ) -> None: pass async def commit(self) -> None: self.commit_count += 1 class TestProcessRefundUnknownTransaction: @pytest.mark.asyncio async def test_skips_silently_for_unknown_transaction(self) -> None: service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=None), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.process_refund(transaction_id="nonexistent") class TestProcessRefundNotGranted: @pytest.mark.asyncio async def test_skips_for_non_granted_transaction(self) -> None: txn = AppleIapTransaction( id=uuid4(), user_id=uuid4(), product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999999", original_transaction_id="2000000999999999", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="verified", credits=100, signed_transaction_info="fake", apple_payload_json={}, ) service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=txn), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.process_refund(transaction_id="2000000999999999") assert txn.status == "verified" class TestProcessRefundSufficientBalance: @pytest.mark.asyncio async def test_deducts_credits_and_writes_refund_ledger(self) -> None: user_id = uuid4() txn = AppleIapTransaction( id=uuid4(), user_id=user_id, product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999999", original_transaction_id="2000000999999999", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="granted", credits=100, signed_transaction_info="fake", apple_payload_json={}, ledger_event_id="payment.apple_iap:2000000999999999", ) account = _FakeAccountForRefund(balance=150, lifetime_earned=200) points_repo = _FakePointsRepository() service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), points_repo=points_repo, verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.process_refund(transaction_id="2000000999999999") assert txn.status == "refunded" assert account.balance == 50 assert account.lifetime_earned == 100 assert len(points_repo.appended_ledger) == 1 ledger = points_repo.appended_ledger[0] assert ledger.change_type.value == "refund" assert ledger.direction == -1 assert ledger.amount == 100 class TestProcessRefundInsufficientBalance: @pytest.mark.asyncio async def test_deducts_to_zero_and_sets_insufficient_status(self) -> None: user_id = uuid4() txn = AppleIapTransaction( id=uuid4(), user_id=user_id, product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999998", original_transaction_id="2000000999999998", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="granted", credits=100, signed_transaction_info="fake", apple_payload_json={}, ledger_event_id="payment.apple_iap:2000000999999998", ) account = _FakeAccountForRefund(balance=30, lifetime_earned=100) points_repo = _FakePointsRepository() service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), points_repo=points_repo, verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.process_refund(transaction_id="2000000999999998") assert txn.status == "refunded_insufficient" assert txn.failure_code == "INSUFFICIENT_BALANCE" assert account.balance == 0 assert len(points_repo.appended_ledger) == 1 ledger = points_repo.appended_ledger[0] assert ledger.amount == 30 class TestProcessRefundIdempotency: @pytest.mark.asyncio async def test_second_refund_is_noop(self) -> None: user_id = uuid4() txn = AppleIapTransaction( id=uuid4(), user_id=user_id, product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999997", original_transaction_id="2000000999999997", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="refunded", credits=100, signed_transaction_info="fake", apple_payload_json={}, ) points_repo = _FakePointsRepository() service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=_FakeAccountForRefund(balance=50)), points_repo=points_repo, verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.process_refund(transaction_id="2000000999999997") assert len(points_repo.appended_ledger) == 0 assert txn.status == "refunded" class TestHandleServerNotificationRefund: @pytest.mark.asyncio async def test_processes_refund_notification(self) -> None: user_id = uuid4() txn = AppleIapTransaction( id=uuid4(), user_id=user_id, product_code="starter_pack", app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999001", original_transaction_id="2000000999999001", environment="Sandbox", bundle_id="com.meeyao.qianwen", purchase_date="1700000000000", status="granted", credits=100, signed_transaction_info="fake", apple_payload_json={}, ledger_event_id="payment.apple_iap:2000000999999001", ) account = _FakeAccountForRefund(balance=200, lifetime_earned=200) points_repo = _FakePointsRepository() service = PaymentService( payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), points_repo=points_repo, verifier=_FakeVerifier(result=_make_verified_transaction()), ) import base64 import json signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001") notification_payload = json.dumps({ "notificationType": "REFUND", "data": {"signedTransactionInfo": signed_txn}, }) signed_payload = _make_fake_jws(notification_payload) await service.handle_server_notification(signed_payload=signed_payload) assert txn.status == "refunded" assert account.balance == 100 @pytest.mark.asyncio async def test_ignores_empty_payload(self) -> None: service = PaymentService( payment_repo=_FakePaymentRepoForRefund(), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.handle_server_notification(signed_payload="") @pytest.mark.asyncio async def test_ignores_non_refund_notification(self) -> None: import json notification_payload = json.dumps({ "notificationType": "DID_RENEW", "data": {}, }) signed_payload = _make_fake_jws(notification_payload) service = PaymentService( payment_repo=_FakePaymentRepoForRefund(), points_repo=_FakePointsRepository(), verifier=_FakeVerifier(result=_make_verified_transaction()), ) await service.handle_server_notification(signed_payload=signed_payload) def _make_fake_jws(payload_str: str) -> str: import base64 h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() p = base64.urlsafe_b64encode(payload_str.encode()).rstrip(b"=").decode() return f"{h}.{p}.fake_signature" def _make_fake_signed_transaction(transaction_id: str) -> str: import base64 import json txn_payload = json.dumps({"transactionId": transaction_id}) h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() p = base64.urlsafe_b64encode(txn_payload.encode()).rstrip(b"=").decode() return f"{h}.{p}.fake_signature"