Skip to content

Commit

Permalink
feat(nordigen)!: add requisition hook
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
martinohansen committed Dec 22, 2023
1 parent 049b7aa commit 91161a1
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 109 deletions.
2 changes: 1 addition & 1 deletion cmd/ynabber/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 5 additions & 6 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
// <status> <link>
RequisitionHook string `envconfig:"NORDIGEN_REQUISITION_HOOK"`
}

// YNAB related settings
Expand Down
93 changes: 39 additions & 54 deletions reader/nordigen/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
}
}
15 changes: 12 additions & 3 deletions reader/nordigen/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 12 additions & 0 deletions reader/nordigen/hooks/example.sh
Original file line number Diff line number Diff line change
@@ -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
64 changes: 19 additions & 45 deletions reader/nordigen/nordigen.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package nordigen

import (
"errors"
"fmt"
"log"
"os"
"path"
"regexp"
"strings"

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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{
Expand All @@ -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)
}
Expand Down

0 comments on commit 91161a1

Please sign in to comment.