Skip to content

Commit

Permalink
feat(accounts): add accounts creation via the API
Browse files Browse the repository at this point in the history
  • Loading branch information
paul-nicolas committed Oct 10, 2024
1 parent b308137 commit cf6db9a
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 5 deletions.
1 change: 1 addition & 0 deletions internal/api/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
//go:generate mockgen -source backend.go -destination backend_generated.go -package backend . Backend
type Backend interface {
// Accounts
AccountsCreate(ctx context.Context, account models.Account) error
AccountsList(ctx context.Context, query storage.ListAccountsQuery) (*bunpaginate.Cursor[models.Account], error)
AccountsGet(ctx context.Context, id models.AccountID) (*models.Account, error)
BankAccountsList(ctx context.Context, query storage.ListBankAccountsQuery) (*bunpaginate.Cursor[models.BankAccount], error)
Expand Down
5 changes: 3 additions & 2 deletions internal/api/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/formancehq/payments/internal/api/backend"
"github.com/formancehq/payments/internal/api/services"
"github.com/formancehq/payments/internal/connectors/engine"
"github.com/formancehq/payments/internal/events"
"github.com/formancehq/payments/internal/storage"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
Expand All @@ -31,8 +32,8 @@ func NewModule(bind string, debug bool) fx.Option {
) *chi.Mux {
return NewRouter(backend, info, healthController, a, debug, versions...)
}, fx.ParamTags(``, ``, ``, ``, `group:"apiVersions"`))),
fx.Provide(func(storage storage.Storage, engine engine.Engine) backend.Backend {
return services.New(storage, engine)
fx.Provide(func(storage storage.Storage, engine engine.Engine, events *events.Events) backend.Backend {
return services.New(storage, engine, events)
}),
)
}
11 changes: 11 additions & 0 deletions internal/api/services/accounts_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package services

import (
"context"

"github.com/formancehq/payments/internal/models"
)

func (s *Service) AccountsCreate(ctx context.Context, account models.Account) error {
return handleEngineErrors(s.engine.CreateFormanceAccount(ctx, account))
}
5 changes: 4 additions & 1 deletion internal/api/services/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ package services

import (
"github.com/formancehq/payments/internal/connectors/engine"
"github.com/formancehq/payments/internal/events"
"github.com/formancehq/payments/internal/storage"
)

type Service struct {
storage storage.Storage

engine engine.Engine
events *events.Events
}

func New(storage storage.Storage, engine engine.Engine) *Service {
func New(storage storage.Storage, engine engine.Engine, events *events.Events) *Service {
return &Service{
storage: storage,
engine: engine,
events: events,
}
}
140 changes: 140 additions & 0 deletions internal/api/v2/handler_accounts_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package v2

import (
"encoding/json"
"errors"
"net/http"
"time"

"github.com/formancehq/go-libs/api"
"github.com/formancehq/payments/internal/api/backend"
"github.com/formancehq/payments/internal/models"
"github.com/formancehq/payments/internal/otel"
)

type createAccountRequest struct {
Reference string `json:"reference"`
ConnectorID string `json:"connectorID"`
CreatedAt time.Time `json:"createdAt"`
DefaultAsset string `json:"defaultAsset"`
AccountName string `json:"accountName"`
Type string `json:"type"`
Metadata map[string]string `json:"metadata"`
}

func (r *createAccountRequest) validate() error {
if r.Reference == "" {
return errors.New("reference is required")
}

if r.ConnectorID == "" {
return errors.New("connectorID is required")
}

if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) {
return errors.New("createdAt is empty or in the future")
}

if r.AccountName == "" {
return errors.New("accountName is required")
}

if r.Type == "" {
return errors.New("type is required")
}

_, err := models.ConnectorIDFromString(r.ConnectorID)
if err != nil {
return errors.New("connectorID is invalid")
}

switch r.Type {
case string(models.ACCOUNT_TYPE_EXTERNAL):
case string(models.ACCOUNT_TYPE_INTERNAL):
default:
return errors.New("type is invalid")
}

return nil
}

func accountsCreate(backend backend.Backend) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer().Start(r.Context(), "v2_accountsCreate")
defer span.End()

var req createAccountRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
otel.RecordError(span, err)
api.BadRequest(w, ErrMissingOrInvalidBody, err)
return
}

if err := req.validate(); err != nil {
otel.RecordError(span, err)
api.BadRequest(w, ErrValidation, err)
return
}

connectorID := models.MustConnectorIDFromString(req.ConnectorID)
raw, err := json.Marshal(req)
if err != nil {
otel.RecordError(span, err)
api.InternalServerError(w, r, err)
return
}

account := models.Account{
ID: models.AccountID{
Reference: req.Reference,
ConnectorID: connectorID,
},
ConnectorID: connectorID,
Reference: req.Reference,
CreatedAt: req.CreatedAt,
Type: models.AccountType(req.Type),
Name: &req.AccountName,
DefaultAsset: &req.DefaultAsset,
Metadata: req.Metadata,
Raw: raw,
}

err = backend.AccountsCreate(ctx, account)
if err != nil {
otel.RecordError(span, err)
handleServiceErrors(w, r, err)
return
}

// Compatibility with old API
res := accountResponse{
ID: account.ID.String(),
Reference: account.Reference,
CreatedAt: account.CreatedAt,
ConnectorID: account.ConnectorID.String(),
Provider: account.ConnectorID.Provider,
Type: string(account.Type),
Metadata: account.Metadata,
Raw: account.Raw,
}

if account.DefaultAsset != nil {
res.DefaultCurrency = *account.DefaultAsset
res.DefaultAsset = *account.DefaultAsset
}

if account.Name != nil {
res.AccountName = *account.Name
}

err = json.NewEncoder(w).Encode(api.BaseResponse[accountResponse]{
Data: &res,
})
if err != nil {
otel.RecordError(span, err)
api.InternalServerError(w, r, err)
return
}
}
}
2 changes: 1 addition & 1 deletion internal/api/v2/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ func newRouter(backend backend.Backend, a auth.Authenticator, debug bool) *chi.M
// Accounts
r.Route("/accounts", func(r chi.Router) {
r.Get("/", accountsList(backend))
r.Post("/", accountsCreate(backend))

r.Route("/{accountID}", func(r chi.Router) {
r.Get("/", accountsGet(backend))
r.Get("/balances", accountsBalances(backend))
// TODO(polo): add create account handler
})
})

Expand Down
112 changes: 112 additions & 0 deletions internal/api/v3/handler_accounts_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package v3

import (
"encoding/json"
"errors"
"net/http"
"time"

"github.com/formancehq/go-libs/api"
"github.com/formancehq/payments/internal/api/backend"
"github.com/formancehq/payments/internal/models"
"github.com/formancehq/payments/internal/otel"
)

type createAccountRequest struct {
Reference string `json:"reference"`
ConnectorID string `json:"connectorID"`
CreatedAt time.Time `json:"createdAt"`
DefaultAsset string `json:"defaultAsset"`
AccountName string `json:"accountName"`
Type string `json:"type"`
Metadata map[string]string `json:"metadata"`
}

func (r *createAccountRequest) validate() error {
if r.Reference == "" {
return errors.New("reference is required")
}

if r.ConnectorID == "" {
return errors.New("connectorID is required")
}

if r.CreatedAt.IsZero() || r.CreatedAt.After(time.Now()) {
return errors.New("createdAt is empty or in the future")
}

if r.AccountName == "" {
return errors.New("accountName is required")
}

if r.Type == "" {
return errors.New("type is required")
}

_, err := models.ConnectorIDFromString(r.ConnectorID)
if err != nil {
return errors.New("connectorID is invalid")
}

switch r.Type {
case string(models.ACCOUNT_TYPE_EXTERNAL):
case string(models.ACCOUNT_TYPE_INTERNAL):
default:
return errors.New("type is invalid")
}

return nil
}

func accountsCreate(backend backend.Backend) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer().Start(r.Context(), "v3_accountsCreate")
defer span.End()

var req createAccountRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
otel.RecordError(span, err)
api.BadRequest(w, ErrMissingOrInvalidBody, err)
return
}

if err := req.validate(); err != nil {
otel.RecordError(span, err)
api.BadRequest(w, ErrValidation, err)
return
}

connectorID := models.MustConnectorIDFromString(req.ConnectorID)
raw, err := json.Marshal(req)
if err != nil {
otel.RecordError(span, err)
api.InternalServerError(w, r, err)
return
}

account := models.Account{
ID: models.AccountID{
Reference: req.Reference,
ConnectorID: connectorID,
},
ConnectorID: connectorID,
Reference: req.Reference,
CreatedAt: req.CreatedAt,
Type: models.AccountType(req.Type),
Name: &req.AccountName,
DefaultAsset: &req.DefaultAsset,
Metadata: req.Metadata,
Raw: raw,
}

err = backend.AccountsCreate(ctx, account)
if err != nil {
otel.RecordError(span, err)
handleServiceErrors(w, r, err)
return
}

api.Created(w, account)
}
}
2 changes: 1 addition & 1 deletion internal/api/v3/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ func newRouter(backend backend.Backend, a auth.Authenticator, debug bool) *chi.M
// Accounts
r.Route("/accounts", func(r chi.Router) {
r.Get("/", accountsList(backend))
r.Post("/", accountsCreate(backend))

r.Route("/{accountID}", func(r chi.Router) {
r.Get("/", accountsGet(backend))
r.Get("/balances", accountsBalances(backend))
// TODO(polo): add create account handler
})
})

Expand Down
Loading

0 comments on commit cf6db9a

Please sign in to comment.