feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
This commit is contained in:
LewisPen
2026-03-08 00:05:48 +08:00
committed by fawney19
parent 9cdcce1b5f
commit 783f654953
108 changed files with 13152 additions and 3372 deletions

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from src.api.admin.api_keys.routes import (
AdminGetFullKeyAdapter,
AdminToggleApiKeyAdapter,
router as admin_api_keys_router,
)
from src.api.admin.users.routes import (
AdminGetUserKeyFullKeyAdapter,
AdminToggleUserKeyLockAdapter,
router as admin_users_router,
)
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
def _build_context(db: MagicMock) -> SimpleNamespace:
return SimpleNamespace(
db=db,
add_audit_metadata=lambda **_: None,
)
def _mock_query_first(db: MagicMock, value: object | None) -> None:
db.query.return_value.filter.return_value.first.return_value = value
def _build_admin_users_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
app = FastAPI()
app.include_router(admin_users_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
ensure_json_body=lambda: {},
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.admin.users.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
def _build_admin_api_keys_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
app = FastAPI()
app.include_router(admin_api_keys_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
ensure_json_body=lambda: {},
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.admin.api_keys.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
@pytest.mark.asyncio
async def test_toggle_user_key_lock_adapter_success() -> None:
db = MagicMock()
api_key = SimpleNamespace(id="key-1", user_id="user-1", is_standalone=False, is_locked=False)
_mock_query_first(db, api_key)
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-1")
result = await adapter.handle(_build_context(db))
assert result["id"] == "key-1"
assert result["is_locked"] is True
assert "锁定" in result["message"]
db.commit.assert_called_once()
db.refresh.assert_called_once_with(api_key)
@pytest.mark.asyncio
async def test_toggle_user_key_lock_adapter_not_found_for_standalone_or_wrong_owner() -> None:
db = MagicMock()
_mock_query_first(db, None)
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-standalone")
with pytest.raises(NotFoundException):
await adapter.handle(_build_context(db))
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-2",
user_id="user-1",
is_standalone=False,
key_encrypted="encrypted-value",
)
_mock_query_first(db, api_key)
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-full-key")
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-2")
result = await adapter.handle(_build_context(db))
assert result == {"key": "sk-user-full-key"}
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_requires_encrypted_key() -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-3",
user_id="user-1",
is_standalone=False,
key_encrypted=None,
)
_mock_query_first(db, api_key)
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-3")
with pytest.raises(InvalidRequestException):
await adapter.handle(_build_context(db))
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_returns_500_on_decrypt_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-4",
user_id="user-1",
is_standalone=False,
key_encrypted="encrypted-value",
)
_mock_query_first(db, api_key)
def _raise(_: str) -> str:
raise ValueError("decrypt failed")
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", _raise)
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-4")
with pytest.raises(HTTPException) as exc_info:
await adapter.handle(_build_context(db))
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_standalone_toggle_adapters_reject_normal_user_key() -> None:
db = MagicMock()
normal_key = SimpleNamespace(
id="key-user",
user_id="user-1",
is_standalone=False,
is_active=True,
is_locked=False,
key_encrypted="encrypted-value",
updated_at=datetime.now(timezone.utc),
)
_mock_query_first(db, normal_key)
context = _build_context(db)
with pytest.raises(InvalidRequestException):
await AdminToggleApiKeyAdapter(key_id="key-user").handle(context)
with pytest.raises(InvalidRequestException):
await AdminGetFullKeyAdapter(key_id="key-user").handle(context)
def test_user_key_lock_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(id="key-5", user_id="user-2", is_standalone=False, is_locked=False)
_mock_query_first(db, api_key)
client = _build_admin_users_app(db, monkeypatch)
response = client.patch("/api/admin/users/user-2/api-keys/key-5/lock")
assert response.status_code == 200
assert response.json()["id"] == "key-5"
assert response.json()["is_locked"] is True
def test_user_key_full_key_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-6",
user_id="user-2",
is_standalone=False,
key_encrypted="enc",
)
_mock_query_first(db, api_key)
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-route-key")
client = _build_admin_users_app(db, monkeypatch)
response = client.get("/api/admin/users/user-2/api-keys/key-6/full-key")
assert response.status_code == 200
assert response.json() == {"key": "sk-user-route-key"}
def test_standalone_lock_route_removed(monkeypatch: pytest.MonkeyPatch) -> None:
client = _build_admin_api_keys_app(MagicMock(), monkeypatch)
response = client.patch("/api/admin/api-keys/key-1/lock")
assert response.status_code == 404
def test_standalone_list_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="sa-key-1",
user_id="admin-1",
name="Standalone Key",
get_display_key=lambda: "sk-stand...1234",
is_active=True,
is_standalone=True,
total_requests=0,
total_cost_usd=0,
rate_limit=None,
allowed_providers=None,
allowed_api_formats=None,
allowed_models=None,
last_used_at=None,
expires_at=None,
created_at=datetime.now(timezone.utc),
updated_at=None,
auto_delete_on_expiry=False,
)
query = db.query.return_value.filter.return_value
query.count.return_value = 1
query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [api_key]
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.get_wallet",
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: SimpleNamespace(id="w-1"),
)
client = _build_admin_api_keys_app(db, monkeypatch)
response = client.get("/api/admin/api-keys")
assert response.status_code == 200
payload = response.json()
assert len(payload["api_keys"]) == 1
assert "is_locked" not in payload["api_keys"][0]
def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="sa-key-2",
user_id="admin-1",
name="Standalone Key 2",
get_display_key=lambda: "sk-stand...5678",
is_active=True,
is_standalone=True,
total_requests=0,
total_cost_usd=0,
rate_limit=None,
allowed_providers=[],
allowed_api_formats=[],
allowed_models=[],
last_used_at=None,
expires_at=None,
created_at=datetime.now(timezone.utc),
updated_at=None,
)
_mock_query_first(db, api_key)
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.get_wallet",
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: None,
)
client = _build_admin_api_keys_app(db, monkeypatch)
response = client.get("/api/admin/api-keys/sa-key-2")
assert response.status_code == 200
payload = response.json()
assert payload["id"] == "sa-key-2"
assert "is_locked" not in payload

View File

@@ -0,0 +1,232 @@
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.admin.payments.routes import AdminPaymentOrderCreditAdapter
from src.api.payment.routes import router as payment_router
from src.config import config
from src.database import get_db
from src.models.database import PaymentOrder
from src.services.payment.gateway import get_payment_gateway
CALLBACK_SECRET = "test-callback-secret"
def _build_payment_app(db: MagicMock) -> TestClient:
app = FastAPI()
app.include_router(payment_router)
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
def _sign_payload(payload: dict[str, object]) -> str:
gateway = get_payment_gateway("alipay")
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
assert signature is not None
return signature
def test_specific_wechat_callback_route_is_not_shadowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": True,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-wechat", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/wechat",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "wechat"
assert payload["payment_method_seen"] == "wechat"
assert payload["request_path"] == "/api/payment/callback/wechat"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
db.commit.assert_called_once()
def test_generic_payment_callback_route_still_handles_custom_methods(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": False,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-generic", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/mockpay",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "mockpay"
assert payload["payment_method_seen"] == "mockpay"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
def test_callback_requires_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-token", "amount_usd": 1.0},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_invalid_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
callback_payload = {"callback_key": "cb-invalid-token", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/alipay",
json=callback_payload,
headers={
"x-payment-callback-token": "wrong-token",
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-signature", "amount_usd": 1.0},
headers={"x-payment-callback-token": CALLBACK_SECRET},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_disabled_when_secret_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", "")
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-secret-missing", "amount_usd": 1.0},
)
assert response.status_code == 503
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_admin_payment_credit_adapter_marks_manual_credit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
order = PaymentOrder(
id="po-credit",
order_no="order-credit",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("8.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("8.00000000"),
payment_method="alipay",
status="pending",
gateway_response={"existing": True},
)
adapter = AdminPaymentOrderCreditAdapter(order_id=order.id)
context = SimpleNamespace(
db=db,
raw_body=b"{}",
ensure_json_body=lambda: {
"pay_amount": 58.0,
"pay_currency": "CNY",
"exchange_rate": 7.25,
},
user=SimpleNamespace(id="admin-1"),
)
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.get_order",
lambda _db, order_id: order if order_id == "po-credit" else None,
)
captured: dict[str, object] = {}
def _fake_credit_order(_db: MagicMock, **kwargs: object) -> tuple[PaymentOrder, bool]:
captured.update(kwargs)
return order, True
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.credit_order",
_fake_credit_order,
)
result = await adapter.handle(context)
assert result["credited"] is True
assert result["order"]["id"] == "po-credit"
gateway_response = captured["gateway_response"]
assert isinstance(gateway_response, dict)
assert gateway_response["existing"] is True
assert gateway_response["manual_credit"] is True
assert gateway_response["credited_by"] == "admin-1"
db.commit.assert_called_once()

View File

@@ -3,7 +3,7 @@ API Pipeline 测试
测试 ApiRequestPipeline 的核心功能:
- 认证流程API Key、JWT Token
- 额计算
- 额计算
- 审计日志记录
"""
@@ -17,56 +17,43 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import UserRole
class TestPipelineQuotaCalculation:
"""测试 Pipeline 额计算"""
class TestPipelineBalanceCalculation:
"""测试 Pipeline 额计算"""
@pytest.fixture
def pipeline(self) -> ApiRequestPipeline:
return ApiRequestPipeline()
def test_calculate_quota_remaining_with_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试有配额限制时计算剩余"""
def test_calculate_balance_remaining_with_balance(self, pipeline: ApiRequestPipeline) -> None:
"""测试有限制钱包时计算剩余"""
mock_user = MagicMock()
mock_user.quota_usd = 100.0
mock_user.used_usd = 30.0
mock_db = MagicMock()
remaining = pipeline._calculate_quota_remaining(mock_user)
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=70.0,
):
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
assert remaining == 70.0
def test_calculate_quota_remaining_no_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试无配额限制时返回 None"""
def test_calculate_balance_remaining_unlimited(self, pipeline: ApiRequestPipeline) -> None:
"""测试无限制钱包时返回 None"""
mock_user = MagicMock()
mock_user.quota_usd = None
mock_user.used_usd = 30.0
mock_db = MagicMock()
remaining = pipeline._calculate_quota_remaining(mock_user)
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=None,
):
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
assert remaining is None
def test_calculate_quota_remaining_negative_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试负配额时返回 None"""
mock_user = MagicMock()
mock_user.quota_usd = -1
mock_user.used_usd = 0.0
remaining = pipeline._calculate_quota_remaining(mock_user)
assert remaining is None
def test_calculate_quota_remaining_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试配额已超时返回 0"""
mock_user = MagicMock()
mock_user.quota_usd = 100.0
mock_user.used_usd = 150.0
remaining = pipeline._calculate_quota_remaining(mock_user)
assert remaining == 0.0
def test_calculate_quota_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
def test_calculate_balance_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
"""测试用户为 None 时返回 None"""
remaining = pipeline._calculate_quota_remaining(None)
mock_db = MagicMock()
remaining = pipeline._calculate_balance_remaining(mock_db, None)
assert remaining is None
@@ -266,12 +253,10 @@ class TestPipelineAuthentication:
assert exc_info.value.status_code == 401
def test_authenticate_client_quota_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试配额超限时抛出异常"""
def test_authenticate_client_balance_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试余额不足时抛出异常"""
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.quota_usd = 100.0
mock_user.used_usd = 100.0
mock_api_key = MagicMock()
mock_api_key.id = "key-123"
@@ -294,13 +279,17 @@ class TestPipelineAuthentication:
):
with patch.object(
pipeline.usage_service,
"check_user_quota",
return_value=(False, "额不足"),
"check_request_balance",
return_value=(False, "额不足"),
):
from src.core.exceptions import QuotaExceededException
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=0.0,
):
from src.core.exceptions import BalanceInsufficientException
with pytest.raises(QuotaExceededException):
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
with pytest.raises(BalanceInsufficientException):
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
class TestPipelineAdminAuth:

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.wallet.routes import router as wallet_router
from src.database import get_db
def _build_wallet_app(
db: MagicMock,
monkeypatch: pytest.MonkeyPatch,
*,
payload: dict[str, object],
user_id: str = "user-1",
) -> TestClient:
app = FastAPI()
app.include_router(wallet_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id=user_id),
ensure_json_body=lambda: payload,
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.wallet.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
def test_create_refund_route_maps_uncredited_order_to_400(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-1"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
def _raise(*args: object, **kwargs: object) -> object:
raise ValueError("payment order is not refundable")
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 400
assert "not refundable" in response.json()["detail"]
db.rollback.assert_called_once()
db.commit.assert_not_called()
def test_create_refund_route_maps_reserved_wallet_amount_to_400(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
def _raise(*args: object, **kwargs: object) -> object:
raise ValueError("refund amount exceeds available refundable recharge balance")
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 400
assert "available refundable recharge balance" in response.json()["detail"]
db.rollback.assert_called_once()
db.commit.assert_not_called()
def test_create_refund_route_passes_default_order_refund_mode_and_commits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-1", "reason": "test"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
refund = SimpleNamespace(
id="refund-1",
refund_no="rf-1",
payment_order_id="order-1",
source_type="payment_order",
source_id="order-1",
refund_mode="original_channel",
amount_usd=Decimal("2.00000000"),
status="pending_approval",
reason="test",
failure_reason=None,
gateway_refund_id=None,
payout_method=None,
payout_reference=None,
payout_proof=None,
created_at="2026-03-07T00:00:00Z",
updated_at="2026-03-07T00:00:00Z",
processed_at=None,
completed_at=None,
)
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
captured: dict[str, object] = {}
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
captured.update(kwargs)
return refund
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.create_refund_request",
_create_refund_request,
)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 200
body = response.json()
assert body["id"] == "refund-1"
assert body["status"] == "pending_approval"
assert captured["refund_mode"] == "original_channel"
assert captured["source_type"] == "payment_order"
assert captured["source_id"] == "order-1"
assert captured["payment_order"] is payment_order
db.commit.assert_called_once()
db.refresh.assert_called_once_with(refund)
db.rollback.assert_not_called()
def test_create_refund_route_uses_offline_payout_for_manual_recharge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-2"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-2", wallet_id="wallet-1", payment_method="admin_manual")
refund = SimpleNamespace(
id="refund-2",
refund_no="rf-2",
payment_order_id="order-2",
source_type="payment_order",
source_id="order-2",
refund_mode="offline_payout",
amount_usd=Decimal("2.00000000"),
status="pending_approval",
reason=None,
failure_reason=None,
gateway_refund_id=None,
payout_method=None,
payout_reference=None,
payout_proof=None,
created_at="2026-03-07T00:00:00Z",
updated_at="2026-03-07T00:00:00Z",
processed_at=None,
completed_at=None,
)
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
captured: dict[str, object] = {}
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
captured.update(kwargs)
return refund
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.create_refund_request",
_create_refund_request,
)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 200
assert captured["refund_mode"] == "offline_payout"