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.
- nanopy_bank/__init__.py +20 -0
- nanopy_bank/api/__init__.py +10 -0
- nanopy_bank/api/server.py +242 -0
- nanopy_bank/app.py +282 -0
- nanopy_bank/cli.py +152 -0
- nanopy_bank/core/__init__.py +85 -0
- nanopy_bank/core/audit.py +404 -0
- nanopy_bank/core/auth.py +306 -0
- nanopy_bank/core/bank.py +407 -0
- nanopy_bank/core/beneficiary.py +258 -0
- nanopy_bank/core/branch.py +319 -0
- nanopy_bank/core/fees.py +243 -0
- nanopy_bank/core/holding.py +416 -0
- nanopy_bank/core/models.py +308 -0
- nanopy_bank/core/products.py +300 -0
- nanopy_bank/data/__init__.py +31 -0
- nanopy_bank/data/demo.py +846 -0
- nanopy_bank/documents/__init__.py +11 -0
- nanopy_bank/documents/statement.py +304 -0
- nanopy_bank/sepa/__init__.py +10 -0
- nanopy_bank/sepa/sepa.py +452 -0
- nanopy_bank/storage/__init__.py +11 -0
- nanopy_bank/storage/json_storage.py +127 -0
- nanopy_bank/storage/sqlite_storage.py +326 -0
- nanopy_bank/ui/__init__.py +14 -0
- nanopy_bank/ui/pages/__init__.py +33 -0
- nanopy_bank/ui/pages/accounts.py +85 -0
- nanopy_bank/ui/pages/advisor.py +140 -0
- nanopy_bank/ui/pages/audit.py +73 -0
- nanopy_bank/ui/pages/beneficiaries.py +115 -0
- nanopy_bank/ui/pages/branches.py +64 -0
- nanopy_bank/ui/pages/cards.py +36 -0
- nanopy_bank/ui/pages/common.py +18 -0
- nanopy_bank/ui/pages/dashboard.py +100 -0
- nanopy_bank/ui/pages/fees.py +60 -0
- nanopy_bank/ui/pages/holding.py +943 -0
- nanopy_bank/ui/pages/loans.py +105 -0
- nanopy_bank/ui/pages/login.py +174 -0
- nanopy_bank/ui/pages/sepa.py +118 -0
- nanopy_bank/ui/pages/settings.py +48 -0
- nanopy_bank/ui/pages/transfers.py +94 -0
- nanopy_bank/ui/pages.py +16 -0
- nanopy_bank-1.0.8.dist-info/METADATA +72 -0
- nanopy_bank-1.0.8.dist-info/RECORD +47 -0
- nanopy_bank-1.0.8.dist-info/WHEEL +5 -0
- nanopy_bank-1.0.8.dist-info/entry_points.txt +2 -0
- nanopy_bank-1.0.8.dist-info/top_level.txt +1 -0
nanopy_bank/sepa/sepa.py
ADDED
|
@@ -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,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
|