Skip to content

Commit

Permalink
Merge pull request #110 from LucaBernstein/94-custom-timezone
Browse files Browse the repository at this point in the history
Add timezone support for transaction dates
  • Loading branch information
LucaBernstein authored Feb 5, 2022
2 parents e39fa5b + 76f69fe commit ae1d1b3
Show file tree
Hide file tree
Showing 12 changed files with 231 additions and 60 deletions.
56 changes: 52 additions & 4 deletions bot/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ import (
"time"

"github.com/LucaBernstein/beancount-bot-tg/helpers"
h "github.com/LucaBernstein/beancount-bot-tg/helpers"
tb "gopkg.in/tucnak/telebot.v2"
)

func (bc *BotController) configHandler(m *tb.Message) {
sc := h.MakeSubcommandHandler("/"+CMD_CONFIG, true)
sc := helpers.MakeSubcommandHandler("/"+CMD_CONFIG, true)
sc.
Add("currency", bc.configHandleCurrency).
Add("tag", bc.configHandleTag).
Add("notify", bc.configHandleNotification).
Add("limit", bc.configHandleLimit).
Add("about", bc.configHandleAbout)
Add("about", bc.configHandleAbout).
Add("tz_offset", bc.configHandleTimezoneOffset)
err := sc.Handle(m)
if err != nil {
bc.configHelp(m, nil)
Expand All @@ -33,7 +33,7 @@ func (bc *BotController) configHelp(m *tb.Message, err error) {
}

tz, _ := time.Now().Zone()
filledTemplate, err := h.Template(`Usage help for /{{.CONFIG_COMMAND}}:
filledTemplate, err := helpers.Template(`Usage help for /{{.CONFIG_COMMAND}}:
/{{.CONFIG_COMMAND}} currency <c> - Change default currency
/{{.CONFIG_COMMAND}} about - Display the version this bot is running on
Expand All @@ -54,6 +54,11 @@ Set suggestion cache limits (i.e. only cache new values until limit is reached,
/{{.CONFIG_COMMAND}} limit - Get currently set cache limits
/{{.CONFIG_COMMAND}} limit <suggestionType> <amount>|off - Set or disable suggestion limit for a type
Set timezone offset from UTC for transactions where date is added automatically:
/{{.CONFIG_COMMAND}} tz_offset - Get current timezone offset from UTC
/{{.CONFIG_COMMAND}} tz_offset <hours> - Set timezone offset from UTC, default 0
`, map[string]interface{}{
"CONFIG_COMMAND": CMD_CONFIG,
"TZ": tz,
Expand Down Expand Up @@ -325,3 +330,46 @@ func escapeCharacters(s string, c ...string) string {
}
return s
}

func (bc *BotController) configHandleTimezoneOffset(m *tb.Message, params ...string) {
tz_offset := bc.Repo.UserGetTzOffset(m)
if len(params) == 0 { // 0 params: GET
_, err := bc.Bot.Send(m.Sender, fmt.Sprintf("Your current timezone offset is set to 'UTC%s'.", prettyTzOffset(tz_offset)))
if err != nil {
bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error())
}
return
} else if len(params) > 1 { // 2 or more params: too many
bc.configHelp(m, fmt.Errorf("invalid amount of parameters specified"))
return
}
// Set new tz_offset
newTzOffset := params[0]
newTzParsed, err := strconv.Atoi(newTzOffset)
if err != nil {
_, err = bc.Bot.Send(m.Sender, "An error ocurred saving your timezone offset preference: "+err.Error())
if err != nil {
bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error())
}
return
}
err = bc.Repo.UserSetTzOffset(m, newTzParsed)
if err != nil {
_, err = bc.Bot.Send(m.Sender, "An error ocurred saving your timezone offset preference: "+err.Error())
if err != nil {
bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error())
}
return
}
_, err = bc.Bot.Send(m.Sender, fmt.Sprintf("Changed timezone offset for default dates for all future transactions from 'UTC%s' to 'UTC%s'.", prettyTzOffset(tz_offset), prettyTzOffset(newTzParsed)))
if err != nil {
bc.Logf(ERROR, m, "Sending bot message failed: %s", err.Error())
}
}

func prettyTzOffset(tzOffset int) string {
if tzOffset < 0 {
return strconv.Itoa(tzOffset)
}
return "+" + strconv.Itoa(tzOffset)
}
21 changes: 11 additions & 10 deletions bot/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/DATA-DOG/go-sqlmock"
"github.com/LucaBernstein/beancount-bot-tg/db/crud"
"github.com/LucaBernstein/beancount-bot-tg/helpers"
tb "gopkg.in/tucnak/telebot.v2"
)

Expand All @@ -22,20 +23,20 @@ func TestConfigCurrency(t *testing.T) {
}
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.currency").
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.currency").
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("SOMEEUR"))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.currency").
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("SOMEEUR"))

mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, "user.currency").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT`).WithArgs(12345, "user.currency", "USD").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, helpers.USERSET_CUR).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT`).WithArgs(12345, helpers.USERSET_CUR, "USD").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

bc := NewBotController(db)
Expand Down Expand Up @@ -99,9 +100,9 @@ func TestConfigTag(t *testing.T) {

// SET tag
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, "user.vacationTag").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, helpers.USERSET_TAG).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT INTO "bot::userSetting"`).
WithArgs(12345, "user.vacationTag", "vacation2021").
WithArgs(12345, helpers.USERSET_TAG, "vacation2021").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
bc.commandConfig(&tb.Message{Text: "/config tag vacation2021", Chat: chat})
Expand All @@ -115,7 +116,7 @@ func TestConfigTag(t *testing.T) {
// GET tag
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.vacationTag").
WithArgs(chat.ID, helpers.USERSET_TAG).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("vacation2021"))
bc.commandConfig(&tb.Message{Text: "/config tag", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help for /config") {
Expand All @@ -127,7 +128,7 @@ func TestConfigTag(t *testing.T) {

mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.vacationTag").
WithArgs(chat.ID, helpers.USERSET_TAG).
WillReturnRows(sqlmock.NewRows([]string{"tag"}).AddRow(nil))
bc.commandConfig(&tb.Message{Text: "/config tag", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help for /config") {
Expand All @@ -139,7 +140,7 @@ func TestConfigTag(t *testing.T) {

// DELETE tag
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, "user.vacationTag").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`DELETE FROM "bot::userSetting"`).WithArgs(12345, helpers.USERSET_TAG).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
bc.commandConfig(&tb.Message{Text: "/config tag off", Chat: chat})
if strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "Usage help for /config") {
Expand Down
3 changes: 2 additions & 1 deletion bot/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ func (bc *BotController) handleTextState(m *tb.Message) {
if tx.IsDone() {
currency := bc.Repo.UserGetCurrency(m)
tag := bc.Repo.UserGetTag(m)
transaction, err := tx.FillTemplate(currency, tag)
tzOffset := bc.Repo.UserGetTzOffset(m)
transaction, err := tx.FillTemplate(currency, tag, tzOffset)
if err != nil {
bc.Logf(ERROR, m, "Something went wrong while templating the transaction: "+err.Error())
_, err := bc.Bot.Send(m.Sender, "Something went wrong while templating the transaction: "+err.Error(), clearKeyboard())
Expand Down
74 changes: 68 additions & 6 deletions bot/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,21 @@ func TestTextHandlingWithoutPriorState(t *testing.T) {
defer db.Close()
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.currency").
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("TEST_CURRENCY"))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.currency").
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("TEST_CURRENCY"))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.vacationTag").
WithArgs(chat.ID, helpers.USERSET_TAG).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("vacation2021"))
today := time.Now().Format(helpers.BEANCOUNT_DATE_FORMAT)
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, helpers.USERSET_TZOFF).
WillReturnRows(sqlmock.NewRows([]string{"value"}))
mock.
ExpectExec(`INSERT INTO "bot::transaction"`).
WithArgs(chat.ID, today+` * "Buy something in the grocery store" #vacation2021
Expand Down Expand Up @@ -92,7 +96,6 @@ func TestTextHandlingWithoutPriorState(t *testing.T) {
}
}

// GitHub-Issue #16: Panic if plain message without state arrives
func TestTransactionDeletion(t *testing.T) {
// create test dependencies
chat := &tb.Chat{ID: 12345}
Expand Down Expand Up @@ -229,7 +232,7 @@ func TestCommandStartHelp(t *testing.T) {

mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.isAdmin").
WithArgs(chat.ID, helpers.USERSET_ADM).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(false))
bc.commandStart(&tb.Message{Chat: chat})

Expand All @@ -246,7 +249,7 @@ func TestCommandStartHelp(t *testing.T) {
// Admin check
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, "user.isAdmin").
WithArgs(chat.ID, helpers.USERSET_ADM).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(true))
bc.commandHelp(&tb.Message{Chat: chat})
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "admin_") {
Expand All @@ -268,3 +271,62 @@ func TestCommandCancel(t *testing.T) {
t.Errorf("Unexpectedly there were open tx before")
}
}

func TestTimezoneOffsetForAutomaticDate(t *testing.T) {
// create test dependencies
crud.TEST_MODE = true
chat := &tb.Chat{ID: 12345}
db, mock, err := sqlmock.New()
if err != nil {
log.Fatal(err)
}
defer db.Close()
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("TEST_CURRENCY"))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, helpers.USERSET_CUR).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("TEST_CURRENCY"))
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, helpers.USERSET_TAG).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("vacation2021"))
yesterday_tzCorrection := time.Now().Add(-24 * time.Hour).Format(helpers.BEANCOUNT_DATE_FORMAT)
mock.
ExpectQuery(`SELECT "value" FROM "bot::userSetting"`).
WithArgs(chat.ID, helpers.USERSET_TZOFF).
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("-24"))
mock.
ExpectExec(`INSERT INTO "bot::transaction"`).
WithArgs(chat.ID, yesterday_tzCorrection+` * "Buy something in the grocery store" #vacation2021
Assets:Wallet -17.34 TEST_CURRENCY
Expenses:Groceries
`).
WillReturnResult(sqlmock.NewResult(1, 1))

bc := NewBotController(db)
bot := &MockBot{}
bc.AddBotAndStart(bot)

// Create simple tx and fill it completely
bc.commandCreateSimpleTx(&tb.Message{Chat: chat})
tx := bc.State.states[12345]
tx.Input(&tb.Message{Text: "17.34"}) // amount
tx.Input(&tb.Message{Text: "Assets:Wallet"}) // from
tx.Input(&tb.Message{Text: "Expenses:Groceries"}) // to
bc.handleTextState(&tb.Message{Chat: chat, Text: "Buy something in the grocery store"}) // description (via handleTextState)

// After the first tx is done, send some command
m := &tb.Message{Chat: chat}
bc.handleTextState(m)

// should catch and send help instead of fail
if !strings.Contains(fmt.Sprintf("%v", bot.LastSentWhat), "you might need to start a transaction first") {
t.Errorf("String did not contain substring as expected (was: '%s')", bot.LastSentWhat)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
33 changes: 21 additions & 12 deletions bot/transactionBuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,19 @@ type Tx interface {
Debug() string
NextHint(*crud.Repo, *tb.Message) *Hint
EnrichHint(r *crud.Repo, m *tb.Message, i Input) *Hint
FillTemplate(currency, tag string) (string, error)
FillTemplate(currency, tag string, tzOffset int) (string, error)
DataKeys() map[string]string

addStep(command command, hint string, handler func(m *tb.Message) (string, error)) Tx
setDate(*tb.Message) (Tx, error)
setDateIfProvided(*tb.Message) (Tx, error)
setTimeIfEmpty(tzOffset int) bool
}

type SimpleTx struct {
steps []command
stepDetails map[command]Input
data []data
date string
date_upd string
step int
}

Expand All @@ -129,17 +130,17 @@ func CreateSimpleTx(m *tb.Message, suggestedCur string) (Tx, error) {
addStep("from", "Please enter the account the money came from (or select one from the list)", HandleRaw).
addStep("to", "Please enter the account the money went to (or select one from the list)", HandleRaw).
addStep("description", "Please enter a description (or select one from the list)", HandleRaw)
return tx.setDate(m)
return tx.setDateIfProvided(m)
}

func (tx *SimpleTx) setDate(m *tb.Message) (Tx, error) {
func (tx *SimpleTx) setDateIfProvided(m *tb.Message) (Tx, error) {
command := strings.Split(m.Text, " ")
if len(command) >= 2 {
date, err := ParseDate(command[1])
if err != nil {
return nil, err
}
tx.date = date
tx.date_upd = date
}
return tx, nil
}
Expand Down Expand Up @@ -217,12 +218,8 @@ func (tx *SimpleTx) hintDate(h *Hint) *Hint {
}

func (tx *SimpleTx) DataKeys() map[string]string {
if tx.date == "" {
// set today as fallback/default date
tx.date = time.Now().Format(c.BEANCOUNT_DATE_FORMAT)
}
return map[string]string{
c.STX_DATE: tx.date,
c.STX_DATE: tx.date_upd,
c.STX_DESC: string(tx.data[3]),
c.STX_ACCF: string(tx.data[1]),
c.STX_AMTF: string(tx.data[0]),
Expand All @@ -234,10 +231,22 @@ func (tx *SimpleTx) IsDone() bool {
return tx.step >= len(tx.steps)
}

func (tx *SimpleTx) FillTemplate(currency, tag string) (string, error) {
func (tx *SimpleTx) setTimeIfEmpty(tzOffset int) bool {
if tx.date_upd == "" {
// set today as fallback/default date
timezoneOff := time.Duration(tzOffset) * time.Hour
tx.date_upd = time.Now().UTC().Add(timezoneOff).Format(c.BEANCOUNT_DATE_FORMAT)
return true
}
return false
}

func (tx *SimpleTx) FillTemplate(currency, tag string, tzOffset int) (string, error) {
if !tx.IsDone() {
return "", fmt.Errorf("not all data for this tx has been gathered")
}
// If still empty, set time and correct for timezone
tx.setTimeIfEmpty(tzOffset)
// Variables
txRaw := tx.DataKeys()
f, err := strconv.ParseFloat(strings.Split(string(txRaw[c.STX_AMTF]), " ")[0], 64)
Expand Down
Loading

0 comments on commit ae1d1b3

Please sign in to comment.