mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
328
tests/services/test_payment_service.py
Normal file
328
tests/services/test_payment_service.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.database import PaymentOrder, Wallet
|
||||
from src.services.payment.gateway import get_payment_gateway
|
||||
from src.services.payment import PaymentService
|
||||
|
||||
CALLBACK_SECRET = "test-callback-secret"
|
||||
|
||||
|
||||
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_create_recharge_order_creates_pending_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
user = SimpleNamespace(id="u1")
|
||||
wallet = SimpleNamespace(id="w1", status="active")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.payment.service.WalletService.get_or_create_wallet",
|
||||
lambda _db, user: wallet,
|
||||
)
|
||||
|
||||
order = PaymentService.create_recharge_order(
|
||||
db,
|
||||
user=user,
|
||||
amount_usd="12.5",
|
||||
payment_method="alipay",
|
||||
pay_amount="88.00",
|
||||
pay_currency="CNY",
|
||||
exchange_rate="7.04",
|
||||
)
|
||||
|
||||
db.add.assert_called_once()
|
||||
db.flush.assert_called_once()
|
||||
assert order.wallet_id == "w1"
|
||||
assert order.user_id == "u1"
|
||||
assert order.status == "pending"
|
||||
assert order.payment_method == "alipay"
|
||||
assert order.gateway_order_id == f"ali_{order.order_no}"
|
||||
assert isinstance(order.gateway_response, dict)
|
||||
assert order.gateway_response["gateway"] == "alipay"
|
||||
assert Decimal(order.amount_usd) == Decimal("12.50000000")
|
||||
assert Decimal(order.refundable_amount_usd) == Decimal("0")
|
||||
|
||||
|
||||
def test_refresh_order_status_marks_expired_pending_order() -> None:
|
||||
order = PaymentOrder(
|
||||
id="po-expired",
|
||||
order_no="order-expired",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("2.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("2.00000000"),
|
||||
payment_method="wechat",
|
||||
status="pending",
|
||||
)
|
||||
order.expires_at = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
changed = PaymentService.refresh_order_status(order)
|
||||
|
||||
assert changed is True
|
||||
assert order.status == "expired"
|
||||
|
||||
|
||||
def test_handle_callback_is_idempotent_for_processed_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
callback = SimpleNamespace(status="processed", payment_order_id="po1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.payment.service.PaymentService.log_callback",
|
||||
lambda *args, **kwargs: (callback, False),
|
||||
)
|
||||
|
||||
result = PaymentService.handle_callback(
|
||||
db,
|
||||
payment_method="alipay",
|
||||
callback_key="cb1",
|
||||
payload={"foo": "bar"},
|
||||
callback_signature=_sign_payload({"foo": "bar"}),
|
||||
callback_secret=CALLBACK_SECRET,
|
||||
order_no="order-1",
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["duplicate"] is True
|
||||
assert result["credited"] is False
|
||||
assert result["order_id"] == "po1"
|
||||
|
||||
|
||||
def test_handle_callback_fails_on_amount_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
callback = SimpleNamespace(
|
||||
status="received",
|
||||
payment_order_id=None,
|
||||
order_no=None,
|
||||
gateway_order_id=None,
|
||||
error_message=None,
|
||||
processed_at=None,
|
||||
)
|
||||
order = SimpleNamespace(
|
||||
id="po1",
|
||||
order_no="order-1",
|
||||
gateway_order_id=None,
|
||||
amount_usd=Decimal("10.00000000"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.payment.service.PaymentService.log_callback",
|
||||
lambda *args, **kwargs: (callback, True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.payment.service.PaymentService.get_order",
|
||||
lambda *args, **kwargs: order,
|
||||
)
|
||||
|
||||
result = PaymentService.handle_callback(
|
||||
db,
|
||||
payment_method="wechat",
|
||||
callback_key="cb2",
|
||||
payload={"foo": "bar"},
|
||||
callback_signature=_sign_payload({"foo": "bar"}),
|
||||
callback_secret=CALLBACK_SECRET,
|
||||
order_no="order-1",
|
||||
amount_usd="9.99",
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "mismatch" in result["error"]
|
||||
assert callback.status == "failed"
|
||||
|
||||
|
||||
def test_handle_callback_rejects_invalid_signature() -> None:
|
||||
db = MagicMock()
|
||||
|
||||
result = PaymentService.handle_callback(
|
||||
db,
|
||||
payment_method="alipay",
|
||||
callback_key="cb-invalid-signature",
|
||||
payload={"foo": "bar"},
|
||||
callback_signature="invalid-signature",
|
||||
callback_secret=CALLBACK_SECRET,
|
||||
order_no="order-1",
|
||||
amount_usd="9.99",
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "signature" in result["error"]
|
||||
|
||||
|
||||
def test_credit_order_applies_wallet_recharge_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
order = PaymentOrder(
|
||||
id="po1",
|
||||
order_no="order-1",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("5.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("0"),
|
||||
payment_method="alipay",
|
||||
status="pending",
|
||||
)
|
||||
wallet = Wallet(
|
||||
id="w1",
|
||||
user_id="u1",
|
||||
balance=Decimal("1.00000000"),
|
||||
status="active",
|
||||
total_recharged=Decimal("1.00000000"),
|
||||
total_consumed=Decimal("0"),
|
||||
total_refunded=Decimal("0"),
|
||||
total_adjusted=Decimal("0"),
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
class DummyOrderQuery:
|
||||
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def with_for_update(self) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def one_or_none(self) -> PaymentOrder:
|
||||
return order
|
||||
|
||||
class DummyWalletQuery:
|
||||
def filter(self, *args: object, **kwargs: object) -> "DummyWalletQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> Wallet:
|
||||
return wallet
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = [DummyOrderQuery(), DummyWalletQuery()]
|
||||
create_tx = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.services.payment.service.WalletService.create_wallet_transaction",
|
||||
create_tx,
|
||||
)
|
||||
|
||||
updated, credited = PaymentService.credit_order(
|
||||
db,
|
||||
order=order,
|
||||
gateway_order_id="gw-1",
|
||||
pay_amount="36.00",
|
||||
pay_currency="CNY",
|
||||
exchange_rate="7.20",
|
||||
)
|
||||
|
||||
assert credited is True
|
||||
assert updated.status == "credited"
|
||||
assert updated.gateway_order_id == "gw-1"
|
||||
assert updated.paid_at is not None
|
||||
assert updated.credited_at is not None
|
||||
assert Decimal(updated.refundable_amount_usd) == Decimal("5.00000000")
|
||||
create_tx.assert_called_once()
|
||||
|
||||
|
||||
def test_expire_order_marks_pending_order_expired() -> None:
|
||||
order = PaymentOrder(
|
||||
id="po2",
|
||||
order_no="order-2",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("3.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("3.00000000"),
|
||||
payment_method="wechat",
|
||||
status="pending",
|
||||
)
|
||||
order.expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
|
||||
class DummyOrderQuery:
|
||||
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def with_for_update(self) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def one_or_none(self) -> PaymentOrder:
|
||||
return order
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value = DummyOrderQuery()
|
||||
|
||||
updated, changed = PaymentService.expire_order(db, order=order, reason="ops_close")
|
||||
|
||||
assert changed is True
|
||||
assert updated.status == "expired"
|
||||
assert updated.gateway_response["expire_reason"] == "ops_close"
|
||||
assert "expired_at" in updated.gateway_response
|
||||
|
||||
|
||||
def test_expire_order_is_idempotent_for_existing_expired_order() -> None:
|
||||
order = PaymentOrder(
|
||||
id="po3",
|
||||
order_no="order-3",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("1.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("1.00000000"),
|
||||
payment_method="alipay",
|
||||
status="expired",
|
||||
)
|
||||
|
||||
class DummyOrderQuery:
|
||||
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def with_for_update(self) -> "DummyOrderQuery":
|
||||
return self
|
||||
|
||||
def one_or_none(self) -> PaymentOrder:
|
||||
return order
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value = DummyOrderQuery()
|
||||
|
||||
updated, changed = PaymentService.expire_order(db, order=order)
|
||||
|
||||
assert changed is False
|
||||
assert updated.status == "expired"
|
||||
|
||||
|
||||
def test_list_orders_expires_overdue_pending_before_filtered_count() -> None:
|
||||
expiry_query = MagicMock()
|
||||
expiry_query.filter.return_value = expiry_query
|
||||
expiry_query.update.return_value = 2
|
||||
|
||||
list_query = MagicMock()
|
||||
list_query.filter.return_value = list_query
|
||||
list_query.count.return_value = 1
|
||||
ordered_query = MagicMock()
|
||||
list_query.order_by.return_value = ordered_query
|
||||
ordered_query.offset.return_value = ordered_query
|
||||
ordered_query.limit.return_value = ordered_query
|
||||
ordered_query.all.return_value = ["order-1"]
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = [expiry_query, list_query]
|
||||
|
||||
items, total, changed = PaymentService.list_orders(
|
||||
db,
|
||||
status="pending",
|
||||
payment_method="alipay",
|
||||
limit=5,
|
||||
offset=10,
|
||||
)
|
||||
|
||||
assert items == ["order-1"]
|
||||
assert total == 1
|
||||
assert changed is True
|
||||
expiry_query.update.assert_called_once()
|
||||
ordered_query.offset.assert_called_once_with(10)
|
||||
ordered_query.limit.assert_called_once_with(5)
|
||||
Reference in New Issue
Block a user