test: add tests

This commit is contained in:
2026-06-23 14:52:00 +08:00
parent b46265cade
commit 5a7dd536b8
3 changed files with 1080 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
import pytest
from pydantic import ValidationError
from models.account import (
AccountType,
BankAccount,
CashAccount,
EWalletAccount,
)
from models.ref import AccountRef
def test_bank_account():
"""测试银行账户模型"""
# 正常创建(最简)
bank = BankAccount(name="招行工资卡", issuer_name="招商银行", account_suffix="8888")
assert bank.account_type == AccountType.ASSET
assert bank.name == "招行工资卡"
assert bank.issuer_name == "招商银行"
assert bank.account_suffix == "8888"
assert bank.account_number is None
assert bank.sub_accounts == []
assert bank.is_active is True
assert bank.deleted_at is None
# 带可选字段
ref = AccountRef(ref_id=bank.id)
bank_full = BankAccount(
name="招行工资卡",
issuer_name="招商银行",
account_suffix="8888",
account_number="6225880000008888",
sub_accounts=[ref],
primary_currency="CNY",
supported_currencies=["CNY", "HKD", "USD"],
remark="主账户",
)
assert bank_full.account_number == "6225880000008888"
assert len(bank_full.sub_accounts) == 1
assert bank_full.primary_currency == "CNY"
assert len(bank_full.supported_currencies) == 3
# 负债类型(信用卡)
credit = BankAccount(
name="招行信用卡",
issuer_name="招商银行",
account_suffix="1234",
account_type=AccountType.LIABILITY,
)
assert credit.account_type == AccountType.LIABILITY
# 必填字段缺失
with pytest.raises(ValidationError):
BankAccount(name="", issuer_name="招商银行", account_suffix="8888")
with pytest.raises(ValidationError):
BankAccount(name="招行工资卡", issuer_name="", account_suffix="8888")
def test_ewallet_account():
"""测试电子钱包账户模型"""
# 正常创建(最简)
wallet = EWalletAccount(name="支付宝日常", platform="Alipay")
assert wallet.account_type == AccountType.ASSET
assert wallet.platform == "Alipay"
assert wallet.account_id is None
# 带可选字段
wallet_full = EWalletAccount(
name="支付宝日常",
platform="Alipay",
account_id="138****0000",
primary_currency="CNY",
remark="日常消费",
)
assert wallet_full.account_id == "138****0000"
# platform 必填
with pytest.raises(ValidationError):
EWalletAccount(name="支付宝", platform="")
with pytest.raises(ValidationError):
EWalletAccount(platform="Alipay")
def test_cash_account():
"""测试现金账户模型"""
# 正常创建(最简)
cash = CashAccount(name="家里现金")
assert cash.account_type == AccountType.ASSET
assert cash.location is None
# 带可选字段
cash_full = CashAccount(name="家里现金", location="保险箱")
assert cash_full.location == "保险箱"
# name 必填
with pytest.raises(ValidationError):
CashAccount(name="")
def test_account_ref():
"""测试账户引用"""
from models.account import BankAccount
from ulid import ULID
bank = BankAccount(name="测试账户", issuer_name="测试银行", account_suffix="0000")
ref = AccountRef(ref_id=bank.id)
assert ref.ref_type == "ACCOUNT"
assert ref.ref_id == bank.id
+113
View File
@@ -0,0 +1,113 @@
import pytest
from pydantic import ValidationError
from models.channel import (
CashChannel,
ChannelType,
EWalletChannel,
PaymentCardChannel,
)
from models.ref import AccountRef
@pytest.fixture
def account_ref():
"""创建账户引用 fixture"""
from ulid import ULID
return AccountRef(ref_id=ULID())
def test_payment_card_channel(account_ref):
"""测试支付卡渠道模型"""
# 正常创建
card = PaymentCardChannel(
ref_accounts=[account_ref],
region="HK",
issuer_name="HSBC HK",
card_suffix="8335",
card_brand="UnionPay",
)
assert card.channel_type == ChannelType.PAYMENT_CARD
assert card.region == "HK"
assert card.issuer_name == "HSBC HK"
assert card.card_suffix == "8335"
assert card.card_brand == "UnionPay"
assert len(card.ref_accounts) == 1
# card_brand 可为空
card_without_brand = PaymentCardChannel(
ref_accounts=[account_ref],
region="HK",
issuer_name="AMEX HK",
card_suffix="12345",
)
assert card_without_brand.card_brand is None
# card_suffix 至少一位
with pytest.raises(ValidationError):
PaymentCardChannel(
ref_accounts=[account_ref], region="CN", issuer_name="渣打中国", card_suffix=""
)
# issuer_name 至少一位
with pytest.raises(ValidationError):
PaymentCardChannel(
ref_accounts=[account_ref], region="SG", issuer_name="", card_suffix="7431"
)
# 无效国家代码
with pytest.raises(ValidationError):
PaymentCardChannel(
ref_accounts=[account_ref], region="XX", issuer_name="Fake Bank", card_suffix="0000"
)
# ref_accounts 至少一个
with pytest.raises(ValidationError):
PaymentCardChannel(ref_accounts=[], region="CN", issuer_name="招商银行", card_suffix="8888")
def test_ewallet_channel(account_ref):
"""测试电子钱包渠道模型"""
# 正常创建
wallet = EWalletChannel(
ref_accounts=[account_ref],
region="CN",
platform="支付宝",
platform_account_id="138****0000",
sub_channel="余额",
)
assert wallet.channel_type == ChannelType.E_WALLET
assert wallet.region == "CN"
assert wallet.platform == "支付宝"
assert wallet.platform_account_id == "138****0000"
assert wallet.sub_channel == "余额"
# 可选字段可以为空
wallet_minimal = EWalletChannel(ref_accounts=[account_ref], region="CN", platform="WeChat")
assert wallet_minimal.platform_account_id is None
assert wallet_minimal.sub_channel is None
# platform 至少一位
with pytest.raises(ValidationError):
EWalletChannel(ref_accounts=[account_ref], region="CN", platform="")
def test_cash_channel(account_ref):
"""测试现金渠道模型"""
# 正常创建
cash = CashChannel(ref_accounts=[account_ref])
assert cash.channel_type == ChannelType.CASH
assert len(cash.ref_accounts) == 1
# 多个关联账户
cash_multi = CashChannel(ref_accounts=[account_ref, account_ref])
assert len(cash_multi.ref_accounts) == 2
# ref_accounts 至少一个
with pytest.raises(ValidationError):
CashChannel(ref_accounts=[])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+857
View File
@@ -0,0 +1,857 @@
import pytest
from datetime import UTC, datetime
from decimal import Decimal
from pydantic import ValidationError
from models.ref import ChannelRef, TransactionRef
from models.transaction import (
ATMTransaction,
BaseTransaction,
DcFlag,
EcomPaymentTransaction,
FxRate,
MiscInTransaction,
MiscOutTransaction,
PaymentTransaction,
PosPaymentTransaction,
Transaction,
TransactionStatus,
TransferTransaction,
)
from ulid import ULID
@pytest.fixture
def channel_ref():
"""创建渠道引用 fixture"""
from ulid import ULID
return ChannelRef(ref_id=ULID())
@pytest.fixture
def minimal_txn(channel_ref):
"""创建最简交易 fixture"""
return BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
)
class TestBaseTransactionCreation:
"""测试基础交易创建"""
def test_minimal_transaction(self, minimal_txn):
"""最简交易创建"""
assert minimal_txn.txn_amt == Decimal("100.00")
assert minimal_txn.txn_ccy == "CNY"
assert minimal_txn.dc_flag == DcFlag.DEBIT
assert minimal_txn.cp == "商户A"
assert minimal_txn.txn_sts == TransactionStatus.COMPLETED
assert minimal_txn.version == 0
assert minimal_txn.ref_transactions == []
assert minimal_txn.deleted_at is None
assert minimal_txn.posting_amt is None
assert minimal_txn.comm_amt is None
assert minimal_txn.surcharge_amt is None
assert minimal_txn.disc_amt is None
assert minimal_txn.fx_rates is None
assert minimal_txn.acq_inst is None
assert minimal_txn.clearing_network is None
assert minimal_txn.description is None
assert minimal_txn.memo is None
assert minimal_txn.raw_description is None
assert minimal_txn.raw_data is None
assert minimal_txn.txn_scene is None
def test_full_transaction(self, channel_ref):
"""完整字段交易 - 闲鱼卖电脑场景"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("5200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="买家_小明",
posting_date=datetime.now(UTC),
posting_amt=Decimal("5148.00"),
posting_ccy="CNY",
comm_amt=Decimal("52.00"),
comm_ccy="CNY",
txn_sts=TransactionStatus.COMPLETED,
acq_inst="支付宝",
description="出售 MacBook Pro 14寸 M3",
memo="二手电脑交易",
raw_description="闲鱼订单 20260622001",
raw_data={"platform": "闲鱼", "order_id": "20260622001", "item": "MacBook Pro"},
ref_channel=channel_ref,
)
assert txn.txn_amt == Decimal("5200.00")
assert txn.txn_ccy == "CNY"
assert txn.dc_flag == DcFlag.CREDIT
assert txn.posting_amt == Decimal("5148.00")
assert txn.posting_ccy == "CNY"
assert txn.comm_amt == Decimal("52.00")
assert txn.comm_ccy == "CNY"
assert txn.txn_sts == TransactionStatus.COMPLETED
assert txn.acq_inst == "支付宝"
assert txn.description == "出售 MacBook Pro 14寸 M3"
assert txn.memo == "二手电脑交易"
assert txn.raw_description == "闲鱼订单 20260622001"
assert txn.raw_data == {"platform": "闲鱼", "order_id": "20260622001", "item": "MacBook Pro"}
def test_cp_as_channel_ref(self, channel_ref):
"""对手方为渠道引用"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("50.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp=channel_ref,
ref_channel=channel_ref,
)
assert isinstance(txn.cp, ChannelRef)
assert txn.cp.ref_type == "CHANNEL"
def test_cp_as_string(self, channel_ref):
"""对手方为字符串"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("50.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
)
assert txn.cp == "商户A"
def test_clearing_network(self, channel_ref):
"""清算网络字段"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
clearing_network="银联",
)
assert txn.clearing_network == "银联"
class TestAmtCcyPairValidation:
"""测试金额/币种对校验"""
def test_all_pairs_none(self, minimal_txn):
"""所有金额/币种对都为 None(应该通过)"""
assert minimal_txn.posting_amt is None
assert minimal_txn.comm_amt is None
assert minimal_txn.surcharge_amt is None
assert minimal_txn.disc_amt is None
def test_posting_pair_both_set(self, channel_ref):
"""入账金额/币种都有值(应该通过)"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("100.00"),
posting_ccy="CNY",
)
assert txn.posting_amt == Decimal("100.00")
assert txn.posting_ccy == "CNY"
def test_posting_amt_only_should_fail(self, channel_ref):
"""只有入账金额没有币种(应该失败)"""
with pytest.raises(ValidationError):
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("95.00"),
)
def test_posting_ccy_only_should_fail(self, channel_ref):
"""只有入账币种没有金额(应该失败)"""
with pytest.raises(ValidationError):
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_ccy="USD",
)
def test_comm_pair_both_set(self, channel_ref):
"""佣金金额/币种都有值(应该通过)"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
comm_amt=Decimal("2.00"),
comm_ccy="CNY",
)
assert txn.comm_amt == Decimal("2.00")
assert txn.comm_ccy == "CNY"
def test_comm_amt_only_should_fail(self, channel_ref):
"""只有佣金金额没有币种(应该失败)"""
with pytest.raises(ValidationError):
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
comm_amt=Decimal("2.00"),
)
def test_surcharge_pair_both_set(self, channel_ref):
"""服务费金额/币种都有值(应该通过)"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
surcharge_amt=Decimal("3.00"),
surcharge_ccy="CNY",
)
assert txn.surcharge_amt == Decimal("3.00")
assert txn.surcharge_ccy == "CNY"
def test_surcharge_amt_only_should_fail(self, channel_ref):
"""只有服务费金额没有币种(应该失败)"""
with pytest.raises(ValidationError):
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
surcharge_amt=Decimal("3.00"),
)
def test_disc_pair_both_set(self, channel_ref):
"""折扣金额/币种都有值(应该通过)"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
disc_amt=Decimal("5.00"),
disc_ccy="CNY",
)
assert txn.disc_amt == Decimal("5.00")
assert txn.disc_ccy == "CNY"
def test_disc_amt_only_should_fail(self, channel_ref):
"""只有折扣金额没有币种(应该失败)"""
with pytest.raises(ValidationError):
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
disc_amt=Decimal("5.00"),
)
def test_multiple_pairs_set(self, channel_ref):
"""多对金额/币种同时设置(应该通过)"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("1000.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("1010.00"),
posting_ccy="CNY",
comm_amt=Decimal("10.00"),
comm_ccy="CNY",
)
assert txn.posting_amt == Decimal("1010.00")
assert txn.comm_amt == Decimal("10.00")
class TestAccountingEquation:
"""测试会计恒等式校验"""
def test_credit_equation_valid(self, channel_ref):
"""CREDIT: posting_amt = txn_amt + surcharge_amt - comm_amt - disc_amt"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("5200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="买家_小明",
ref_channel=channel_ref,
posting_amt=Decimal("5148.00"),
posting_ccy="CNY",
comm_amt=Decimal("52.00"),
comm_ccy="CNY",
)
assert txn.posting_amt == Decimal("5148.00")
def test_credit_equation_with_surcharge(self, channel_ref):
"""CREDIT: 含附加费"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("1000.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="客户A",
ref_channel=channel_ref,
posting_amt=Decimal("1005.00"),
posting_ccy="CNY",
comm_amt=Decimal("10.00"),
comm_ccy="CNY",
surcharge_amt=Decimal("15.00"),
surcharge_ccy="CNY",
)
assert txn.posting_amt == Decimal("1005.00")
def test_credit_equation_invalid(self, channel_ref):
"""CREDIT: 恒等式不成立应失败"""
with pytest.raises(ValidationError) as exc_info:
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("5200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="买家_小明",
ref_channel=channel_ref,
posting_amt=Decimal("5000.00"),
posting_ccy="CNY",
comm_amt=Decimal("52.00"),
comm_ccy="CNY",
)
assert "Accounting equation mismatch" in str(exc_info.value)
def test_debit_equation_valid(self, channel_ref):
"""DEBIT: posting_amt = txn_amt + surcharge_amt + comm_amt - disc_amt"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("110.00"),
posting_ccy="CNY",
comm_amt=Decimal("10.00"),
comm_ccy="CNY",
)
assert txn.posting_amt == Decimal("110.00")
def test_debit_equation_with_disc(self, channel_ref):
"""DEBIT: 含折扣"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("105.00"),
posting_ccy="CNY",
comm_amt=Decimal("10.00"),
comm_ccy="CNY",
disc_amt=Decimal("5.00"),
disc_ccy="CNY",
)
assert txn.posting_amt == Decimal("105.00")
def test_debit_equation_invalid(self, channel_ref):
"""DEBIT: 恒等式不成立应失败"""
with pytest.raises(ValidationError) as exc_info:
BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("100.00"),
posting_ccy="CNY",
comm_amt=Decimal("10.00"),
comm_ccy="CNY",
)
assert "Accounting equation mismatch" in str(exc_info.value)
def test_different_currency_skip_validation(self, channel_ref):
"""不同币种跳过校验"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("5200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="买家_小明",
ref_channel=channel_ref,
posting_amt=Decimal("700.00"),
posting_ccy="USD",
)
assert txn.posting_amt == Decimal("700.00")
def test_no_posting_amt_skip_validation(self, channel_ref):
"""无 posting_amt 跳过校验"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("5200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="买家_小明",
ref_channel=channel_ref,
)
assert txn.posting_amt is None
class TestFxRate:
"""测试汇率快照"""
def test_fx_rate_creation(self):
"""创建汇率快照"""
fx = FxRate(from_ccy="USD", to_ccy="CNY", rate=Decimal("6.75"))
assert fx.from_ccy == "USD"
assert fx.to_ccy == "CNY"
assert fx.rate == Decimal("6.75")
def test_invalid_currency_should_fail(self):
"""无效币种应该失败"""
with pytest.raises(ValidationError):
FxRate(from_ccy="INVALID", to_ccy="CNY", rate=Decimal("6.75"))
def test_transaction_with_fx_rates(self, channel_ref):
"""交易带多个汇率快照"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="USD",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
fx_rates=[
FxRate(from_ccy="USD", to_ccy="CNY", rate=Decimal("6.75")),
FxRate(from_ccy="USD", to_ccy="HKD", rate=Decimal("7.82")),
],
)
assert len(txn.fx_rates) == 2
assert txn.fx_rates[0].from_ccy == "USD"
assert txn.fx_rates[0].to_ccy == "CNY"
assert txn.fx_rates[1].from_ccy == "USD"
assert txn.fx_rates[1].to_ccy == "HKD"
class TestTransactionStatus:
"""测试交易状态"""
def test_default_status(self, minimal_txn):
"""默认状态为 COMPLETED"""
assert minimal_txn.txn_sts == TransactionStatus.COMPLETED
def test_set_pending(self, channel_ref):
"""设置为 PENDING"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
txn_sts=TransactionStatus.PENDING,
)
assert txn.txn_sts == TransactionStatus.PENDING
def test_set_failed(self, channel_ref):
"""设置为 FAILED"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
txn_sts=TransactionStatus.FAILED,
)
assert txn.txn_sts == TransactionStatus.FAILED
def test_set_refunded(self, channel_ref):
"""设置为 REFUNDED"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
txn_sts=TransactionStatus.REFUNDED,
)
assert txn.txn_sts == TransactionStatus.REFUNDED
class TestDcFlag:
"""测试借贷方向"""
def test_debit(self, channel_ref):
"""借方"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
)
assert txn.dc_flag == DcFlag.DEBIT
def test_credit(self, channel_ref):
"""贷方"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="商户A",
ref_channel=channel_ref,
)
assert txn.dc_flag == DcFlag.CREDIT
class TestTransactionRef:
"""测试交易引用"""
def test_ref_transactions(self, channel_ref):
"""关联交易引用"""
ref = TransactionRef(ref_id=ULID())
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
ref_transactions=[ref],
)
assert len(txn.ref_transactions) == 1
assert txn.ref_transactions[0].ref_type == "TRANSACTION"
class TestSoftDelete:
def test_not_deleted(self, minimal_txn):
"""未删除状态"""
assert minimal_txn.deleted_at is None
def test_set_deleted_at(self, minimal_txn):
"""设置删除时间"""
deleted_at = datetime.now(UTC)
minimal_txn.deleted_at = deleted_at
assert minimal_txn.deleted_at == deleted_at
class TestVersion:
"""测试乐观锁"""
def test_default_version(self, minimal_txn):
"""默认版本为 0"""
assert minimal_txn.version == 0
def test_set_version(self, channel_ref):
"""设置版本号"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
version=5,
)
assert txn.version == 5
class TestOptionalFields:
"""测试可选字段"""
def test_empty_description_allowed(self, channel_ref):
"""空字符串描述允许"""
txn = BaseTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
description="",
)
assert txn.description == ""
def test_all_optional_fields_none(self, minimal_txn):
"""所有可选字段都为 None"""
assert minimal_txn.description is None
assert minimal_txn.memo is None
assert minimal_txn.raw_description is None
assert minimal_txn.raw_data is None
assert minimal_txn.acq_inst is None
assert minimal_txn.clearing_network is None
class TestMiscInTransaction:
"""测试其他收入交易"""
def test_create(self, channel_ref):
"""创建其他收入交易"""
txn = MiscInTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("500.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="红包",
ref_channel=channel_ref,
posting_amt=Decimal("500.00"),
posting_ccy="CNY",
)
assert txn.txn_scene == "MISC_IN"
def test_txn_scene_fixed(self, channel_ref):
"""txn_scene 固定为 MISC_IN"""
txn = MiscInTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.CREDIT,
cp="退款",
ref_channel=channel_ref,
posting_amt=Decimal("100.00"),
posting_ccy="CNY",
)
assert txn.txn_scene == "MISC_IN"
class TestMiscOutTransaction:
"""测试其他支出交易"""
def test_create(self, channel_ref):
"""创建其他支出交易"""
txn = MiscOutTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("200.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="捐赠",
ref_channel=channel_ref,
posting_amt=Decimal("200.00"),
posting_ccy="CNY",
)
assert txn.txn_scene == "MISC_OUT"
class TestPaymentTransaction:
"""测试支付交易"""
def test_create(self, channel_ref):
"""创建支付交易"""
txn = PaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户A",
ref_channel=channel_ref,
posting_amt=Decimal("100.00"),
posting_ccy="CNY",
merchant_name="星巴克",
)
assert txn.txn_scene == "PAYMENT"
assert txn.merchant_name == "星巴克"
def test_merchant_name_optional(self, channel_ref):
"""商户名称可选"""
txn = PaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("50.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户B",
ref_channel=channel_ref,
posting_amt=Decimal("50.00"),
posting_ccy="CNY",
)
assert txn.merchant_name is None
class TestEcomPaymentTransaction:
"""测试电子商务支付交易"""
def test_create(self, channel_ref):
"""创建电商支付交易"""
txn = EcomPaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("299.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="淘宝商家",
ref_channel=channel_ref,
posting_amt=Decimal("299.00"),
posting_ccy="CNY",
merchant_name="淘宝店铺",
order_id="TB20260622001",
)
assert txn.txn_scene == "ECOM_PAYMENT"
assert txn.order_id == "TB20260622001"
assert txn.merchant_name == "淘宝店铺"
def test_order_id_optional(self, channel_ref):
"""订单ID可选"""
txn = EcomPaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("100.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="京东",
ref_channel=channel_ref,
posting_amt=Decimal("100.00"),
posting_ccy="CNY",
)
assert txn.order_id is None
class TestPosPaymentTransaction:
"""测试POS支付交易"""
def test_create_with_geo(self, channel_ref):
"""创建POS支付交易(带地理信息)"""
txn = PosPaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("50.00"),
txn_ccy="HKD",
dc_flag=DcFlag.DEBIT,
cp="7-11",
ref_channel=channel_ref,
posting_amt=Decimal("50.00"),
posting_ccy="HKD",
geo=("HK"),
)
assert txn.txn_scene == "POS_PAYMENT"
assert txn.geo == ("HK",)
def test_geo_country_only(self, channel_ref):
"""地理信息仅包含国家"""
txn = PosPaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("30.00"),
txn_ccy="HKD",
dc_flag=DcFlag.DEBIT,
cp="7-11",
ref_channel=channel_ref,
posting_amt=Decimal("30.00"),
posting_ccy="HKD",
geo=("HK",),
)
assert txn.geo == ("HK",)
def test_geo_optional(self, channel_ref):
"""地理信息可选"""
txn = PosPaymentTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("20.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="商户",
ref_channel=channel_ref,
posting_amt=Decimal("20.00"),
posting_ccy="CNY",
)
assert txn.geo is None
class TestATMTransaction:
"""测试ATM交易"""
def test_create_with_geo(self, channel_ref):
"""创建ATM交易(带地理信息)"""
txn = ATMTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("2000.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="ATM",
ref_channel=channel_ref,
posting_amt=Decimal("2000.00"),
posting_ccy="CNY",
geo=("CN", "SH"),
)
assert txn.txn_scene == "ATM"
assert txn.geo == ("CN", "SH")
def test_geo_optional(self, channel_ref):
"""地理信息可选"""
txn = ATMTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("500.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="ATM",
ref_channel=channel_ref,
posting_amt=Decimal("500.00"),
posting_ccy="CNY",
)
assert txn.geo is None
class TestTransferTransaction:
"""测试转账交易"""
def test_create(self, channel_ref):
"""创建转账交易"""
txn = TransferTransaction(
txn_date=datetime.now(UTC),
txn_amt=Decimal("10000.00"),
txn_ccy="CNY",
dc_flag=DcFlag.DEBIT,
cp="朋友A",
ref_channel=channel_ref,
posting_amt=Decimal("10000.00"),
posting_ccy="CNY",
)
assert txn.txn_scene == "TRANSFER"
class TestDiscriminatedUnion:
"""测试 Discriminated Union"""
def test_all_scenes_present(self):
"""所有交易场景都已定义"""
scenes = {"MISC_IN", "MISC_OUT", "TRANSFER", "PAYMENT", "ECOM_PAYMENT", "POS_PAYMENT", "ATM"}
# 验证所有场景都有对应的模型
assert scenes == {
MiscInTransaction.model_fields["txn_scene"].default,
MiscOutTransaction.model_fields["txn_scene"].default,
TransferTransaction.model_fields["txn_scene"].default,
PaymentTransaction.model_fields["txn_scene"].default,
EcomPaymentTransaction.model_fields["txn_scene"].default,
PosPaymentTransaction.model_fields["txn_scene"].default,
ATMTransaction.model_fields["txn_scene"].default,
}