Skip to content

Commit

Permalink
Merge pull request #39 from martinohansen/martin/chore-refactor
Browse files Browse the repository at this point in the history
Martin/chore refactor
  • Loading branch information
martinohansen authored Nov 17, 2022
2 parents c306999 + 7242dab commit f7748fc
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 110 deletions.
4 changes: 2 additions & 2 deletions cmd/ynabber/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ func main() {

// Handle movement of PayeeStrip from YNAB to Nordigen config strut
if cfg.Nordigen.PayeeStrip == nil {
if cfg.YNAB.PayeeStrip != nil {
if cfg.PayeeStrip != nil {
log.Printf("Config YNABBER_PAYEE_STRIP is depreciated, please use NORDIGEN_PAYEE_STRIP instead")
cfg.Nordigen.PayeeStrip = cfg.YNAB.PayeeStrip
cfg.Nordigen.PayeeStrip = cfg.PayeeStrip
}
}

Expand Down
38 changes: 36 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
package ynabber

import (
"encoding/json"
"time"
)

const DateFormat = "2006-01-02"

type Date time.Time

// Decode implements `envconfig.Decoder` for Date to parse string to time.Time
func (date *Date) Decode(value string) error {
time, err := time.Parse(DateFormat, value)
if err != nil {
return err
}
*date = Date(time)
return nil
}

type AccountMap map[string]string

// Decode implements `envconfig.Decoder` for AccountMap to decode JSON properly
func (accountMap *AccountMap) Decode(value string) error {
err := json.Unmarshal([]byte(value), &accountMap)
if err != nil {
return err
}
return nil
}

// Config is loaded from the environment during execution with cmd/ynabber
type Config struct {
// DataDir is the path for storing files e.g. Nordigen authorization
Expand All @@ -23,6 +49,9 @@ type Config struct {
// only YNAB is supported.
Writers []string `envconfig:"YNABBER_WRITERS" default:"ynab"`

// PayeeStrip is depreciated please use Nordigen.PayeeStrip instead
PayeeStrip []string `envconfig:"YNABBER_PAYEE_STRIP"`

// Reader and/or writer specific settings
Nordigen Nordigen
YNAB YNAB
Expand Down Expand Up @@ -62,8 +91,13 @@ type Nordigen struct {

// YNAB related settings
type YNAB struct {
// PayeeStrip is depreciated please use Nordigen.PayeeStrip instead
PayeeStrip []string `envconfig:"YNABBER_PAYEE_STRIP"`
// BudgetID for the budget you want to import transactions into. You can
// find the ID in the URL of YNAB: https://app.youneedabudget.com/<budget_id>/budget
BudgetID string `envconfig:"YNAB_BUDGETID"`

// Token is your personal access token as obtained from the YNAB developer
// settings section
Token string `envconfig:"YNAB_TOKEN"`

// Set cleared status, possible values: cleared, uncleared, reconciled .
// Default is uncleared for historical reasons but recommend setting this
Expand Down
5 changes: 1 addition & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ module github.com/martinohansen/ynabber

go 1.17

require (
github.com/frieser/nordigen-go-lib/v2 v2.1.1
github.com/google/uuid v1.3.0
)
require github.com/frieser/nordigen-go-lib/v2 v2.1.1

require github.com/kelseyhightower/envconfig v1.4.0
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
github.com/frieser/nordigen-go-lib/v2 v2.1.1 h1:nApiepW2oh+gPL2my9dvkHEVN6w30cmpAf0glNJkHCY=
github.com/frieser/nordigen-go-lib/v2 v2.1.1/go.mod h1:NejYisqD8GvynCN0vDGw7J66slnj7jB25c8tS1tr8bw=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
33 changes: 22 additions & 11 deletions reader/nordigen/nordigen.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ import (

const timeLayout = "2006-01-02"

// TODO(Martin): Move accountParser from Nordigen to YNAB package. We want to
// map Ynabber transaction to YNAB and not so much Nordigen to Ynabber like this
// is during currently.
func accountParser(account string, accountMap map[string]string) (ynabber.Account, error) {
for from, to := range accountMap {
if account == from {
return ynabber.Account{
ID: ynabber.ID(ynabber.IDFromString(to)),
ID: ynabber.ID(to),
Name: from,
}, nil
}
Expand All @@ -46,6 +49,11 @@ func payeeStripNonAlphanumeric(payee string) (x string) {
}

func transactionToYnabber(cfg ynabber.Config, account ynabber.Account, t nordigen.Transaction) (x ynabber.Transaction, err error) {
id := t.TransactionId
if id == "" {
log.Printf("Transaction ID is empty, this might cause duplicate entires in YNAB")
}

memo := t.RemittanceInformationUnstructured

amount, err := strconv.ParseFloat(t.TransactionAmount.Amount, 64)
Expand Down Expand Up @@ -89,7 +97,7 @@ func transactionToYnabber(cfg ynabber.Config, account ynabber.Account, t nordige

return ynabber.Transaction{
Account: account,
ID: ynabber.ID(ynabber.IDFromString(t.TransactionId)),
ID: ynabber.ID(id),
Date: date,
Payee: ynabber.Payee(payee),
Memo: memo,
Expand All @@ -109,13 +117,8 @@ func transactionsToYnabber(cfg ynabber.Config, account ynabber.Account, t nordig
return x, nil
}

func BulkReader(cfg ynabber.Config) (t []ynabber.Transaction, err error) {
c, err := nordigen.NewClient(cfg.Nordigen.SecretID, cfg.Nordigen.SecretKey)
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}

// Select persistent dataFile
// dataFile returns a persistent path
func dataFile(cfg ynabber.Config) string {
dataFile := ""
if cfg.Nordigen.Datafile != "" {
if path.IsAbs(cfg.Nordigen.Datafile) {
Expand All @@ -127,7 +130,7 @@ func BulkReader(cfg ynabber.Config) (t []ynabber.Transaction, err error) {
dataFileBankSpecific := fmt.Sprintf("%s/%s-%s.json", cfg.DataDir, "ynabber", cfg.Nordigen.BankID)
dataFileGeneric := fmt.Sprintf("%s/%s.json", cfg.DataDir, "ynabber")
dataFile = dataFileBankSpecific
_, err = os.Stat(dataFileBankSpecific)
_, err := os.Stat(dataFileBankSpecific)
if errors.Is(err, os.ErrNotExist) {
_, err := os.Stat(dataFileGeneric)
if errors.Is(err, os.ErrNotExist) {
Expand All @@ -139,11 +142,19 @@ func BulkReader(cfg ynabber.Config) (t []ynabber.Transaction, err error) {
}
}
}
return dataFile
}

func BulkReader(cfg ynabber.Config) (t []ynabber.Transaction, err error) {
c, err := nordigen.NewClient(cfg.Nordigen.SecretID, cfg.Nordigen.SecretKey)
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}

Authorization := Authorization{
Client: *c,
BankID: cfg.Nordigen.BankID,
File: dataFile,
File: dataFile(cfg),
}
r, err := Authorization.Wrapper()
if err != nil {
Expand Down
6 changes: 2 additions & 4 deletions reader/nordigen/nordigen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ func TestTransactionToYnabber(t *testing.T) {
}{
{name: "milliunits a",
args: args{cfg: ynabber.Config{}, account: ynabber.Account{}, t: nordigen.Transaction{
TransactionId: "00000000-0000-0000-0000-000000000000",
BookingDate: "0001-01-01",
BookingDate: "0001-01-01",
TransactionAmount: struct {
Amount string "json:\"amount,omitempty\""
Currency string "json:\"currency,omitempty\""
Expand All @@ -37,8 +36,7 @@ func TestTransactionToYnabber(t *testing.T) {
},
{name: "milliunits b",
args: args{cfg: ynabber.Config{}, account: ynabber.Account{}, t: nordigen.Transaction{
TransactionId: "00000000-0000-0000-0000-000000000000",
BookingDate: "0001-01-01",
BookingDate: "0001-01-01",
TransactionAmount: struct {
Amount string "json:\"amount,omitempty\""
Currency string "json:\"currency,omitempty\""
Expand Down
119 changes: 57 additions & 62 deletions writer/ynab/ynab.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,89 +8,84 @@ import (
"log"
"net/http"
"net/http/httputil"
"os"
"regexp"
"strings"

"github.com/google/uuid"
"github.com/martinohansen/ynabber"
)

const maxMemoSize int = 200 // Max size of memo field in YNAB API
const maxPayeeSize int = 100 // Max size of payee field in YNAB API

func BulkWriter(cfg ynabber.Config, t []ynabber.Transaction) error {
var space = regexp.MustCompile(`\s+`) // Matches all whitespace characters

// Ytransaction is a single YNAB transaction
type Ytransaction struct {
AccountID string `json:"account_id"`
Date string `json:"date"`
Amount string `json:"amount"`
PayeeName string `json:"payee_name"`
Memo string `json:"memo"`
ImportID string `json:"import_id"`
Cleared string `json:"cleared"`
Approved bool `json:"approved"`
}

// Ytransactions is multiple YNAB transactions
type Ytransactions struct {
Transactions []Ytransaction `json:"transactions"`
}

budgetID, found := os.LookupEnv("YNAB_BUDGETID")
if !found {
return fmt.Errorf("env variable YNAB_BUDGETID: %w", ynabber.ErrNotFound)
func ynabberToYNAB(cfg ynabber.Config, t ynabber.Transaction) Ytransaction {
date := t.Date.Format("2006-01-02")
amount := t.Amount.String()

// Trim consecutive spaces from memo and truncate if too long
memo := strings.TrimSpace(space.ReplaceAllString(t.Memo, " "))
if len(memo) > maxMemoSize {
log.Printf("Memo on account %s on date %s is too long - truncated to %d characters",
t.Account.Name, date, maxMemoSize)
memo = memo[0:(maxMemoSize - 1)]
}
token, found := os.LookupEnv("YNAB_TOKEN")
if !found {
return fmt.Errorf("env variable YNAB_TOKEN: %w", ynabber.ErrNotFound)

// Trim consecutive spaces from payee and truncate if too long
payee := strings.TrimSpace(space.ReplaceAllString(string(t.Payee), " "))
if len(payee) > maxPayeeSize {
log.Printf("Payee on account %s on date %s is too long - truncated to %d characters",
t.Account.Name, date, maxPayeeSize)
payee = payee[0:(maxPayeeSize - 1)]
}

// Generating YNAB compliant import ID, output example:
// YBBR:-741000:2021-02-18:92f2beb1
hash := sha256.Sum256([]byte(t.Memo))
id := fmt.Sprintf("YBBR:%s:%s:%x", amount, date, hash[:2])

return Ytransaction{
AccountID: string(t.Account.ID),
Date: date,
Amount: amount,
PayeeName: payee,
Memo: memo,
ImportID: id,
Cleared: cfg.YNAB.Cleared,
Approved: false,
}
}

func BulkWriter(cfg ynabber.Config, t []ynabber.Transaction) error {
if len(t) == 0 {
log.Println("No transactions to write")
return nil
}

type Ytransaction struct {
AccountID string `json:"account_id"`
Date string `json:"date"`
Amount string `json:"amount"`
PayeeName string `json:"payee_name"`
Memo string `json:"memo"`
ImportID string `json:"import_id"`
Cleared string `json:"cleared"`
Approved bool `json:"approved"`
}
type Ytransactions struct {
Transactions []Ytransaction `json:"transactions"`
}

// Build array of transactions to send to YNAB
y := new(Ytransactions)
space := regexp.MustCompile(`\s+`) // Reused on every iteration
for _, v := range t {
date := v.Date.Format("2006-01-02")
amount := v.Amount.String()

// Trim consecutive spaces from memo and truncate if too long
memo := strings.TrimSpace(space.ReplaceAllString(v.Memo, " "))
if len(memo) > maxMemoSize {
log.Printf("Memo on account %s on date %s is too long - truncated to %d characters",
v.Account.Name, date, maxMemoSize)
memo = memo[0:(maxMemoSize - 1)]
}

// Trim consecutive spaces from payee and truncate if too long
payee := strings.TrimSpace(space.ReplaceAllString(string(v.Payee), " "))
if len(payee) > maxPayeeSize {
log.Printf("Payee on account %s on date %s is too long - truncated to %d characters",
v.Account.Name, date, maxPayeeSize)
payee = payee[0:(maxPayeeSize - 1)]
}

// Generating YNAB compliant import ID, output example:
// YBBR:-741000:2021-02-18:92f2beb1
hash := sha256.Sum256([]byte(v.Memo))
id := fmt.Sprintf("YBBR:%s:%s:%x", amount, date, hash[:2])

x := Ytransaction{
AccountID: uuid.UUID(v.Account.ID).String(),
Date: date,
Amount: amount,
PayeeName: payee,
Memo: memo,
ImportID: id,
Cleared: cfg.YNAB.Cleared,
Approved: false,
}

y.Transactions = append(y.Transactions, x)
y.Transactions = append(y.Transactions, ynabberToYNAB(cfg, v))
}

url := fmt.Sprintf("https://api.youneedabudget.com/v1/budgets/%s/transactions", budgetID)
url := fmt.Sprintf("https://api.youneedabudget.com/v1/budgets/%s/transactions", cfg.YNAB.BudgetID)

payload, err := json.Marshal(y)
if err != nil {
Expand All @@ -108,7 +103,7 @@ func BulkWriter(cfg ynabber.Config, t []ynabber.Transaction) error {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", cfg.YNAB.Token))

res, err := client.Do(req)
if err != nil {
Expand Down
24 changes: 1 addition & 23 deletions ynabber.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,16 @@
package ynabber

import (
"encoding/json"
"strconv"
"time"

"github.com/google/uuid"
)

type AccountMap map[string]string

// Decode implements `envconfig.Decoder` for AccountMap to decode JSON properly
func (input *AccountMap) Decode(value string) error {
err := json.Unmarshal([]byte(value), &input)
if err != nil {
return err
}
return nil
}

type Account struct {
ID ID
Name string
}

type ID uuid.UUID
type ID string

type Payee string

Expand All @@ -44,14 +30,6 @@ type Ynabber interface {
bulkWriter([]Transaction) error
}

func IDFromString(id string) uuid.UUID {
x, err := uuid.Parse(id)
if err != nil {
return uuid.New()
}
return x
}

func (m Milliunits) String() string {
return strconv.FormatInt(int64(m), 10)
}
Expand Down

0 comments on commit f7748fc

Please sign in to comment.