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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Banking Models - Account, Transaction, Customer
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
import uuid
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransactionType(Enum):
|
|
15
|
+
"""Transaction types"""
|
|
16
|
+
CREDIT = "credit" # Money in
|
|
17
|
+
DEBIT = "debit" # Money out
|
|
18
|
+
TRANSFER = "transfer"
|
|
19
|
+
CREDIT_TRANSFER = "credit_transfer" # Virement
|
|
20
|
+
DIRECT_DEBIT = "direct_debit" # Prelevement
|
|
21
|
+
SEPA_CREDIT = "sepa_credit"
|
|
22
|
+
SEPA_DEBIT = "sepa_debit"
|
|
23
|
+
CARD_PAYMENT = "card_payment"
|
|
24
|
+
ATM_WITHDRAWAL = "atm_withdrawal"
|
|
25
|
+
FEE = "fee"
|
|
26
|
+
INTEREST = "interest"
|
|
27
|
+
REFUND = "refund"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AccountType(Enum):
|
|
31
|
+
"""Account types"""
|
|
32
|
+
CHECKING = "checking" # Compte courant
|
|
33
|
+
SAVINGS = "savings" # Livret d'épargne
|
|
34
|
+
BUSINESS = "business" # Compte professionnel
|
|
35
|
+
JOINT = "joint" # Compte joint
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AccountStatus(Enum):
|
|
39
|
+
"""Account status"""
|
|
40
|
+
ACTIVE = "active"
|
|
41
|
+
BLOCKED = "blocked"
|
|
42
|
+
CLOSED = "closed"
|
|
43
|
+
PENDING = "pending"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Currency(Enum):
|
|
47
|
+
"""Supported currencies"""
|
|
48
|
+
EUR = "EUR"
|
|
49
|
+
USD = "USD"
|
|
50
|
+
GBP = "GBP"
|
|
51
|
+
CHF = "CHF"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CardType(Enum):
|
|
55
|
+
"""Card types"""
|
|
56
|
+
DEBIT = "debit"
|
|
57
|
+
CREDIT = "credit"
|
|
58
|
+
PREPAID = "prepaid"
|
|
59
|
+
BUSINESS = "business"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CardStatus(Enum):
|
|
63
|
+
"""Card status"""
|
|
64
|
+
ACTIVE = "active"
|
|
65
|
+
BLOCKED = "blocked"
|
|
66
|
+
EXPIRED = "expired"
|
|
67
|
+
PENDING = "pending"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Customer:
|
|
72
|
+
"""Bank customer"""
|
|
73
|
+
customer_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
|
|
74
|
+
first_name: str = ""
|
|
75
|
+
last_name: str = ""
|
|
76
|
+
email: str = ""
|
|
77
|
+
phone: str = ""
|
|
78
|
+
address: str = ""
|
|
79
|
+
city: str = ""
|
|
80
|
+
postal_code: str = ""
|
|
81
|
+
country: str = "FR"
|
|
82
|
+
birth_date: Optional[datetime] = None
|
|
83
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def full_name(self) -> str:
|
|
87
|
+
return f"{self.first_name} {self.last_name}"
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"customer_id": self.customer_id,
|
|
92
|
+
"first_name": self.first_name,
|
|
93
|
+
"last_name": self.last_name,
|
|
94
|
+
"full_name": self.full_name,
|
|
95
|
+
"email": self.email,
|
|
96
|
+
"phone": self.phone,
|
|
97
|
+
"address": self.address,
|
|
98
|
+
"city": self.city,
|
|
99
|
+
"postal_code": self.postal_code,
|
|
100
|
+
"country": self.country,
|
|
101
|
+
"birth_date": self.birth_date.isoformat() if self.birth_date else None,
|
|
102
|
+
"created_at": self.created_at.isoformat(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Account:
|
|
108
|
+
"""Bank account"""
|
|
109
|
+
iban: str = ""
|
|
110
|
+
bic: str = "NANPFRPP" # Default BIC for NanoPy Bank
|
|
111
|
+
account_type: AccountType = AccountType.CHECKING
|
|
112
|
+
currency: Currency = Currency.EUR
|
|
113
|
+
balance: Decimal = Decimal("0.00")
|
|
114
|
+
available_balance: Decimal = Decimal("0.00")
|
|
115
|
+
overdraft_limit: Decimal = Decimal("0.00")
|
|
116
|
+
customer_id: str = ""
|
|
117
|
+
account_name: str = ""
|
|
118
|
+
status: AccountStatus = AccountStatus.ACTIVE
|
|
119
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
120
|
+
last_transaction: Optional[datetime] = None
|
|
121
|
+
|
|
122
|
+
# Card info (optional)
|
|
123
|
+
card_number: Optional[str] = None
|
|
124
|
+
card_expiry: Optional[str] = None
|
|
125
|
+
card_cvv: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
def __post_init__(self):
|
|
128
|
+
if not self.iban:
|
|
129
|
+
self.iban = self._generate_iban()
|
|
130
|
+
if not self.account_name:
|
|
131
|
+
self.account_name = f"Compte {self.account_type.value}"
|
|
132
|
+
|
|
133
|
+
def _generate_iban(self) -> str:
|
|
134
|
+
"""Generate a valid French IBAN"""
|
|
135
|
+
# Bank code (5 digits) + Branch code (5 digits) + Account number (11 digits) + Key (2 digits)
|
|
136
|
+
bank_code = "30001" # NanoPy Bank code
|
|
137
|
+
branch_code = "00001"
|
|
138
|
+
account_num = str(uuid.uuid4().int)[:11].zfill(11)
|
|
139
|
+
|
|
140
|
+
# Calculate check digits (simplified)
|
|
141
|
+
bban = f"{bank_code}{branch_code}{account_num}00"
|
|
142
|
+
# Convert letters to numbers (A=10, B=11, etc.)
|
|
143
|
+
numeric = ""
|
|
144
|
+
for char in f"{bban}FR00":
|
|
145
|
+
if char.isalpha():
|
|
146
|
+
numeric += str(ord(char.upper()) - 55)
|
|
147
|
+
else:
|
|
148
|
+
numeric += char
|
|
149
|
+
|
|
150
|
+
check = 98 - (int(numeric) % 97)
|
|
151
|
+
return f"FR{check:02d}{bank_code}{branch_code}{account_num}00"
|
|
152
|
+
|
|
153
|
+
def can_debit(self, amount: Decimal) -> bool:
|
|
154
|
+
"""Check if account can be debited"""
|
|
155
|
+
return self.status == AccountStatus.ACTIVE and \
|
|
156
|
+
(self.available_balance + self.overdraft_limit) >= amount
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict:
|
|
159
|
+
return {
|
|
160
|
+
"iban": self.iban,
|
|
161
|
+
"iban_formatted": self.format_iban(),
|
|
162
|
+
"bic": self.bic,
|
|
163
|
+
"account_type": self.account_type.value,
|
|
164
|
+
"currency": self.currency.value,
|
|
165
|
+
"balance": str(self.balance),
|
|
166
|
+
"available_balance": str(self.available_balance),
|
|
167
|
+
"overdraft_limit": str(self.overdraft_limit),
|
|
168
|
+
"customer_id": self.customer_id,
|
|
169
|
+
"account_name": self.account_name,
|
|
170
|
+
"status": self.status.value,
|
|
171
|
+
"created_at": self.created_at.isoformat(),
|
|
172
|
+
"last_transaction": self.last_transaction.isoformat() if self.last_transaction else None,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def format_iban(self) -> str:
|
|
176
|
+
"""Format IBAN with spaces every 4 characters"""
|
|
177
|
+
return " ".join([self.iban[i:i+4] for i in range(0, len(self.iban), 4)])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class Transaction:
|
|
182
|
+
"""Bank transaction"""
|
|
183
|
+
transaction_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
184
|
+
reference: str = field(default_factory=lambda: f"TXN{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:6].upper()}")
|
|
185
|
+
transaction_type: TransactionType = TransactionType.CREDIT
|
|
186
|
+
amount: Decimal = Decimal("0.00")
|
|
187
|
+
currency: Currency = Currency.EUR
|
|
188
|
+
|
|
189
|
+
# Accounts
|
|
190
|
+
from_iban: Optional[str] = None
|
|
191
|
+
to_iban: Optional[str] = None
|
|
192
|
+
account_iban: str = "" # The account this transaction belongs to
|
|
193
|
+
|
|
194
|
+
# Details
|
|
195
|
+
label: str = ""
|
|
196
|
+
description: str = ""
|
|
197
|
+
category: str = ""
|
|
198
|
+
|
|
199
|
+
# Counterparty
|
|
200
|
+
counterparty_name: str = ""
|
|
201
|
+
counterparty_iban: Optional[str] = None
|
|
202
|
+
counterparty_bic: Optional[str] = None
|
|
203
|
+
|
|
204
|
+
# Metadata
|
|
205
|
+
status: str = "completed" # pending, completed, failed, cancelled
|
|
206
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
207
|
+
executed_at: Optional[datetime] = None
|
|
208
|
+
value_date: Optional[datetime] = None
|
|
209
|
+
|
|
210
|
+
# SEPA specific
|
|
211
|
+
end_to_end_id: Optional[str] = None
|
|
212
|
+
mandate_id: Optional[str] = None
|
|
213
|
+
|
|
214
|
+
# Balance after transaction
|
|
215
|
+
balance_after: Optional[Decimal] = None
|
|
216
|
+
|
|
217
|
+
def __post_init__(self):
|
|
218
|
+
if not self.end_to_end_id:
|
|
219
|
+
self.end_to_end_id = self.reference
|
|
220
|
+
if not self.executed_at:
|
|
221
|
+
self.executed_at = self.created_at
|
|
222
|
+
if not self.value_date:
|
|
223
|
+
self.value_date = self.created_at
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def is_credit(self) -> bool:
|
|
227
|
+
return self.transaction_type in [
|
|
228
|
+
TransactionType.CREDIT,
|
|
229
|
+
TransactionType.SEPA_CREDIT,
|
|
230
|
+
TransactionType.REFUND,
|
|
231
|
+
TransactionType.INTEREST,
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def is_debit(self) -> bool:
|
|
236
|
+
return not self.is_credit
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def signed_amount(self) -> Decimal:
|
|
240
|
+
"""Return amount with sign (positive for credit, negative for debit)"""
|
|
241
|
+
return self.amount if self.is_credit else -self.amount
|
|
242
|
+
|
|
243
|
+
def to_dict(self) -> dict:
|
|
244
|
+
return {
|
|
245
|
+
"transaction_id": self.transaction_id,
|
|
246
|
+
"reference": self.reference,
|
|
247
|
+
"transaction_type": self.transaction_type.value,
|
|
248
|
+
"amount": str(self.amount),
|
|
249
|
+
"signed_amount": str(self.signed_amount),
|
|
250
|
+
"currency": self.currency.value,
|
|
251
|
+
"from_iban": self.from_iban,
|
|
252
|
+
"to_iban": self.to_iban,
|
|
253
|
+
"account_iban": self.account_iban,
|
|
254
|
+
"label": self.label,
|
|
255
|
+
"description": self.description,
|
|
256
|
+
"category": self.category,
|
|
257
|
+
"counterparty_name": self.counterparty_name,
|
|
258
|
+
"counterparty_iban": self.counterparty_iban,
|
|
259
|
+
"counterparty_bic": self.counterparty_bic,
|
|
260
|
+
"status": self.status,
|
|
261
|
+
"created_at": self.created_at.isoformat(),
|
|
262
|
+
"executed_at": self.executed_at.isoformat() if self.executed_at else None,
|
|
263
|
+
"value_date": self.value_date.isoformat() if self.value_date else None,
|
|
264
|
+
"end_to_end_id": self.end_to_end_id,
|
|
265
|
+
"mandate_id": self.mandate_id,
|
|
266
|
+
"balance_after": str(self.balance_after) if self.balance_after else None,
|
|
267
|
+
"is_credit": self.is_credit,
|
|
268
|
+
"is_debit": self.is_debit,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class Card:
|
|
274
|
+
"""Bank card"""
|
|
275
|
+
card_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
276
|
+
card_number: str = "" # Last 4 digits only for display
|
|
277
|
+
card_type: str = "visa" # visa, mastercard
|
|
278
|
+
expiry_month: int = 12
|
|
279
|
+
expiry_year: int = 2027
|
|
280
|
+
status: str = "active" # active, blocked, expired
|
|
281
|
+
account_iban: str = ""
|
|
282
|
+
daily_limit: Decimal = Decimal("1000.00")
|
|
283
|
+
monthly_limit: Decimal = Decimal("5000.00")
|
|
284
|
+
contactless_enabled: bool = True
|
|
285
|
+
online_payments_enabled: bool = True
|
|
286
|
+
|
|
287
|
+
def __post_init__(self):
|
|
288
|
+
if not self.card_number:
|
|
289
|
+
# Generate last 4 digits
|
|
290
|
+
self.card_number = f"**** **** **** {uuid.uuid4().int % 10000:04d}"
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def expiry(self) -> str:
|
|
294
|
+
return f"{self.expiry_month:02d}/{self.expiry_year % 100:02d}"
|
|
295
|
+
|
|
296
|
+
def to_dict(self) -> dict:
|
|
297
|
+
return {
|
|
298
|
+
"card_id": self.card_id,
|
|
299
|
+
"card_number": self.card_number,
|
|
300
|
+
"card_type": self.card_type,
|
|
301
|
+
"expiry": self.expiry,
|
|
302
|
+
"status": self.status,
|
|
303
|
+
"account_iban": self.account_iban,
|
|
304
|
+
"daily_limit": str(self.daily_limit),
|
|
305
|
+
"monthly_limit": str(self.monthly_limit),
|
|
306
|
+
"contactless_enabled": self.contactless_enabled,
|
|
307
|
+
"online_payments_enabled": self.online_payments_enabled,
|
|
308
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Banking Products - Loans, Credits, Insurance, Savings
|
|
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 LoanType(Enum):
|
|
14
|
+
"""Types of loans"""
|
|
15
|
+
PERSONAL = "personal" # Prêt personnel
|
|
16
|
+
MORTGAGE = "mortgage" # Prêt immobilier
|
|
17
|
+
AUTO = "auto" # Crédit auto
|
|
18
|
+
STUDENT = "student" # Prêt étudiant
|
|
19
|
+
BUSINESS = "business" # Prêt professionnel
|
|
20
|
+
REVOLVING = "revolving" # Crédit renouvelable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoanStatus(Enum):
|
|
24
|
+
"""Loan status"""
|
|
25
|
+
PENDING = "pending" # En attente d'approbation
|
|
26
|
+
APPROVED = "approved" # Approuvé
|
|
27
|
+
ACTIVE = "active" # En cours
|
|
28
|
+
LATE = "late" # En retard
|
|
29
|
+
DEFAULT = "default" # Défaut de paiement
|
|
30
|
+
PAID_OFF = "paid_off" # Remboursé
|
|
31
|
+
CANCELLED = "cancelled" # Annulé
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InsuranceType(Enum):
|
|
35
|
+
"""Types of insurance"""
|
|
36
|
+
LIFE = "life" # Assurance vie
|
|
37
|
+
HOME = "home" # Assurance habitation
|
|
38
|
+
AUTO = "auto" # Assurance auto
|
|
39
|
+
HEALTH = "health" # Mutuelle santé
|
|
40
|
+
CREDIT = "credit" # Assurance emprunteur
|
|
41
|
+
SAVINGS = "savings" # Assurance épargne
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SavingsType(Enum):
|
|
45
|
+
"""Types of savings products"""
|
|
46
|
+
LIVRET_A = "livret_a" # Livret A
|
|
47
|
+
LDDS = "ldds" # Livret Développement Durable
|
|
48
|
+
LEP = "lep" # Livret d'Épargne Populaire
|
|
49
|
+
PEL = "pel" # Plan Épargne Logement
|
|
50
|
+
CEL = "cel" # Compte Épargne Logement
|
|
51
|
+
TERM_DEPOSIT = "term_deposit" # Dépôt à terme
|
|
52
|
+
PEA = "pea" # Plan d'Épargne en Actions
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Loan:
|
|
57
|
+
"""
|
|
58
|
+
Loan / Credit product
|
|
59
|
+
"""
|
|
60
|
+
loan_id: str = field(default_factory=lambda: f"LN{uuid.uuid4().hex[:10].upper()}")
|
|
61
|
+
customer_id: str = ""
|
|
62
|
+
account_iban: str = "" # Account for repayments
|
|
63
|
+
|
|
64
|
+
# Loan details
|
|
65
|
+
loan_type: LoanType = LoanType.PERSONAL
|
|
66
|
+
purpose: str = "" # Description of loan purpose
|
|
67
|
+
|
|
68
|
+
# Amounts
|
|
69
|
+
principal: Decimal = Decimal("0.00") # Montant emprunté
|
|
70
|
+
interest_rate: Decimal = Decimal("0.00") # Taux annuel (ex: 3.5 = 3.5%)
|
|
71
|
+
total_interest: Decimal = Decimal("0.00") # Total des intérêts
|
|
72
|
+
total_amount: Decimal = Decimal("0.00") # Principal + intérêts
|
|
73
|
+
|
|
74
|
+
# Current state
|
|
75
|
+
remaining_principal: Decimal = Decimal("0.00")
|
|
76
|
+
remaining_interest: Decimal = Decimal("0.00")
|
|
77
|
+
paid_principal: Decimal = Decimal("0.00")
|
|
78
|
+
paid_interest: Decimal = Decimal("0.00")
|
|
79
|
+
|
|
80
|
+
# Schedule
|
|
81
|
+
duration_months: int = 12
|
|
82
|
+
monthly_payment: Decimal = Decimal("0.00")
|
|
83
|
+
start_date: Optional[date] = None
|
|
84
|
+
end_date: Optional[date] = None
|
|
85
|
+
next_payment_date: Optional[date] = None
|
|
86
|
+
|
|
87
|
+
# Status
|
|
88
|
+
status: LoanStatus = LoanStatus.PENDING
|
|
89
|
+
payments_made: int = 0
|
|
90
|
+
payments_late: int = 0
|
|
91
|
+
days_overdue: int = 0
|
|
92
|
+
|
|
93
|
+
# Insurance
|
|
94
|
+
has_insurance: bool = False
|
|
95
|
+
insurance_premium: Decimal = Decimal("0.00")
|
|
96
|
+
|
|
97
|
+
# Collateral (for secured loans)
|
|
98
|
+
collateral_type: str = ""
|
|
99
|
+
collateral_value: Decimal = Decimal("0.00")
|
|
100
|
+
|
|
101
|
+
# Metadata
|
|
102
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
103
|
+
approved_at: Optional[datetime] = None
|
|
104
|
+
approved_by: str = ""
|
|
105
|
+
|
|
106
|
+
def __post_init__(self):
|
|
107
|
+
if self.principal > 0 and self.remaining_principal == 0:
|
|
108
|
+
self.remaining_principal = self.principal
|
|
109
|
+
self._calculate_loan()
|
|
110
|
+
|
|
111
|
+
def _calculate_loan(self):
|
|
112
|
+
"""Calculate loan schedule"""
|
|
113
|
+
if self.principal <= 0 or self.duration_months <= 0:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Monthly interest rate
|
|
117
|
+
monthly_rate = self.interest_rate / 100 / 12
|
|
118
|
+
|
|
119
|
+
if monthly_rate > 0:
|
|
120
|
+
# Amortization formula
|
|
121
|
+
self.monthly_payment = self.principal * (
|
|
122
|
+
monthly_rate * (1 + monthly_rate) ** self.duration_months
|
|
123
|
+
) / ((1 + monthly_rate) ** self.duration_months - 1)
|
|
124
|
+
else:
|
|
125
|
+
self.monthly_payment = self.principal / self.duration_months
|
|
126
|
+
|
|
127
|
+
self.total_amount = self.monthly_payment * self.duration_months
|
|
128
|
+
self.total_interest = self.total_amount - self.principal
|
|
129
|
+
self.remaining_interest = self.total_interest
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict:
|
|
132
|
+
return {
|
|
133
|
+
"loan_id": self.loan_id,
|
|
134
|
+
"customer_id": self.customer_id,
|
|
135
|
+
"account_iban": self.account_iban,
|
|
136
|
+
"loan_type": self.loan_type.value,
|
|
137
|
+
"purpose": self.purpose,
|
|
138
|
+
"principal": str(self.principal),
|
|
139
|
+
"interest_rate": str(self.interest_rate),
|
|
140
|
+
"total_interest": str(self.total_interest),
|
|
141
|
+
"total_amount": str(self.total_amount),
|
|
142
|
+
"remaining_principal": str(self.remaining_principal),
|
|
143
|
+
"remaining_interest": str(self.remaining_interest),
|
|
144
|
+
"paid_principal": str(self.paid_principal),
|
|
145
|
+
"paid_interest": str(self.paid_interest),
|
|
146
|
+
"duration_months": self.duration_months,
|
|
147
|
+
"monthly_payment": str(self.monthly_payment),
|
|
148
|
+
"start_date": self.start_date.isoformat() if self.start_date else None,
|
|
149
|
+
"end_date": self.end_date.isoformat() if self.end_date else None,
|
|
150
|
+
"next_payment_date": self.next_payment_date.isoformat() if self.next_payment_date else None,
|
|
151
|
+
"status": self.status.value,
|
|
152
|
+
"payments_made": self.payments_made,
|
|
153
|
+
"payments_late": self.payments_late,
|
|
154
|
+
"days_overdue": self.days_overdue,
|
|
155
|
+
"has_insurance": self.has_insurance,
|
|
156
|
+
"insurance_premium": str(self.insurance_premium),
|
|
157
|
+
"collateral_type": self.collateral_type,
|
|
158
|
+
"collateral_value": str(self.collateral_value),
|
|
159
|
+
"created_at": self.created_at.isoformat(),
|
|
160
|
+
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
|
|
161
|
+
"approved_by": self.approved_by,
|
|
162
|
+
"progress_percent": self.progress_percent,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def progress_percent(self) -> float:
|
|
167
|
+
"""Percentage of loan paid off"""
|
|
168
|
+
if self.total_amount <= 0:
|
|
169
|
+
return 0
|
|
170
|
+
paid = self.paid_principal + self.paid_interest
|
|
171
|
+
return min(100, float(paid / self.total_amount * 100))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class Insurance:
|
|
176
|
+
"""
|
|
177
|
+
Insurance product
|
|
178
|
+
"""
|
|
179
|
+
insurance_id: str = field(default_factory=lambda: f"INS{uuid.uuid4().hex[:10].upper()}")
|
|
180
|
+
customer_id: str = ""
|
|
181
|
+
policy_number: str = field(default_factory=lambda: f"POL{uuid.uuid4().hex[:8].upper()}")
|
|
182
|
+
|
|
183
|
+
# Insurance details
|
|
184
|
+
insurance_type: InsuranceType = InsuranceType.HOME
|
|
185
|
+
provider: str = "NanoPy Assurance"
|
|
186
|
+
product_name: str = ""
|
|
187
|
+
|
|
188
|
+
# Coverage
|
|
189
|
+
coverage_amount: Decimal = Decimal("0.00")
|
|
190
|
+
deductible: Decimal = Decimal("0.00") # Franchise
|
|
191
|
+
coverage_details: str = ""
|
|
192
|
+
|
|
193
|
+
# Premium
|
|
194
|
+
premium_amount: Decimal = Decimal("0.00")
|
|
195
|
+
premium_frequency: str = "monthly" # monthly, quarterly, yearly
|
|
196
|
+
account_iban: str = "" # For premium payments
|
|
197
|
+
|
|
198
|
+
# Dates
|
|
199
|
+
start_date: date = field(default_factory=date.today)
|
|
200
|
+
end_date: Optional[date] = None
|
|
201
|
+
next_payment_date: Optional[date] = None
|
|
202
|
+
|
|
203
|
+
# Status
|
|
204
|
+
status: str = "active" # active, suspended, cancelled, expired
|
|
205
|
+
is_auto_renew: bool = True
|
|
206
|
+
|
|
207
|
+
# Claims
|
|
208
|
+
claims_count: int = 0
|
|
209
|
+
total_claimed: Decimal = Decimal("0.00")
|
|
210
|
+
|
|
211
|
+
# Metadata
|
|
212
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> dict:
|
|
215
|
+
return {
|
|
216
|
+
"insurance_id": self.insurance_id,
|
|
217
|
+
"customer_id": self.customer_id,
|
|
218
|
+
"policy_number": self.policy_number,
|
|
219
|
+
"insurance_type": self.insurance_type.value,
|
|
220
|
+
"provider": self.provider,
|
|
221
|
+
"product_name": self.product_name,
|
|
222
|
+
"coverage_amount": str(self.coverage_amount),
|
|
223
|
+
"deductible": str(self.deductible),
|
|
224
|
+
"coverage_details": self.coverage_details,
|
|
225
|
+
"premium_amount": str(self.premium_amount),
|
|
226
|
+
"premium_frequency": self.premium_frequency,
|
|
227
|
+
"account_iban": self.account_iban,
|
|
228
|
+
"start_date": self.start_date.isoformat(),
|
|
229
|
+
"end_date": self.end_date.isoformat() if self.end_date else None,
|
|
230
|
+
"next_payment_date": self.next_payment_date.isoformat() if self.next_payment_date else None,
|
|
231
|
+
"status": self.status,
|
|
232
|
+
"is_auto_renew": self.is_auto_renew,
|
|
233
|
+
"claims_count": self.claims_count,
|
|
234
|
+
"total_claimed": str(self.total_claimed),
|
|
235
|
+
"created_at": self.created_at.isoformat(),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class SavingsProduct:
|
|
241
|
+
"""
|
|
242
|
+
Savings product (Livret A, PEL, etc.)
|
|
243
|
+
"""
|
|
244
|
+
savings_id: str = field(default_factory=lambda: f"SAV{uuid.uuid4().hex[:10].upper()}")
|
|
245
|
+
customer_id: str = ""
|
|
246
|
+
account_iban: str = "" # The savings account
|
|
247
|
+
|
|
248
|
+
# Product details
|
|
249
|
+
savings_type: SavingsType = SavingsType.LIVRET_A
|
|
250
|
+
product_name: str = ""
|
|
251
|
+
|
|
252
|
+
# Interest
|
|
253
|
+
interest_rate: Decimal = Decimal("3.00") # Current rate
|
|
254
|
+
interest_frequency: str = "yearly" # When interest is paid
|
|
255
|
+
interest_earned: Decimal = Decimal("0.00")
|
|
256
|
+
last_interest_date: Optional[date] = None
|
|
257
|
+
|
|
258
|
+
# Limits
|
|
259
|
+
min_balance: Decimal = Decimal("0.00")
|
|
260
|
+
max_balance: Decimal = Decimal("22950.00") # Livret A ceiling
|
|
261
|
+
min_deposit: Decimal = Decimal("10.00")
|
|
262
|
+
max_withdrawal_per_month: Optional[Decimal] = None
|
|
263
|
+
|
|
264
|
+
# Tax
|
|
265
|
+
is_tax_exempt: bool = True # Livret A, LDDS are tax-free
|
|
266
|
+
tax_rate: Decimal = Decimal("0.00") # Flat tax rate if applicable
|
|
267
|
+
|
|
268
|
+
# Status
|
|
269
|
+
status: str = "active"
|
|
270
|
+
opened_date: date = field(default_factory=date.today)
|
|
271
|
+
closed_date: Optional[date] = None
|
|
272
|
+
|
|
273
|
+
# Metadata
|
|
274
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
275
|
+
|
|
276
|
+
def to_dict(self) -> dict:
|
|
277
|
+
return {
|
|
278
|
+
"savings_id": self.savings_id,
|
|
279
|
+
"customer_id": self.customer_id,
|
|
280
|
+
"account_iban": self.account_iban,
|
|
281
|
+
"savings_type": self.savings_type.value,
|
|
282
|
+
"product_name": self.product_name,
|
|
283
|
+
"interest_rate": str(self.interest_rate),
|
|
284
|
+
"interest_frequency": self.interest_frequency,
|
|
285
|
+
"interest_earned": str(self.interest_earned),
|
|
286
|
+
"last_interest_date": self.last_interest_date.isoformat() if self.last_interest_date else None,
|
|
287
|
+
"min_balance": str(self.min_balance),
|
|
288
|
+
"max_balance": str(self.max_balance),
|
|
289
|
+
"min_deposit": str(self.min_deposit),
|
|
290
|
+
"max_withdrawal_per_month": str(self.max_withdrawal_per_month) if self.max_withdrawal_per_month else None,
|
|
291
|
+
"is_tax_exempt": self.is_tax_exempt,
|
|
292
|
+
"tax_rate": str(self.tax_rate),
|
|
293
|
+
"status": self.status,
|
|
294
|
+
"opened_date": self.opened_date.isoformat(),
|
|
295
|
+
"closed_date": self.closed_date.isoformat() if self.closed_date else None,
|
|
296
|
+
"created_at": self.created_at.isoformat(),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# Note: Demo savings products are in nanopy_bank/data/demo.py
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data module - Test and demo data
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .demo import (
|
|
6
|
+
create_demo_bank,
|
|
7
|
+
DEMO_CUSTOMERS,
|
|
8
|
+
DEMO_ACCOUNTS,
|
|
9
|
+
DEMO_TRANSACTIONS,
|
|
10
|
+
DEMO_CARDS,
|
|
11
|
+
DEMO_BENEFICIARIES,
|
|
12
|
+
DEMO_STANDING_ORDERS,
|
|
13
|
+
DEMO_FEES,
|
|
14
|
+
DEMO_RATES,
|
|
15
|
+
DEMO_BRANCHES,
|
|
16
|
+
DEMO_EMPLOYEES,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"create_demo_bank",
|
|
21
|
+
"DEMO_CUSTOMERS",
|
|
22
|
+
"DEMO_ACCOUNTS",
|
|
23
|
+
"DEMO_TRANSACTIONS",
|
|
24
|
+
"DEMO_CARDS",
|
|
25
|
+
"DEMO_BENEFICIARIES",
|
|
26
|
+
"DEMO_STANDING_ORDERS",
|
|
27
|
+
"DEMO_FEES",
|
|
28
|
+
"DEMO_RATES",
|
|
29
|
+
"DEMO_BRANCHES",
|
|
30
|
+
"DEMO_EMPLOYEES",
|
|
31
|
+
]
|