first commit

This commit is contained in:
fengmengqi 2026-07-13 13:34:27 +08:00
commit 2086f67e68
17 changed files with 11994 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
android/
ios/
*.hprof
# Metro
.metro-health-check*
# Debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Env
.env
.env.*
# Outputs
outputs/
# MiMoCode
.mimocode/

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# Bean Mobile
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`CSV 账单先生成复式分录草稿,再由用户确认。
## 启动
```bash
npm install
npm run start
```
使用 Expo CNG 开发构建运行:`npm run android` 或 `npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布。
## 账本约定
在主账本添加一次:
```beancount
include "mobile.bean"
```
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`。导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。
## 当前边界
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。

10
app.json Normal file
View File

@ -0,0 +1,10 @@
{
"expo": {
"name": "Bean Mobile",
"slug": "bean-mobile",
"scheme": "beanmobile",
"plugins": [["expo-sqlite", { "enableFTS": true }]],
"ios": { "bundleIdentifier": "com.example.beanmobile" },
"android": { "package": "com.example.beanmobile" }
}
}

11103
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "beancount-mobile",
"version": "0.1.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"expo": "~54.0.0",
"expo-file-system": "~19.0.0",
"expo-sqlite": "~16.0.0",
"expo-status-bar": "~3.0.0",
"react": "19.1.0",
"react-native": "0.81.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.0",
"vitest": "^3.2.0"
}
}

458
plan-ocr.md Normal file
View File

@ -0,0 +1,458 @@
# OCR 自动记账集成方案
## 摘要
借鉴 AutoAccounting 项目的 OCR 模式,为 beancount-mobile 增加屏幕识别自动记账能力。用户在支付宝/微信/银行 App 付款后,通过无障碍服务截屏 → OCR 识别 → 解析账单 → 生成 Beancount 复式分录 → 用户确认入账。
**核心原则**:不修改任何应用、不需要 Root、不需要 Shizuku仅依赖 Android 无障碍权限。
## 技术方案
### 架构概览
```
用户在支付 App 完成付款
Android 无障碍服务检测到页面变化
AccessibilityService.takeScreenshot() 截屏API 30+
Google ML Kit OCR 识别文字
正则 + 规则引擎解析为 ImportedEvent
复用现有 classify() → TransactionDraft
用户确认 → commitMobileTransaction() → mobile.bean
```
### 依赖 AutoAccounting 的部分
| AutoAccounting 组件 | 用途 | beancount-mobile 替代方案 |
| --------------------------- | ------------------------- | ---------------------------------- |
| `OcrTools.kt` | 无障碍截屏 + 前台应用检测 | 原生模块重写,逻辑一致 |
| `OcrProcessor.kt` | PP-OCRv5 文字识别 | Google ML Kit免费无需 AAR |
| `JsExecutor.kt` | QuickJS 规则引擎 | 复用现有`importStatement` + 规则 |
| `BillService.kt` | 账单分析流程 | 复用现有`classify()` 流程 |
| `PageSignatureManager.kt` | 页面特征匹配 | 可选,首版不做 |
| `FlipDetector.kt` | 翻转触发 | 改为悬浮按钮触发 |
### 不依赖的部分
- Shizuku SDK无障碍模式不需要
- Xposed/LSPatch 框架
- Ktor 嵌入式服务器
- Room 数据库
- MMKV 配置存储
- TapBack 双击背部模块
## 实现计划
### 阶段一原生模块搭建3 天)
#### 1.1 创建 Android 原生模块目录结构
```
android/app/src/main/java/com/beancount/mobile/
├── ocr/
│ ├── OcrModule.kt # React Native 原生模块
│ ├── OcrAccessibilityService.kt # 无障碍服务
│ └── OcrManager.kt # OCR 处理管理器
```
#### 1.2 实现 OcrAccessibilityService.kt
参考 AutoAccounting 的 `OcrTools.kt`,实现:
```kotlin
class OcrAccessibilityService : AccessibilityService() {
// 1. 监听窗口变化事件
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// 检测前台应用变化
// 触发截屏回调
}
// 2. 截屏功能API 30+
fun takeScreenshot(callback: (Bitmap?) -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
val bitmap = Bitmap.wrapHardwareBuffer(
result.hardwareBuffer, result.colorSpace
)
callback(bitmap)
result.hardwareBuffer.close()
}
override fun onFailure(errorCode: Int) {
callback(null)
}
}
)
}
}
// 3. 获取前台应用包名
fun getTopPackage(): String? {
// 通过 rootInActiveWindow 获取
}
}
```
#### 1.3 实现 OcrModule.ktReact Native Bridge
```kotlin
@ReactModule(name = "OcrModule")
class OcrModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@ReactMethod
fun startOcrService(promise: Promise) {
// 启动无障碍服务
}
@ReactMethod
fun stopOcrService(promise: Promise) {
// 停止无障碍服务
}
@ReactMethod
fun takeScreenshot(promise: Promise) {
// 调用无障碍服务截屏
// 返回 base64 编码的图片
}
@ReactMethod
fun getTopApp(promise: Promise) {
// 返回前台应用包名
}
@ReactMethod
fun isServiceEnabled(promise: Promise) {
// 检查无障碍服务是否已启用
}
}
```
#### 1.4 配置 AndroidManifest.xml
```xml
<service
android:name=".ocr.OcrAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
```
#### 1.5 创建无障碍服务配置
`android/app/src/main/res/xml/accessibility_service_config.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/ocr_service_description"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canTakeScreenshot="true"
android:canRetrieveWindowContent="false" />
```
### 阶段二OCR 引擎集成2 天)
#### 2.1 添加 ML Kit 依赖
`android/app/build.gradle`:
```gradle
dependencies {
// Google ML Kit OCR
implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}
```
#### 2.2 实现 OcrManager.kt
```kotlin
class OcrManager(private val context: Context) {
private val recognizer = TextRecognition.getClient(
ChineseTextRecognizerOptions.Builder().build()
)
suspend fun recognizeText(bitmap: Bitmap): String {
val image = InputImage.fromBitmap(bitmap, 0)
val result = recognizer.process(image).await()
return result.text
}
}
```
#### 2.3 金额/商户正则解析
参考 AutoAccounting 的 JS 规则,用 Kotlin 正则实现:
```kotlin
object BillParser {
// 金额匹配¥100.00 / 100.00元 / -50.50
private val amountPattern = Regex("""[¥¥]?\s*(-?\d+\.?\d*)\s*元?""")
// 时间匹配2026-07-10 14:30:00 / 07-10 14:30
private val timePattern = Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}(?::\d{2})?)""")
// 商户匹配:支付成功至 XXX / 商户名称XXX
private val merchantPattern = Regex("""(?:商户|商家|收款方)[:]\s*(.+?)(?:\s|$)""")
fun parse(ocrText: String, appPackage: String): ImportedEvent? {
val amount = amountPattern.find(ocrText)?.groupValues?.get(1) ?: return null
val time = timePattern.find(ocrText)?.groupValues?.get(1) ?: return null
val merchant = merchantPattern.find(ocrText)?.groupValues?.get(1) ?: "未知商户"
// 根据 appPackage 判断渠道
val channel = when {
appPackage.contains("alipay") -> "Alipay"
appPackage.contains("wechat") -> "WeChat"
else -> "Bank"
}
return ImportedEvent(
id = "ocr-${System.currentTimeMillis()}",
occurredAt = time.replace("/", "-").take(10),
amount = amount,
currency = "CNY",
direction = if (amount.startsWith("-")) "expense" else "income",
channel = channel,
counterparty = merchant,
memo = "OCR 自动识别",
raw = mapOf("ocrText" to ocrText)
)
}
}
```
### 阶段三JS 层集成2 天)
#### 3.1 创建 OCR Bridge 模块
`src/ocr/OcrBridge.ts`:
```typescript
import { NativeModules, Platform } from 'react-native';
const { OcrModule } = NativeModules;
export interface OcrResult {
text: string;
imagePath: string;
}
export class OcrBridge {
static async isAvailable(): Promise<boolean> {
if (Platform.OS !== 'android') return false;
return await OcrModule?.isServiceEnabled() ?? false;
}
static async startService(): Promise<void> {
await OcrModule?.startOcrService();
}
static async takeScreenshot(): Promise<string | null> {
return await OcrModule?.takeScreenshot();
}
static async getTopApp(): Promise<string | null> {
return await OcrModule?.getTopApp();
}
}
```
#### 3.2 OCR 识别流程
`src/ocr/OcrProcessor.ts`:
```typescript
import { OcrBridge } from './OcrBridge';
import { importStatement, type ImportedEvent } from '../domain';
export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
// 1. 截屏
const base64Image = await OcrBridge.takeScreenshot();
if (!base64Image) return null;
// 2. 获取前台应用
const topApp = await OcrBridge.getTopApp();
if (!topApp) return null;
// 3. OCR 识别(通过原生模块)
const ocrText = await OcrModule.recognizeText(base64Image);
if (!ocrText) return null;
// 4. 解析为 ImportedEvent
const event = parseOcrText(ocrText, topApp);
return event;
}
```
#### 3.3 集成到现有导入流程
修改 `App.tsx` 的导入标签页,添加 OCR 入口:
```typescript
const handleOcrImport = async () => {
const event = await processOcrScreenshot();
if (event) {
setEvents(current => [...current, event]);
setMessage('OCR 识别成功,请确认账单。');
} else {
setMessage('OCR 识别失败,请重试。');
}
};
```
### 阶段四UI 适配2 天)
#### 4.1 添加 OCR 触发按钮
在导入标签页添加"屏幕识别"按钮:
```tsx
{tab === '导入' && (
<>
<Card title="自动记账">
<Button label="屏幕识别OCR" onPress={handleOcrImport} />
<Button label="导入 CSV 文件" onPress={loadDemo} />
</Card>
{/* 现有的事件列表 */}
</>
)}
```
#### 4.2 OCR 结果确认界面
展示识别结果,允许用户修正:
```tsx
<Card title={`OCR 识别 · ${event.amount} ${event.currency}`}>
<Text>{event.counterparty} · {event.memo}</Text>
<Text style={styles.muted}>来源:{event.channel} · {event.occurredAt}</Text>
{/* 编辑按钮 */}
<Button label="确认入账" onPress={() => confirm(event)} />
</Card>
```
#### 4.3 无障碍权限引导
首次使用时引导用户开启无障碍权限:
```tsx
const enableOcr = async () => {
const available = await OcrBridge.isAvailable();
if (!available) {
// 打开系统无障碍设置
await OcrBridge.startService();
}
};
```
### 阶段五测试与优化2 天)
#### 5.1 测试用例
| 测试场景 | 预期结果 |
| ---------------------- | ------------------------ |
| 支付宝付款成功页 OCR | 正确识别金额、商户、时间 |
| 微信支付凭证页 OCR | 正确识别金额、商户 |
| 银行卡扣款通知页 OCR | 正确识别金额、来源 |
| 识别失败(非支付页面) | 返回 null提示重试 |
| 重复识别同一笔交易 | 去重,提示已存在 |
#### 5.2 性能优化
- 截屏后立即回收 Bitmap避免内存泄漏
- OCR 识别在后台线程执行
- 缓存最近识别结果,避免重复处理
#### 5.3 兼容性处理
- Android 11 以下不支持 `takeScreenshot()`,降级为提示用户手动截图
- 不同支付 App 的页面布局差异,通过正则适配
## 文件清单
### 新增文件
```
android/app/src/main/java/com/beancount/mobile/ocr/
├── OcrModule.kt # React Native 原生模块
├── OcrAccessibilityService.kt # 无障碍服务
└── OcrManager.kt # OCR 处理管理器
android/app/src/main/res/xml/
└── accessibility_service_config.xml # 无障碍服务配置
src/ocr/
├── OcrBridge.ts # JS 层 Bridge
└── OcrProcessor.ts # OCR 处理逻辑
```
### 修改文件
```
android/app/build.gradle # 添加 ML Kit 依赖
android/app/src/main/AndroidManifest.xml # 注册无障碍服务
App.tsx # 添加 OCR 入口
package.json # 无变化(纯原生模块)
```
## 依赖清单
| 依赖 | 版本 | 用途 | 必需 |
| ------------------------------ | ------- | -------- | ---- |
| Google ML Kit Text Recognition | 16.0.0 | 中文 OCR | 是 |
| React Native | 0.81.0 | 框架 | 是 |
| Expo | ~54.0.0 | 开发框架 | 是 |
**不需要的依赖**
- Shizuku SDK
- Xposed/LSPatch
- PP-OCR AAR
- QuickJS
## 风险与限制
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------ | ------------------ |
| Android < 11 不支持截屏 API | 低版本设备无法使用 | 降级为手动截图导入 |
| 支付 App 页面更新 | OCR 正则失效 | 正则设计为宽松匹配 |
| 无障碍权限被系统回收 | 服务停止 | 前台通知保活 |
| OCR 识别准确率 | 可能识别错误 | 用户确认环节兜底 |
## 预估工期
| 阶段 | 工作量 | 产出 |
| ---------------- | --------------- | ----------------- |
| 阶段一:原生模块 | 3 天 | 无障碍服务 + 截屏 |
| 阶段二OCR 引擎 | 2 天 | ML Kit 集成 |
| 阶段三JS 集成 | 2 天 | Bridge + 解析 |
| 阶段四UI 适配 | 2 天 | 按钮 + 确认界面 |
| 阶段五:测试优化 | 2 天 | 测试用例 + 兼容性 |
| **总计** | **11 天** | |
## 与 plan.md 的关系
本方案是 plan.md 的扩展,补充了 OCR 自动记账能力。plan.md 中明确"首版不做 OCR",本方案作为第二阶段实现。
两份文档的关系:
- `plan.md`:核心架构 + CSV 导入 + 手工记账(首版)
- `plan-ocr.md`OCR 屏幕识别 + 自动记账(第二阶段)
实现顺序:先完成 plan.md 的核心功能,再实现 plan-ocr.md 的 OCR 能力。

62
plan.md Normal file
View File

@ -0,0 +1,62 @@
# Beancount 移动端:复式记账与账单自动记账方案
## 摘要
面向已有 Beancount 账本的个人用户,开发 iOS/Android 应用。账本文件是唯一权威来源:应用读取现有 `.bean` 文件,并将手机确认的新交易追加到独立的 `mobile.bean`。自动记账采用“导入账单 → 规则生成复式分录草稿 → 用户确认入账”,不申请短信权限、不自动提交未确认交易。
首版不做实时 Git/WebDAV 双向同步、OCR、AI 直接记账、投资 lot 完整计算或团队协作。
## 核心实现
- 使用 React Native + TypeScript + Expo CNG自定义开发构建支持 iOS 与 Android本地以加密 SQLite 保存账本索引、导入记录、规则和草稿缓存,原始 `.bean` 文件仍为唯一权威数据。
- 首次打开时导入账本包(主文件及其 `include` 引用的文件);应用解析账户、交易、商品、价格和余额断言,建立只读查询索引。
- 要求用户在主账本中一次性加入 `include "mobile.bean"`。应用只创建、编辑或删除 `mobile.bean` 内的分录,绝不改写桌面维护的源文件。
- 导出时输出完整账本包及更新后的 `mobile.bean`;用户可通过 Files/iCloud/Android 文件选择器交回其原有同步方式。首版不做后台文件监听或并发合并。
- 使用 Tree-sitter 提供 Beancount 语法树解析;复式规则由独立账务层执行:
- 交易必须至少有两条 posting
- 同币种显式金额 posting 必须平衡;
- 账户必须已 `open`
- 金额采用 Decimal/字符串定点表示,禁止浮点计算;
- 不支持或无法安全解释的高级指令、插件与复杂库存交易保留原文并标记为“只读兼容”,不允许移动端修改。
- 应用公开的领域接口固定为:
- `parseLedger(files) -> LedgerIndex | ParseDiagnostics`
- `validateTransaction(draft, ledger) -> ValidationResult`
- `importStatement(file, adapter) -> ImportedEvent[]`
- `classify(event, rules, ledger) -> TransactionDraft`
- `commitMobileTransaction(draft) -> mobile.bean diff`
- `exportLedgerBundle() -> zip`
## 自动记账流程
- 首版支持可版本化的账单适配器:支付宝 CSV、微信支付 CSV、银行卡 CSVExcel 仅在格式等同 CSV 时支持。PDF 账单不纳入首版。
- 每个适配器统一输出 `ImportedEvent`:交易时间、金额、币种、收付款方向、渠道账户、交易对手、备注、外部流水号和原始字段。
- 导入后按“渠道 + 外部流水号”去重;缺少流水号时,以日期时间、金额、方向、对手方和备注生成指纹,并提示可能重复项。
- 规则按优先级匹配:渠道账户、收付款方向、对手方关键词、备注关键词、金额区间、币种。规则输出目标账户、对方账户或分类、标签、摘要和置信度。
- 未匹配规则时,生成含 `Expenses:Uncategorized``Income:Uncategorized` 的草稿;用户必须选择或确认分类后才能写入。
- 转账识别仅在金额、币种一致且时间差在 3 天内的两条相反流水之间执行;生成 `Assets:* ↔ Assets:*` 分录,并要求用户确认。
- 确认交易写入 `mobile.bean`,并保留导入事件与生成交易的关联;用户可从一笔已确认交易反查源账单及命中规则。
- 用户在确认页手工修正账户、金额、日期、摘要或分录后,可选择“保存为规则”;规则只对之后导入的账单生效,不回写历史交易。
## 页面与交互
- `账本`:导入账本、解析状态、`mobile.bean` 写入状态、导出账本包。
- `记账`:支出、收入、转账、手工复式分录;默认提供借贷平衡提示。
- `导入`:选择账单文件、选择适配器、字段预览、重复项处理、草稿确认队列和导入结果。
- `规则`:按优先级管理自动分类规则,展示最近命中次数与示例。
- `账户与报表`:账户树、账户明细、未分类交易、月度收支和净资产;仅基于已解析且有效的分录计算。
- `诊断`:展示 Beancount 文件语法/账务错误、未识别指令与无法写回的原因;错误不阻断阅读,但阻断受影响账本的写入。
## 测试与验收
- 账务单测覆盖:平衡/不平衡分录、Decimal 精度、多币种隔离、关闭账户、未知账户、转账、编辑和删除 `mobile.bean` 内交易。
- 解析兼容测试使用公开 Beancount 示例及中文账户名;要求未支持内容不丢失、导出后仍保留原始文本。
- 每个账单适配器使用脱敏固定样本,覆盖收入、支出、退款、手续费、转账、重复流水和字段缺失。
- 端到端测试覆盖:导入账本 → 导入支付宝账单 → 命中规则 → 修改草稿 → 确认 → 导出 → 桌面端 Beancount 校验通过。
- 上架前在 iOS/Android 真机验证离线记账、数据库加密、文件导入导出、应用锁和崩溃恢复;不申请 SMS、全盘存储或后台读取权限。
## 默认假设
- 默认主币种为 CNY但账本可含其他货币首版只对同币种交易做自动平衡校验不自动推导汇率、成本或库存。
- `mobile.bean` 是仅由移动端维护的追加文件;桌面端交易在首版只读。
- 用户负责将导出的账本包同步回 Git、WebDAV、iCloud 或其他已有方案;实时多端同步属于下一阶段。

26
src/domain/decimal.ts Normal file
View File

@ -0,0 +1,26 @@
/** Exact fixed-point helpers. Values are decimal strings, never JS numbers. */
export function parseDecimal(value: string): { coefficient: bigint; scale: number } {
const normalized = value.trim().replace(/,/g, '');
if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) throw new Error(`Invalid decimal: ${value}`);
const sign = normalized.startsWith('-') ? -1n : 1n;
const plain = normalized.replace(/^[+-]/, '');
const [whole, fraction = ''] = plain.split('.');
return { coefficient: sign * BigInt(`${whole}${fraction}`), scale: fraction.length };
}
export function formatDecimal(coefficient: bigint, scale: number): string {
const sign = coefficient < 0n ? '-' : ''; const digits = (coefficient < 0n ? -coefficient : coefficient).toString().padStart(scale + 1, '0');
if (scale === 0) return `${sign}${digits}`;
const output = `${sign}${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
return output.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
}
export function addDecimals(values: string[]): string {
const parts = values.map(parseDecimal); const scale = Math.max(0, ...parts.map(p => p.scale));
const sum = parts.reduce((total, p) => total + p.coefficient * 10n ** BigInt(scale - p.scale), 0n);
return formatDecimal(sum, scale);
}
export function compareDecimals(a: string, b: string): number {
const [x, y] = [parseDecimal(a), parseDecimal(b)]; const scale = Math.max(x.scale, y.scale);
const left = x.coefficient * 10n ** BigInt(scale - x.scale); const right = y.coefficient * 10n ** BigInt(scale - y.scale);
return left === right ? 0 : left > right ? 1 : -1;
}
export function negateDecimal(value: string): string { return value.startsWith('-') ? value.slice(1) : `-${value}`; }

5
src/domain/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './types';
export * from './ledger';
export * from './statements';
export * from './rules';
export * from './mobile';

49
src/domain/ledger.ts Normal file
View File

@ -0,0 +1,49 @@
import { addDecimals } from './decimal';
import type { Account, Diagnostic, LedgerFile, LedgerIndex, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
const OPEN = /^(\d{4}-\d{2}-\d{2})\s+open\s+([^\s]+)(?:\s+([A-Z][A-Z0-9_-]*))?/;
const CLOSE = /^(\d{4}-\d{2}-\d{2})\s+close\s+([^\s]+)/;
const TXN = /^(\d{4}-\d{2}-\d{2})\s+([*!])(?:\s+"([^"]*)")?(?:\s+"([^"]*)")?/;
const POSTING = /^\s+([^\s;]+)(?:\s+([+-]?[\d,]+(?:\.\d+)?)(?:\s+([A-Z][A-Z0-9_-]*))?)?/;
const hash = (text: string) => { let h = 2166136261; for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return (h >>> 0).toString(16); };
export function parseLedger(files: LedgerFile[]): LedgerIndex {
const accounts = new Map<string, Account>(); const transactions: Transaction[] = []; const diagnostics: Diagnostic[] = []; const unsupported: Diagnostic[] = [];
for (const file of files) {
const lines = file.content.replace(/\r\n/g, '\n').split('\n');
for (let index = 0; index < lines.length; index++) {
const line = lines[index]; const open = line.match(OPEN); const close = line.match(CLOSE); const start = line.match(TXN);
if (open) { accounts.set(open[2], { name: open[2], openedOn: open[1], currencies: open[3] ? [open[3]] : [] }); continue; }
if (close) { const current = accounts.get(close[2]); if (current) current.closedOn = close[1]; else diagnostics.push({ level: 'warning', file: file.path, line: index + 1, message: `关闭了未开户账户 ${close[2]}` }); continue; }
if (!start) { if (/^\d{4}-\d{2}-\d{2}\s+(commodity|price|balance|pad|event|note|document|query|custom)\b/.test(line)) unsupported.push({ level: 'warning', file: file.path, line: index + 1, message: '此指令以只读兼容模式保留' }); continue; }
const rawLines = [line]; const postings: Posting[] = []; let cursor = index + 1;
while (cursor < lines.length && (/^\s/.test(lines[cursor]) || lines[cursor] === '')) {
rawLines.push(lines[cursor]); const posting = lines[cursor].match(POSTING);
if (posting && posting[1] && !posting[1].startsWith(';')) postings.push({ account: posting[1], amount: posting[2]?.replace(/,/g, ''), currency: posting[3] });
cursor++;
}
const raw = rawLines.join('\n'); transactions.push({ id: hash(`${file.path}:${index}:${raw}`), date: start[1], flag: start[2], payee: start[4] ? start[3] : undefined, narration: start[4] ?? start[3] ?? '', postings, tags: [...line.matchAll(/#([\w-]+)/g)].map(m => m[1]), links: [...line.matchAll(/\^([\w-]+)/g)].map(m => m[1]), source: file.path, raw }); index = cursor - 1;
}
}
return { files, accounts, transactions, diagnostics, unsupported };
}
export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex): ValidationResult {
const errors: string[] = []; const grouped = new Map<string, string[]>();
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.date)) errors.push('日期必须为 YYYY-MM-DD');
if (draft.postings.length < 2) errors.push('交易至少需要两条分录');
for (const posting of draft.postings) {
const account = ledger.accounts.get(posting.account);
if (!account) errors.push(`账户未 open${posting.account}`);
else if (account.closedOn && draft.date > account.closedOn) errors.push(`账户已关闭:${posting.account}`);
if (posting.amount && posting.currency) grouped.set(posting.currency, [...(grouped.get(posting.currency) ?? []), posting.amount]);
}
const balances: Record<string, string> = {};
for (const [currency, amounts] of grouped) { balances[currency] = addDecimals(amounts); if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`); }
return { valid: errors.length === 0, errors, balances };
}
export function serializeTransaction(draft: TransactionDraft): string {
const header = `${draft.date} *${draft.payee ? ` "${draft.payee}"` : ''} "${draft.narration}"${(draft.tags ?? []).map(t => ` #${t}`).join('')}`;
return `${header}\n${draft.postings.map(p => ` ${p.account}${p.amount ? ` ${p.amount}${p.currency ? ` ${p.currency}` : ''}` : ''}`).join('\n')}\n`;
}

11
src/domain/mobile.ts Normal file
View File

@ -0,0 +1,11 @@
import { serializeTransaction, validateTransaction } from './ledger';
import type { LedgerIndex, TransactionDraft } from './types';
export interface MobileCommit { content: string; diff: string; sourceEventId?: string; }
export function commitMobileTransaction(draft: TransactionDraft, ledger: LedgerIndex, currentMobileBean: string): MobileCommit {
const result = validateTransaction(draft, ledger); if (!result.valid) throw new Error(result.errors.join('\n'));
const diff = serializeTransaction(draft); return { content: `${currentMobileBean.trimEnd()}${currentMobileBean.trim() ? '\n\n' : ''}${diff}`, diff, sourceEventId: draft.sourceEventId };
}
export function exportLedgerBundle(ledger: LedgerIndex, mobileBean: string): Record<string, string> {
return Object.fromEntries([...ledger.files.filter(file => file.path !== 'mobile.bean'), { path: 'mobile.bean', content: mobileBean }].map(file => [file.path, file.content]));
}

30
src/domain/rules.ts Normal file
View File

@ -0,0 +1,30 @@
import { compareDecimals, negateDecimal } from './decimal';
import type { Classification, ImportedEvent, LedgerIndex, Rule, TransactionDraft } from './types';
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
function matches(event: ImportedEvent, rule: Rule): boolean {
return (!rule.channel || rule.channel === event.channel) && (!rule.direction || rule.direction === event.direction) &&
(!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) &&
(!rule.minAmount || compareDecimals(event.amount.replace('-', ''), rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(event.amount.replace('-', ''), rule.maxAmount) <= 0);
}
export function classify(event: ImportedEvent, rules: Rule[], ledger: LedgerIndex): Classification {
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item));
const channelAccount = rule?.channelAccount ?? `Assets:${event.channel}`;
const categoryAccount = rule?.categoryAccount ?? (event.direction === 'income' || event.direction === 'refund' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
const known = ledger.accounts.has(channelAccount) && ledger.accounts.has(categoryAccount);
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(channelAmount);
const narration = rule?.narration ?? (event.memo || event.counterparty || '导入账单');
const draft: TransactionDraft = { date: event.occurredAt, payee: event.counterparty || undefined, narration, tags: rule?.tags, sourceEventId: event.id, postings: [{ account: channelAccount, amount: channelAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency }] };
return { draft, ruleId: rule?.id, confidence: rule && known ? 'high' : 'low' };
}
/** A transfer is deliberately kept as a candidate until the user confirms both legs. */
export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent, leftAccount: string, rightAccount: string): TransactionDraft {
if (left.currency !== right.currency || left.amount.replace('-', '') !== right.amount.replace('-', '')) throw new Error('转账候选金额或币种不一致');
const amount = left.amount.startsWith('-') ? left.amount.slice(1) : left.amount;
const from = left.amount.startsWith('-') ? leftAccount : rightAccount;
const to = left.amount.startsWith('-') ? rightAccount : leftAccount;
return { date: left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt, narration: `转账:${from}${to}`, sourceEventId: `${left.id}|${right.id}`, postings: [{ account: from, amount: `-${amount}`, currency: left.currency }, { account: to, amount, currency: left.currency }] };
}

45
src/domain/statements.ts Normal file
View File

@ -0,0 +1,45 @@
import { compareDecimals, negateDecimal } from './decimal';
import type { Direction, ImportedEvent } from './types';
export type StatementAdapter = 'alipay-csv-v1' | 'wechat-csv-v1' | 'bank-csv-v1';
type Row = Record<string, string>;
const columns = (line: string) => line.split(',').map(value => value.trim().replace(/^"|"$/g, ''));
const first = (row: Row, keys: string[]) => keys.map(key => row[key]).find(Boolean)?.trim() ?? '';
const digest = (value: string) => { let h = 0; for (const c of value) h = (h * 31 + c.charCodeAt(0)) | 0; return `fp-${(h >>> 0).toString(16)}`; };
function parseCsv(content: string): Row[] {
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(line => line.trim());
const headerLine = lines.find(line => /交易|金额|收支|时间|date|amount/i.test(line)); if (!headerLine) throw new Error('未找到账单表头');
const headers = columns(headerLine); return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
}).filter(row => Object.values(row).some(Boolean));
}
function direction(value: string, amount: string): Direction {
if (/转账|转入|转出/i.test(value)) return 'transfer'; if (/退款/i.test(value)) return 'refund'; if (/手续费/i.test(value)) return 'fee';
return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(value) ? 'expense' : 'income';
}
export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] {
const rows = parseCsv(content); const channel = adapter.startsWith('alipay') ? 'Alipay' : adapter.startsWith('wechat') ? 'WeChat' : 'Bank';
return rows.map((row, index) => {
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const flow = first(row, ['收/支', '收支类型', '交易类型', '资金方向', 'Type', 'type']);
if (!amount) throw new Error(`${index + 1} 行缺少金额`);
if (/支出|付款|消费|转出/i.test(flow) && !amount.startsWith('-')) amount = negateDecimal(amount);
const occurredAt = first(row, ['交易创建时间', '交易时间', '交易日期', '时间', 'Date', 'date']).replace(/[/.]/g, '-').slice(0, 10);
const externalId = first(row, ['交易订单号', '交易单号', '商户订单号', '流水号', 'Transaction ID', 'id']);
const counterparty = first(row, ['交易对方', '交易对手', '对方账户', '商户名称', 'Counterparty', 'merchant']);
const memo = first(row, ['商品说明', '商品名称', '备注', '交易描述', 'Memo', 'description']);
const eventDirection = direction(flow, amount);
const id = externalId ? `${channel}:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|'));
return { id, occurredAt: /^\d{4}-\d{2}-\d{2}$/.test(occurredAt) ? occurredAt : new Date().toISOString().slice(0, 10), amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, channel, counterparty, memo, externalId: externalId || undefined, raw: row };
});
}
export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
const seen = new Set(existingIds); const accepted: ImportedEvent[] = []; const duplicates: ImportedEvent[] = [];
for (const event of events) { if (seen.has(event.id)) duplicates.push(event); else { seen.add(event.id); accepted.push(event); } }
return { accepted, duplicates };
}
export function canPairTransfer(a: ImportedEvent, b: ImportedEvent): boolean {
const days = Math.abs(Date.parse(a.occurredAt) - Date.parse(b.occurredAt)) / 86400000;
return a.direction === 'transfer' && b.direction === 'transfer' && a.currency === b.currency && compareDecimals(a.amount, negateDecimal(b.amount)) === 0 && days <= 3;
}

26
src/domain/types.ts Normal file
View File

@ -0,0 +1,26 @@
export type Direction = 'income' | 'expense' | 'transfer' | 'refund' | 'fee';
export interface LedgerFile { path: string; content: string; }
export interface Account { name: string; openedOn: string; closedOn?: string; currencies: string[]; }
export interface Posting { account: string; amount?: string; currency?: string; }
export interface Transaction {
id: string; date: string; flag: string; payee?: string; narration?: string;
postings: Posting[]; tags: string[]; links: string[]; source: string; raw: string;
}
export interface Diagnostic { level: 'error' | 'warning'; file: string; line: number; message: string; }
export interface LedgerIndex {
files: LedgerFile[]; accounts: Map<string, Account>; transactions: Transaction[];
diagnostics: Diagnostic[]; unsupported: Diagnostic[];
}
export interface TransactionDraft { date: string; payee?: string; narration: string; postings: Posting[]; tags?: string[]; sourceEventId?: string; }
export interface ValidationResult { valid: boolean; errors: string[]; balances: Record<string, string>; }
export interface ImportedEvent {
id: string; occurredAt: string; amount: string; currency: string; direction: Direction;
channel: string; counterparty: string; memo: string; externalId?: string; raw: Record<string, string>;
}
export interface Rule {
id: string; priority: number; channel?: string; direction?: Direction; counterpartyContains?: string;
memoContains?: string; minAmount?: string; maxAmount?: string; currency?: string;
channelAccount: string; categoryAccount: string; narration?: string; tags?: string[]; hits: number;
}
export interface Classification { draft: TransactionDraft; ruleId?: string; confidence: 'high' | 'low'; }

View File

@ -0,0 +1,33 @@
import * as SQLite from 'expo-sqlite';
import type { ImportedEvent, LedgerIndex, Rule, TransactionDraft } from '../domain';
/** Cache only: .bean files remain the source of truth. SQLCipher is enabled in app.json. */
export class LedgerRepository {
private constructor(private readonly db: SQLite.SQLiteDatabase) {}
static async open(): Promise<LedgerRepository> {
const db = await SQLite.openDatabaseAsync('bean-mobile.db');
await db.execAsync(`PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS ledger_files (path TEXT PRIMARY KEY, content TEXT NOT NULL, checksum TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS imported_events (id TEXT PRIMARY KEY, payload TEXT NOT NULL, committed_transaction TEXT);
CREATE TABLE IF NOT EXISTS rules (id TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS drafts (id TEXT PRIMARY KEY, payload TEXT NOT NULL, created_at TEXT NOT NULL);`);
return new LedgerRepository(db);
}
async cacheLedger(ledger: LedgerIndex): Promise<void> {
await this.db.withTransactionAsync(async () => {
await this.db.runAsync('DELETE FROM ledger_files');
for (const file of ledger.files) await this.db.runAsync('INSERT INTO ledger_files (path, content, checksum) VALUES (?, ?, ?)', file.path, file.content, String(file.content.length));
});
}
async saveImported(events: ImportedEvent[]): Promise<void> {
for (const event of events) await this.db.runAsync('INSERT OR IGNORE INTO imported_events (id, payload) VALUES (?, ?)', event.id, JSON.stringify(event));
}
async committedEventIds(): Promise<Set<string>> {
const rows = await this.db.getAllAsync<{ id: string }>('SELECT id FROM imported_events WHERE committed_transaction IS NOT NULL');
return new Set(rows.map(row => row.id));
}
async markCommitted(eventId: string, transactionId: string): Promise<void> { await this.db.runAsync('UPDATE imported_events SET committed_transaction = ? WHERE id = ?', transactionId, eventId); }
async saveRule(rule: Rule): Promise<void> { await this.db.runAsync('INSERT OR REPLACE INTO rules (id, priority, payload) VALUES (?, ?, ?)', rule.id, rule.priority, JSON.stringify(rule)); }
async loadRules(): Promise<Rule[]> { const rows = await this.db.getAllAsync<{ payload: string }>('SELECT payload FROM rules ORDER BY priority DESC'); return rows.map(row => JSON.parse(row.payload) as Rule); }
async saveDraft(id: string, draft: TransactionDraft): Promise<void> { await this.db.runAsync('INSERT OR REPLACE INTO drafts (id, payload, created_at) VALUES (?, ?, ?)', id, JSON.stringify(draft), new Date().toISOString()); }
}

26
tests/accounting.test.ts Normal file
View File

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { classify, commitMobileTransaction, deduplicate, importStatement, parseLedger, validateTransaction } from '../src/domain';
const ledger = parseLedger([{ path: 'main.bean', content: `2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\ninclude "mobile.bean"\n` }]);
describe('复式账务', () => {
it('拒绝不平衡交易且精确处理小数', () => {
expect(validateTransaction({ date: '2026-01-02', narration: '午餐', postings: [{ account: 'Assets:Alipay', amount: '-0.1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '0.1', currency: 'CNY' }] }, ledger).valid).toBe(true);
expect(validateTransaction({ date: '2026-01-02', narration: '错误', postings: [{ account: 'Assets:Alipay', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '0.9', currency: 'CNY' }] }, ledger).errors[0]).toContain('不平衡');
});
it('只在校验通过后追加 mobile.bean', () => {
const commit = commitMobileTransaction({ date: '2026-01-02', narration: '午餐', postings: [{ account: 'Assets:Alipay', amount: '-20', currency: 'CNY' }, { account: 'Expenses:Food', amount: '20', currency: 'CNY' }] }, ledger, '');
expect(commit.content).toContain('Expenses:Food');
});
});
describe('账单导入', () => {
it('识别 CSV 并按规则生成草稿', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,25.50,支出,咖啡店,A1', 'alipay-csv-v1');
const classified = classify(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], ledger);
expect(classified.draft.postings).toEqual([{ account: 'Assets:Alipay', amount: '-25.50', currency: 'CNY' }, { account: 'Expenses:Food', amount: '25.50', currency: 'CNY' }]);
});
it('不会重复接收同一流水', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易订单号\n2026-02-01,1,支出,A1', 'alipay-csv-v1');
const result = deduplicate([event, event], new Set());
expect(result.accepted).toHaveLength(1); expect(result.duplicates).toHaveLength(1);
});
});

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"module": "esnext",
"target": "es2022",
"skipLibCheck": true,
"noEmit": true
},
"include": [
"App.tsx",
"src",
"tests"
],
"extends": "expo/tsconfig.base"
}