beancount-helper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ # test data
2
+ *.xls
3
+ *.xlsx
4
+
5
+ # python things
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ __pycache__/
10
+
11
+ .pytest_cache/
12
+ .venv/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FishCat233
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: beancount-helper
3
+ Version: 0.1.0
4
+ Summary: Convert bank statement Excel files to Beancount format with a rule-based account matching engine.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: openpyxl>=3.1.5
9
+ Requires-Dist: pandas>=3.0.3
10
+ Requires-Dist: xlrd>=2.0.2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # beancount-helper
14
+
15
+ Convert bank statement Excel files into [Beancount](https://beancount.github.io/) transactions with a rule-based account matching engine.
16
+
17
+ ```
18
+ 2025-01-15 * "Coffee Shop" "Latte"
19
+ Expenses:Food:Coffee 25.00 CNY
20
+ Assets:CCB
21
+ ```
22
+
23
+ ## Supported banks
24
+
25
+ | Parser | Source |
26
+ |--------|--------|
27
+ | `ccb` | China Construction Bank (建设银行) statement `.xls` |
28
+ | `wechat` | WeChat Pay (微信支付) statement `.xlsx` |
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install beancount-helper
34
+ ```
35
+
36
+ For development:
37
+
38
+ ```bash
39
+ git clone https://github.com/your-org/beancount-helper.git
40
+ cd beancount-helper
41
+ uv sync
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ **Step 1** — Export your statement from the bank app and save it locally.
47
+
48
+ **Step 2** — (Optional) Create a rules file:
49
+
50
+ ```
51
+ # rules.txt
52
+ p=opposite:eq:Coffee Shop;;direct=out=>Expenses:Food:Coffee
53
+ direct=in=>Income:Misc
54
+ ```
55
+
56
+ **Step 3** — Run:
57
+
58
+ ```bash
59
+ beancount-helper ccb.xls -p ccb -r rules.txt -o ledger.beancount
60
+ ```
61
+
62
+ That's it. Every record falls into a Beancount account — either matched by your rules, or placed in `Equity:Opening-Balances` as a fallback.
63
+
64
+ ## CLI reference
65
+
66
+ ```
67
+ beancount-helper INPUT -p PARSER [-r RULES] [-o OUTPUT] [--asset ASSET] [--default-account ACCOUNT]
68
+ ```
69
+
70
+ | Argument | Required | Default | Description |
71
+ |----------|----------|---------|-------------|
72
+ | `INPUT` | yes | — | Path to the bank statement file (`.xls` / `.xlsx`). |
73
+ | `-p`, `--parser` | yes | — | `ccb` or `wechat`. |
74
+ | `-r`, `--rules` | no | — | Path to a rules file. Without it, all records use the default account. |
75
+ | `-o`, `--output` | no | `output.beancount` | Output file path. |
76
+ | `--asset` | no | `Assets:CCB` / `Assets:WeChat` | Asset account for the source of funds. |
77
+ | `--default-account` | no | `Equity:Opening-Balances` | Fallback account when no rule matches. |
78
+
79
+ ### Examples
80
+
81
+ ```bash
82
+ # Bare minimum — everything goes to Equity:Opening-Balances
83
+ beancount-helper ccb.xls -p ccb
84
+
85
+ # With rules
86
+ beancount-helper wechat.xlsx -p wechat -r rules.txt
87
+
88
+ # Custom accounts
89
+ beancount-helper ccb.xls -p ccb -r rules.txt \
90
+ --asset Assets:CCB:Savings \
91
+ --default-account Expenses:Pending
92
+ ```
93
+
94
+ ## Rule system
95
+
96
+ Rules tell the tool which Beancount account a bill should be posted to. Each rule has this shape:
97
+
98
+ ```
99
+ condition1;;condition2;;...=>TargetAccount
100
+ ```
101
+
102
+ Conditions are AND-ed: a bill must satisfy **all** of them for the rule to fire. Rules are tried in order; the first match wins.
103
+
104
+ ### Condition reference
105
+
106
+ #### Direction (`direct` / `direction`)
107
+
108
+ Matches whether money flows in or out.
109
+
110
+ ```
111
+ direct=in # income
112
+ direct=out # expense
113
+ ```
114
+
115
+ #### Property (`p` / `property`)
116
+
117
+ Matches a field on the bill record.
118
+
119
+ | Method | Syntax | Example |
120
+ |--------|--------|---------|
121
+ | exact | `p=field:eq:value` | `p=opposite:eq:Coffee Shop` |
122
+ | contains | `p=field:in:substring` | `p=note:in:lunch` |
123
+ | regex | `p=field:re:pattern` | `p=product:re:^ATMS$` |
124
+
125
+ Available fields: `opposite`, `note`, `product` (wechat only).
126
+
127
+ #### Amount comparisons
128
+
129
+ | Operator | Meaning |
130
+ |----------|---------|
131
+ | `lt=N` | amount < N |
132
+ | `le=N` | amount ≤ N |
133
+ | `gt=N` | amount > N |
134
+ | `ge=N` | amount ≥ N |
135
+ | `eq=N` | amount = N |
136
+
137
+ Negative amounts are expenses, positive are income (from the bank's perspective).
138
+
139
+ ### Combining conditions
140
+
141
+ Separate conditions with `;;`:
142
+
143
+ ```
144
+ # Small lunch expenses from a specific vendor
145
+ direct=out;;p=opposite:in:Restaurant;;lt=50.0=>Expenses:Food:Lunch
146
+
147
+ # Any income
148
+ direct=in=>Income:Misc
149
+ ```
150
+
151
+ ### Rule file format
152
+
153
+ One rule per line. Blank lines and `#`-comments are ignored.
154
+
155
+ ```
156
+ # Food & drinks
157
+ p=opposite:eq:Starbucks=>Expenses:Food:Coffee
158
+ p=opposite:in:Pizza;;lt=80.0=>Expenses:Food:Takeout
159
+
160
+ # Transport
161
+ p=opposite:eq:DiDi=>Expenses:Transport:Ride
162
+ p=note:re:metro|subway=>Expenses:Transport:Transit
163
+
164
+ # Income
165
+ direct=in;;gt=5000.0=>Income:Salary
166
+ direct=in=>Income:Misc
167
+ ```
168
+
169
+ ## How it works
170
+
171
+ ```
172
+ Bank Excel (.xls/.xlsx)
173
+
174
+
175
+ bill_resolver ← parses bank-specific formats into Bill objects
176
+
177
+
178
+ transformer.rule ← matches each Bill against the rule set
179
+
180
+
181
+ beancount.core ← assembles BeanCount transactions, dumps to text
182
+
183
+
184
+ output.beancount
185
+ ```
186
+
187
+ ## Project structure
188
+
189
+ ```
190
+ beancount-helper/
191
+ ├── main.py CLI entry point
192
+ ├── rules.example.txt Sample rule set
193
+ ├── src/
194
+ │ ├── beancount/core.py BeanCount model + serialization
195
+ │ ├── bill_resolver/
196
+ │ │ ├── ccb.py CCB Excel parser
197
+ │ │ └── wechat.py WeChat Excel parser
198
+ │ └── transformer/rule.py Rule matching engine
199
+ └── test/
200
+ ├── test_beancount_core.py
201
+ └── rule_matcher.py
202
+ ```
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ # Install dev dependencies
208
+ uv sync
209
+
210
+ # Run tests
211
+ uv run pytest
212
+
213
+ # Lint
214
+ uv run ruff check .
215
+ ```
216
+
217
+ ## License
218
+
219
+ MIT
@@ -0,0 +1,207 @@
1
+ # beancount-helper
2
+
3
+ Convert bank statement Excel files into [Beancount](https://beancount.github.io/) transactions with a rule-based account matching engine.
4
+
5
+ ```
6
+ 2025-01-15 * "Coffee Shop" "Latte"
7
+ Expenses:Food:Coffee 25.00 CNY
8
+ Assets:CCB
9
+ ```
10
+
11
+ ## Supported banks
12
+
13
+ | Parser | Source |
14
+ |--------|--------|
15
+ | `ccb` | China Construction Bank (建设银行) statement `.xls` |
16
+ | `wechat` | WeChat Pay (微信支付) statement `.xlsx` |
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install beancount-helper
22
+ ```
23
+
24
+ For development:
25
+
26
+ ```bash
27
+ git clone https://github.com/your-org/beancount-helper.git
28
+ cd beancount-helper
29
+ uv sync
30
+ ```
31
+
32
+ ## Quick start
33
+
34
+ **Step 1** — Export your statement from the bank app and save it locally.
35
+
36
+ **Step 2** — (Optional) Create a rules file:
37
+
38
+ ```
39
+ # rules.txt
40
+ p=opposite:eq:Coffee Shop;;direct=out=>Expenses:Food:Coffee
41
+ direct=in=>Income:Misc
42
+ ```
43
+
44
+ **Step 3** — Run:
45
+
46
+ ```bash
47
+ beancount-helper ccb.xls -p ccb -r rules.txt -o ledger.beancount
48
+ ```
49
+
50
+ That's it. Every record falls into a Beancount account — either matched by your rules, or placed in `Equity:Opening-Balances` as a fallback.
51
+
52
+ ## CLI reference
53
+
54
+ ```
55
+ beancount-helper INPUT -p PARSER [-r RULES] [-o OUTPUT] [--asset ASSET] [--default-account ACCOUNT]
56
+ ```
57
+
58
+ | Argument | Required | Default | Description |
59
+ |----------|----------|---------|-------------|
60
+ | `INPUT` | yes | — | Path to the bank statement file (`.xls` / `.xlsx`). |
61
+ | `-p`, `--parser` | yes | — | `ccb` or `wechat`. |
62
+ | `-r`, `--rules` | no | — | Path to a rules file. Without it, all records use the default account. |
63
+ | `-o`, `--output` | no | `output.beancount` | Output file path. |
64
+ | `--asset` | no | `Assets:CCB` / `Assets:WeChat` | Asset account for the source of funds. |
65
+ | `--default-account` | no | `Equity:Opening-Balances` | Fallback account when no rule matches. |
66
+
67
+ ### Examples
68
+
69
+ ```bash
70
+ # Bare minimum — everything goes to Equity:Opening-Balances
71
+ beancount-helper ccb.xls -p ccb
72
+
73
+ # With rules
74
+ beancount-helper wechat.xlsx -p wechat -r rules.txt
75
+
76
+ # Custom accounts
77
+ beancount-helper ccb.xls -p ccb -r rules.txt \
78
+ --asset Assets:CCB:Savings \
79
+ --default-account Expenses:Pending
80
+ ```
81
+
82
+ ## Rule system
83
+
84
+ Rules tell the tool which Beancount account a bill should be posted to. Each rule has this shape:
85
+
86
+ ```
87
+ condition1;;condition2;;...=>TargetAccount
88
+ ```
89
+
90
+ Conditions are AND-ed: a bill must satisfy **all** of them for the rule to fire. Rules are tried in order; the first match wins.
91
+
92
+ ### Condition reference
93
+
94
+ #### Direction (`direct` / `direction`)
95
+
96
+ Matches whether money flows in or out.
97
+
98
+ ```
99
+ direct=in # income
100
+ direct=out # expense
101
+ ```
102
+
103
+ #### Property (`p` / `property`)
104
+
105
+ Matches a field on the bill record.
106
+
107
+ | Method | Syntax | Example |
108
+ |--------|--------|---------|
109
+ | exact | `p=field:eq:value` | `p=opposite:eq:Coffee Shop` |
110
+ | contains | `p=field:in:substring` | `p=note:in:lunch` |
111
+ | regex | `p=field:re:pattern` | `p=product:re:^ATMS$` |
112
+
113
+ Available fields: `opposite`, `note`, `product` (wechat only).
114
+
115
+ #### Amount comparisons
116
+
117
+ | Operator | Meaning |
118
+ |----------|---------|
119
+ | `lt=N` | amount < N |
120
+ | `le=N` | amount ≤ N |
121
+ | `gt=N` | amount > N |
122
+ | `ge=N` | amount ≥ N |
123
+ | `eq=N` | amount = N |
124
+
125
+ Negative amounts are expenses, positive are income (from the bank's perspective).
126
+
127
+ ### Combining conditions
128
+
129
+ Separate conditions with `;;`:
130
+
131
+ ```
132
+ # Small lunch expenses from a specific vendor
133
+ direct=out;;p=opposite:in:Restaurant;;lt=50.0=>Expenses:Food:Lunch
134
+
135
+ # Any income
136
+ direct=in=>Income:Misc
137
+ ```
138
+
139
+ ### Rule file format
140
+
141
+ One rule per line. Blank lines and `#`-comments are ignored.
142
+
143
+ ```
144
+ # Food & drinks
145
+ p=opposite:eq:Starbucks=>Expenses:Food:Coffee
146
+ p=opposite:in:Pizza;;lt=80.0=>Expenses:Food:Takeout
147
+
148
+ # Transport
149
+ p=opposite:eq:DiDi=>Expenses:Transport:Ride
150
+ p=note:re:metro|subway=>Expenses:Transport:Transit
151
+
152
+ # Income
153
+ direct=in;;gt=5000.0=>Income:Salary
154
+ direct=in=>Income:Misc
155
+ ```
156
+
157
+ ## How it works
158
+
159
+ ```
160
+ Bank Excel (.xls/.xlsx)
161
+
162
+
163
+ bill_resolver ← parses bank-specific formats into Bill objects
164
+
165
+
166
+ transformer.rule ← matches each Bill against the rule set
167
+
168
+
169
+ beancount.core ← assembles BeanCount transactions, dumps to text
170
+
171
+
172
+ output.beancount
173
+ ```
174
+
175
+ ## Project structure
176
+
177
+ ```
178
+ beancount-helper/
179
+ ├── main.py CLI entry point
180
+ ├── rules.example.txt Sample rule set
181
+ ├── src/
182
+ │ ├── beancount/core.py BeanCount model + serialization
183
+ │ ├── bill_resolver/
184
+ │ │ ├── ccb.py CCB Excel parser
185
+ │ │ └── wechat.py WeChat Excel parser
186
+ │ └── transformer/rule.py Rule matching engine
187
+ └── test/
188
+ ├── test_beancount_core.py
189
+ └── rule_matcher.py
190
+ ```
191
+
192
+ ## Development
193
+
194
+ ```bash
195
+ # Install dev dependencies
196
+ uv sync
197
+
198
+ # Run tests
199
+ uv run pytest
200
+
201
+ # Lint
202
+ uv run ruff check .
203
+ ```
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "beancount-helper"
3
+ version = "0.1.0"
4
+ description = "Convert bank statement Excel files to Beancount format with a rule-based account matching engine."
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ requires-python = ">=3.12"
8
+ dependencies = ["openpyxl>=3.1.5", "pandas>=3.0.3", "xlrd>=2.0.2"]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/beancount", "src/bill_resolver", "src/transformer", "src/beancount_helper"]
16
+
17
+ [project.scripts]
18
+ beancount-helper = "beancount_helper.cli:main"
19
+
20
+ [tool.pytest.ini_options]
21
+ pythonpath = ["src"]
22
+
23
+ [dependency-groups]
24
+ dev = ["coverage>=7.15.2", "pytest>=9.1.1", "ruff>=0.15.22"]
@@ -0,0 +1,19 @@
1
+ # beancount-helper 规则集
2
+ # 格式: 条件段1;;条件段2;;...=>目标账户
3
+ # 条件类型:
4
+ # direct=in|out 收支方向
5
+ # p=字段:eq|in|re:值 属性匹配(精确/包含/正则)
6
+ # lt|le|gt|ge|eq=数值 金额比较
7
+ #
8
+ # 示例:
9
+ # p=opposite:eq:一车一人=>Expenses:Trip:共享车:一车一人
10
+ # p=opposite:in:餐饮;;lt=50.0=>Expenses:Life:餐饮
11
+ # direct=in=>Income:Salary
12
+
13
+ p=opposite:eq:一车一人=>Expenses:Trip:共享车:一车一人
14
+ p=opposite:eq:享坐车出行=>Expenses:Trip:共享车:摆渡车
15
+ p=opposite:eq:灵川夜不将就餐饮店(个体工商户)=>Expenses:Life:餐饮
16
+ p=opposite:eq:灵川县这些年小炒店=>Expenses:Life:餐饮
17
+ p=opposite:eq:灵川县张兰食苑餐馆=>Expenses:Life:餐饮
18
+ p=opposite:eq:家乐轩餐厅丨灵川县=>Expenses:Life:餐饮
19
+ p=opposite:in:甘晋宇;;lt=20.0;;direct=in=>Expenses:Life:餐饮
File without changes
File without changes
@@ -0,0 +1,88 @@
1
+ import re
2
+ from datetime import datetime
3
+
4
+ _HEADER_PATTERN = re.compile(r'^(\d{4}-\d{2}-\d{2}) \* "(.*?)" "(.*?)"$')
5
+
6
+
7
+ def resolve_beancounts(string: str) -> list["BeanCount"]:
8
+ # 去除空行
9
+ string = "\n".join([line.rstrip() for line in string.split("\n")])
10
+
11
+ blocks = [b.strip() for b in string.strip().split("\n\n") if b.strip()]
12
+ results: list["BeanCount"] = []
13
+
14
+ for block in blocks:
15
+ results.append(resolve_beancount(block))
16
+
17
+ return results
18
+
19
+
20
+ def resolve_beancount(string: str) -> "BeanCount":
21
+ lines = [line.strip() for line in string.split("\n") if line.strip()]
22
+ if len(lines) < 3:
23
+ raise ValueError(
24
+ f"Beancount transaction block must be 3 lines for resolving, but {len(lines)} lines got from: {string}"
25
+ )
26
+
27
+ # resolve header
28
+ match = _HEADER_PATTERN.match(lines[0])
29
+ if not match:
30
+ raise ValueError(f"Can't resolve beancount block: {string}")
31
+
32
+ date, receiver, digest = match.groups()
33
+ date = datetime.strptime(date, "%Y-%m-%d")
34
+
35
+ # resolve information
36
+ in_account, amount, currency_type = lines[1].split(" ")
37
+ out_account = lines[2]
38
+
39
+ return BeanCount(
40
+ datetime=date,
41
+ receiver=receiver,
42
+ digest=digest,
43
+ in_account=in_account,
44
+ out_account=out_account,
45
+ amount=float(amount),
46
+ currency_type=currency_type,
47
+ )
48
+
49
+
50
+ def dump_beancount(beancount: "BeanCount") -> str:
51
+ return beancount.to_str()
52
+
53
+
54
+ def dump_beancounts(beancounts: list["BeanCount"]) -> str:
55
+ return "\n\n".join([b.to_str() for b in beancounts])
56
+
57
+
58
+ class BeanCount:
59
+ def __init__(
60
+ self,
61
+ datetime: datetime,
62
+ receiver: str,
63
+ digest: str,
64
+ in_account: str,
65
+ out_account: str,
66
+ amount: float,
67
+ currency_type: str,
68
+ ):
69
+ self.datetime = datetime
70
+ self.receiver = receiver
71
+ self.digest = digest
72
+ self.in_account = in_account
73
+ self.out_account = out_account
74
+ self.amount = amount
75
+ self.currency_type = currency_type
76
+
77
+ def __str__(self) -> str:
78
+ date_str = self.datetime.strftime("%Y-%m-%d")
79
+ return (
80
+ f'{date_str} * "{self.receiver}" "{self.digest}"\n'
81
+ f" {self.in_account} {self.amount} {self.currency_type}\n"
82
+ f" {self.out_account}"
83
+ )
84
+
85
+ def __repr__(self) -> str:
86
+ return f'BeanCount(date={self.datetime.strftime("%Y-%m-%d")}, {self.in_account}->{self.out_account}({self.amount}), receiver: {self.receiver}({self.digest}))'
87
+
88
+ to_str = __str__