Hp137 commited on
Commit
41dfc48
·
1 Parent(s): 119d5fc

feat:added payslip service

Browse files
requirements.txt CHANGED
@@ -54,6 +54,7 @@ pydantic-settings==2.12.0
54
  pydantic_core==2.41.5
55
  pyparsing==3.2.5
56
  PyPDF2==3.0.1
 
57
  python-dotenv==1.2.1
58
  python-jose==3.5.0
59
  python-multipart==0.0.20
 
54
  pydantic_core==2.41.5
55
  pyparsing==3.2.5
56
  PyPDF2==3.0.1
57
+ python-dateutil==2.9.0.post0
58
  python-dotenv==1.2.1
59
  python-jose==3.5.0
60
  python-multipart==0.0.20
src/payslip/googleservice.py CHANGED
@@ -1,68 +1,69 @@
 
1
  import base64
2
  import json
3
  import requests
4
- from typing import Tuple
5
  from fastapi import HTTPException
6
-
7
  from src.core.config import settings
8
 
 
 
 
9
 
10
  def exchange_code_for_tokens(code: str):
11
- """
12
- Exchange Google 'code' for access_token + refresh_token
13
- """
14
  data = {
15
- "code": code,
16
  "client_id": settings.GOOGLE_CLIENT_ID,
17
  "client_secret": settings.GOOGLE_CLIENT_SECRET,
18
- "redirect_uri": settings.GOOGLE_REDIRECT_URI,
19
  "grant_type": "authorization_code",
 
20
  }
21
 
22
- res = requests.post(settings.TOKEN_URL, data=data)
23
- if res.status_code != 200:
24
- raise HTTPException(500, f"Google token exchange error: {res.text}")
25
 
26
- return res.json()
27
 
 
 
 
 
 
 
 
 
28
 
29
- def refresh_google_access_token(refresh_token: str) -> str:
30
- """
31
- Input refresh_token
32
- Output → new access_token
33
- """
34
  data = {
35
- "refresh_token": refresh_token,
36
  "client_id": settings.GOOGLE_CLIENT_ID,
37
  "client_secret": settings.GOOGLE_CLIENT_SECRET,
 
38
  "grant_type": "refresh_token",
39
  }
40
 
41
- res = requests.post(settings.TOKEN_URL, data=data)
42
- if res.status_code != 200:
43
- raise HTTPException(500, f"Failed to refresh access token: {res.text}")
44
 
45
- return res.json()["access_token"]
 
46
 
 
47
 
48
- def build_email(from_email: str, to_email: str, subject: str, body: str) -> str:
49
- """
50
- Gmail API expects Base64URL-encoded email.
51
- """
52
  message = (
53
  f"From: {from_email}\r\n"
54
  f"To: {to_email}\r\n"
55
- f"Subject: {subject}\r\n"
56
- "\r\n"
57
  f"{body}"
58
  )
59
-
60
- message_bytes = message.encode("utf-8")
61
- encoded = base64.urlsafe_b64encode(message_bytes).decode("utf-8")
62
- return encoded
63
 
64
 
65
- def send_gmail(access_token: str, raw_message: str) -> str:
 
66
  url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
67
 
68
  headers = {
@@ -70,11 +71,12 @@ def send_gmail(access_token: str, raw_message: str) -> str:
70
  "Content-Type": "application/json",
71
  }
72
 
73
- payload = {"raw": raw_message}
74
 
75
- res = requests.post(url, headers=headers, data=json.dumps(payload))
 
76
 
77
- if res.status_code not in (200, 202):
78
- raise HTTPException(500, f"Gmail API error: {res.text}")
79
 
80
- return res.json().get("id") # message_id
 
1
+ # src/payslip/googleservice.py
2
  import base64
3
  import json
4
  import requests
 
5
  from fastapi import HTTPException
 
6
  from src.core.config import settings
7
 
8
+ # TEMPORARY in-memory store
9
+ GOOGLE_TOKENS = {} # user_id -> refresh_token
10
+
11
 
12
  def exchange_code_for_tokens(code: str):
13
+ """Exchange authorization code for access + refresh tokens."""
 
 
14
  data = {
 
15
  "client_id": settings.GOOGLE_CLIENT_ID,
16
  "client_secret": settings.GOOGLE_CLIENT_SECRET,
17
+ "code": code,
18
  "grant_type": "authorization_code",
19
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
20
  }
21
 
22
+ response = requests.post(settings.TOKEN_URL, data=data)
23
+ return response.json()
 
24
 
 
25
 
26
+ def extract_email_from_id_token(id_token: str) -> str:
27
+ """Decode ID token to extract the email Google selected."""
28
+ try:
29
+ payload_part = id_token.split(".")[1] + "==="
30
+ decoded = json.loads(base64.urlsafe_b64decode(payload_part))
31
+ return decoded.get("email")
32
+ except Exception:
33
+ raise HTTPException(400, "Invalid ID token format")
34
 
35
+
36
+ def refresh_google_access_token(refresh_token: str):
37
+ """Refresh access token using refresh_token."""
 
 
38
  data = {
 
39
  "client_id": settings.GOOGLE_CLIENT_ID,
40
  "client_secret": settings.GOOGLE_CLIENT_SECRET,
41
+ "refresh_token": refresh_token,
42
  "grant_type": "refresh_token",
43
  }
44
 
45
+ response = requests.post(settings.TOKEN_URL, data=data)
46
+ token_data = response.json()
 
47
 
48
+ if "access_token" not in token_data:
49
+ raise HTTPException(400, f"Google refresh failed: {token_data}")
50
 
51
+ return token_data["access_token"]
52
 
53
+
54
+ def build_email(from_email: str, to_email: str, subject: str, body: str):
55
+ """Build raw Gmail MIME email."""
 
56
  message = (
57
  f"From: {from_email}\r\n"
58
  f"To: {to_email}\r\n"
59
+ f"Subject: {subject}\r\n\r\n"
 
60
  f"{body}"
61
  )
62
+ return base64.urlsafe_b64encode(message.encode("utf-8")).decode("utf-8")
 
 
 
63
 
64
 
65
+ def send_gmail(access_token: str, raw_message: str):
66
+ """Send email through Gmail API."""
67
  url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
68
 
69
  headers = {
 
71
  "Content-Type": "application/json",
72
  }
73
 
74
+ data = {"raw": raw_message}
75
 
76
+ res = requests.post(url, headers=headers, json=data)
77
+ data = res.json()
78
 
79
+ if "id" not in data:
80
+ raise HTTPException(400, f"Gmail send error: {data}")
81
 
82
+ return data["id"]
src/payslip/router.py CHANGED
@@ -1,21 +1,127 @@
1
- from fastapi import APIRouter, Depends
2
- from src.payslip.schemas import PayslipRequestSchema
3
- from src.payslip.service import process_payslip_request
4
- from src.auth.utils import get_current_user
 
 
5
  from sqlalchemy.ext.asyncio import AsyncSession
 
 
 
6
  from src.core.database import get_async_session
7
  from src.core.models import Users
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- router = APIRouter(prefix="/payslips", tags=["Payslips"])
 
 
 
 
 
10
 
11
 
12
  @router.post("/request")
13
- def request_payslip(
14
  payload: PayslipRequestSchema,
15
  session: AsyncSession = Depends(get_async_session),
16
- user: Users = Depends(get_current_user),
17
  ):
18
- entry = process_payslip_request(session, user, payload)
 
 
 
 
 
 
 
 
 
 
19
  return {
20
  "status": entry.status,
21
  "requested_at": entry.requested_at,
 
1
+ # src/payslip/router.py
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from fastapi.responses import HTMLResponse
4
+ from urllib.parse import urlencode
5
+ import uuid
6
+
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy import select
9
+
10
+ from src.core.config import settings
11
  from src.core.database import get_async_session
12
  from src.core.models import Users
13
+ from src.payslip.schemas import PayslipRequestSchema
14
+ from src.payslip.service import process_payslip_request
15
+ from src.payslip.googleservice import (
16
+ exchange_code_for_tokens,
17
+ extract_email_from_id_token,
18
+ )
19
+ from src.payslip.utils import get_current_user_model
20
+ from src.payslip.models import PayslipRequest, PayslipStatus
21
+
22
+ router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
23
+
24
+
25
+ @router.get("/gmail/connect-url")
26
+ async def gmail_connect_url(user_id: uuid.UUID):
27
+ """
28
+ Returns the Google OAuth URL for the frontend to open in InAppBrowser.
29
+ """
30
+ params = {
31
+ "client_id": settings.GOOGLE_CLIENT_ID,
32
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
33
+ "response_type": "code",
34
+ "scope": "openid email profile https://www.googleapis.com/auth/gmail.send",
35
+ "access_type": "offline",
36
+ "prompt": "consent",
37
+ "state": str(user_id),
38
+ }
39
+ return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
40
+
41
+
42
+ @router.get("/gmail/callback", response_class=HTMLResponse)
43
+ async def gmail_callback(
44
+ code: str,
45
+ state: str,
46
+ session: AsyncSession = Depends(get_async_session),
47
+ ):
48
+ """
49
+ Google redirects here with ?code & ?state=user_id.
50
+ We:
51
+ - exchange code for tokens
52
+ - verify the Google email matches our user's email
53
+ - store refresh_token into payslip_requests table
54
+ """
55
+ user_id = uuid.UUID(state)
56
+ user = await session.get(Users, user_id)
57
+
58
+ if not user:
59
+ raise HTTPException(400, "User not found for given state")
60
+
61
+ token_data = exchange_code_for_tokens(code)
62
+ google_email = extract_email_from_id_token(token_data["id_token"])
63
+
64
+ if google_email.lower() != user.email_id.lower():
65
+ raise HTTPException(
66
+ 400,
67
+ f"Please select your registered email: {user.email_id}",
68
+ )
69
+
70
+ refresh_token = token_data.get("refresh_token")
71
+ if not refresh_token:
72
+ raise HTTPException(400, "No refresh token received from Google")
73
+
74
+ # Check if this user already has any payslip row
75
+ q = (
76
+ select(PayslipRequest)
77
+ .where(PayslipRequest.user_id == user_id)
78
+ .order_by(PayslipRequest.requested_at.desc())
79
+ )
80
+ existing = (await session.execute(q)).scalar_one_or_none()
81
+
82
+ if existing:
83
+ # Update the latest row with new refresh token
84
+ existing.refresh_token = refresh_token
85
+ # Do NOT change requested_at or status here;
86
+ # this endpoint is only for (re)connecting Gmail.
87
+ session.add(existing)
88
+ else:
89
+ # First time ever connecting Gmail -> create a "connection row"
90
+ connection_row = PayslipRequest(
91
+ user_id=user_id,
92
+ refresh_token=refresh_token,
93
+ status=PayslipStatus.PENDING, # not an actual request yet
94
+ # requested_at default is now; one_request_per_day ignores PENDING rows
95
+ )
96
+ session.add(connection_row)
97
+
98
+ await session.commit()
99
 
100
+ return """
101
+ <html><body>
102
+ <h1>Gmail Connected Successfully ✔</h1>
103
+ <p>You may now request your payslip.</p>
104
+ </body></html>
105
+ """
106
 
107
 
108
  @router.post("/request")
109
+ async def request_payslip(
110
  payload: PayslipRequestSchema,
111
  session: AsyncSession = Depends(get_async_session),
112
+ user: Users = Depends(get_current_user_model),
113
  ):
114
+ """
115
+ User hits this when pressing "Request Payslip" in the app.
116
+ We:
117
+ - enforce 1 request per day
118
+ - compute period
119
+ - validate join date
120
+ - load refresh_token from payslip_requests table
121
+ - send email
122
+ - update or create row in payslip_requests
123
+ """
124
+ entry = await process_payslip_request(session, user, payload)
125
  return {
126
  "status": entry.status,
127
  "requested_at": entry.requested_at,
src/payslip/service.py CHANGED
@@ -1,8 +1,13 @@
1
- from sqlmodel import Session, select
 
 
2
  from fastapi import HTTPException
3
- from src.payslip.models import PayslipRequest, PayslipStatus
4
- from src.core.models import Users, Roles, UserTeamsRole
5
 
 
 
 
6
  from src.payslip.utils import calculate_period, validate_join_date
7
  from src.payslip.googleservice import (
8
  refresh_google_access_token,
@@ -11,79 +16,141 @@ from src.payslip.googleservice import (
11
  )
12
 
13
 
14
- def get_latest_refresh_token(session: Session, user_id):
15
- stmt = (
16
- select(PayslipRequest)
17
- .where(
18
- PayslipRequest.user_id == user_id,
19
- PayslipRequest.refresh_token.is_not(None),
20
- )
21
- .order_by(PayslipRequest.requested_at.desc())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  )
23
- entry = session.exec(stmt).first()
24
- return entry.refresh_token if entry else None
 
 
25
 
26
 
27
- def get_hr_email(session: Session) -> str:
28
- role = session.exec(select(Roles).where(Roles.name == "HR Manager")).first()
 
29
  if not role:
30
- raise HTTPException(500, "HR Manager role missing")
31
 
32
- mapping = session.exec(
33
- select(UserTeamsRole).where(UserTeamsRole.role_id == role.id)
34
- ).first()
35
 
36
  if not mapping:
37
- raise HTTPException(500, "No HR assigned")
38
 
39
- hr_user = session.get(Users, mapping.user_id)
40
- return hr_user.email_id
41
 
42
 
43
- def process_payslip_request(session: Session, user: Users, payload):
44
- # 1. Compute period
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  period_start, period_end = calculate_period(
46
- payload.mode, payload.start_month, payload.end_month
 
 
47
  )
48
 
49
- # 2. Validate join date
50
  validate_join_date(user.join_date, period_start)
51
 
52
- # 3. Find refresh_token
53
- refresh_token = get_latest_refresh_token(session, user.id)
 
 
54
 
55
  if not refresh_token:
 
56
  raise HTTPException(
57
- 400, "Please connect your Gmail before requesting a payslip."
58
  )
59
 
60
- # 4. Get access token
61
  access_token = refresh_google_access_token(refresh_token)
62
 
63
- # 5. Find HR email
64
- hr_email = get_hr_email(session)
65
 
66
- # 6. Build message
 
 
 
67
  subject = "Payslip Request"
68
  body = (
69
- f"Payslip request from {user.email_id}\n"
70
- f"Period: {period_start} → {period_end}"
 
71
  )
72
- raw = build_email(user.email_id, hr_email, subject, body)
73
-
74
- # 7. Send email
75
- message_id = send_gmail(access_token, raw)
76
 
77
- # 8. Create new DB record
78
- entry = PayslipRequest(
79
- user_id=user.id,
80
- status=PayslipStatus.SENT,
81
- refresh_token=refresh_token,
82
- error_message=None,
83
- )
84
 
85
- session.add(entry)
86
- session.commit()
87
- session.refresh(entry)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
- return entry
 
 
 
 
1
+ # src/payslip/service.py
2
+ from datetime import datetime, date
3
+
4
  from fastapi import HTTPException
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy import select
7
 
8
+ from src.payslip.models import PayslipRequest, PayslipStatus
9
+ from src.core.models import Users, Roles, UserTeamsRole, Teams
10
+ from src.payslip.schemas import PayslipRequestSchema
11
  from src.payslip.utils import calculate_period, validate_join_date
12
  from src.payslip.googleservice import (
13
  refresh_google_access_token,
 
16
  )
17
 
18
 
19
+ async def user_team_name(session: AsyncSession, user_id):
20
+ """Return user's team name."""
21
+ q = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
22
+ mapping = (await session.execute(q)).scalar_one_or_none()
23
+
24
+ if not mapping:
25
+ return "Unknown Team"
26
+
27
+ team = await session.get(Teams, mapping.team_id)
28
+ return team.name if team else "Unknown Team"
29
+
30
+
31
+ async def one_request_per_day(session: AsyncSession, user_id):
32
+ """
33
+ Enforce: one payslip REQUEST per calendar day.
34
+ We count only rows where status != PENDING (i.e., actual requests),
35
+ so that the Gmail-connect row (status=PENDING) does NOT block.
36
+ """
37
+ today_start = datetime.combine(date.today(), datetime.min.time())
38
+
39
+ q = select(PayslipRequest).where(
40
+ PayslipRequest.user_id == user_id,
41
+ PayslipRequest.requested_at >= today_start,
42
+ PayslipRequest.status != PayslipStatus.PENDING,
43
  )
44
+
45
+ result = await session.execute(q)
46
+ if result.scalar_one_or_none():
47
+ raise HTTPException(400, "You already sent a payslip request today.")
48
 
49
 
50
+ async def get_hr_email(session: AsyncSession):
51
+ q = select(Roles).where(Roles.name == "HR Manager")
52
+ role = (await session.execute(q)).scalar_one_or_none()
53
  if not role:
54
+ raise HTTPException(500, "HR role missing")
55
 
56
+ q2 = select(UserTeamsRole).where(UserTeamsRole.role_id == role.id)
57
+ mapping = (await session.execute(q2)).scalar_one_or_none()
 
58
 
59
  if not mapping:
60
+ raise HTTPException(500, "No HR manager mapped")
61
 
62
+ hr = await session.get(Users, mapping.user_id)
63
+ return hr.email_id
64
 
65
 
66
+ async def get_latest_payslip_row(session: AsyncSession, user_id):
67
+ """
68
+ Get the most recent payslip row for this user (any status).
69
+ We use this to get the refresh_token and to decide whether to update or insert.
70
+ """
71
+ q = (
72
+ select(PayslipRequest)
73
+ .where(PayslipRequest.user_id == user_id)
74
+ .order_by(PayslipRequest.requested_at.desc())
75
+ )
76
+ return (await session.execute(q)).scalar_one_or_none()
77
+
78
+
79
+ async def process_payslip_request(
80
+ session: AsyncSession, user: Users, payload: PayslipRequestSchema
81
+ ):
82
+ # 1. Only ONE request per day (for actual payslip sends)
83
+ await one_request_per_day(session, user.id)
84
+
85
+ # 2. Validate period based on mode + months
86
  period_start, period_end = calculate_period(
87
+ payload.mode,
88
+ payload.start_month,
89
+ payload.end_month,
90
  )
91
 
92
+ # 3. Validate join date
93
  validate_join_date(user.join_date, period_start)
94
 
95
+ # 4. Get refresh_token from latest payslip row (DB)
96
+ latest = await get_latest_payslip_row(session, user.id)
97
+
98
+ refresh_token = latest.refresh_token if latest else None
99
 
100
  if not refresh_token:
101
+ # No token stored yet
102
  raise HTTPException(
103
+ 400, "Please connect your Gmail account before requesting payslip."
104
  )
105
 
106
+ # 5. Refresh access token with Google
107
  access_token = refresh_google_access_token(refresh_token)
108
 
109
+ # 6. Get HR email
110
+ hr_email = await get_hr_email(session)
111
 
112
+ # 7. Get team name
113
+ team = await user_team_name(session, user.id)
114
+
115
+ # 8. Build email body
116
  subject = "Payslip Request"
117
  body = (
118
+ f"Payslip request from {user.user_name} ({user.email_id})\n"
119
+ f"Team: {team}\n"
120
+ f"Period: {period_start} → {period_end}\n"
121
  )
 
 
 
 
122
 
123
+ raw = build_email(user.email_id, hr_email, subject, body)
 
 
 
 
 
 
124
 
125
+ # 9. Send email via Gmail API
126
+ send_gmail(access_token, raw)
127
+
128
+ # 10. Decide whether to UPDATE existing row or CREATE a new one
129
+ now = datetime.now()
130
+
131
+ if latest and latest.status == PayslipStatus.PENDING:
132
+ # This is the "connection row" (created when Gmail was connected)
133
+ # ✅ Update this row with today's request info
134
+ latest.status = PayslipStatus.SENT
135
+ latest.requested_at = now
136
+ latest.error_message = None
137
+ latest.refresh_token = refresh_token # keep token
138
+ session.add(latest)
139
+ await session.commit()
140
+ await session.refresh(latest)
141
+ return latest
142
+ else:
143
+ # Either no row existed, or latest is already SENT/FAILED.
144
+ # ✅ Create a new row for this request, copying the refresh token.
145
+ entry = PayslipRequest(
146
+ user_id=user.id,
147
+ status=PayslipStatus.SENT,
148
+ requested_at=now,
149
+ refresh_token=refresh_token,
150
+ error_message=None,
151
+ )
152
 
153
+ session.add(entry)
154
+ await session.commit()
155
+ await session.refresh(entry)
156
+ return entry
src/payslip/utils.py CHANGED
@@ -1,57 +1,109 @@
 
1
  from datetime import date, datetime
2
- from fastapi import HTTPException
3
 
 
 
 
 
 
 
4
 
5
- def parse_month(month_str: str) -> date:
6
- """Convert YYYY-MM to a date object representing first day of the month."""
 
 
 
 
 
 
 
 
 
 
 
 
7
  try:
8
  d = datetime.strptime(month_str, "%Y-%m")
9
  return date(d.year, d.month, 1)
10
- except:
11
- raise HTTPException(status_code=400, detail="Invalid month format. Use YYYY-MM")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
- def calculate_period(mode: str, start: str | None, end: str | None):
 
 
 
 
 
 
15
  today = date.today()
16
- current_month = date(today.year, today.month, 1)
17
 
18
  if mode == "3_months":
19
- months = 3
20
- elif mode == "6_months":
21
- months = 6
22
- else: # manual mode
23
- if not start or not end:
24
- raise HTTPException(400, "start_month and end_month required")
25
- start_date = parse_month(start)
26
- end_date = parse_month(end)
27
 
28
- if end_date > current_month:
29
- raise HTTPException(400, "Cannot request future payslips")
30
- if start_date > end_date:
31
- raise HTTPException(400, "start_month cannot be after end_month")
32
 
33
- return start_date, end_date
 
 
 
34
 
35
- # Auto period
36
- end_date = current_month
37
- year = end_date.year
38
- mon = end_date.month - (months - 1)
 
39
 
40
- while mon <= 0:
41
- mon += 12
42
- year -= 1
43
 
44
- start_date = date(year, mon, 1)
45
- return start_date, end_date
46
 
 
 
 
 
 
 
 
 
 
47
 
48
- def validate_join_date(join_date_str: str, period_start: date):
49
- """User cannot request payslips earlier than join date"""
50
  try:
51
- join = datetime.strptime(join_date_str, "%Y-%m-%d").date()
52
- join_month = join.replace(day=1)
53
- except:
54
- raise HTTPException(500, "Invalid join_date format in DB")
 
 
 
 
 
 
 
 
 
55
 
56
- if period_start < join_month:
57
- raise HTTPException(400, "You cannot request payslips before your joining date")
 
1
+ # src/payslip/utils.py
2
  from datetime import date, datetime
3
+ from typing import Optional
4
 
5
+ from dateutil.relativedelta import relativedelta
6
+ from fastapi import Depends, HTTPException
7
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
+ from jose import jwt, JWTError
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from sqlalchemy import select
11
 
12
+ from src.core.database import get_async_session
13
+ from src.core.models import Users
14
+ from src.core.config import settings
15
+
16
+ bearer_scheme = HTTPBearer()
17
+
18
+ SECRET_KEY = settings.SECRET_KEY
19
+ ALGORITHM = settings.JWT_ALGORITHM
20
+
21
+
22
+ def _parse_month(month_str: str) -> date:
23
+ """
24
+ "2024-05" -> date(2024, 5, 1)
25
+ """
26
  try:
27
  d = datetime.strptime(month_str, "%Y-%m")
28
  return date(d.year, d.month, 1)
29
+ except ValueError:
30
+ raise HTTPException(
31
+ status_code=400,
32
+ detail="Invalid month format. Use YYYY-MM, e.g. 2024-05",
33
+ )
34
+
35
+
36
+ def validate_join_date(join_date: Optional[str], period_start: date):
37
+ if not join_date:
38
+ return
39
+
40
+ join = datetime.strptime(join_date, "%Y-%m-%d").date()
41
+ if period_start < join:
42
+ raise HTTPException(
43
+ 400,
44
+ f"You joined on {join}. You cannot request payslips before joining date.",
45
+ )
46
 
47
 
48
+ def calculate_period(mode: str, start_month: str = None, end_month: str = None):
49
+ """
50
+ mode:
51
+ - "3_months"
52
+ - "6_months"
53
+ - "manual" + start_month, end_month in "YYYY-MM"
54
+ """
55
  today = date.today()
 
56
 
57
  if mode == "3_months":
58
+ end = today.replace(day=1)
59
+ start = end - relativedelta(months=3)
60
+ return start, end
 
 
 
 
 
61
 
62
+ if mode == "6_months":
63
+ end = today.replace(day=1)
64
+ start = end - relativedelta(months=6)
65
+ return start, end
66
 
67
+ if mode == "manual":
68
+ # Validate fields
69
+ if not start_month or not end_month:
70
+ raise HTTPException(400, "Manual mode requires start_month and end_month")
71
 
72
+ try:
73
+ start = datetime.strptime(start_month, "%Y-%m").date()
74
+ end = datetime.strptime(end_month, "%Y-%m").date()
75
+ except ValueError:
76
+ raise HTTPException(400, "Invalid month format. Use YYYY-MM")
77
 
78
+ if start > end:
79
+ raise HTTPException(400, "Start month cannot be after end month")
 
80
 
81
+ return start, end
 
82
 
83
+ # Invalid mode
84
+ raise HTTPException(400, "Invalid payslip request mode")
85
+
86
+
87
+ async def get_current_user_model(
88
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
89
+ session: AsyncSession = Depends(get_async_session),
90
+ ):
91
+ token = credentials.credentials
92
 
 
 
93
  try:
94
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
95
+ user_id = payload.get("sub")
96
+
97
+ if not user_id:
98
+ raise HTTPException(401, "Invalid token")
99
+
100
+ result = await session.execute(select(Users).where(Users.id == user_id))
101
+ user = result.scalar_one_or_none()
102
+
103
+ if not user:
104
+ raise HTTPException(401, "User not found")
105
+
106
+ return user
107
 
108
+ except JWTError:
109
+ raise HTTPException(401, "Invalid or expired token")