diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index cdcb4af..2242948 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -125,6 +125,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go index b79c881..e17fb19 100644 --- a/api/gateway/mntx/internal/service/gateway/connector.go +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -68,13 +68,13 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp } if strings.TrimSpace(reader.String("card_token")) != "" { - resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequest(reader, payoutID, amountMinor, currency)) + resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, amountMinor, currency)) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil } - resp, err := s.CreateCardPayout(ctx, buildCardPayoutRequest(reader, payoutID, amountMinor, currency)) + resp, err := s.CreateCardPayout(ctx, buildCardPayoutRequestFromParams(reader, payoutID, amountMinor, currency)) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } @@ -160,7 +160,7 @@ func currencyFromOperation(op *connectorv1.Operation) string { return strings.ToUpper(currency) } -func buildCardTokenPayoutRequest(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { +func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { req := &mntxv1.CardTokenPayoutRequest{ PayoutId: payoutID, ProjectId: readerInt64(reader, "project_id"), @@ -184,7 +184,7 @@ func buildCardTokenPayoutRequest(reader params.Reader, payoutID string, amountMi return req } -func buildCardPayoutRequest(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { +func buildCardPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { return &mntxv1.CardPayoutRequest{ PayoutId: payoutID, ProjectId: readerInt64(reader, "project_id"), diff --git a/api/gateway/mntx/internal/service/gateway/instances.go b/api/gateway/mntx/internal/service/gateway/instances.go index 0c28e75..634afcb 100644 --- a/api/gateway/mntx/internal/service/gateway/instances.go +++ b/api/gateway/mntx/internal/service/gateway/instances.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/proto" ) // ListGatewayInstances exposes the Monetix gateway instance descriptors. @@ -25,16 +26,15 @@ func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1 if src == nil { return nil } - cp := *src + cp := proto.Clone(src).(*gatewayv1.GatewayInstanceDescriptor) if src.Currencies != nil { cp.Currencies = append([]string(nil), src.Currencies...) } if src.Capabilities != nil { - cap := *src.Capabilities - cp.Capabilities = &cap + cp.Capabilities = proto.Clone(src.Capabilities).(*gatewayv1.RailCapabilities) } if src.Limits != nil { - limits := *src.Limits + limits := &gatewayv1.Limits{} if src.Limits.VolumeLimit != nil { limits.VolumeLimit = map[string]string{} for key, value := range src.Limits.VolumeLimit { @@ -53,11 +53,10 @@ func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1 if value == nil { continue } - clone := *value - limits.CurrencyLimits[key] = &clone + limits.CurrencyLimits[key] = proto.Clone(value).(*gatewayv1.LimitsOverride) } } - cp.Limits = &limits + cp.Limits = limits } - return &cp + return cp } diff --git a/api/server/interface/api/srequest/ledger.go b/api/server/interface/api/srequest/ledger.go new file mode 100644 index 0000000..1270a50 --- /dev/null +++ b/api/server/interface/api/srequest/ledger.go @@ -0,0 +1,48 @@ +package srequest + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" +) + +type LedgerAccountType string + +const ( + LedgerAccountTypeUnspecified LedgerAccountType = "unspecified" + LedgerAccountTypeAsset LedgerAccountType = "asset" + LedgerAccountTypeLiability LedgerAccountType = "liability" + LedgerAccountTypeRevenue LedgerAccountType = "revenue" + LedgerAccountTypeExpense LedgerAccountType = "expense" +) + +type LedgerAccountStatus string + +const ( + LedgerAccountStatusUnspecified LedgerAccountStatus = "unspecified" + LedgerAccountStatusActive LedgerAccountStatus = "active" + LedgerAccountStatusFrozen LedgerAccountStatus = "frozen" +) + +type CreateLedgerAccount struct { + AccountCode string `json:"accountCode"` + AccountType LedgerAccountType `json:"accountType"` + Currency string `json:"currency"` + Status LedgerAccountStatus `json:"status,omitempty"` + AllowNegative bool `json:"allowNegative,omitempty"` + IsSettlement bool `json:"isSettlement,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func (r *CreateLedgerAccount) Validate() error { + if strings.TrimSpace(r.AccountCode) == "" { + return merrors.InvalidArgument("accountCode is required", "accountCode") + } + if strings.TrimSpace(r.Currency) == "" { + return merrors.InvalidArgument("currency is required", "currency") + } + if strings.TrimSpace(string(r.AccountType)) == "" || strings.EqualFold(string(r.AccountType), string(LedgerAccountTypeUnspecified)) { + return merrors.InvalidArgument("accountType is required", "accountType") + } + return nil +} diff --git a/api/server/interface/api/sresponse/ledger.go b/api/server/interface/api/sresponse/ledger.go index 84bcdee..fc75008 100644 --- a/api/server/interface/api/sresponse/ledger.go +++ b/api/server/interface/api/sresponse/ledger.go @@ -29,6 +29,11 @@ type ledgerAccountsResponse struct { Accounts []ledgerAccount `json:"accounts"` } +type ledgerAccountResponse struct { + authResponse `json:",inline"` + Account ledgerAccount `json:"account"` +} + type ledgerMoney struct { Amount string `json:"amount"` Currency string `json:"currency"` @@ -57,6 +62,13 @@ func LedgerAccounts(logger mlogger.Logger, accounts []*ledgerv1.LedgerAccount, a }) } +func LedgerAccountCreated(logger mlogger.Logger, account *ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc { + return response.Created(logger, ledgerAccountResponse{ + Account: toLedgerAccount(account), + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc { return response.Ok(logger, ledgerBalanceResponse{ Balance: toLedgerBalance(resp), diff --git a/api/server/internal/server/ledgerapiimp/create.go b/api/server/internal/server/ledgerapiimp/create.go new file mode 100644 index 0000000..8e4ab6d --- /dev/null +++ b/api/server/internal/server/ledgerapiimp/create.go @@ -0,0 +1,122 @@ +package ledgerapiimp + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + "github.com/tech/sendico/server/interface/api/srequest" + "github.com/tech/sendico/server/interface/api/sresponse" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for ledger account create", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate) + if err != nil { + a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r)) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when creating ledger account", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "ledger accounts write permission denied") + } + + payload, err := decodeLedgerAccountCreatePayload(r) + if err != nil { + a.logger.Warn("Failed to decode ledger account create payload", zap.Error(err), mutil.PLog(a.oph, r)) + return response.BadPayload(a.logger, a.Name(), err) + } + + accountType, err := mapLedgerAccountType(payload.AccountType) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + status, err := mapLedgerAccountStatus(payload.Status) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + + if a.client == nil { + return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured")) + } + + resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ + OrganizationRef: orgRef.Hex(), + AccountCode: payload.AccountCode, + AccountType: accountType, + Currency: payload.Currency, + Status: status, + AllowNegative: payload.AllowNegative, + IsSettlement: payload.IsSettlement, + Metadata: payload.Metadata, + }) + if err != nil { + a.logger.Warn("Failed to create ledger account", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, mservice.Ledger, err) + } + + return sresponse.LedgerAccountCreated(a.logger, resp.GetAccount(), token) +} + +func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) { + defer r.Body.Close() + + payload := &srequest.CreateLedgerAccount{} + if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) + } + payload.AccountCode = strings.TrimSpace(payload.AccountCode) + payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency)) + if len(payload.Metadata) == 0 { + payload.Metadata = nil + } + if err := payload.Validate(); err != nil { + return nil, err + } + return payload, nil +} + +func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) { + switch strings.ToUpper(strings.TrimSpace(string(accountType))) { + case "ACCOUNT_TYPE_ASSET", "ASSET": + return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil + case "ACCOUNT_TYPE_LIABILITY", "LIABILITY": + return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil + case "ACCOUNT_TYPE_REVENUE", "REVENUE": + return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil + case "ACCOUNT_TYPE_EXPENSE", "EXPENSE": + return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil + case "", "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED": + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("accountType is required", "accountType") + default: + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("unsupported accountType: "+string(accountType), "accountType") + } +} + +func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) { + switch strings.ToUpper(strings.TrimSpace(string(status))) { + case "", "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil + case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, nil + case "ACCOUNT_STATUS_FROZEN", "FROZEN": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN, nil + default: + return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status") + } +} diff --git a/api/server/internal/server/ledgerapiimp/service.go b/api/server/internal/server/ledgerapiimp/service.go index d9dd121..4114c04 100644 --- a/api/server/internal/server/ledgerapiimp/service.go +++ b/api/server/internal/server/ledgerapiimp/service.go @@ -21,6 +21,7 @@ import ( ) type ledgerClient interface { + CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) Close() error @@ -75,6 +76,7 @@ func CreateAPI(apiCtx eapi.API) (*LedgerAPI, error) { } apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listAccounts) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.createAccount) apiCtx.Register().AccountHandler(p.Name(), p.aph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getBalance) return p, nil