Files
worktimeBot/internal/bot/salary.go

305 lines
9.7 KiB
Go

package bot
import (
"context"
"fmt"
"math"
"strings"
"time"
"unicode"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// SalaryEstimate holds a salary calculation result.
type SalaryEstimate struct {
Currency string
HourlyRate float64
WorkSeconds int64
WorkHours float64
GrossEarning float64
WorkDays int
}
// computeDailySalary calculates salary for a single day's work.
func computeDailySalary(workSeconds int64, hourlyRate float64) float64 {
hours := float64(workSeconds) / secondsPerHour
return math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
}
// computeMonthlySalary calculates salary for a range of days.
func computeMonthlySalary(days []db.Day, store *db.Store, userID int64, hourlyRate float64, loc *time.Location) SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := store.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / secondsPerHour
gross := math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
return SalaryEstimate{
WorkSeconds: totalWork,
WorkHours: math.Round(hours*salaryRoundFactor) / salaryRoundFactor,
GrossEarning: gross,
WorkDays: workDayCount,
}
}
// currencySymbol extracts the first token as symbol, defaulting to "$".
func currencySymbol(currency string) string {
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
return parts[0]
}
return "$"
}
// formatSalary formats a salary estimate into a readable string.
func (e SalaryEstimate) formatSalary(currency string) string {
sym := currencySymbol(currency)
code := "USD"
if parts := strings.Fields(currency); len(parts) >= 2 {
code = parts[1]
}
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
}
// formatSalaryShort returns just the symbol + amount.
func (e SalaryEstimate) formatSalaryShort(currency string) string {
return fmt.Sprintf("%s%.2f", currencySymbol(currency), e.GrossEarning)
}
// buildSalaryStatus returns a formatted daily salary line.
func (h *Handler) buildSalaryStatus(userID int64, workSeconds int64) string {
st, err := h.DB.GetOrCreateSettings(userID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := computeDailySalary(workSeconds, st.HourlyRate)
s := SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.formatSalaryShort(st.Currency), st.HourlyRate)
}
// salaryEstimate returns the salary estimate for the current month.
func (h *Handler) salaryEstimate(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if st.HourlyRate <= 0 {
return "No hourly rate configured.\nUse /setrate <amount> to set your rate.", nil
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = gregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = gregorianToHijri(calY, calM, now.Day())
}
startStr, endStr := monthGregorianRange(user.Calendar, calY, calM)
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := formatMonthTitle(user.Calendar, calY, calM)
return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f",
title, est.formatSalary(st.Currency), currencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
// handleSetRateMsg handles /setrate <amount>.
func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current hourly rate: %s%.2f\nUsage: /setrate <amount> (e.g. /setrate 25.50)", currencySymbol(st.Currency), st.HourlyRate))
} else {
h.sendText(ctx, msg.Chat.ID, "No rate configured.\nUsage: /setrate <amount> (e.g. /setrate 25.50)")
}
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > maxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
st.HourlyRate = float64(int(rate*salaryRoundFactor)) / 100.0
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate))
}
// parseCurrencyInput validates and normalizes currency input (symbol + code).
func parseCurrencyInput(s string) (string, string) {
parts := strings.Fields(s)
if len(parts) < 2 {
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
}
for _, p := range parts[:2] {
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
}
}
return parts[0] + " " + parts[1], ""
}
// handleSetCurrencyMsg handles /setcurrency <symbol> <code>.
func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial", st.Currency))
return
}
currency, errMsg := parseCurrencyInput(args)
if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, errMsg)
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
st.Currency = currency
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
}
// currencyCallback shows the currency picker (inline).
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
text, kb := buildCurrencyKeyboard(st.Currency)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildCurrencyKeyboard returns the currency preset picker keyboard.
func buildCurrencyKeyboard(current string) (string, models.InlineKeyboardMarkup) {
presets := []string{"$ USD", "T Toman", "IRR Rial", "€ EUR", "£ GBP"}
rows := [][]models.InlineKeyboardButton{}
for _, p := range presets {
label := p
if p == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "currency_" + p},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select currency:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectCurrency saves the chosen currency preset.
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, callbackID, currency string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
if err := h.DB.UpdateSettings(st); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// rateCallback shows rate info (inline).
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
var text string
if st.HourlyRate > 0 {
text = fmt.Sprintf("Hourly rate: %s%.2f\n\nUse /setrate <amount> to change it.\nExample: /setrate 25.50", currencySymbol(st.Currency), st.HourlyRate)
} else {
text = "No hourly rate configured.\n\nUse /setrate <amount> to set your rate.\nExample: /setrate 25.50"
}
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}