From e8b0bcd372b096166898f60ec530e4b81f7c0ab4 Mon Sep 17 00:00:00 2001 From: mput Date: Tue, 23 Jul 2024 22:22:09 +0100 Subject: [PATCH] add ability to confirm transaction --- app/bot/bot.go | 178 ++++++++++++++++----- app/bot/templates/propose_transaction.html | 22 +++ app/ledger/ledger.go | 103 +++++++++--- app/ledger/ledger_test.go | 73 ++++----- app/teledger/teledger.go | 64 +++++--- app/teledger/teledger_test.go | 129 ++++++++++++++- todo.org | 8 +- 7 files changed, 443 insertions(+), 134 deletions(-) create mode 100644 app/bot/templates/propose_transaction.html diff --git a/app/bot/bot.go b/app/bot/bot.go index 8a8a983..1917df2 100644 --- a/app/bot/bot.go +++ b/app/bot/bot.go @@ -1,7 +1,10 @@ package bot import ( + "bytes" + _ "embed" "fmt" + "html/template" "log/slog" "strings" "time" @@ -9,19 +12,18 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" - "github.com/mput/teledger/app/teledger" "github.com/mput/teledger/app/ledger" "github.com/mput/teledger/app/repo" + "github.com/mput/teledger/app/teledger" ) - type Opts struct { Telegram struct { Token string `long:"token" env:"TOKEN" required:"true" description:"telegram bot token"` } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"` Github struct { - URL string `long:"url" env:"URL" required:"true" description:"github repo url"` + URL string `long:"url" env:"URL" required:"true" description:"github repo url"` Token string `long:"token" env:"TOKEN" required:"true" description:"fine-grained personal access tokens for repo with RW Contents scope"` } `group:"github" namespace:"github" env-namespace:"GITHUB"` @@ -34,9 +36,9 @@ type Opts struct { } type Bot struct { - opts *Opts + opts *Opts teledger *teledger.Teledger - bot *gotgbot.Bot + bot *gotgbot.Bot } func NewBot(opts *Opts) (*Bot, error) { @@ -57,9 +59,9 @@ func NewBot(opts *Opts) (*Bot, error) { } return &Bot{ - opts: opts, + opts: opts, teledger: tel, - bot: b, + bot: b, }, nil } @@ -67,7 +69,6 @@ func NewBot(opts *Opts) (*Bot, error) { func (bot *Bot) Start() error { defaultCommands := []gotgbot.BotCommand{ {Command: "reports", Description: "Show available reports"}, - {Command: "balance", Description: "Show balance"}, {Command: "version", Description: "Show version"}, } smcRes, err := bot.bot.SetMyCommands(defaultCommands, nil) @@ -87,22 +88,16 @@ func (bot *Bot) Start() error { updater := ext.NewUpdater(dispatcher, nil) - - - - dispatcher.AddHandler(handlers.NewCommand("reports", wrapUserResponse(bot.showAvailableReports, "reports"))) dispatcher.AddHandler(handlers.NewCallback(isReportCallback, wrapUserResponse(bot.showReport, "show-report"))) - dispatcher.AddHandler(handlers.NewCommand("start", wrapUserResponse(start, "start"))) dispatcher.AddHandler(handlers.NewCommand("version", wrapUserResponse(bot.vesrion, "version"))) - dispatcher.AddHandler(handlers.NewCommand("balance", wrapUserResponse(bot.bal, "balance"))) // these handlers should be at the end, as they are less specific dispatcher.AddHandler(handlers.NewCommand("/", wrapUserResponse(bot.comment, "comment"))) dispatcher.AddHandler(handlers.NewMessage(nil, wrapUserResponse(bot.proposeTransaction, "propose-transaction"))) - + dispatcher.AddHandler(handlers.NewCallback(isConfirmCallback, bot.confirmTransaction)) // Start receiving updates. err = updater.StartPolling(bot.bot, &ext.PollingOpts{ @@ -115,7 +110,7 @@ func (bot *Bot) Start() error { }, }) if err != nil { - return fmt.Errorf("failed to start polling: %v", err) + return fmt.Errorf("failed to start polling: %v", err) } slog.Info("bot has been started", "bot-name", bot.bot.Username) updater.Idle() @@ -123,9 +118,9 @@ func (bot *Bot) Start() error { return nil } - type response func(ctx *ext.Context) (msg string, opts *gotgbot.SendMessageOpts, err error) -func wrapUserResponse (next response, name string) handlers.Response { + +func wrapUserResponse(next response, name string) handlers.Response { return func(b *gotgbot.Bot, ctx *ext.Context) error { start := time.Now() msg := ctx.EffectiveMessage @@ -135,14 +130,14 @@ func wrapUserResponse (next response, name string) handlers.Response { "error in bot handler", "error", err, "duration", time.Since(start), - "from", msg.From.Username, + "from", msg.From.Username, "handler", name, ) } else { slog.Info( "handler success", "duration", time.Since(start), - "from", msg.From.Username, + "from", msg.From.Username, "handler", name, ) @@ -154,7 +149,7 @@ func wrapUserResponse (next response, name string) handlers.Response { "unable to send response", "error", ierr, "duration", time.Since(start), - "from", msg.From.Username, + "from", msg.From.Username, "handler", name, ) } @@ -168,18 +163,9 @@ func start(_ *ext.Context) (string, *gotgbot.SendMessageOpts, error) { } func (bot *Bot) vesrion(_ *ext.Context) (string, *gotgbot.SendMessageOpts, error) { - return fmt.Sprintf("teledger v: %s", bot.opts.Version), nil , nil + return fmt.Sprintf("teledger v: %s", bot.opts.Version), nil, nil } -func (bot *Bot) bal(_ *ext.Context) (string, *gotgbot.SendMessageOpts, error) { - balance, err := bot.teledger.Balance() - if err != nil { - werr := fmt.Errorf("unable to get balance: %v", err) - return werr.Error(), nil, werr - } - - return fmt.Sprintf("```%s```", balance), &gotgbot.SendMessageOpts{ParseMode: "MarkdownV2"}, nil -} func (bot *Bot) comment(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, error) { msg := ctx.EffectiveMessage @@ -200,18 +186,42 @@ func (bot *Bot) comment(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, err return fmt.Sprintf("```\n%s\n```", comment), &gotgbot.SendMessageOpts{ParseMode: "MarkdownV2"}, nil } +//go:embed templates/propose_transaction.html +var proposeTemplateS string +var proposeTemplate = template.Must(template.New("letter").Parse(proposeTemplateS)) + func (bot *Bot) proposeTransaction(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, error) { msg := ctx.EffectiveMessage - transaction, err := bot.teledger.ProposeTransaction(msg.Text) + pendTr := bot.teledger.ProposeTransaction(msg.Text) + var buf bytes.Buffer + err := proposeTemplate.Execute(&buf, pendTr) if err != nil { - return fmt.Sprintf("Error: %v", err), nil, nil + return "", nil, fmt.Errorf("unable to execute template: %v", err) } - return transaction, &gotgbot.SendMessageOpts{ParseMode: "MarkdownV2"}, nil -} + inlineKeyboard := [][]gotgbot.InlineKeyboardButton{} + + if key := pendTr.PendingKey; key != "" { + inlineKeyboard = append(inlineKeyboard, []gotgbot.InlineKeyboardButton{ + { + Text: "✅ Confirm", + CallbackData: fmt.Sprintf("%s%s",confirmPrefix, key), + }, + }) + } + + return buf.String(), &gotgbot.SendMessageOpts{ + ParseMode: "HTML", + ReplyParameters: &gotgbot.ReplyParameters{MessageId: msg.MessageId}, + DisableNotification: true, + ReplyMarkup: gotgbot.InlineKeyboardMarkup{ + InlineKeyboard: inlineKeyboard, + }, + }, nil +} func (bot *Bot) showAvailableReports(_ *ext.Context) (string, *gotgbot.SendMessageOpts, error) { reports := bot.teledger.Ledger.Config.Reports @@ -221,7 +231,7 @@ func (bot *Bot) showAvailableReports(_ *ext.Context) (string, *gotgbot.SendMessa for _, report := range reports { inlineKeyboard = append(inlineKeyboard, []gotgbot.InlineKeyboardButton{ { - Text: report.Title, + Text: report.Title, CallbackData: fmt.Sprintf("report:%s", report.Title), }, }) @@ -232,14 +242,102 @@ func (bot *Bot) showAvailableReports(_ *ext.Context) (string, *gotgbot.SendMessa ReplyMarkup: gotgbot.InlineKeyboardMarkup{ InlineKeyboard: inlineKeyboard, }, - } return "Available reports:", opts, nil } -func isReportCallback(_ *gotgbot.CallbackQuery) bool { - return true +func isReportCallback(cb *gotgbot.CallbackQuery) bool { + return strings.HasPrefix(cb.Data, "report:") +} + +const confirmPrefix = "cf:" +const deletePrefix = "rm:" + +func isConfirmCallback(cb *gotgbot.CallbackQuery) bool { + return strings.HasPrefix(cb.Data, confirmPrefix) +} + +func (bot *Bot) confirmTransaction(_ *gotgbot.Bot, ctx *ext.Context) error { + cq := ctx.CallbackQuery + + _, _, err := bot.bot.EditMessageReplyMarkup( + &gotgbot.EditMessageReplyMarkupOpts{ + MessageId: cq.Message.GetMessageId(), + ChatId: cq.Message.GetChat().Id, + InlineMessageId: cq.InlineMessageId, + ReplyMarkup: gotgbot.InlineKeyboardMarkup{}, + }, + ) + + if err != nil { + slog.Error("unable to edit inline keyboard", "error", err) + } + + + key := strings.TrimPrefix(cq.Data, confirmPrefix) + pendTr, err := bot.teledger.ConfirmTransaction(key) + + var newMessageContent bytes.Buffer + if err == nil { + err2 := proposeTemplate.Execute(&newMessageContent, pendTr) + if err2 != nil { + err = err2 + } + + } + + if err != nil { + _, _ = bot.bot.AnswerCallbackQuery(cq.Id, &gotgbot.AnswerCallbackQueryOpts{ + ShowAlert: true, + Text: fmt.Sprintf("🛑️ Error!\n%s", err) , + }) + + _, _, _ = bot.bot.EditMessageReplyMarkup( + &gotgbot.EditMessageReplyMarkupOpts{ + MessageId: cq.Message.GetMessageId(), + ChatId: cq.Message.GetChat().Id, + InlineMessageId: cq.InlineMessageId, + ReplyMarkup: gotgbot.InlineKeyboardMarkup{}, + }, + ) + + return nil + } + + + _, _, err = bot.bot.EditMessageText( + newMessageContent.String(), + &gotgbot.EditMessageTextOpts{ + MessageId: cq.Message.GetMessageId(), + ChatId: cq.Message.GetChat().Id, + InlineMessageId: cq.InlineMessageId, + ParseMode: "HTML", + ReplyMarkup: gotgbot.InlineKeyboardMarkup{ + InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ + []gotgbot.InlineKeyboardButton{ + { + Text: "🛑 Delete", + CallbackData: fmt.Sprint(deletePrefix, key), + }, + }, + }, + }, + }, + ) + + if err != nil { + slog.Error("unable to edit message", "error", err) + } + + _, err = bot.bot.AnswerCallbackQuery(cq.Id, &gotgbot.AnswerCallbackQueryOpts{ + Text: "✔️ confirmed", + }) + + if err != nil { + slog.Error("unable to answer callback query", "error", err) + } + return nil } func (bot *Bot) showReport(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, error) { @@ -252,7 +350,6 @@ func (bot *Bot) showReport(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, slog.Error("unable to answer callback query", "error", err) } - reportTitle := strings.TrimPrefix(cq.Data, "report:") report, err := bot.teledger.Report(reportTitle) @@ -260,6 +357,5 @@ func (bot *Bot) showReport(ctx *ext.Context) (string, *gotgbot.SendMessageOpts, return fmt.Sprintf("Error: %v", err), nil, nil } - return fmt.Sprintf("```\n%s\n```", report), &gotgbot.SendMessageOpts{ParseMode: "MarkdownV2"}, nil } diff --git a/app/bot/templates/propose_transaction.html b/app/bot/templates/propose_transaction.html new file mode 100644 index 0000000..1d732b8 --- /dev/null +++ b/app/bot/templates/propose_transaction.html @@ -0,0 +1,22 @@ +{{ if .Committed }} +✅ Committed! +{{- end -}} +{{ if .UserProvidedTransaction }} +Provided a valid transaction: +
+{{  .UserProvidedTransaction }}
+
+{{ end }} +{{- if .GeneratedTransaction }} +Transaction: +
+{{  .GeneratedTransaction -}}
+
+{{ .AttemptNumber }} attempt +{{ end -}} +{{ if .Error }} +🛑 Error: + +{{- .Error -}} + +{{ end }} diff --git a/app/ledger/ledger.go b/app/ledger/ledger.go index 8d94d99..9a0358e 100644 --- a/app/ledger/ledger.go +++ b/app/ledger/ledger.go @@ -38,11 +38,11 @@ type Report struct { } type Config struct { - MainFile string `yaml:"mainFile"` // default: main.ledger, not required - StrictMode bool `yaml:"strict"` // whether to allow non existing accounts and commodities - PromptTemplate string `yaml:"promptTemplate"` // not required - Version string `yaml:"version"` // do not include in documentation - Reports []Report `yaml:"reports"` // + MainFile string `yaml:"mainFile"` // default: main.ledger, not required + StrictMode bool `yaml:"strict"` // whether to allow non existing accounts and commodities + PromptTemplate string `yaml:"promptTemplate"` // not required + Version string `yaml:"version"` // do not include in documentation + Reports []Report `yaml:"reports"` // } func NewLedger(rs repo.Service, gen TransactionGenerator) *Ledger { @@ -219,7 +219,23 @@ func (l *Ledger) AddTransaction(transaction string) error { return fmt.Errorf("unable to set config: %v", err) } - return l.addTransaction(transaction) + err = l.addTransaction(transaction) + + if err != nil { + return err + } + + err = l.repo.CommitPush("New comment", "teledger", "teledger@example.com") + if err != nil { + return fmt.Errorf("unable to commit: %v", err) + } + return nil +} + +const transactionIDPrefix = ";; tid:" + +func (l *Ledger) AddTransactionWithID(transaction , id string) error { + return l.AddTransaction(fmt.Sprintf("%s%s\n%s",transactionIDPrefix, id, transaction)) } func (l *Ledger) validate() error { @@ -300,7 +316,7 @@ func (t *Transaction) Format(withComment bool) string { var res strings.Builder if withComment { res.WriteString( - wrapIntoComment(fmt.Sprintf("%s: %s", t.RealDateTime.Format("2006-01-02 15:04:05 Monday"), t.Comment)), + wrapIntoComment(t.Comment), ) res.WriteString("\n") } @@ -314,6 +330,11 @@ func (t *Transaction) Format(withComment bool) string { return res.String() } + +func (t *Transaction) String() string { + return t.Format(false) +} + // Posting represents a single posting in a transaction, linking an account with an amount and currency. type Posting struct { Account string `json:"account"` // The name of the account @@ -487,7 +508,9 @@ func (l *Ledger) proposeTransaction(userInput string) (Transaction, error) { return trx, fmt.Errorf("unable to generate transaction: %v", err) } - err = l.validateWith(trx.Format(true)) + // try to add for validation + err = l.addTransaction(trx.Format(false)) + if err != nil { return trx, fmt.Errorf("unable to validate transaction: %v", err) } @@ -505,7 +528,6 @@ func parseConfig(r io.Reader, c *Config) error { return nil } - func (l *Ledger) setConfig() error { if l.Config == nil { l.Config = &Config{} @@ -537,50 +559,81 @@ func (l *Ledger) setConfig() error { return nil } +type ProposeTransactionRespones struct { + // If the user provided a valid transaction as + // a description, it will be stored here + UserProvidedTransaction string + // If the user provided just a human-readable description + // of the transaction, the proposed transaction will be stored here + GeneratedTransaction *Transaction + // It's possible that a transaction was generated, but it's invalid + Error error + // Attempt from which the transaction was generated + AttemptNumber int + Committed bool +} + +func (l *Ledger) AddOrProposeTransaction(userInput string, attempts int) ProposeTransactionRespones { + resp := ProposeTransactionRespones{} -func (l *Ledger) AddOrProposeTransaction(userInput string, attempts int) (wasGenerated bool, tr Transaction, err error) { - wasGenerated = false - err = l.repo.Init() + // wasGenerated = false + err := l.repo.Init() defer l.repo.Free() if err != nil { - return wasGenerated, tr, err + resp.Error = err + return resp + // return wasGenerated, tr, err } + err = l.setConfig() if err != nil { - return wasGenerated, tr, fmt.Errorf("unable to set config: %v", err) + resp.Error = err + return resp } // first try to add userInput as transaction err = l.addTransaction(userInput) if err == nil { + // if user input was a valid transaction, commit it err = l.repo.CommitPush("New comment", "teledger", "teledger@example.com") + resp.UserProvidedTransaction = userInput if err != nil { - return wasGenerated, tr, err + resp.Error = err + return resp } - return wasGenerated, tr, nil + resp.Committed = true + return resp } + // if the error starts with "invalid transaction", it means that + // the user input was not a valid transaction. + // All other errors are returned as is, without trying to generate a transaction if !strings.HasPrefix(err.Error(), "invalid transaction:") { - return wasGenerated, tr, err + resp.Error = err + return resp } - // after this generate a transaction using LLM - wasGenerated = true if attempts <= 0 { panic("times should be greater than 0") } - for i := 0; i < attempts; i++ { - if i > 0 { + var addErr error + var tr Transaction + + for i := 1; i <= attempts; i++ { + if i > 1 { slog.Warn("retrying transaction generation", "attempt", i) } - tr, err = l.proposeTransaction(userInput) - if err == nil { - return wasGenerated, tr, nil + tr, addErr = l.proposeTransaction(userInput) + resp.Error = addErr + resp.GeneratedTransaction = &tr + resp.AttemptNumber = i + if addErr == nil { + return resp } } - return wasGenerated, tr, err + return resp } type PromptCtx struct { diff --git a/app/ledger/ledger_test.go b/app/ledger/ledger_test.go index cd6d080..166adf9 100644 --- a/app/ledger/ledger_test.go +++ b/app/ledger/ledger_test.go @@ -4,11 +4,12 @@ import ( "strings" "testing" - "github.com/mput/teledger/app/repo" - "github.com/stretchr/testify/assert" + "os" "time" + "github.com/joho/godotenv" - "os" + "github.com/mput/teledger/app/repo" + "github.com/stretchr/testify/assert" ) func TestLedger_Execute(t *testing.T) { @@ -156,14 +157,13 @@ dummy dummy }) } - func TestLedger_ProposeTransaction(t *testing.T) { mockCall := 0 var mockedTransactionGenerator *TransactionGeneratorMock mockedTransactionGenerator = &TransactionGeneratorMock{ - GenerateTransactionFunc: func(_ PromptCtx) (mocktr Transaction,err error) { + GenerateTransactionFunc: func(_ PromptCtx) (mocktr Transaction, err error) { mockCall++ dt, _ := time.Parse(time.RFC3339, "2014-11-12T11:45:26.371Z") // On the first attempt, return transaction that is not valid @@ -171,17 +171,17 @@ func TestLedger_ProposeTransaction(t *testing.T) { if len(mockedTransactionGenerator.calls.GenerateTransaction) == 1 { mocktr = Transaction{ RealDateTime: dt, - Description: "My tr", - Comment: "invalid transaction", + Description: "My tr", + Comment: "invalid transaction", Postings: []Posting{ { - Account: "cash", - Amount: -3000.43, + Account: "cash", + Amount: -3000.43, Currency: "EUR", }, { - Account: "taxi", - Amount: 3000.43, + Account: "taxi", + Amount: 3000.43, Currency: "EUR", }, }, @@ -189,17 +189,17 @@ func TestLedger_ProposeTransaction(t *testing.T) { } else { mocktr = Transaction{ RealDateTime: dt, - Comment: "valid transaction\n22 multiple lines", - Description: "Tacos", + Comment: "valid transaction\n22 multiple lines", + Description: "Tacos", Postings: []Posting{ { - Account: "Assets:Cash", - Amount: -3000.43, + Account: "Assets:Cash", + Amount: -3000.43, Currency: "EUR", }, { - Account: "Food", - Amount: 3000.43, + Account: "Food", + Amount: 3000.43, Currency: "EUR", }, }, @@ -228,7 +228,7 @@ strict: true // strict mode ledger := NewLedger( &repo.Mock{Files: map[string]string{ - "main.ledger": testFile, + "main.ledger": testFile, "teledger.yaml": configYaml, }}, mockedTransactionGenerator, @@ -236,82 +236,80 @@ strict: true t.Run("happy path", func(t *testing.T) { - - wasGenerated, tr, err := ledger.AddOrProposeTransaction("20 Taco Bell", 5) + resp := ledger.AddOrProposeTransaction("20 Taco Bell", 5) assert.True(t, ledger.Config.StrictMode) - assert.True(t, wasGenerated) + // assert.True(t, wasGenerated) + assert.Equal(t, "", resp.UserProvidedTransaction) - assert.NoError(t, err) + assert.NoError(t, resp.Error) assert.Equal(t, len(mockedTransactionGenerator.calls.GenerateTransaction), 2) + assert.Equal(t, 2, resp.AttemptNumber) - assert.Equal(t, "valid transaction\n22 multiple lines", tr.Comment) - + assert.Equal(t, "valid transaction\n22 multiple lines", resp.GeneratedTransaction.Comment) assert.Equal( t, - []string{"Food", "Assets:Cash", "Equity" }, + []string{"Food", "Assets:Cash", "Equity"}, mockedTransactionGenerator.calls.GenerateTransaction[0].PromptCtx.Accounts, ) - assert.Equal( t, []string{"EUR", "USD"}, mockedTransactionGenerator.calls.GenerateTransaction[0].PromptCtx.Commodities, ) - assert.Equal( t, "20 Taco Bell", mockedTransactionGenerator.calls.GenerateTransaction[0].PromptCtx.UserInput, ) + assert.False(t, resp.Committed) assert.Equal(t, - `;; 2014-11-12 11:45:26 Wednesday: valid transaction + `;; valid transaction ;; 22 multiple lines 2014-11-12 * Tacos Assets:Cash -3.000,43 EUR Food 3.000,43 EUR `, - tr.Format(true), + resp.GeneratedTransaction.Format(true), ) }) t.Run("add an already valid transaction", func(t *testing.T) { mockedTransactionGenerator.ResetCalls() - wasGenerated, _, err := ledger.AddOrProposeTransaction(` + resp := ledger.AddOrProposeTransaction(` 2014-11-12 * Tacos Assets:Cash -2,43 EUR Food 2,43 EUR `, 1) - assert.False(t, wasGenerated) - assert.NoError(t, err) + assert.Nil(t, resp.GeneratedTransaction) + assert.True(t, resp.Committed) + assert.NoError(t, resp.Error) assert.Equal(t, 0, len(mockedTransactionGenerator.calls.GenerateTransaction)) + assert.Equal(t, 0, resp.AttemptNumber) }) t.Run("validation error path", func(t *testing.T) { mockedTransactionGenerator.ResetCalls() - _, _, err := ledger.AddOrProposeTransaction("20 Taco Bell", 1) - - assert.ErrorContains(t, err, "Unknown account 'cash'") + resp := ledger.AddOrProposeTransaction("20 Taco Bell", 1) + assert.ErrorContains(t, resp.Error, "Unknown account 'cash'") assert.Equal(t, len(mockedTransactionGenerator.calls.GenerateTransaction), 1) }) - } - func TestWithRepo(t *testing.T) { _ = godotenv.Load("../../.env.dev") @@ -337,5 +335,4 @@ func TestWithRepo(t *testing.T) { assert.NotEmpty(t, res) - } diff --git a/app/teledger/teledger.go b/app/teledger/teledger.go index ef43eb8..4a02159 100644 --- a/app/teledger/teledger.go +++ b/app/teledger/teledger.go @@ -2,6 +2,7 @@ package teledger import ( "fmt" + "sync" "time" "github.com/mput/teledger/app/ledger" @@ -10,13 +11,15 @@ import ( // Teledger is the service that handles all the // operations related to the Ledger files type Teledger struct { - Ledger *ledger.Ledger + Ledger *ledger.Ledger + WaitingToBeConfirmedResponses *map[string]*PendingTransaction } - func NewTeledger(ldgr *ledger.Ledger) *Teledger { + m := make(map[string]*PendingTransaction) return &Teledger{ Ledger: ldgr, + WaitingToBeConfirmedResponses: &m, } } @@ -66,15 +69,15 @@ func (tel *Teledger) Report(reportTitle string) (string, error) { return tel.Ledger.Execute(reportArgs...) } - func (tel *Teledger) Init() error { _, err := tel.Ledger.Execute("bal") - return err + return err } - -func inBacktick(s string) string { - return fmt.Sprintf("```\n%s\n```", s) +type PendingTransaction struct { + ledger.ProposeTransactionRespones + PendingKey string + Mu sync.Mutex } // Receive a short free-text description of a transaction @@ -82,28 +85,39 @@ func inBacktick(s string) string { // ledger file. // Store the transaction in a state, so the user can confirm // or reject it. -func (tel *Teledger) ProposeTransaction(desc string) (string, error) { - wasGenerated, tr, err := tel.Ledger.AddOrProposeTransaction(desc, 2) - if wasGenerated { - if err == nil { - return inBacktick(tr.Format(false)), nil - } - - if len(tr.Postings) == 0 { - return fmt.Sprintf(`Proposed but invalid transaction: -%s`, - inBacktick(tr.Format(false)), - ), nil - } - - return "", err +func (tel *Teledger) ProposeTransaction(desc string) *PendingTransaction { + resp := tel.Ledger.AddOrProposeTransaction(desc, 2) + pt := PendingTransaction{ + ProposeTransactionRespones: resp, + } + if resp.Error == nil && resp.GeneratedTransaction != nil { + key := resp.GeneratedTransaction.RealDateTime.Format("2006-01-02 15:04:05.999 Mon") + pt.PendingKey = key + (*tel.WaitingToBeConfirmedResponses)[key] = &pt + } + return &pt +} +func (tel *Teledger) ConfirmTransaction(pendingKey string) (*PendingTransaction, error) { + pendTr, ok := (*tel.WaitingToBeConfirmedResponses)[pendingKey] + if !ok { + return nil, fmt.Errorf("missing pending transaction: `%s`", pendingKey) } + locked := pendTr.Mu.TryLock() + if !locked { + return nil, fmt.Errorf("transaction confirmation already in progress: `%s`", pendingKey) + } + defer pendTr.Mu.Unlock() + + err := tel.Ledger.AddTransactionWithID(pendTr.GeneratedTransaction.Format(true), pendingKey) - if err == nil { - return "Transaction Added", nil + if err != nil { + // pendTr.Error = err + return nil, err } - return "", err + pendTr.Committed = true + delete(*tel.WaitingToBeConfirmedResponses, pendingKey) + return pendTr, nil } diff --git a/app/teledger/teledger_test.go b/app/teledger/teledger_test.go index 00524f7..d63ca84 100644 --- a/app/teledger/teledger_test.go +++ b/app/teledger/teledger_test.go @@ -2,10 +2,10 @@ package teledger import ( "testing" + "time" - - "github.com/mput/teledger/app/repo" "github.com/mput/teledger/app/ledger" + "github.com/mput/teledger/app/repo" "github.com/stretchr/testify/assert" ) @@ -47,5 +47,130 @@ func TestTeledger_AddComment(t *testing.T) { + }) +} + +func TestTeledger_AddTransaction(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + initContent := ` +account Food +account Assets:Cash +account Equity +commodity EUR + +2024-02-13 * Test + Assets:Cash 100.00 EUR + Equity +` + const configYaml = ` +strict: true +` + + r := &repo.Mock{ + Files: map[string]string{"main.ledger": initContent, "teledger.yaml": configYaml}, + } + + mockedTransactionGenerator := &ledger.TransactionGeneratorMock{ + GenerateTransactionFunc: func(prmt ledger.PromptCtx) (mocktr ledger.Transaction, err error) { + dt, _ := time.Parse(time.RFC3339, "2014-11-30T11:45:26.371443Z") + + + switch prmt.UserInput { + case "valid": + mocktr = ledger.Transaction{ + RealDateTime: dt, + Description: "My tr", + Comment: prmt.UserInput, + Postings: []ledger.Posting{ + { + Account: "Assets:Cash", + Amount: -10, + Currency: "EUR", + }, + { + Account: "Food", + Amount: 10, + Currency: "EUR", + }, + }, + } + default: + panic("Should not be here!") + + } + return + }, + } + + + l := ledger.NewLedger(r, mockedTransactionGenerator) + + tldgr := NewTeledger(l) + resp := tldgr.ProposeTransaction("valid") + assert.NotEmpty(t, resp.PendingKey) + assert.Empty(t, resp.Error) + + t.Run("attempt to concurrently confirm the same transaction", func(t *testing.T) { + (*tldgr.WaitingToBeConfirmedResponses)[resp.PendingKey].Mu.Lock() + _, err := tldgr.ConfirmTransaction(resp.PendingKey) + assert.ErrorContains(t, err, "already in progress") + (*tldgr.WaitingToBeConfirmedResponses)[resp.PendingKey].Mu.Unlock() + }) + + r.Files["main.ledger"] = initContent + + assert.NotEmpty(t, resp.PendingKey) + _, err := tldgr.ConfirmTransaction(resp.PendingKey) + assert.Empty(t, err) + + assert.Equal( + t, + r.Files["main.ledger"], + ` +account Food +account Assets:Cash +account Equity +commodity EUR + +2024-02-13 * Test + Assets:Cash 100.00 EUR + Equity + +;; tid:2014-11-30 11:45:26.371 Sun +;; valid +2014-11-30 * My tr + Assets:Cash -10,00 EUR + Food 10,00 EUR + +`, + ) + + t.Run("attempt to confirm for the second time", func(t *testing.T) { + _, err = tldgr.ConfirmTransaction(resp.PendingKey) + assert.ErrorContains(t, err, "missing pending transaction") + + }) + + + t.Run("attempt to confirm with unknonw key", func(t *testing.T) { + _, err = tldgr.ConfirmTransaction("unk") + assert.ErrorContains(t, err, "missing pending transaction") + }) + + t.Run("propose valid transaction, not free form explanation", func(t *testing.T) { + resp := tldgr.ProposeTransaction(` +2014-11-30 * My tr + Assets:Cash -10,00 EUR + Food 10,00 EUR +`) + assert.Empty(t, resp.PendingKey) + assert.Equal(t, 0, resp.AttemptNumber) + assert.Empty(t, resp.Error) + + + }) + + + }) } diff --git a/todo.org b/todo.org index e74f9ce..856b4a4 100644 --- a/todo.org +++ b/todo.org @@ -3,13 +3,15 @@ * Main ** [ ] Add Transaction To a ledger repository - [X] Basic (transaction LLM completion and valid transaction addition) -- [ ] Add transaction to log with chat callback button +- [X] Add transaction to log with chat callback button + - [ ] Add ability to discard transaction - [ ] Add button to delete transaction ** [ ] Configuration from a repository with Ledger -- [ ] Strict mode -- [ ] Main file +- [X] Strict mode +- [X] Main file - [ ] Add transactions immediately without confirmation - [ ] Prompt Templates +- [ ] GPT version config ** [x] Text Reports (from configuration) * Nice to Have ** [ ] Visual Reports