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/core/bank.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bank - Core banking logic
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Optional, List, Dict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .models import (
|
|
13
|
+
Account, Transaction, Customer, Card,
|
|
14
|
+
TransactionType, AccountType, AccountStatus, Currency
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Bank:
|
|
19
|
+
"""
|
|
20
|
+
Core banking system - manages accounts, transactions, customers
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, data_dir: Optional[str] = None):
|
|
24
|
+
self.data_dir = Path(data_dir) if data_dir else Path.home() / ".nanopy-bank"
|
|
25
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
# In-memory storage (loaded from JSON files)
|
|
28
|
+
self.customers: Dict[str, Customer] = {}
|
|
29
|
+
self.accounts: Dict[str, Account] = {} # Key = IBAN
|
|
30
|
+
self.transactions: Dict[str, Transaction] = {}
|
|
31
|
+
self.cards: Dict[str, Card] = {}
|
|
32
|
+
|
|
33
|
+
# Load existing data
|
|
34
|
+
self._load_data()
|
|
35
|
+
|
|
36
|
+
# ========== DATA PERSISTENCE ==========
|
|
37
|
+
|
|
38
|
+
def _load_data(self):
|
|
39
|
+
"""Load data from JSON files"""
|
|
40
|
+
# Load customers
|
|
41
|
+
customers_file = self.data_dir / "customers.json"
|
|
42
|
+
if customers_file.exists():
|
|
43
|
+
with open(customers_file, "r") as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
for cust_data in data:
|
|
46
|
+
# Remove computed properties that shouldn't be passed to constructor
|
|
47
|
+
cust_data.pop("full_name", None)
|
|
48
|
+
if cust_data.get("created_at"):
|
|
49
|
+
cust_data["created_at"] = datetime.fromisoformat(cust_data["created_at"])
|
|
50
|
+
if cust_data.get("birth_date"):
|
|
51
|
+
cust_data["birth_date"] = datetime.fromisoformat(cust_data["birth_date"])
|
|
52
|
+
cust = Customer(**cust_data)
|
|
53
|
+
self.customers[cust.customer_id] = cust
|
|
54
|
+
|
|
55
|
+
# Load accounts
|
|
56
|
+
accounts_file = self.data_dir / "accounts.json"
|
|
57
|
+
if accounts_file.exists():
|
|
58
|
+
with open(accounts_file, "r") as f:
|
|
59
|
+
data = json.load(f)
|
|
60
|
+
for acc_data in data:
|
|
61
|
+
# Remove computed properties
|
|
62
|
+
acc_data.pop("iban_formatted", None)
|
|
63
|
+
acc_data["account_type"] = AccountType(acc_data["account_type"])
|
|
64
|
+
acc_data["currency"] = Currency(acc_data["currency"])
|
|
65
|
+
acc_data["status"] = AccountStatus(acc_data["status"])
|
|
66
|
+
acc_data["balance"] = Decimal(acc_data["balance"])
|
|
67
|
+
acc_data["available_balance"] = Decimal(acc_data["available_balance"])
|
|
68
|
+
acc_data["overdraft_limit"] = Decimal(acc_data["overdraft_limit"])
|
|
69
|
+
if acc_data.get("created_at"):
|
|
70
|
+
acc_data["created_at"] = datetime.fromisoformat(acc_data["created_at"])
|
|
71
|
+
if acc_data.get("last_transaction"):
|
|
72
|
+
acc_data["last_transaction"] = datetime.fromisoformat(acc_data["last_transaction"])
|
|
73
|
+
acc = Account(**acc_data)
|
|
74
|
+
self.accounts[acc.iban] = acc
|
|
75
|
+
|
|
76
|
+
# Load transactions
|
|
77
|
+
transactions_file = self.data_dir / "transactions.json"
|
|
78
|
+
if transactions_file.exists():
|
|
79
|
+
with open(transactions_file, "r") as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
for tx_data in data:
|
|
82
|
+
# Remove computed properties
|
|
83
|
+
tx_data.pop("signed_amount", None)
|
|
84
|
+
tx_data.pop("is_credit", None)
|
|
85
|
+
tx_data.pop("is_debit", None)
|
|
86
|
+
tx_data["transaction_type"] = TransactionType(tx_data["transaction_type"])
|
|
87
|
+
tx_data["currency"] = Currency(tx_data["currency"])
|
|
88
|
+
tx_data["amount"] = Decimal(tx_data["amount"])
|
|
89
|
+
if tx_data.get("balance_after"):
|
|
90
|
+
tx_data["balance_after"] = Decimal(tx_data["balance_after"])
|
|
91
|
+
if tx_data.get("created_at"):
|
|
92
|
+
tx_data["created_at"] = datetime.fromisoformat(tx_data["created_at"])
|
|
93
|
+
if tx_data.get("executed_at"):
|
|
94
|
+
tx_data["executed_at"] = datetime.fromisoformat(tx_data["executed_at"])
|
|
95
|
+
if tx_data.get("value_date"):
|
|
96
|
+
tx_data["value_date"] = datetime.fromisoformat(tx_data["value_date"])
|
|
97
|
+
tx = Transaction(**tx_data)
|
|
98
|
+
self.transactions[tx.transaction_id] = tx
|
|
99
|
+
|
|
100
|
+
print(f"[BANK] Loaded {len(self.customers)} customers, {len(self.accounts)} accounts, {len(self.transactions)} transactions")
|
|
101
|
+
|
|
102
|
+
def _save_data(self):
|
|
103
|
+
"""Save data to JSON files"""
|
|
104
|
+
# Save customers
|
|
105
|
+
customers_data = [c.to_dict() for c in self.customers.values()]
|
|
106
|
+
with open(self.data_dir / "customers.json", "w") as f:
|
|
107
|
+
json.dump(customers_data, f, indent=2, default=str)
|
|
108
|
+
|
|
109
|
+
# Save accounts
|
|
110
|
+
accounts_data = []
|
|
111
|
+
for acc in self.accounts.values():
|
|
112
|
+
d = acc.to_dict()
|
|
113
|
+
d["account_type"] = acc.account_type.value
|
|
114
|
+
d["currency"] = acc.currency.value
|
|
115
|
+
d["status"] = acc.status.value
|
|
116
|
+
accounts_data.append(d)
|
|
117
|
+
with open(self.data_dir / "accounts.json", "w") as f:
|
|
118
|
+
json.dump(accounts_data, f, indent=2, default=str)
|
|
119
|
+
|
|
120
|
+
# Save transactions
|
|
121
|
+
transactions_data = []
|
|
122
|
+
for tx in self.transactions.values():
|
|
123
|
+
d = tx.to_dict()
|
|
124
|
+
d["transaction_type"] = tx.transaction_type.value
|
|
125
|
+
d["currency"] = tx.currency.value
|
|
126
|
+
transactions_data.append(d)
|
|
127
|
+
with open(self.data_dir / "transactions.json", "w") as f:
|
|
128
|
+
json.dump(transactions_data, f, indent=2, default=str)
|
|
129
|
+
|
|
130
|
+
# ========== CUSTOMERS ==========
|
|
131
|
+
|
|
132
|
+
def create_customer(
|
|
133
|
+
self,
|
|
134
|
+
first_name: str,
|
|
135
|
+
last_name: str,
|
|
136
|
+
email: str,
|
|
137
|
+
phone: str = "",
|
|
138
|
+
address: str = "",
|
|
139
|
+
city: str = "",
|
|
140
|
+
postal_code: str = "",
|
|
141
|
+
country: str = "FR"
|
|
142
|
+
) -> Customer:
|
|
143
|
+
"""Create a new customer"""
|
|
144
|
+
customer = Customer(
|
|
145
|
+
first_name=first_name,
|
|
146
|
+
last_name=last_name,
|
|
147
|
+
email=email,
|
|
148
|
+
phone=phone,
|
|
149
|
+
address=address,
|
|
150
|
+
city=city,
|
|
151
|
+
postal_code=postal_code,
|
|
152
|
+
country=country
|
|
153
|
+
)
|
|
154
|
+
self.customers[customer.customer_id] = customer
|
|
155
|
+
self._save_data()
|
|
156
|
+
print(f"[BANK] Created customer: {customer.full_name} ({customer.customer_id})")
|
|
157
|
+
return customer
|
|
158
|
+
|
|
159
|
+
def get_customer(self, customer_id: str) -> Optional[Customer]:
|
|
160
|
+
"""Get customer by ID"""
|
|
161
|
+
return self.customers.get(customer_id)
|
|
162
|
+
|
|
163
|
+
def get_customer_accounts(self, customer_id: str) -> List[Account]:
|
|
164
|
+
"""Get all accounts for a customer"""
|
|
165
|
+
return [acc for acc in self.accounts.values() if acc.customer_id == customer_id]
|
|
166
|
+
|
|
167
|
+
# ========== ACCOUNTS ==========
|
|
168
|
+
|
|
169
|
+
def create_account(
|
|
170
|
+
self,
|
|
171
|
+
customer_id: str,
|
|
172
|
+
account_type: AccountType = AccountType.CHECKING,
|
|
173
|
+
currency: Currency = Currency.EUR,
|
|
174
|
+
initial_balance: Decimal = Decimal("0.00"),
|
|
175
|
+
overdraft_limit: Decimal = Decimal("0.00"),
|
|
176
|
+
account_name: str = ""
|
|
177
|
+
) -> Account:
|
|
178
|
+
"""Create a new account"""
|
|
179
|
+
if customer_id not in self.customers:
|
|
180
|
+
raise ValueError(f"Customer {customer_id} not found")
|
|
181
|
+
|
|
182
|
+
account = Account(
|
|
183
|
+
customer_id=customer_id,
|
|
184
|
+
account_type=account_type,
|
|
185
|
+
currency=currency,
|
|
186
|
+
balance=initial_balance,
|
|
187
|
+
available_balance=initial_balance,
|
|
188
|
+
overdraft_limit=overdraft_limit,
|
|
189
|
+
account_name=account_name
|
|
190
|
+
)
|
|
191
|
+
self.accounts[account.iban] = account
|
|
192
|
+
self._save_data()
|
|
193
|
+
print(f"[BANK] Created account: {account.iban} for customer {customer_id}")
|
|
194
|
+
return account
|
|
195
|
+
|
|
196
|
+
def get_account(self, iban: str) -> Optional[Account]:
|
|
197
|
+
"""Get account by IBAN"""
|
|
198
|
+
# Normalize IBAN (remove spaces)
|
|
199
|
+
iban = iban.replace(" ", "").upper()
|
|
200
|
+
return self.accounts.get(iban)
|
|
201
|
+
|
|
202
|
+
def get_account_balance(self, iban: str) -> Optional[Decimal]:
|
|
203
|
+
"""Get account balance"""
|
|
204
|
+
account = self.get_account(iban)
|
|
205
|
+
return account.balance if account else None
|
|
206
|
+
|
|
207
|
+
def get_account_transactions(self, iban: str, limit: int = 50) -> List[Transaction]:
|
|
208
|
+
"""Get transactions for an account"""
|
|
209
|
+
iban = iban.replace(" ", "").upper()
|
|
210
|
+
txs = [tx for tx in self.transactions.values() if tx.account_iban == iban]
|
|
211
|
+
txs.sort(key=lambda x: x.created_at, reverse=True)
|
|
212
|
+
return txs[:limit]
|
|
213
|
+
|
|
214
|
+
# ========== TRANSACTIONS ==========
|
|
215
|
+
|
|
216
|
+
def credit(
|
|
217
|
+
self,
|
|
218
|
+
iban: str,
|
|
219
|
+
amount: Decimal,
|
|
220
|
+
label: str,
|
|
221
|
+
counterparty_name: str = "",
|
|
222
|
+
counterparty_iban: str = "",
|
|
223
|
+
transaction_type: TransactionType = TransactionType.CREDIT,
|
|
224
|
+
description: str = "",
|
|
225
|
+
category: str = ""
|
|
226
|
+
) -> Transaction:
|
|
227
|
+
"""Credit an account (add money)"""
|
|
228
|
+
account = self.get_account(iban)
|
|
229
|
+
if not account:
|
|
230
|
+
raise ValueError(f"Account {iban} not found")
|
|
231
|
+
|
|
232
|
+
if account.status != AccountStatus.ACTIVE:
|
|
233
|
+
raise ValueError(f"Account {iban} is not active")
|
|
234
|
+
|
|
235
|
+
# Update balance
|
|
236
|
+
account.balance += amount
|
|
237
|
+
account.available_balance += amount
|
|
238
|
+
account.last_transaction = datetime.now()
|
|
239
|
+
|
|
240
|
+
# Create transaction
|
|
241
|
+
tx = Transaction(
|
|
242
|
+
transaction_type=transaction_type,
|
|
243
|
+
amount=amount,
|
|
244
|
+
currency=account.currency,
|
|
245
|
+
account_iban=account.iban,
|
|
246
|
+
to_iban=account.iban,
|
|
247
|
+
from_iban=counterparty_iban or None,
|
|
248
|
+
label=label,
|
|
249
|
+
description=description,
|
|
250
|
+
category=category,
|
|
251
|
+
counterparty_name=counterparty_name,
|
|
252
|
+
counterparty_iban=counterparty_iban or None,
|
|
253
|
+
balance_after=account.balance
|
|
254
|
+
)
|
|
255
|
+
self.transactions[tx.transaction_id] = tx
|
|
256
|
+
self._save_data()
|
|
257
|
+
|
|
258
|
+
print(f"[BANK] Credit {amount} {account.currency.value} to {iban}: {label}")
|
|
259
|
+
return tx
|
|
260
|
+
|
|
261
|
+
def debit(
|
|
262
|
+
self,
|
|
263
|
+
iban: str,
|
|
264
|
+
amount: Decimal,
|
|
265
|
+
label: str,
|
|
266
|
+
counterparty_name: str = "",
|
|
267
|
+
counterparty_iban: str = "",
|
|
268
|
+
transaction_type: TransactionType = TransactionType.DEBIT,
|
|
269
|
+
description: str = "",
|
|
270
|
+
category: str = ""
|
|
271
|
+
) -> Transaction:
|
|
272
|
+
"""Debit an account (remove money)"""
|
|
273
|
+
account = self.get_account(iban)
|
|
274
|
+
if not account:
|
|
275
|
+
raise ValueError(f"Account {iban} not found")
|
|
276
|
+
|
|
277
|
+
if not account.can_debit(amount):
|
|
278
|
+
raise ValueError(f"Insufficient funds in account {iban}")
|
|
279
|
+
|
|
280
|
+
# Update balance
|
|
281
|
+
account.balance -= amount
|
|
282
|
+
account.available_balance -= amount
|
|
283
|
+
account.last_transaction = datetime.now()
|
|
284
|
+
|
|
285
|
+
# Create transaction
|
|
286
|
+
tx = Transaction(
|
|
287
|
+
transaction_type=transaction_type,
|
|
288
|
+
amount=amount,
|
|
289
|
+
currency=account.currency,
|
|
290
|
+
account_iban=account.iban,
|
|
291
|
+
from_iban=account.iban,
|
|
292
|
+
to_iban=counterparty_iban or None,
|
|
293
|
+
label=label,
|
|
294
|
+
description=description,
|
|
295
|
+
category=category,
|
|
296
|
+
counterparty_name=counterparty_name,
|
|
297
|
+
counterparty_iban=counterparty_iban or None,
|
|
298
|
+
balance_after=account.balance
|
|
299
|
+
)
|
|
300
|
+
self.transactions[tx.transaction_id] = tx
|
|
301
|
+
self._save_data()
|
|
302
|
+
|
|
303
|
+
print(f"[BANK] Debit {amount} {account.currency.value} from {iban}: {label}")
|
|
304
|
+
return tx
|
|
305
|
+
|
|
306
|
+
def transfer(
|
|
307
|
+
self,
|
|
308
|
+
from_iban: str,
|
|
309
|
+
to_iban: str,
|
|
310
|
+
amount: Decimal,
|
|
311
|
+
label: str,
|
|
312
|
+
description: str = ""
|
|
313
|
+
) -> tuple:
|
|
314
|
+
"""Transfer money between accounts"""
|
|
315
|
+
from_account = self.get_account(from_iban)
|
|
316
|
+
to_account = self.get_account(to_iban)
|
|
317
|
+
|
|
318
|
+
if not from_account:
|
|
319
|
+
raise ValueError(f"Source account {from_iban} not found")
|
|
320
|
+
|
|
321
|
+
# Debit source account
|
|
322
|
+
debit_tx = self.debit(
|
|
323
|
+
from_iban,
|
|
324
|
+
amount,
|
|
325
|
+
f"Virement vers {to_iban[-4:]}",
|
|
326
|
+
counterparty_name=to_account.account_name if to_account else "",
|
|
327
|
+
counterparty_iban=to_iban,
|
|
328
|
+
transaction_type=TransactionType.TRANSFER,
|
|
329
|
+
description=description
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Credit destination account (if internal)
|
|
333
|
+
credit_tx = None
|
|
334
|
+
if to_account:
|
|
335
|
+
credit_tx = self.credit(
|
|
336
|
+
to_iban,
|
|
337
|
+
amount,
|
|
338
|
+
f"Virement de {from_iban[-4:]}",
|
|
339
|
+
counterparty_name=from_account.account_name,
|
|
340
|
+
counterparty_iban=from_iban,
|
|
341
|
+
transaction_type=TransactionType.TRANSFER,
|
|
342
|
+
description=description
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
print(f"[BANK] Transfer {amount} EUR from {from_iban} to {to_iban}")
|
|
346
|
+
return debit_tx, credit_tx
|
|
347
|
+
|
|
348
|
+
# ========== SEPA ==========
|
|
349
|
+
|
|
350
|
+
def sepa_credit_transfer(
|
|
351
|
+
self,
|
|
352
|
+
from_iban: str,
|
|
353
|
+
to_iban: str,
|
|
354
|
+
to_bic: str,
|
|
355
|
+
to_name: str,
|
|
356
|
+
amount: Decimal,
|
|
357
|
+
label: str,
|
|
358
|
+
end_to_end_id: str = ""
|
|
359
|
+
) -> Transaction:
|
|
360
|
+
"""Execute a SEPA Credit Transfer"""
|
|
361
|
+
tx = self.debit(
|
|
362
|
+
from_iban,
|
|
363
|
+
amount,
|
|
364
|
+
label,
|
|
365
|
+
counterparty_name=to_name,
|
|
366
|
+
counterparty_iban=to_iban,
|
|
367
|
+
transaction_type=TransactionType.SEPA_CREDIT,
|
|
368
|
+
description=f"SEPA Credit Transfer to {to_name}"
|
|
369
|
+
)
|
|
370
|
+
tx.counterparty_bic = to_bic
|
|
371
|
+
tx.end_to_end_id = end_to_end_id or tx.reference
|
|
372
|
+
self._save_data()
|
|
373
|
+
return tx
|
|
374
|
+
|
|
375
|
+
# ========== STATISTICS ==========
|
|
376
|
+
|
|
377
|
+
def get_stats(self) -> dict:
|
|
378
|
+
"""Get bank statistics"""
|
|
379
|
+
total_balance = sum(acc.balance for acc in self.accounts.values())
|
|
380
|
+
total_transactions = len(self.transactions)
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"total_customers": len(self.customers),
|
|
384
|
+
"total_accounts": len(self.accounts),
|
|
385
|
+
"total_transactions": total_transactions,
|
|
386
|
+
"total_balance": str(total_balance),
|
|
387
|
+
"accounts_by_type": {
|
|
388
|
+
at.value: len([a for a in self.accounts.values() if a.account_type == at])
|
|
389
|
+
for at in AccountType
|
|
390
|
+
},
|
|
391
|
+
"transactions_today": len([
|
|
392
|
+
tx for tx in self.transactions.values()
|
|
393
|
+
if tx.created_at.date() == datetime.now().date()
|
|
394
|
+
])
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# Singleton instance
|
|
399
|
+
_bank_instance: Optional[Bank] = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_bank(data_dir: Optional[str] = None) -> Bank:
|
|
403
|
+
"""Get or create bank instance"""
|
|
404
|
+
global _bank_instance
|
|
405
|
+
if _bank_instance is None:
|
|
406
|
+
_bank_instance = Bank(data_dir)
|
|
407
|
+
return _bank_instance
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Beneficiary Models - Contacts for transfers, Standing Orders, SEPA Mandates
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, date
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MandateStatus(Enum):
|
|
14
|
+
"""SEPA Mandate status"""
|
|
15
|
+
ACTIVE = "active"
|
|
16
|
+
SUSPENDED = "suspended"
|
|
17
|
+
CANCELLED = "cancelled"
|
|
18
|
+
EXPIRED = "expired"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MandateType(Enum):
|
|
22
|
+
"""SEPA Mandate type"""
|
|
23
|
+
CORE = "core" # Particuliers
|
|
24
|
+
B2B = "b2b" # Entreprises
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OrderFrequency(Enum):
|
|
28
|
+
"""Standing order frequency"""
|
|
29
|
+
DAILY = "daily"
|
|
30
|
+
WEEKLY = "weekly"
|
|
31
|
+
BIWEEKLY = "biweekly"
|
|
32
|
+
MONTHLY = "monthly"
|
|
33
|
+
QUARTERLY = "quarterly"
|
|
34
|
+
YEARLY = "yearly"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OrderStatus(Enum):
|
|
38
|
+
"""Standing order status"""
|
|
39
|
+
ACTIVE = "active"
|
|
40
|
+
PAUSED = "paused"
|
|
41
|
+
CANCELLED = "cancelled"
|
|
42
|
+
COMPLETED = "completed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Beneficiary:
|
|
47
|
+
"""
|
|
48
|
+
Saved beneficiary for quick transfers
|
|
49
|
+
"""
|
|
50
|
+
beneficiary_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
|
|
51
|
+
customer_id: str = "" # Owner of this beneficiary
|
|
52
|
+
|
|
53
|
+
# Beneficiary info
|
|
54
|
+
name: str = ""
|
|
55
|
+
iban: str = ""
|
|
56
|
+
bic: str = ""
|
|
57
|
+
|
|
58
|
+
# Additional info
|
|
59
|
+
alias: str = "" # Nickname (e.g., "Mom", "Landlord")
|
|
60
|
+
email: str = ""
|
|
61
|
+
phone: str = ""
|
|
62
|
+
address: str = ""
|
|
63
|
+
country: str = "FR"
|
|
64
|
+
|
|
65
|
+
# Categorization
|
|
66
|
+
category: str = "" # "family", "business", "bills", etc.
|
|
67
|
+
is_favorite: bool = False
|
|
68
|
+
|
|
69
|
+
# Metadata
|
|
70
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
71
|
+
last_used: Optional[datetime] = None
|
|
72
|
+
use_count: int = 0
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"beneficiary_id": self.beneficiary_id,
|
|
77
|
+
"customer_id": self.customer_id,
|
|
78
|
+
"name": self.name,
|
|
79
|
+
"iban": self.iban,
|
|
80
|
+
"iban_formatted": self.format_iban(),
|
|
81
|
+
"bic": self.bic,
|
|
82
|
+
"alias": self.alias,
|
|
83
|
+
"email": self.email,
|
|
84
|
+
"phone": self.phone,
|
|
85
|
+
"address": self.address,
|
|
86
|
+
"country": self.country,
|
|
87
|
+
"category": self.category,
|
|
88
|
+
"is_favorite": self.is_favorite,
|
|
89
|
+
"created_at": self.created_at.isoformat(),
|
|
90
|
+
"last_used": self.last_used.isoformat() if self.last_used else None,
|
|
91
|
+
"use_count": self.use_count,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def format_iban(self) -> str:
|
|
95
|
+
"""Format IBAN with spaces"""
|
|
96
|
+
return " ".join([self.iban[i:i+4] for i in range(0, len(self.iban), 4)])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class StandingOrder:
|
|
101
|
+
"""
|
|
102
|
+
Recurring transfer order (virement permanent)
|
|
103
|
+
"""
|
|
104
|
+
order_id: str = field(default_factory=lambda: f"SO{uuid.uuid4().hex[:10].upper()}")
|
|
105
|
+
|
|
106
|
+
# Source
|
|
107
|
+
from_iban: str = ""
|
|
108
|
+
customer_id: str = ""
|
|
109
|
+
|
|
110
|
+
# Destination
|
|
111
|
+
to_iban: str = ""
|
|
112
|
+
to_bic: str = ""
|
|
113
|
+
to_name: str = ""
|
|
114
|
+
beneficiary_id: Optional[str] = None # Link to saved beneficiary
|
|
115
|
+
|
|
116
|
+
# Amount
|
|
117
|
+
amount: Decimal = Decimal("0.00")
|
|
118
|
+
currency: str = "EUR"
|
|
119
|
+
|
|
120
|
+
# Schedule
|
|
121
|
+
frequency: OrderFrequency = OrderFrequency.MONTHLY
|
|
122
|
+
start_date: date = field(default_factory=date.today)
|
|
123
|
+
end_date: Optional[date] = None # None = no end
|
|
124
|
+
next_execution: Optional[date] = None
|
|
125
|
+
execution_day: int = 1 # Day of month (1-28)
|
|
126
|
+
|
|
127
|
+
# Details
|
|
128
|
+
label: str = ""
|
|
129
|
+
reference: str = ""
|
|
130
|
+
category: str = ""
|
|
131
|
+
|
|
132
|
+
# Status
|
|
133
|
+
status: OrderStatus = OrderStatus.ACTIVE
|
|
134
|
+
execution_count: int = 0
|
|
135
|
+
last_execution: Optional[datetime] = None
|
|
136
|
+
last_status: str = "" # "success", "failed", "insufficient_funds"
|
|
137
|
+
|
|
138
|
+
# Metadata
|
|
139
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
140
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
141
|
+
|
|
142
|
+
def __post_init__(self):
|
|
143
|
+
if not self.next_execution:
|
|
144
|
+
self.next_execution = self.start_date
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict:
|
|
147
|
+
return {
|
|
148
|
+
"order_id": self.order_id,
|
|
149
|
+
"from_iban": self.from_iban,
|
|
150
|
+
"customer_id": self.customer_id,
|
|
151
|
+
"to_iban": self.to_iban,
|
|
152
|
+
"to_bic": self.to_bic,
|
|
153
|
+
"to_name": self.to_name,
|
|
154
|
+
"beneficiary_id": self.beneficiary_id,
|
|
155
|
+
"amount": str(self.amount),
|
|
156
|
+
"currency": self.currency,
|
|
157
|
+
"frequency": self.frequency.value,
|
|
158
|
+
"start_date": self.start_date.isoformat(),
|
|
159
|
+
"end_date": self.end_date.isoformat() if self.end_date else None,
|
|
160
|
+
"next_execution": self.next_execution.isoformat() if self.next_execution else None,
|
|
161
|
+
"execution_day": self.execution_day,
|
|
162
|
+
"label": self.label,
|
|
163
|
+
"reference": self.reference,
|
|
164
|
+
"category": self.category,
|
|
165
|
+
"status": self.status.value,
|
|
166
|
+
"execution_count": self.execution_count,
|
|
167
|
+
"last_execution": self.last_execution.isoformat() if self.last_execution else None,
|
|
168
|
+
"last_status": self.last_status,
|
|
169
|
+
"created_at": self.created_at.isoformat(),
|
|
170
|
+
"updated_at": self.updated_at.isoformat(),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class SEPAMandate:
|
|
176
|
+
"""
|
|
177
|
+
SEPA Direct Debit Mandate (Mandat de prélèvement)
|
|
178
|
+
"""
|
|
179
|
+
mandate_id: str = field(default_factory=lambda: f"MNDT{uuid.uuid4().hex[:12].upper()}")
|
|
180
|
+
|
|
181
|
+
# Debtor (the account holder giving permission)
|
|
182
|
+
debtor_iban: str = ""
|
|
183
|
+
debtor_bic: str = ""
|
|
184
|
+
debtor_name: str = ""
|
|
185
|
+
customer_id: str = ""
|
|
186
|
+
|
|
187
|
+
# Creditor (the one who will collect)
|
|
188
|
+
creditor_id: str = "" # SEPA Creditor Identifier
|
|
189
|
+
creditor_name: str = ""
|
|
190
|
+
creditor_iban: str = ""
|
|
191
|
+
creditor_bic: str = ""
|
|
192
|
+
|
|
193
|
+
# Mandate details
|
|
194
|
+
mandate_type: MandateType = MandateType.CORE
|
|
195
|
+
signature_date: date = field(default_factory=date.today)
|
|
196
|
+
signature_location: str = ""
|
|
197
|
+
|
|
198
|
+
# Limits
|
|
199
|
+
max_amount: Optional[Decimal] = None # Max per debit
|
|
200
|
+
max_frequency: Optional[str] = None # "monthly", "quarterly", etc.
|
|
201
|
+
|
|
202
|
+
# Status
|
|
203
|
+
status: MandateStatus = MandateStatus.ACTIVE
|
|
204
|
+
|
|
205
|
+
# Sequence tracking
|
|
206
|
+
sequence_type: str = "FRST" # FRST, RCUR, OOFF, FNAL
|
|
207
|
+
first_collection_date: Optional[date] = None
|
|
208
|
+
last_collection_date: Optional[date] = None
|
|
209
|
+
collection_count: int = 0
|
|
210
|
+
|
|
211
|
+
# Metadata
|
|
212
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
213
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
214
|
+
cancelled_at: Optional[datetime] = None
|
|
215
|
+
cancellation_reason: str = ""
|
|
216
|
+
|
|
217
|
+
def to_dict(self) -> dict:
|
|
218
|
+
return {
|
|
219
|
+
"mandate_id": self.mandate_id,
|
|
220
|
+
"debtor_iban": self.debtor_iban,
|
|
221
|
+
"debtor_bic": self.debtor_bic,
|
|
222
|
+
"debtor_name": self.debtor_name,
|
|
223
|
+
"customer_id": self.customer_id,
|
|
224
|
+
"creditor_id": self.creditor_id,
|
|
225
|
+
"creditor_name": self.creditor_name,
|
|
226
|
+
"creditor_iban": self.creditor_iban,
|
|
227
|
+
"creditor_bic": self.creditor_bic,
|
|
228
|
+
"mandate_type": self.mandate_type.value,
|
|
229
|
+
"signature_date": self.signature_date.isoformat(),
|
|
230
|
+
"signature_location": self.signature_location,
|
|
231
|
+
"max_amount": str(self.max_amount) if self.max_amount else None,
|
|
232
|
+
"max_frequency": self.max_frequency,
|
|
233
|
+
"status": self.status.value,
|
|
234
|
+
"sequence_type": self.sequence_type,
|
|
235
|
+
"first_collection_date": self.first_collection_date.isoformat() if self.first_collection_date else None,
|
|
236
|
+
"last_collection_date": self.last_collection_date.isoformat() if self.last_collection_date else None,
|
|
237
|
+
"collection_count": self.collection_count,
|
|
238
|
+
"created_at": self.created_at.isoformat(),
|
|
239
|
+
"updated_at": self.updated_at.isoformat(),
|
|
240
|
+
"cancelled_at": self.cancelled_at.isoformat() if self.cancelled_at else None,
|
|
241
|
+
"cancellation_reason": self.cancellation_reason,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def can_collect(self, amount: Decimal) -> bool:
|
|
245
|
+
"""Check if a collection is allowed"""
|
|
246
|
+
if self.status != MandateStatus.ACTIVE:
|
|
247
|
+
return False
|
|
248
|
+
if self.max_amount and amount > self.max_amount:
|
|
249
|
+
return False
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def update_sequence(self):
|
|
253
|
+
"""Update sequence type after collection"""
|
|
254
|
+
self.collection_count += 1
|
|
255
|
+
if self.sequence_type == "FRST":
|
|
256
|
+
self.sequence_type = "RCUR"
|
|
257
|
+
self.last_collection_date = date.today()
|
|
258
|
+
self.updated_at = datetime.now()
|