feat: add avatar payments and finance management
This commit is contained in:
@@ -6,6 +6,9 @@ from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
ChatAttachment,
|
||||
InvoiceApplication,
|
||||
PaymentRefund,
|
||||
PaymentTransaction,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
@@ -115,6 +118,21 @@ def authorization_context():
|
||||
synchronize_session=False
|
||||
)
|
||||
user_ids = [owner.id, other.id]
|
||||
order_numbers = [
|
||||
row[0] for row in db.query(TokenPaymentOrder.order_no).filter(
|
||||
TokenPaymentOrder.user_id.in_(user_ids)
|
||||
).all()
|
||||
]
|
||||
if order_numbers:
|
||||
db.query(InvoiceApplication).filter(InvoiceApplication.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentRefund).filter(PaymentRefund.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(PaymentTransaction).filter(PaymentTransaction.order_no.in_(order_numbers)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services.huihui_payment import HuihuiPaymentClient
|
||||
@@ -48,3 +49,35 @@ def test_create_payment_uses_huihui_payment_v3_contract():
|
||||
assert body["payWay"] == "APP"
|
||||
assert body["masterOrderAmt"] == "10.00"
|
||||
assert body["payAmt"] == 10.0
|
||||
|
||||
|
||||
def test_request_refund_uses_configured_huihui_endpoint_without_exposing_secret():
|
||||
client = HuihuiPaymentClient({
|
||||
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
|
||||
"HUIHUI_APP_ID": "app-id",
|
||||
"HUIHUI_ACCESS_ID": "access-id",
|
||||
"HUIHUI_ACCESS_SECRET": "access-secret",
|
||||
})
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"code": 200, "data": {"status": "PROCESSING", "refundNo": "provider-rf"}}
|
||||
with patch.dict(os.environ, {"HUIHUI_PAYMENT_REFUND_PATH": "/payment/refund"}), patch(
|
||||
"services.huihui_payment.httpx.post", return_value=response
|
||||
) as post:
|
||||
result = client.request_refund(
|
||||
huihui_token="user-token",
|
||||
huihui_user_id="user-id",
|
||||
order_no="AV1",
|
||||
refund_no="RF1",
|
||||
amount="10.00",
|
||||
reason="用户申请",
|
||||
)
|
||||
assert result["refundNo"] == "provider-rf"
|
||||
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/refund"
|
||||
assert post.call_args.kwargs["json"] == {
|
||||
"appId": "app-id",
|
||||
"masterOrderNo": "AV1",
|
||||
"refundOrderNo": "RF1",
|
||||
"refundAmt": 10.0,
|
||||
"refundReason": "用户申请",
|
||||
}
|
||||
assert "accessSecret" not in post.call_args.kwargs["params"]
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,72 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services import wechat_virtual_payment as virtual
|
||||
|
||||
|
||||
def test_build_payment_params_signs_the_exact_compact_payload():
|
||||
order = SimpleNamespace(order_no="AV202609080001", plan_id="plan-1", price_cents=1000)
|
||||
plan = SimpleNamespace(id="plan-1", virtual_product_id="points_plan_1")
|
||||
env = {
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
result = virtual.build_payment_params(order=order, plan=plan, session_key="session-key")
|
||||
|
||||
sign_data = result["signData"]
|
||||
assert sign_data == json.dumps({
|
||||
"offerId": "offer-1",
|
||||
"buyQuantity": 1,
|
||||
"env": 1,
|
||||
"currencyType": "CNY",
|
||||
"productId": "points_plan_1",
|
||||
"goodsPrice": 1000,
|
||||
"outTradeNo": "AV202609080001",
|
||||
"attach": '{"orderNo":"AV202609080001","planId":"plan-1"}',
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert result["paySig"] == hmac.new(
|
||||
b"sandbox-key", f"requestVirtualPayment&{sign_data}".encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
assert result["signature"] == hmac.new(
|
||||
b"session-key", sign_data.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_callback_signature_and_xml_body_are_supported():
|
||||
with patch.dict(os.environ, {"WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token"}):
|
||||
signature = hashlib.sha1("".join(sorted(["callback-token", "100", "nonce"])).encode()).hexdigest()
|
||||
assert virtual.verify_callback_signature(signature, "100", "nonce")
|
||||
payload = virtual.parse_callback_body(
|
||||
b"<xml><Event>xpay_refund_notify</Event><GoodsInfo><ActualPrice>1000</ActualPrice></GoodsInfo></xml>"
|
||||
)
|
||||
assert virtual.callback_value(payload, "event") == "xpay_refund_notify"
|
||||
assert virtual.callback_value(payload, "goodsinfo", "actualprice") == "1000"
|
||||
|
||||
|
||||
def test_xpay_request_uses_server_access_token_and_pay_signature():
|
||||
token_response = Mock(status_code=200)
|
||||
token_response.json.return_value = {"access_token": "server-token", "expires_in": 7200}
|
||||
pay_response = Mock(status_code=200)
|
||||
pay_response.json.return_value = {"errcode": 0, "order": {"status": 2}}
|
||||
virtual._access_token_cache = ("", 0)
|
||||
env = {
|
||||
"WECHAT_MP_APP_ID": "wx-app",
|
||||
"WECHAT_MP_APP_SECRET": "wx-secret",
|
||||
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||
}
|
||||
with patch.dict(os.environ, env), patch.object(virtual.httpx, "get", return_value=token_response), patch.object(
|
||||
virtual.httpx, "post", return_value=pay_response
|
||||
) as post:
|
||||
result = virtual.query_order(openid="openid", order_no="AV1")
|
||||
assert result["order"]["status"] == 2
|
||||
body = '{"openid":"openid","order_id":"AV1","env":1}'
|
||||
expected = hmac.new(b"sandbox-key", f"/xpay/query_order&{body}".encode(), hashlib.sha256).hexdigest()
|
||||
assert post.call_args.args[0] == "https://api.weixin.qq.com/xpay/query_order"
|
||||
assert post.call_args.kwargs["params"] == {"access_token": "server-token", "pay_sig": expected}
|
||||
Reference in New Issue
Block a user