diff --git a/internal/connectors/plugins/currency/currency.go b/internal/connectors/plugins/currency/currency.go index 2523727c..7524d4a4 100644 --- a/internal/connectors/plugins/currency/currency.go +++ b/internal/connectors/plugins/currency/currency.go @@ -7,6 +7,8 @@ import ( ) var ( + ErrMissingCurrencies = errors.New("missing currencies") + UnsupportedCurrencies = map[string]struct{}{ "HUF": {}, "ISK": {}, @@ -203,7 +205,7 @@ func GetPrecision(currencies map[string]int, cur string) (int, error) { def, ok := currencies[asset] if !ok { - return 0, errors.New("missing currencies") + return 0, ErrMissingCurrencies } return def, nil diff --git a/internal/connectors/plugins/public/moneycorp/accounts.go b/internal/connectors/plugins/public/moneycorp/accounts.go index c484cc19..a0986586 100644 --- a/internal/connectors/plugins/public/moneycorp/accounts.go +++ b/internal/connectors/plugins/public/moneycorp/accounts.go @@ -5,7 +5,9 @@ import ( "encoding/json" "time" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" ) type accountsState struct { @@ -15,7 +17,7 @@ type accountsState struct { LastIDCreated string `json:"lastIDCreated"` } -func (p Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { +func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { var oldState accountsState if req.State != nil { if err := json.Unmarshal(req.State, &oldState); err != nil { @@ -28,51 +30,33 @@ func (p Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccou LastIDCreated: oldState.LastIDCreated, } - var accounts []models.PSPAccount + accounts := make([]models.PSPAccount, 0, req.PageSize) hasMore := false for page := oldState.LastPage; ; page++ { newState.LastPage = page + pageSize := req.PageSize - len(accounts) - pagedAccounts, err := p.client.GetAccounts(ctx, page, req.PageSize) + pagedAccounts, err := p.client.GetAccounts(ctx, page, pageSize) if err != nil { return models.FetchNextAccountsResponse{}, err } - if len(pagedAccounts) == 0 { + hasMore = false break } - for _, account := range pagedAccounts { - if account.ID <= oldState.LastIDCreated { - continue - } - - raw, err := json.Marshal(account) - if err != nil { - return models.FetchNextAccountsResponse{}, err - } - - accounts = append(accounts, models.PSPAccount{ - Reference: account.ID, - // Moneycorp does not send the opening date of the account - CreatedAt: time.Now().UTC(), - Name: &account.Attributes.AccountName, - Raw: raw, - }) - - newState.LastIDCreated = account.ID - - if len(accounts) >= req.PageSize { - break - } + accounts, err = toPSPAccounts(oldState.LastIDCreated, accounts, pagedAccounts) + if err != nil { + return models.FetchNextAccountsResponse{}, err } - - if len(pagedAccounts) < req.PageSize { + if len(accounts) == 0 { break } + newState.LastIDCreated = accounts[len(accounts)-1].Reference - if len(accounts) >= req.PageSize { - hasMore = true + needMore := true + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedAccounts, pageSize) + if !needMore { break } } @@ -88,3 +72,29 @@ func (p Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccou HasMore: hasMore, }, nil } + +func toPSPAccounts( + lastIDSeen string, + accounts []models.PSPAccount, + pagedAccounts []*client.Account, +) ([]models.PSPAccount, error) { + for _, account := range pagedAccounts { + if account.ID <= lastIDSeen { + continue + } + + raw, err := json.Marshal(account) + if err != nil { + return accounts, err + } + + accounts = append(accounts, models.PSPAccount{ + Reference: account.ID, + // Moneycorp does not send the opening date of the account + CreatedAt: time.Now().UTC(), + Name: &account.Attributes.AccountName, + Raw: raw, + }) + } + return accounts, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/accounts_test.go b/internal/connectors/plugins/public/moneycorp/accounts_test.go new file mode 100644 index 00000000..e456a9c0 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/accounts_test.go @@ -0,0 +1,60 @@ +package moneycorp + +import ( + "encoding/json" + "fmt" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp Plugin Accounts", func() { + Context("fetch next accounts", func() { + var ( + plg *Plugin + m *client.MockClient + + pageSize int + sampleAccounts []*client.Account + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + pageSize = 15 + + sampleAccounts = make([]*client.Account, 0) + for i := 0; i < pageSize; i++ { + sampleAccounts = append(sampleAccounts, &client.Account{ + ID: fmt.Sprintf("moneycorp-reference-%d", i), + }) + } + + }) + It("fetches next accounts", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + m.EXPECT().GetAccounts(ctx, gomock.Any(), pageSize).Return( + sampleAccounts, + nil, + ) + res, err := plg.FetchNextAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeTrue()) + Expect(res.Accounts).To(HaveLen(req.PageSize)) + + var state accountsState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(0)) + Expect(state.LastIDCreated).To(Equal(res.Accounts[len(res.Accounts)-1].Reference)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/balances.go b/internal/connectors/plugins/public/moneycorp/balances.go index 2403e7b1..db892fbd 100644 --- a/internal/connectors/plugins/public/moneycorp/balances.go +++ b/internal/connectors/plugins/public/moneycorp/balances.go @@ -9,7 +9,7 @@ import ( "github.com/formancehq/payments/internal/models" ) -func (p Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { +func (p *Plugin) fetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { var from models.PSPAccount if req.FromPayload == nil { return models.FetchNextBalancesResponse{}, models.ErrMissingFromPayloadInRequest diff --git a/internal/connectors/plugins/public/moneycorp/balances_test.go b/internal/connectors/plugins/public/moneycorp/balances_test.go new file mode 100644 index 00000000..b1798851 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/balances_test.go @@ -0,0 +1,62 @@ +package moneycorp + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp Plugin Balances", func() { + var ( + plg *Plugin + ) + + Context("fetch next balances", func() { + var ( + m *client.MockClient + + accRef string + sampleBalance *client.Balance + expectedAmount *big.Int + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + accRef = "abc" + expectedAmount = big.NewInt(309900) + sampleBalance = &client.Balance{ + Attributes: client.Attributes{ + CurrencyCode: "AED", + AvailableBalance: json.Number("3099"), + }, + } + }) + It("fetches next balances", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + } + m.EXPECT().GetAccountBalances(ctx, accRef).Return( + []*client.Balance{sampleBalance}, + nil, + ) + res, err := plg.FetchNextBalances(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Balances).To(HaveLen(1)) + + Expect(res.Balances[0].AccountReference).To(Equal(accRef)) + Expect(res.Balances[0].Amount).To(BeEquivalentTo(expectedAmount)) + Expect(res.Balances[0].Asset).To(HavePrefix(strings.ToUpper(sampleBalance.Attributes.CurrencyCode))) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/client/accounts.go b/internal/connectors/plugins/public/moneycorp/client/accounts.go index 151afe90..ff877d32 100644 --- a/internal/connectors/plugins/public/moneycorp/client/accounts.go +++ b/internal/connectors/plugins/public/moneycorp/client/accounts.go @@ -18,7 +18,7 @@ type Account struct { } `json:"attributes"` } -func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) { +func (c *client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) { // TODO(polo, crimson): metrics // metrics can also be embedded in wrapper // f := connectors.ClientMetrics(ctx, "moneycorp", "list_accounts") diff --git a/internal/connectors/plugins/public/moneycorp/client/balances.go b/internal/connectors/plugins/public/moneycorp/client/balances.go index aba5cb0a..4b82f90b 100644 --- a/internal/connectors/plugins/public/moneycorp/client/balances.go +++ b/internal/connectors/plugins/public/moneycorp/client/balances.go @@ -12,18 +12,20 @@ type balancesResponse struct { } type Balance struct { - ID string `json:"id"` - Attributes struct { - CurrencyCode string `json:"currencyCode"` - OverallBalance json.Number `json:"overallBalance"` - AvailableBalance json.Number `json:"availableBalance"` - ClearedBalance json.Number `json:"clearedBalance"` - ReservedBalance json.Number `json:"reservedBalance"` - UnclearedBalance json.Number `json:"unclearedBalance"` - } `json:"attributes"` + ID string `json:"id"` + Attributes Attributes `json:"attributes"` } -func (c *Client) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { +type Attributes struct { + CurrencyCode string `json:"currencyCode"` + OverallBalance json.Number `json:"overallBalance"` + AvailableBalance json.Number `json:"availableBalance"` + ClearedBalance json.Number `json:"clearedBalance"` + ReservedBalance json.Number `json:"reservedBalance"` + UnclearedBalance json.Number `json:"unclearedBalance"` +} + +func (c *client) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "moneycorp", "list_account_balances") // now := time.Now() diff --git a/internal/connectors/plugins/public/moneycorp/client/client.go b/internal/connectors/plugins/public/moneycorp/client/client.go index eb68a727..f1d50b2f 100644 --- a/internal/connectors/plugins/public/moneycorp/client/client.go +++ b/internal/connectors/plugins/public/moneycorp/client/client.go @@ -1,19 +1,29 @@ package client import ( + "context" "net/http" "strings" + "time" "github.com/formancehq/payments/internal/connectors/httpwrapper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) -type Client struct { +//go:generate mockgen -source client.go -destination client_generated.go -package client . Client +type Client interface { + GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) + GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) + GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) + GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) +} + +type client struct { httpClient httpwrapper.Client endpoint string } -func New(clientID, apiKey, endpoint string) (*Client, error) { +func New(clientID, apiKey, endpoint string) (*client, error) { config := &httpwrapper.Config{ Transport: &apiTransport{ clientID: clientID, @@ -36,7 +46,7 @@ func New(clientID, apiKey, endpoint string) (*Client, error) { endpoint = strings.TrimSuffix(endpoint, "/") httpClient, err := httpwrapper.NewClient(config) - c := &Client{ + c := &client{ httpClient: httpClient, endpoint: endpoint, } diff --git a/internal/connectors/plugins/public/moneycorp/client/client_generated.go b/internal/connectors/plugins/public/moneycorp/client/client_generated.go new file mode 100644 index 00000000..4a621651 --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/client/client_generated.go @@ -0,0 +1,101 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source client.go -destination client_generated.go -package client . Client +// + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetAccountBalances mocks base method. +func (m *MockClient) GetAccountBalances(ctx context.Context, accountID string) ([]*Balance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountBalances", ctx, accountID) + ret0, _ := ret[0].([]*Balance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountBalances indicates an expected call of GetAccountBalances. +func (mr *MockClientMockRecorder) GetAccountBalances(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBalances", reflect.TypeOf((*MockClient)(nil).GetAccountBalances), ctx, accountID) +} + +// GetAccounts mocks base method. +func (m *MockClient) GetAccounts(ctx context.Context, page, pageSize int) ([]*Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccounts", ctx, page, pageSize) + ret0, _ := ret[0].([]*Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccounts indicates an expected call of GetAccounts. +func (mr *MockClientMockRecorder) GetAccounts(ctx, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccounts", reflect.TypeOf((*MockClient)(nil).GetAccounts), ctx, page, pageSize) +} + +// GetRecipients mocks base method. +func (m *MockClient) GetRecipients(ctx context.Context, accountID string, page, pageSize int) ([]*Recipient, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRecipients", ctx, accountID, page, pageSize) + ret0, _ := ret[0].([]*Recipient) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRecipients indicates an expected call of GetRecipients. +func (mr *MockClientMockRecorder) GetRecipients(ctx, accountID, page, pageSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecipients", reflect.TypeOf((*MockClient)(nil).GetRecipients), ctx, accountID, page, pageSize) +} + +// GetTransactions mocks base method. +func (m *MockClient) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, accountID, page, pageSize, lastCreatedAt) + ret0, _ := ret[0].([]*Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockClientMockRecorder) GetTransactions(ctx, accountID, page, pageSize, lastCreatedAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockClient)(nil).GetTransactions), ctx, accountID, page, pageSize, lastCreatedAt) +} diff --git a/internal/connectors/plugins/public/moneycorp/client/recipients.go b/internal/connectors/plugins/public/moneycorp/client/recipients.go index 1e37076b..53ff780d 100644 --- a/internal/connectors/plugins/public/moneycorp/client/recipients.go +++ b/internal/connectors/plugins/public/moneycorp/client/recipients.go @@ -12,15 +12,17 @@ type recipientsResponse struct { } type Recipient struct { - ID string `json:"id"` - Attributes struct { - BankAccountCurrency string `json:"bankAccountCurrency"` - CreatedAt string `json:"createdAt"` - BankAccountName string `json:"bankAccountName"` - } `json:"attributes"` + ID string `json:"id"` + Attributes RecipientAttributes `json:"attributes"` } -func (c *Client) GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) { +type RecipientAttributes struct { + BankAccountCurrency string `json:"bankAccountCurrency"` + CreatedAt string `json:"createdAt"` + BankAccountName string `json:"bankAccountName"` +} + +func (c *client) GetRecipients(ctx context.Context, accountID string, page int, pageSize int) ([]*Recipient, error) { // TODO(polo): add metrics // f := connectors.ClientMetrics(ctx, "moneycorp", "list_recipients") // now := time.Now() diff --git a/internal/connectors/plugins/public/moneycorp/client/transactions.go b/internal/connectors/plugins/public/moneycorp/client/transactions.go index 01ce7368..ef17e01d 100644 --- a/internal/connectors/plugins/public/moneycorp/client/transactions.go +++ b/internal/connectors/plugins/public/moneycorp/client/transactions.go @@ -24,21 +24,23 @@ type fetchTransactionRequest struct { } type Transaction struct { - ID string `json:"id"` - Type string `json:"type"` - Attributes struct { - AccountID int32 `json:"accountId"` - CreatedAt string `json:"createdAt"` - Currency string `json:"transactionCurrency"` - Amount json.Number `json:"transactionAmount"` - Direction string `json:"transactionDirection"` - Type string `json:"transactionType"` - ClientReference string `json:"clientReference"` - TransactionReference string `json:"transactionReference"` - } `json:"attributes"` + ID string `json:"id"` + Type string `json:"type"` + Attributes TransactionAttributes `json:"attributes"` } -func (c *Client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { +type TransactionAttributes struct { + AccountID int32 `json:"accountId"` + CreatedAt string `json:"createdAt"` + Currency string `json:"transactionCurrency"` + Amount json.Number `json:"transactionAmount"` + Direction string `json:"transactionDirection"` + Type string `json:"transactionType"` + ClientReference string `json:"clientReference"` + TransactionReference string `json:"transactionReference"` +} + +func (c *client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, lastCreatedAt time.Time) ([]*Transaction, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "moneycorp", "list_transactions") // now := time.Now() diff --git a/internal/connectors/plugins/public/moneycorp/external_accounts.go b/internal/connectors/plugins/public/moneycorp/external_accounts.go index 3a5775da..2ebaae4c 100644 --- a/internal/connectors/plugins/public/moneycorp/external_accounts.go +++ b/internal/connectors/plugins/public/moneycorp/external_accounts.go @@ -8,7 +8,9 @@ import ( "github.com/formancehq/go-libs/pointer" "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" ) type externalAccountsState struct { @@ -18,7 +20,7 @@ type externalAccountsState struct { LastCreatedAt time.Time `json:"last_created_at"` } -func (p Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { +func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { var oldState externalAccountsState if req.State != nil { if err := json.Unmarshal(req.State, &oldState); err != nil { @@ -39,59 +41,34 @@ func (p Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchN LastCreatedAt: oldState.LastCreatedAt, } - var accounts []models.PSPAccount hasMore := false + accounts := make([]models.PSPAccount, 0, req.PageSize) for page := oldState.LastPage; ; page++ { newState.LastPage = page + pageSize := req.PageSize - len(accounts) - pagedRecipients, err := p.client.GetRecipients(ctx, from.Reference, page, req.PageSize) + pagedRecipients, err := p.client.GetRecipients(ctx, from.Reference, page, pageSize) if err != nil { return models.FetchNextExternalAccountsResponse{}, err } - if len(pagedRecipients) == 0 { + hasMore = false break } - for _, recipient := range pagedRecipients { - createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", recipient.Attributes.CreatedAt) - if err != nil { - return models.FetchNextExternalAccountsResponse{}, fmt.Errorf("failed to parse transaction date: %v", err) - } - - switch createdAt.Compare(oldState.LastCreatedAt) { - case -1, 0: - continue - default: - } - - raw, err := json.Marshal(recipient) - if err != nil { - return models.FetchNextExternalAccountsResponse{}, err - } - - accounts = append(accounts, models.PSPAccount{ - Reference: recipient.ID, - // Moneycorp does not send the opening date of the account - CreatedAt: createdAt, - Name: &recipient.Attributes.BankAccountName, - DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, recipient.Attributes.BankAccountCurrency)), - Raw: raw, - }) - - newState.LastCreatedAt = createdAt - - if len(accounts) >= req.PageSize { - break - } + var lastCreatedAt time.Time + accounts, lastCreatedAt, err = recipientToPSPAccounts(oldState.LastCreatedAt, accounts, pagedRecipients) + if err != nil { + return models.FetchNextExternalAccountsResponse{}, err } - - if len(accounts) >= req.PageSize { - hasMore = true + if len(accounts) == 0 { break } + newState.LastCreatedAt = lastCreatedAt - if len(pagedRecipients) < req.PageSize { + needMore := true + needMore, hasMore = pagination.ShouldFetchMore(accounts, pagedRecipients, pageSize) + if !needMore { break } } @@ -107,3 +84,39 @@ func (p Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchN HasMore: hasMore, }, nil } + +func recipientToPSPAccounts( + lastCreatedAt time.Time, + accounts []models.PSPAccount, + pagedAccounts []*client.Recipient, +) ([]models.PSPAccount, time.Time, error) { + var newCreatedAt time.Time + for _, recipient := range pagedAccounts { + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", recipient.Attributes.CreatedAt) + if err != nil { + return accounts, lastCreatedAt, fmt.Errorf("failed to parse transaction date: %v", err) + } + + switch createdAt.Compare(lastCreatedAt) { + case -1, 0: + continue + default: + } + + raw, err := json.Marshal(recipient) + if err != nil { + return accounts, lastCreatedAt, err + } + + newCreatedAt = createdAt + accounts = append(accounts, models.PSPAccount{ + Reference: recipient.ID, + // Moneycorp does not send the opening date of the account + CreatedAt: createdAt, + Name: &recipient.Attributes.BankAccountName, + DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesWithDecimal, recipient.Attributes.BankAccountCurrency)), + Raw: raw, + }) + } + return accounts, newCreatedAt, nil +} diff --git a/internal/connectors/plugins/public/moneycorp/external_accounts_test.go b/internal/connectors/plugins/public/moneycorp/external_accounts_test.go new file mode 100644 index 00000000..03e913aa --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/external_accounts_test.go @@ -0,0 +1,72 @@ +package moneycorp + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp Plugin ExternalAccounts", func() { + var ( + plg *Plugin + ) + + Context("fetch next ExternalAccounts", func() { + var ( + m *client.MockClient + + pageSize int + sampleExternalAccounts []*client.Recipient + accRef string + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + pageSize = 10 + accRef = "baseAcc" + sampleExternalAccounts = make([]*client.Recipient, 0) + for i := 0; i < pageSize; i++ { + sampleExternalAccounts = append(sampleExternalAccounts, &client.Recipient{ + Attributes: client.RecipientAttributes{ + BankAccountCurrency: "JPY", + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + BankAccountName: "jpy account", + }, + }) + } + + }) + It("fetches next ExternalAccounts", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + m.EXPECT().GetRecipients(ctx, accRef, gomock.Any(), pageSize).Return( + sampleExternalAccounts, + nil, + ) + res, err := plg.FetchNextExternalAccounts(ctx, req) + Expect(err).To(BeNil()) + Expect(res.HasMore).To(BeTrue()) + Expect(res.ExternalAccounts).To(HaveLen(pageSize)) + Expect(*res.ExternalAccounts[0].Name).To(Equal(sampleExternalAccounts[0].Attributes.BankAccountName)) + + var state accountsState + + err = json.Unmarshal(res.NewState, &state) + Expect(err).To(BeNil()) + Expect(state.LastPage).To(Equal(0)) + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/payments.go b/internal/connectors/plugins/public/moneycorp/payments.go index e3933e54..e984210c 100644 --- a/internal/connectors/plugins/public/moneycorp/payments.go +++ b/internal/connectors/plugins/public/moneycorp/payments.go @@ -11,13 +11,14 @@ import ( "github.com/formancehq/payments/internal/connectors/plugins/currency" "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" "github.com/formancehq/payments/internal/models" + "github.com/formancehq/payments/internal/utils/pagination" ) type paymentsState struct { LastCreatedAt time.Time `json:"lastCreatedAt"` } -func (p Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { +func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { var oldState paymentsState if req.State != nil { if err := json.Unmarshal(req.State, &oldState); err != nil { @@ -37,53 +38,33 @@ func (p Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPayme LastCreatedAt: oldState.LastCreatedAt, } - var payments []models.PSPPayment + payments := make([]models.PSPPayment, 0, req.PageSize) hasMore := false for page := 0; ; page++ { - pagedTransactions, err := p.client.GetTransactions(ctx, from.Reference, page, req.PageSize, oldState.LastCreatedAt) + pageSize := req.PageSize - len(payments) + + pagedTransactions, err := p.client.GetTransactions(ctx, from.Reference, page, pageSize, oldState.LastCreatedAt) if err != nil { - // retryable error already handled by the client return models.FetchNextPaymentsResponse{}, err } - if len(pagedTransactions) == 0 { + hasMore = false break } - for _, transaction := range pagedTransactions { - createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) - if err != nil { - return models.FetchNextPaymentsResponse{}, fmt.Errorf("failed to parse transaction date: %v", err) - } - - switch createdAt.Compare(oldState.LastCreatedAt) { - case -1, 0: - continue - default: - } - - newState.LastCreatedAt = createdAt - - payment, err := transactionToPayment(transaction) - if err != nil { - return models.FetchNextPaymentsResponse{}, err - } - - if payment != nil { - payments = append(payments, *payment) - } - - if len(payments) == req.PageSize { - break - } + var lastCreatedAt time.Time + payments, lastCreatedAt, err = toPSPPayments(oldState.LastCreatedAt, payments, pagedTransactions) + if err != nil { + return models.FetchNextPaymentsResponse{}, err } - - if len(pagedTransactions) < req.PageSize { + if len(payments) == 0 { break } + newState.LastCreatedAt = lastCreatedAt - if len(payments) == req.PageSize { - hasMore = true + needMore := true + needMore, hasMore = pagination.ShouldFetchMore(payments, pagedTransactions, pageSize) + if !needMore { break } } @@ -100,6 +81,38 @@ func (p Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPayme }, nil } +func toPSPPayments( + lastCreatedAt time.Time, + payments []models.PSPPayment, + transactions []*client.Transaction, +) ([]models.PSPPayment, time.Time, error) { + var newCreatedAt time.Time + for _, transaction := range transactions { + createdAt, err := time.Parse("2006-01-02T15:04:05.999999999", transaction.Attributes.CreatedAt) + if err != nil { + return payments, lastCreatedAt, fmt.Errorf("failed to parse transaction date: %v", err) + } + + switch createdAt.Compare(lastCreatedAt) { + case -1, 0: + continue + default: + } + + payment, err := transactionToPayment(transaction) + if err != nil { + return payments, lastCreatedAt, err + } + if payment == nil { + continue + } + + newCreatedAt = createdAt + payments = append(payments, *payment) + } + return payments, newCreatedAt, nil +} + func transactionToPayment(transaction *client.Transaction) (*models.PSPPayment, error) { rawData, err := json.Marshal(transaction) if err != nil { diff --git a/internal/connectors/plugins/public/moneycorp/payments_test.go b/internal/connectors/plugins/public/moneycorp/payments_test.go new file mode 100644 index 00000000..7cc812ca --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/payments_test.go @@ -0,0 +1,175 @@ +package moneycorp + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/formancehq/payments/internal/connectors/plugins/currency" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp/client" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" +) + +var _ = Describe("Moneycorp Plugin Payments", func() { + var ( + plg *Plugin + ) + + Context("fetch next Payments", func() { + var ( + m *client.MockClient + + samplePayments []*client.Transaction + accRef string + pageSize int + ) + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + m = client.NewMockClient(ctrl) + plg = &Plugin{client: m} + + pageSize = 5 + accRef = "baseAcc" + samplePayments = []*client.Transaction{ + { + ID: "transfer-1", + Attributes: client.TransactionAttributes{ + Type: "Transfer", + Currency: "EUR", + Amount: json.Number("65"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "payment-1", + Attributes: client.TransactionAttributes{ + Type: "Payment", + Direction: "Debit", + Currency: "DKK", + Amount: json.Number("42"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "exchange-1", + Attributes: client.TransactionAttributes{ + Type: "Exchange", + Direction: "Debit", + Currency: "GBP", + Amount: json.Number("28"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "charge-1", + Attributes: client.TransactionAttributes{ + Type: "Charge", + Direction: "Credit", + Currency: "JPY", + Amount: json.Number("6400"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "refund-1", + Attributes: client.TransactionAttributes{ + Type: "Refund", + Direction: "Credit", + Currency: "MAD", + Amount: json.Number("64"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + { + ID: "unsupported-1", + Attributes: client.TransactionAttributes{ + Type: "Unsupported", + Currency: "USD", + Amount: json.Number("29"), + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + }, + }, + } + + }) + + It("fails when payments contain unsupported currencies", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + p := []*client.Transaction{ + { + ID: "someid", + Attributes: client.TransactionAttributes{ + Type: "Transfer", + CreatedAt: strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339Nano), "Z"), + Currency: "EEK", + }, + }, + } + m.EXPECT().GetTransactions(ctx, accRef, gomock.Any(), pageSize, gomock.Any()).Return( + p, + nil, + ) + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(MatchError(currency.ErrMissingCurrencies)) + Expect(res.HasMore).To(BeFalse()) + }) + + It("fetches payments", func(ctx SpecContext) { + req := models.FetchNextPaymentsRequest{ + FromPayload: json.RawMessage(fmt.Sprintf(`{"reference": "%s"}`, accRef)), + State: json.RawMessage(`{}`), + PageSize: pageSize, + } + m.EXPECT().GetTransactions(ctx, accRef, gomock.Any(), pageSize, gomock.Any()).Return( + samplePayments, + nil, + ) + res, err := plg.FetchNextPayments(ctx, req) + Expect(err).To(BeNil()) + Expect(res.Payments).To(HaveLen(len(samplePayments) - 1)) + Expect(res.HasMore).To(BeTrue()) + + // Transfer + Expect(res.Payments[0].Reference).To(Equal(samplePayments[0].ID)) + Expect(res.Payments[0].Type).To(Equal(models.PAYMENT_TYPE_TRANSFER)) + expectedAmount, err := samplePayments[0].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[0].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Payment + Expect(res.Payments[1].Reference).To(Equal(samplePayments[1].ID)) + Expect(res.Payments[1].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + expectedAmount, err = samplePayments[1].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[1].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Exchange + Expect(res.Payments[2].Reference).To(Equal(samplePayments[2].ID)) + Expect(res.Payments[2].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT)) + expectedAmount, err = samplePayments[2].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[2].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + // Charge + Expect(res.Payments[3].Reference).To(Equal(samplePayments[3].ID)) + Expect(res.Payments[3].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + expectedAmount, err = samplePayments[3].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[3].Amount).To(Equal(big.NewInt(expectedAmount))) // currency already in minors + // Refund + Expect(res.Payments[4].Reference).To(Equal(samplePayments[4].ID)) + Expect(res.Payments[4].Type).To(Equal(models.PAYMENT_TYPE_PAYIN)) + expectedAmount, err = samplePayments[4].Attributes.Amount.Int64() + Expect(err).To(BeNil()) + Expect(res.Payments[4].Amount).To(Equal(big.NewInt(expectedAmount * 100))) // after conversion to minors + + }) + }) +}) diff --git a/internal/connectors/plugins/public/moneycorp/plugin.go b/internal/connectors/plugins/public/moneycorp/plugin.go index cd58829a..a7e06793 100644 --- a/internal/connectors/plugins/public/moneycorp/plugin.go +++ b/internal/connectors/plugins/public/moneycorp/plugin.go @@ -9,7 +9,7 @@ import ( ) type Plugin struct { - client *client.Client + client client.Client } func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { @@ -30,51 +30,51 @@ func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.I }, nil } -func (p Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { +func (p *Plugin) Uninstall(ctx context.Context, req models.UninstallRequest) (models.UninstallResponse, error) { return models.UninstallResponse{}, nil } -func (p Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { +func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) { if p.client == nil { return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled } return p.fetchNextAccounts(ctx, req) } -func (p Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { +func (p *Plugin) FetchNextBalances(ctx context.Context, req models.FetchNextBalancesRequest) (models.FetchNextBalancesResponse, error) { if p.client == nil { return models.FetchNextBalancesResponse{}, plugins.ErrNotYetInstalled } return p.fetchNextBalances(ctx, req) } -func (p Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { +func (p *Plugin) FetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) { if p.client == nil { return models.FetchNextExternalAccountsResponse{}, plugins.ErrNotYetInstalled } return p.fetchNextExternalAccounts(ctx, req) } -func (p Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { +func (p *Plugin) FetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) { if p.client == nil { return models.FetchNextPaymentsResponse{}, plugins.ErrNotYetInstalled } return p.fetchNextPayments(ctx, req) } -func (p Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { +func (p *Plugin) FetchNextOthers(ctx context.Context, req models.FetchNextOthersRequest) (models.FetchNextOthersResponse, error) { return models.FetchNextOthersResponse{}, plugins.ErrNotImplemented } -func (p Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { +func (p *Plugin) CreateBankAccount(ctx context.Context, req models.CreateBankAccountRequest) (models.CreateBankAccountResponse, error) { return models.CreateBankAccountResponse{}, plugins.ErrNotImplemented } -func (p Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { +func (p *Plugin) CreateWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) { return models.CreateWebhooksResponse{}, plugins.ErrNotImplemented } -func (p Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { +func (p *Plugin) TranslateWebhook(ctx context.Context, req models.TranslateWebhookRequest) (models.TranslateWebhookResponse, error) { return models.TranslateWebhookResponse{}, plugins.ErrNotImplemented } diff --git a/internal/connectors/plugins/public/moneycorp/plugin_test.go b/internal/connectors/plugins/public/moneycorp/plugin_test.go new file mode 100644 index 00000000..7add1f5a --- /dev/null +++ b/internal/connectors/plugins/public/moneycorp/plugin_test.go @@ -0,0 +1,78 @@ +package moneycorp_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/formancehq/payments/internal/connectors/plugins" + "github.com/formancehq/payments/internal/connectors/plugins/public/moneycorp" + "github.com/formancehq/payments/internal/models" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Moneycorp Plugin Suite") +} + +var _ = Describe("Moneycorp Plugin", func() { + var ( + plg *moneycorp.Plugin + config json.RawMessage + ) + + BeforeEach(func() { + plg = &moneycorp.Plugin{} + config = json.RawMessage(`{"clientID":"1234","apiKey":"abc123","endpoint":"example.com"}`) + }) + + Context("install", func() { + It("reports validation errors in the config", func(ctx SpecContext) { + req := models.InstallRequest{Config: json.RawMessage(`{}`)} + _, err := plg.Install(context.Background(), req) + Expect(err).To(MatchError(ContainSubstring("config"))) + }) + It("returns valid install response", func(ctx SpecContext) { + req := models.InstallRequest{Config: config} + res, err := plg.Install(context.Background(), req) + Expect(err).To(BeNil()) + Expect(len(res.Capabilities) > 0).To(BeTrue()) + Expect(len(res.Workflow) > 0).To(BeTrue()) + Expect(res.Workflow[0].Name).To(Equal("fetch_accounts")) + }) + }) + + Context("uninstall", func() { + It("returns valid uninstall response", func(ctx SpecContext) { + req := models.UninstallRequest{ConnectorID: "dummyID"} + _, err := plg.Uninstall(context.Background(), req) + Expect(err).To(BeNil()) + }) + }) + + Context("calling functions on uninstalled plugins", func() { + It("fails when fetch next accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next balances is called before install", func(ctx SpecContext) { + req := models.FetchNextBalancesRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextBalances(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + It("fails when fetch next external accounts is called before install", func(ctx SpecContext) { + req := models.FetchNextExternalAccountsRequest{ + State: json.RawMessage(`{}`), + } + _, err := plg.FetchNextExternalAccounts(context.Background(), req) + Expect(err).To(MatchError(plugins.ErrNotYetInstalled)) + }) + }) +}) diff --git a/internal/utils/pagination/paginator.go b/internal/utils/pagination/paginator.go new file mode 100644 index 00000000..174d456b --- /dev/null +++ b/internal/utils/pagination/paginator.go @@ -0,0 +1,5 @@ +package pagination + +func ShouldFetchMore[T any, C any](total []T, currentBatch []C, pageSize int) (bool, bool) { + return len(total) < pageSize, len(currentBatch) >= pageSize +} diff --git a/internal/utils/pagination/paginator_test.go b/internal/utils/pagination/paginator_test.go new file mode 100644 index 00000000..d395f084 --- /dev/null +++ b/internal/utils/pagination/paginator_test.go @@ -0,0 +1,68 @@ +package pagination_test + +import ( + "testing" + + "github.com/formancehq/payments/internal/utils/pagination" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pagination Suite") +} + +var _ = Describe("ShouldFetchMore", func() { + type GenericContainer struct { + Val int + } + + Context("pagination", func() { + It("detects when total is max capacity and batch has fewer than page size", func(_ SpecContext) { + pageSize := 10 + total := make([]GenericContainer, 0, pageSize) + batch := []GenericContainer{ + {Val: 11}, + } + + for i := 0; i < pageSize; i++ { + total = append(total, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeFalse()) + }) + + It("detects when total is max capacity and batch has max page size", func(_ SpecContext) { + pageSize := 15 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize; i++ { + total = append(total, GenericContainer{i}) + batch = append(batch, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeFalse()) + Expect(hasMore).To(BeTrue()) + }) + + It("detects when total cannot be acheived with current batch size", func(_ SpecContext) { + pageSize := 8 + total := make([]GenericContainer, 0, pageSize) + batch := make([]GenericContainer, 0, pageSize) + + for i := 0; i < pageSize-1; i++ { + total = append(total, GenericContainer{i}) + batch = append(batch, GenericContainer{i}) + } + + needsMore, hasMore := pagination.ShouldFetchMore(total, batch, pageSize) + Expect(needsMore).To(BeTrue()) + Expect(hasMore).To(BeFalse()) + }) + }) +})