feat: 实现 iOS Apple Pay 内购支付功能
前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from v1.payments.apple_verifier import (
|
||||
AppleJwsVerifier,
|
||||
VerificationError,
|
||||
VerifiedTransaction,
|
||||
)
|
||||
|
||||
|
||||
def _make_jws_parts(header: dict[str, object], payload: dict[str, object]) -> tuple[str, str]:
|
||||
h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode()
|
||||
p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
|
||||
return h, p
|
||||
|
||||
|
||||
class TestAppleJwsVerifierInvalidInput:
|
||||
def test_invalid_header_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
result = verifier.verify_signed_transaction(
|
||||
"not-a-jws",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert result.code == "PAYMENT_TRANSACTION_INVALID"
|
||||
assert "decode" in result.detail.lower() or "header" in result.detail.lower()
|
||||
|
||||
def test_missing_x5c_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
h, p = _make_jws_parts({"alg": "ES256"}, {"bundleId": "test"})
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "x5c" in result.detail
|
||||
|
||||
def test_short_x5c_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
h, p = _make_jws_parts({"alg": "ES256", "x5c": ["one"]}, {"bundleId": "test"})
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "x5c" in result.detail
|
||||
|
||||
def test_issuer_subject_mismatch_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
leaf_cert_b64 = base64.b64encode(b"fake_leaf_cert").decode()
|
||||
intermediate_cert_b64 = base64.b64encode(b"fake_intermediate_cert").decode()
|
||||
root_cert_b64 = base64.b64encode(b"fake_root_cert").decode()
|
||||
h, p = _make_jws_parts(
|
||||
{"alg": "ES256", "x5c": [leaf_cert_b64, intermediate_cert_b64, root_cert_b64]},
|
||||
{"bundleId": "com.meeyao.qianwen"},
|
||||
)
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail
|
||||
@@ -0,0 +1,617 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
) -> VerifiedTransaction | VerificationError:
|
||||
return self._result
|
||||
|
||||
|
||||
def _make_verified_transaction(
|
||||
*,
|
||||
transaction_id: str = "2000000123456789",
|
||||
product_id: str = "com.meeyao.qianwen.basic_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="basic_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="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_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] = []
|
||||
|
||||
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
|
||||
|
||||
|
||||
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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_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"
|
||||
Reference in New Issue
Block a user