Spaces:
Sleeping
Sleeping
Implement automatic clearing of irrelevant compensation fields when compensation_type changes, including validation updates and helper logic in project_service
Browse files
docs/dev/compensation_field_clearing.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Compensation Field Clearing Behavior
|
| 2 |
+
|
| 3 |
+
## Problem
|
| 4 |
+
When changing compensation type (e.g., from `per_day` to `per_ticket`), should the system keep old field values or clear them?
|
| 5 |
+
|
| 6 |
+
## Solution: Clear Irrelevant Fields (Industry Standard)
|
| 7 |
+
|
| 8 |
+
When `compensation_type` changes, the system automatically clears all compensation fields, then only the relevant field is set.
|
| 9 |
+
|
| 10 |
+
### Example Flow
|
| 11 |
+
|
| 12 |
+
**Before:**
|
| 13 |
+
```json
|
| 14 |
+
{
|
| 15 |
+
"compensation_type": "per_day",
|
| 16 |
+
"daily_rate": 1000.00
|
| 17 |
+
}
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
**User changes to per_ticket:**
|
| 21 |
+
```json
|
| 22 |
+
{
|
| 23 |
+
"compensation_type": "per_ticket",
|
| 24 |
+
"per_ticket_rate": 500.00
|
| 25 |
+
}
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
**Result (automatic clearing):**
|
| 29 |
+
```json
|
| 30 |
+
{
|
| 31 |
+
"compensation_type": "per_ticket",
|
| 32 |
+
"daily_rate": null, // ✅ Cleared automatically
|
| 33 |
+
"per_ticket_rate": 500.00
|
| 34 |
+
}
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## Why This Approach?
|
| 38 |
+
|
| 39 |
+
### ✅ Benefits
|
| 40 |
+
1. **Clarity**: Only one payment amount is active
|
| 41 |
+
2. **Prevents Errors**: Can't accidentally use wrong field in payroll calculations
|
| 42 |
+
3. **User Expectation**: Matches how users think ("I changed the payment type")
|
| 43 |
+
4. **Industry Standard**: Used by Stripe, QuickBooks, most modern SaaS
|
| 44 |
+
|
| 45 |
+
### ❌ Alternative (Keep All Fields)
|
| 46 |
+
- **Problem**: Confusing which field is "active"
|
| 47 |
+
- **Problem**: Risk of using wrong field in calculations
|
| 48 |
+
- **Problem**: Database has stale data
|
| 49 |
+
- **When Used**: Legacy systems, audit-heavy systems
|
| 50 |
+
|
| 51 |
+
## Implementation
|
| 52 |
+
|
| 53 |
+
### Function: `_clear_irrelevant_compensation_fields()`
|
| 54 |
+
Located in: `src/app/services/project_service.py`
|
| 55 |
+
|
| 56 |
+
```python
|
| 57 |
+
@staticmethod
|
| 58 |
+
def _clear_irrelevant_compensation_fields(role: ProjectRole, compensation_type: str):
|
| 59 |
+
"""
|
| 60 |
+
Clear compensation fields that don't apply to the selected type.
|
| 61 |
+
World-class systems (Stripe, QuickBooks) clear irrelevant fields on type change.
|
| 62 |
+
"""
|
| 63 |
+
# Clear ALL fields first
|
| 64 |
+
role.daily_rate = None
|
| 65 |
+
role.weekly_rate = None
|
| 66 |
+
role.per_ticket_rate = None
|
| 67 |
+
role.flat_rate_amount = None
|
| 68 |
+
role.commission_percentage = None
|
| 69 |
+
role.base_amount = None
|
| 70 |
+
role.bonus_percentage = None
|
| 71 |
+
role.hourly_rate = None
|
| 72 |
+
|
| 73 |
+
# The relevant field will be set by the caller after this
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### When It Runs
|
| 77 |
+
1. **On Create**: Fields are set based on compensation_type (others remain NULL)
|
| 78 |
+
2. **On Update**: If compensation_type changes, all fields cleared first, then new field set
|
| 79 |
+
|
| 80 |
+
## Field Mapping
|
| 81 |
+
|
| 82 |
+
| Compensation Type | Active Field | All Others |
|
| 83 |
+
|------------------|--------------|------------|
|
| 84 |
+
| `per_day` | `daily_rate` | NULL |
|
| 85 |
+
| `per_week` | `weekly_rate` | NULL |
|
| 86 |
+
| `per_ticket` | `per_ticket_rate` | NULL |
|
| 87 |
+
| `flat_rate` | `flat_rate_amount` | NULL |
|
| 88 |
+
| `hourly` | `hourly_rate` | NULL |
|
| 89 |
+
| `commission` | `commission_percentage` | NULL |
|
| 90 |
+
| `hybrid` | `base_amount` + `commission_percentage` | Others NULL |
|
| 91 |
+
| `custom` | Any combination | User decides |
|
| 92 |
+
|
| 93 |
+
## User Experience
|
| 94 |
+
|
| 95 |
+
### Frontend Should:
|
| 96 |
+
1. Show only relevant field based on selected type
|
| 97 |
+
2. Hide/disable irrelevant fields
|
| 98 |
+
3. Clear hidden fields when type changes (or let backend handle it)
|
| 99 |
+
|
| 100 |
+
### Example UI Flow:
|
| 101 |
+
```
|
| 102 |
+
User selects: "Per Day"
|
| 103 |
+
→ Show: Daily Rate input (1000 KES)
|
| 104 |
+
→ Hide: Weekly Rate, Per Ticket Rate, etc.
|
| 105 |
+
|
| 106 |
+
User changes to: "Per Ticket"
|
| 107 |
+
→ Show: Per Ticket Rate input (500 KES)
|
| 108 |
+
→ Hide: Daily Rate (value cleared in backend)
|
| 109 |
+
→ Previous daily_rate value is gone
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## Payroll Calculation Safety
|
| 113 |
+
|
| 114 |
+
With field clearing, payroll calculation is simple and safe:
|
| 115 |
+
|
| 116 |
+
```python
|
| 117 |
+
if role.compensation_type == "per_day":
|
| 118 |
+
earnings = days_worked * role.daily_rate # ✅ Only this field has value
|
| 119 |
+
elif role.compensation_type == "per_ticket":
|
| 120 |
+
earnings = tickets_closed * role.per_ticket_rate # ✅ Only this field has value
|
| 121 |
+
# No risk of using wrong field!
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Migration Note
|
| 125 |
+
|
| 126 |
+
Existing roles are NOT affected by this change:
|
| 127 |
+
- Old data remains as-is
|
| 128 |
+
- Clearing only happens on UPDATE when type changes
|
| 129 |
+
- Backward compatible
|
| 130 |
+
|
| 131 |
+
## References
|
| 132 |
+
|
| 133 |
+
**Industry Examples:**
|
| 134 |
+
- **Stripe**: Changing payment method clears old payment details
|
| 135 |
+
- **QuickBooks**: Changing employee pay type clears irrelevant fields
|
| 136 |
+
- **Workday**: Compensation changes clear previous structure
|
| 137 |
+
- **BambooHR**: Pay type change resets compensation fields
|
| 138 |
+
|
| 139 |
+
This is the standard approach in modern SaaS systems.
|
docs/devlogs/db/logs.sql
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
[{"idx":0,"id":"096f3c95-b8fb-4fa8-9602-9f5f0da467d8","ticket_id":"0fd3ee15-5e7d-465a-b377-155f9bdb7e70","ticket_assignment_id":"d6f25868-5117-4b85-8bb3-44314144ef6e","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-01 05:50:51.241567+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-01 05:50:51.282714+00","deleted_at":null},{"idx":1,"id":"265382ad-9448-4dca-9459-77751afbfa65","ticket_id":"70090c47-e9c1-4b0a-add4-69bec53d92f9","ticket_assignment_id":"20772cb1-ec31-41dc-9cb0-73649dc6ac55","old_status":"in_progress","new_status":"completed","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket completed with all requirements satisfied","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-09 06:11:34.954989+00","notes":"well done","additional_metadata":"{}","created_at":"2025-12-09 06:11:35.031582+00","deleted_at":null},{"idx":2,"id":"2920a339-4a41-4806-bdc4-2a4d846fefb9","ticket_id":"2de41ce7-dff1-4151-9710-87958d18b5c4","ticket_assignment_id":"34c2a077-5630-4af8-843d-e125c2497267","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 11:26:09.599984+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 11:26:09.65707+00","deleted_at":null},{"idx":3,"id":"4242169c-27d7-47c4-836f-8fa78175f1e9","ticket_id":"70090c47-e9c1-4b0a-add4-69bec53d92f9","ticket_assignment_id":"20772cb1-ec31-41dc-9cb0-73649dc6ac55","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 08:04:10.126738+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 08:04:10.170657+00","deleted_at":null},{"idx":4,"id":"48ef8f9d-731d-4e74-b2ed-136f3bd1d026","ticket_id":"0fd3ee15-5e7d-465a-b377-155f9bdb7e70","ticket_assignment_id":"d6f25868-5117-4b85-8bb3-44314144ef6e","old_status":"assigned","new_status":"in_progress","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Agent started journey to site","communication_method":"app","location_latitude":-1.2200188,"location_longitude":36.8770193,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-01 05:52:03.358718+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-01 05:52:03.372316+00","deleted_at":null},{"idx":5,"id":"5a2da76e-ed83-4452-99b1-07279bce2c75","ticket_id":"1e622599-1909-49b9-9d8b-4c5cb483b29e","ticket_assignment_id":"4fe9d28c-b657-4672-9138-78edabb9749b","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-10 11:05:29.244044+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-10 11:05:29.291039+00","deleted_at":null},{"idx":6,"id":"5ea8357b-ec4e-4619-84bf-b86ac84af7c7","ticket_id":"f59b29fc-d0b9-4618-b0d1-889e340da612","ticket_assignment_id":null,"old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-11-30 10:21:10.085345+00","notes":null,"additional_metadata":"{}","created_at":"2025-11-30 10:21:10.120971+00","deleted_at":null},{"idx":7,"id":"80d8c547-3a5b-421a-92e4-43fdbc48f616","ticket_id":"169eec08-654d-4ffe-bdb3-45fad1101637","ticket_assignment_id":"f5b40f0c-bc9c-4904-9ec1-7e570bda34eb","old_status":"assigned","new_status":"in_progress","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Agent started journey to site","communication_method":"app","location_latitude":-1.2200555,"location_longitude":36.876972,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 13:21:46.868373+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 13:21:46.892776+00","deleted_at":null},{"idx":8,"id":"b556d270-ecd0-4e69-ba8f-2b01b4770a59","ticket_id":"1f807cf8-f139-421b-86e3-38c2f8bc7070","ticket_assignment_id":"69a2a4f6-f72a-425c-9f90-4088e074c13b","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-02 14:12:10.074375+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-02 14:12:10.121546+00","deleted_at":null},{"idx":9,"id":"bd68aa7b-f77b-49e8-8b89-dda96ee027e2","ticket_id":"8f08ad14-df8b-4780-84e7-0d45e133f2a6","ticket_assignment_id":"b3a83bd0-d287-4cea-a1c8-8bef145c1296","old_status":"in_progress","new_status":"completed","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket completed with all requirements satisfied","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-11-30 12:45:46.345702+00","notes":"work done","additional_metadata":"{}","created_at":"2025-11-30 12:45:46.438515+00","deleted_at":null},{"idx":10,"id":"bf32cd5d-7e10-40f9-93df-8b133d1cbe1e","ticket_id":"169eec08-654d-4ffe-bdb3-45fad1101637","ticket_assignment_id":"f5b40f0c-bc9c-4904-9ec1-7e570bda34eb","old_status":"in_progress","new_status":"completed","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket completed with all requirements satisfied","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-10 11:25:21.69739+00","notes":"work was done","additional_metadata":"{}","created_at":"2025-12-10 11:25:21.741359+00","deleted_at":null},{"idx":11,"id":"d2f0d9af-5b7d-4c7a-a3df-dc450f759eed","ticket_id":"1f807cf8-f139-421b-86e3-38c2f8bc7070","ticket_assignment_id":"69a2a4f6-f72a-425c-9f90-4088e074c13b","old_status":"in_progress","new_status":"pending_review","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket dropped by agent - cancellation: opted for another installer","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 11:59:40.305252+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 11:59:40.36956+00","deleted_at":null},{"idx":12,"id":"db41cedc-5cef-4d6b-95a5-9be9b764a564","ticket_id":"169eec08-654d-4ffe-bdb3-45fad1101637","ticket_assignment_id":"f5b40f0c-bc9c-4904-9ec1-7e570bda34eb","old_status":"open","new_status":"assigned","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Self-assigned by agent","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 13:21:09.488078+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 13:21:09.500288+00","deleted_at":null},{"idx":13,"id":"df7dc5a2-273a-4256-8203-5b42ef347da3","ticket_id":"2de41ce7-dff1-4151-9710-87958d18b5c4","ticket_assignment_id":"34c2a077-5630-4af8-843d-e125c2497267","old_status":"assigned","new_status":"in_progress","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Agent started journey to site","communication_method":"app","location_latitude":-1.21995899256506,"location_longitude":36.8769753048327,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 11:26:46.747547+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 11:26:46.795274+00","deleted_at":null},{"idx":14,"id":"e8de766c-14f9-4425-8c67-a77fa671b16f","ticket_id":"0fd3ee15-5e7d-465a-b377-155f9bdb7e70","ticket_assignment_id":"d6f25868-5117-4b85-8bb3-44314144ef6e","old_status":"in_progress","new_status":"completed","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket completed with all requirements satisfied","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-01 05:54:37.189824+00","notes":"Hhhdg","additional_metadata":"{}","created_at":"2025-12-01 05:54:37.249557+00","deleted_at":null},{"idx":15,"id":"f6a3e40d-c238-4b8e-ac9e-9478645aa077","ticket_id":"1f807cf8-f139-421b-86e3-38c2f8bc7070","ticket_assignment_id":"69a2a4f6-f72a-425c-9f90-4088e074c13b","old_status":"assigned","new_status":"in_progress","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Agent started journey to site","communication_method":"app","location_latitude":-1.2200122,"location_longitude":36.8770178,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-02 14:12:27.929761+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-02 14:12:27.941753+00","deleted_at":null},{"idx":16,"id":"f8e57466-88c1-4c90-968c-678ea2ed485a","ticket_id":"70090c47-e9c1-4b0a-add4-69bec53d92f9","ticket_assignment_id":"20772cb1-ec31-41dc-9cb0-73649dc6ac55","old_status":"assigned","new_status":"in_progress","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Agent started journey to site","communication_method":"app","location_latitude":-1.2200548,"location_longitude":36.876972,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-03 08:04:25.13036+00","notes":null,"additional_metadata":"{}","created_at":"2025-12-03 08:04:25.139554+00","deleted_at":null},{"idx":17,"id":"faf96f42-b676-4c03-bf71-895aaace1ca4","ticket_id":"2de41ce7-dff1-4151-9710-87958d18b5c4","ticket_assignment_id":"34c2a077-5630-4af8-843d-e125c2497267","old_status":"in_progress","new_status":"completed","changed_by_user_id":"43b778b0-2062-4724-abbb-916a4835a9b0","change_reason":"Ticket completed with all requirements satisfied","communication_method":"app","location_latitude":null,"location_longitude":null,"location_accuracy":null,"location_name":null,"location_verified":false,"location_distance_meters":null,"changed_at":"2025-12-10 12:36:25.46604+00","notes":"done","additional_metadata":"{}","created_at":"2025-12-10 12:36:25.536221+00","deleted_at":null}]
|
|
|
|
| 1 |
+
[{"idx":0,"id":"0ab58206-86b4-4a98-972c-579a440bbe34","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"role","compensation_type":"flat_rate","flat_rate_amount":"1000.00","commission_percentage":null,"base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"role of the workser","is_active":true,"created_at":"2025-11-24 12:50:39.153498+00","updated_at":"2025-11-24 12:50:39.153501+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":null},{"idx":1,"id":"351a94cf-1556-4e69-9f54-5c95e846284a","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"New role","compensation_type":"commission","flat_rate_amount":"100.00","commission_percentage":"50.00","base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"new role in system","is_active":true,"created_at":"2025-11-24 12:52:50.952608+00","updated_at":"2025-11-24 12:52:50.95261+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":null},{"idx":2,"id":"7c88af42-df6e-4933-bf4c-026ee051e313","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"Rihanna","compensation_type":"flat_rate","flat_rate_amount":"1000.00","commission_percentage":null,"base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"Work work work","is_active":true,"created_at":"2025-11-23 12:38:50.921751+00","updated_at":"2025-11-23 12:38:50.921755+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":null},{"idx":3,"id":"7e0f7731-737c-4fb3-8b1a-fc98e54680b4","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"Poles","compensation_type":"per_ticket","flat_rate_amount":"1000.00","commission_percentage":null,"base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"pole 1","is_active":true,"created_at":"2025-11-24 12:40:32.367092+00","updated_at":"2025-12-11 11:30:50.864049+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":"300.00"},{"idx":4,"id":"ac88b852-ba9c-4b83-9a35-ccaee4d79b1f","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"Project Manager","compensation_type":"flat_rate","flat_rate_amount":"10000.00","commission_percentage":null,"base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"Manages this whole project","is_active":true,"created_at":"2025-11-23 13:48:30.87883+00","updated_at":"2025-11-23 13:48:30.878833+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":null},{"idx":5,"id":"e5b69085-33dd-4126-bb14-9e57a455d7b1","project_id":"0ade6bd1-e492-4e25-b681-59f42058d29a","role_name":"Rigger","compensation_type":"flat_rate","flat_rate_amount":"99.00","commission_percentage":null,"base_amount":null,"bonus_percentage":null,"hourly_rate":null,"description":"Rigging","is_active":true,"created_at":"2025-11-24 12:54:59.913112+00","updated_at":"2025-11-24 12:54:59.913118+00","deleted_at":null,"daily_rate":null,"weekly_rate":null,"per_ticket_rate":null}]
|
src/app/services/project_service.py
CHANGED
|
@@ -1206,8 +1206,16 @@ class ProjectService(BaseFilterService):
|
|
| 1206 |
# Update existing role (idempotent behavior)
|
| 1207 |
logger.info(f"Role '{data.role_name}' already exists, updating instead")
|
| 1208 |
|
|
|
|
|
|
|
|
|
|
| 1209 |
existing.description = data.description
|
| 1210 |
existing.compensation_type = data.compensation_type
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
existing.flat_rate_amount = data.flat_rate_amount
|
| 1212 |
existing.commission_percentage = data.commission_percentage
|
| 1213 |
existing.base_amount = data.base_amount
|
|
@@ -1226,6 +1234,11 @@ class ProjectService(BaseFilterService):
|
|
| 1226 |
role_name=data.role_name,
|
| 1227 |
description=data.description,
|
| 1228 |
compensation_type=data.compensation_type,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1229 |
flat_rate_amount=data.flat_rate_amount,
|
| 1230 |
commission_percentage=data.commission_percentage,
|
| 1231 |
base_amount=data.base_amount,
|
|
@@ -1241,12 +1254,53 @@ class ProjectService(BaseFilterService):
|
|
| 1241 |
logger.info(f"Created role '{role.role_name}' for project {project_id}")
|
| 1242 |
return role
|
| 1243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
@staticmethod
|
| 1245 |
def _validate_compensation_structure(data):
|
| 1246 |
"""Validate compensation fields based on type"""
|
| 1247 |
comp_type = data.compensation_type
|
| 1248 |
|
| 1249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
if not data.flat_rate_amount or data.flat_rate_amount <= 0:
|
| 1251 |
raise HTTPException(
|
| 1252 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -1264,16 +1318,16 @@ class ProjectService(BaseFilterService):
|
|
| 1264 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1265 |
detail="hourly_rate is required for hourly compensation"
|
| 1266 |
)
|
| 1267 |
-
elif comp_type == '
|
| 1268 |
if not data.base_amount or data.base_amount < 0:
|
| 1269 |
raise HTTPException(
|
| 1270 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1271 |
-
detail="base_amount is required for
|
| 1272 |
)
|
| 1273 |
if not data.commission_percentage or data.commission_percentage <= 0:
|
| 1274 |
raise HTTPException(
|
| 1275 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1276 |
-
detail="commission_percentage is required for
|
| 1277 |
)
|
| 1278 |
|
| 1279 |
@staticmethod
|
|
@@ -1334,12 +1388,21 @@ class ProjectService(BaseFilterService):
|
|
| 1334 |
detail=f"Role '{data.role_name}' already exists in this project"
|
| 1335 |
)
|
| 1336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1337 |
# Validate compensation if type is changing
|
| 1338 |
if data.compensation_type:
|
| 1339 |
# Create a temporary object for validation
|
| 1340 |
from types import SimpleNamespace
|
| 1341 |
temp_data = SimpleNamespace(
|
| 1342 |
compensation_type=data.compensation_type,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1343 |
flat_rate_amount=data.flat_rate_amount if data.flat_rate_amount is not None else role.flat_rate_amount,
|
| 1344 |
commission_percentage=data.commission_percentage if data.commission_percentage is not None else role.commission_percentage,
|
| 1345 |
hourly_rate=data.hourly_rate if data.hourly_rate is not None else role.hourly_rate,
|
|
|
|
| 1206 |
# Update existing role (idempotent behavior)
|
| 1207 |
logger.info(f"Role '{data.role_name}' already exists, updating instead")
|
| 1208 |
|
| 1209 |
+
# Clear irrelevant fields when compensation type changes
|
| 1210 |
+
ProjectService._clear_irrelevant_compensation_fields(existing, data.compensation_type)
|
| 1211 |
+
|
| 1212 |
existing.description = data.description
|
| 1213 |
existing.compensation_type = data.compensation_type
|
| 1214 |
+
# NEW - Simple fields
|
| 1215 |
+
existing.daily_rate = data.daily_rate
|
| 1216 |
+
existing.weekly_rate = data.weekly_rate
|
| 1217 |
+
existing.per_ticket_rate = data.per_ticket_rate
|
| 1218 |
+
# OLD - Legacy fields
|
| 1219 |
existing.flat_rate_amount = data.flat_rate_amount
|
| 1220 |
existing.commission_percentage = data.commission_percentage
|
| 1221 |
existing.base_amount = data.base_amount
|
|
|
|
| 1234 |
role_name=data.role_name,
|
| 1235 |
description=data.description,
|
| 1236 |
compensation_type=data.compensation_type,
|
| 1237 |
+
# NEW - Simple fields
|
| 1238 |
+
daily_rate=data.daily_rate,
|
| 1239 |
+
weekly_rate=data.weekly_rate,
|
| 1240 |
+
per_ticket_rate=data.per_ticket_rate,
|
| 1241 |
+
# OLD - Legacy fields
|
| 1242 |
flat_rate_amount=data.flat_rate_amount,
|
| 1243 |
commission_percentage=data.commission_percentage,
|
| 1244 |
base_amount=data.base_amount,
|
|
|
|
| 1254 |
logger.info(f"Created role '{role.role_name}' for project {project_id}")
|
| 1255 |
return role
|
| 1256 |
|
| 1257 |
+
@staticmethod
|
| 1258 |
+
def _clear_irrelevant_compensation_fields(role: ProjectRole, compensation_type: str):
|
| 1259 |
+
"""
|
| 1260 |
+
Clear compensation fields that don't apply to the selected type.
|
| 1261 |
+
This prevents confusion and ensures only relevant fields have values.
|
| 1262 |
+
|
| 1263 |
+
World-class systems (Stripe, QuickBooks) clear irrelevant fields on type change.
|
| 1264 |
+
"""
|
| 1265 |
+
# Clear ALL fields first
|
| 1266 |
+
role.daily_rate = None
|
| 1267 |
+
role.weekly_rate = None
|
| 1268 |
+
role.per_ticket_rate = None
|
| 1269 |
+
role.flat_rate_amount = None
|
| 1270 |
+
role.commission_percentage = None
|
| 1271 |
+
role.base_amount = None
|
| 1272 |
+
role.bonus_percentage = None
|
| 1273 |
+
role.hourly_rate = None
|
| 1274 |
+
|
| 1275 |
+
# Note: The relevant field will be set by the caller after this function
|
| 1276 |
+
# This ensures clean state - only one payment method is active
|
| 1277 |
+
|
| 1278 |
@staticmethod
|
| 1279 |
def _validate_compensation_structure(data):
|
| 1280 |
"""Validate compensation fields based on type"""
|
| 1281 |
comp_type = data.compensation_type
|
| 1282 |
|
| 1283 |
+
# NEW - Simple types
|
| 1284 |
+
if comp_type == 'per_day':
|
| 1285 |
+
if not data.daily_rate or data.daily_rate <= 0:
|
| 1286 |
+
raise HTTPException(
|
| 1287 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1288 |
+
detail="daily_rate is required for per_day compensation (e.g., 1000 KES/day)"
|
| 1289 |
+
)
|
| 1290 |
+
elif comp_type == 'per_week':
|
| 1291 |
+
if not data.weekly_rate or data.weekly_rate <= 0:
|
| 1292 |
+
raise HTTPException(
|
| 1293 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1294 |
+
detail="weekly_rate is required for per_week compensation (e.g., 7000 KES/week)"
|
| 1295 |
+
)
|
| 1296 |
+
elif comp_type == 'per_ticket':
|
| 1297 |
+
if not data.per_ticket_rate or data.per_ticket_rate <= 0:
|
| 1298 |
+
raise HTTPException(
|
| 1299 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1300 |
+
detail="per_ticket_rate is required for per_ticket compensation (e.g., 500 KES/ticket)"
|
| 1301 |
+
)
|
| 1302 |
+
# OLD - Legacy types
|
| 1303 |
+
elif comp_type == 'flat_rate':
|
| 1304 |
if not data.flat_rate_amount or data.flat_rate_amount <= 0:
|
| 1305 |
raise HTTPException(
|
| 1306 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
| 1318 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1319 |
detail="hourly_rate is required for hourly compensation"
|
| 1320 |
)
|
| 1321 |
+
elif comp_type == 'hybrid':
|
| 1322 |
if not data.base_amount or data.base_amount < 0:
|
| 1323 |
raise HTTPException(
|
| 1324 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1325 |
+
detail="base_amount is required for hybrid compensation"
|
| 1326 |
)
|
| 1327 |
if not data.commission_percentage or data.commission_percentage <= 0:
|
| 1328 |
raise HTTPException(
|
| 1329 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1330 |
+
detail="commission_percentage is required for hybrid compensation"
|
| 1331 |
)
|
| 1332 |
|
| 1333 |
@staticmethod
|
|
|
|
| 1388 |
detail=f"Role '{data.role_name}' already exists in this project"
|
| 1389 |
)
|
| 1390 |
|
| 1391 |
+
# If compensation type is changing, clear irrelevant fields
|
| 1392 |
+
if data.compensation_type and data.compensation_type != role.compensation_type:
|
| 1393 |
+
ProjectService._clear_irrelevant_compensation_fields(role, data.compensation_type)
|
| 1394 |
+
|
| 1395 |
# Validate compensation if type is changing
|
| 1396 |
if data.compensation_type:
|
| 1397 |
# Create a temporary object for validation
|
| 1398 |
from types import SimpleNamespace
|
| 1399 |
temp_data = SimpleNamespace(
|
| 1400 |
compensation_type=data.compensation_type,
|
| 1401 |
+
# NEW - Simple fields
|
| 1402 |
+
daily_rate=data.daily_rate if data.daily_rate is not None else role.daily_rate,
|
| 1403 |
+
weekly_rate=data.weekly_rate if data.weekly_rate is not None else role.weekly_rate,
|
| 1404 |
+
per_ticket_rate=data.per_ticket_rate if data.per_ticket_rate is not None else role.per_ticket_rate,
|
| 1405 |
+
# OLD - Legacy fields
|
| 1406 |
flat_rate_amount=data.flat_rate_amount if data.flat_rate_amount is not None else role.flat_rate_amount,
|
| 1407 |
commission_percentage=data.commission_percentage if data.commission_percentage is not None else role.commission_percentage,
|
| 1408 |
hourly_rate=data.hourly_rate if data.hourly_rate is not None else role.hourly_rate,
|