from src.payments.contract_checkout import PaymentContractCheckoutService, PaymentIntentRecord ETHEREUM_USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" POLYGON_USDC = "0x3c499c542cef5E3811e1192ce70d8cc03d5c3359" RECEIVER = "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32" def _setup_env(monkeypatch, tmp_path): monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example") monkeypatch.setenv( "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", '[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]', ) monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db")) monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False) def _setup_multichain_env(monkeypatch, tmp_path): monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://polygon-rpc.example") monkeypatch.setenv( "POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON", '{"1":["https://ethereum-rpc.example"],"137":["https://polygon-rpc.example"]}', ) monkeypatch.setenv( "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", ( "[" f'{{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon",' f'"chain_id":137,"chain_code":"polygon","chain_name":"Polygon",' f'"address":"{POLYGON_USDC}","decimals":6,' f'"receiver_contract":"{RECEIVER}","is_default":true}},' f'{{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum",' f'"chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet",' f'"address":"{ETHEREUM_USDC}","decimals":6,' f'"receiver_contract":"{RECEIVER}",' f'"direct_receiver_address":"{RECEIVER}",' f'"supports_contract_checkout":false,' f'"explorer_tx_url":"https://etherscan.io/tx/{{tx_hash}}"}}' "]" ), ) monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db")) monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False) def test_config_payload_exposes_polygon_and_ethereum_payment_routes( monkeypatch, tmp_path ): _setup_multichain_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() payload = service.get_config_payload() assert payload["default_chain_id"] == 137 assert {row["chain_id"] for row in payload["chains"]} == {1, 137} eth_token = next( row for row in payload["tokens"] if row["chain_id"] == 1 ) assert eth_token["chain_code"] == "ethereum" assert eth_token["address"] == ETHEREUM_USDC.lower() assert eth_token["supports_direct_transfer"] is True assert eth_token["supports_contract_checkout"] is False def test_create_direct_intent_can_target_ethereum_usdc(monkeypatch, tmp_path): _setup_multichain_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() posts = [] def fake_rest(method, table, **kwargs): if method == "POST" and table == "payment_intents": payload = dict(kwargs["payload"]) payload["id"] = "intent-eth-1" payload["tx_hash"] = None posts.append(payload) return [payload] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) result = service.create_intent( "user-1", "pro_monthly", payment_mode="direct", chain_id=1, token_address=ETHEREUM_USDC, ) assert posts[0]["chain_id"] == 1 assert posts[0]["token_address"] == ETHEREUM_USDC.lower() assert result["direct_payment"]["chain_id"] == 1 assert result["direct_payment"]["chain"] == "ethereum" assert result["direct_payment"]["chain_name"] == "Ethereum Mainnet" assert result["direct_payment"]["explorer_tx_url"] == ( "https://etherscan.io/tx/{tx_hash}" ) def test_pending_confirm_intents_include_supported_non_default_chains( monkeypatch, tmp_path ): _setup_multichain_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() captured_params = {} def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_intents": captured_params.update(kwargs["params"]) return [ { "id": "intent-eth-1", "user_id": "user-1", "tx_hash": "0x" + "a" * 64, "status": "submitted", "chain_id": 1, "updated_at": "2026-05-29T00:00:00+00:00", "created_at": "2026-05-29T00:00:00+00:00", } ] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) rows = service.list_pending_confirm_intents(limit=5) assert "chain_id" not in captured_params assert rows[0]["intent_id"] == "intent-eth-1" assert rows[0]["chain_id"] == 1 def test_insert_payment_record_stores_ethereum_chain(monkeypatch, tmp_path): _setup_multichain_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() inserted = {} def fake_rest(method, table, **kwargs): if method == "POST" and table == "payments": inserted.update(kwargs["payload"]) return [kwargs["payload"]] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) service._insert_payment_record( user_id="user-1", tx_hash="0x" + "b" * 64, amount_units=10_000_000, token_address=ETHEREUM_USDC, chain_id=1, payload={}, ) assert inserted["chain"] == "ethereum" assert inserted["currency"] == "USDC" def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path): _setup_multichain_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "c" * 64 intent = PaymentIntentRecord( intent_id="intent-direct-eth", order_id_hex="0x" + "1" * 64, plan_code="pro_monthly", plan_id=101, chain_id=1, amount_units=10_000_000, amount_usdc="10", token_address=ETHEREUM_USDC.lower(), token_decimals=6, token_symbol="USDC", receiver_address=RECEIVER.lower(), status="submitted", payment_mode="direct", allowed_wallet=None, expires_at="2099-01-01T00:00:00+00:00", tx_hash=tx_hash, metadata={}, ) confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"}) intents = [intent, confirmed_intent] get_intent_calls = [] requested_chains = [] tx_rows = [] def fake_get_intent(user_id, intent_id): get_intent_calls.append((user_id, intent_id)) return intents.pop(0) if intents else confirmed_intent monkeypatch.setattr(service, "get_intent", fake_get_intent) class _Eth: chain_id = 1 block_number = 20 @staticmethod def get_transaction(_tx_hash): return { "to": ETHEREUM_USDC, "from": "0x9999999999999999999999999999999999999999", } class _Web3: eth = _Eth() @staticmethod def is_connected(): return True def fake_get_web3(*args, **kwargs): requested_chains.append(kwargs.get("chain_id") or (args[0] if args else None)) return _Web3() monkeypatch.setattr(service, "_get_web3", fake_get_web3) monkeypatch.setattr( service, "_wait_receipt", lambda _tx_hash, *args, **kwargs: { "status": 1, "to": ETHEREUM_USDC, "from": "0x9999999999999999999999999999999999999999", "blockNumber": 10, }, ) monkeypatch.setattr( service, "_extract_direct_transfer_event", lambda receipt, local_intent: { "from": "0x9999999999999999999999999999999999999999", "to": local_intent.receiver_address, "token_address": local_intent.token_address, "amount_units": local_intent.amount_units, }, ) monkeypatch.setattr( service, "_consume_points_for_intent", lambda user_id, local_intent: {"applied": False}, ) monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30}) monkeypatch.setattr( service, "_insert_payment_record", lambda **kwargs: {"chain_id": kwargs["chain_id"], "tx_hash": kwargs["tx_hash"]}, ) monkeypatch.setattr( service, "_grant_subscription", lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]}, ) monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] if method == "GET" and table == "payment_intents": return [] if method == "PATCH" and table == "payment_intents": assert kwargs["prefer"] == "return=representation" assert kwargs["params"]["select"] == "id" return [{"id": intent.intent_id}] if method == "POST" and table == "payment_transactions": tx_rows.append(kwargs["payload"]) return [kwargs["payload"]] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash) assert 1 in requested_chains assert tx_rows[0]["chain_id"] == 1 assert result["payment"]["chain_id"] == 1 assert get_intent_calls == [("user-1", intent.intent_id)] def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() posts = [] def fake_rest(method, table, **kwargs): if method == "GET" and table == "user_wallets": return [] if method == "POST" and table == "payment_intents": payload = dict(kwargs["payload"]) payload["id"] = "intent-direct-1" payload["tx_hash"] = None posts.append(payload) return [payload] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) result = service.create_intent("user-1", "pro_monthly", payment_mode="direct") assert result["intent"]["payment_mode"] == "direct" assert result["intent"]["allowed_wallet"] is None assert "direct_payment" in result assert result["direct_payment"]["receiver_address"] == "0xed2f13aa5ff033c58fb436e178451cd07f693f32" assert result["direct_payment"]["amount_usdc"] == "29.9" assert posts[0]["payment_mode"] == "direct" def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() submitted = {} monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "created", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": None, "metadata": {}, } ), ) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] if method == "GET" and table == "payment_intents": return [] if method == "PATCH" and table == "payment_intents": submitted.update(kwargs["payload"]) return [{"ok": True}] if method == "POST" and table == "payment_transactions": return [kwargs["payload"]] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) monkeypatch.setattr( service, "_validate_loaded_intent_tx", lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}}, ) result = service.submit_intent_tx("user-1", "intent-direct-1", "0x" + "2" * 64, "") assert result["status"] == "submitted" assert result["from_address"] is None assert submitted["tx_hash"] == "0x" + "2" * 64 def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "7" * 64 monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "created", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": None, "metadata": {}, } ), ) monkeypatch.setattr( service, "_validate_loaded_intent_tx", lambda *args, **kwargs: { "valid": False, "reason": "tx_not_mined", "detail": "tx has not been mined yet", }, ) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] if method == "GET" and table == "payment_intents": return [] raise AssertionError(f"submit must not mutate {table} before validation passes") monkeypatch.setattr(service, "_rest", fake_rest) try: service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "") except Exception as exc: assert getattr(exc, "status_code", None) == 400 assert "tx_not_mined" in getattr(exc, "detail", "") else: raise AssertionError("expected tx_not_mined rejection") def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "8" * 64 monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "created", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": None, "metadata": {}, } ), ) monkeypatch.setattr( service, "_validate_loaded_intent_tx", lambda *args, **kwargs: { "valid": False, "reason": "receiver_mismatch", "detail": "Transfer went to 0xd216..., expected 0xed2f...", }, ) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] if method == "GET" and table == "payment_intents": return [] raise AssertionError(f"submit must not mutate {table} after receiver mismatch") monkeypatch.setattr(service, "_rest", fake_rest) try: service.submit_intent_tx("user-1", "intent-direct-wrong", tx_hash, "") except Exception as exc: assert getattr(exc, "status_code", None) == 400 assert "receiver_mismatch" in getattr(exc, "detail", "") else: raise AssertionError("expected receiver mismatch rejection") def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "3" * 64 intent = PaymentIntentRecord( intent_id="intent-direct-2", order_id_hex="0x" + "1" * 64, plan_code="pro_monthly", plan_id=101, chain_id=137, amount_units=5000000, amount_usdc="5", token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174", token_decimals=6, token_symbol="USDC.e", receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32", status="submitted", payment_mode="direct", allowed_wallet=None, expires_at="2099-01-01T00:00:00+00:00", tx_hash=tx_hash, metadata={}, ) confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"}) intents = [intent, confirmed_intent] rest_calls = [] monkeypatch.setattr(service, "get_intent", lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent) class _Eth: chain_id = 137 block_number = 20 @staticmethod def get_transaction(_tx_hash): return { "to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "from": "0x9999999999999999999999999999999999999999", } class _Web3: eth = _Eth() @staticmethod def is_connected(): return True monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _Web3()) monkeypatch.setattr( service, "_wait_receipt", lambda _tx_hash, *args, **kwargs: { "status": 1, "to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "from": "0x9999999999999999999999999999999999999999", "blockNumber": 10, }, ) monkeypatch.setattr( service, "_extract_direct_transfer_event", lambda receipt, local_intent: { "from": "0x9999999999999999999999999999999999999999", "to": local_intent.receiver_address, "token_address": local_intent.token_address, "amount_units": local_intent.amount_units, }, ) monkeypatch.setattr(service, "_consume_points_for_intent", lambda user_id, local_intent: {"applied": False}) monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30}) monkeypatch.setattr(service, "_insert_payment_record", lambda **kwargs: {"tx_hash": kwargs["tx_hash"]}) monkeypatch.setattr(service, "_grant_subscription", lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]}) monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None) def fake_rest(method, table, **kwargs): rest_calls.append((method, table, kwargs)) if method == "GET" and table == "payment_transactions": return [] if method == "GET" and table == "payment_intents": return [] if method == "PATCH" and table == "payment_intents": return [{"id": intent.intent_id, "status": "confirmed"}] if method == "POST" and table == "payment_transactions": return [] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("wallet not required"))) result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash) assert result["subscription"]["status"] == "active" assert result["transaction"]["tx_hash"] == tx_hash assert result["tx"]["event"]["amount_units"] == 5000000 transaction_write = next( call for call in rest_calls if call[0] == "POST" and call[1] == "payment_transactions" ) assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal" def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "4" * 64 monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "created", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": None, "metadata": {}, } ), ) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [{"intent_id": "other-intent", "tx_hash": tx_hash, "status": "confirmed"}] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) try: service.submit_intent_tx("user-1", "intent-direct-3", tx_hash, "") except Exception as exc: assert getattr(exc, "status_code", None) == 409 assert "tx_hash already used" in getattr(exc, "detail", "") else: raise AssertionError("expected duplicate tx_hash rejection") def test_confirm_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "7" * 64 monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "created", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": None, "metadata": {}, } ), ) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [{"intent_id": "other-intent"}] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) try: service.confirm_intent_tx("user-1", "intent-direct-4", tx_hash) except Exception as exc: assert getattr(exc, "status_code", None) == 409 assert "tx_hash already used" in getattr(exc, "detail", "") else: raise AssertionError("expected duplicate tx_hash rejection") def test_submit_marks_late_tx_as_refund_required_after_intent_paid(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() paid_tx = "0x" + "5" * 64 late_tx = "0x" + "6" * 64 duplicate_rows = [] monkeypatch.setattr( service, "get_intent", lambda user_id, intent_id: service._serialize_intent( { "id": intent_id, "plan_code": "pro_monthly", "plan_id": 101, "chain_id": 137, "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", "amount_units": "5000000", "payment_mode": "direct", "allowed_wallet": None, "order_id_hex": "0x" + "1" * 64, "status": "confirmed", "expires_at": "2099-01-01T00:00:00+00:00", "tx_hash": paid_tx, "metadata": {}, } ), ) def fake_rest(method, table, **kwargs): if method == "POST" and table == "payment_transactions": duplicate_rows.append(kwargs["payload"]) return [kwargs["payload"]] raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) try: service.submit_intent_tx("user-1", "intent-direct-paid", late_tx, "") except Exception as exc: assert getattr(exc, "status_code", None) == 409 assert "已支付" in getattr(exc, "detail", "") else: raise AssertionError("expected already-paid rejection") assert duplicate_rows[0]["tx_hash"] == late_tx assert duplicate_rows[0]["status"] == "refund_required" assert duplicate_rows[0]["payment_method"] == "direct"