|
@@ -1,6 +1,10 @@
|
1
|
1
|
# coding=utf-8
|
2
|
2
|
"""Convert Bank statement CSV to Gnucash-acceptable format"""
|
3
|
3
|
|
|
4
|
+from collections import namedtuple
|
|
5
|
+
|
|
6
|
+ParserState = namedtuple('ParserState', 'ths nxt')
|
|
7
|
+
|
4
|
8
|
|
5
|
9
|
def valid_formats():
|
6
|
10
|
return _parsers.keys()
|
|
@@ -53,14 +57,13 @@ class BaseTransactionParser(object):
|
53
|
57
|
|
54
|
58
|
def __init__(self):
|
55
|
59
|
self.transactions = []
|
56
|
|
- self.state = False
|
57
|
|
- self.next_state = False
|
|
60
|
+ self.state = ParserState(ths=False, nxt=False)
|
58
|
61
|
|
59
|
62
|
def is_open(self):
|
60
|
|
- return self.state
|
|
63
|
+ return self.state.ths
|
61
|
64
|
|
62
|
65
|
def is_closed(self):
|
63
|
|
- return not self.state
|
|
66
|
+ return not self.state.ths
|
64
|
67
|
|
65
|
68
|
def parse_file(self, fpath, encoding='utf-8'):
|
66
|
69
|
lines_read = 0
|
|
@@ -68,7 +71,7 @@ class BaseTransactionParser(object):
|
68
|
71
|
for line in f.readlines():
|
69
|
72
|
line = line.decode(encoding).strip()
|
70
|
73
|
lines_read += 1
|
71
|
|
- self.state = self.pick_state(line, lineno=lines_read)
|
|
74
|
+ self.state = self.advance(line, lineno=lines_read)
|
72
|
75
|
if self.is_open():
|
73
|
76
|
self.transactions.append(self.parse_line(line).to_gc())
|
74
|
77
|
|
|
@@ -82,32 +85,33 @@ class BaseTransactionParser(object):
|
82
|
85
|
class MbankTransaction(BaseTransaction):
|
83
|
86
|
"""Item in an Mbank CSV"""
|
84
|
87
|
|
85
|
|
- # Datum uskutečnění transakce
|
86
|
|
- # Datum zaúčtování transakce
|
87
|
|
- # Popis transakce
|
88
|
|
- # Zpráva pro příjemce
|
89
|
|
- # Plátce/Příjemce
|
90
|
|
- # Číslo účtu plátce/příjemce
|
91
|
|
- # KS
|
92
|
|
- # VS
|
93
|
|
- # SS
|
94
|
|
- # Částka transakce
|
95
|
|
- # Účetní zůstatek po transakci
|
|
88
|
+ # (a) Datum uskutečnění transakce
|
|
89
|
+ # (b) Datum zaúčtování transakce
|
|
90
|
+ # (c) Popis transakce
|
|
91
|
+ # (d) Zpráva pro příjemce
|
|
92
|
+ # (e) Plátce/Příjemce
|
|
93
|
+ # (f) Číslo účtu plátce/příjemce
|
|
94
|
+ # (g) KS
|
|
95
|
+ # (h) VS
|
|
96
|
+ # (i) SS
|
|
97
|
+ # (j) Částka transakce
|
|
98
|
+ # (k) Účetní zůstatek po transakci
|
|
99
|
+ # (l) (empty column at the end)
|
96
|
100
|
|
97
|
101
|
def __init__(self, line):
|
98
|
102
|
fields = [self._cleanup_field(f) for f in line.split(";")]
|
99
|
|
- self.date_r = self._convert_date(fields.pop(0))
|
100
|
|
- self.date_b = self._convert_date(fields.pop(0))
|
101
|
|
- __ = fields.pop()
|
102
|
|
- self.new_balance = self._parse_currency(fields.pop())
|
103
|
|
- amount = self._parse_currency(fields.pop())
|
|
103
|
+ self.date_r = self._convert_date(fields.pop(0)) # (a)
|
|
104
|
+ self.date_b = self._convert_date(fields.pop(0)) # (b)
|
|
105
|
+ __ = fields.pop() # (l)
|
|
106
|
+ self.new_balance = self._parse_currency(fields.pop()) # (k)
|
|
107
|
+ amount = self._parse_currency(fields.pop()) # (j)
|
104
|
108
|
if amount >= 0:
|
105
|
109
|
self.amountd = amount
|
106
|
110
|
self.amountw = 0
|
107
|
111
|
else:
|
108
|
112
|
self.amountd = 0
|
109
|
113
|
self.amountw = -amount
|
110
|
|
- self._scrap = fields
|
|
114
|
+ self._scrap = fields # (c-i)
|
111
|
115
|
|
112
|
116
|
def _cleanup_field(self, field):
|
113
|
117
|
x = ' '.join(_unquote(field).strip().split())
|
|
@@ -118,12 +122,36 @@ class MbankTransaction(BaseTransaction):
|
118
|
122
|
return '-'.join([year, month, day])
|
119
|
123
|
|
120
|
124
|
def _description(self):
|
|
125
|
+ def type_interesting(T):
|
|
126
|
+ boring_types = [
|
|
127
|
+ u'PŘÍCHOZÍ PLATBA Z MBANK',
|
|
128
|
+ u'ODCHOZÍ PLATBA DO MBANK',
|
|
129
|
+ u'PŘÍCHOZÍ PLATBA Z JINÉ BANKY',
|
|
130
|
+ u'ODCHOZÍ PLATBA DO JINÉ BANKY',
|
|
131
|
+ u'PLATBA KARTOU',
|
|
132
|
+ ]
|
|
133
|
+ if not T:
|
|
134
|
+ return False
|
|
135
|
+ if T in boring_types:
|
|
136
|
+ return False
|
|
137
|
+ return True
|
|
138
|
+ def message_interesting(M):
|
|
139
|
+ boring_messages = [
|
|
140
|
+ u'PŘEVOD PROSTŘEDKŮ',
|
|
141
|
+ ]
|
|
142
|
+ if not M:
|
|
143
|
+ return False
|
|
144
|
+ if M in boring_messages:
|
|
145
|
+ return False
|
|
146
|
+ return True
|
121
|
147
|
type_, message, party, number, ks, vs, ss = self._scrap
|
122
|
|
- message = message.replace(u' DATUM PROVEDENÍ TRANSAKCE: ', '; DATUM: ')
|
|
148
|
+ dpt = u' DATUM PROVEDENÍ TRANSAKCE: '
|
|
149
|
+ if dpt in message:
|
|
150
|
+ message, __ = message.split(dpt)
|
123
|
151
|
out = []
|
124
|
|
- if type_:
|
|
152
|
+ if type_interesting(type_):
|
125
|
153
|
out.append(type_)
|
126
|
|
- if message:
|
|
154
|
+ if message_interesting(message):
|
127
|
155
|
out.append(message)
|
128
|
156
|
if party:
|
129
|
157
|
out.append(party)
|
|
@@ -150,16 +178,14 @@ class MbankTransactionParser(BaseTransactionParser):
|
150
|
178
|
def parse_line(self, line):
|
151
|
179
|
return MbankTransaction(line)
|
152
|
180
|
|
153
|
|
- def pick_state(self, line, lineno=None):
|
|
181
|
+ def advance(self, line, lineno=None):
|
154
|
182
|
"""Choose parser state according to current line and line no."""
|
155
|
|
- if self.next_state:
|
156
|
|
- self.next_state = False
|
157
|
|
- return True
|
|
183
|
+ if self.state.nxt:
|
|
184
|
+ return ParserState(ths=True, nxt=False)
|
158
|
185
|
if self.is_closed() and line.startswith(u'#Datum uskute'):
|
159
|
|
- self.next_state = True
|
160
|
|
- return False
|
|
186
|
+ return ParserState(ths=False, nxt=True)
|
161
|
187
|
if self.is_open() and not line:
|
162
|
|
- return False
|
|
188
|
+ return ParserState(ths=False, nxt=False)
|
163
|
189
|
return self.state
|
164
|
190
|
|
165
|
191
|
|
|
@@ -249,18 +275,121 @@ class FioTransactionParser(BaseTransactionParser):
|
249
|
275
|
def parse_line(self, line):
|
250
|
276
|
return FioTransaction(line)
|
251
|
277
|
|
252
|
|
- def pick_state(self, line, lineno=None):
|
|
278
|
+ def advance(self, line, lineno=None):
|
253
|
279
|
"""Choose parser state according to current line and line no."""
|
254
|
|
- if self.next_state:
|
255
|
|
- self.next_state = False
|
256
|
|
- return True
|
257
|
|
- if self.is_closed() and line.startswith(u'"ID operace";"Datum";'):
|
258
|
|
- self.next_state = True
|
259
|
|
- return False
|
|
280
|
+ if self.state.nxt:
|
|
281
|
+ return ParserState(ths=True, nxt=False)
|
|
282
|
+ if self.is_open() and line.startswith(u'"ID operace";"Datum";'):
|
|
283
|
+ return ParserState(ths=False, nxt=True)
|
|
284
|
+ return self.state
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+class RaifTransaction(BaseTransaction):
|
|
288
|
+
|
|
289
|
+ # (a) Transaction Date
|
|
290
|
+ # (b) Booking Date
|
|
291
|
+ # (c) Account number
|
|
292
|
+ # (d) Account Name
|
|
293
|
+ # (e) Transaction Category
|
|
294
|
+ # (f) Accocunt Number [SIC]
|
|
295
|
+ # (g) Name of Account
|
|
296
|
+ # (h) Transaction type
|
|
297
|
+ # (i) Message
|
|
298
|
+ # (j) Note
|
|
299
|
+ # (k) VS
|
|
300
|
+ # (l) KS
|
|
301
|
+ # (m) SS
|
|
302
|
+ # (n) Booked amount
|
|
303
|
+ # (o) Account Currency
|
|
304
|
+ # (p) Original Amount and Currency
|
|
305
|
+ # (q) Original Amount and Currency
|
|
306
|
+ # (r) Fee
|
|
307
|
+ # (s) Transaction ID
|
|
308
|
+
|
|
309
|
+ def __init__(self, line):
|
|
310
|
+ self._fields = [self._cleanup_field(f) for f in line.split(";")]
|
|
311
|
+ self.date = self._convert_date(self._fields[0])
|
|
312
|
+ amount = self._parse_currency(self._fields[13])
|
|
313
|
+ if amount >= 0:
|
|
314
|
+ self.amountd = amount
|
|
315
|
+ self.amountw = 0
|
|
316
|
+ else:
|
|
317
|
+ self.amountd = 0
|
|
318
|
+ self.amountw = -amount
|
|
319
|
+ self.currency = self._fields[14]
|
|
320
|
+ assert self.currency == 'CZK'
|
|
321
|
+
|
|
322
|
+ def _cleanup_field(self, field):
|
|
323
|
+ x = ' '.join(_unquote(field).strip().split())
|
|
324
|
+ return '' if x == '-' else x
|
|
325
|
+
|
|
326
|
+ def _description(self):
|
|
327
|
+ (
|
|
328
|
+ transaction_date,
|
|
329
|
+ booking_date,
|
|
330
|
+ src_account_number,
|
|
331
|
+ src_account_name,
|
|
332
|
+ transaction_category,
|
|
333
|
+ dst_account_number,
|
|
334
|
+ dst_account_name,
|
|
335
|
+ dst_ransaction_type,
|
|
336
|
+ message,
|
|
337
|
+ note,
|
|
338
|
+ vs,
|
|
339
|
+ ks,
|
|
340
|
+ ss,
|
|
341
|
+ booked_amount,
|
|
342
|
+ account_currency,
|
|
343
|
+ original_amount_and_currency,
|
|
344
|
+ original_amount_and_currency,
|
|
345
|
+ fee,
|
|
346
|
+ transaction_id,
|
|
347
|
+ ) = self._fields
|
|
348
|
+ out = []
|
|
349
|
+ if message:
|
|
350
|
+ out.append(message)
|
|
351
|
+ if note and note != message:
|
|
352
|
+ out.append(note)
|
|
353
|
+ if dst_account_name:
|
|
354
|
+ out.append(dst_account_name)
|
|
355
|
+ else:
|
|
356
|
+ out.append(dst_account_number)
|
|
357
|
+ out.append("VS:%s" % vs)
|
|
358
|
+ return " / ".join(out)
|
|
359
|
+
|
|
360
|
+ def _convert_date(self, text):
|
|
361
|
+ day, month, year = text.split('.')
|
|
362
|
+ return '-'.join([year, month, day])
|
|
363
|
+
|
|
364
|
+ def _parse_currency(self, text):
|
|
365
|
+ return float(text.replace(',', '.').replace(' ', ''))
|
|
366
|
+
|
|
367
|
+ def to_gc(self):
|
|
368
|
+ """Convert to GCTransaction"""
|
|
369
|
+ return GCTransaction(
|
|
370
|
+ date=self.date,
|
|
371
|
+ deposit=self.amountd,
|
|
372
|
+ withdrawal=self.amountw,
|
|
373
|
+ description=self._description()
|
|
374
|
+ )
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+class RaifTransactionParser(BaseTransactionParser):
|
|
378
|
+
|
|
379
|
+ def parse_line(self, line):
|
|
380
|
+ return RaifTransaction(line)
|
|
381
|
+
|
|
382
|
+ def advance(self, line, lineno=None):
|
|
383
|
+ """Choose parser state according to current line and line no."""
|
|
384
|
+ if self.state.nxt:
|
|
385
|
+ return ParserState(ths=True, nxt=False)
|
|
386
|
+ if self.is_closed() and line.startswith(u'Transaction Date;Booking'):
|
|
387
|
+ return ParserState(ths=False, nxt=True)
|
260
|
388
|
return self.state
|
261
|
389
|
|
262
|
390
|
|
263
|
391
|
_parsers = {
|
264
|
392
|
'CzMbank': MbankTransactionParser,
|
265
|
393
|
'CzFio': FioTransactionParser,
|
|
394
|
+ 'CzRaif': RaifTransactionParser,
|
266
|
395
|
}
|