statement-parser 0.0.14__tar.gz → 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_parser-0.0.14/statement_parser.egg-info → statement_parser-0.1.0}/PKG-INFO +46 -8
- statement_parser-0.1.0/README.md +78 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/pyproject.toml +5 -2
- statement_parser-0.1.0/statement_parser/GenericBank.py +312 -0
- statement_parser-0.1.0/statement_parser/__init__.py +9 -0
- statement_parser-0.1.0/statement_parser/bank_configs.json +198 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0/statement_parser.egg-info}/PKG-INFO +46 -8
- statement_parser-0.1.0/statement_parser.egg-info/SOURCES.txt +13 -0
- statement_parser-0.0.14/README.md +0 -40
- statement_parser-0.0.14/statement_parser/__init__.py +0 -19
- statement_parser-0.0.14/statement_parser/banks/HdfcCredit.py +0 -86
- statement_parser-0.0.14/statement_parser/banks/HsbcCredit.py +0 -68
- statement_parser-0.0.14/statement_parser/banks/HsbcDebit.py +0 -72
- statement_parser-0.0.14/statement_parser/banks/IciciCredit.py +0 -78
- statement_parser-0.0.14/statement_parser/banks/IciciDebit.py +0 -96
- statement_parser-0.0.14/statement_parser/banks/KotakDebit.py +0 -91
- statement_parser-0.0.14/statement_parser/banks/Wallet.py +0 -66
- statement_parser-0.0.14/statement_parser.egg-info/SOURCES.txt +0 -18
- {statement_parser-0.0.14 → statement_parser-0.1.0}/LICENSE +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/setup.cfg +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/statement_parser/Bank.py +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/statement_parser/Transaction.py +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/statement_parser.egg-info/dependency_links.txt +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/statement_parser.egg-info/requires.txt +0 -0
- {statement_parser-0.0.14 → statement_parser-0.1.0}/statement_parser.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statement_parser
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Bank Statement Parser is a Python library designed to parse and normalize transaction data from various bank statement formats ( CSV, Excel, etc.) into a consistent and easy-to-use Pandas DataFrame. It supports multiple banks and file formats, making it a versatile tool for financial data analysis.
|
|
5
5
|
Author-email: Khuzema Challawala <khuzema.ac@gmail.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -34,10 +34,11 @@ Dynamic: license-file
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
36
36
|
- **Multi-Format Support**: Parse bank statements from CSV, Excel, and more.
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
37
|
+
- **Config-Driven**: All parsing behaviour is described by a config dict — no per-bank classes to maintain.
|
|
38
|
+
- **Resilient**: Tolerant of header spacing/punctuation changes and messy number/date formatting.
|
|
39
|
+
- **Consistent Output**: Normalized transaction data with standardized columns (`bank`, `created_date`, `remarks`, `amount`, `hash`).
|
|
39
40
|
- **Easy Integration**: Simple API for quick integration into your Python projects.
|
|
40
|
-
- **Extensible**: Add support for new
|
|
41
|
+
- **Extensible**: Add support for a new bank by writing config, not code.
|
|
41
42
|
|
|
42
43
|
---
|
|
43
44
|
|
|
@@ -51,13 +52,50 @@ pip install statement_parser
|
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
# Usage
|
|
54
|
-
|
|
55
|
+
|
|
56
|
+
### Using a bundled preset
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from statement_parser import GenericBank, list_banks
|
|
60
|
+
|
|
61
|
+
print(list_banks()) # ['HDFC-CREDIT', 'HSBC-CREDIT', ...]
|
|
62
|
+
|
|
63
|
+
parser = GenericBank.from_builtin("HSBC-CREDIT")
|
|
64
|
+
df = parser.getDataFrame("path/to/statement.csv")
|
|
65
|
+
print(df.head())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Bring your own config
|
|
69
|
+
|
|
70
|
+
No subclassing required — define a config dict and hand it to `GenericBank`:
|
|
55
71
|
|
|
56
72
|
```python
|
|
57
|
-
from statement_parser
|
|
73
|
+
from statement_parser import GenericBank
|
|
74
|
+
|
|
75
|
+
config = {
|
|
76
|
+
"file": {
|
|
77
|
+
"delimiter": ",",
|
|
78
|
+
"header": {
|
|
79
|
+
"mode": "detect", # detect | fixed | none
|
|
80
|
+
"match": ["date", "description", "amount"],
|
|
81
|
+
"min_matches": 2,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
"columns": { # logical -> candidate names
|
|
85
|
+
"date": ["Txn Date", "Date"],
|
|
86
|
+
"details": ["Description", "Narration"],
|
|
87
|
+
"amount": ["Amount"],
|
|
88
|
+
},
|
|
89
|
+
"date_field": "date",
|
|
90
|
+
"remarks": [{"field": "details"}],
|
|
91
|
+
"amount": {"mode": "direct", "field": "amount"},
|
|
92
|
+
}
|
|
58
93
|
|
|
59
|
-
parser =
|
|
94
|
+
parser = GenericBank(config, bank_id="MY-BANK")
|
|
60
95
|
df = parser.getDataFrame("path/to/statement.csv")
|
|
61
|
-
# Display the parsed transactions
|
|
62
96
|
print(df.head())
|
|
63
97
|
```
|
|
98
|
+
|
|
99
|
+
**Amount modes**: `direct` (single amount column), `signed` (amount column whose
|
|
100
|
+
sign is decided by a CR/DR column), or `deposit_minus_withdrawal` (separate
|
|
101
|
+
deposit and withdrawal columns).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Bank Statement Parser
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
**Bank Statement Parser** is a Python library designed to parse and normalize transaction data from various bank statement formats ( CSV, Excel, etc.) into a consistent and easy-to-use Pandas DataFrame. It supports multiple banks and file formats, making it a versatile tool for financial data analysis.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Multi-Format Support**: Parse bank statements from CSV, Excel, and more.
|
|
14
|
+
- **Config-Driven**: All parsing behaviour is described by a config dict — no per-bank classes to maintain.
|
|
15
|
+
- **Resilient**: Tolerant of header spacing/punctuation changes and messy number/date formatting.
|
|
16
|
+
- **Consistent Output**: Normalized transaction data with standardized columns (`bank`, `created_date`, `remarks`, `amount`, `hash`).
|
|
17
|
+
- **Easy Integration**: Simple API for quick integration into your Python projects.
|
|
18
|
+
- **Extensible**: Add support for a new bank by writing config, not code.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
You can install the library via pip:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install statement_parser
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Usage
|
|
32
|
+
|
|
33
|
+
### Using a bundled preset
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from statement_parser import GenericBank, list_banks
|
|
37
|
+
|
|
38
|
+
print(list_banks()) # ['HDFC-CREDIT', 'HSBC-CREDIT', ...]
|
|
39
|
+
|
|
40
|
+
parser = GenericBank.from_builtin("HSBC-CREDIT")
|
|
41
|
+
df = parser.getDataFrame("path/to/statement.csv")
|
|
42
|
+
print(df.head())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Bring your own config
|
|
46
|
+
|
|
47
|
+
No subclassing required — define a config dict and hand it to `GenericBank`:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from statement_parser import GenericBank
|
|
51
|
+
|
|
52
|
+
config = {
|
|
53
|
+
"file": {
|
|
54
|
+
"delimiter": ",",
|
|
55
|
+
"header": {
|
|
56
|
+
"mode": "detect", # detect | fixed | none
|
|
57
|
+
"match": ["date", "description", "amount"],
|
|
58
|
+
"min_matches": 2,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
"columns": { # logical -> candidate names
|
|
62
|
+
"date": ["Txn Date", "Date"],
|
|
63
|
+
"details": ["Description", "Narration"],
|
|
64
|
+
"amount": ["Amount"],
|
|
65
|
+
},
|
|
66
|
+
"date_field": "date",
|
|
67
|
+
"remarks": [{"field": "details"}],
|
|
68
|
+
"amount": {"mode": "direct", "field": "amount"},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
parser = GenericBank(config, bank_id="MY-BANK")
|
|
72
|
+
df = parser.getDataFrame("path/to/statement.csv")
|
|
73
|
+
print(df.head())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Amount modes**: `direct` (single amount column), `signed` (amount column whose
|
|
77
|
+
sign is decided by a CR/DR column), or `deposit_minus_withdrawal` (separate
|
|
78
|
+
deposit and withdrawal columns).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "statement_parser"
|
|
7
|
-
version = "0.0
|
|
7
|
+
version = "0.1.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Khuzema Challawala", email="khuzema.ac@gmail.com" },
|
|
10
10
|
]
|
|
@@ -30,4 +30,7 @@ dev = [
|
|
|
30
30
|
"pytest-cov>=6.0.0",
|
|
31
31
|
"flake8>=7.1.2",
|
|
32
32
|
"sphinx"
|
|
33
|
-
]
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
statement_parser = ["bank_configs.json"]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from dateutil import parser as date_parser
|
|
9
|
+
|
|
10
|
+
from statement_parser.Bank import Bank
|
|
11
|
+
from statement_parser.Transaction import Transaction
|
|
12
|
+
|
|
13
|
+
CONFIG_PATH = Path(__file__).parent / "bank_configs.json"
|
|
14
|
+
|
|
15
|
+
_CONFIG_CACHE: dict | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_configs() -> dict:
|
|
19
|
+
"""Load and cache the bank configuration file."""
|
|
20
|
+
global _CONFIG_CACHE
|
|
21
|
+
if _CONFIG_CACHE is None:
|
|
22
|
+
with open(CONFIG_PATH, "r", encoding="utf-8") as handle:
|
|
23
|
+
_CONFIG_CACHE = json.load(handle)
|
|
24
|
+
return _CONFIG_CACHE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_banks() -> list[str]:
|
|
28
|
+
"""Return the list of configured bank ids."""
|
|
29
|
+
return list(load_configs().keys())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize(name) -> str:
|
|
33
|
+
"""Normalize a column name so spacing/punctuation changes don't matter."""
|
|
34
|
+
return re.sub(r"[^a-z0-9]", "", str(name).lower())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GenericBank(Bank):
|
|
38
|
+
"""
|
|
39
|
+
A single, configuration-driven statement parser.
|
|
40
|
+
|
|
41
|
+
Behaviour (delimiters, header location, column names, how the
|
|
42
|
+
amount/remarks are derived) is described entirely by a ``config`` dict, so
|
|
43
|
+
supporting a new bank means writing config - not code.
|
|
44
|
+
|
|
45
|
+
Usage::
|
|
46
|
+
|
|
47
|
+
# Bring your own config
|
|
48
|
+
parser = GenericBank(my_config, bank_id="MY-BANK")
|
|
49
|
+
df = parser.getDataFrame("statement.csv")
|
|
50
|
+
|
|
51
|
+
# Or use one of the bundled presets
|
|
52
|
+
parser = GenericBank.from_builtin("HDFC-CREDIT")
|
|
53
|
+
|
|
54
|
+
The engine is intentionally tolerant:
|
|
55
|
+
* Column names are matched after normalization, so an added/removed
|
|
56
|
+
space or punctuation change in a header does not break parsing.
|
|
57
|
+
* Numbers are cleaned (commas, currency symbols, blanks) before casting.
|
|
58
|
+
* Dates are parsed leniently and rows without a valid date are dropped,
|
|
59
|
+
which also removes header/footer junk automatically.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: dict, bank_id: str | None = None):
|
|
63
|
+
if not isinstance(config, dict):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
"config must be a dict describing how to parse the statement. "
|
|
66
|
+
"Use GenericBank.from_builtin(<id>) for a bundled preset."
|
|
67
|
+
)
|
|
68
|
+
self.config = config
|
|
69
|
+
self.bank_id = bank_id or config.get("bank_id", "UNKNOWN")
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_builtin(cls, bank_id: str) -> "GenericBank":
|
|
73
|
+
"""Create a parser from one of the configs in ``bank_configs.json``."""
|
|
74
|
+
configs = load_configs()
|
|
75
|
+
if bank_id not in configs:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"No built-in configuration for '{bank_id}'. "
|
|
78
|
+
f"Available: {', '.join(configs)}"
|
|
79
|
+
)
|
|
80
|
+
return cls(config=configs[bank_id], bank_id=bank_id)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ #
|
|
83
|
+
# Public API
|
|
84
|
+
# ------------------------------------------------------------------ #
|
|
85
|
+
def getTransactions(self, filename: str) -> list[Transaction]:
|
|
86
|
+
df = self.getData(filename)
|
|
87
|
+
return self._build_transactions(df)
|
|
88
|
+
|
|
89
|
+
def getData(self, filename: str) -> pd.DataFrame:
|
|
90
|
+
df = self._load(filename)
|
|
91
|
+
df.columns = [str(c).strip() for c in df.columns]
|
|
92
|
+
return df
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
# Loading
|
|
96
|
+
# ------------------------------------------------------------------ #
|
|
97
|
+
def _load(self, filename: str) -> pd.DataFrame:
|
|
98
|
+
file_cfg = self.config.get("file", {})
|
|
99
|
+
header_cfg = file_cfg.get("header", {"mode": "fixed", "row": 0})
|
|
100
|
+
mode = header_cfg.get("mode", "fixed")
|
|
101
|
+
delimiter = file_cfg.get("delimiter", ",")
|
|
102
|
+
is_excel = filename.endswith((".xls", ".xlsx"))
|
|
103
|
+
|
|
104
|
+
if mode == "none":
|
|
105
|
+
names = header_cfg.get("names")
|
|
106
|
+
if is_excel:
|
|
107
|
+
df = pd.read_excel(filename, header=None)
|
|
108
|
+
else:
|
|
109
|
+
df = pd.read_csv(
|
|
110
|
+
filename,
|
|
111
|
+
delimiter=delimiter,
|
|
112
|
+
header=None,
|
|
113
|
+
engine="python",
|
|
114
|
+
on_bad_lines="skip",
|
|
115
|
+
)
|
|
116
|
+
if names:
|
|
117
|
+
df = df.iloc[:, : len(names)]
|
|
118
|
+
df.columns = names
|
|
119
|
+
return df
|
|
120
|
+
|
|
121
|
+
if mode == "detect":
|
|
122
|
+
skip_rows = self._find_header_row(
|
|
123
|
+
filename,
|
|
124
|
+
[t.lower() for t in header_cfg.get("match", [])],
|
|
125
|
+
header_cfg.get("min_matches", 1),
|
|
126
|
+
delimiter,
|
|
127
|
+
is_excel,
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
skip_rows = header_cfg.get("row", 0)
|
|
131
|
+
|
|
132
|
+
if is_excel:
|
|
133
|
+
return pd.read_excel(filename, skiprows=skip_rows)
|
|
134
|
+
|
|
135
|
+
return pd.read_csv(
|
|
136
|
+
filename,
|
|
137
|
+
delimiter=delimiter,
|
|
138
|
+
skiprows=skip_rows,
|
|
139
|
+
engine="python",
|
|
140
|
+
on_bad_lines="skip",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _find_header_row(self, filename, tokens, min_matches,
|
|
144
|
+
delimiter, is_excel) -> int:
|
|
145
|
+
if is_excel:
|
|
146
|
+
raw = pd.read_excel(filename, header=None)
|
|
147
|
+
lines = [
|
|
148
|
+
" ".join(str(v) for v in row if pd.notna(v)).lower()
|
|
149
|
+
for row in raw.values.tolist()
|
|
150
|
+
]
|
|
151
|
+
else:
|
|
152
|
+
with open(filename, "r", encoding="utf-8") as handle:
|
|
153
|
+
lines = [line.lower() for line in handle.readlines()]
|
|
154
|
+
|
|
155
|
+
for i, line in enumerate(lines):
|
|
156
|
+
matches = sum(1 for token in tokens if token in line)
|
|
157
|
+
if matches >= min_matches:
|
|
158
|
+
return i
|
|
159
|
+
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"Could not locate header row for '{self.bank_id}'. "
|
|
162
|
+
f"Expected at least {min_matches} of: {tokens}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------ #
|
|
166
|
+
# Column resolution
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
def _resolve_columns(self, df: pd.DataFrame) -> dict:
|
|
169
|
+
"""Map each logical field to an actual dataframe column."""
|
|
170
|
+
normalized = {}
|
|
171
|
+
for actual in df.columns:
|
|
172
|
+
normalized.setdefault(_normalize(actual), actual)
|
|
173
|
+
|
|
174
|
+
resolved = {}
|
|
175
|
+
for logical, candidates in self.config.get("columns", {}).items():
|
|
176
|
+
if isinstance(candidates, str):
|
|
177
|
+
candidates = [candidates]
|
|
178
|
+
found = None
|
|
179
|
+
for candidate in candidates:
|
|
180
|
+
key = _normalize(candidate)
|
|
181
|
+
if key in normalized:
|
|
182
|
+
found = normalized[key]
|
|
183
|
+
break
|
|
184
|
+
if found is None:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"[{self.bank_id}] Could not find a column for "
|
|
187
|
+
f"'{logical}'. Tried {candidates}. "
|
|
188
|
+
f"Available columns: {list(df.columns)}"
|
|
189
|
+
)
|
|
190
|
+
resolved[logical] = found
|
|
191
|
+
return resolved
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
# Value helpers
|
|
195
|
+
# ------------------------------------------------------------------ #
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _to_float(series: pd.Series) -> pd.Series:
|
|
198
|
+
cleaned = (
|
|
199
|
+
series.astype(str)
|
|
200
|
+
.str.replace(",", "", regex=False)
|
|
201
|
+
.str.replace(r"[^0-9.\-]", "", regex=True)
|
|
202
|
+
.str.strip()
|
|
203
|
+
.replace("", "0")
|
|
204
|
+
)
|
|
205
|
+
return pd.to_numeric(cleaned, errors="coerce")
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _to_date(series: pd.Series) -> pd.Series:
|
|
209
|
+
def parse(value):
|
|
210
|
+
text = str(value).strip()
|
|
211
|
+
if not text:
|
|
212
|
+
return pd.NaT
|
|
213
|
+
# A bare number (id/serial column) is not a real date. dateutil
|
|
214
|
+
# would happily turn "1" into a date, so reject these explicitly.
|
|
215
|
+
if re.fullmatch(r"[+-]?\d+(\.\d+)?", text):
|
|
216
|
+
return pd.NaT
|
|
217
|
+
try:
|
|
218
|
+
parsed = date_parser.parse(text, dayfirst=True)
|
|
219
|
+
if parsed.year < 1900:
|
|
220
|
+
return pd.NaT
|
|
221
|
+
return parsed
|
|
222
|
+
except (ValueError, OverflowError, TypeError):
|
|
223
|
+
return pd.NaT
|
|
224
|
+
|
|
225
|
+
return series.apply(parse)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ #
|
|
228
|
+
# Transaction building
|
|
229
|
+
# ------------------------------------------------------------------ #
|
|
230
|
+
def _build_transactions(self, df: pd.DataFrame) -> list[Transaction]:
|
|
231
|
+
cols = self._resolve_columns(df)
|
|
232
|
+
work = pd.DataFrame(index=df.index)
|
|
233
|
+
|
|
234
|
+
# Date (used to drop non-transaction rows automatically)
|
|
235
|
+
date_field = self.config["date_field"]
|
|
236
|
+
work["_date"] = self._to_date(df[cols[date_field]])
|
|
237
|
+
work = work[work["_date"].notna()]
|
|
238
|
+
df = df.loc[work.index]
|
|
239
|
+
|
|
240
|
+
# Amount
|
|
241
|
+
work["_amount"] = self._compute_amount(df, cols).loc[work.index]
|
|
242
|
+
|
|
243
|
+
# Remarks (without the duplicate marker yet)
|
|
244
|
+
work["_remarks"] = self._compute_remarks(df, cols).loc[work.index]
|
|
245
|
+
|
|
246
|
+
# Duplicate sequence marker
|
|
247
|
+
seq = (
|
|
248
|
+
work.groupby(["_date", "_remarks", "_amount"]).cumcount().add(1)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
transactions: list[Transaction] = []
|
|
252
|
+
for idx, row in work.iterrows():
|
|
253
|
+
remarks = row["_remarks"]
|
|
254
|
+
if seq[idx] > 1:
|
|
255
|
+
remarks = remarks + " (" + str(seq[idx]) + ") "
|
|
256
|
+
transactions.append(
|
|
257
|
+
Transaction(
|
|
258
|
+
bank=self.bank_id,
|
|
259
|
+
created_date=row["_date"],
|
|
260
|
+
remarks=remarks,
|
|
261
|
+
amount=row["_amount"],
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return transactions
|
|
265
|
+
|
|
266
|
+
def _compute_amount(self, df: pd.DataFrame, cols: dict) -> pd.Series:
|
|
267
|
+
cfg = self.config["amount"]
|
|
268
|
+
mode = cfg.get("mode", "direct")
|
|
269
|
+
|
|
270
|
+
if mode == "direct":
|
|
271
|
+
return self._to_float(df[cols[cfg["field"]]]).fillna(0)
|
|
272
|
+
|
|
273
|
+
if mode == "deposit_minus_withdrawal":
|
|
274
|
+
deposit = self._to_float(df[cols[cfg["deposit"]]]).fillna(0)
|
|
275
|
+
withdrawal = self._to_float(df[cols[cfg["withdrawal"]]]).fillna(0)
|
|
276
|
+
return deposit - withdrawal
|
|
277
|
+
|
|
278
|
+
if mode == "signed":
|
|
279
|
+
value = self._to_float(df[cols[cfg["field"]]]).fillna(0)
|
|
280
|
+
credit_values = {
|
|
281
|
+
str(v).upper() for v in cfg.get("credit_values", ["CR"])
|
|
282
|
+
}
|
|
283
|
+
credit_sign = cfg.get("credit_sign", 1)
|
|
284
|
+
debit_sign = cfg.get("debit_sign", -1)
|
|
285
|
+
sign_col = df[cols[cfg["sign_field"]]].astype(str)
|
|
286
|
+
sign = sign_col.str.upper().str.strip().map(
|
|
287
|
+
lambda v: credit_sign if v in credit_values else debit_sign
|
|
288
|
+
)
|
|
289
|
+
return value * sign
|
|
290
|
+
|
|
291
|
+
raise ValueError(f"[{self.bank_id}] Unknown amount mode '{mode}'")
|
|
292
|
+
|
|
293
|
+
def _compute_remarks(self, df: pd.DataFrame, cols: dict) -> pd.Series:
|
|
294
|
+
parts_cfg = self.config["remarks"]
|
|
295
|
+
result = pd.Series([""] * len(df), index=df.index)
|
|
296
|
+
|
|
297
|
+
for part in parts_cfg:
|
|
298
|
+
field = part["field"]
|
|
299
|
+
prefix = part.get("prefix", "")
|
|
300
|
+
suffix = part.get("suffix", "")
|
|
301
|
+
skip = {str(s).lower() for s in part.get("skip", [])}
|
|
302
|
+
column = df[cols[field]]
|
|
303
|
+
|
|
304
|
+
def render(value):
|
|
305
|
+
text = "" if pd.isna(value) else str(value).strip()
|
|
306
|
+
if text.lower() in skip:
|
|
307
|
+
return ""
|
|
308
|
+
return prefix + text + suffix
|
|
309
|
+
|
|
310
|
+
result = result + column.map(render)
|
|
311
|
+
|
|
312
|
+
return result.str.strip()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
{
|
|
2
|
+
"HDFC-CREDIT": {
|
|
3
|
+
"file": {
|
|
4
|
+
"delimiter": "~",
|
|
5
|
+
"header": {
|
|
6
|
+
"mode": "detect",
|
|
7
|
+
"match": ["transaction type", "description", "debit / credit"],
|
|
8
|
+
"min_matches": 2
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"columns": {
|
|
12
|
+
"date": ["DATE"],
|
|
13
|
+
"description": ["Description"],
|
|
14
|
+
"amount": ["AMT"],
|
|
15
|
+
"sign": ["Debit / Credit"]
|
|
16
|
+
},
|
|
17
|
+
"date_field": "date",
|
|
18
|
+
"remarks": [
|
|
19
|
+
{"field": "description"}
|
|
20
|
+
],
|
|
21
|
+
"amount": {
|
|
22
|
+
"mode": "signed",
|
|
23
|
+
"field": "amount",
|
|
24
|
+
"sign_field": "sign",
|
|
25
|
+
"credit_values": ["CR"]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
"HSBC-CREDIT": {
|
|
30
|
+
"file": {
|
|
31
|
+
"delimiter": ",",
|
|
32
|
+
"header": {
|
|
33
|
+
"mode": "none",
|
|
34
|
+
"names": ["Date", "Transaction Details", "Amount"]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"columns": {
|
|
38
|
+
"date": ["Date"],
|
|
39
|
+
"details": ["Transaction Details"],
|
|
40
|
+
"amount": ["Amount"]
|
|
41
|
+
},
|
|
42
|
+
"date_field": "date",
|
|
43
|
+
"remarks": [
|
|
44
|
+
{"field": "details"}
|
|
45
|
+
],
|
|
46
|
+
"amount": {
|
|
47
|
+
"mode": "direct",
|
|
48
|
+
"field": "amount"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
"HSBC-DEBIT": {
|
|
53
|
+
"file": {
|
|
54
|
+
"header": {
|
|
55
|
+
"mode": "detect",
|
|
56
|
+
"match": ["date", "transaction details", "deposits", "withdrawals"],
|
|
57
|
+
"min_matches": 3
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"columns": {
|
|
61
|
+
"date": ["Date"],
|
|
62
|
+
"details": ["Transaction Details"],
|
|
63
|
+
"deposit": ["Deposits"],
|
|
64
|
+
"withdrawal": ["Withdrawals"]
|
|
65
|
+
},
|
|
66
|
+
"date_field": "date",
|
|
67
|
+
"remarks": [
|
|
68
|
+
{"field": "details"}
|
|
69
|
+
],
|
|
70
|
+
"amount": {
|
|
71
|
+
"mode": "deposit_minus_withdrawal",
|
|
72
|
+
"deposit": "deposit",
|
|
73
|
+
"withdrawal": "withdrawal"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
"ICICI-CREDIT": {
|
|
78
|
+
"file": {
|
|
79
|
+
"delimiter": ",",
|
|
80
|
+
"header": {
|
|
81
|
+
"mode": "detect",
|
|
82
|
+
"match": ["date", "sr.no", "transaction details", "amount(in rs)"],
|
|
83
|
+
"min_matches": 3
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"columns": {
|
|
87
|
+
"date": ["Date"],
|
|
88
|
+
"details": ["Transaction Details"],
|
|
89
|
+
"amount": ["Amount(in Rs)"],
|
|
90
|
+
"sign": ["BillingAmountSign"]
|
|
91
|
+
},
|
|
92
|
+
"date_field": "date",
|
|
93
|
+
"remarks": [
|
|
94
|
+
{"field": "details"}
|
|
95
|
+
],
|
|
96
|
+
"amount": {
|
|
97
|
+
"mode": "signed",
|
|
98
|
+
"field": "amount",
|
|
99
|
+
"sign_field": "sign",
|
|
100
|
+
"credit_values": ["CR"]
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
"ICICI-DEBIT": {
|
|
105
|
+
"file": {
|
|
106
|
+
"header": {
|
|
107
|
+
"mode": "detect",
|
|
108
|
+
"match": [
|
|
109
|
+
"transaction date",
|
|
110
|
+
"cheque number",
|
|
111
|
+
"transaction remarks",
|
|
112
|
+
"withdrawal amount",
|
|
113
|
+
"deposit amount"
|
|
114
|
+
],
|
|
115
|
+
"min_matches": 3
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"columns": {
|
|
119
|
+
"date": ["Transaction Date"],
|
|
120
|
+
"cheque": ["Cheque Number"],
|
|
121
|
+
"remarks": ["Transaction Remarks"],
|
|
122
|
+
"withdrawal": ["Withdrawal Amount (INR )"],
|
|
123
|
+
"deposit": ["Deposit Amount (INR )"]
|
|
124
|
+
},
|
|
125
|
+
"date_field": "date",
|
|
126
|
+
"remarks": [
|
|
127
|
+
{"field": "cheque", "prefix": "CHQ: ", "suffix": " ",
|
|
128
|
+
"skip": ["-", "", "nan"]},
|
|
129
|
+
{"field": "remarks"}
|
|
130
|
+
],
|
|
131
|
+
"amount": {
|
|
132
|
+
"mode": "deposit_minus_withdrawal",
|
|
133
|
+
"deposit": "deposit",
|
|
134
|
+
"withdrawal": "withdrawal"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
"KOTAK-DEBIT": {
|
|
139
|
+
"file": {
|
|
140
|
+
"delimiter": ",",
|
|
141
|
+
"header": {
|
|
142
|
+
"mode": "detect",
|
|
143
|
+
"match": [
|
|
144
|
+
"sl. no.",
|
|
145
|
+
"transaction date",
|
|
146
|
+
"description",
|
|
147
|
+
"amount",
|
|
148
|
+
"dr / cr"
|
|
149
|
+
],
|
|
150
|
+
"min_matches": 3
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"columns": {
|
|
154
|
+
"date": ["Transaction Date"],
|
|
155
|
+
"description": ["Description"],
|
|
156
|
+
"ref": ["Chq / Ref No."],
|
|
157
|
+
"amount": ["Amount"],
|
|
158
|
+
"sign": ["Dr / Cr"]
|
|
159
|
+
},
|
|
160
|
+
"date_field": "date",
|
|
161
|
+
"remarks": [
|
|
162
|
+
{"field": "ref", "prefix": "Ref: ", "suffix": " ",
|
|
163
|
+
"skip": ["nan", "", "-"]},
|
|
164
|
+
{"field": "description"}
|
|
165
|
+
],
|
|
166
|
+
"amount": {
|
|
167
|
+
"mode": "signed",
|
|
168
|
+
"field": "amount",
|
|
169
|
+
"sign_field": "sign",
|
|
170
|
+
"credit_values": ["CR"]
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
"WALLET": {
|
|
175
|
+
"file": {
|
|
176
|
+
"header": {
|
|
177
|
+
"mode": "detect",
|
|
178
|
+
"match": ["date", "note", "category", "amount"],
|
|
179
|
+
"min_matches": 3
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"columns": {
|
|
183
|
+
"date": ["date"],
|
|
184
|
+
"note": ["note"],
|
|
185
|
+
"category": ["category"],
|
|
186
|
+
"amount": ["amount"]
|
|
187
|
+
},
|
|
188
|
+
"date_field": "date",
|
|
189
|
+
"remarks": [
|
|
190
|
+
{"field": "category", "suffix": ": ", "skip": [""]},
|
|
191
|
+
{"field": "note"}
|
|
192
|
+
],
|
|
193
|
+
"amount": {
|
|
194
|
+
"mode": "direct",
|
|
195
|
+
"field": "amount"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|