Coverage for modules\hr\services.py: 5%
532 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 17:03 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 17:03 +0800
1from decimal import Decimal, ROUND_HALF_UP
2from typing import Tuple, List, Optional
3from uuid import UUID
4from datetime import date
5from core.exceptions import PermissionDeniedException
6from modules.team.models import TeamMember
7from .models import Month13Bonus
10def require_hr(request):
11 from core.permissions import require_tenant
12 tenant_id = require_tenant(request)
13 if request.user.is_superuser or TeamMember.objects.filter(tenant_id=tenant_id, user=request.user, role__in=['OWNER', 'ADMIN', 'HR'], status='ACTIVE').exists():
14 return tenant_id
15 raise PermissionDeniedException('HR access is required.')
18def calculate_withholding_tax(annual_taxable_income):
19 income = max(Decimal('0'), Decimal(annual_taxable_income))
20 if income <= 400000:
21 tax = Decimal('0')
22 elif income <= 800000:
23 tax = (income - 400000) * Decimal('.15')
24 elif income <= 2000000:
25 tax = Decimal('60000') + (income - 800000) * Decimal('.20')
26 elif income <= 8000000:
27 tax = Decimal('300000') + (income - 2000000) * Decimal('.25')
28 else:
29 tax = Decimal('1800000') + (income - 8000000) * Decimal('.35')
30 return tax.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
33def calculate_sss_contribution(monthly_gross):
34 """Calculate SSS contribution based on monthly gross salary (2023 tables)."""
35 gross = max(Decimal('0'), monthly_gross)
36 # SSS contribution table: ranges from 1,125 to 22,500 monthly salary
37 if gross <= 0:
38 return Decimal('0')
39 elif gross <= 3250:
40 return Decimal('112.50')
41 elif gross <= 3750:
42 return Decimal('135.00')
43 elif gross <= 4250:
44 return Decimal('157.50')
45 elif gross <= 4750:
46 return Decimal('180.00')
47 elif gross <= 5250:
48 return Decimal('202.50')
49 elif gross <= 5750:
50 return Decimal('225.00')
51 elif gross <= 6250:
52 return Decimal('247.50')
53 elif gross <= 6750:
54 return Decimal('270.00')
55 elif gross <= 7250:
56 return Decimal('292.50')
57 elif gross <= 7750:
58 return Decimal('315.00')
59 elif gross <= 8250:
60 return Decimal('337.50')
61 elif gross <= 8750:
62 return Decimal('360.00')
63 elif gross <= 9250:
64 return Decimal('382.50')
65 elif gross <= 9750:
66 return Decimal('405.00')
67 elif gross <= 10250:
68 return Decimal('427.50')
69 elif gross <= 10750:
70 return Decimal('450.00')
71 elif gross <= 11250:
72 return Decimal('472.50')
73 elif gross <= 11750:
74 return Decimal('495.00')
75 elif gross <= 12250:
76 return Decimal('517.50')
77 elif gross <= 12750:
78 return Decimal('540.00')
79 elif gross <= 13250:
80 return Decimal('562.50')
81 elif gross <= 13750:
82 return Decimal('585.00')
83 elif gross <= 14250:
84 return Decimal('607.50')
85 elif gross <= 14750:
86 return Decimal('630.00')
87 elif gross <= 15250:
88 return Decimal('652.50')
89 elif gross <= 15750:
90 return Decimal('675.00')
91 elif gross <= 16250:
92 return Decimal('697.50')
93 elif gross <= 16750:
94 return Decimal('720.00')
95 elif gross <= 17250:
96 return Decimal('742.50')
97 elif gross <= 17750:
98 return Decimal('765.00')
99 elif gross <= 18250:
100 return Decimal('787.50')
101 elif gross <= 18750:
102 return Decimal('810.00')
103 elif gross <= 19250:
104 return Decimal('832.50')
105 elif gross <= 19750:
106 return Decimal('855.00')
107 elif gross <= 20250:
108 return Decimal('877.50')
109 elif gross <= 20750:
110 return Decimal('900.00')
111 elif gross <= 21250:
112 return Decimal('922.50')
113 elif gross <= 21750:
114 return Decimal('945.00')
115 elif gross <= 22250:
116 return Decimal('967.50')
117 elif gross <= 22750:
118 return Decimal('990.00')
119 elif gross <= 23250:
120 return Decimal('1012.50')
121 elif gross <= 23750:
122 return Decimal('1035.00')
123 elif gross <= 24250:
124 return Decimal('1057.50')
125 elif gross <= 24750:
126 return Decimal('1080.00')
127 elif gross <= 25250:
128 return Decimal('1102.50')
129 elif gross <= 25750:
130 return Decimal('1125.00')
131 elif gross <= 26250:
132 return Decimal('1147.50')
133 elif gross <= 26750:
134 return Decimal('1170.00')
135 elif gross <= 27250:
136 return Decimal('1192.50')
137 elif gross <= 27750:
138 return Decimal('1215.00')
139 elif gross <= 28250:
140 return Decimal('1237.50')
141 elif gross <= 28750:
142 return Decimal('1260.00')
143 elif gross <= 29250:
144 return Decimal('1282.50')
145 elif gross <= 29750:
146 return Decimal('1305.00')
147 elif gross <= 30250:
148 return Decimal('1327.50')
149 elif gross <= 30750:
150 return Decimal('1350.00')
151 elif gross <= 31250:
152 return Decimal('1372.50')
153 elif gross <= 31750:
154 return Decimal('1395.00')
155 elif gross <= 32250:
156 return Decimal('1417.50')
157 elif gross <= 32750:
158 return Decimal('1440.00')
159 elif gross <= 33250:
160 return Decimal('1462.50')
161 elif gross <= 33750:
162 return Decimal('1485.00')
163 elif gross <= 34250:
164 return Decimal('1507.50')
165 elif gross <= 34750:
166 return Decimal('1530.00')
167 elif gross <= 35250:
168 return Decimal('1552.50')
169 elif gross <= 35750:
170 return Decimal('1575.00')
171 elif gross <= 36250:
172 return Decimal('1597.50')
173 elif gross <= 36750:
174 return Decimal('1620.00')
175 elif gross <= 37250:
176 return Decimal('1642.50')
177 elif gross <= 37750:
178 return Decimal('1665.00')
179 elif gross <= 38250:
180 return Decimal('1687.50')
181 elif gross <= 38750:
182 return Decimal('1710.00')
183 elif gross <= 39250:
184 return Decimal('1732.50')
185 elif gross <= 39750:
186 return Decimal('1755.00')
187 elif gross <= 40250:
188 return Decimal('1777.50')
189 elif gross <= 40750:
190 return Decimal('1800.00')
191 elif gross <= 41250:
192 return Decimal('1822.50')
193 elif gross <= 41750:
194 return Decimal('1845.00')
195 elif gross <= 42250:
196 return Decimal('1867.50')
197 elif gross <= 42750:
198 return Decimal('1890.00')
199 elif gross <= 43250:
200 return Decimal('1912.50')
201 elif gross <= 43750:
202 return Decimal('1935.00')
203 elif gross <= 44250:
204 return Decimal('1957.50')
205 elif gross <= 44750:
206 return Decimal('1980.00')
207 elif gross <= 45250:
208 return Decimal('2002.50')
209 elif gross <= 45750:
210 return Decimal('2025.00')
211 elif gross <= 46250:
212 return Decimal('2047.50')
213 elif gross <= 46750:
214 return Decimal('2070.00')
215 elif gross <= 47250:
216 return Decimal('2092.50')
217 elif gross <= 47750:
218 return Decimal('2115.00')
219 elif gross <= 48250:
220 return Decimal('2137.50')
221 elif gross <= 48750:
222 return Decimal('2160.00')
223 elif gross <= 49250:
224 return Decimal('2182.50')
225 elif gross <= 49750:
226 return Decimal('2205.00')
227 elif gross <= 50250:
228 return Decimal('2227.50')
229 elif gross <= 50750:
230 return Decimal('2250.00')
231 elif gross <= 51250:
232 return Decimal('2272.50')
233 elif gross <= 51750:
234 return Decimal('2295.00')
235 elif gross <= 52250:
236 return Decimal('2317.50')
237 elif gross <= 52750:
238 return Decimal('2340.00')
239 elif gross <= 53250:
240 return Decimal('2362.50')
241 elif gross <= 53750:
242 return Decimal('2385.00')
243 elif gross <= 54250:
244 return Decimal('2407.50')
245 elif gross <= 54750:
246 return Decimal('2430.00')
247 elif gross <= 55250:
248 return Decimal('2452.50')
249 elif gross <= 55750:
250 return Decimal('2475.00')
251 elif gross <= 56250:
252 return Decimal('2497.50')
253 elif gross <= 56750:
254 return Decimal('2520.00')
255 elif gross <= 57250:
256 return Decimal('2542.50')
257 elif gross <= 57750:
258 return Decimal('2565.00')
259 elif gross <= 58250:
260 return Decimal('2587.50')
261 elif gross <= 58750:
262 return Decimal('2610.00')
263 elif gross <= 59250:
264 return Decimal('2632.50')
265 elif gross <= 59750:
266 return Decimal('2655.00')
267 elif gross <= 60250:
268 return Decimal('2677.50')
269 elif gross <= 60750:
270 return Decimal('2700.00')
271 elif gross <= 61250:
272 return Decimal('2722.50')
273 elif gross <= 61750:
274 return Decimal('2745.00')
275 elif gross <= 62250:
276 return Decimal('2767.50')
277 elif gross <= 62750:
278 return Decimal('2790.00')
279 elif gross <= 63250:
280 return Decimal('2812.50')
281 elif gross <= 63750:
282 return Decimal('2835.00')
283 elif gross <= 64250:
284 return Decimal('2857.50')
285 elif gross <= 64750:
286 return Decimal('2880.00')
287 elif gross <= 65250:
288 return Decimal('2902.50')
289 elif gross <= 65750:
290 return Decimal('2925.00')
291 elif gross <= 66250:
292 return Decimal('2947.50')
293 elif gross <= 66750:
294 return Decimal('2970.00')
295 elif gross <= 67250:
296 return Decimal('2992.50')
297 elif gross <= 67750:
298 return Decimal('3015.00')
299 elif gross <= 68250:
300 return Decimal('3037.50')
301 elif gross <= 68750:
302 return Decimal('3060.00')
303 elif gross <= 69250:
304 return Decimal('3082.50')
305 elif gross <= 69750:
306 return Decimal('3105.00')
307 elif gross <= 70250:
308 return Decimal('3127.50')
309 elif gross <= 70750:
310 return Decimal('3150.00')
311 elif gross <= 71250:
312 return Decimal('3172.50')
313 elif gross <= 71750:
314 return Decimal('3195.00')
315 elif gross <= 72250:
316 return Decimal('3217.50')
317 elif gross <= 72750:
318 return Decimal('3240.00')
319 elif gross <= 73250:
320 return Decimal('3262.50')
321 elif gross <= 73750:
322 return Decimal('3285.00')
323 elif gross <= 74250:
324 return Decimal('3307.50')
325 elif gross <= 74750:
326 return Decimal('3330.00')
327 elif gross <= 75250:
328 return Decimal('3352.50')
329 elif gross <= 75750:
330 return Decimal('3375.00')
331 elif gross <= 76250:
332 return Decimal('3397.50')
333 elif gross <= 76750:
334 return Decimal('3420.00')
335 elif gross <= 77250:
336 return Decimal('3442.50')
337 elif gross <= 77750:
338 return Decimal('3465.00')
339 elif gross <= 78250:
340 return Decimal('3487.50')
341 elif gross <= 78750:
342 return Decimal('3510.00')
343 elif gross <= 79250:
344 return Decimal('3532.50')
345 elif gross <= 79750:
346 return Decimal('3555.00')
347 elif gross <= 80250:
348 return Decimal('3577.50')
349 elif gross <= 80750:
350 return Decimal('3600.00')
351 elif gross <= 81250:
352 return Decimal('3622.50')
353 elif gross <= 81750:
354 return Decimal('3645.00')
355 elif gross <= 82250:
356 return Decimal('3667.50')
357 elif gross <= 82750:
358 return Decimal('3690.00')
359 elif gross <= 83250:
360 return Decimal('3712.50')
361 elif gross <= 83750:
362 return Decimal('3735.00')
363 elif gross <= 84250:
364 return Decimal('3757.50')
365 elif gross <= 84750:
366 return Decimal('3780.00')
367 elif gross <= 85250:
368 return Decimal('3802.50')
369 elif gross <= 85750:
370 return Decimal('3825.00')
371 elif gross <= 86250:
372 return Decimal('3847.50')
373 elif gross <= 86750:
374 return Decimal('3870.00')
375 elif gross <= 87250:
376 return Decimal('3892.50')
377 elif gross <= 87750:
378 return Decimal('3915.00')
379 elif gross <= 88250:
380 return Decimal('3937.50')
381 elif gross <= 88750:
382 return Decimal('3960.00')
383 elif gross <= 89250:
384 return Decimal('3982.50')
385 elif gross <= 89750:
386 return Decimal('4005.00')
387 elif gross <= 90250:
388 return Decimal('4027.50')
389 elif gross <= 90750:
390 return Decimal('4050.00')
391 elif gross <= 91250:
392 return Decimal('4072.50')
393 elif gross <= 91750:
394 return Decimal('4095.00')
395 elif gross <= 92250:
396 return Decimal('4117.50')
397 elif gross <= 92750:
398 return Decimal('4140.00')
399 elif gross <= 93250:
400 return Decimal('4162.50')
401 elif gross <= 93750:
402 return Decimal('4185.00')
403 elif gross <= 94250:
404 return Decimal('4207.50')
405 elif gross <= 94750:
406 return Decimal('4230.00')
407 elif gross <= 95250:
408 return Decimal('4252.50')
409 elif gross <= 95750:
410 return Decimal('4275.00')
411 elif gross <= 96250:
412 return Decimal('4297.50')
413 elif gross <= 96750:
414 return Decimal('4320.00')
415 elif gross <= 97250:
416 return Decimal('4342.50')
417 elif gross <= 97750:
418 return Decimal('4365.00')
419 elif gross <= 98250:
420 return Decimal('4387.50')
421 elif gross <= 98750:
422 return Decimal('4410.00')
423 elif gross <= 99250:
424 return Decimal('4432.50')
425 elif gross <= 99750:
426 return Decimal('4455.00')
427 else:
428 return Decimal('4500.00') # Cap at 22,500 monthly salary * 20%
431def calculate_philhealth_contribution(monthly_gross):
432 """Calculate PhilHealth contribution (2023: 5% of monthly salary, half from employee)."""
433 gross = max(Decimal('0'), monthly_gross)
434 # PhilHealth: 5% of monthly basic salary, half (2.5%) paid by employee, capped at 450 for salary >= 18,000
435 if gross <= 0:
436 return Decimal('0')
437 elif gross < 18000:
438 employee_share = gross * Decimal('0.025')
439 else:
440 employee_share = Decimal('450.00')
441 return employee_share.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
444def calculate_pagibig_contribution(monthly_gross):
445 """Calculate Pag-IBIG contribution (2023: 2% of monthly salary, max 100)."""
446 gross = max(Decimal('0'), monthly_gross)
447 if gross <= 0:
448 return Decimal('0')
449 elif gross >= 5000:
450 return Decimal('100.00')
451 else:
452 contribution = gross * Decimal('0.02')
453 return contribution.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
456def generate_payslip_for_item(payroll_item):
457 """Generate a payslip for a single payroll item."""
458 from .models import Payslip
459 from django.utils import timezone
460 profile = payroll_item.profile
461 user = profile.user
462 gross = payroll_item.gross_pay
463 sss = calculate_sss_contribution(gross)
464 philhealth = calculate_philhealth_contribution(gross)
465 pagibig = calculate_pagibig_contribution(gross)
466 tax = payroll_item.withholding_tax
467 total_deductions = sss + philhealth + pagibig + tax
468 net = gross - total_deductions
469 # Parse pay_rates
470 rates = payroll_item.pay_rates or []
471 basic = Decimal(str(rates[0])) if rates else gross
472 overtime = sum(Decimal(str(r)) for r in rates[1:]) if len(rates) > 1 else Decimal('0')
473 payslip, created = Payslip.objects.update_or_create(
474 payroll_item=payroll_item,
475 defaults={
476 'tenant_id': payroll_item.tenant_id,
477 'employee_name': user.full_name,
478 'employee_number': profile.employee_number,
479 'job_title': profile.job_title or '',
480 'period_start': payroll_item.run.period_start,
481 'period_end': payroll_item.run.period_end,
482 'basic_salary': basic,
483 'overtime_pay': overtime,
484 'allowances': Decimal('0'),
485 'other_earnings': Decimal('0'),
486 'gross_pay': gross,
487 'sss_deduction': sss,
488 'philhealth_deduction': philhealth,
489 'pagibig_deduction': pagibig,
490 'withholding_tax': tax,
491 'other_deductions': Decimal('0'),
492 'total_deductions': total_deductions,
493 'net_pay': net,
494 'status': 'GENERATED',
495 }
496 )
497 return payslip
500def generate_payslips_for_run(run):
501 """Generate payslips for all payroll items in a run and update run totals."""
502 from .models import PayrollItem
503 total_sss = Decimal('0')
504 total_philhealth = Decimal('0')
505 total_pagibig = Decimal('0')
506 for item in run.items.all():
507 payslip = generate_payslip_for_item(item)
508 total_sss += payslip.sss_deduction
509 total_philhealth += payslip.philhealth_deduction
510 total_pagibig += payslip.pagibig_deduction
511 run.sss_contribution = total_sss
512 run.philhealth_contribution = total_philhealth
513 run.pagibig_contribution = total_pagibig
514 run.save(update_fields=['sss_contribution', 'philhealth_contribution', 'pagibig_contribution', 'updated_at'])
515 return run
518def send_payslip_email(payslip, employee_email: str, subject: str, message: str) -> Tuple[bool, str]:
519 """
520 Send a payslip to an employee via email.
521 Uses the first available email account for the tenant.
522 Returns (success, error_message).
523 """
524 from ..email.services import EmailMessageService
525 from ..email.models import EmailAccount
526 from datetime import datetime
528 # Get primary email account for this tenant
529 account = EmailAccount.objects.filter(tenant_id=payslip.tenant_id, is_primary=True).first()
530 if not account:
531 return False, "No primary email account found for this tenant"
533 # Build HTML payslip email
534 html_body = f"""
535 <div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px;">
536 <div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 24px;">
537 <h2 style="color: #1e293b; margin-top: 0;">Payslip</h2>
538 <p style="color: #64748b;"><strong>Employee:</strong> {payslip.employee_name}</p>
539 <p style="color: #64748b;"><strong>Employee No:</strong> {payslip.employee_number}</p>
540 <p style="color: #64748b;"><strong>Period:</strong> {payslip.period_start.strftime('%B %d, %Y')} - {payslip.period_end.strftime('%B %d, %Y')}</p>
542 <table style="width: 100%; margin-top: 20px; border-collapse: collapse;">
543 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;"><strong>Earnings</strong></td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;"></td></tr>
544 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Basic Salary</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.basic_salary:,.2f}</td></tr>
545 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Overtime Pay</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.overtime_pay:,.2f}</td></tr>
546 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Allowances</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.allowances:,.2f}</td></tr>
547 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Other Earnings</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.other_earnings:,.2f}</td></tr>
548 <tr><td style="padding: 8px; background: #f1f5f9; font-weight: bold;">Gross Pay</td><td style="padding: 8px; background: #f1f5f9; font-weight: bold; text-align: right;">PHP {payslip.gross_pay:,.2f}</td></tr>
549 </table>
551 <table style="width: 100%; margin-top: 16px; border-collapse: collapse;">
552 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;"><strong>Deductions</strong></td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;"></td></tr>
553 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">SSS</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.sss_deduction:,.2f}</td></tr>
554 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">PhilHealth</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.philhealth_deduction:,.2f}</td></tr>
555 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Pag-IBIG</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.pagibig_deduction:,.2f}</td></tr>
556 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Withholding Tax</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.withholding_tax:,.2f}</td></tr>
557 <tr><td style="padding: 8px; border-bottom: 1px solid #e2e8f0;">Other Deductions</td><td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: right;">PHP {payslip.other_deductions:,.2f}</td></tr>
558 <tr><td style="padding: 8px; background: #fef2f2; font-weight: bold;">Total Deductions</td><td style="padding: 8px; background: #fef2f2; font-weight: bold; text-align: right;">PHP {payslip.total_deductions:,.2f}</td></tr>
559 </table>
561 <table style="width: 100%; margin-top: 16px; border-collapse: collapse;">
562 <tr><td style="padding: 12px; background: #1e293b; color: white; font-weight: bold; font-size: 18px;">NET PAY</td><td style="padding: 12px; background: #1e293b; color: white; font-weight: bold; font-size: 18px; text-align: right;">PHP {payslip.net_pay:,.2f}</td></tr>
563 </table>
565 <p style="margin-top: 24px; color: #64748b; font-size: 12px;">{message}</p>
566 <p style="color: #64748b; font-size: 11px; margin-top: 16px;">Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p>
567 </div>
568 </div>
569 """
571 try:
572 success, result = EmailMessageService.send_email(
573 account=account,
574 to_addresses=[{'email': employee_email, 'name': payslip.employee_name}],
575 subject=subject,
576 body=html_body,
577 is_html=True
578 )
579 if success:
580 # Update payslip status
581 payslip.status = 'SENT'
582 payslip.released_at = datetime.now()
583 payslip.save(update_fields=['status', 'released_at'])
584 return True, "Payslip sent successfully"
585 else:
586 return False, result
587 except Exception as e:
588 error_msg = str(e)
589 if 'authentication' in error_msg.lower() or 'credential' in error_msg.lower() or 'password' in error_msg.lower() or '535' in error_msg or '530' in error_msg:
590 return False, "SMTP authentication failed. Please check email account credentials in Settings > Email."
591 elif 'connection' in error_msg.lower() or 'refused' in error_msg.lower() or 'timeout' in error_msg.lower():
592 return False, "Could not connect to email server. Please check SMTP settings."
593 else:
594 return False, f"Failed to send email: {error_msg}"
597# ── 13th Month Bonus ───────────────────────────────────────────────────────────
599def calculate_13th_month(tenant_id: UUID, year: int, profile_ids: Optional[List[UUID]] = None) -> Month13Bonus:
600 """
601 Calculate and persist 13th month bonus for all eligible employees.
603 Eligibility: employee must have been active for at least 1 month in the year.
604 Formula: total basic salary for months worked in the year / 12.
605 Tax-exempt under Philippine BIR rules.
606 """
607 from dateutil.relativedelta import relativedelta
608 from .models import EmployeeProfile, Month13Item
610 profiles = EmployeeProfile.objects.filter(
611 tenant_id=tenant_id,
612 employment_status__in=('ACTIVE', 'PROBATIONARY')
613 )
614 if profile_ids:
615 profiles = profiles.filter(id__in=profile_ids)
617 # Upsert the bonus run
618 bonus, _ = Month13Bonus.objects.update_or_create(
619 tenant_id=tenant_id,
620 year=year,
621 defaults={'status': 'DRAFT', 'notes': ''},
622 )
623 # Remove old items so we can rebuild
624 bonus.items.all().delete()
626 total_net = Decimal('0')
627 created_items = []
629 year_start = date(year, 1, 1)
630 year_end = date(year, 12, 31)
631 current_date = date.today()
633 for profile in profiles:
634 # Determine which months the employee actually worked
635 months_worked = []
636 if profile.hire_date:
637 first_month = max(year_start, profile.hire_date)
638 else:
639 first_month = year_start
641 # If terminated/resigned in this year, cap at that date
642 last_month = min(year_end, current_date)
644 d = first_month
645 while d <= last_month:
646 months_worked.append(d.month)
647 d += relativedelta(months=1)
649 if not months_worked:
650 continue
652 # Sum basic salary for those months
653 monthly_salary = profile.salary_monthly or Decimal('0')
654 total_basic = monthly_salary * len(months_worked)
655 bonus_amount = (total_basic / 12).quantize(Decimal('.01'))
657 item = Month13Item.objects.create(
658 tenant_id=tenant_id,
659 bonus=bonus,
660 profile=profile,
661 employee_name=profile.user.full_name,
662 employee_number=profile.employee_number,
663 months_employed=len(months_worked),
664 months_employed_json=months_worked,
665 basic_salary_total=total_basic,
666 bonus_amount=bonus_amount,
667 status='PENDING',
668 )
669 created_items.append(item)
670 total_net += bonus_amount
672 bonus.item_count = len(created_items)
673 bonus.total_gross = total_net
674 bonus.total_net = total_net # tax-exempt
675 bonus.save(update_fields=['item_count', 'total_gross', 'total_net', 'updated_at'])
677 return bonus
680def release_13th_month(bonus: Month13Bonus) -> Month13Bonus:
681 """Mark all pending items as RELEASED and update bonus status."""
682 from django.utils import timezone
683 now = timezone.now()
684 bonus.items.filter(status='PENDING').update(status='RELEASED', released_at=now)
685 bonus.status = 'RELEASED'
686 bonus.save(update_fields=['status', 'updated_at'])
687 return bonus