feat: add avatar payments and finance management

This commit is contained in:
stefanfeng
2026-09-08 18:44:21 +08:00
parent 1bcdcead8d
commit 7e7aa06903
36 changed files with 10228 additions and 43 deletions
@@ -0,0 +1,104 @@
import hashlib
import json
import os
from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User
from services.token_billing import DEFAULT_TOKEN_GRANT
client = TestClient(app)
def _signature(token, timestamp, nonce):
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context):
seed()
context = authorization_context
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.wechat_mp_openid = "openid-flow"
user.wechat_mp_session_key = "session-flow"
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
plan.virtual_product_id = "points_plan_1"
db.commit()
finally:
db.close()
env = {
"WECHAT_VIRTUAL_ENV": "sandbox",
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token",
"AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123",
}
with patch.dict(os.environ, env):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
).json()["data"]
assert created["provider"] == "wechat_virtual"
params = json.loads(created["payMessage"])
assert params["mode"] == "short_series_goods"
assert "session-flow" not in created["payMessage"]
notify = {
"Event": "xpay_goods_deliver_notify",
"OutTradeNo": created["orderNo"],
"OpenId": "openid-flow",
"Env": 1,
"GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}),
"WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}),
}
query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")}
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
invoice = client.post(
f"/api/token/orders/{created['orderNo']}/invoice",
headers=context["owner_headers"],
json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"},
).json()["data"]
assert invoice["status"] == "pending"
with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}):
refund_response = client.post(
f"/api/token/admin/orders/{created['orderNo']}/refund",
headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"},
json={"reason": "用户申请退款", "operator": "tester"},
)
assert refund_response.json()["data"]["status"] == "processing"
refund_no = refund_response.json()["data"]["refundNo"]
refund_notify = {
"Event": "xpay_refund_notify",
"MchOrderId": created["orderNo"],
"MchRefundId": refund_no,
"WxRefundId": "wx-refund-1",
"RefundFee": 1000,
"RetCode": 0,
}
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0
assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one()
invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one()
assert order.status == "refunded"
assert refund.status == "succeeded"
assert invoice.status == "cancelled"
assert account.balance == DEFAULT_TOKEN_GRANT
finally:
db.close()