| | 1 | = Финална имплементација на случаи на употреба |
| | 2 | |
| | 3 | == Трансфер на средства со email известување |
| | 4 | |
| | 5 | **User-to-User трансфер** |
| | 6 | |
| | 7 | Најавен корисник се навигира кон страницата за трансфер на средства и внесува email адреса на примачот. |
| | 8 | |
| | 9 | **Backend имплементација - views.py** |
| | 10 | |
| | 11 | При испраќање на трансфер преку POST барање, најпрво се валидира изворната сметка и балансот. Потоа се проверува дали примачот постои во системот. |
| | 12 | |
| | 13 | |
| | 14 | {{{ |
| | 15 | |
| | 16 | @api_view(['POST']) |
| | 17 | @permission_classes([IsAuthenticated]) |
| | 18 | def transfer_funds(request): |
| | 19 | data = request.data |
| | 20 | source_account_id = data.get('source_account_id') |
| | 21 | recipient_email = data.get('recipient_email') |
| | 22 | amount = Decimal(str(data.get('amount', 0))) |
| | 23 | note = data.get('note', '') |
| | 24 | |
| | 25 | # Validate source account |
| | 26 | source_account = Account.objects.get(id=source_account_id, user=request.user) |
| | 27 | |
| | 28 | # Check balance |
| | 29 | if source_account.balance < amount: |
| | 30 | return Response({'error': 'Insufficient balance'}) |
| | 31 | |
| | 32 | }}} |
| | 33 | |
| | 34 | По успешна валидација, се креираат две трансакции - една за испраќачот (debit) и една за примачот (credit): |
| | 35 | |
| | 36 | |
| | 37 | {{{ |
| | 38 | |
| | 39 | with transaction.atomic(): |
| | 40 | Transaction.objects.create( |
| | 41 | account=source_account, |
| | 42 | transaction_type='debit', |
| | 43 | amount=amount, |
| | 44 | description=f'Sent to {recipient_user.first_name}' |
| | 45 | ) |
| | 46 | |
| | 47 | Transaction.objects.create( |
| | 48 | account=recipient_account, |
| | 49 | transaction_type='credit', |
| | 50 | amount=amount, |
| | 51 | description=f'Received from {request.user.first_name}' |
| | 52 | ) |
| | 53 | |
| | 54 | }}} |
| | 55 | |
| | 56 | **Email известување |
| | 57 | ** |
| | 58 | |
| | 59 | По завршување на трансферот, се праќаат два email-и преку функцијата send_transfer_email: |
| | 60 | |
| | 61 | |
| | 62 | {{{ |
| | 63 | def send_transfer_email(user, transfer_type, amount, recipient_name, |
| | 64 | reference_number, new_balance, is_sender=True, note=None): |
| | 65 | if is_sender: |
| | 66 | subject = f'Banker - Transfer Sent: ${amount}' |
| | 67 | else: |
| | 68 | subject = f'Banker - Payment Received: ${amount}' |
| | 69 | |
| | 70 | send_mail( |
| | 71 | subject=subject, |
| | 72 | message=message, |
| | 73 | from_email=settings.DEFAULT_FROM_EMAIL, |
| | 74 | recipient_list=[user.email], |
| | 75 | html_message=html_message |
| | 76 | ) |
| | 77 | |
| | 78 | }}} |
| | 79 | |
| | 80 | Email-от содржи: сума, име на примач/испраќач, референтен број, датум и време, нов баланс, и опционална порака (note) доколку е внесена при трансферот. |
| | 81 | |
| | 82 | **Internal трансфер (меѓу свои сметки)** |
| | 83 | |
| | 84 | Корисникот може да пренесе средства меѓу своите сметки. |
| | 85 | |
| | 86 | |
| | 87 | При интерен трансфер, корисникот добива email со детали за двете сметки: |
| | 88 | |
| | 89 | {{{ |
| | 90 | def send_internal_transfer_email(user, amount, from_account, to_account, |
| | 91 | reference_number, from_balance, to_balance): |
| | 92 | subject = f'Banker - Internal Transfer Completed: ${amount}' |
| | 93 | |
| | 94 | # HTML email содржи табела со: |
| | 95 | # - Amount |
| | 96 | # - From Account (име на изворна сметка) |
| | 97 | # - To Account (име на дестинациска сметка) |
| | 98 | # - Updated Balances за двете сметки |
| | 99 | }}} |
| | 100 | |
| | 101 | == Најава преку Google OAuth |
| | 102 | |
| | 103 | **Ненајавен корисник** |
| | 104 | |
| | 105 | Ненајавениот корисник се навигира кон страницата за најава. |
| | 106 | |
| | 107 | |
| | 108 | Корисникот го кликнува копчето за најава преку Google профил и го селектира посакуваниот профил. |
| | 109 | |
| | 110 | |
| | 111 | По успешна најава, корисникот е пренасочен кон главната страница. |
| | 112 | |
| | 113 | **Frontend имплементација - OAuthButtons.tsx** |
| | 114 | |
| | 115 | Во frontend делот се иницијализира Google Sign-In SDK и се рендерира официјалното Google копче: |
| | 116 | |
| | 117 | {{{ |
| | 118 | useEffect(() => { |
| | 119 | if (googleLoaded && window.google) { |
| | 120 | window.google.accounts.id.initialize({ |
| | 121 | client_id: GOOGLE_CLIENT_ID, |
| | 122 | callback: handleGoogleCallback, |
| | 123 | ux_mode: 'popup', |
| | 124 | }); |
| | 125 | |
| | 126 | window.google.accounts.id.renderButton( |
| | 127 | googleButtonRef.current, |
| | 128 | { theme: 'outline', size: 'large', text: 'signin_with' } |
| | 129 | ); |
| | 130 | } |
| | 131 | }, [googleLoaded]); |
| | 132 | }}} |
| | 133 | |
| | 134 | По успешна автентикација, credential-от се праќа до backend-от: |
| | 135 | |
| | 136 | {{{ |
| | 137 | const handleGoogleCallback = async (response) => { |
| | 138 | const backendResponse = await fetch( |
| | 139 | 'http://localhost:8000/api/auth/google/', |
| | 140 | { |
| | 141 | method: 'POST', |
| | 142 | headers: { 'Content-Type': 'application/json' }, |
| | 143 | body: JSON.stringify({ credential: response.credential }) |
| | 144 | } |
| | 145 | ); |
| | 146 | |
| | 147 | const data = await backendResponse.json(); |
| | 148 | localStorage.setItem('access_token', data.access); |
| | 149 | localStorage.setItem('user', JSON.stringify(data.user)); |
| | 150 | }; |
| | 151 | }}} |
| | 152 | |
| | 153 | **Backend имплементација - oauth_views.py** |
| | 154 | |
| | 155 | Во backend-от се верифицира Google credential преку Google tokeninfo endpoint: |
| | 156 | |
| | 157 | {{{ |
| | 158 | @api_view(['POST']) |
| | 159 | @permission_classes([AllowAny]) |
| | 160 | def google_login(request): |
| | 161 | credential = request.data.get('credential') |
| | 162 | |
| | 163 | # Verify token with Google |
| | 164 | google_url = f'https://oauth2.googleapis.com/tokeninfo?id_token={credential}' |
| | 165 | response = requests.get(google_url) |
| | 166 | google_data = response.json() |
| | 167 | |
| | 168 | # Verify audience matches our app |
| | 169 | if google_data.get('aud') != settings.GOOGLE_CLIENT_ID: |
| | 170 | return Response({'error': 'Token not for this app'}) |
| | 171 | |
| | 172 | email = google_data.get('email') |
| | 173 | first_name = google_data.get('given_name', '') |
| | 174 | last_name = google_data.get('family_name', '') |
| | 175 | }}} |
| | 176 | |
| | 177 | Доколку корисникот не постои, се креира нов: |
| | 178 | |
| | 179 | {{{ |
| | 180 | user, created = User.objects.get_or_create( |
| | 181 | email=email, |
| | 182 | defaults={ |
| | 183 | 'username': email, |
| | 184 | 'first_name': first_name, |
| | 185 | 'last_name': last_name, |
| | 186 | } |
| | 187 | ) |
| | 188 | |
| | 189 | # Generate JWT tokens |
| | 190 | refresh = RefreshToken.for_user(user) |
| | 191 | |
| | 192 | # Send login notification email |
| | 193 | send_login_notification(user, 'Google Sign-in') |
| | 194 | }}} |
| | 195 | |
| | 196 | **Email известување при најава** |
| | 197 | |
| | 198 | При секоја успешна најава преку Google, корисникот добива security email: |
| | 199 | |
| | 200 | {{{ |
| | 201 | def send_login_notification(user, login_method): |
| | 202 | subject = 'Banker - New Sign-in to Your Account' |
| | 203 | |
| | 204 | # Email содржи: |
| | 205 | # - Account email |
| | 206 | # - Sign-in Method (Google Sign-in) |
| | 207 | # - Date & Time |
| | 208 | # - Security warning доколку не е корисникот |
| | 209 | }}} |
| | 210 | |
| | 211 | == Support страница со email известување |
| | 212 | |
| | 213 | **Корисник испраќа support барање** |
| | 214 | |
| | 215 | Најавениот корисник се навигира кон Support страницата. |
| | 216 | |
| | 217 | Корисникот пополнува форма со категорија, приоритет, предмет и порака. |
| | 218 | |
| | 219 | По испраќање, корисникот добива потврда со ticket ID. |
| | 220 | |
| | 221 | **Frontend имплементација - support/page.tsx** |
| | 222 | |
| | 223 | Support формата содржи селекција за категорија и приоритет, како и полиња за предмет и порака: |
| | 224 | |
| | 225 | {{{ |
| | 226 | const categories = [ |
| | 227 | { value: 'general', label: 'General Inquiry', icon: HelpCircle }, |
| | 228 | { value: 'bug', label: 'Bug Report', icon: Bug }, |
| | 229 | { value: 'feature', label: 'Feature Request', icon: Lightbulb }, |
| | 230 | { value: 'billing', label: 'Billing Issue', icon: CreditCard }, |
| | 231 | ]; |
| | 232 | |
| | 233 | const priorities = [ |
| | 234 | { value: 'low', label: 'Low', color: 'bg-gray-100' }, |
| | 235 | { value: 'normal', label: 'Normal', color: 'bg-blue-100' }, |
| | 236 | { value: 'high', label: 'High', color: 'bg-orange-100' }, |
| | 237 | { value: 'urgent', label: 'Urgent', color: 'bg-red-100' }, |
| | 238 | ]; |
| | 239 | }}} |
| | 240 | |
| | 241 | При submit, се праќа POST барање до backend-от: |
| | 242 | |
| | 243 | {{{ |
| | 244 | const handleSubmit = async (e) => { |
| | 245 | const response = await fetch('http://localhost:8000/api/banking/support/', { |
| | 246 | method: 'POST', |
| | 247 | headers: { |
| | 248 | 'Authorization': `Bearer ${accessToken}`, |
| | 249 | 'Content-Type': 'application/json', |
| | 250 | }, |
| | 251 | body: JSON.stringify({ subject, message, category, priority }), |
| | 252 | }); |
| | 253 | }; |
| | 254 | }}} |
| | 255 | |
| | 256 | **Backend имплементација - contact_views.py** |
| | 257 | |
| | 258 | Backend-от го обработува support барањето и праќа два email-и: |
| | 259 | |
| | 260 | {{{ |
| | 261 | @api_view(['POST']) |
| | 262 | @permission_classes([IsAuthenticated]) |
| | 263 | def send_support_message(request): |
| | 264 | data = request.data |
| | 265 | user = request.user |
| | 266 | |
| | 267 | subject = data.get('subject', 'Support Request') |
| | 268 | message = data.get('message') |
| | 269 | category = data.get('category', 'general') |
| | 270 | priority = data.get('priority', 'normal') |
| | 271 | |
| | 272 | # Generate unique ticket ID |
| | 273 | ticket_id = f'TKT-{user.id}-{int(time.time())}' |
| | 274 | }}} |
| | 275 | |
| | 276 | |
| | 277 | {{{ |
| | 278 | Се праќаат два email-и: |
| | 279 | |
| | 280 | # Email 1: To support team |
| | 281 | send_mail( |
| | 282 | subject=f'[Banker Support - {priority.upper()}] {subject}', |
| | 283 | message=support_email_body, |
| | 284 | from_email=settings.DEFAULT_FROM_EMAIL, |
| | 285 | recipient_list=[settings.SUPPORT_EMAIL], |
| | 286 | ) |
| | 287 | |
| | 288 | # Email 2: Confirmation to user |
| | 289 | send_mail( |
| | 290 | subject=f'[Banker] Support Request Received - {ticket_id}', |
| | 291 | message=user_confirmation, |
| | 292 | from_email=settings.DEFAULT_FROM_EMAIL, |
| | 293 | recipient_list=[user.email], |
| | 294 | ) |
| | 295 | }}} |
| | 296 | |
| | 297 | == Бришење на банкарска сметка |
| | 298 | |
| | 299 | **Корисник брише сметка** |
| | 300 | |
| | 301 | Најавениот корисник се навигира кон My Banks страницата. |
| | 302 | |
| | 303 | Корисникот кликнува на копчето за бришење на сметка. |
| | 304 | |
| | 305 | Системот прво проверува дали сметката има баланс - доколку има, не дозволува бришење. |
| | 306 | |
| | 307 | **Backend имплементација - views.py** |
| | 308 | |
| | 309 | {{{ |
| | 310 | @api_view(['DELETE']) |
| | 311 | @permission_classes([IsAuthenticated]) |
| | 312 | def delete_account(request, account_id): |
| | 313 | account = Account.objects.get(id=account_id, user=request.user) |
| | 314 | |
| | 315 | # Check if account has balance |
| | 316 | if account.balance > 0: |
| | 317 | return Response({ |
| | 318 | 'error': f'Cannot delete account with balance. Current: ${account.balance}' |
| | 319 | }, status=status.HTTP_400_BAD_REQUEST) |
| | 320 | |
| | 321 | # Delete all transactions for this account |
| | 322 | Transaction.objects.filter(account=account).delete() |
| | 323 | |
| | 324 | # Delete the account |
| | 325 | account_name = f'{account.bank.name} {account.account_type.title()}' |
| | 326 | account.delete() |
| | 327 | |
| | 328 | return Response({ |
| | 329 | 'message': f'Account "{account_name}" deleted successfully' |
| | 330 | }) |
| | 331 | }}} |
| | 332 | |
| | 333 | **URL конфигурација - urls.py** |
| | 334 | |
| | 335 | {{{ |
| | 336 | path('accounts/<uuid:account_id>/', views.delete_account, name='delete_account'), |
| | 337 | }}} |
| | 338 | |
| | 339 | == Филтрирање на аналитика по категорија |
| | 340 | |
| | 341 | **Корисник филтрира аналитика** |
| | 342 | |
| | 343 | Најавениот корисник се навигира кон Analytics страницата. |
| | 344 | |
| | 345 | Корисникот може да филтрира по временски период и по категорија. |
| | 346 | |
| | 347 | **Backend имплементација - views.py** |
| | 348 | |
| | 349 | Analytics endpoint-от прима query параметри за филтрирање: |
| | 350 | |
| | 351 | {{{ |
| | 352 | @api_view(['GET']) |
| | 353 | @permission_classes([IsAuthenticated]) |
| | 354 | def analytics_view(request): |
| | 355 | # Get query parameters |
| | 356 | date_range = request.GET.get('range', 'all') |
| | 357 | category_filter = request.GET.get('category', 'all') |
| | 358 | |
| | 359 | # Filter transactions by date |
| | 360 | transactions = Transaction.objects.filter(account__in=user_accounts) |
| | 361 | if start_date: |
| | 362 | transactions = transactions.filter(created_at__gte=start_date) |
| | 363 | |
| | 364 | # Filter by category if specified |
| | 365 | if category_filter and category_filter != 'all': |
| | 366 | transactions = transactions.filter(category__iexact=category_filter) |
| | 367 | }}} |
| | 368 | |
| | 369 | |
| | 370 | Филтрирањето се применува на сите калкулации - category breakdown, monthly trends и income vs expenses. |
| | 371 | |
| | 372 | Исто така се враќа листа на достапни категории за dropdown менито: |
| | 373 | |
| | 374 | |
| | 375 | {{{ |
| | 376 | # Get all available categories for filter dropdown |
| | 377 | all_categories = list(Transaction.objects.filter( |
| | 378 | account__in=user_accounts |
| | 379 | ).values_list('category', flat=True).distinct()) |
| | 380 | |
| | 381 | return Response({ |
| | 382 | 'categoryBreakdown': category_breakdown, |
| | 383 | 'monthlyTrends': monthly_trends, |
| | 384 | 'incomeVsExpenses': income_vs_expenses, |
| | 385 | 'availableCategories': all_categories |
| | 386 | }) |
| | 387 | }}} |
| | 388 | |
| | 389 | |
| | 390 | == Конфигурација на системот |
| | 391 | |
| | 392 | **Django Settings - settings.py** |
| | 393 | |
| | 394 | Email конфигурација за Gmail SMTP: |
| | 395 | |
| | 396 | {{{ |
| | 397 | EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' |
| | 398 | EMAIL_HOST = 'smtp.gmail.com' |
| | 399 | EMAIL_PORT = 587 |
| | 400 | EMAIL_USE_TLS = True |
| | 401 | EMAIL_HOST_USER = 'your-email@gmail.com' |
| | 402 | EMAIL_HOST_PASSWORD = 'your-app-password' |
| | 403 | DEFAULT_FROM_EMAIL = 'Banker App <your-email@gmail.com>' |
| | 404 | SUPPORT_EMAIL = 'your-email@gmail.com' |
| | 405 | }}} |
| | 406 | |
| | 407 | Google OAuth конфигурација: |
| | 408 | |
| | 409 | {{{ |
| | 410 | GOOGLE_CLIENT_ID = 'your-google-client-id.apps.googleusercontent.com' |
| | 411 | GOOGLE_CLIENT_SECRET = 'your-google-client-secret' |
| | 412 | }}} |
| | 413 | |
| | 414 | **URL Routing - urls.py** |
| | 415 | |
| | 416 | Authentication endpoints: |
| | 417 | |
| | 418 | {{{ |
| | 419 | # authentication/urls.py |
| | 420 | urlpatterns = [ |
| | 421 | path('google/', google_login, name='google_login'), |
| | 422 | path('login/', login_view, name='login'), |
| | 423 | path('register/', register_view, name='register'), |
| | 424 | ] |
| | 425 | }}} |
| | 426 | |
| | 427 | Banking endpoints: |
| | 428 | |
| | 429 | {{{ |
| | 430 | # banking/urls.py |
| | 431 | urlpatterns = [ |
| | 432 | path('transfer/', transfer_funds, name='transfer_funds'), |
| | 433 | path('support/', send_support_message, name='support'), |
| | 434 | path('analytics/', analytics_view, name='analytics'), |
| | 435 | path('accounts/<uuid:account_id>/', delete_account, name='delete_account'), |
| | 436 | ] |
| | 437 | }}} |
| | 438 | |
| | 439 | |
| | 440 | |
| | 441 | |
| | 442 | |