diff --git a/projects/update-ynab/banks/banks.go b/projects/update-ynab/banks/banks.go index bb186c4..6595c49 100644 --- a/projects/update-ynab/banks/banks.go +++ b/projects/update-ynab/banks/banks.go @@ -11,15 +11,7 @@ import ( "github.com/shopspring/decimal" ) -func LoadBankTxs(db *sqlx.DB) ([]types.BankAccountWithTransactions, error) { - var fromMonth time.Time - if time.Now().Day() <= 10 { - year, month, day := time.Now().Date() - fromMonth = time.Date(year, month-1, day, 0, 0, 0, 0, time.UTC) - } else { - fromMonth = time.Now() - } - +func LoadBankTxs(db *sqlx.DB, fromMonth time.Time) ([]types.BankAccountWithTransactions, error) { slog.Info("loading bank txs", slog.String("fromMonth", fromMonth.Format("2006-01"))) bankTxs := []types.DbBankTx{} @@ -76,13 +68,13 @@ func LoadBankTxs(db *sqlx.DB) ([]types.BankAccountWithTransactions, error) { bankAccounts = append(bankAccounts, *bankAccount) } - slog.Info("printing found bank txs:") - for _, bankAccount := range bankAccounts { - for _, bankTx := range bankAccount.Transactions { - slog.Info(fmt.Sprintf("account: %s, date: %s, doc_no: %s, amount: %d", bankAccount.Account.Number, - bankTx.Date.Format("2006-01-02"), bankTx.DocNo, bankTx.Amount)) - } - } + // slog.Info("printing found bank txs:") + // for _, bankAccount := range bankAccounts { + // for _, bankTx := range bankAccount.Transactions { + // slog.Info(fmt.Sprintf("account: %s, date: %s, doc_no: %s, amount: %d", bankAccount.Account.Number, + // bankTx.Date.Format("2006-01-02"), bankTx.DocNo, bankTx.Amount)) + // } + // } return bankAccounts, nil } diff --git a/projects/update-ynab/main.go b/projects/update-ynab/main.go index 28a2bbc..826ec2d 100644 --- a/projects/update-ynab/main.go +++ b/projects/update-ynab/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "os" + "time" "bank-bots/update-ynab/banks" "bank-bots/update-ynab/types" @@ -61,12 +62,20 @@ func work() (*string, error) { return nil, err } - bankAccountsWithTxs, err := banks.LoadBankTxs(db) + var fromMonth time.Time + if time.Now().Day() <= 10 { + year, month, day := time.Now().Date() + fromMonth = time.Date(year, month-1, day, 0, 0, 0, 0, time.UTC) + } else { + fromMonth = time.Now() + } + + bankAccountsWithTxs, err := banks.LoadBankTxs(db, fromMonth) if err != nil { return nil, err } - err = ynab.UpdateYnabWithBankTxs(config, bankAccountsWithTxs) + err = ynab.UpdateYnabWithBankTxs(config, bankAccountsWithTxs, fromMonth) if err != nil { return nil, err } diff --git a/projects/update-ynab/ynab/ynab.go b/projects/update-ynab/ynab/ynab.go index 0496305..123d86e 100644 --- a/projects/update-ynab/ynab/ynab.go +++ b/projects/update-ynab/ynab/ynab.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "regexp" "slices" "time" @@ -21,12 +22,21 @@ type AugmentedYnabTransaction struct { ParsedMemo *ParsedYnabTransactionMemo } -func UpdateYnabWithBankTxs(config *types.Config, bankAccountsWithTxs []types.BankAccountWithTransactions) error { +func UpdateYnabWithBankTxs(config *types.Config, bankAccountsWithTxs []types.BankAccountWithTransactions, fromMonth time.Time) error { client := ynab.NewClient(config.YNAB.AccessToken) ynabTxCreates := []transaction.PayloadTransaction{} ynabTxUpdates := []transaction.PayloadTransaction{} + type DeleteTransaction struct { + Id string `json:"id"` + FlagColor transaction.FlagColor `json:"flag_color"` + } + type DeleteTransactionsPayload struct { + Transactions []DeleteTransaction `json:"transactions"` + } + ynabTxDeletes := []DeleteTransaction{} + for _, bankAccount := range bankAccountsWithTxs { if len(bankAccount.Transactions) == 0 { continue @@ -69,9 +79,10 @@ func UpdateYnabWithBankTxs(config *types.Config, bankAccountsWithTxs []types.Ban }) } + // upserts for _, bankTx := range bankAccount.Transactions { - idx := slices.IndexFunc(ynabTxs, func(e AugmentedYnabTransaction) bool { - return e.ParsedMemo.Ref == bankTx.Ref || e.ParsedMemo.Ref == bankTx.DocNo + idx := slices.IndexFunc(ynabTxs, func(ynabTx AugmentedYnabTransaction) bool { + return ynabTx.ParsedMemo.Ref == bankTx.Ref || ynabTx.ParsedMemo.Ref == bankTx.DocNo }) if idx == -1 { slog.Info(fmt.Sprintf("creating transaction: %+v", bankTx)) @@ -102,6 +113,37 @@ func UpdateYnabWithBankTxs(config *types.Config, bankAccountsWithTxs []types.Ban } } } + // deletes + rgx := regexp.MustCompile(`\d{8,}_\d+(\(\d+\))?$`) + isSameOrFutureMonth := func(fromMonth, t time.Time) bool { + fromYear, fromMonthNum, _ := fromMonth.Date() + tYear, tMonthNum, _ := t.Date() + + if tYear > fromYear { + return true + } else if tYear == fromYear && tMonthNum >= fromMonthNum { + return true + } + return false + } + for _, ynabTx := range ynabTxs { + if !isSameOrFutureMonth(fromMonth, ynabTx.Tx.Date.Time) { + continue + } + if !rgx.MatchString(ynabTx.ParsedMemo.Ref) { + continue + } + idx := slices.IndexFunc(bankAccount.Transactions, func(bankTx types.PreparedBankTx) bool { + return ynabTx.ParsedMemo.Ref == bankTx.Ref || ynabTx.ParsedMemo.Ref == bankTx.DocNo + }) + if idx == -1 { + slog.Warn(fmt.Sprintf("marking transaction for deletion: %+v", ynabTx.Tx), "ref", ynabTx.ParsedMemo.Ref) + ynabTxDeletes = append(ynabTxDeletes, DeleteTransaction{ + Id: ynabTx.Tx.ID, + FlagColor: transaction.FlagColorRed, + }) + } + } } slog.Info(fmt.Sprintf("creating %v transactions", len(ynabTxCreates))) @@ -124,6 +166,31 @@ func UpdateYnabWithBankTxs(config *types.Config, bankAccountsWithTxs []types.Ban return err } } + slog.Info(fmt.Sprintf("deleting %v transactions", len(ynabTxDeletes))) + if len(ynabTxDeletes) > 0 { + payload := DeleteTransactionsPayload{ + Transactions: ynabTxDeletes, + } + jsonStr, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest( + "PATCH", + fmt.Sprintf("https://api.ynab.com/v1/budgets/%s/transactions", config.YNAB.BudgetID), + bytes.NewBuffer(jsonStr)) + if err != nil { + return err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.YNAB.AccessToken)) + req.Header.Add("Content-Type", "application/json") + httpClient := &http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + } return nil }