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

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 

8 

9 

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.') 

16 

17 

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) 

31 

32 

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% 

429 

430 

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) 

442 

443 

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) 

454 

455 

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 

498 

499 

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 

516 

517 

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 

527 

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" 

532 

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> 

541 

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> 

550 

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> 

560 

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> 

564 

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 """ 

570 

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}" 

595 

596 

597# ── 13th Month Bonus ─────────────────────────────────────────────────────────── 

598 

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. 

602 

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 

609 

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) 

616 

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() 

625 

626 total_net = Decimal('0') 

627 created_items = [] 

628 

629 year_start = date(year, 1, 1) 

630 year_end = date(year, 12, 31) 

631 current_date = date.today() 

632 

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 

640 

641 # If terminated/resigned in this year, cap at that date 

642 last_month = min(year_end, current_date) 

643 

644 d = first_month 

645 while d <= last_month: 

646 months_worked.append(d.month) 

647 d += relativedelta(months=1) 

648 

649 if not months_worked: 

650 continue 

651 

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')) 

656 

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 

671 

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']) 

676 

677 return bonus 

678 

679 

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