Skip to content

Commit

Permalink
feat: bankingcircle connector (#45)
Browse files Browse the repository at this point in the history
* feat: bankingcircle connector support

* feat: banking circle connector

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>

* fix: lint & test issues

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>
  • Loading branch information
darkmatterpool authored Nov 21, 2022
1 parent c7066e4 commit b1f5192
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 0 deletions.
2 changes: 2 additions & 0 deletions internal/app/api/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"github.com/numary/payments/internal/pkg/connectors/bankingcircle"
"github.com/numary/payments/internal/pkg/connectors/currencycloud"

"github.com/gorilla/mux"
Expand Down Expand Up @@ -68,6 +69,7 @@ func HTTPModule() fx.Option {
addConnector[stripe.Config, stripe.TaskDescriptor](stripe.NewLoader()),
addConnector[wise.Config, wise.TaskDescriptor](wise.NewLoader()),
addConnector[currencycloud.Config, currencycloud.TaskDescriptor](currencycloud.NewLoader()),
addConnector[bankingcircle.Config, bankingcircle.TaskDescriptor](bankingcircle.NewLoader()),
)
}

Expand Down
236 changes: 236 additions & 0 deletions internal/pkg/connectors/bankingcircle/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package bankingcircle

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/numary/go-libs/sharedlogging"
)

type client struct {
httpClient *http.Client

username string
password string

endpoint string
authorizationEndpoint string

logger sharedlogging.Logger

accessToken string
accessTokenExpiresAt time.Time
}

func newClient(username, password, endpoint, authorizationEndpoint string, logger sharedlogging.Logger) (*client, error) {
c := &client{
httpClient: &http.Client{Timeout: 10 * time.Second},

username: username,
password: password,
endpoint: endpoint,
authorizationEndpoint: authorizationEndpoint,

logger: logger,
}

if err := c.login(); err != nil {
return nil, err
}

return c, nil
}

func (c *client) login() error {
req, err := http.NewRequest(http.MethodGet, c.authorizationEndpoint+"/api/v1/authorizations/authorize", http.NoBody)
if err != nil {
return fmt.Errorf("failed to create login request: %w", err)
}

req.SetBasicAuth(c.username, c.password)

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to login: %w", err)
}

defer func() {
err = resp.Body.Close()
if err != nil {
c.logger.Error(err)
}
}()

responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read login response body: %w", err)
}

//nolint:tagliatelle // allow for client-side structures
type response struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}

var res response

if err = json.Unmarshal(responseBody, &res); err != nil {
return fmt.Errorf("failed to unmarshal login response: %w", err)
}

c.accessToken = res.AccessToken
c.accessTokenExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)

return nil
}

func (c *client) ensureAccessTokenIsValid() error {
if c.accessTokenExpiresAt.After(time.Now()) {
return nil
}

return c.login()
}

//nolint:tagliatelle // allow for client-side structures
type payment struct {
PaymentID string `json:"paymentId"`
TransactionReference string `json:"transactionReference"`
ConcurrencyToken string `json:"concurrencyToken"`
Classification string `json:"classification"`
Status string `json:"status"`
Errors interface{} `json:"errors"`
LastChangedTimestamp time.Time `json:"lastChangedTimestamp"`
DebtorInformation struct {
PaymentBulkID interface{} `json:"paymentBulkId"`
AccountID string `json:"accountId"`
Account struct {
Account string `json:"account"`
FinancialInstitution string `json:"financialInstitution"`
Country string `json:"country"`
} `json:"account"`
VibanID interface{} `json:"vibanId"`
Viban struct {
Account string `json:"account"`
FinancialInstitution string `json:"financialInstitution"`
Country string `json:"country"`
} `json:"viban"`
InstructedDate interface{} `json:"instructedDate"`
DebitAmount struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
} `json:"debitAmount"`
DebitValueDate time.Time `json:"debitValueDate"`
FxRate interface{} `json:"fxRate"`
Instruction interface{} `json:"instruction"`
} `json:"debtorInformation"`
Transfer struct {
DebtorAccount interface{} `json:"debtorAccount"`
DebtorName interface{} `json:"debtorName"`
DebtorAddress interface{} `json:"debtorAddress"`
Amount struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
} `json:"amount"`
ValueDate interface{} `json:"valueDate"`
ChargeBearer interface{} `json:"chargeBearer"`
RemittanceInformation interface{} `json:"remittanceInformation"`
CreditorAccount interface{} `json:"creditorAccount"`
CreditorName interface{} `json:"creditorName"`
CreditorAddress interface{} `json:"creditorAddress"`
} `json:"transfer"`
CreditorInformation struct {
AccountID string `json:"accountId"`
Account struct {
Account string `json:"account"`
FinancialInstitution string `json:"financialInstitution"`
Country string `json:"country"`
} `json:"account"`
VibanID interface{} `json:"vibanId"`
Viban struct {
Account string `json:"account"`
FinancialInstitution string `json:"financialInstitution"`
Country string `json:"country"`
} `json:"viban"`
CreditAmount struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
} `json:"creditAmount"`
CreditValueDate time.Time `json:"creditValueDate"`
FxRate interface{} `json:"fxRate"`
} `json:"creditorInformation"`
}

func (c *client) getAllPayments() ([]*payment, error) {
var payments []*payment

for page := 0; ; page++ {
pagedPayments, err := c.getPayments(page)
if err != nil {
return nil, err
}

if len(pagedPayments) == 0 {
break
}

payments = append(payments, pagedPayments...)
}

return payments, nil
}

func (c *client) getPayments(page int) ([]*payment, error) {
if err := c.ensureAccessTokenIsValid(); err != nil {
return nil, err
}

req, err := http.NewRequest(http.MethodGet, c.endpoint+"/api/v1/payments/singles", http.NoBody)
if err != nil {
return nil, fmt.Errorf("failed to create login request: %w", err)
}

q := req.URL.Query()
q.Add("PageSize", "5000")
q.Add("PageNumber", fmt.Sprint(page))

req.URL.RawQuery = q.Encode()

req.Header.Set("Authorization", "Bearer "+c.accessToken)

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to login: %w", err)
}

defer func() {
err = resp.Body.Close()
if err != nil {
c.logger.Error(err)
}
}()

responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read login response body: %w", err)
}

type response struct {
Result []*payment `json:"result"`
PageInfo struct {
CurrentPage int `json:"currentPage"`
PageSize int `json:"pageSize"`
} `json:"pageInfo"`
}

var res response

if err = json.Unmarshal(responseBody, &res); err != nil {
return nil, fmt.Errorf("failed to unmarshal login response: %w", err)
}

return res.Result, nil
}
28 changes: 28 additions & 0 deletions internal/pkg/connectors/bankingcircle/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package bankingcircle

type Config struct {
Username string `json:"username" yaml:"username" bson:"username"`
Password string `json:"password" yaml:"password" bson:"password"`
Endpoint string `json:"endpoint" yaml:"endpoint" bson:"endpoint"`
AuthorizationEndpoint string `json:"authorizationEndpoint" yaml:"authorizationEndpoint" bson:"authorizationEndpoint"`
}

func (c Config) Validate() error {
if c.Username == "" {
return ErrMissingUsername
}

if c.Password == "" {
return ErrMissingPassword
}

if c.Endpoint == "" {
return ErrMissingEndpoint
}

if c.AuthorizationEndpoint == "" {
return ErrMissingAuthorizationEndpoint
}

return nil
}
20 changes: 20 additions & 0 deletions internal/pkg/connectors/bankingcircle/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package bankingcircle

import "github.com/pkg/errors"

var (
// ErrMissingTask is returned when the task is missing.
ErrMissingTask = errors.New("task is not implemented")

// ErrMissingUsername is returned when the username is missing.
ErrMissingUsername = errors.New("missing username from config")

// ErrMissingPassword is returned when the password is missing.
ErrMissingPassword = errors.New("missing password from config")

// ErrMissingEndpoint is returned when the endpoint is missing.
ErrMissingEndpoint = errors.New("missing endpoint from config")

// ErrMissingAuthorizationEndpoint is returned when the authorization endpoint is missing.
ErrMissingAuthorizationEndpoint = errors.New("missing authorization endpoint from config")
)
28 changes: 28 additions & 0 deletions internal/pkg/connectors/bankingcircle/loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package bankingcircle

import (
"github.com/numary/go-libs/sharedlogging"
"github.com/numary/payments/internal/pkg/integration"
"github.com/numary/payments/internal/pkg/task"
)

const connectorName = "bankingcircle"

// NewLoader creates a new loader.
func NewLoader() integration.Loader[Config, TaskDescriptor] {
loader := integration.NewLoaderBuilder[Config, TaskDescriptor](connectorName).
WithLoad(func(logger sharedlogging.Logger, config Config) integration.Connector[TaskDescriptor] {
return integration.NewConnectorBuilder[TaskDescriptor]().
WithInstall(func(ctx task.ConnectorContext[TaskDescriptor]) error {
return ctx.Scheduler().
Schedule(TaskDescriptor{
Name: "Fetch payments from source",
Key: taskNameFetchPayments,
}, false)
}).
WithResolve(resolveTasks(logger, config)).
Build()
}).Build()

return loader
}
Loading

0 comments on commit b1f5192

Please sign in to comment.