Skip to content

Commit

Permalink
fix(nordigen): nordea duplicate transactions
Browse files Browse the repository at this point in the history
Nordea never sleeps, this is a fix for a duplicate transactions which is
caused by Nordea showing the same transaction twice with different
transaction IDs.

BREAKING CHANGE: Nordea users will have new import IDs generated which
causes duplicate transactions. To fix this set the YNAB_FROM_DATE value
to the day you start using this version.

commit-id:40d40309
  • Loading branch information
martinohansen committed Sep 18, 2024
1 parent c582615 commit ee1dba0
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 60 deletions.
74 changes: 27 additions & 47 deletions reader/nordigen/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,14 @@ import (
"github.com/martinohansen/ynabber"
)

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

// 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{}
// Mapper uses the most specific mapper for the bank in question
func (r Reader) Mapper(a ynabber.Account, t nordigen.Transaction) (*ynabber.Transaction, error) {
switch {
case strings.HasPrefix(r.Config.Nordigen.BankID, "NORDEA_"):
return r.nordeaMapper(a, t)

default:
return Default{
PayeeSource: r.Config.Nordigen.PayeeSource,
TransactionID: r.Config.Nordigen.TransactionID,
}
return r.defaultMapper(a, t)
}
}

Expand All @@ -44,27 +37,24 @@ func parseDate(t nordigen.Transaction) (time.Time, error) {
return date, nil
}

// Default mapping for all banks unless a more specific mapping exists
type Default struct {
PayeeSource []string
TransactionID string
}
// defaultMapper is generic and tries to identify the appropriate mapping
func (r Reader) defaultMapper(a ynabber.Account, t nordigen.Transaction) (*ynabber.Transaction, error) {
PayeeSource := r.Config.Nordigen.PayeeSource
TransactionID := r.Config.Nordigen.TransactionID

// 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
return nil, err
}
date, err := parseDate(t)
if err != nil {
return ynabber.Transaction{}, err
return nil, err
}

// Get the Payee from the first data source that returns data in the order
// defined by config
payee := ""
for _, source := range mapper.PayeeSource {
for _, source := range PayeeSource {
if payee == "" {
switch source {
case "unstructured":
Expand Down Expand Up @@ -94,23 +84,23 @@ func (mapper Default) Map(a ynabber.Account, t nordigen.Transaction) (ynabber.Tr

default:
// Return an error if source is not recognized
return ynabber.Transaction{}, fmt.Errorf("unrecognized PayeeSource: %s", source)
return nil, fmt.Errorf("unrecognized PayeeSource: %s", source)
}
}
}

// Set the transaction ID according to config
var id string
switch mapper.TransactionID {
switch TransactionID {
case "InternalTransactionId":
id = t.InternalTransactionId
case "TransactionId":
id = t.TransactionId
default:
return ynabber.Transaction{}, fmt.Errorf("unrecognized TransactionID: %s", mapper.TransactionID)
return nil, fmt.Errorf("unrecognized TransactionID: %s", TransactionID)
}

return ynabber.Transaction{
return &ynabber.Transaction{
Account: a,
ID: ynabber.ID(id),
Date: date,
Expand All @@ -120,26 +110,16 @@ func (mapper Default) Map(a ynabber.Account, t nordigen.Transaction) (ynabber.Tr
}, 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
// nordeaMapper handles Nordea transactions specifically
func (r Reader) nordeaMapper(a ynabber.Account, t nordigen.Transaction) (*ynabber.Transaction, error) {
// They now maintain two transactions for every actual transaction. First
// they show up prefixed with a ID prefixed with a H, sometime later another
// transaction describing the same transactions shows up with a new ID
// prefixed with a P instead. The H transaction matches the date which its
// visible in my account so i will discard the P transactions for now.
if strings.HasPrefix(t.TransactionId, "P") {
return nil, nil
}

return ynabber.Transaction{
Account: a,
ID: ynabber.ID(t.InternalTransactionId),
Date: date,
Payee: ynabber.Payee(payeeStripNonAlphanumeric(t.RemittanceInformationUnstructured)),
Memo: t.RemittanceInformationUnstructured,
Amount: ynabber.MilliunitsFromAmount(amount),
}, nil
return r.defaultMapper(a, t)
}
15 changes: 10 additions & 5 deletions reader/nordigen/nordigen.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ func payeeStripNonAlphanumeric(payee string) (x string) {
return strings.TrimSpace(x)
}

func (r Reader) toYnabber(a ynabber.Account, t nordigen.Transaction) (ynabber.Transaction, error) {
transaction, err := r.Mapper().Map(a, t)
func (r Reader) toYnabber(a ynabber.Account, t nordigen.Transaction) (*ynabber.Transaction, error) {
transaction, err := r.Mapper(a, t)
if err != nil {
return ynabber.Transaction{}, err
return nil, err
}

// Execute strip method on payee if defined in config
Expand All @@ -63,8 +63,13 @@ func (r Reader) toYnabbers(a ynabber.Account, t nordigen.AccountTransactions) ([
}

// Append transaction
r.logger.Debug("mapped transaction", "from", v, "to", transaction)
y = append(y, transaction)
if transaction != nil {
r.logger.Debug("mapped transaction", "from", v, "to", transaction)
y = append(y, *transaction)
} else {
r.logger.Debug("skipping", "transaction", v)
}

}
return y, nil
}
Expand Down
17 changes: 9 additions & 8 deletions reader/nordigen/nordigen_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package nordigen

import (
"reflect"
"testing"
"time"

Expand All @@ -22,7 +23,7 @@ func TestToYnabber(t *testing.T) {
bankID string
reader Reader
args args
want ynabber.Transaction
want *ynabber.Transaction
wantErr bool
}{
{
Expand All @@ -33,10 +34,10 @@ func TestToYnabber(t *testing.T) {
args: args{
account: ynabber.Account{Name: "foo", IBAN: "bar"},
t: nordigen.Transaction{
InternalTransactionId: "H00000000000000000000",
EntryReference: "",
BookingDate: "2023-02-24",
ValueDate: "2023-02-24",
TransactionId: "H00000000000000000000",
EntryReference: "",
BookingDate: "2023-02-24",
ValueDate: "2023-02-24",
TransactionAmount: struct {
Amount string "json:\"amount,omitempty\""
Currency string "json:\"currency,omitempty\""
Expand All @@ -56,7 +57,7 @@ func TestToYnabber(t *testing.T) {
BankTransactionCode: "",
AdditionalInformation: "VISA KØB"},
},
want: ynabber.Transaction{
want: &ynabber.Transaction{
Account: ynabber.Account{Name: "foo", IBAN: "bar"},
ID: ynabber.ID("H00000000000000000000"),
Date: time.Date(2023, time.February, 24, 0, 0, 0, 0, time.UTC),
Expand Down Expand Up @@ -96,7 +97,7 @@ func TestToYnabber(t *testing.T) {
BankTransactionCode: "PURCHASE",
AdditionalInformation: "PASCAL AS"},
},
want: ynabber.Transaction{
want: &ynabber.Transaction{
Account: ynabber.Account{Name: "foo", IBAN: "bar"},
ID: ynabber.ID("foobar"),
Date: time.Date(2023, time.February, 24, 0, 0, 0, 0, time.UTC),
Expand All @@ -120,7 +121,7 @@ func TestToYnabber(t *testing.T) {
t.Errorf("error = %+v, wantErr %+v", err, tt.wantErr)
return
}
if got != tt.want {
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got = \n%+v, want \n%+v", got, tt.want)
}
})
Expand Down

0 comments on commit ee1dba0

Please sign in to comment.