Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nordigen)!: add requisition hook #58

Merged
merged 1 commit into from
Dec 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading