305 lines
9.7 KiB
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)
|
|
}
|