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.
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ )