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

Martin/nordea mapper #62

Merged
merged 3 commits into from
Jan 7, 2024
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
34 changes: 5 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,55 +59,31 @@ docker run \
ghcr.io/martinohansen/ynabber:latest
```


### Requisition URL Hooks

In order to allow bank account data to flow to YNAB, this application requires an authentication with Nordigen. That URL is called "requistion URL" and is available in the docker logs. For some banks, this access is only valid for 90 days. This application requires a relogin after. In order to make that process easier (i.e. by sending the requistion URL to the phone) ynabber supports hooks when creating a requisition URL. In order to set it up, one first creates a shell-script, for example named `hook.sh`:

```bash
#! /bin/sh

echo "Hi from hook 👋
status: $1
link: $2
at: $(date)"
fi
```

And then configures a hook in the configuration file:
```bash
NORDIGEN_REQUISITION_HOOK=/data/hook.sh
```

When using ynabber throuch docker, keep in mind that the docker container does not support a vast array of command line tools (i.e. no bash, wget instead of cURL).

## Readers

Currently tested readers and verified banks, but any bank supported by Nordigen
should work.

| Reader | Bank | |
|----------|-----------------|---|
| Nordigen | ALANDSBANKEN_AABAFI22 | ✅
| | NORDEA_NDEADKKK | ✅[^1]
| [Nordigen](/reader/nordigen/)[^1] | ALANDSBANKEN_AABAFI22 | ✅
| | NORDEA_NDEADKKK | ✅
| | NORDEA_NDEAFIHH | ✅
| | NORWEGIAN_FI_NORWNOK1 | ✅
| | S_PANKKI_SBANFIHH | ✅

Please open an [issue](https://github.com/martinohansen/ynabber/issues/new) if
[^1]: Please open an [issue](https://github.com/martinohansen/ynabber/issues/new) if
you have problems with a specific bank.

[^1]: Requires setting NORDIGEN_TRANSACTION_ID to "InternalTransactionId"

## Writers

The default writer is YNAB (that's really what this tool is set out to handle)
but we also have a JSON writer that can be used for testing purposes.

| Writer | Description |
|---------|---------------|
| YNAB | Pushes transactions to YNAB |
| JSON | Writes transactions to stdout in JSON format |
| [YNAB](/writer/ynab/) | Pushes transactions to YNAB |
| [JSON](/writer/json/) | Writes transactions to stdout in JSON format |

## Contributing

Expand Down
9 changes: 0 additions & 9 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,6 @@ type Nordigen struct {
// "foo,bar"
PayeeStrip []string `envconfig:"NORDIGEN_PAYEE_STRIP"`

// TransactionID picks the field to use as transaction ID. This is relevant
// for some banks where the ID provided by the bank is not consistent. For
// example with NORDEA_NDEADKKK the TransactionId changes with time, which
// might cause hard to debug duplicate entries in YNAB. Only change this if
// you have a good reason to do so.
//
// 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>
Expand Down
18 changes: 18 additions & 0 deletions reader/nordigen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Nordigen

This reader reads transactions from [Nordigen](https://nordigen.com/en/), now
acquired by GoCardless.

## Requisition Hook

In order to allow bank account data to flow, you must be authenticated to your
bank. To authenticate a requisition URL is available in the logs. For some banks
this access is only valid for 90 days, whereafter a reauthorization is required.
To ease that process a requisition hook is made available.

See [config.go](../../config.go) for information on how to configure it.

### Examples

A few shell scripts that can be used as targets for the hook are available in
the [hooks](./hooks/) directory.
4 changes: 4 additions & 0 deletions reader/nordigen/hooks/logsnag-example.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#!/bin/sh

# This is an example of how to use Logsnag (https://logsnag.com/) to send a
# notifications.

reqURL=$2

logsnagToken="<your-logsnag-token>"
Expand Down
80 changes: 62 additions & 18 deletions reader/nordigen/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,58 @@ import (
)

type Mapper interface {
Map(ynabber.Config, ynabber.Account, nordigen.Transaction) ynabber.Transaction
Map(ynabber.Account, nordigen.Transaction) (ynabber.Transaction, error)
}

// Default mapping for all banks unless a more specific mapping exists
type Default struct{}
// Mapper returns a mapper to transform the banks transaction to Ynabber
func (r Reader) Mapper() Mapper {
switch r.Config.Nordigen.BankID {
case "NORDEA_NDEADKKK":
return Nordea{}

default:
return Default{
PayeeSource: r.Config.Nordigen.PayeeSource,
}
}
}

// Map Nordigen transactions using the default mapper
func (Default) Map(cfg ynabber.Config, a ynabber.Account, t nordigen.Transaction) (ynabber.Transaction, error) {
func parseAmount(t nordigen.Transaction) (float64, error) {
amount, err := strconv.ParseFloat(t.TransactionAmount.Amount, 64)
if err != nil {
return ynabber.Transaction{}, fmt.Errorf("failed to convert string to float: %w", err)
return 0, fmt.Errorf("failed to convert string to float: %w", err)
}
return amount, nil
}

func parseDate(t nordigen.Transaction) (time.Time, error) {
date, err := time.Parse("2006-01-02", t.BookingDate)
if err != nil {
return ynabber.Transaction{}, fmt.Errorf("failed to parse string to time: %w", err)
return time.Time{}, fmt.Errorf("failed to parse string to time: %w", err)
}
return date, nil
}

// Default mapping for all banks unless a more specific mapping exists
type Default struct {
PayeeSource []string
}

// Map t using the default mapper
func (mapper Default) Map(a ynabber.Account, t nordigen.Transaction) (ynabber.Transaction, error) {
amount, err := parseAmount(t)
if err != nil {
return ynabber.Transaction{}, err
}
date, err := parseDate(t)
if err != nil {
return ynabber.Transaction{}, err
}

// Get the Payee from the first data source that returns data in the order
// defined by config
payee := ""
for _, source := range cfg.Nordigen.PayeeSource {
for _, source := range mapper.PayeeSource {
if payee == "" {
switch source {
// Unstructured should properly have been called "remittance" but
Expand Down Expand Up @@ -61,21 +91,35 @@ func (Default) Map(cfg ynabber.Config, a ynabber.Account, t nordigen.Transaction
}
}

// Get the ID from the first data source that returns data as defined in the
// config
var id string
switch cfg.Nordigen.TransactionID {
case "InternalTransactionId":
id = t.InternalTransactionId
default:
id = t.TransactionId
return ynabber.Transaction{
Account: a,
ID: ynabber.ID(t.TransactionId),
Date: date,
Payee: ynabber.Payee(payee),
Memo: t.RemittanceInformationUnstructured,
Amount: ynabber.MilliunitsFromAmount(amount),
}, nil
}

// Nordea implements a specific mapper for Nordea
type Nordea struct{}

// Map t using the Nordea mapper
func (mapper Nordea) Map(a ynabber.Account, t nordigen.Transaction) (ynabber.Transaction, error) {
amount, err := parseAmount(t)
if err != nil {
return ynabber.Transaction{}, err
}
date, err := parseDate(t)
if err != nil {
return ynabber.Transaction{}, err
}

return ynabber.Transaction{
Account: a,
ID: ynabber.ID(id),
ID: ynabber.ID(t.InternalTransactionId),
Date: date,
Payee: ynabber.Payee(payee),
Payee: ynabber.Payee(payeeStripNonAlphanumeric(t.RemittanceInformationUnstructured)),
Memo: t.RemittanceInformationUnstructured,
Amount: ynabber.MilliunitsFromAmount(amount),
}, nil
Expand Down
50 changes: 50 additions & 0 deletions reader/nordigen/mapper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package nordigen

import (
"fmt"
"testing"

"github.com/frieser/nordigen-go-lib/v2"
)

func TestParseAmount(t *testing.T) {
tests := []struct {
transaction nordigen.Transaction
want float64
wantErr bool
}{
{
transaction: nordigen.Transaction{
TransactionAmount: struct {
Amount string "json:\"amount,omitempty\""
Currency string "json:\"currency,omitempty\""
}{Amount: "328.18"},
},
want: 328.18,
wantErr: false,
},
{
transaction: nordigen.Transaction{
TransactionAmount: struct {
Amount string "json:\"amount,omitempty\""
Currency string "json:\"currency,omitempty\""
}{Amount: "32818"},
},
want: 32818,
wantErr: false,
},
}

for _, tt := range tests {
t.Run(fmt.Sprintf("Amount: %s", tt.transaction.TransactionAmount.Amount), func(t *testing.T) {
got, err := parseAmount(tt.transaction)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("got = %v, want %v", got, tt.want)
}
})
}
}
31 changes: 13 additions & 18 deletions reader/nordigen/nordigen.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,37 +36,32 @@ func payeeStripNonAlphanumeric(payee string) (x string) {
return strings.TrimSpace(x)
}

func transactionToYnabber(cfg ynabber.Config, account ynabber.Account, t nordigen.Transaction) (y ynabber.Transaction, err error) {
// Pick an appropriate mapper based on the BankID provided or fallback to
// our default best effort mapper.
switch cfg.Nordigen.BankID {
default:
y, err = Default{}.Map(cfg, account, t)
}

// Return now if any of the mappings resulted in error
func (r Reader) toYnabber(a ynabber.Account, t nordigen.Transaction) (ynabber.Transaction, error) {
transaction, err := r.Mapper().Map(a, t)
if err != nil {
return y, err
return ynabber.Transaction{}, err
}

// Execute strip method on payee if defined in config
if cfg.Nordigen.PayeeStrip != nil {
y.Payee = y.Payee.Strip(cfg.Nordigen.PayeeStrip)
if r.Config.Nordigen.PayeeStrip != nil {
transaction.Payee = transaction.Payee.Strip(r.Config.Nordigen.PayeeStrip)
}

return y, err
return transaction, nil
}

func transactionsToYnabber(cfg ynabber.Config, account ynabber.Account, t nordigen.AccountTransactions) (x []ynabber.Transaction, err error) {
func (r Reader) toYnabbers(a ynabber.Account, t nordigen.AccountTransactions) ([]ynabber.Transaction, error) {
y := []ynabber.Transaction{}
for _, v := range t.Transactions.Booked {
transaction, err := transactionToYnabber(cfg, account, v)
transaction, err := r.toYnabber(a, v)
if err != nil {
return nil, err
}

// Append transaction
x = append(x, transaction)
y = append(y, transaction)
}
return x, nil
return y, nil
}

func (r Reader) Bulk() (t []ynabber.Transaction, err error) {
Expand Down Expand Up @@ -111,7 +106,7 @@ func (r Reader) Bulk() (t []ynabber.Transaction, err error) {
log.Printf("Transactions received from Nordigen: %+v", transactions)
}

x, err := transactionsToYnabber(*r.Config, account, transactions)
x, err := r.toYnabbers(account, transactions)
if err != nil {
return nil, fmt.Errorf("failed to convert transaction: %w", err)
}
Expand Down
Loading
Loading