statement-etl-ph 0.1.0__py3-none-any.whl
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/__init__.py +11 -0
- statement_etl_ph/__main__.py +3 -0
- statement_etl_ph/banks/__init__.py +35 -0
- statement_etl_ph/banks/base.py +50 -0
- statement_etl_ph/banks/gcash.py +234 -0
- statement_etl_ph/banks/gotyme.py +262 -0
- statement_etl_ph/banks/grab.py +237 -0
- statement_etl_ph/cli.py +349 -0
- statement_etl_ph/config.py +93 -0
- statement_etl_ph/gmail/__init__.py +0 -0
- statement_etl_ph/gmail/auth.py +135 -0
- statement_etl_ph/gmail/fetch.py +492 -0
- statement_etl_ph/models.py +451 -0
- statement_etl_ph/pdf_geometry.py +127 -0
- statement_etl_ph/sinks/__init__.py +65 -0
- statement_etl_ph/sinks/export.py +84 -0
- statement_etl_ph/sinks/money_manager.py +117 -0
- statement_etl_ph-0.1.0.dist-info/METADATA +155 -0
- statement_etl_ph-0.1.0.dist-info/RECORD +22 -0
- statement_etl_ph-0.1.0.dist-info/WHEEL +4 -0
- statement_etl_ph-0.1.0.dist-info/entry_points.txt +2 -0
- statement_etl_ph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""GCash: decrypt the emailed transaction-history PDF and extract its table.
|
|
2
|
+
|
|
3
|
+
GCash renders the statement as a borderless table, so ``extract_table()``
|
|
4
|
+
guesses badly at it. What *is* reliable is geometry: the Debit, Credit and
|
|
5
|
+
Balance figures are right-aligned under their headers, always at the same x
|
|
6
|
+
coordinate. So we read the header row once to learn where the money columns
|
|
7
|
+
live, then assign every numeric token on every row to the nearest column.
|
|
8
|
+
|
|
9
|
+
That distinction matters. A text-only parse of ``59.00 3343.68`` cannot
|
|
10
|
+
tell a debit from a credit, and getting it backwards would flip the sign of
|
|
11
|
+
the transaction wherever it ends up.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import pdfplumber
|
|
23
|
+
|
|
24
|
+
from ..models import Statement, StatementParseError, Transaction
|
|
25
|
+
from .base import BankDefinition
|
|
26
|
+
from ..pdf_geometry import AMOUNT_RE, assign_money_columns, cluster_rows, find_money_columns
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_PERIOD_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})")
|
|
31
|
+
# A GCash reference number: a long digit run, distinct from an amount.
|
|
32
|
+
_REFERENCE_RE = re.compile(r"^\d{9,}$")
|
|
33
|
+
# Same, but rescued from inside a token that picked up neighbouring glyphs.
|
|
34
|
+
_DIGIT_RUN_RE = re.compile(r"\d{9,}")
|
|
35
|
+
|
|
36
|
+
# Rows are single lines that never wrap, so grouping words a fraction of a
|
|
37
|
+
# point apart in top position is enough to call them the same row.
|
|
38
|
+
_ROW_CLUSTER_TOLERANCE = 3.0
|
|
39
|
+
# How close (in points) a number's right edge must be to a header's right edge
|
|
40
|
+
# before we accept the match. The three columns are ~50pt apart, so 25 is a
|
|
41
|
+
# safe half-width.
|
|
42
|
+
_COLUMN_TOLERANCE = 25.0
|
|
43
|
+
|
|
44
|
+
_MONEY_HEADERS = ("debit", "credit", "balance")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _group_words_into_lines(words) -> list[list[dict]]:
|
|
48
|
+
"""Cluster extracted words into visual rows, in left-to-right reading order.
|
|
49
|
+
|
|
50
|
+
No GCash row wraps, so sorting purely by ``x0`` is always reading order.
|
|
51
|
+
"""
|
|
52
|
+
return cluster_rows(words, _ROW_CLUSTER_TOLERANCE, sort_key=lambda w: (w["x0"],))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_reference_zone(lines: list[list[dict]]) -> Optional[tuple[float, float]]:
|
|
56
|
+
"""Locate the horizontal span of the 'Reference No.' column.
|
|
57
|
+
|
|
58
|
+
Position is a better signal than pattern matching here. A long merchant
|
|
59
|
+
description can contain digit runs of its own, and reference numbers are
|
|
60
|
+
not always the first such run on the row; the column they sit in is the
|
|
61
|
+
thing that actually identifies them.
|
|
62
|
+
"""
|
|
63
|
+
for line in lines:
|
|
64
|
+
for index, word in enumerate(line):
|
|
65
|
+
if word["text"].strip().lower() != "reference":
|
|
66
|
+
continue
|
|
67
|
+
right = word["x1"]
|
|
68
|
+
if index + 1 < len(line) and line[index + 1]["text"].strip().lower() == "no.":
|
|
69
|
+
right = line[index + 1]["x1"]
|
|
70
|
+
return (word["x0"] - 30.0, right + 15.0)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _split_reference_and_description(
|
|
75
|
+
words: list[dict], zone: Optional[tuple[float, float]]
|
|
76
|
+
) -> tuple[str, str]:
|
|
77
|
+
"""Separate the reference number from the description on one row."""
|
|
78
|
+
reference = ""
|
|
79
|
+
description_parts: list[str] = []
|
|
80
|
+
|
|
81
|
+
for word in words:
|
|
82
|
+
token = word["text"].strip()
|
|
83
|
+
if not token or AMOUNT_RE.match(token):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
in_zone = zone is not None and word["x1"] >= zone[0] and word["x0"] <= zone[1]
|
|
87
|
+
if not reference and (in_zone or zone is None):
|
|
88
|
+
match = _REFERENCE_RE.match(token) or _DIGIT_RUN_RE.search(token)
|
|
89
|
+
if match:
|
|
90
|
+
reference = match.group(0)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Anything to the right of the reference column is a stray artefact,
|
|
94
|
+
# not part of the merchant name.
|
|
95
|
+
if zone is not None and word["x0"] > zone[1]:
|
|
96
|
+
continue
|
|
97
|
+
if not reference:
|
|
98
|
+
description_parts.append(token)
|
|
99
|
+
|
|
100
|
+
return reference, " ".join(description_parts).strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _row_text(line: list[dict]) -> str:
|
|
104
|
+
return " ".join(w["text"] for w in line)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_datetime(date_token: str, time_token: str) -> Optional[datetime]:
|
|
108
|
+
cleaned = time_token.upper().replace(".", "").replace(" ", "")
|
|
109
|
+
for fmt in ("%Y-%m-%d %I:%M%p", "%Y-%m-%d %H:%M"):
|
|
110
|
+
try:
|
|
111
|
+
return datetime.strptime(f"{date_token} {cleaned}", fmt)
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_statement(pdf_path: str | Path, password: str) -> Statement:
|
|
118
|
+
"""Decrypt and parse a GCash statement PDF into a :class:`Statement`."""
|
|
119
|
+
pdf_path = Path(pdf_path)
|
|
120
|
+
transactions: list[Transaction] = []
|
|
121
|
+
starting_balance = ending_balance = total_debit = total_credit = None
|
|
122
|
+
period_start: Optional[date] = None
|
|
123
|
+
period_end: Optional[date] = None
|
|
124
|
+
|
|
125
|
+
with pdfplumber.open(str(pdf_path), password=password) as pdf:
|
|
126
|
+
if not pdf.pages:
|
|
127
|
+
raise StatementParseError("PDF has no pages")
|
|
128
|
+
|
|
129
|
+
columns: Optional[dict[str, float]] = None
|
|
130
|
+
reference_zone: Optional[tuple[float, float]] = None
|
|
131
|
+
|
|
132
|
+
for page_no, page in enumerate(pdf.pages, start=1):
|
|
133
|
+
lines = _group_words_into_lines(page.extract_words(keep_blank_chars=False))
|
|
134
|
+
|
|
135
|
+
if columns is None:
|
|
136
|
+
try:
|
|
137
|
+
columns = find_money_columns(lines, _MONEY_HEADERS)
|
|
138
|
+
reference_zone = _find_reference_zone(lines)
|
|
139
|
+
logger.debug(
|
|
140
|
+
"money columns at %s, reference zone %s", columns, reference_zone
|
|
141
|
+
)
|
|
142
|
+
except StatementParseError:
|
|
143
|
+
if page_no == len(pdf.pages):
|
|
144
|
+
raise
|
|
145
|
+
continue # header might be on a later page
|
|
146
|
+
|
|
147
|
+
for line in lines:
|
|
148
|
+
text = _row_text(line)
|
|
149
|
+
|
|
150
|
+
if period_start is None:
|
|
151
|
+
period_match = _PERIOD_RE.search(text)
|
|
152
|
+
if period_match:
|
|
153
|
+
period_start = date.fromisoformat(period_match.group(1))
|
|
154
|
+
period_end = date.fromisoformat(period_match.group(2))
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
amounts = assign_money_columns(line, columns, _COLUMN_TOLERANCE)
|
|
158
|
+
upper = text.upper()
|
|
159
|
+
|
|
160
|
+
if "STARTING BALANCE" in upper:
|
|
161
|
+
starting_balance = amounts.get("balance")
|
|
162
|
+
continue
|
|
163
|
+
if "ENDING BALANCE" in upper:
|
|
164
|
+
ending_balance = amounts.get("balance")
|
|
165
|
+
continue
|
|
166
|
+
if "TOTAL DEBIT" in upper:
|
|
167
|
+
total_debit = amounts.get("debit")
|
|
168
|
+
continue
|
|
169
|
+
if "TOTAL CREDIT" in upper:
|
|
170
|
+
total_credit = amounts.get("credit")
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# A transaction row starts with a date and a time.
|
|
174
|
+
if len(line) < 3:
|
|
175
|
+
continue
|
|
176
|
+
stamp = _parse_datetime(line[0]["text"], f"{line[1]['text']} {line[2]['text']}")
|
|
177
|
+
if stamp is None:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
reference, description = _split_reference_and_description(
|
|
181
|
+
line[3:], reference_zone
|
|
182
|
+
)
|
|
183
|
+
if not description:
|
|
184
|
+
logger.warning("row at %s has no description; keeping it anyway", stamp)
|
|
185
|
+
|
|
186
|
+
if not amounts.get("debit") and not amounts.get("credit"):
|
|
187
|
+
logger.warning(
|
|
188
|
+
"skipping row with no debit or credit amount: %s", text[:90]
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
transactions.append(
|
|
193
|
+
Transaction(
|
|
194
|
+
posted_at=stamp,
|
|
195
|
+
description=description,
|
|
196
|
+
reference_no=reference,
|
|
197
|
+
debit=amounts.get("debit"),
|
|
198
|
+
credit=amounts.get("credit"),
|
|
199
|
+
balance=amounts.get("balance"),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not transactions:
|
|
204
|
+
raise StatementParseError("no transaction rows found in the statement")
|
|
205
|
+
|
|
206
|
+
statement = Statement(
|
|
207
|
+
period_start=period_start,
|
|
208
|
+
period_end=period_end,
|
|
209
|
+
starting_balance=starting_balance,
|
|
210
|
+
ending_balance=ending_balance,
|
|
211
|
+
total_debit=total_debit,
|
|
212
|
+
total_credit=total_credit,
|
|
213
|
+
transactions=transactions,
|
|
214
|
+
)
|
|
215
|
+
logger.info(
|
|
216
|
+
"parsed %d transactions for %s to %s",
|
|
217
|
+
len(transactions),
|
|
218
|
+
period_start,
|
|
219
|
+
period_end,
|
|
220
|
+
)
|
|
221
|
+
return statement
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
BANK = BankDefinition(
|
|
225
|
+
name="gcash",
|
|
226
|
+
display_name="GCash",
|
|
227
|
+
parser=parse_statement,
|
|
228
|
+
gmail_query=(
|
|
229
|
+
'from:noreply-transhistory@gcash.com subject:"Your GCash Transaction History" '
|
|
230
|
+
"has:attachment"
|
|
231
|
+
),
|
|
232
|
+
dkim_domains=("gcash.com",),
|
|
233
|
+
password_env="GCASH_PDF_PASSWORD",
|
|
234
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""GoTyme Bank: decrypt a statement of account and extract its table.
|
|
2
|
+
|
|
3
|
+
The technique is the one :mod:`statement_etl_ph.banks.gcash` uses, from
|
|
4
|
+
:mod:`statement_etl_ph.pdf_geometry`: find the money column
|
|
5
|
+
headers, then assign each numeric token to a column by its right edge. What
|
|
6
|
+
does not transfer is the vocabulary, and three differences are load-bearing.
|
|
7
|
+
|
|
8
|
+
**Both money columns are always printed.** GoTyme writes ``0.00`` in the
|
|
9
|
+
column a row does not use, so a row reading ``0.00 174.25`` is a debit of
|
|
10
|
+
174.25 and *not* a credit of zero. A zero is therefore read as "not this
|
|
11
|
+
side" and stored as ``None``; storing ``Decimal("0")`` would make
|
|
12
|
+
``signed_amount`` return zero for every transaction on the statement.
|
|
13
|
+
|
|
14
|
+
**Descriptions wrap around the data line.** A long payee is printed on the
|
|
15
|
+
line *above* the date and amounts, and continues on the line *below* them:
|
|
16
|
+
|
|
17
|
+
Transfer from J. D. SANTOS Maya Philippines, Inc (PayMaya Wallet), ...
|
|
18
|
+
08 Aug 2026 3,000.00 0.00 3,445.95
|
|
19
|
+
••••••••0000
|
|
20
|
+
|
|
21
|
+
Those three lines are one logical row. Geometry separates them from ordinary
|
|
22
|
+
rows without guesswork: wrapped lines sit 3.9pt apart and start in the
|
|
23
|
+
Details column, while consecutive rows are 15.7pt apart and start in the Date
|
|
24
|
+
column. So lines are clustered vertically with a threshold between the two,
|
|
25
|
+
and the description is every non-date, non-amount word in the cluster read
|
|
26
|
+
top-to-bottom, left-to-right.
|
|
27
|
+
|
|
28
|
+
**There are no reference numbers.** GCash prints one per row, which makes an
|
|
29
|
+
ideal idempotency key. GoTyme prints none, and the usual fallback of
|
|
30
|
+
``amount:date`` is not good enough here: this statement contains two GRAB
|
|
31
|
+
payments of 149.00 on the same day, which would collide and silently import
|
|
32
|
+
as one. The running balance disambiguates them — it is a ledger, so no two
|
|
33
|
+
rows share one — so each row gets a synthetic reference derived from date,
|
|
34
|
+
description, both amounts and that balance. It is stable across re-runs of
|
|
35
|
+
the same statement, which is what idempotency needs.
|
|
36
|
+
|
|
37
|
+
What it is *not* stable across is a **reissued** statement. A backdated
|
|
38
|
+
adjustment shifts every running balance after it, so every row from that
|
|
39
|
+
point onward gets a new key and re-imports alongside the originals. That is
|
|
40
|
+
accepted rather than solved: dropping the balance trades visible duplicates
|
|
41
|
+
for silent data loss, keying on row position breaks on any insertion, and
|
|
42
|
+
hashing date and description alone collides on genuinely repeated purchases —
|
|
43
|
+
one real month held eight identical ``MOVE IT`` rows. Duplicates you can see
|
|
44
|
+
and delete beat a transaction that never arrives.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import hashlib
|
|
50
|
+
import logging
|
|
51
|
+
import re
|
|
52
|
+
from datetime import date, datetime
|
|
53
|
+
from decimal import Decimal
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Optional
|
|
56
|
+
|
|
57
|
+
import pdfplumber
|
|
58
|
+
|
|
59
|
+
from ..models import Statement, StatementParseError, Transaction
|
|
60
|
+
from .base import BankDefinition
|
|
61
|
+
from ..pdf_geometry import (
|
|
62
|
+
AMOUNT_RE,
|
|
63
|
+
assign_money_columns,
|
|
64
|
+
cluster_rows,
|
|
65
|
+
find_money_columns,
|
|
66
|
+
to_decimal,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
_DATE_RE = re.compile(r"^(\d{1,2}) ([A-Za-z]{3}) (\d{4})$")
|
|
72
|
+
_PERIOD_RE = re.compile(
|
|
73
|
+
r"Period:\s*(\d{1,2} [A-Za-z]{3} \d{4})\s*[-–]\s*(\d{1,2} [A-Za-z]{3} \d{4})"
|
|
74
|
+
)
|
|
75
|
+
_SUMMARY_RES = {
|
|
76
|
+
"starting_balance": re.compile(r"Opening balance\s+[A-Z]{3}\s+([\d,]+\.\d{2})"),
|
|
77
|
+
"ending_balance": re.compile(r"Closing balance\s+[A-Z]{3}\s+([\d,]+\.\d{2})"),
|
|
78
|
+
"total_credit": re.compile(r"Total Credit\s+[A-Z]{3}\s+([\d,]+\.\d{2})"),
|
|
79
|
+
"total_debit": re.compile(r"Total Debit\s+[A-Z]{3}\s+([\d,]+\.\d{2})"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Wrapped continuation lines sit 3.9pt from their data line; separate rows sit
|
|
83
|
+
# 15.7pt apart. Anything between the two separates rows without splitting one.
|
|
84
|
+
_ROW_CLUSTER_TOLERANCE = 10.0
|
|
85
|
+
# Credits and Debits are ~37pt apart, so half that is a safe column tolerance.
|
|
86
|
+
_COLUMN_TOLERANCE = 15.0
|
|
87
|
+
_MONEY_HEADERS = ("credits", "debits", "balance")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cluster_rows(words) -> list[list[dict]]:
|
|
91
|
+
"""Group words into logical rows, keeping a wrapped description together.
|
|
92
|
+
|
|
93
|
+
A row can wrap above and below its data line, so words must read back
|
|
94
|
+
top-to-bottom before left-to-right, or a wrapped line would interleave
|
|
95
|
+
with the data line by horizontal position instead of following it.
|
|
96
|
+
"""
|
|
97
|
+
return cluster_rows(words, _ROW_CLUSTER_TOLERANCE, sort_key=lambda w: (w["top"], w["x0"]))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _synthetic_reference(
|
|
101
|
+
when: date,
|
|
102
|
+
description: str,
|
|
103
|
+
credit: Optional[Decimal],
|
|
104
|
+
debit: Optional[Decimal],
|
|
105
|
+
balance: Optional[Decimal],
|
|
106
|
+
) -> str:
|
|
107
|
+
"""A stable per-row key, standing in for the reference GoTyme omits.
|
|
108
|
+
|
|
109
|
+
Includes the running balance deliberately: it is what distinguishes two
|
|
110
|
+
otherwise identical payments on the same day.
|
|
111
|
+
"""
|
|
112
|
+
payload = "|".join(
|
|
113
|
+
[
|
|
114
|
+
when.isoformat(),
|
|
115
|
+
" ".join(description.split()).lower(),
|
|
116
|
+
str(credit if credit is not None else ""),
|
|
117
|
+
str(debit if debit is not None else ""),
|
|
118
|
+
str(balance if balance is not None else ""),
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_row(
|
|
125
|
+
row: list[dict], columns: dict[str, float]
|
|
126
|
+
) -> Optional[Transaction]:
|
|
127
|
+
amounts = [w for w in row if AMOUNT_RE.match(w["text"])]
|
|
128
|
+
if len(amounts) < 3:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# The date is three consecutive words ("08", "Aug", "2026") in the Date
|
|
132
|
+
# column. Find them rather than assuming they start the cluster — on a
|
|
133
|
+
# wrapped row the description is printed above them.
|
|
134
|
+
texts = [w["text"] for w in row]
|
|
135
|
+
when: Optional[date] = None
|
|
136
|
+
date_indices: set[int] = set()
|
|
137
|
+
for i in range(len(texts) - 2):
|
|
138
|
+
candidate = " ".join(texts[i : i + 3])
|
|
139
|
+
if _DATE_RE.match(candidate):
|
|
140
|
+
try:
|
|
141
|
+
when = datetime.strptime(candidate, "%d %b %Y").date()
|
|
142
|
+
except ValueError:
|
|
143
|
+
continue
|
|
144
|
+
date_indices = {i, i + 1, i + 2}
|
|
145
|
+
break
|
|
146
|
+
if when is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
values = assign_money_columns(row, columns, _COLUMN_TOLERANCE)
|
|
150
|
+
credit = values.get("credits")
|
|
151
|
+
debit = values.get("debits")
|
|
152
|
+
balance = values.get("balance")
|
|
153
|
+
|
|
154
|
+
# GoTyme prints 0.00 in the column a row does not use. Treating that as a
|
|
155
|
+
# real zero would make every transaction worth nothing.
|
|
156
|
+
if credit == 0:
|
|
157
|
+
credit = None
|
|
158
|
+
if debit == 0:
|
|
159
|
+
debit = None
|
|
160
|
+
if credit is None and debit is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
amount_ids = {id(w) for w in amounts}
|
|
164
|
+
description = " ".join(
|
|
165
|
+
w["text"]
|
|
166
|
+
for i, w in enumerate(row)
|
|
167
|
+
if i not in date_indices and id(w) not in amount_ids
|
|
168
|
+
)
|
|
169
|
+
description = " ".join(description.split())
|
|
170
|
+
if not description:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
return Transaction(
|
|
174
|
+
posted_at=datetime(when.year, when.month, when.day),
|
|
175
|
+
description=description,
|
|
176
|
+
reference_no=_synthetic_reference(when, description, credit, debit, balance),
|
|
177
|
+
debit=debit,
|
|
178
|
+
credit=credit,
|
|
179
|
+
balance=balance,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_summary(text: str) -> dict[str, Optional[Decimal]]:
|
|
184
|
+
found: dict[str, Optional[Decimal]] = {}
|
|
185
|
+
for field, pattern in _SUMMARY_RES.items():
|
|
186
|
+
match = pattern.search(text)
|
|
187
|
+
found[field] = to_decimal(match.group(1)) if match else None
|
|
188
|
+
return found
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_period(text: str) -> tuple[Optional[date], Optional[date]]:
|
|
192
|
+
match = _PERIOD_RE.search(text)
|
|
193
|
+
if not match:
|
|
194
|
+
return None, None
|
|
195
|
+
try:
|
|
196
|
+
start = datetime.strptime(match.group(1), "%d %b %Y").date()
|
|
197
|
+
end = datetime.strptime(match.group(2), "%d %b %Y").date()
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None, None
|
|
200
|
+
return start, end
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_statement(pdf_path: str | Path, password: str) -> Statement:
|
|
204
|
+
"""Parse a password-protected GoTyme statement into a :class:`Statement`."""
|
|
205
|
+
transactions: list[Transaction] = []
|
|
206
|
+
full_text: list[str] = []
|
|
207
|
+
|
|
208
|
+
with pdfplumber.open(str(pdf_path), password=password) as pdf:
|
|
209
|
+
all_rows: list[list[dict]] = []
|
|
210
|
+
for page in pdf.pages:
|
|
211
|
+
full_text.append(page.extract_text() or "")
|
|
212
|
+
all_rows.extend(_cluster_rows(page.extract_words()))
|
|
213
|
+
|
|
214
|
+
# GoTyme wraps the last header as "Running" over "Balance", so the
|
|
215
|
+
# three labels only land in one row because rows are grouped
|
|
216
|
+
# vertically first.
|
|
217
|
+
columns = find_money_columns(all_rows, _MONEY_HEADERS)
|
|
218
|
+
logger.debug("money columns at %s", columns)
|
|
219
|
+
for row in all_rows:
|
|
220
|
+
transaction = _parse_row(row, columns)
|
|
221
|
+
if transaction is not None:
|
|
222
|
+
transactions.append(transaction)
|
|
223
|
+
|
|
224
|
+
if not transactions:
|
|
225
|
+
raise StatementParseError(
|
|
226
|
+
"no transaction rows found; the statement may be empty or its "
|
|
227
|
+
"layout may have changed"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
text = "\n".join(full_text)
|
|
231
|
+
start, end = _parse_period(text)
|
|
232
|
+
summary = _parse_summary(text)
|
|
233
|
+
|
|
234
|
+
logger.info(
|
|
235
|
+
"parsed %d transactions for %s to %s",
|
|
236
|
+
len(transactions),
|
|
237
|
+
start or "?",
|
|
238
|
+
end or "?",
|
|
239
|
+
)
|
|
240
|
+
return Statement(
|
|
241
|
+
period_start=start,
|
|
242
|
+
period_end=end,
|
|
243
|
+
starting_balance=summary["starting_balance"],
|
|
244
|
+
ending_balance=summary["ending_balance"],
|
|
245
|
+
total_debit=summary["total_debit"],
|
|
246
|
+
total_credit=summary["total_credit"],
|
|
247
|
+
transactions=transactions,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
BANK = BankDefinition(
|
|
252
|
+
name="gotyme",
|
|
253
|
+
display_name="GoTyme",
|
|
254
|
+
parser=parse_statement,
|
|
255
|
+
gmail_query='from:no-reply@gotyme.com.ph subject:"Statement of Account" has:attachment',
|
|
256
|
+
# Not amazonses.com, though GoTyme sends through it: SES signs for anyone.
|
|
257
|
+
dkim_domains=("gotyme.com.ph",),
|
|
258
|
+
password_env="GOTYME_PDF_PASSWORD",
|
|
259
|
+
# One email carries the deposit and the savings statements. The config
|
|
260
|
+
# file can pick the other one.
|
|
261
|
+
attachment_patterns=("EverydayDepositAccount",),
|
|
262
|
+
)
|