statement-etl-ph 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.
- statement_etl_ph-0.1.0/.gitignore +17 -0
- statement_etl_ph-0.1.0/LICENSE +21 -0
- statement_etl_ph-0.1.0/PKG-INFO +155 -0
- statement_etl_ph-0.1.0/README.md +125 -0
- statement_etl_ph-0.1.0/docs/add-a-bank.md +82 -0
- statement_etl_ph-0.1.0/docs/run-locally.md +102 -0
- statement_etl_ph-0.1.0/pyproject.toml +50 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/__init__.py +11 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/__main__.py +3 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/banks/__init__.py +35 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/banks/base.py +50 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/banks/gcash.py +234 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/banks/gotyme.py +262 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/banks/grab.py +237 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/cli.py +349 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/config.py +93 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/gmail/__init__.py +0 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/gmail/auth.py +135 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/gmail/fetch.py +492 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/models.py +451 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/pdf_geometry.py +127 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/sinks/__init__.py +65 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/sinks/export.py +84 -0
- statement_etl_ph-0.1.0/src/statement_etl_ph/sinks/money_manager.py +117 -0
- statement_etl_ph-0.1.0/tests/conftest.py +42 -0
- statement_etl_ph-0.1.0/tests/fixtures/make_fixture.py +143 -0
- statement_etl_ph-0.1.0/tests/fixtures/make_gotyme_fixture.py +155 -0
- statement_etl_ph-0.1.0/tests/fixtures/synthetic_gotyme_statement.pdf +0 -0
- statement_etl_ph-0.1.0/tests/fixtures/synthetic_statement.pdf +0 -0
- statement_etl_ph-0.1.0/tests/test_auth_and_config.py +117 -0
- statement_etl_ph-0.1.0/tests/test_cli.py +199 -0
- statement_etl_ph-0.1.0/tests/test_gmail_fetch.py +356 -0
- statement_etl_ph-0.1.0/tests/test_models.py +287 -0
- statement_etl_ph-0.1.0/tests/test_parse_gcash.py +166 -0
- statement_etl_ph-0.1.0/tests/test_parse_gotyme.py +143 -0
- statement_etl_ph-0.1.0/tests/test_parse_grab.py +279 -0
- statement_etl_ph-0.1.0/tests/test_sinks.py +158 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
|
|
10
|
+
# Never commit a real statement, a token or a config with passwords in it.
|
|
11
|
+
*.pdf
|
|
12
|
+
!tests/fixtures/synthetic_*.pdf
|
|
13
|
+
token.json
|
|
14
|
+
client_secret*.json
|
|
15
|
+
statement-etl-ph.json
|
|
16
|
+
*_moneymanager.csv
|
|
17
|
+
.env
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 statement-etl-ph contributors
|
|
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,155 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: statement-etl-ph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read GCash, GoTyme and Grab statements into checked transactions, a CSV, or a Money Manager import.
|
|
5
|
+
Project-URL: Issues, https://github.com/Jomaries777/statement-etl-ph/issues
|
|
6
|
+
Project-URL: Source, https://github.com/Jomaries777/statement-etl-ph
|
|
7
|
+
Author: statement-etl-ph contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: bank statement,budget,gcash,gotyme,grab,pdf,philippines
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: pdfplumber>=0.11.4
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pikepdf>=9.0.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: reportlab>=4.2.0; extra == 'dev'
|
|
24
|
+
Provides-Extra: gmail
|
|
25
|
+
Requires-Dist: google-api-python-client>=2.140.0; extra == 'gmail'
|
|
26
|
+
Requires-Dist: google-auth-httplib2>=0.2.0; extra == 'gmail'
|
|
27
|
+
Requires-Dist: google-auth-oauthlib>=1.2.1; extra == 'gmail'
|
|
28
|
+
Requires-Dist: google-auth>=2.34.0; extra == 'gmail'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# statement-etl-ph
|
|
32
|
+
|
|
33
|
+
Turn your **GCash**, **GoTyme** and **GrabPay** statements into a list of transactions you can actually use: a CSV for a spreadsheet, or a file you import straight into the **Money Manager** app.
|
|
34
|
+
|
|
35
|
+
Every statement is checked before anything is written. The rows it reads must add up to the totals the bank printed, so a misread statement stops instead of quietly putting wrong numbers into your budget.
|
|
36
|
+
|
|
37
|
+
> **Only trust the official source.** The package is `statement-etl-ph` on PyPI and the code lives at <https://github.com/Jomaries777/statement-etl-ph>. This tool runs on your own computer. It never sends your statements, passwords or transactions to anyone. Be suspicious of any copy, fork or "helper" site that asks for them.
|
|
38
|
+
|
|
39
|
+
## Quick start: one statement, no setup
|
|
40
|
+
|
|
41
|
+
You need Python 3.10 or newer.
|
|
42
|
+
|
|
43
|
+
1. **Get your statement.** In the GCash app, open your transaction history and request it by email. It arrives as a password-protected PDF; the email tells you what the password is.
|
|
44
|
+
2. **Install:**
|
|
45
|
+
```bash
|
|
46
|
+
pipx install statement-etl-ph
|
|
47
|
+
```
|
|
48
|
+
(Or `pip install statement-etl-ph` inside a virtual environment.)
|
|
49
|
+
3. **Convert it:**
|
|
50
|
+
```bash
|
|
51
|
+
statement-etl-ph statement.pdf --bank gcash --to moneymanager --password YOUR-PDF-PASSWORD
|
|
52
|
+
```
|
|
53
|
+
It prints every row and the check, then writes `gcash_<from>_to_<to>_moneymanager.csv`. To keep the password out of your shell history, set `GCASH_PDF_PASSWORD` instead of passing `--password`.
|
|
54
|
+
4. **Import that file into Money Manager.** The Category column is left blank on purpose, so you can sort rows in the app.
|
|
55
|
+
|
|
56
|
+
Add `--dry-run` to see the rows without writing anything, and `--to csv` for a plain spreadsheet instead.
|
|
57
|
+
|
|
58
|
+
### What the check catches
|
|
59
|
+
|
|
60
|
+
GCash prints some inbound bank transfers with **no description at all**. A parser that matches a list of descriptions to a list of amounts by position shifts every later amount onto the wrong row, then drops the last one, and reports no error. This package reads the page as a grid, keeps the blank row, and then proves nothing moved: the debits and credits it read must equal the statement's printed Total Debit and Total Credit. If they don't, nothing is written (unless you pass `--force`).
|
|
61
|
+
|
|
62
|
+
It also reports what the bank's own arithmetic leaves out. GCash sometimes refunds a cancelled booking without printing a row, and the running balance is the only evidence. Such refunds, and any other gap the balance proves but nothing explains, are listed in the output rather than hidden. They're never written as rows: add them yourself if you want them.
|
|
63
|
+
|
|
64
|
+
## Supported banks and e-wallets
|
|
65
|
+
|
|
66
|
+
| `--bank` | What it reads | Password variable |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `gcash` | GCash transaction history PDF | `GCASH_PDF_PASSWORD` |
|
|
69
|
+
| `gotyme` | GoTyme Bank statement of account PDF (the Everyday deposit account by default) | `GOTYME_PDF_PASSWORD` |
|
|
70
|
+
| `grab` | Grab e-receipt emails; only GrabPay Wallet payments are imported, because card and GCash payments already appear on those statements | none |
|
|
71
|
+
|
|
72
|
+
Want another bank? See [Adding a bank](docs/add-a-bank.md). It's one file.
|
|
73
|
+
|
|
74
|
+
## Output formats
|
|
75
|
+
|
|
76
|
+
**`--to moneymanager`**: Money Manager's bulk import, with these columns:
|
|
77
|
+
|
|
78
|
+
| Column | Value |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Date | the transaction date on the statement (default `2026-08-26`; change it with `--date-format`) |
|
|
81
|
+
| Account | `GCash`, `GoTyme` or `GrabPay`; use `--account-name` to match an account you already have |
|
|
82
|
+
| Category, Subcategory, Note | blank |
|
|
83
|
+
| Amount | always positive, e.g. `1250.00` |
|
|
84
|
+
| Income/Expense | `Income` for money in, `Expense` for money out |
|
|
85
|
+
| Description | the merchant and the bank's reference, e.g. `Example Apps (ref 0000100000066)` |
|
|
86
|
+
|
|
87
|
+
Money Manager doesn't detect duplicates, so importing the same file twice imports every row twice. Each file is named after its bank and statement period to make that easy to notice.
|
|
88
|
+
|
|
89
|
+
**`--to csv`**: one row per transaction with the signed amount, debit, credit, balance, reference number and a `duplicate_key` column. Writing into a CSV that already exists only appends rows whose key isn't there yet.
|
|
90
|
+
|
|
91
|
+
## Level up: fetch statements from Gmail automatically
|
|
92
|
+
|
|
93
|
+
Instead of downloading each PDF, let the tool find statement emails in your Gmail, check they really came from the bank, and convert them.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pipx install "statement-etl-ph[gmail]"
|
|
97
|
+
statement-etl-ph auth client_secret.json # once
|
|
98
|
+
statement-etl-ph init # once: a config file for your passwords
|
|
99
|
+
statement-etl-ph fetch --bank gcash --to moneymanager --out ~/Statements/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Only mail the bank actually sent is accepted.** A `From:` address is easy to fake, so each message must carry a passing DKIM signature from the bank's own domain (`gcash.com`, `gotyme.com.ph` or `grab.com`), as verified by Gmail. Anything else is refused and left alone.
|
|
103
|
+
|
|
104
|
+
After a statement is converted, its email gets the label `statement-etl-ph/<bank>`, so the next run skips it.
|
|
105
|
+
|
|
106
|
+
### Set up your own Gmail access (about 10 minutes, free)
|
|
107
|
+
|
|
108
|
+
This tool has no shared Google login. You create your own, so nobody else's app ever gets access to your mail. Nothing is deployed, and no billing is needed.
|
|
109
|
+
|
|
110
|
+
1. Go to [console.cloud.google.com](https://console.cloud.google.com), create a project (any name), and enable the **Gmail API** for it.
|
|
111
|
+
2. Open **Google Auth Platform** and set up the consent screen. Choose **External**, and add your own Gmail address as a test user.
|
|
112
|
+
3. Under **Clients**, create an OAuth client of type **Desktop app** and download its JSON file.
|
|
113
|
+
4. **Publish the app:** on the **Audience** page, click **Publish app** to move it from *Testing* to *In production*. Without this, Google expires your sign-in after **7 days** and fetching stops working.
|
|
114
|
+
5. Run `statement-etl-ph auth path/to/client_secret.json`. A browser opens. Google warns that it "hasn't verified this app": that's expected, because the app is yours. Continue, and allow access.
|
|
115
|
+
|
|
116
|
+
The token is saved to `~/.config/statement-etl-ph/token.json`, readable only by you. Revoke it any time at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
|
|
117
|
+
|
|
118
|
+
**Read-only option:** `statement-etl-ph auth client_secret.json --readonly` never changes your mailbox. Processed emails aren't labelled, so each run sees them again. For CSV output, the duplicate key stops rows being added twice; a Money Manager file is simply rewritten.
|
|
119
|
+
|
|
120
|
+
### Your bank mail goes somewhere other than Gmail?
|
|
121
|
+
|
|
122
|
+
Create a free Gmail account just for statements, and forward the bank emails to it. A dedicated account also means the token can only ever see bank mail.
|
|
123
|
+
|
|
124
|
+
- **Forward with a rule that keeps the original email intact:** a Gmail filter's forwarding, or Outlook's **Redirect to**. "Forward"-style rules and forwarding by hand change the sender, add "FW:" and can wrap the PDF inside another message; those won't match and are rejected.
|
|
125
|
+
- **In the receiving Gmail account,** add a filter for the bank senders set to **Never send it to Spam**. The tool doesn't search Spam.
|
|
126
|
+
- **Known limits:**
|
|
127
|
+
- Microsoft 365 work accounts often block forwarding outside the company. Where it does forward, Safe Links and "External sender" banners break the bank's DKIM signature, so those messages are rejected.
|
|
128
|
+
- Some providers, such as Yahoo and Proton, may need a paid plan for automatic forwarding.
|
|
129
|
+
- Forwarding adds a few seconds to a minute of delay.
|
|
130
|
+
|
|
131
|
+
## Run it on a schedule
|
|
132
|
+
|
|
133
|
+
[docs/run-locally.md](docs/run-locally.md) has ready-to-edit schedules for macOS (`launchd`), Linux (`cron` or a systemd timer) and Windows (Task Scheduler), so `fetch` runs hourly or daily on its own.
|
|
134
|
+
|
|
135
|
+
## Use it as a library
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from statement_etl_ph.banks import get_bank
|
|
139
|
+
|
|
140
|
+
statement = get_bank("gcash").parser("statement.pdf", "password")
|
|
141
|
+
result = statement.reconcile()
|
|
142
|
+
assert result.ok, result.errors
|
|
143
|
+
for txn in statement.transactions:
|
|
144
|
+
print(txn.value_date, txn.signed_amount, txn.merchant, txn.reference_no)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Amounts are `Decimal`, never floats.
|
|
148
|
+
|
|
149
|
+
## Never share a real statement
|
|
150
|
+
|
|
151
|
+
When reporting a problem, **never attach a real statement, a screenshot of one, or a real reference number**, and never post your PDF password. The [issue template](.github/ISSUE_TEMPLATE/parse-bug.yml) explains how to describe a layout problem using `-v` output and invented values instead.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# statement-etl-ph
|
|
2
|
+
|
|
3
|
+
Turn your **GCash**, **GoTyme** and **GrabPay** statements into a list of transactions you can actually use: a CSV for a spreadsheet, or a file you import straight into the **Money Manager** app.
|
|
4
|
+
|
|
5
|
+
Every statement is checked before anything is written. The rows it reads must add up to the totals the bank printed, so a misread statement stops instead of quietly putting wrong numbers into your budget.
|
|
6
|
+
|
|
7
|
+
> **Only trust the official source.** The package is `statement-etl-ph` on PyPI and the code lives at <https://github.com/Jomaries777/statement-etl-ph>. This tool runs on your own computer. It never sends your statements, passwords or transactions to anyone. Be suspicious of any copy, fork or "helper" site that asks for them.
|
|
8
|
+
|
|
9
|
+
## Quick start: one statement, no setup
|
|
10
|
+
|
|
11
|
+
You need Python 3.10 or newer.
|
|
12
|
+
|
|
13
|
+
1. **Get your statement.** In the GCash app, open your transaction history and request it by email. It arrives as a password-protected PDF; the email tells you what the password is.
|
|
14
|
+
2. **Install:**
|
|
15
|
+
```bash
|
|
16
|
+
pipx install statement-etl-ph
|
|
17
|
+
```
|
|
18
|
+
(Or `pip install statement-etl-ph` inside a virtual environment.)
|
|
19
|
+
3. **Convert it:**
|
|
20
|
+
```bash
|
|
21
|
+
statement-etl-ph statement.pdf --bank gcash --to moneymanager --password YOUR-PDF-PASSWORD
|
|
22
|
+
```
|
|
23
|
+
It prints every row and the check, then writes `gcash_<from>_to_<to>_moneymanager.csv`. To keep the password out of your shell history, set `GCASH_PDF_PASSWORD` instead of passing `--password`.
|
|
24
|
+
4. **Import that file into Money Manager.** The Category column is left blank on purpose, so you can sort rows in the app.
|
|
25
|
+
|
|
26
|
+
Add `--dry-run` to see the rows without writing anything, and `--to csv` for a plain spreadsheet instead.
|
|
27
|
+
|
|
28
|
+
### What the check catches
|
|
29
|
+
|
|
30
|
+
GCash prints some inbound bank transfers with **no description at all**. A parser that matches a list of descriptions to a list of amounts by position shifts every later amount onto the wrong row, then drops the last one, and reports no error. This package reads the page as a grid, keeps the blank row, and then proves nothing moved: the debits and credits it read must equal the statement's printed Total Debit and Total Credit. If they don't, nothing is written (unless you pass `--force`).
|
|
31
|
+
|
|
32
|
+
It also reports what the bank's own arithmetic leaves out. GCash sometimes refunds a cancelled booking without printing a row, and the running balance is the only evidence. Such refunds, and any other gap the balance proves but nothing explains, are listed in the output rather than hidden. They're never written as rows: add them yourself if you want them.
|
|
33
|
+
|
|
34
|
+
## Supported banks and e-wallets
|
|
35
|
+
|
|
36
|
+
| `--bank` | What it reads | Password variable |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `gcash` | GCash transaction history PDF | `GCASH_PDF_PASSWORD` |
|
|
39
|
+
| `gotyme` | GoTyme Bank statement of account PDF (the Everyday deposit account by default) | `GOTYME_PDF_PASSWORD` |
|
|
40
|
+
| `grab` | Grab e-receipt emails; only GrabPay Wallet payments are imported, because card and GCash payments already appear on those statements | none |
|
|
41
|
+
|
|
42
|
+
Want another bank? See [Adding a bank](docs/add-a-bank.md). It's one file.
|
|
43
|
+
|
|
44
|
+
## Output formats
|
|
45
|
+
|
|
46
|
+
**`--to moneymanager`**: Money Manager's bulk import, with these columns:
|
|
47
|
+
|
|
48
|
+
| Column | Value |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Date | the transaction date on the statement (default `2026-08-26`; change it with `--date-format`) |
|
|
51
|
+
| Account | `GCash`, `GoTyme` or `GrabPay`; use `--account-name` to match an account you already have |
|
|
52
|
+
| Category, Subcategory, Note | blank |
|
|
53
|
+
| Amount | always positive, e.g. `1250.00` |
|
|
54
|
+
| Income/Expense | `Income` for money in, `Expense` for money out |
|
|
55
|
+
| Description | the merchant and the bank's reference, e.g. `Example Apps (ref 0000100000066)` |
|
|
56
|
+
|
|
57
|
+
Money Manager doesn't detect duplicates, so importing the same file twice imports every row twice. Each file is named after its bank and statement period to make that easy to notice.
|
|
58
|
+
|
|
59
|
+
**`--to csv`**: one row per transaction with the signed amount, debit, credit, balance, reference number and a `duplicate_key` column. Writing into a CSV that already exists only appends rows whose key isn't there yet.
|
|
60
|
+
|
|
61
|
+
## Level up: fetch statements from Gmail automatically
|
|
62
|
+
|
|
63
|
+
Instead of downloading each PDF, let the tool find statement emails in your Gmail, check they really came from the bank, and convert them.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pipx install "statement-etl-ph[gmail]"
|
|
67
|
+
statement-etl-ph auth client_secret.json # once
|
|
68
|
+
statement-etl-ph init # once: a config file for your passwords
|
|
69
|
+
statement-etl-ph fetch --bank gcash --to moneymanager --out ~/Statements/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Only mail the bank actually sent is accepted.** A `From:` address is easy to fake, so each message must carry a passing DKIM signature from the bank's own domain (`gcash.com`, `gotyme.com.ph` or `grab.com`), as verified by Gmail. Anything else is refused and left alone.
|
|
73
|
+
|
|
74
|
+
After a statement is converted, its email gets the label `statement-etl-ph/<bank>`, so the next run skips it.
|
|
75
|
+
|
|
76
|
+
### Set up your own Gmail access (about 10 minutes, free)
|
|
77
|
+
|
|
78
|
+
This tool has no shared Google login. You create your own, so nobody else's app ever gets access to your mail. Nothing is deployed, and no billing is needed.
|
|
79
|
+
|
|
80
|
+
1. Go to [console.cloud.google.com](https://console.cloud.google.com), create a project (any name), and enable the **Gmail API** for it.
|
|
81
|
+
2. Open **Google Auth Platform** and set up the consent screen. Choose **External**, and add your own Gmail address as a test user.
|
|
82
|
+
3. Under **Clients**, create an OAuth client of type **Desktop app** and download its JSON file.
|
|
83
|
+
4. **Publish the app:** on the **Audience** page, click **Publish app** to move it from *Testing* to *In production*. Without this, Google expires your sign-in after **7 days** and fetching stops working.
|
|
84
|
+
5. Run `statement-etl-ph auth path/to/client_secret.json`. A browser opens. Google warns that it "hasn't verified this app": that's expected, because the app is yours. Continue, and allow access.
|
|
85
|
+
|
|
86
|
+
The token is saved to `~/.config/statement-etl-ph/token.json`, readable only by you. Revoke it any time at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
|
|
87
|
+
|
|
88
|
+
**Read-only option:** `statement-etl-ph auth client_secret.json --readonly` never changes your mailbox. Processed emails aren't labelled, so each run sees them again. For CSV output, the duplicate key stops rows being added twice; a Money Manager file is simply rewritten.
|
|
89
|
+
|
|
90
|
+
### Your bank mail goes somewhere other than Gmail?
|
|
91
|
+
|
|
92
|
+
Create a free Gmail account just for statements, and forward the bank emails to it. A dedicated account also means the token can only ever see bank mail.
|
|
93
|
+
|
|
94
|
+
- **Forward with a rule that keeps the original email intact:** a Gmail filter's forwarding, or Outlook's **Redirect to**. "Forward"-style rules and forwarding by hand change the sender, add "FW:" and can wrap the PDF inside another message; those won't match and are rejected.
|
|
95
|
+
- **In the receiving Gmail account,** add a filter for the bank senders set to **Never send it to Spam**. The tool doesn't search Spam.
|
|
96
|
+
- **Known limits:**
|
|
97
|
+
- Microsoft 365 work accounts often block forwarding outside the company. Where it does forward, Safe Links and "External sender" banners break the bank's DKIM signature, so those messages are rejected.
|
|
98
|
+
- Some providers, such as Yahoo and Proton, may need a paid plan for automatic forwarding.
|
|
99
|
+
- Forwarding adds a few seconds to a minute of delay.
|
|
100
|
+
|
|
101
|
+
## Run it on a schedule
|
|
102
|
+
|
|
103
|
+
[docs/run-locally.md](docs/run-locally.md) has ready-to-edit schedules for macOS (`launchd`), Linux (`cron` or a systemd timer) and Windows (Task Scheduler), so `fetch` runs hourly or daily on its own.
|
|
104
|
+
|
|
105
|
+
## Use it as a library
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from statement_etl_ph.banks import get_bank
|
|
109
|
+
|
|
110
|
+
statement = get_bank("gcash").parser("statement.pdf", "password")
|
|
111
|
+
result = statement.reconcile()
|
|
112
|
+
assert result.ok, result.errors
|
|
113
|
+
for txn in statement.transactions:
|
|
114
|
+
print(txn.value_date, txn.signed_amount, txn.merchant, txn.reference_no)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Amounts are `Decimal`, never floats.
|
|
118
|
+
|
|
119
|
+
## Never share a real statement
|
|
120
|
+
|
|
121
|
+
When reporting a problem, **never attach a real statement, a screenshot of one, or a real reference number**, and never post your PDF password. The [issue template](.github/ISSUE_TEMPLATE/parse-bug.yml) explains how to describe a layout problem using `-v` output and invented values instead.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Adding a bank
|
|
2
|
+
|
|
3
|
+
A bank is one module in `src/statement_etl_ph/banks/`: a parser and a `BANK` definition. You never need, and must never share, a real statement to add one.
|
|
4
|
+
|
|
5
|
+
## 1. Learn the layout without sharing it
|
|
6
|
+
|
|
7
|
+
On your own machine, run an existing parser over your statement with `-v` to see how the PDF is laid out:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
statement-etl-ph your-statement.pdf --bank gcash --dry-run -v
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It fails for a new bank, but the debug lines show what pdfplumber sees. For a closer look, print the words and their positions yourself:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import pdfplumber
|
|
17
|
+
|
|
18
|
+
with pdfplumber.open("your-statement.pdf", password="...") as pdf:
|
|
19
|
+
for word in pdf.pages[0].extract_words():
|
|
20
|
+
print(round(word["x0"]), round(word["x1"]), round(word["top"]), word["text"])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Note the **positions** of the column headers and of a few amounts, never the values themselves. Those positions are all the rest of the work needs.
|
|
24
|
+
|
|
25
|
+
## 2. Write the parser
|
|
26
|
+
|
|
27
|
+
Most statements are a borderless table, and `pdf_geometry` already does the hard part:
|
|
28
|
+
|
|
29
|
+
- `cluster_rows(words, tolerance, sort_key)` groups words into visual rows.
|
|
30
|
+
- `find_money_columns(rows, headers)` finds where the money columns sit from their header row.
|
|
31
|
+
- `assign_money_columns(row, columns, tolerance)` assigns each amount on a row to the nearest column by its right edge.
|
|
32
|
+
|
|
33
|
+
What differs per bank is the vocabulary: the header names, how the period is printed, what the totals rows are called, and how forgiving the tolerances need to be. Pass yours in; don't change another bank's.
|
|
34
|
+
|
|
35
|
+
The parser has one shape, `parse_statement(path, password) -> Statement`. Fill in the printed totals (`starting_balance`, `ending_balance`, `total_debit`, `total_credit`) whenever the statement prints them: they're what `reconcile()` checks the rows against.
|
|
36
|
+
|
|
37
|
+
**Reference numbers.** If the bank prints one per row, use it. It becomes the duplicate key. If not, build a stable one from the date, description, amounts and running balance, as `banks/gotyme.py` does, so two identical payments on one day stay two rows.
|
|
38
|
+
|
|
39
|
+
## 3. Declare the bank
|
|
40
|
+
|
|
41
|
+
At the bottom of your module:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
BANK = BankDefinition(
|
|
45
|
+
name="examplebank",
|
|
46
|
+
display_name="Example Bank",
|
|
47
|
+
parser=parse_statement,
|
|
48
|
+
gmail_query='from:statements@examplebank.com.ph subject:"Statement" has:attachment',
|
|
49
|
+
dkim_domains=("examplebank.com.ph",),
|
|
50
|
+
password_env="EXAMPLEBANK_PDF_PASSWORD",
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then add it to the tuple in `banks/__init__.py`.
|
|
55
|
+
|
|
56
|
+
**`dkim_domains`:** in Gmail, open one of the bank's emails, choose **Show original**, and read the `Authentication-Results` line. Use the domain on the `dkim=pass` result that belongs to the bank (`header.i=@…` or `header.d=…`). Never add an email provider's domain such as `amazonses.com` or `sendgrid.net`: they sign mail for anyone who pays them.
|
|
57
|
+
|
|
58
|
+
## 4. Build a synthetic statement
|
|
59
|
+
|
|
60
|
+
Copy `tests/fixtures/make_fixture.py` into `tests/fixtures/make_<bank>_fixture.py` and change it to draw your bank's layout:
|
|
61
|
+
|
|
62
|
+
- the same header words, at the same x positions you measured
|
|
63
|
+
- **invented** rows: made-up merchants, made-up reference numbers, round amounts
|
|
64
|
+
- a running balance and printed totals that genuinely add up
|
|
65
|
+
- encrypted with a made-up password, using pikepdf
|
|
66
|
+
|
|
67
|
+
Cover the cases that break parsers: a credit row, a thousands separator, a long description, a row with an empty description, and a second page if real statements have one.
|
|
68
|
+
|
|
69
|
+
## 5. Test it
|
|
70
|
+
|
|
71
|
+
In `tests/test_parse_<bank>.py`, at least:
|
|
72
|
+
|
|
73
|
+
- every row is extracted, and every reference and description matches the generator's rows exactly
|
|
74
|
+
- `reconcile().ok` is true
|
|
75
|
+
- a wrong password is rejected
|
|
76
|
+
- a row with an empty description keeps every later amount on its own row
|
|
77
|
+
|
|
78
|
+
## 6. Before you open the pull request
|
|
79
|
+
|
|
80
|
+
- [ ] No real names, account or mobile numbers, card digits, reference numbers or addresses anywhere: code, tests, comments or commit messages.
|
|
81
|
+
- [ ] No real PDFs, screenshots or `Authentication-Results` headers. Invent values with the same shape.
|
|
82
|
+
- [ ] `pytest` passes.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Run it on your own computer
|
|
2
|
+
|
|
3
|
+
Everything here runs on your machine. The only Google Cloud step is the free project that holds your Gmail OAuth client ([README](../README.md#set-up-your-own-gmail-access-about-10-minutes-free)); nothing is deployed.
|
|
4
|
+
|
|
5
|
+
## 1. Install
|
|
6
|
+
|
|
7
|
+
Python 3.10 or newer, on macOS, Linux or Windows.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install "statement-etl-ph[gmail]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Drop `[gmail]` if you'll only convert PDFs you download yourself.
|
|
14
|
+
|
|
15
|
+
## 2. Sign in to Gmail once
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
statement-etl-ph auth path/to/client_secret.json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A browser opens for consent. The token is saved to `~/.config/statement-etl-ph/token.json` (`%APPDATA%\statement-etl-ph\token.json` on Windows), readable only by you. Add `--readonly` if the tool must never change your mailbox.
|
|
22
|
+
|
|
23
|
+
## 3. Write your config
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
statement-etl-ph init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This writes `~/.config/statement-etl-ph/statement-etl-ph.json`, also readable only by you. Fill in:
|
|
30
|
+
|
|
31
|
+
- each bank's `password` (or leave it empty and set `GCASH_PDF_PASSWORD` / `GOTYME_PDF_PASSWORD` instead)
|
|
32
|
+
- `account_name`, if your Money Manager account is named differently
|
|
33
|
+
- `output.to` (`moneymanager` or `csv`) and `output.dir`
|
|
34
|
+
- `gmail.since`: how far back to search (`30d`, `2w`, `yesterday`, `all`)
|
|
35
|
+
|
|
36
|
+
Remove the banks you don't use: `fetch` with no `--bank` reads every bank listed.
|
|
37
|
+
|
|
38
|
+
## 4. Run it
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# A PDF you already have
|
|
42
|
+
statement-etl-ph statement.pdf --bank gcash --to moneymanager
|
|
43
|
+
|
|
44
|
+
# Everything new in Gmail, for every bank in your config
|
|
45
|
+
statement-etl-ph fetch
|
|
46
|
+
|
|
47
|
+
# See what would happen, write nothing, label nothing
|
|
48
|
+
statement-etl-ph fetch --dry-run
|
|
49
|
+
|
|
50
|
+
# Show how a PDF was read, column positions included
|
|
51
|
+
statement-etl-ph statement.pdf --bank gcash --dry-run -v
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A statement whose rows don't match its printed totals is never written, and its email isn't labelled, so the next run tries it again.
|
|
55
|
+
|
|
56
|
+
## 5. Run it on a schedule (optional)
|
|
57
|
+
|
|
58
|
+
Each schedule runs `statement-etl-ph fetch`. Because converted emails are labelled, running often is harmless: a run with nothing new does nothing.
|
|
59
|
+
|
|
60
|
+
First find the full path of the command, since schedulers don't load your shell's `PATH`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
which statement-etl-ph
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### macOS (launchd)
|
|
67
|
+
|
|
68
|
+
1. Copy [`deploy/local/com.statement-etl-ph.fetch.plist`](../deploy/local/com.statement-etl-ph.fetch.plist) to `~/Library/LaunchAgents/`.
|
|
69
|
+
2. Edit the command path and the log paths in it.
|
|
70
|
+
3. Load it:
|
|
71
|
+
```bash
|
|
72
|
+
launchctl load ~/Library/LaunchAgents/com.statement-etl-ph.fetch.plist
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
It runs every hour while you're logged in, and once on login.
|
|
76
|
+
|
|
77
|
+
### Linux (systemd timer)
|
|
78
|
+
|
|
79
|
+
1. Copy [`deploy/local/statement-etl-ph.service`](../deploy/local/statement-etl-ph.service) and [`statement-etl-ph.timer`](../deploy/local/statement-etl-ph.timer) to `~/.config/systemd/user/`.
|
|
80
|
+
2. Edit the command path in the `.service` file.
|
|
81
|
+
3. Enable it:
|
|
82
|
+
```bash
|
|
83
|
+
systemctl --user enable --now statement-etl-ph.timer
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or with cron (`crontab -e`), hourly:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
0 * * * * /home/you/.local/bin/statement-etl-ph fetch >> /home/you/statement-etl-ph.log 2>&1
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Windows (Task Scheduler)
|
|
93
|
+
|
|
94
|
+
In PowerShell, using the path from `where.exe statement-etl-ph`:
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
schtasks /Create /SC HOURLY /TN "statement-etl-ph fetch" /TR "C:\Users\you\.local\bin\statement-etl-ph.exe fetch"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## What a local run can't do
|
|
101
|
+
|
|
102
|
+
An import happens when the command runs, not the moment the email arrives. Reacting instantly needs a public address for Gmail's notifications to reach, which a laptop doesn't have.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "statement-etl-ph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read GCash, GoTyme and Grab statements into checked transactions, a CSV, or a Money Manager import."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "statement-etl-ph contributors" }]
|
|
14
|
+
keywords = ["gcash", "gotyme", "grab", "philippines", "bank statement", "pdf", "budget"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
23
|
+
]
|
|
24
|
+
# Parsing a statement you already have needs nothing else.
|
|
25
|
+
dependencies = ["pdfplumber>=0.11.4"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
gmail = [
|
|
29
|
+
"google-api-python-client>=2.140.0",
|
|
30
|
+
"google-auth>=2.34.0",
|
|
31
|
+
"google-auth-oauthlib>=1.2.1",
|
|
32
|
+
"google-auth-httplib2>=0.2.0",
|
|
33
|
+
]
|
|
34
|
+
dev = ["pytest>=8.3.0", "reportlab>=4.2.0", "pikepdf>=9.0.0"]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
statement-etl-ph = "statement_etl_ph.cli:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Issues = "https://github.com/Jomaries777/statement-etl-ph/issues"
|
|
41
|
+
Source = "https://github.com/Jomaries777/statement-etl-ph"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/statement_etl_ph"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src/", "tests/", "README.md", "LICENSE", "docs/"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Read Philippine bank and e-wallet statements into plain transactions.
|
|
2
|
+
|
|
3
|
+
from statement_etl_ph.banks import get_bank
|
|
4
|
+
|
|
5
|
+
statement = get_bank("gcash").parser("statement.pdf", "password")
|
|
6
|
+
assert statement.reconcile().ok
|
|
7
|
+
for txn in statement.transactions:
|
|
8
|
+
print(txn.value_date, txn.signed_amount, txn.merchant)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The banks and e-wallets this package can read, one module each.
|
|
2
|
+
|
|
3
|
+
The registry is a fixed list on purpose. Nothing is discovered from other
|
|
4
|
+
installed packages: a statement is financial data, and which code reads it
|
|
5
|
+
should be decided here, not by whatever else happens to be installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .base import BankDefinition
|
|
11
|
+
from .gcash import BANK as GCASH
|
|
12
|
+
from .gotyme import BANK as GOTYME
|
|
13
|
+
from .grab import BANK as GRAB
|
|
14
|
+
|
|
15
|
+
__all__ = ["BankDefinition", "available", "get_bank", "register"]
|
|
16
|
+
|
|
17
|
+
_REGISTRY: dict[str, BankDefinition] = {bank.name: bank for bank in (GCASH, GOTYME, GRAB)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(bank: BankDefinition) -> None:
|
|
21
|
+
"""Add a bank from your own code, e.g. while developing a new parser."""
|
|
22
|
+
_REGISTRY[bank.name] = bank
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def available() -> list[str]:
|
|
26
|
+
return sorted(_REGISTRY)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_bank(name: str) -> BankDefinition:
|
|
30
|
+
try:
|
|
31
|
+
return _REGISTRY[name.lower()]
|
|
32
|
+
except KeyError:
|
|
33
|
+
raise KeyError(
|
|
34
|
+
f"no bank called {name!r}. Available: {', '.join(available())}"
|
|
35
|
+
) from None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What a bank is to this package: how to find its mail and how to read it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from ..models import Statement
|
|
9
|
+
|
|
10
|
+
# (path, password) -> Statement. A provider whose statement is the email body
|
|
11
|
+
# rather than a PDF keeps the same shape and ignores the password.
|
|
12
|
+
Parser = Callable[..., Statement]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BankDefinition:
|
|
17
|
+
"""Everything that differs between banks, in one place.
|
|
18
|
+
|
|
19
|
+
Adding a bank means writing one module in this package that defines a
|
|
20
|
+
parser and a ``BANK`` of this type, then listing it in ``banks/__init__``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
# How the provider is named to a person: the Account column of a Money
|
|
25
|
+
# Manager import, a memo, a report.
|
|
26
|
+
display_name: str
|
|
27
|
+
parser: Parser
|
|
28
|
+
# Gmail search for the statement email. The processed label and date
|
|
29
|
+
# window are added at fetch time, not written here.
|
|
30
|
+
gmail_query: str
|
|
31
|
+
# The domains the provider DKIM-signs its mail with. A message no one of
|
|
32
|
+
# them signed is refused, whatever its From header says.
|
|
33
|
+
dkim_domains: tuple[str, ...]
|
|
34
|
+
# The environment variable holding the PDF password. Empty for a provider
|
|
35
|
+
# whose statement is not encrypted.
|
|
36
|
+
password_env: str = ""
|
|
37
|
+
# "pdf" for an attachment; "email_body" when the email *is* the receipt.
|
|
38
|
+
content: str = "pdf"
|
|
39
|
+
# Filename fragments that pick the right PDF when a message carries
|
|
40
|
+
# several, tried in order. A pattern that matches nothing fails the
|
|
41
|
+
# message rather than falling back to the first PDF.
|
|
42
|
+
attachment_patterns: tuple[str, ...] = field(default_factory=tuple)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def reads_body(self) -> bool:
|
|
46
|
+
return self.content == "email_body"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def processed_label(self) -> str:
|
|
50
|
+
return f"statement-etl-ph/{self.name}"
|