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