kamau1 commited on
Commit
1804ee6
·
1 Parent(s): 66d100e

feat: add expense payment export, auto-populate payment details, and comprehensive filtering system

Browse files
docs/features/expenses/COMMON_USE_CASES.md ADDED
File without changes
docs/features/expenses/EXPENSE_API.md ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Expense Management API
2
+
3
+ **Base:** `/api/v1/ticket-expenses`
4
+
5
+ ---
6
+
7
+ ## PM Endpoints
8
+
9
+ ### 1. List All Expenses
10
+ `GET /ticket-expenses`
11
+
12
+ **Filters:**
13
+ ```
14
+ ?is_approved=false // Pending approval
15
+ ?ready_for_payment=true // Ready to pay
16
+ ?date_range=this_week // today|yesterday|this_week|last_week|this_month|last_month
17
+ ?category=transport,materials // Comma-separated
18
+ ?project_id=uuid
19
+ ?user_id=uuid
20
+ ?search=taxi
21
+ ```
22
+
23
+ **Response:**
24
+ ```json
25
+ {
26
+ "expenses": [{
27
+ "id": "uuid",
28
+ "ticket_reference": "TKT-123",
29
+ "incurred_by_user_name": "John Doe",
30
+ "category": "transport",
31
+ "description": "Taxi to site",
32
+ "expense_date": "2024-12-08",
33
+ "total_cost": 500.00,
34
+ "is_approved": false,
35
+ "is_paid": false,
36
+ "location_verified": true,
37
+ "payment_method": "send_money",
38
+ "payment_details": {"phone_number": "+254712345678", "recipient_name": "John Doe"}
39
+ }],
40
+ "total": 25,
41
+ "page": 1,
42
+ "page_size": 50,
43
+ "pages": 1
44
+ }
45
+ ```
46
+
47
+ ### 2. Approve/Reject
48
+ `POST /ticket-expenses/{id}/approve`
49
+
50
+ **Request:**
51
+ ```json
52
+ // Approve
53
+ {"is_approved": true}
54
+
55
+ // Reject
56
+ {"is_approved": false, "rejection_reason": "Receipt unclear"}
57
+ ```
58
+
59
+ ### 3. Export for Payment (CSV)
60
+ `POST /ticket-expenses/export-for-payment?from_date=2024-12-01&to_date=2024-12-08`
61
+
62
+ **Returns:** CSV file
63
+
64
+ **CSV Columns:** `technician_name, phone_number, account_name, total_amount, expense_count, date, tickets_summary, categories_summary, expense_ids`
65
+
66
+ **⚠️ Side Effect:** Marks all expenses as paid (irreversible!)
67
+
68
+ ### 4. Get Statistics
69
+ `GET /ticket-expenses/stats/summary?from_date=2024-12-01&to_date=2024-12-08`
70
+
71
+ **Response:**
72
+ ```json
73
+ {
74
+ "total_expenses": 25,
75
+ "total_amount": 12500.00,
76
+ "pending_count": 3,
77
+ "pending_amount": 1500.00,
78
+ "approved_count": 20,
79
+ "approved_amount": 10000.00,
80
+ "paid_count": 15,
81
+ "paid_amount": 7500.00,
82
+ "by_category": {"transport": 5000.00, "materials": 4000.00}
83
+ }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Field Agent Endpoints
89
+
90
+ ### 1. Create Expense
91
+ `POST /ticket-expenses`
92
+
93
+ **Request:**
94
+ ```json
95
+ {
96
+ "ticket_assignment_id": "uuid",
97
+ "category": "transport",
98
+ "description": "Taxi to customer site",
99
+ "total_cost": 500.00,
100
+ "expense_date": "2024-12-08",
101
+ "notes": "Shared with team"
102
+ }
103
+ ```
104
+
105
+ **Categories:** `transport`, `materials`, `meals`, `accommodation`, `other`
106
+
107
+ **Note:** Payment details auto-populated from financial account
108
+
109
+ ### 2. My Expenses
110
+ `GET /ticket-expenses/my-expenses`
111
+
112
+ **Filters:** Same as PM list (is_approved, is_paid, date_range, category, search)
113
+
114
+ ### 3. My Stats
115
+ `GET /ticket-expenses/my-stats`
116
+
117
+ **Response:** Same as PM stats but for current user only
118
+
119
+ ### 4. Update Expense
120
+ `PUT /ticket-expenses/{id}`
121
+
122
+ **Only before approval!**
123
+
124
+ ### 5. Delete Expense
125
+ `DELETE /ticket-expenses/{id}`
126
+
127
+ **Only before approval!**
128
+
129
+ ---
130
+
131
+ ## Quick Filters
132
+
133
+ ```typescript
134
+ const FILTERS = {
135
+ pendingApproval: '?is_approved=false',
136
+ readyForPayment: '?ready_for_payment=true',
137
+ today: '?date_range=today',
138
+ thisWeek: '?date_range=this_week',
139
+ unpaid: '?is_approved=true&is_paid=false',
140
+ };
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Data Types
146
+
147
+ ```typescript
148
+ interface CreateExpense {
149
+ ticket_assignment_id: string;
150
+ category: 'transport' | 'materials' | 'meals' | 'accommodation' | 'other';
151
+ description: string;
152
+ total_cost: number;
153
+ expense_date?: string; // ISO date, defaults to today
154
+ notes?: string;
155
+ }
156
+
157
+ interface Expense {
158
+ id: string;
159
+ ticket_reference: string;
160
+ incurred_by_user_name: string;
161
+ category: string;
162
+ description: string;
163
+ expense_date: string;
164
+ total_cost: number;
165
+ is_approved: boolean;
166
+ is_paid: boolean;
167
+ location_verified: boolean;
168
+ payment_method?: string;
169
+ payment_details?: object;
170
+ rejection_reason?: string;
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ## UI Components Needed
177
+
178
+ **PM:**
179
+ - Expense list with filters
180
+ - Approve/Reject buttons
181
+ - Export dialog (date range picker)
182
+ - Stats dashboard cards
183
+
184
+ **Field Agent:**
185
+ - Add expense form (in ticket detail)
186
+ - My expenses list
187
+ - Status badges (Pending/Approved/Rejected/Paid)
188
+ - Stats cards
189
+
190
+ ---
191
+
192
+ ## Important Rules
193
+
194
+ 1. **Export = Payment** - CSV export marks expenses as paid automatically
195
+ 2. **No editing after approval** - Locked once approved
196
+ 3. **Location verified** - System checks GPS automatically
197
+ 4. **Payment auto-populated** - From financial account
198
+ 5. **Same-day rule** - Can add expenses on completion day only
src/app/api/v1/ticket_expenses.py CHANGED
@@ -19,7 +19,7 @@ from fastapi import APIRouter, Depends, status, Query
19
  from sqlalchemy.orm import Session
20
  from typing import Optional, List
21
  from uuid import UUID
22
- from datetime import date
23
 
24
  from app.api.deps import get_db, get_current_user
25
  from app.models.user import User
@@ -34,10 +34,111 @@ from app.schemas.ticket_expense import (
34
  TicketExpenseListResponse,
35
  TicketExpenseStats,
36
  )
 
37
 
38
  router = APIRouter()
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  # ============================================
42
  # CREATE EXPENSE
43
  # ============================================
@@ -362,32 +463,46 @@ def delete_expense(
362
  summary="Get current user's expenses"
363
  )
364
  def get_my_expenses(
365
- is_approved: Optional[bool] = Query(None, description="Filter by approval status"),
366
- is_paid: Optional[bool] = Query(None, description="Filter by payment status"),
367
- from_date: Optional[date] = Query(None, description="Filter from date"),
368
- to_date: Optional[date] = Query(None, description="Filter to date"),
369
- page: int = Query(1, ge=1, description="Page number"),
370
- page_size: int = Query(100, ge=1, le=100, description="Items per page"),
371
  db: Session = Depends(get_db),
372
  current_user: User = Depends(get_current_user)
373
  ):
374
  """
375
- Get all expenses for the current user.
376
 
377
  **Purpose:** Field agents can see all their expenses in one place
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  **Returns:** List of user's expenses with full context (ticket info, approval/payment status)
380
  """
381
- expenses, total = TicketExpenseService.list_expenses(
 
 
 
382
  db=db,
383
  current_user=current_user,
384
- user_id=current_user.id, # Force filter to current user
385
- is_approved=is_approved,
386
- is_paid=is_paid,
387
- from_date=from_date,
388
- to_date=to_date,
389
- page=page,
390
- page_size=page_size
391
  )
392
 
393
  # Build responses with contextual information
@@ -415,13 +530,13 @@ def get_my_expenses(
415
 
416
  expense_responses.append(response)
417
 
418
- pages = (total + page_size - 1) // page_size
419
 
420
  return TicketExpenseListResponse(
421
  expenses=expense_responses,
422
  total=total,
423
- page=page,
424
- page_size=page_size,
425
  pages=pages
426
  )
427
 
@@ -517,21 +632,12 @@ def get_expense(
517
  summary="List expenses with filters"
518
  )
519
  def list_expenses(
520
- ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"),
521
- assignment_id: Optional[UUID] = Query(None, description="Filter by assignment"),
522
- user_id: Optional[UUID] = Query(None, description="Filter by user"),
523
- category: Optional[str] = Query(None, description="Filter by category"),
524
- is_approved: Optional[bool] = Query(None, description="Filter by approval status"),
525
- is_paid: Optional[bool] = Query(None, description="Filter by payment status"),
526
- from_date: Optional[date] = Query(None, description="Filter from date (inclusive)"),
527
- to_date: Optional[date] = Query(None, description="Filter to date (inclusive)"),
528
- page: int = Query(1, ge=1, description="Page number"),
529
- page_size: int = Query(50, ge=1, le=100, description="Items per page"),
530
  db: Session = Depends(get_db),
531
  current_user: User = Depends(get_current_user)
532
  ):
533
  """
534
- List expenses with filters and pagination.
535
 
536
  **Authorization:**
537
  - Field agents: See only their own expenses
@@ -540,31 +646,44 @@ def list_expenses(
540
  **Filters:**
541
  - `ticket_id`: Filter by specific ticket
542
  - `assignment_id`: Filter by specific assignment
543
- - `user_id`: Filter by user who incurred expense
544
- - `category`: Filter by category (transport, materials, meals, etc.)
 
 
 
545
  - `is_approved`: Filter by approval status
546
  - `is_paid`: Filter by payment status
547
- - `from_date`: Filter from date (inclusive)
548
- - `to_date`: Filter to date (inclusive)
549
-
550
- **Example:**
 
 
 
 
 
 
 
 
 
551
  ```
552
- GET /ticket-expenses?is_approved=false&category=transport&page=1&page_size=20
 
 
 
 
 
 
 
 
 
 
553
  ```
554
  """
555
- expenses, total = TicketExpenseService.list_expenses(
556
  db=db,
557
  current_user=current_user,
558
- ticket_id=ticket_id,
559
- assignment_id=assignment_id,
560
- user_id=user_id,
561
- category=category,
562
- is_approved=is_approved,
563
- is_paid=is_paid,
564
- from_date=from_date,
565
- to_date=to_date,
566
- page=page,
567
- page_size=page_size
568
  )
569
 
570
  # Build responses with user names and contextual information
@@ -592,13 +711,13 @@ def list_expenses(
592
 
593
  expense_responses.append(response)
594
 
595
- pages = (total + page_size - 1) // page_size
596
 
597
  return TicketExpenseListResponse(
598
  expenses=expense_responses,
599
  total=total,
600
- page=page,
601
- page_size=page_size,
602
  pages=pages
603
  )
604
 
@@ -666,3 +785,118 @@ def get_expense_stats(
666
  )
667
 
668
  return stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from sqlalchemy.orm import Session
20
  from typing import Optional, List
21
  from uuid import UUID
22
+ from datetime import date, datetime, timedelta
23
 
24
  from app.api.deps import get_db, get_current_user
25
  from app.models.user import User
 
34
  TicketExpenseListResponse,
35
  TicketExpenseStats,
36
  )
37
+ from app.schemas.filters import ExpenseFilters
38
 
39
  router = APIRouter()
40
 
41
 
42
+ # ============================================
43
+ # FILTER PARSING
44
+ # ============================================
45
+
46
+ def parse_expense_filters(
47
+ ticket_id: Optional[UUID] = Query(None),
48
+ assignment_id: Optional[UUID] = Query(None),
49
+ user_id: Optional[UUID] = Query(None),
50
+ project_id: Optional[UUID] = Query(None),
51
+ category: Optional[str] = Query(None),
52
+ payment_method: Optional[str] = Query(None),
53
+ payment_recipient_type: Optional[str] = Query(None),
54
+ expense_date: Optional[date] = Query(None),
55
+ expense_date_from: Optional[date] = Query(None),
56
+ expense_date_to: Optional[date] = Query(None),
57
+ is_approved: Optional[bool] = Query(None),
58
+ is_paid: Optional[bool] = Query(None),
59
+ location_verified: Optional[bool] = Query(None),
60
+ has_receipt: Optional[bool] = Query(None),
61
+ has_payment_details: Optional[bool] = Query(None),
62
+ ready_for_payment: Optional[bool] = Query(None),
63
+ date_range: Optional[str] = Query(None),
64
+ search: Optional[str] = Query(None),
65
+ sort_by: Optional[str] = Query(None),
66
+ sort_order: str = Query("desc"),
67
+ page: int = Query(1, ge=1),
68
+ page_size: int = Query(50, ge=1, le=100),
69
+ from_date: Optional[date] = Query(None),
70
+ to_date: Optional[date] = Query(None),
71
+ ) -> ExpenseFilters:
72
+ """Parse and convert query parameters to ExpenseFilters"""
73
+ # Parse comma-separated multi-value filters
74
+ def parse_csv(value: Optional[str]) -> Optional[List[str]]:
75
+ if value is None:
76
+ return None
77
+ return [item.strip() for item in value.split(',') if item.strip()]
78
+
79
+ # Handle quick date range filters
80
+ if date_range:
81
+ today = date.today()
82
+ if date_range == "today":
83
+ expense_date_from = today
84
+ expense_date_to = today
85
+ elif date_range == "yesterday":
86
+ yesterday = today - timedelta(days=1)
87
+ expense_date_from = yesterday
88
+ expense_date_to = yesterday
89
+ elif date_range == "this_week":
90
+ # Monday to Sunday
91
+ start_of_week = today - timedelta(days=today.weekday())
92
+ expense_date_from = start_of_week
93
+ expense_date_to = today
94
+ elif date_range == "last_week":
95
+ start_of_last_week = today - timedelta(days=today.weekday() + 7)
96
+ end_of_last_week = start_of_last_week + timedelta(days=6)
97
+ expense_date_from = start_of_last_week
98
+ expense_date_to = end_of_last_week
99
+ elif date_range == "this_month":
100
+ expense_date_from = today.replace(day=1)
101
+ expense_date_to = today
102
+ elif date_range == "last_month":
103
+ first_of_this_month = today.replace(day=1)
104
+ last_day_of_last_month = first_of_this_month - timedelta(days=1)
105
+ expense_date_from = last_day_of_last_month.replace(day=1)
106
+ expense_date_to = last_day_of_last_month
107
+
108
+ # Support legacy from_date/to_date parameters
109
+ if from_date and not expense_date_from:
110
+ expense_date_from = from_date
111
+ if to_date and not expense_date_to:
112
+ expense_date_to = to_date
113
+
114
+ return ExpenseFilters(
115
+ ticket_id=ticket_id,
116
+ assignment_id=assignment_id,
117
+ user_id=user_id,
118
+ project_id=project_id,
119
+ category=parse_csv(category),
120
+ payment_method=parse_csv(payment_method),
121
+ payment_recipient_type=parse_csv(payment_recipient_type),
122
+ expense_date=expense_date,
123
+ expense_date_from=expense_date_from,
124
+ expense_date_to=expense_date_to,
125
+ is_approved=is_approved,
126
+ is_paid=is_paid,
127
+ location_verified=location_verified,
128
+ has_receipt=has_receipt,
129
+ has_payment_details=has_payment_details,
130
+ ready_for_payment=ready_for_payment,
131
+ date_range=date_range,
132
+ search=search,
133
+ sort_by=sort_by,
134
+ sort_order=sort_order,
135
+ page=page,
136
+ page_size=page_size,
137
+ from_date=expense_date_from,
138
+ to_date=expense_date_to,
139
+ )
140
+
141
+
142
  # ============================================
143
  # CREATE EXPENSE
144
  # ============================================
 
463
  summary="Get current user's expenses"
464
  )
465
  def get_my_expenses(
466
+ filters: ExpenseFilters = Depends(parse_expense_filters),
 
 
 
 
 
467
  db: Session = Depends(get_db),
468
  current_user: User = Depends(get_current_user)
469
  ):
470
  """
471
+ Get all expenses for the current user with comprehensive filtering.
472
 
473
  **Purpose:** Field agents can see all their expenses in one place
474
 
475
+ **Supports all expense filters:**
476
+ - `is_approved`: Filter by approval status
477
+ - `is_paid`: Filter by payment status
478
+ - `category`: Filter by category (transport,materials,meals)
479
+ - `date_range`: Quick filter (today,yesterday,this_week,last_week,this_month,last_month)
480
+ - `expense_date_from/to`: Date range
481
+ - `has_receipt`: Filter expenses with receipts
482
+ - `search`: Search description and notes
483
+ - And more...
484
+
485
+ **Examples:**
486
+ ```
487
+ # Pending approval
488
+ GET /ticket-expenses/my-expenses?is_approved=false
489
+
490
+ # This week's expenses
491
+ GET /ticket-expenses/my-expenses?date_range=this_week
492
+
493
+ # Transport expenses only
494
+ GET /ticket-expenses/my-expenses?category=transport
495
+ ```
496
+
497
  **Returns:** List of user's expenses with full context (ticket info, approval/payment status)
498
  """
499
+ # Force filter to current user
500
+ filters.user_id = current_user.id
501
+
502
+ expenses, total = TicketExpenseService.list_expenses_with_filters(
503
  db=db,
504
  current_user=current_user,
505
+ filters=filters
 
 
 
 
 
 
506
  )
507
 
508
  # Build responses with contextual information
 
530
 
531
  expense_responses.append(response)
532
 
533
+ pages = (total + filters.page_size - 1) // filters.page_size
534
 
535
  return TicketExpenseListResponse(
536
  expenses=expense_responses,
537
  total=total,
538
+ page=filters.page,
539
+ page_size=filters.page_size,
540
  pages=pages
541
  )
542
 
 
632
  summary="List expenses with filters"
633
  )
634
  def list_expenses(
635
+ filters: ExpenseFilters = Depends(parse_expense_filters),
 
 
 
 
 
 
 
 
 
636
  db: Session = Depends(get_db),
637
  current_user: User = Depends(get_current_user)
638
  ):
639
  """
640
+ List expenses with comprehensive filtering and pagination.
641
 
642
  **Authorization:**
643
  - Field agents: See only their own expenses
 
646
  **Filters:**
647
  - `ticket_id`: Filter by specific ticket
648
  - `assignment_id`: Filter by specific assignment
649
+ - `user_id`: Filter by technician who incurred expense
650
+ - `project_id`: Filter by project (via ticket)
651
+ - `category`: Filter by category (comma-separated: transport,materials,meals)
652
+ - `payment_method`: Filter by payment method (comma-separated)
653
+ - `payment_recipient_type`: Filter by recipient (agent,vendor)
654
  - `is_approved`: Filter by approval status
655
  - `is_paid`: Filter by payment status
656
+ - `location_verified`: Filter by location verification
657
+ - `has_receipt`: Filter expenses with receipts
658
+ - `has_payment_details`: Filter expenses with payment details
659
+ - `ready_for_payment`: Filter expenses ready for payment (approved + unpaid + has payment details)
660
+ - `expense_date`: Filter by exact expense date
661
+ - `expense_date_from`: Filter from date (inclusive)
662
+ - `expense_date_to`: Filter to date (inclusive)
663
+ - `date_range`: Quick filter (today,yesterday,this_week,last_week,this_month,last_month)
664
+ - `search`: Search across description and notes
665
+ - `sort_by`: Field to sort by
666
+ - `sort_order`: asc or desc
667
+
668
+ **Examples:**
669
  ```
670
+ # Pending approval, transport only
671
+ GET /ticket-expenses?is_approved=false&category=transport
672
+
673
+ # Ready for payment
674
+ GET /ticket-expenses?ready_for_payment=true
675
+
676
+ # This week's expenses
677
+ GET /ticket-expenses?date_range=this_week
678
+
679
+ # Multiple categories
680
+ GET /ticket-expenses?category=transport,materials&date_range=today
681
  ```
682
  """
683
+ expenses, total = TicketExpenseService.list_expenses_with_filters(
684
  db=db,
685
  current_user=current_user,
686
+ filters=filters
 
 
 
 
 
 
 
 
 
687
  )
688
 
689
  # Build responses with user names and contextual information
 
711
 
712
  expense_responses.append(response)
713
 
714
+ pages = (total + filters.page_size - 1) // filters.page_size
715
 
716
  return TicketExpenseListResponse(
717
  expenses=expense_responses,
718
  total=total,
719
+ page=filters.page,
720
+ page_size=filters.page_size,
721
  pages=pages
722
  )
723
 
 
785
  )
786
 
787
  return stats
788
+
789
+
790
+ # ============================================
791
+ # EXPORT FOR PAYMENT
792
+ # ============================================
793
+
794
+ @router.post(
795
+ "/export-for-payment",
796
+ summary="Export expenses for payment (CSV)",
797
+ description="Export approved unpaid expenses as CSV and mark them as paid"
798
+ )
799
+ def export_expenses_for_payment(
800
+ from_date: date = Query(..., description="Start date (inclusive)"),
801
+ to_date: date = Query(..., description="End date (inclusive)"),
802
+ project_id: Optional[UUID] = Query(None, description="Filter by project"),
803
+ ticket_id: Optional[UUID] = Query(None, description="Filter by ticket"),
804
+ user_id: Optional[UUID] = Query(None, description="Filter by technician"),
805
+ db: Session = Depends(get_db),
806
+ current_user: User = Depends(get_current_user)
807
+ ):
808
+ """
809
+ Export approved unpaid expenses for payment processing.
810
+
811
+ **Authorization:** PM, Dispatcher, Platform Admin only
812
+
813
+ **What it does:**
814
+ 1. Fetches all approved + unpaid expenses in date range
815
+ 2. Groups by user + date (one payment per user per day)
816
+ 3. Generates CSV with payment details
817
+ 4. **Marks all expenses as paid** (irreversible!)
818
+
819
+ **CSV Columns:**
820
+ - Technician Name
821
+ - Phone Number
822
+ - Account Name
823
+ - Total Amount
824
+ - Expense Count
825
+ - Date
826
+ - Tickets Summary (detailed breakdown)
827
+ - Categories Summary (totals by category)
828
+ - Expense IDs (comma-separated UUIDs)
829
+
830
+ **Grouping Logic:**
831
+ - Agent payments: Grouped by user + date
832
+ - Vendor payments: Separate row per expense
833
+
834
+ **Side Effects:**
835
+ - All exported expenses marked as `is_paid = true`
836
+ - `paid_at` set to export timestamp
837
+ - `payment_reference` set to `CSV_EXPORT_{timestamp}_{pm_user_id}`
838
+
839
+ **Re-export Behavior:**
840
+ - Safe to run multiple times
841
+ - Only exports newly approved expenses (already paid excluded)
842
+
843
+ **Example:**
844
+ ```
845
+ POST /ticket-expenses/export-for-payment?from_date=2024-12-01&to_date=2024-12-08
846
+ ```
847
+
848
+ **Returns:** CSV file download
849
+ """
850
+ from fastapi.responses import StreamingResponse
851
+ import io
852
+ import csv
853
+
854
+ # Export expenses
855
+ csv_rows, warnings = TicketExpenseService.export_for_payment(
856
+ db=db,
857
+ from_date=from_date,
858
+ to_date=to_date,
859
+ current_user=current_user,
860
+ project_id=project_id,
861
+ ticket_id=ticket_id,
862
+ user_id=user_id
863
+ )
864
+
865
+ # Generate CSV
866
+ output = io.StringIO()
867
+ if csv_rows:
868
+ fieldnames = [
869
+ "technician_name",
870
+ "phone_number",
871
+ "account_name",
872
+ "total_amount",
873
+ "expense_count",
874
+ "date",
875
+ "tickets_summary",
876
+ "categories_summary",
877
+ "expense_ids"
878
+ ]
879
+
880
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
881
+ writer.writeheader()
882
+ writer.writerows(csv_rows)
883
+
884
+ # Add warnings as comments at the end
885
+ if warnings:
886
+ output.write("\n# WARNINGS:\n")
887
+ for warning in warnings:
888
+ output.write(f"# {warning}\n")
889
+
890
+ # Prepare response
891
+ output.seek(0)
892
+ filename = f"expense_payments_{from_date.isoformat()}_{to_date.isoformat()}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
893
+
894
+ return StreamingResponse(
895
+ iter([output.getvalue()]),
896
+ media_type="text/csv",
897
+ headers={
898
+ "Content-Disposition": f"attachment; filename={filename}",
899
+ "X-Export-Count": str(len(csv_rows)),
900
+ "X-Warnings-Count": str(len(warnings))
901
+ }
902
+ )
src/app/schemas/filters.py CHANGED
@@ -194,3 +194,33 @@ class InventoryFilters(BaseListFilters):
194
  is_active: Optional[bool] = Field(None, description="Filter active/inactive items")
195
  has_serial_numbers: Optional[bool] = Field(None, description="Filter items with serial numbers")
196
  is_fully_distributed: Optional[bool] = Field(None, description="Filter fully distributed items")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  is_active: Optional[bool] = Field(None, description="Filter active/inactive items")
195
  has_serial_numbers: Optional[bool] = Field(None, description="Filter items with serial numbers")
196
  is_fully_distributed: Optional[bool] = Field(None, description="Filter fully distributed items")
197
+
198
+
199
+ class ExpenseFilters(BaseListFilters):
200
+ """Comprehensive expense filtering - ALL FIELDS VERIFIED"""
201
+ # UUID filters
202
+ ticket_id: Optional[UUID] = Field(None, description="Filter by ticket")
203
+ assignment_id: Optional[UUID] = Field(None, description="Filter by assignment")
204
+ user_id: Optional[UUID] = Field(None, description="Filter by technician who incurred expense")
205
+ project_id: Optional[UUID] = Field(None, description="Filter by project (via ticket)")
206
+
207
+ # Multi-value filters
208
+ category: Optional[List[str]] = Field(None, description="Filter by category: transport,materials,meals,accommodation,other")
209
+ payment_method: Optional[List[str]] = Field(None, description="Filter by payment method: send_money,till_number,paybill,pochi_la_biashara,bank_transfer,cash")
210
+ payment_recipient_type: Optional[List[str]] = Field(None, description="Filter by recipient: agent,vendor")
211
+
212
+ # Date filters
213
+ expense_date: Optional[date] = Field(None, description="Filter by exact expense date")
214
+ expense_date_from: Optional[date] = Field(None, description="Expense from date (inclusive)")
215
+ expense_date_to: Optional[date] = Field(None, description="Expense to date (inclusive)")
216
+
217
+ # Boolean filters
218
+ is_approved: Optional[bool] = Field(None, description="Filter by approval status")
219
+ is_paid: Optional[bool] = Field(None, description="Filter by payment status")
220
+ location_verified: Optional[bool] = Field(None, description="Filter by location verification")
221
+ has_receipt: Optional[bool] = Field(None, description="Filter expenses with receipt documents")
222
+ has_payment_details: Optional[bool] = Field(None, description="Filter expenses with payment details set")
223
+ ready_for_payment: Optional[bool] = Field(None, description="Filter expenses ready for payment (approved + unpaid + has payment details)")
224
+
225
+ # Quick date filters (convenience)
226
+ date_range: Optional[str] = Field(None, description="Quick date filter: today,yesterday,this_week,last_week,this_month,last_month")
src/app/schemas/ticket_expense.py CHANGED
@@ -150,6 +150,11 @@ class TicketExpenseCreate(BaseModel):
150
  receipt_document_id: Optional[UUID] = Field(None, description="ID of receipt document")
151
  notes: Optional[str] = Field(None, max_length=2000, description="Additional notes")
152
 
 
 
 
 
 
153
  @field_validator('category')
154
  @classmethod
155
  def validate_category(cls, v):
 
150
  receipt_document_id: Optional[UUID] = Field(None, description="ID of receipt document")
151
  notes: Optional[str] = Field(None, max_length=2000, description="Additional notes")
152
 
153
+ # Payment routing (optional - defaults to agent's financial account if not provided)
154
+ payment_recipient_type: Optional[PaymentRecipientType] = Field(None, description="Who receives payment: agent (default) or vendor")
155
+ payment_method: Optional[PaymentMethod] = Field(None, description="Payment method (auto-populated from financial account if not provided)")
156
+ payment_details: Optional[dict] = Field(None, description="Payment details (auto-populated from financial account if not provided, or vendor details if paying vendor)")
157
+
158
  @field_validator('category')
159
  @classmethod
160
  def validate_category(cls, v):
src/app/services/ticket_expense_service.py CHANGED
@@ -178,6 +178,14 @@ class TicketExpenseService:
178
  notes=data.notes,
179
  )
180
 
 
 
 
 
 
 
 
 
181
  db.add(expense)
182
  db.commit()
183
  db.refresh(expense)
@@ -873,3 +881,514 @@ class TicketExpenseService:
873
 
874
  # No location verification found
875
  return False, f"Not verified: No GPS location found for {expense_date}. Manual review required."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  notes=data.notes,
179
  )
180
 
181
+ # Auto-populate payment details
182
+ expense = TicketExpenseService._populate_payment_details(
183
+ db=db,
184
+ expense=expense,
185
+ data=data,
186
+ current_user=current_user
187
+ )
188
+
189
  db.add(expense)
190
  db.commit()
191
  db.refresh(expense)
 
881
 
882
  # No location verification found
883
  return False, f"Not verified: No GPS location found for {expense_date}. Manual review required."
884
+
885
+ # ============================================
886
+ # PAYMENT DETAILS AUTO-POPULATION (PRIVATE)
887
+ # ============================================
888
+
889
+ @staticmethod
890
+ def _populate_payment_details(
891
+ db: Session,
892
+ expense: TicketExpense,
893
+ data: TicketExpenseCreate,
894
+ current_user: User
895
+ ) -> TicketExpense:
896
+ """
897
+ Auto-populate payment details from user's financial account or use provided vendor details.
898
+
899
+ Logic:
900
+ - If payment details provided (vendor payment): Use them as-is
901
+ - If not provided (agent payment): Auto-populate from user's primary financial account
902
+ - If no financial account: Leave null (PM will handle manually)
903
+
904
+ Args:
905
+ db: Database session
906
+ expense: Expense object to populate
907
+ data: Creation data
908
+ current_user: User creating expense
909
+
910
+ Returns:
911
+ Expense with payment details populated
912
+ """
913
+ from app.models.user_financial_account import UserFinancialAccount
914
+
915
+ # If payment details provided from frontend (vendor payment), use them
916
+ if data.payment_recipient_type and data.payment_method and data.payment_details:
917
+ expense.payment_recipient_type = data.payment_recipient_type.value
918
+ expense.payment_method = data.payment_method.value
919
+ expense.payment_details = data.payment_details
920
+ logger.info(
921
+ f"Using provided payment details for expense: "
922
+ f"recipient_type={data.payment_recipient_type.value}, "
923
+ f"method={data.payment_method.value}"
924
+ )
925
+ return expense
926
+
927
+ # Otherwise, auto-populate from user's financial account (agent payment)
928
+ financial_account = db.query(UserFinancialAccount).filter(
929
+ UserFinancialAccount.user_id == current_user.id,
930
+ UserFinancialAccount.is_primary == True,
931
+ UserFinancialAccount.is_active == True,
932
+ UserFinancialAccount.deleted_at.is_(None)
933
+ ).first()
934
+
935
+ if not financial_account:
936
+ logger.warning(
937
+ f"No primary financial account found for user {current_user.id}. "
938
+ f"Expense created without payment details."
939
+ )
940
+ return expense
941
+
942
+ # Set recipient type to agent
943
+ expense.payment_recipient_type = "agent"
944
+
945
+ # Map payout method to payment method
946
+ if financial_account.payout_method == "mobile_money":
947
+ expense.payment_method = "send_money"
948
+ expense.payment_details = {
949
+ "phone_number": financial_account.mobile_money_phone,
950
+ "recipient_name": financial_account.mobile_money_account_name or current_user.name
951
+ }
952
+ logger.info(
953
+ f"Auto-populated mobile money payment details for user {current_user.id}: "
954
+ f"phone={financial_account.mobile_money_phone}"
955
+ )
956
+
957
+ elif financial_account.payout_method == "bank_transfer":
958
+ expense.payment_method = "bank_transfer"
959
+ expense.payment_details = {
960
+ "bank_name": financial_account.bank_name,
961
+ "account_number": financial_account.bank_account_number,
962
+ "account_name": financial_account.bank_account_name,
963
+ "branch": financial_account.bank_branch
964
+ }
965
+ logger.info(
966
+ f"Auto-populated bank transfer payment details for user {current_user.id}: "
967
+ f"bank={financial_account.bank_name}"
968
+ )
969
+
970
+ else:
971
+ # Unsupported payout method, leave null
972
+ logger.warning(
973
+ f"Unsupported payout method '{financial_account.payout_method}' "
974
+ f"for user {current_user.id}. Expense created without payment details."
975
+ )
976
+
977
+ return expense
978
+
979
+ # ============================================
980
+ # CSV EXPORT FOR PAYMENT
981
+ # ============================================
982
+
983
+ @staticmethod
984
+ def export_for_payment(
985
+ db: Session,
986
+ from_date: date,
987
+ to_date: date,
988
+ current_user: User,
989
+ project_id: Optional[UUID] = None,
990
+ ticket_id: Optional[UUID] = None,
991
+ user_id: Optional[UUID] = None
992
+ ) -> Tuple[List[dict], List[str]]:
993
+ """
994
+ Export approved unpaid expenses grouped by user+date for payment processing.
995
+ Marks all exported expenses as paid.
996
+
997
+ Args:
998
+ db: Database session
999
+ from_date: Start date (inclusive)
1000
+ to_date: End date (inclusive)
1001
+ current_user: User performing export (PM/Dispatcher/Admin)
1002
+ project_id: Optional project filter
1003
+ ticket_id: Optional ticket filter
1004
+ user_id: Optional user filter
1005
+
1006
+ Returns:
1007
+ Tuple of (csv_rows, warnings)
1008
+ - csv_rows: List of dicts with payment data
1009
+ - warnings: List of warning messages
1010
+
1011
+ Raises:
1012
+ HTTPException: If not authorized
1013
+ """
1014
+ from app.models.user_financial_account import UserFinancialAccount
1015
+ from app.models.ticket import Ticket
1016
+ from collections import defaultdict
1017
+
1018
+ # Authorization: Only PM, dispatcher, or platform admin
1019
+ if current_user.role not in [
1020
+ AppRole.PLATFORM_ADMIN,
1021
+ AppRole.PROJECT_MANAGER,
1022
+ AppRole.DISPATCHER
1023
+ ]:
1024
+ raise HTTPException(
1025
+ status_code=status.HTTP_403_FORBIDDEN,
1026
+ detail="Not authorized to export expenses for payment"
1027
+ )
1028
+
1029
+ # Fetch approved unpaid expenses
1030
+ query = db.query(TicketExpense).options(
1031
+ joinedload(TicketExpense.incurred_by_user),
1032
+ joinedload(TicketExpense.ticket),
1033
+ joinedload(TicketExpense.assignment)
1034
+ ).filter(
1035
+ TicketExpense.is_approved == True,
1036
+ TicketExpense.is_paid == False,
1037
+ TicketExpense.expense_date >= from_date,
1038
+ TicketExpense.expense_date <= to_date,
1039
+ TicketExpense.deleted_at.is_(None)
1040
+ )
1041
+
1042
+ # Apply optional filters
1043
+ if project_id:
1044
+ query = query.join(Ticket).filter(Ticket.project_id == project_id)
1045
+ if ticket_id:
1046
+ query = query.filter(TicketExpense.ticket_id == ticket_id)
1047
+ if user_id:
1048
+ query = query.filter(TicketExpense.incurred_by_user_id == user_id)
1049
+
1050
+ expenses = query.all()
1051
+
1052
+ if not expenses:
1053
+ return [], ["No approved unpaid expenses found in the specified date range"]
1054
+
1055
+ # Group expenses by user_id + expense_date
1056
+ grouped = defaultdict(list)
1057
+ for expense in expenses:
1058
+ # For vendor payments, group separately (no user grouping)
1059
+ if expense.payment_recipient_type == "vendor":
1060
+ key = f"vendor_{expense.id}"
1061
+ else:
1062
+ key = (expense.incurred_by_user_id, expense.expense_date)
1063
+ grouped[key].append(expense)
1064
+
1065
+ # Build CSV rows
1066
+ csv_rows = []
1067
+ warnings = []
1068
+ payment_reference = f"CSV_EXPORT_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{current_user.id}"
1069
+
1070
+ for key, group_expenses in grouped.items():
1071
+ # Skip if no expenses in group
1072
+ if not group_expenses:
1073
+ continue
1074
+
1075
+ first_expense = group_expenses[0]
1076
+
1077
+ # Handle vendor payments separately
1078
+ if first_expense.payment_recipient_type == "vendor":
1079
+ row = TicketExpenseService._build_vendor_payment_row(
1080
+ expense=first_expense,
1081
+ warnings=warnings
1082
+ )
1083
+ if row:
1084
+ csv_rows.append(row)
1085
+ continue
1086
+
1087
+ # Handle agent payments
1088
+ user = first_expense.incurred_by_user
1089
+ expense_date = first_expense.expense_date
1090
+
1091
+ # Get payment details from first expense (should be same for all in group)
1092
+ phone_number = None
1093
+ account_name = None
1094
+
1095
+ if first_expense.payment_details:
1096
+ phone_number = first_expense.payment_details.get("phone_number") or \
1097
+ first_expense.payment_details.get("account_number")
1098
+ account_name = first_expense.payment_details.get("recipient_name") or \
1099
+ first_expense.payment_details.get("account_name")
1100
+
1101
+ # If no payment details, try to get from financial account
1102
+ if not phone_number or not account_name:
1103
+ financial_account = db.query(UserFinancialAccount).filter(
1104
+ UserFinancialAccount.user_id == user.id,
1105
+ UserFinancialAccount.is_primary == True,
1106
+ UserFinancialAccount.is_active == True,
1107
+ UserFinancialAccount.deleted_at.is_(None)
1108
+ ).first()
1109
+
1110
+ if financial_account:
1111
+ if financial_account.payout_method == "mobile_money":
1112
+ phone_number = financial_account.mobile_money_phone
1113
+ account_name = financial_account.mobile_money_account_name or user.name
1114
+ elif financial_account.payout_method == "bank_transfer":
1115
+ phone_number = financial_account.bank_account_number
1116
+ account_name = financial_account.bank_account_name
1117
+
1118
+ # Skip if still no payment details
1119
+ if not phone_number or not account_name:
1120
+ warnings.append(
1121
+ f"Skipped {len(group_expenses)} expense(s) for {user.name} on {expense_date}: "
1122
+ f"No payment details found"
1123
+ )
1124
+ continue
1125
+
1126
+ # Calculate totals
1127
+ total_amount = sum(e.total_cost for e in group_expenses)
1128
+ expense_count = len(group_expenses)
1129
+ expense_ids = [str(e.id) for e in group_expenses]
1130
+
1131
+ # Build tickets summary
1132
+ tickets_summary = TicketExpenseService._build_tickets_summary(group_expenses)
1133
+
1134
+ # Build categories summary
1135
+ categories_summary = TicketExpenseService._build_categories_summary(group_expenses)
1136
+
1137
+ # Build CSV row
1138
+ csv_rows.append({
1139
+ "technician_name": user.name,
1140
+ "phone_number": phone_number,
1141
+ "account_name": account_name,
1142
+ "total_amount": float(total_amount),
1143
+ "expense_count": expense_count,
1144
+ "date": expense_date.isoformat(),
1145
+ "tickets_summary": tickets_summary,
1146
+ "categories_summary": categories_summary,
1147
+ "expense_ids": ",".join(expense_ids)
1148
+ })
1149
+
1150
+ # Mark all expenses as paid (in transaction)
1151
+ try:
1152
+ for expense in expenses:
1153
+ expense.is_paid = True
1154
+ expense.paid_at = datetime.utcnow()
1155
+ expense.paid_to_user_id = expense.incurred_by_user_id
1156
+ expense.payment_reference = payment_reference
1157
+
1158
+ db.commit()
1159
+
1160
+ logger.info(
1161
+ f"Exported {len(expenses)} expenses for payment by user {current_user.id}. "
1162
+ f"Generated {len(csv_rows)} payment rows. Reference: {payment_reference}"
1163
+ )
1164
+ except Exception as e:
1165
+ db.rollback()
1166
+ logger.error(f"Failed to mark expenses as paid: {str(e)}")
1167
+ raise HTTPException(
1168
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1169
+ detail="Failed to mark expenses as paid"
1170
+ )
1171
+
1172
+ return csv_rows, warnings
1173
+
1174
+ @staticmethod
1175
+ def _build_vendor_payment_row(expense: TicketExpense, warnings: List[str]) -> Optional[dict]:
1176
+ """Build CSV row for vendor payment"""
1177
+ if not expense.payment_details:
1178
+ warnings.append(
1179
+ f"Skipped vendor expense {expense.id}: No payment details"
1180
+ )
1181
+ return None
1182
+
1183
+ phone_number = expense.payment_details.get("phone_number") or \
1184
+ expense.payment_details.get("till_number") or \
1185
+ expense.payment_details.get("account_number")
1186
+ account_name = expense.payment_details.get("recipient_name") or \
1187
+ expense.payment_details.get("business_name") or \
1188
+ expense.payment_details.get("account_name")
1189
+
1190
+ if not phone_number or not account_name:
1191
+ warnings.append(
1192
+ f"Skipped vendor expense {expense.id}: Incomplete payment details"
1193
+ )
1194
+ return None
1195
+
1196
+ # Build ticket summary for single expense
1197
+ ticket_ref = expense.ticket.ticket_reference if expense.ticket else "Unknown"
1198
+ tickets_summary = f"Ticket #{ticket_ref} (1 expense: {expense.category.title()} {float(expense.total_cost)} KES)"
1199
+
1200
+ return {
1201
+ "technician_name": f"VENDOR: {account_name}",
1202
+ "phone_number": phone_number,
1203
+ "account_name": account_name,
1204
+ "total_amount": float(expense.total_cost),
1205
+ "expense_count": 1,
1206
+ "date": expense.expense_date.isoformat(),
1207
+ "tickets_summary": tickets_summary,
1208
+ "categories_summary": f"{expense.category.title()}: {float(expense.total_cost)} (1x)",
1209
+ "expense_ids": str(expense.id)
1210
+ }
1211
+
1212
+ @staticmethod
1213
+ def _build_tickets_summary(expenses: List[TicketExpense]) -> str:
1214
+ """Build detailed tickets summary for CSV"""
1215
+ from collections import defaultdict
1216
+
1217
+ # Group by ticket
1218
+ by_ticket = defaultdict(list)
1219
+ for expense in expenses:
1220
+ ticket_ref = expense.ticket.ticket_reference if expense.ticket else "Unknown"
1221
+ by_ticket[ticket_ref].append(expense)
1222
+
1223
+ # Build summary for each ticket
1224
+ ticket_summaries = []
1225
+ for ticket_ref, ticket_expenses in by_ticket.items():
1226
+ expense_details = []
1227
+ ticket_total = Decimal(0)
1228
+
1229
+ for expense in ticket_expenses:
1230
+ expense_details.append(
1231
+ f"{expense.category.title()} {float(expense.total_cost)}"
1232
+ )
1233
+ ticket_total += expense.total_cost
1234
+
1235
+ summary = f"Ticket #{ticket_ref} ({len(ticket_expenses)} expenses: {', '.join(expense_details)} = {float(ticket_total)} KES)"
1236
+ ticket_summaries.append(summary)
1237
+
1238
+ return " | ".join(ticket_summaries)
1239
+
1240
+ @staticmethod
1241
+ def _build_categories_summary(expenses: List[TicketExpense]) -> str:
1242
+ """Build categories summary for CSV"""
1243
+ from collections import defaultdict
1244
+
1245
+ # Group by category
1246
+ by_category = defaultdict(list)
1247
+ for expense in expenses:
1248
+ by_category[expense.category].append(expense)
1249
+
1250
+ # Build summary for each category
1251
+ category_summaries = []
1252
+ for category, cat_expenses in by_category.items():
1253
+ total = sum(e.total_cost for e in cat_expenses)
1254
+ count = len(cat_expenses)
1255
+ category_summaries.append(
1256
+ f"{category.title()}: {float(total)} ({count}x)"
1257
+ )
1258
+
1259
+ return " | ".join(category_summaries)
1260
+
1261
+
1262
+ @staticmethod
1263
+ def list_expenses_with_filters(
1264
+ db: Session,
1265
+ current_user: User,
1266
+ filters: 'ExpenseFilters'
1267
+ ) -> Tuple[List[TicketExpense], int]:
1268
+ """
1269
+ List expenses with comprehensive filtering support.
1270
+
1271
+ Args:
1272
+ db: Database session
1273
+ current_user: Current user
1274
+ filters: ExpenseFilters object with all filter criteria
1275
+
1276
+ Returns:
1277
+ Tuple of (expenses, total_count)
1278
+ """
1279
+ from app.schemas.filters import ExpenseFilters
1280
+ from app.models.ticket import Ticket
1281
+
1282
+ query = db.query(TicketExpense).options(
1283
+ joinedload(TicketExpense.incurred_by_user),
1284
+ joinedload(TicketExpense.approved_by_user),
1285
+ joinedload(TicketExpense.paid_to_user),
1286
+ joinedload(TicketExpense.ticket),
1287
+ joinedload(TicketExpense.assignment)
1288
+ ).filter(
1289
+ TicketExpense.deleted_at.is_(None)
1290
+ )
1291
+
1292
+ # Authorization filter
1293
+ if current_user.role == AppRole.FIELD_AGENT:
1294
+ # Field agents see only their own expenses
1295
+ query = query.filter(TicketExpense.incurred_by_user_id == current_user.id)
1296
+
1297
+ # UUID filters
1298
+ if filters.ticket_id:
1299
+ query = query.filter(TicketExpense.ticket_id == filters.ticket_id)
1300
+
1301
+ if filters.assignment_id:
1302
+ query = query.filter(TicketExpense.ticket_assignment_id == filters.assignment_id)
1303
+
1304
+ if filters.user_id:
1305
+ query = query.filter(TicketExpense.incurred_by_user_id == filters.user_id)
1306
+
1307
+ if filters.project_id:
1308
+ query = query.join(Ticket).filter(Ticket.project_id == filters.project_id)
1309
+
1310
+ # Multi-value filters
1311
+ if filters.category:
1312
+ query = query.filter(TicketExpense.category.in_(filters.category))
1313
+
1314
+ if filters.payment_method:
1315
+ query = query.filter(TicketExpense.payment_method.in_(filters.payment_method))
1316
+
1317
+ if filters.payment_recipient_type:
1318
+ query = query.filter(TicketExpense.payment_recipient_type.in_(filters.payment_recipient_type))
1319
+
1320
+ # Date filters
1321
+ if filters.expense_date:
1322
+ query = query.filter(TicketExpense.expense_date == filters.expense_date)
1323
+
1324
+ if filters.expense_date_from:
1325
+ query = query.filter(TicketExpense.expense_date >= filters.expense_date_from)
1326
+
1327
+ if filters.expense_date_to:
1328
+ query = query.filter(TicketExpense.expense_date <= filters.expense_date_to)
1329
+
1330
+ # Boolean filters
1331
+ if filters.is_approved is not None:
1332
+ query = query.filter(TicketExpense.is_approved == filters.is_approved)
1333
+
1334
+ if filters.is_paid is not None:
1335
+ query = query.filter(TicketExpense.is_paid == filters.is_paid)
1336
+
1337
+ if filters.location_verified is not None:
1338
+ query = query.filter(TicketExpense.location_verified == filters.location_verified)
1339
+
1340
+ if filters.has_receipt is not None:
1341
+ if filters.has_receipt:
1342
+ query = query.filter(TicketExpense.receipt_document_id.isnot(None))
1343
+ else:
1344
+ query = query.filter(TicketExpense.receipt_document_id.is_(None))
1345
+
1346
+ if filters.has_payment_details is not None:
1347
+ if filters.has_payment_details:
1348
+ query = query.filter(TicketExpense.payment_details.isnot(None))
1349
+ else:
1350
+ query = query.filter(TicketExpense.payment_details.is_(None))
1351
+
1352
+ if filters.ready_for_payment:
1353
+ # Approved + unpaid + has payment details
1354
+ query = query.filter(
1355
+ and_(
1356
+ TicketExpense.is_approved == True,
1357
+ TicketExpense.is_paid == False,
1358
+ TicketExpense.payment_details.isnot(None)
1359
+ )
1360
+ )
1361
+
1362
+ # Search filter
1363
+ if filters.search:
1364
+ search_term = f"%{filters.search}%"
1365
+ query = query.filter(
1366
+ or_(
1367
+ TicketExpense.description.ilike(search_term),
1368
+ TicketExpense.notes.ilike(search_term),
1369
+ TicketExpense.category.ilike(search_term)
1370
+ )
1371
+ )
1372
+
1373
+ # Get total count
1374
+ total = query.count()
1375
+
1376
+ # Sorting
1377
+ sort_column = TicketExpense.expense_date # default
1378
+ if filters.sort_by:
1379
+ if hasattr(TicketExpense, filters.sort_by):
1380
+ sort_column = getattr(TicketExpense, filters.sort_by)
1381
+
1382
+ if filters.sort_order == "asc":
1383
+ query = query.order_by(sort_column.asc())
1384
+ else:
1385
+ query = query.order_by(sort_column.desc())
1386
+
1387
+ # Secondary sort by created_at
1388
+ query = query.order_by(TicketExpense.created_at.desc())
1389
+
1390
+ # Apply pagination
1391
+ offset = (filters.page - 1) * filters.page_size
1392
+ expenses = query.offset(offset).limit(filters.page_size).all()
1393
+
1394
+ return expenses, total