From 91161a141bed526f7afef3ba58b471976e01f94f Mon Sep 17 00:00:00 2001 From: Martin Hansen Date: Fri, 22 Dec 2023 17:59:23 +0100 Subject: [PATCH] feat(nordigen)!: add requisition hook Rework the requisition fulfillment to be under the Reader struct and simplify the file handling. Move the requisition hook to the Nordigen config struct and make it more generic so i can be used to handle arbitrary events tied to the requisition and its status. BREAKING CHANGE: Remove NORDIGEN_DATAFILE in favor of the YNABBER_DATADIR option. This will require recreating the requisition. --- cmd/ynabber/main.go | 2 +- config.go | 11 ++-- reader/nordigen/auth.go | 93 ++++++++++++++------------------ reader/nordigen/auth_test.go | 15 ++++-- reader/nordigen/hooks/example.sh | 12 +++++ reader/nordigen/nordigen.go | 64 +++++++--------------- 6 files changed, 88 insertions(+), 109 deletions(-) create mode 100755 reader/nordigen/hooks/example.sh 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) }