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 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 . 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 (e.g. /setrate 25.50)", currencySymbol(st.Currency), st.HourlyRate)) } else { h.sendText(ctx, msg.Chat.ID, "No rate configured.\nUsage: /setrate (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 \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 . 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 \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 to change it.\nExample: /setrate 25.50", currencySymbol(st.Currency), st.HourlyRate) } else { text = "No hourly rate configured.\n\nUse /setrate 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) }