🎉 init

This commit is contained in:
2026-06-22 23:58:01 +08:00
commit 7ba6a32015
11 changed files with 670 additions and 0 deletions
View File
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Annotated, Literal
from annotated_types import MinLen
from pydantic import BaseModel, Field
from pydantic_extra_types.currency_code import Currency
from ulid import ULID
from .ref import AccountRef
class AccountType(StrEnum):
"""资金性质"""
ASSET = "ASSET"
LIABILITY = "LIABILITY"
EQUITY = "EQUITY"
class BaseAccount(BaseModel):
"""基础账户模型"""
id: ULID = Field(default_factory=ULID)
name: Annotated[str, "用户自定义名称", MinLen(1)]
account_type: AccountType
primary_currency: Annotated[Currency | None, "主币种(可选)", Field(default=None)]
supported_currencies: Annotated[
list[Currency] | None, "支持的币种列表(None=全币种)", Field(default=None)
]
is_active: Annotated[bool, "是否启用", Field(default=True)]
remark: Annotated[str | None, "备注", Field(default=None)]
created_at: Annotated[datetime, "创建时间", Field(default_factory=lambda: datetime.now(UTC))]
updated_at: Annotated[datetime, "更新时间", Field(default_factory=lambda: datetime.now(UTC))]
deleted_at: Annotated[datetime | None, "软删除时间(None=未删除)", Field(default=None)]
class BankAccount(BaseAccount):
"""银行账户"""
account_type: Literal[AccountType.ASSET, AccountType.LIABILITY] = AccountType.ASSET
issuer_name: Annotated[str, "银行名称", MinLen(1)]
account_number: Annotated[str | None, "完整账户号(敏感信息)", Field(default=None)]
account_suffix: Annotated[
str | None, "账户号末尾部分,用于识别", MinLen(1), Field(default=None)
]
sub_accounts: Annotated[list[AccountRef], "子账户引用", Field(default_factory=list)]
class EWalletAccount(BaseAccount):
"""电子钱包账户"""
account_type: Literal[AccountType.ASSET] = AccountType.ASSET
platform: Annotated[
str,
"平台名称: Alipay/WeChat/PayMe/AlipayHK/MPay/Octopus/Tap&Go/AlipayMO",
MinLen(1),
]
account_id: Annotated[str | None, "平台账户ID", Field(default=None)]
class CashAccount(BaseAccount):
"""现金账户"""
account_type: Literal[AccountType.ASSET] = AccountType.ASSET
location: Annotated[str | None, "存放位置描述", Field(default=None)]
type Account = Annotated[
BankAccount | EWalletAccount | CashAccount,
Field(discriminator="account_type"),
]
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Annotated
from annotated_types import MinLen
from pydantic import BaseModel, Field
from pydantic_extra_types.country import CountryAlpha2
from ulid import ULID
from .ref import AccountRef
class ChannelType(StrEnum):
"""渠道类型"""
PAYMENT_CARD = "PAYMENT_CARD"
E_WALLET = "E_WALLET"
CASH = "CASH"
class PaymentInstrumentType(StrEnum):
"""支付工具类型"""
CREDIT = "CREDIT"
DEBIT = "DEBIT"
# 暂无实现的必要
# PREPAID = "PREPAID"
# BNPL = "BNPL"
# CONSUMER_LOAN = "CONSUMER_LOAN"
class BaseChannel(BaseModel):
"""基础渠道模型"""
id: ULID = Field(default_factory=ULID)
ref_accounts: Annotated[list[AccountRef], "关联的账户引用列表", MinLen(1)]
created_at: Annotated[datetime, "创建时间", Field(default_factory=lambda: datetime.now(UTC))]
updated_at: Annotated[datetime, "更新时间", Field(default_factory=lambda: datetime.now(UTC))]
deleted_at: Annotated[datetime | None, "软删除时间(None=未删除)", Field(default=None)]
class PaymentCardChannel(BaseChannel):
"""支付卡渠道"""
channel_type = ChannelType.PAYMENT_CARD
region: Annotated[CountryAlpha2, "发卡地/清算区域"]
issuer_name: Annotated[str, "发卡方名称", MinLen(1)]
card_type: Annotated[PaymentInstrumentType, "卡类型"]
card_suffix: Annotated[str, "卡号末尾部分,可为后4/5位或完整卡号", MinLen(1)]
card_brand: Annotated[
str | None,
"卡组织: UnionPay/Visa/Mastercard/JCB/AMEX/Octopus。通常可由完整卡号自动推断",
Field(default=None),
]
class EWalletChannel(BaseChannel):
"""电子钱包渠道"""
channel_type = ChannelType.E_WALLET
region: Annotated[CountryAlpha2, "钱包所属区域"]
platform: Annotated[
str,
"平台名: Alipay/WeChatPay/PayMe/AlipayHK/MPay/Octopus/AlipayMO",
MinLen(1),
]
platform_account_id: Annotated[str | None, "平台账户ID/手机号掩码", Field(default=None)]
sub_channel: Annotated[
str | None, "子渠道: 余额/花呗/信用卡快捷/八达通App等", Field(default=None)
]
sub_channel_type: Annotated[PaymentInstrumentType, "子渠道类型"]
class CashChannel(BaseChannel):
"""现金渠道"""
channel_type = ChannelType.CASH
class TransferChannel(BaseChannel):
"""转账渠道"""
channel_type = ChannelType.TRANSFER
type Channel = Annotated[
PaymentCardChannel | EWalletChannel | CashChannel,
Field(discriminator="channel_type"),
]
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from ulid import ULID
class BaseRef(BaseModel):
"""基础引用模型"""
ref_type: str
ref_id: ULID
class AccountRef(BaseRef):
"""账户引用"""
ref_type: Literal["ACCOUNT"] = "ACCOUNT"
class ChannelRef(BaseRef):
"""渠道引用"""
ref_type: Literal["CHANNEL"] = "CHANNEL"
class TransactionRef(BaseRef):
"""交易引用"""
ref_type: Literal["TRANSACTION"] = "TRANSACTION"
+187
View File
@@ -0,0 +1,187 @@
from datetime import UTC, datetime
from decimal import Decimal
from enum import StrEnum
from typing import Annotated, Self
from pydantic import BaseModel, Field, model_validator
from pydantic_extra_types.country import CountryAlpha2
from pydantic_extra_types.currency_code import Currency
from ulid import ULID
from .ref import ChannelRef, TransactionRef
class TransactionStatus(StrEnum):
"""交易状态"""
PENDING = "PENDING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
REFUNDED = "REFUNDED"
class DcFlag(StrEnum):
"""借贷方向"""
DEBIT = "DEBIT"
CREDIT = "CREDIT"
class FxRate(BaseModel):
"""汇率快照"""
from_ccy: Annotated[Currency, "源币种"]
to_ccy: Annotated[Currency, "目标币种"]
rate: Annotated[Decimal, "汇率"]
class BaseTransaction(BaseModel):
"""基础交易模型"""
id: ULID = Field(default_factory=ULID)
version: Annotated[int, "乐观锁版本号", Field(default=0)]
ref_transactions: Annotated[
list[TransactionRef], "关联的交易引用列表", Field(default_factory=list)
]
txn_date: Annotated[datetime, "交易日期"]
clearing_date: Annotated[datetime | None, "清算日期", Field(default=None)]
posting_date: Annotated[datetime | None, "入账日期", Field(default=None)]
created_at: Annotated[datetime, "创建时间", Field(default_factory=lambda: datetime.now(UTC))]
updated_at: Annotated[datetime, "更新时间", Field(default_factory=lambda: datetime.now(UTC))]
deleted_at: Annotated[datetime | None, "软删除时间(None=未删除)", Field(default=None)]
txn_amt: Annotated[Decimal, "交易金额"]
txn_ccy: Annotated[Currency, "交易币种"]
posting_amt: Annotated[Decimal | None, "入账金额", Field(default=None)]
posting_ccy: Annotated[Currency | None, "入账币种", Field(default=None)]
comm_amt: Annotated[Decimal | None, "手续费金额", Field(default=None)]
comm_ccy: Annotated[Currency | None, "手续费币种", Field(default=None)]
surcharge_amt: Annotated[Decimal | None, "服务费金额", Field(default=None)]
surcharge_ccy: Annotated[Currency | None, "服务费币种", Field(default=None)]
disc_amt: Annotated[Decimal | None, "折扣金额", Field(default=None)]
disc_ccy: Annotated[Currency | None, "折扣币种", Field(default=None)]
fx_rates: Annotated[list[FxRate] | None, "汇率快照列表", Field(default=None)]
dc_flag: Annotated[DcFlag, "借贷方向"]
ref_channel: Annotated[ChannelRef, "关联的渠道引用列表"]
cp: Annotated[str | ChannelRef, "对手方(字符串描述或渠道引用)"]
acq_inst: Annotated[str | None, "收单机构", Field(default=None)]
clearing_network: Annotated[str | None, "清算网络", Field(default=None)]
txn_sts: Annotated[TransactionStatus, "交易状态", Field(default=TransactionStatus.COMPLETED)]
description: Annotated[str | None, "交易描述", Field(default=None)]
memo: Annotated[str | None, "备注", Field(default=None)]
raw_description: Annotated[str | None, "原始交易描述", Field(default=None)]
raw_data: Annotated[dict | None, "原始数据", Field(default=None)]
txn_scene: Annotated[str | None, "交易场景", Field(default=None)]
@model_validator(mode="after")
def _validate_amt_ccy_pairs(self) -> Self:
pairs = [
("posting_amt", "posting_ccy"),
("comm_amt", "comm_ccy"),
("surcharge_amt", "surcharge_ccy"),
("disc_amt", "disc_ccy"),
]
for amt_field, ccy_field in pairs:
amt = getattr(self, amt_field)
ccy = getattr(self, ccy_field)
if (amt is None) != (ccy is None):
msg = f"{amt_field} and {ccy_field} must both be None or both have values"
raise ValueError(msg)
return self
@model_validator(mode="after")
def _validate_accounting_equation(self) -> Self:
"""
会计恒等式校验(同币种)
- CREDIT: posting_amt = txn_amt + surcharge_amt - comm_amt - disc_amt
- DEBIT: posting_amt = txn_amt + surcharge_amt + comm_amt - disc_amt
"""
if self.posting_amt is None or self.posting_ccy != self.txn_ccy:
return self
expected = self.txn_amt
if self.surcharge_amt is not None and self.surcharge_ccy == self.txn_ccy:
expected += self.surcharge_amt
if self.disc_amt is not None and self.disc_ccy == self.txn_ccy:
expected -= self.disc_amt
if self.dc_flag == DcFlag.CREDIT:
if self.comm_amt is not None and self.comm_ccy == self.txn_ccy:
expected -= self.comm_amt
elif self.dc_flag == DcFlag.DEBIT:
if self.comm_amt is not None and self.comm_ccy == self.txn_ccy:
expected += self.comm_amt
if self.posting_amt != expected:
msg = (
f"Accounting equation mismatch ({self.dc_flag.value}): "
f"posting_amt({self.posting_amt}) != "
f"txn_amt({self.txn_amt}) + surcharge_amt({self.surcharge_amt})"
f" {'+' if self.dc_flag == DcFlag.DEBIT else '-'} comm_amt({self.comm_amt})"
f" - disc_amt({self.disc_amt})"
)
raise ValueError(msg)
return self
class MiscInTransaction(BaseTransaction):
"""其他收入交易"""
txn_scene = "MISC_IN"
class MiscOutTransaction(BaseTransaction):
"""其他支出交易"""
txn_scene = "MISC_OUT"
class PaymentTransaction(MiscOutTransaction):
"""支付交易"""
txn_scene = "PAYMENT"
merchant_name: Annotated[str | None, "商户名称", Field(default=None)]
class EcomPaymentTransaction(PaymentTransaction):
"""电子商务支付交易"""
txn_scene = "ECOM_PAYMENT"
order_id: Annotated[str | None, "订单ID", Field(default=None)]
class PosPaymentTransaction(PaymentTransaction):
"""POS支付交易"""
txn_scene = "POS_PAYMENT"
geo: Annotated[
tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None)
]
class ATMTransaction(BaseTransaction):
"""ATM交易"""
txn_scene = "ATM"
geo: Annotated[
tuple[CountryAlpha2] | tuple[CountryAlpha2, str] | None, "地理信息", Field(default=None)
]
class TransferTransaction(BaseTransaction):
"""转账交易"""
txn_scene = "TRANSFER"
type Transaction = Annotated[
MiscInTransaction
| MiscOutTransaction
| TransferTransaction
| PaymentTransaction
| EcomPaymentTransaction
| PosPaymentTransaction
| ATMTransaction,
Field(discriminator="txn_scene"),
]