diff --git a/cmd/ynabber/main.go b/cmd/ynabber/main.go index 799d03e..37173bf 100644 --- a/cmd/ynabber/main.go +++ b/cmd/ynabber/main.go @@ -58,7 +58,7 @@ func main() { for _, reader := range cfg.Readers { switch reader { case "nordigen": - ynabber.Readers = append(ynabber.Readers, nordigen.Reader{Config: &cfg}) + ynabber.Readers = append(ynabber.Readers, nordigen.NewReader(&cfg)) default: log.Fatalf("Unknown reader: %s", reader) } diff --git a/config.go b/config.go index 70c327d..163d7e4 100644 --- a/config.go +++ b/config.go @@ -52,9 +52,6 @@ type Config struct { // PayeeStrip is depreciated please use Nordigen.PayeeStrip instead PayeeStrip []string `envconfig:"YNABBER_PAYEE_STRIP"` - // SecretID is used to create requisition - NotificationScript string `envconfig:"NOTIFICATION_SCRIPT"` - // Reader and/or writer specific settings Nordigen Nordigen YNAB YNAB @@ -74,9 +71,6 @@ type Nordigen struct { // SecretKey is used to create requisition SecretKey string `envconfig:"NORDIGEN_SECRET_KEY"` - // Use named datafile(relative path in datadir, absolute if starts with slash) instead of default (ynabber-NORDIGEN_BANKID.json) - Datafile string `envconfig:"NORDIGEN_DATAFILE"` - // PayeeSource is a list of sources for Payee candidates, the first method // that yields a result will be used. Valid options are: unstructured, name // and additional. @@ -98,6 +92,11 @@ type Nordigen struct { // // Valid options are: TransactionId, InternalTransactionId TransactionID string `envconfig:"NORDIGEN_TRANSACTION_ID" default:"TransactionId"` + + // RequisitionHook is a exec hook thats executed at various stages of the + // requisition process. The hook is executed with the following arguments: + // + RequisitionHook string `envconfig:"NORDIGEN_REQUISITION_HOOK"` } // YNAB related settings diff --git a/reader/nordigen/auth.go b/reader/nordigen/auth.go index 14b1a35..1ee05b8 100644 --- a/reader/nordigen/auth.go +++ b/reader/nordigen/auth.go @@ -11,29 +11,23 @@ import ( "strconv" "time" - "github.com/martinohansen/ynabber" - "github.com/frieser/nordigen-go-lib/v2" ) -type Authorization struct { - BankID string - Client nordigen.Client - File string -} +const RequisitionRedirect = "https://raw.githubusercontent.com/martinohansen/ynabber/main/ok.html" -// Store returns a clean path to the requisition file -func (auth Authorization) Store() string { - return path.Clean(auth.File) +// requisitionStore returns a clean path to the requisition file +func (r Reader) requisitionStore() string { + return path.Clean(fmt.Sprintf("%s/%s", r.Config.DataDir, r.Config.Nordigen.BankID)) } -// AuthorizationWrapper tries to get requisition from disk, if it fails it will -// create a new and store that one to disk. -func (auth Authorization) Wrapper(cfg *ynabber.Config) (nordigen.Requisition, error) { - requisitionFile, err := os.ReadFile(auth.Store()) +// Requisition tries to get requisition from disk, if it fails it will create a +// new and store that one to disk. +func (r Reader) Requisition() (nordigen.Requisition, error) { + requisitionFile, err := os.ReadFile(r.requisitionStore()) if errors.Is(err, os.ErrNotExist) { log.Print("Requisition is not found") - return auth.CreateAndSave(*cfg) + return r.createRequisition() } else if err != nil { return nordigen.Requisition{}, fmt.Errorf("ReadFile: %w", err) } @@ -42,85 +36,76 @@ func (auth Authorization) Wrapper(cfg *ynabber.Config) (nordigen.Requisition, er err = json.Unmarshal(requisitionFile, &requisition) if err != nil { log.Print("Failed to parse requisition file") - return auth.CreateAndSave(*cfg) + return r.createRequisition() } switch requisition.Status { case "EX": + // Create a new requisition if expired log.Printf("Requisition is expired") - return auth.CreateAndSave(*cfg) + return r.createRequisition() case "LN": + // Return requisition if it's still valid return requisition, nil default: + // Handle unknown status by recreating requisition log.Printf("Unsupported requisition status: %s", requisition.Status) - return auth.CreateAndSave(*cfg) - } -} - -func (auth Authorization) CreateAndSave(cfg ynabber.Config) (nordigen.Requisition, error) { - log.Print("Creating new requisition...") - requisition, err := auth.Create(cfg) - if err != nil { - return nordigen.Requisition{}, fmt.Errorf("AuthorizationCreate: %w", err) - } - err = auth.Save(requisition) - if err != nil { - log.Printf("Failed to write requisition to disk: %s", err) + return r.createRequisition() } - log.Printf("Requisition stored for reuse: %s", auth.Store()) - return requisition, nil } -func (auth Authorization) Save(requisition nordigen.Requisition) error { +func (r Reader) saveRequisition(requisition nordigen.Requisition) error { requisitionFile, err := json.Marshal(requisition) if err != nil { return err } - err = os.WriteFile(auth.Store(), requisitionFile, 0644) + err = os.WriteFile(r.requisitionStore(), requisitionFile, 0644) if err != nil { return err } return nil } -func (auth Authorization) Create(cfg ynabber.Config) (nordigen.Requisition, error) { - requisition := nordigen.Requisition{ - Redirect: "https://raw.githubusercontent.com/martinohansen/ynabber/main/ok.html", +func (r Reader) createRequisition() (nordigen.Requisition, error) { + requisition, err := r.Client.CreateRequisition(nordigen.Requisition{ + Redirect: RequisitionRedirect, Reference: strconv.Itoa(int(time.Now().Unix())), Agreement: "", - InstitutionId: auth.BankID, - } - - r, err := auth.Client.CreateRequisition(requisition) + InstitutionId: r.Config.Nordigen.BankID, + }) if err != nil { return nordigen.Requisition{}, fmt.Errorf("CreateRequisition: %w", err) } - auth.Notify(cfg, r) - log.Printf("Initiate requisition by going to: %s", r.Link) + r.requisitionHook(requisition) + log.Printf("Initiate requisition by going to: %s", requisition.Link) // Keep waiting for the user to accept the requisition - for r.Status != "LN" { - r, err = auth.Client.GetRequisition(r.Id) - + for requisition.Status != "LN" { + requisition, err = r.Client.GetRequisition(requisition.Id) if err != nil { return nordigen.Requisition{}, fmt.Errorf("GetRequisition: %w", err) } - time.Sleep(1 * time.Second) + time.Sleep(2 * time.Second) } - return r, nil + // Store requisition on disk + err = r.saveRequisition(requisition) + if err != nil { + log.Printf("Failed to write requisition to disk: %s", err) + } + + return requisition, nil } -func (auth Authorization) Notify(cfg ynabber.Config, r nordigen.Requisition) { - if cfg.NotificationScript != "" { - cmd := exec.Command(cfg.NotificationScript, r.Link) +// requisitionHook executes the hook with the status and link as arguments +func (r Reader) requisitionHook(req nordigen.Requisition) { + if r.Config.Nordigen.RequisitionHook != "" { + cmd := exec.Command(r.Config.Nordigen.RequisitionHook, req.Status, req.Link) _, err := cmd.Output() if err != nil { - log.Println("Could not notify user about new requisition: ", err) + log.Printf("failed to run requisition hook: %s", err) } - } else { - log.Println("No Notification Script set") } } diff --git a/reader/nordigen/auth_test.go b/reader/nordigen/auth_test.go index df31773..518cec9 100644 --- a/reader/nordigen/auth_test.go +++ b/reader/nordigen/auth_test.go @@ -2,12 +2,21 @@ package nordigen import ( "testing" + + "github.com/martinohansen/ynabber" ) func TestStore(t *testing.T) { - auth := Authorization{File: "./ynabber.json"} - want := "ynabber.json" - got := auth.Store() + r := Reader{ + Config: &ynabber.Config{ + Nordigen: ynabber.Nordigen{ + BankID: "foo", + }, + DataDir: ".", + }, + } + want := "foo" + got := r.requisitionStore() if want != got { t.Fatalf("default: %s != %s", want, got) } diff --git a/reader/nordigen/hooks/example.sh b/reader/nordigen/hooks/example.sh new file mode 100755 index 0000000..aa45dd7 --- /dev/null +++ b/reader/nordigen/hooks/example.sh @@ -0,0 +1,12 @@ +#! /bin/bash + +echo "Hi from hook 👋 +status: $1 +link: $2 +at: $(date)" | tee /tmp/nordigen.log + +# If you want to only act on certain events, you key off the first argument like +# this: +if [ "$1" == "CR" ]; then + echo "Requsition created!" | tee -a /tmp/nordigen.log +fi diff --git a/reader/nordigen/nordigen.go b/reader/nordigen/nordigen.go index 7ee8cf2..2a8cbda 100644 --- a/reader/nordigen/nordigen.go +++ b/reader/nordigen/nordigen.go @@ -1,11 +1,8 @@ package nordigen import ( - "errors" "fmt" "log" - "os" - "path" "regexp" "strings" @@ -15,6 +12,21 @@ import ( type Reader struct { Config *ynabber.Config + + Client *nordigen.Client +} + +// NewReader returns a new nordigen reader or panics +func NewReader(cfg *ynabber.Config) Reader { + client, err := nordigen.NewClient(cfg.Nordigen.SecretID, cfg.Nordigen.SecretKey) + if err != nil { + panic("Failed to create nordigen client") + } + + return Reader{ + Config: cfg, + Client: client, + } } // payeeStripNonAlphanumeric removes all non-alphanumeric characters from payee @@ -57,53 +69,15 @@ func transactionsToYnabber(cfg ynabber.Config, account ynabber.Account, t nordig return x, nil } -// dataFile returns a persistent path -func dataFile(cfg ynabber.Config) string { - dataFile := "" - if cfg.Nordigen.Datafile != "" { - if path.IsAbs(cfg.Nordigen.Datafile) { - dataFile = cfg.Nordigen.Datafile - } else { - dataFile = fmt.Sprintf("%s/%s", cfg.DataDir, cfg.Nordigen.Datafile) - } - } else { - 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) - if errors.Is(err, os.ErrNotExist) { - _, err := os.Stat(dataFileGeneric) - if errors.Is(err, os.ErrNotExist) { - // If bank specific does not exists and neither does generic, use dataFileBankSpecific - dataFile = dataFileBankSpecific - } else { - // Generic dataFile exists(old naming) but not the bank specific, use dataFileGeneric - dataFile = dataFileGeneric - } - } - } - return dataFile -} - func (r Reader) Bulk() (t []ynabber.Transaction, err error) { - c, err := nordigen.NewClient(r.Config.Nordigen.SecretID, r.Config.Nordigen.SecretKey) - if err != nil { - return nil, fmt.Errorf("failed to create client: %w", err) - } - - Authorization := Authorization{ - Client: *c, - BankID: r.Config.Nordigen.BankID, - File: dataFile(*r.Config), - } - req, err := Authorization.Wrapper(r.Config) + req, err := r.Requisition() if err != nil { return nil, fmt.Errorf("failed to authorize: %w", err) } log.Printf("Found %v accounts", len(req.Accounts)) for _, account := range req.Accounts { - accountMetadata, err := c.GetAccountMetadata(account) + accountMetadata, err := r.Client.GetAccountMetadata(account) if err != nil { return nil, fmt.Errorf("failed to get account metadata: %w", err) } @@ -117,7 +91,7 @@ func (r Reader) Bulk() (t []ynabber.Transaction, err error) { account, accountMetadata.Status, ) - Authorization.CreateAndSave(*r.Config) + r.createRequisition() } account := ynabber.Account{ @@ -128,7 +102,7 @@ func (r Reader) Bulk() (t []ynabber.Transaction, err error) { log.Printf("Reading transactions from account: %s", account.Name) - transactions, err := c.GetAccountTransactions(string(account.ID)) + transactions, err := r.Client.GetAccountTransactions(string(account.ID)) if err != nil { return nil, fmt.Errorf("failed to get transactions: %w", err) }