first commit
This commit is contained in:
commit
2086f67e68
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
26
README.md
Normal 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
10
app.json
Normal 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
11103
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
458
plan-ocr.md
Normal 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.kt(React 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
62
plan.md
Normal 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、银行卡 CSV;Excel 仅在格式等同 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
26
src/domain/decimal.ts
Normal 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
5
src/domain/index.ts
Normal 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
49
src/domain/ledger.ts
Normal 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
11
src/domain/mobile.ts
Normal 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
30
src/domain/rules.ts
Normal 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
45
src/domain/statements.ts
Normal 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
26
src/domain/types.ts
Normal 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'; }
|
||||
33
src/storage/ledgerRepository.ts
Normal file
33
src/storage/ledgerRepository.ts
Normal 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
26
tests/accounting.test.ts
Normal 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
17
tsconfig.json
Normal 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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user