Spaces:
Sleeping
Sleeping
feat:added payslip service
Browse files- requirements.txt +1 -0
- src/payslip/googleservice.py +39 -37
- src/payslip/router.py +114 -8
- src/payslip/service.py +117 -50
- src/payslip/utils.py +89 -37
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 |
-
"
|
| 19 |
"grant_type": "authorization_code",
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
raise HTTPException(500, f"Google token exchange error: {res.text}")
|
| 25 |
|
| 26 |
-
return res.json()
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 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 |
-
|
| 42 |
-
|
| 43 |
-
raise HTTPException(500, f"Failed to refresh access token: {res.text}")
|
| 44 |
|
| 45 |
-
|
|
|
|
| 46 |
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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)
|
|
|
|
| 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 |
-
|
| 74 |
|
| 75 |
-
res = requests.post(url, headers=headers, data
|
|
|
|
| 76 |
|
| 77 |
-
if
|
| 78 |
-
raise HTTPException(
|
| 79 |
|
| 80 |
-
return
|
|
|
|
| 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 |
-
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
-
from
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
@router.post("/request")
|
| 13 |
-
def request_payslip(
|
| 14 |
payload: PayslipRequestSchema,
|
| 15 |
session: AsyncSession = Depends(get_async_session),
|
| 16 |
-
user: Users = Depends(
|
| 17 |
):
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 2 |
from fastapi import HTTPException
|
| 3 |
-
from
|
| 4 |
-
from
|
| 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
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
)
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
-
def get_hr_email(session:
|
| 28 |
-
|
|
|
|
| 29 |
if not role:
|
| 30 |
-
raise HTTPException(500, "HR
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
).first()
|
| 35 |
|
| 36 |
if not mapping:
|
| 37 |
-
raise HTTPException(500, "No HR
|
| 38 |
|
| 39 |
-
|
| 40 |
-
return
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
period_start, period_end = calculate_period(
|
| 46 |
-
payload.mode,
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
-
#
|
| 50 |
validate_join_date(user.join_date, period_start)
|
| 51 |
|
| 52 |
-
#
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
if not refresh_token:
|
|
|
|
| 56 |
raise HTTPException(
|
| 57 |
-
400, "Please connect your Gmail before requesting
|
| 58 |
)
|
| 59 |
|
| 60 |
-
#
|
| 61 |
access_token = refresh_google_access_token(refresh_token)
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
hr_email = get_hr_email(session)
|
| 65 |
|
| 66 |
-
#
|
|
|
|
|
|
|
|
|
|
| 67 |
subject = "Payslip Request"
|
| 68 |
body = (
|
| 69 |
-
f"Payslip request from {user.email_id}\n"
|
| 70 |
-
f"
|
|
|
|
| 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 |
-
|
| 78 |
-
entry = PayslipRequest(
|
| 79 |
-
user_id=user.id,
|
| 80 |
-
status=PayslipStatus.SENT,
|
| 81 |
-
refresh_token=refresh_token,
|
| 82 |
-
error_message=None,
|
| 83 |
-
)
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
try:
|
| 8 |
d = datetime.strptime(month_str, "%Y-%m")
|
| 9 |
return date(d.year, d.month, 1)
|
| 10 |
-
except:
|
| 11 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
-
def calculate_period(mode: str,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
today = date.today()
|
| 16 |
-
current_month = date(today.year, today.month, 1)
|
| 17 |
|
| 18 |
if mode == "3_months":
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
year -= 1
|
| 43 |
|
| 44 |
-
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
raise HTTPException(
|
|
|
|
| 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")
|