From 5a7dd536b8ce97f2e482929b91bfd9dee092494d Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:52:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fluxent/tests/test_accounts.py | 110 +++ packages/fluxent/tests/test_channels.py | 113 +++ packages/fluxent/tests/test_transactions.py | 857 ++++++++++++++++++++ 3 files changed, 1080 insertions(+) create mode 100644 packages/fluxent/tests/test_accounts.py create mode 100644 packages/fluxent/tests/test_channels.py create mode 100644 packages/fluxent/tests/test_transactions.py diff --git a/packages/fluxent/tests/test_accounts.py b/packages/fluxent/tests/test_accounts.py new file mode 100644 index 0000000..3ffc665 --- /dev/null +++ b/packages/fluxent/tests/test_accounts.py @@ -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 \ No newline at end of file diff --git a/packages/fluxent/tests/test_channels.py b/packages/fluxent/tests/test_channels.py new file mode 100644 index 0000000..e609a92 --- /dev/null +++ b/packages/fluxent/tests/test_channels.py @@ -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"]) diff --git a/packages/fluxent/tests/test_transactions.py b/packages/fluxent/tests/test_transactions.py new file mode 100644 index 0000000..03b974c --- /dev/null +++ b/packages/fluxent/tests/test_transactions.py @@ -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, + } \ No newline at end of file