nanopy-bank 1.0.8__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.
Files changed (47) hide show
  1. nanopy_bank/__init__.py +20 -0
  2. nanopy_bank/api/__init__.py +10 -0
  3. nanopy_bank/api/server.py +242 -0
  4. nanopy_bank/app.py +282 -0
  5. nanopy_bank/cli.py +152 -0
  6. nanopy_bank/core/__init__.py +85 -0
  7. nanopy_bank/core/audit.py +404 -0
  8. nanopy_bank/core/auth.py +306 -0
  9. nanopy_bank/core/bank.py +407 -0
  10. nanopy_bank/core/beneficiary.py +258 -0
  11. nanopy_bank/core/branch.py +319 -0
  12. nanopy_bank/core/fees.py +243 -0
  13. nanopy_bank/core/holding.py +416 -0
  14. nanopy_bank/core/models.py +308 -0
  15. nanopy_bank/core/products.py +300 -0
  16. nanopy_bank/data/__init__.py +31 -0
  17. nanopy_bank/data/demo.py +846 -0
  18. nanopy_bank/documents/__init__.py +11 -0
  19. nanopy_bank/documents/statement.py +304 -0
  20. nanopy_bank/sepa/__init__.py +10 -0
  21. nanopy_bank/sepa/sepa.py +452 -0
  22. nanopy_bank/storage/__init__.py +11 -0
  23. nanopy_bank/storage/json_storage.py +127 -0
  24. nanopy_bank/storage/sqlite_storage.py +326 -0
  25. nanopy_bank/ui/__init__.py +14 -0
  26. nanopy_bank/ui/pages/__init__.py +33 -0
  27. nanopy_bank/ui/pages/accounts.py +85 -0
  28. nanopy_bank/ui/pages/advisor.py +140 -0
  29. nanopy_bank/ui/pages/audit.py +73 -0
  30. nanopy_bank/ui/pages/beneficiaries.py +115 -0
  31. nanopy_bank/ui/pages/branches.py +64 -0
  32. nanopy_bank/ui/pages/cards.py +36 -0
  33. nanopy_bank/ui/pages/common.py +18 -0
  34. nanopy_bank/ui/pages/dashboard.py +100 -0
  35. nanopy_bank/ui/pages/fees.py +60 -0
  36. nanopy_bank/ui/pages/holding.py +943 -0
  37. nanopy_bank/ui/pages/loans.py +105 -0
  38. nanopy_bank/ui/pages/login.py +174 -0
  39. nanopy_bank/ui/pages/sepa.py +118 -0
  40. nanopy_bank/ui/pages/settings.py +48 -0
  41. nanopy_bank/ui/pages/transfers.py +94 -0
  42. nanopy_bank/ui/pages.py +16 -0
  43. nanopy_bank-1.0.8.dist-info/METADATA +72 -0
  44. nanopy_bank-1.0.8.dist-info/RECORD +47 -0
  45. nanopy_bank-1.0.8.dist-info/WHEEL +5 -0
  46. nanopy_bank-1.0.8.dist-info/entry_points.txt +2 -0
  47. nanopy_bank-1.0.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,452 @@
1
+ """
2
+ SEPA XML Generator and Parser - ISO 20022 pain.001 (Credit Transfer) and pain.008 (Direct Debit)
3
+ """
4
+
5
+ from datetime import datetime
6
+ from decimal import Decimal
7
+ from typing import List, Optional
8
+ from lxml import etree
9
+ import uuid
10
+
11
+ from ..core.models import Transaction, TransactionType
12
+
13
+
14
+ class SEPAGenerator:
15
+ """
16
+ Generate SEPA XML files (ISO 20022)
17
+
18
+ Supported formats:
19
+ - pain.001.001.03: Credit Transfer Initiation (SCT)
20
+ - pain.008.001.02: Direct Debit Initiation (SDD)
21
+ - camt.053.001.02: Bank to Customer Statement
22
+ """
23
+
24
+ # Namespaces
25
+ NS_PAIN001 = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"
26
+ NS_PAIN008 = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02"
27
+ NS_CAMT053 = "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"
28
+
29
+ def __init__(
30
+ self,
31
+ initiator_name: str = "NanoPy Bank",
32
+ initiator_iban: str = "",
33
+ initiator_bic: str = "NANPFRPP"
34
+ ):
35
+ self.initiator_name = initiator_name
36
+ self.initiator_iban = initiator_iban
37
+ self.initiator_bic = initiator_bic
38
+
39
+ def generate_credit_transfer(
40
+ self,
41
+ transactions: List[dict],
42
+ execution_date: Optional[datetime] = None
43
+ ) -> str:
44
+ """
45
+ Generate SEPA Credit Transfer XML (pain.001.001.03)
46
+
47
+ Each transaction dict should contain:
48
+ - amount: Decimal
49
+ - currency: str (default EUR)
50
+ - creditor_name: str
51
+ - creditor_iban: str
52
+ - creditor_bic: str (optional)
53
+ - remittance_info: str (label/description)
54
+ - end_to_end_id: str (optional)
55
+ """
56
+ if not execution_date:
57
+ execution_date = datetime.now()
58
+
59
+ # Create root element
60
+ nsmap = {None: self.NS_PAIN001}
61
+ root = etree.Element("Document", nsmap=nsmap)
62
+ cstmr_cdt_trf_initn = etree.SubElement(root, "CstmrCdtTrfInitn")
63
+
64
+ # Group Header
65
+ grp_hdr = etree.SubElement(cstmr_cdt_trf_initn, "GrpHdr")
66
+ msg_id = f"NANOPY-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
67
+ etree.SubElement(grp_hdr, "MsgId").text = msg_id
68
+ etree.SubElement(grp_hdr, "CreDtTm").text = datetime.now().isoformat()
69
+ etree.SubElement(grp_hdr, "NbOfTxs").text = str(len(transactions))
70
+
71
+ # Calculate total
72
+ total = sum(Decimal(str(tx.get("amount", 0))) for tx in transactions)
73
+ etree.SubElement(grp_hdr, "CtrlSum").text = f"{total:.2f}"
74
+
75
+ # Initiating Party
76
+ initg_pty = etree.SubElement(grp_hdr, "InitgPty")
77
+ etree.SubElement(initg_pty, "Nm").text = self.initiator_name
78
+
79
+ # Payment Information
80
+ pmt_inf = etree.SubElement(cstmr_cdt_trf_initn, "PmtInf")
81
+ etree.SubElement(pmt_inf, "PmtInfId").text = f"PMT-{msg_id}"
82
+ etree.SubElement(pmt_inf, "PmtMtd").text = "TRF" # Transfer
83
+ etree.SubElement(pmt_inf, "NbOfTxs").text = str(len(transactions))
84
+ etree.SubElement(pmt_inf, "CtrlSum").text = f"{total:.2f}"
85
+
86
+ # Payment Type Information
87
+ pmt_tp_inf = etree.SubElement(pmt_inf, "PmtTpInf")
88
+ svc_lvl = etree.SubElement(pmt_tp_inf, "SvcLvl")
89
+ etree.SubElement(svc_lvl, "Cd").text = "SEPA"
90
+
91
+ # Requested Execution Date
92
+ etree.SubElement(pmt_inf, "ReqdExctnDt").text = execution_date.strftime("%Y-%m-%d")
93
+
94
+ # Debtor (payer)
95
+ dbtr = etree.SubElement(pmt_inf, "Dbtr")
96
+ etree.SubElement(dbtr, "Nm").text = self.initiator_name
97
+
98
+ dbtr_acct = etree.SubElement(pmt_inf, "DbtrAcct")
99
+ dbtr_acct_id = etree.SubElement(dbtr_acct, "Id")
100
+ etree.SubElement(dbtr_acct_id, "IBAN").text = self.initiator_iban.replace(" ", "")
101
+
102
+ dbtr_agt = etree.SubElement(pmt_inf, "DbtrAgt")
103
+ dbtr_agt_fin_instn_id = etree.SubElement(dbtr_agt, "FinInstnId")
104
+ etree.SubElement(dbtr_agt_fin_instn_id, "BIC").text = self.initiator_bic
105
+
106
+ # Credit Transfer Transaction Information (for each transaction)
107
+ for tx in transactions:
108
+ cdt_trf_tx_inf = etree.SubElement(pmt_inf, "CdtTrfTxInf")
109
+
110
+ # Payment ID
111
+ pmt_id = etree.SubElement(cdt_trf_tx_inf, "PmtId")
112
+ end_to_end_id = tx.get("end_to_end_id", f"E2E-{uuid.uuid4().hex[:12].upper()}")
113
+ etree.SubElement(pmt_id, "EndToEndId").text = end_to_end_id
114
+
115
+ # Amount
116
+ amt = etree.SubElement(cdt_trf_tx_inf, "Amt")
117
+ instd_amt = etree.SubElement(amt, "InstdAmt", Ccy=tx.get("currency", "EUR"))
118
+ instd_amt.text = f"{Decimal(str(tx['amount'])):.2f}"
119
+
120
+ # Creditor Agent (beneficiary bank)
121
+ if tx.get("creditor_bic"):
122
+ cdtr_agt = etree.SubElement(cdt_trf_tx_inf, "CdtrAgt")
123
+ cdtr_agt_fin_instn_id = etree.SubElement(cdtr_agt, "FinInstnId")
124
+ etree.SubElement(cdtr_agt_fin_instn_id, "BIC").text = tx["creditor_bic"]
125
+
126
+ # Creditor (beneficiary)
127
+ cdtr = etree.SubElement(cdt_trf_tx_inf, "Cdtr")
128
+ etree.SubElement(cdtr, "Nm").text = tx["creditor_name"]
129
+
130
+ cdtr_acct = etree.SubElement(cdt_trf_tx_inf, "CdtrAcct")
131
+ cdtr_acct_id = etree.SubElement(cdtr_acct, "Id")
132
+ etree.SubElement(cdtr_acct_id, "IBAN").text = tx["creditor_iban"].replace(" ", "")
133
+
134
+ # Remittance Information
135
+ rmt_inf = etree.SubElement(cdt_trf_tx_inf, "RmtInf")
136
+ etree.SubElement(rmt_inf, "Ustrd").text = tx.get("remittance_info", "")[:140]
137
+
138
+ return etree.tostring(root, pretty_print=True, xml_declaration=True, encoding="UTF-8").decode()
139
+
140
+ def generate_direct_debit(
141
+ self,
142
+ transactions: List[dict],
143
+ collection_date: Optional[datetime] = None,
144
+ sequence_type: str = "OOFF" # OOFF=One-off, FRST=First, RCUR=Recurring, FNAL=Final
145
+ ) -> str:
146
+ """
147
+ Generate SEPA Direct Debit XML (pain.008.001.02)
148
+
149
+ Each transaction dict should contain:
150
+ - amount: Decimal
151
+ - debtor_name: str
152
+ - debtor_iban: str
153
+ - debtor_bic: str (optional)
154
+ - mandate_id: str
155
+ - mandate_date: str (YYYY-MM-DD)
156
+ - remittance_info: str
157
+ - end_to_end_id: str (optional)
158
+ """
159
+ if not collection_date:
160
+ collection_date = datetime.now()
161
+
162
+ nsmap = {None: self.NS_PAIN008}
163
+ root = etree.Element("Document", nsmap=nsmap)
164
+ cstmr_drct_dbt_initn = etree.SubElement(root, "CstmrDrctDbtInitn")
165
+
166
+ # Group Header
167
+ grp_hdr = etree.SubElement(cstmr_drct_dbt_initn, "GrpHdr")
168
+ msg_id = f"NANOPY-DD-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
169
+ etree.SubElement(grp_hdr, "MsgId").text = msg_id
170
+ etree.SubElement(grp_hdr, "CreDtTm").text = datetime.now().isoformat()
171
+ etree.SubElement(grp_hdr, "NbOfTxs").text = str(len(transactions))
172
+
173
+ total = sum(Decimal(str(tx.get("amount", 0))) for tx in transactions)
174
+ etree.SubElement(grp_hdr, "CtrlSum").text = f"{total:.2f}"
175
+
176
+ initg_pty = etree.SubElement(grp_hdr, "InitgPty")
177
+ etree.SubElement(initg_pty, "Nm").text = self.initiator_name
178
+
179
+ # Payment Information
180
+ pmt_inf = etree.SubElement(cstmr_drct_dbt_initn, "PmtInf")
181
+ etree.SubElement(pmt_inf, "PmtInfId").text = f"PMT-{msg_id}"
182
+ etree.SubElement(pmt_inf, "PmtMtd").text = "DD" # Direct Debit
183
+ etree.SubElement(pmt_inf, "NbOfTxs").text = str(len(transactions))
184
+ etree.SubElement(pmt_inf, "CtrlSum").text = f"{total:.2f}"
185
+
186
+ # Payment Type Information
187
+ pmt_tp_inf = etree.SubElement(pmt_inf, "PmtTpInf")
188
+ svc_lvl = etree.SubElement(pmt_tp_inf, "SvcLvl")
189
+ etree.SubElement(svc_lvl, "Cd").text = "SEPA"
190
+ lcl_instrm = etree.SubElement(pmt_tp_inf, "LclInstrm")
191
+ etree.SubElement(lcl_instrm, "Cd").text = "CORE" # CORE or B2B
192
+ etree.SubElement(pmt_tp_inf, "SeqTp").text = sequence_type
193
+
194
+ # Requested Collection Date
195
+ etree.SubElement(pmt_inf, "ReqdColltnDt").text = collection_date.strftime("%Y-%m-%d")
196
+
197
+ # Creditor (collector)
198
+ cdtr = etree.SubElement(pmt_inf, "Cdtr")
199
+ etree.SubElement(cdtr, "Nm").text = self.initiator_name
200
+
201
+ cdtr_acct = etree.SubElement(pmt_inf, "CdtrAcct")
202
+ cdtr_acct_id = etree.SubElement(cdtr_acct, "Id")
203
+ etree.SubElement(cdtr_acct_id, "IBAN").text = self.initiator_iban.replace(" ", "")
204
+
205
+ cdtr_agt = etree.SubElement(pmt_inf, "CdtrAgt")
206
+ cdtr_agt_fin_instn_id = etree.SubElement(cdtr_agt, "FinInstnId")
207
+ etree.SubElement(cdtr_agt_fin_instn_id, "BIC").text = self.initiator_bic
208
+
209
+ # Creditor Scheme Identification (SEPA Creditor ID)
210
+ cdtr_schme_id = etree.SubElement(pmt_inf, "CdtrSchmeId")
211
+ cdtr_schme_id_id = etree.SubElement(cdtr_schme_id, "Id")
212
+ prvt_id = etree.SubElement(cdtr_schme_id_id, "PrvtId")
213
+ othr = etree.SubElement(prvt_id, "Othr")
214
+ etree.SubElement(othr, "Id").text = f"FR{uuid.uuid4().hex[:18].upper()}" # Creditor ID
215
+ schme_nm = etree.SubElement(othr, "SchmeNm")
216
+ etree.SubElement(schme_nm, "Prtry").text = "SEPA"
217
+
218
+ # Direct Debit Transaction Information
219
+ for tx in transactions:
220
+ drct_dbt_tx_inf = etree.SubElement(pmt_inf, "DrctDbtTxInf")
221
+
222
+ # Payment ID
223
+ pmt_id = etree.SubElement(drct_dbt_tx_inf, "PmtId")
224
+ end_to_end_id = tx.get("end_to_end_id", f"E2E-{uuid.uuid4().hex[:12].upper()}")
225
+ etree.SubElement(pmt_id, "EndToEndId").text = end_to_end_id
226
+
227
+ # Amount
228
+ instd_amt = etree.SubElement(drct_dbt_tx_inf, "InstdAmt", Ccy="EUR")
229
+ instd_amt.text = f"{Decimal(str(tx['amount'])):.2f}"
230
+
231
+ # Mandate Related Information
232
+ drct_dbt_tx = etree.SubElement(drct_dbt_tx_inf, "DrctDbtTx")
233
+ mndt_rltd_inf = etree.SubElement(drct_dbt_tx, "MndtRltdInf")
234
+ etree.SubElement(mndt_rltd_inf, "MndtId").text = tx["mandate_id"]
235
+ etree.SubElement(mndt_rltd_inf, "DtOfSgntr").text = tx.get("mandate_date", datetime.now().strftime("%Y-%m-%d"))
236
+
237
+ # Debtor Agent
238
+ if tx.get("debtor_bic"):
239
+ dbtr_agt = etree.SubElement(drct_dbt_tx_inf, "DbtrAgt")
240
+ dbtr_agt_fin_instn_id = etree.SubElement(dbtr_agt, "FinInstnId")
241
+ etree.SubElement(dbtr_agt_fin_instn_id, "BIC").text = tx["debtor_bic"]
242
+
243
+ # Debtor
244
+ dbtr = etree.SubElement(drct_dbt_tx_inf, "Dbtr")
245
+ etree.SubElement(dbtr, "Nm").text = tx["debtor_name"]
246
+
247
+ dbtr_acct = etree.SubElement(drct_dbt_tx_inf, "DbtrAcct")
248
+ dbtr_acct_id = etree.SubElement(dbtr_acct, "Id")
249
+ etree.SubElement(dbtr_acct_id, "IBAN").text = tx["debtor_iban"].replace(" ", "")
250
+
251
+ # Remittance Information
252
+ rmt_inf = etree.SubElement(drct_dbt_tx_inf, "RmtInf")
253
+ etree.SubElement(rmt_inf, "Ustrd").text = tx.get("remittance_info", "")[:140]
254
+
255
+ return etree.tostring(root, pretty_print=True, xml_declaration=True, encoding="UTF-8").decode()
256
+
257
+ def generate_statement(
258
+ self,
259
+ iban: str,
260
+ transactions: List[Transaction],
261
+ opening_balance: Decimal,
262
+ closing_balance: Decimal,
263
+ from_date: datetime,
264
+ to_date: datetime
265
+ ) -> str:
266
+ """
267
+ Generate Bank Statement XML (camt.053.001.02)
268
+ """
269
+ nsmap = {None: self.NS_CAMT053}
270
+ root = etree.Element("Document", nsmap=nsmap)
271
+ bk_to_cstmr_stmt = etree.SubElement(root, "BkToCstmrStmt")
272
+
273
+ # Group Header
274
+ grp_hdr = etree.SubElement(bk_to_cstmr_stmt, "GrpHdr")
275
+ msg_id = f"STMT-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
276
+ etree.SubElement(grp_hdr, "MsgId").text = msg_id
277
+ etree.SubElement(grp_hdr, "CreDtTm").text = datetime.now().isoformat()
278
+
279
+ # Statement
280
+ stmt = etree.SubElement(bk_to_cstmr_stmt, "Stmt")
281
+ etree.SubElement(stmt, "Id").text = msg_id
282
+ etree.SubElement(stmt, "CreDtTm").text = datetime.now().isoformat()
283
+
284
+ # Account
285
+ acct = etree.SubElement(stmt, "Acct")
286
+ acct_id = etree.SubElement(acct, "Id")
287
+ etree.SubElement(acct_id, "IBAN").text = iban.replace(" ", "")
288
+
289
+ # Balance - Opening
290
+ bal_opng = etree.SubElement(stmt, "Bal")
291
+ tp_opng = etree.SubElement(bal_opng, "Tp")
292
+ cd_or_prtry_opng = etree.SubElement(tp_opng, "CdOrPrtry")
293
+ etree.SubElement(cd_or_prtry_opng, "Cd").text = "OPBD" # Opening Booked
294
+ amt_opng = etree.SubElement(bal_opng, "Amt", Ccy="EUR")
295
+ amt_opng.text = f"{opening_balance:.2f}"
296
+ etree.SubElement(bal_opng, "CdtDbtInd").text = "CRDT" if opening_balance >= 0 else "DBIT"
297
+ etree.SubElement(bal_opng, "Dt").text = from_date.strftime("%Y-%m-%d")
298
+
299
+ # Balance - Closing
300
+ bal_clsg = etree.SubElement(stmt, "Bal")
301
+ tp_clsg = etree.SubElement(bal_clsg, "Tp")
302
+ cd_or_prtry_clsg = etree.SubElement(tp_clsg, "CdOrPrtry")
303
+ etree.SubElement(cd_or_prtry_clsg, "Cd").text = "CLBD" # Closing Booked
304
+ amt_clsg = etree.SubElement(bal_clsg, "Amt", Ccy="EUR")
305
+ amt_clsg.text = f"{closing_balance:.2f}"
306
+ etree.SubElement(bal_clsg, "CdtDbtInd").text = "CRDT" if closing_balance >= 0 else "DBIT"
307
+ etree.SubElement(bal_clsg, "Dt").text = to_date.strftime("%Y-%m-%d")
308
+
309
+ # Entries (transactions)
310
+ for tx in transactions:
311
+ ntry = etree.SubElement(stmt, "Ntry")
312
+ amt_ntry = etree.SubElement(ntry, "Amt", Ccy=tx.currency.value)
313
+ amt_ntry.text = f"{tx.amount:.2f}"
314
+ etree.SubElement(ntry, "CdtDbtInd").text = "CRDT" if tx.is_credit else "DBIT"
315
+ etree.SubElement(ntry, "Sts").text = "BOOK"
316
+ etree.SubElement(ntry, "BookgDt").text = tx.created_at.strftime("%Y-%m-%d")
317
+ etree.SubElement(ntry, "ValDt").text = tx.value_date.strftime("%Y-%m-%d") if tx.value_date else tx.created_at.strftime("%Y-%m-%d")
318
+
319
+ # Entry Details
320
+ ntry_dtls = etree.SubElement(ntry, "NtryDtls")
321
+ tx_dtls = etree.SubElement(ntry_dtls, "TxDtls")
322
+
323
+ refs = etree.SubElement(tx_dtls, "Refs")
324
+ etree.SubElement(refs, "EndToEndId").text = tx.end_to_end_id or tx.reference
325
+
326
+ # Related Parties
327
+ if tx.counterparty_name:
328
+ rltd_pties = etree.SubElement(tx_dtls, "RltdPties")
329
+ if tx.is_credit:
330
+ dbtr = etree.SubElement(rltd_pties, "Dbtr")
331
+ etree.SubElement(dbtr, "Nm").text = tx.counterparty_name
332
+ else:
333
+ cdtr = etree.SubElement(rltd_pties, "Cdtr")
334
+ etree.SubElement(cdtr, "Nm").text = tx.counterparty_name
335
+
336
+ # Remittance Information
337
+ rmt_inf = etree.SubElement(tx_dtls, "RmtInf")
338
+ etree.SubElement(rmt_inf, "Ustrd").text = tx.label[:140]
339
+
340
+ return etree.tostring(root, pretty_print=True, xml_declaration=True, encoding="UTF-8").decode()
341
+
342
+
343
+ class SEPAParser:
344
+ """
345
+ Parse SEPA XML files
346
+ """
347
+
348
+ @staticmethod
349
+ def parse_credit_transfer(xml_content: str) -> List[dict]:
350
+ """Parse pain.001 Credit Transfer file"""
351
+ root = etree.fromstring(xml_content.encode())
352
+
353
+ # Remove namespace for easier parsing
354
+ for elem in root.iter():
355
+ if elem.tag.startswith('{'):
356
+ elem.tag = elem.tag.split('}')[1]
357
+
358
+ transactions = []
359
+ for cdt_trf_tx_inf in root.findall(".//CdtTrfTxInf"):
360
+ tx = {}
361
+
362
+ # Amount
363
+ instd_amt = cdt_trf_tx_inf.find(".//InstdAmt")
364
+ if instd_amt is not None:
365
+ tx["amount"] = Decimal(instd_amt.text)
366
+ tx["currency"] = instd_amt.get("Ccy", "EUR")
367
+
368
+ # End to End ID
369
+ end_to_end = cdt_trf_tx_inf.find(".//EndToEndId")
370
+ if end_to_end is not None:
371
+ tx["end_to_end_id"] = end_to_end.text
372
+
373
+ # Creditor Name
374
+ cdtr_nm = cdt_trf_tx_inf.find(".//Cdtr/Nm")
375
+ if cdtr_nm is not None:
376
+ tx["creditor_name"] = cdtr_nm.text
377
+
378
+ # Creditor IBAN
379
+ cdtr_iban = cdt_trf_tx_inf.find(".//CdtrAcct/Id/IBAN")
380
+ if cdtr_iban is not None:
381
+ tx["creditor_iban"] = cdtr_iban.text
382
+
383
+ # Creditor BIC
384
+ cdtr_bic = cdt_trf_tx_inf.find(".//CdtrAgt/FinInstnId/BIC")
385
+ if cdtr_bic is not None:
386
+ tx["creditor_bic"] = cdtr_bic.text
387
+
388
+ # Remittance Info
389
+ ustrd = cdt_trf_tx_inf.find(".//RmtInf/Ustrd")
390
+ if ustrd is not None:
391
+ tx["remittance_info"] = ustrd.text
392
+
393
+ transactions.append(tx)
394
+
395
+ return transactions
396
+
397
+ @staticmethod
398
+ def parse_statement(xml_content: str) -> dict:
399
+ """Parse camt.053 Bank Statement file"""
400
+ root = etree.fromstring(xml_content.encode())
401
+
402
+ # Remove namespace
403
+ for elem in root.iter():
404
+ if elem.tag.startswith('{'):
405
+ elem.tag = elem.tag.split('}')[1]
406
+
407
+ result = {
408
+ "iban": "",
409
+ "opening_balance": Decimal("0"),
410
+ "closing_balance": Decimal("0"),
411
+ "transactions": []
412
+ }
413
+
414
+ # IBAN
415
+ iban = root.find(".//Acct/Id/IBAN")
416
+ if iban is not None:
417
+ result["iban"] = iban.text
418
+
419
+ # Balances
420
+ for bal in root.findall(".//Bal"):
421
+ cd = bal.find(".//Cd")
422
+ amt = bal.find(".//Amt")
423
+ if cd is not None and amt is not None:
424
+ value = Decimal(amt.text)
425
+ if cd.text == "OPBD":
426
+ result["opening_balance"] = value
427
+ elif cd.text == "CLBD":
428
+ result["closing_balance"] = value
429
+
430
+ # Transactions
431
+ for ntry in root.findall(".//Ntry"):
432
+ tx = {}
433
+ amt = ntry.find("Amt")
434
+ if amt is not None:
435
+ tx["amount"] = Decimal(amt.text)
436
+ tx["currency"] = amt.get("Ccy", "EUR")
437
+
438
+ cdt_dbt = ntry.find("CdtDbtInd")
439
+ if cdt_dbt is not None:
440
+ tx["is_credit"] = cdt_dbt.text == "CRDT"
441
+
442
+ bookg_dt = ntry.find("BookgDt")
443
+ if bookg_dt is not None:
444
+ tx["booking_date"] = bookg_dt.text
445
+
446
+ ustrd = ntry.find(".//Ustrd")
447
+ if ustrd is not None:
448
+ tx["label"] = ustrd.text
449
+
450
+ result["transactions"].append(tx)
451
+
452
+ return result
@@ -0,0 +1,11 @@
1
+ """
2
+ Storage module - JSON/SQLite persistence
3
+ """
4
+
5
+ from .json_storage import JSONStorage
6
+ from .sqlite_storage import SQLiteStorage
7
+
8
+ __all__ = [
9
+ "JSONStorage",
10
+ "SQLiteStorage",
11
+ ]
@@ -0,0 +1,127 @@
1
+ """
2
+ JSON Storage - Simple file-based persistence
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Optional, Dict, List
9
+ from datetime import datetime
10
+ from decimal import Decimal
11
+
12
+
13
+ class JSONEncoder(json.JSONEncoder):
14
+ """Custom JSON encoder for banking types"""
15
+
16
+ def default(self, obj):
17
+ if isinstance(obj, Decimal):
18
+ return str(obj)
19
+ if isinstance(obj, datetime):
20
+ return obj.isoformat()
21
+ if hasattr(obj, "to_dict"):
22
+ return obj.to_dict()
23
+ if hasattr(obj, "value"): # Enum
24
+ return obj.value
25
+ return super().default(obj)
26
+
27
+
28
+ class JSONStorage:
29
+ """
30
+ Simple JSON file storage for banking data
31
+ """
32
+
33
+ def __init__(self, data_dir: Optional[str] = None):
34
+ self.data_dir = Path(data_dir) if data_dir else Path.home() / ".nanopy-bank"
35
+ self.data_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ def _get_file_path(self, collection: str) -> Path:
38
+ """Get file path for a collection"""
39
+ return self.data_dir / f"{collection}.json"
40
+
41
+ def load(self, collection: str) -> List[Dict]:
42
+ """Load all items from a collection"""
43
+ file_path = self._get_file_path(collection)
44
+ if not file_path.exists():
45
+ return []
46
+
47
+ with open(file_path, "r", encoding="utf-8") as f:
48
+ return json.load(f)
49
+
50
+ def save(self, collection: str, data: List[Dict]):
51
+ """Save all items to a collection"""
52
+ file_path = self._get_file_path(collection)
53
+ with open(file_path, "w", encoding="utf-8") as f:
54
+ json.dump(data, f, indent=2, cls=JSONEncoder, default=str)
55
+
56
+ def get(self, collection: str, key: str, key_field: str = "id") -> Optional[Dict]:
57
+ """Get a single item by key"""
58
+ items = self.load(collection)
59
+ for item in items:
60
+ if item.get(key_field) == key:
61
+ return item
62
+ return None
63
+
64
+ def put(self, collection: str, item: Dict, key_field: str = "id"):
65
+ """Add or update an item"""
66
+ items = self.load(collection)
67
+ key = item.get(key_field)
68
+
69
+ # Update existing or append
70
+ updated = False
71
+ for i, existing in enumerate(items):
72
+ if existing.get(key_field) == key:
73
+ items[i] = item
74
+ updated = True
75
+ break
76
+
77
+ if not updated:
78
+ items.append(item)
79
+
80
+ self.save(collection, items)
81
+
82
+ def delete(self, collection: str, key: str, key_field: str = "id") -> bool:
83
+ """Delete an item"""
84
+ items = self.load(collection)
85
+ original_len = len(items)
86
+ items = [item for item in items if item.get(key_field) != key]
87
+
88
+ if len(items) < original_len:
89
+ self.save(collection, items)
90
+ return True
91
+ return False
92
+
93
+ def clear(self, collection: str):
94
+ """Clear all items from a collection"""
95
+ self.save(collection, [])
96
+
97
+ def count(self, collection: str) -> int:
98
+ """Count items in a collection"""
99
+ return len(self.load(collection))
100
+
101
+ def query(self, collection: str, filters: Dict[str, Any]) -> List[Dict]:
102
+ """Query items with filters"""
103
+ items = self.load(collection)
104
+ results = []
105
+
106
+ for item in items:
107
+ match = True
108
+ for key, value in filters.items():
109
+ if item.get(key) != value:
110
+ match = False
111
+ break
112
+ if match:
113
+ results.append(item)
114
+
115
+ return results
116
+
117
+
118
+ # Singleton
119
+ _storage_instance: Optional[JSONStorage] = None
120
+
121
+
122
+ def get_json_storage(data_dir: Optional[str] = None) -> JSONStorage:
123
+ """Get or create JSON storage instance"""
124
+ global _storage_instance
125
+ if _storage_instance is None:
126
+ _storage_instance = JSONStorage(data_dir)
127
+ return _storage_instance