Compare commits

...

3 Commits

Author SHA1 Message Date
SerinaNya ac5a3c6dd7 📝 docs(AGENTS.md): init 2026-06-23 14:52:19 +08:00
SerinaNya 5a7dd536b8 test: add tests 2026-06-23 14:52:00 +08:00
SerinaNya b46265cade feat(transaction) 2026-06-23 14:40:20 +08:00
5 changed files with 1127 additions and 8 deletions
+37
View File
@@ -0,0 +1,37 @@
# Fluxent 记账逻辑
## dc_flag 语义
`dc_flag` 表示**资金流向**(从用户角度),不反转:
- `DEBIT` = 资金流出(支出)
- `CREDIT` = 资金流入(收入)
## 账户类型与余额变化
`account_type` 决定余额如何变化:
| 账户类型 | DEBIT(支出) | CREDIT(收入) |
|----------|--------------|---------------|
| `ASSET`(资产) | 余额减少 | 余额增加 |
| `LIABILITY`(负债) | 负债减少(还款) | 负债增加(消费/借款) |
| `EQUITY`(权益) | 权益减少 | 权益增加 |
## 信用卡交易示例
| 交易 | 账户 | account_type | dc_flag | 余额变化 |
|------|------|-------------|---------|---------|
| 用信用卡买咖啡 ¥30 | 信用卡 | LIABILITY | DEBIT | 负债 +30 |
| 从储蓄卡还信用卡 ¥5000 | 储蓄卡 | ASSET | DEBIT | 余额 -5000 |
| 从储蓄卡还信用卡 ¥5000 | 信用卡 | LIABILITY | CREDIT | 负债 -5000 |
## 会计恒等式
同币种时校验:
- **CREDIT(收入)**: `posting_amt = txn_amt + surcharge_amt - comm_amt - disc_amt`
- **DEBIT(支出)**: `posting_amt = txn_amt + surcharge_amt + comm_amt - disc_amt`
## 金额/币种对校验
可选的金额/币种对(`posting_amt/ccy``comm_amt/ccy``surcharge_amt/ccy``disc_amt/ccy`)必须同时为 `None` 或同时有值。
+10 -8
View File
@@ -1,7 +1,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from enum import StrEnum from enum import StrEnum
from typing import Annotated, Self from typing import Annotated, Literal, Self
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from pydantic_extra_types.country import CountryAlpha2 from pydantic_extra_types.country import CountryAlpha2
@@ -128,33 +128,35 @@ class BaseTransaction(BaseModel):
class MiscInTransaction(BaseTransaction): class MiscInTransaction(BaseTransaction):
"""其他收入交易""" """其他收入交易"""
txn_scene = "MISC_IN" txn_scene: Annotated[Literal["MISC_IN"], "交易场景"] = "MISC_IN"
dc_flag: Annotated[DcFlag, "借贷方向"] = DcFlag.CREDIT
class MiscOutTransaction(BaseTransaction): class MiscOutTransaction(BaseTransaction):
"""其他支出交易""" """其他支出交易"""
txn_scene = "MISC_OUT" txn_scene: Annotated[Literal["MISC_OUT"], "交易场景"] = "MISC_OUT"
dc_flag: Annotated[DcFlag, "借贷方向"] = DcFlag.DEBIT
class PaymentTransaction(MiscOutTransaction): class PaymentTransaction(MiscOutTransaction):
"""支付交易""" """支付交易"""
txn_scene = "PAYMENT" txn_scene: Annotated[Literal["PAYMENT"], "交易场景"] = "PAYMENT"
merchant_name: Annotated[str | None, "商户名称", Field(default=None)] merchant_name: Annotated[str | None, "商户名称", Field(default=None)]
class EcomPaymentTransaction(PaymentTransaction): class EcomPaymentTransaction(PaymentTransaction):
"""电子商务支付交易""" """电子商务支付交易"""
txn_scene = "ECOM_PAYMENT" txn_scene: Annotated[Literal["ECOM_PAYMENT"], "交易场景"] = "ECOM_PAYMENT"
order_id: Annotated[str | None, "订单ID", Field(default=None)] order_id: Annotated[str | None, "订单ID", Field(default=None)]
class PosPaymentTransaction(PaymentTransaction): class PosPaymentTransaction(PaymentTransaction):
"""POS支付交易""" """POS支付交易"""
txn_scene = "POS_PAYMENT" txn_scene: Annotated[Literal["POS_PAYMENT"], "交易场景"] = "POS_PAYMENT"
geo: Annotated[ geo: Annotated[
tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None) tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None)
] ]
@@ -163,7 +165,7 @@ class PosPaymentTransaction(PaymentTransaction):
class ATMTransaction(BaseTransaction): class ATMTransaction(BaseTransaction):
"""ATM交易""" """ATM交易"""
txn_scene = "ATM" txn_scene: Annotated[Literal["ATM"], "交易场景"] = "ATM"
geo: Annotated[ geo: Annotated[
tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None) tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None)
] ]
@@ -172,7 +174,7 @@ class ATMTransaction(BaseTransaction):
class TransferTransaction(BaseTransaction): class TransferTransaction(BaseTransaction):
"""转账交易""" """转账交易"""
txn_scene = "TRANSFER" txn_scene: Annotated[Literal["TRANSFER"], "交易场景"] = "TRANSFER"
type Transaction = Annotated[ type Transaction = Annotated[
+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,
}