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,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()