diff --git a/.gitignore b/.gitignore index d73c05b..9754b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.pb.gw.go pubspec.lock .DS_Store +update_dep.sh \ No newline at end of file diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index 3b0671a..f6ecfce 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -16,6 +16,8 @@ import ( // Client exposes typed helpers around the ledger gRPC API. type Client interface { + CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) + ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) @@ -29,6 +31,8 @@ type Client interface { } type grpcLedgerClient interface { + CreateAccount(ctx context.Context, in *ledgerv1.CreateAccountRequest, opts ...grpc.CallOption) (*ledgerv1.CreateAccountResponse, error) + ListAccounts(ctx context.Context, in *ledgerv1.ListAccountsRequest, opts ...grpc.CallOption) (*ledgerv1.ListAccountsResponse, error) PostCreditWithCharges(ctx context.Context, in *ledgerv1.PostCreditRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) PostDebitWithCharges(ctx context.Context, in *ledgerv1.PostDebitRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) TransferInternal(ctx context.Context, in *ledgerv1.TransferRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) @@ -91,6 +95,18 @@ func (c *ledgerClient) Close() error { return nil } +func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.CreateAccount(ctx, req) +} + +func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.ListAccounts(ctx, req) +} + func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() diff --git a/api/ledger/internal/service/ledger/list_accounts.go b/api/ledger/internal/service/ledger/list_accounts.go new file mode 100644 index 0000000..02e352e --- /dev/null +++ b/api/ledger/internal/service/ledger/list_accounts.go @@ -0,0 +1,47 @@ +package ledger + +import ( + "context" + "strings" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + "go.uber.org/zap" +) + +func (s *Service) listAccountsResponder(_ context.Context, req *ledgerv1.ListAccountsRequest) gsresponse.Responder[ledgerv1.ListAccountsResponse] { + return func(ctx context.Context) (*ledgerv1.ListAccountsResponse, error) { + if s.storage == nil { + return nil, errStorageNotInitialized + } + if req == nil { + return nil, merrors.InvalidArgument("request is required") + } + + orgRefStr := strings.TrimSpace(req.GetOrganizationRef()) + if orgRefStr == "" { + return nil, merrors.InvalidArgument("organization_ref is required") + } + orgRef, err := parseObjectID(orgRefStr) + if err != nil { + return nil, err + } + + // No pagination requested; return all accounts for the organization. + accounts, err := s.storage.Accounts().ListByOrganization(ctx, orgRef, 0, 0) + if err != nil { + s.logger.Warn("failed to list ledger accounts", zap.Error(err), zap.String("organizationRef", orgRef.Hex())) + return nil, err + } + + resp := &ledgerv1.ListAccountsResponse{ + Accounts: make([]*ledgerv1.LedgerAccount, 0, len(accounts)), + } + for _, acc := range accounts { + resp.Accounts = append(resp.Accounts, toProtoAccount(acc)) + } + + return resp, nil + } +} diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index 3763481..c2fafbd 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -81,6 +81,12 @@ func (s *Service) Register(router routers.GRPC) error { }) } +// ListAccounts lists ledger accounts for an organization. +func (s *Service) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) { + responder := s.listAccountsResponder(ctx, req) + return responder(ctx) +} + // CreateAccount provisions a new ledger account scoped to an organization. func (s *Service) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) { responder := s.createAccountResponder(ctx, req) diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 51b3b47..d1c4e5b 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -40,6 +40,7 @@ const ( Roles Type = "roles" // Represents roles in access control Storage Type = "storage" // Represents statuses of tasks or projects Tenants Type = "tenants" // Represents tenants managed in the system + Wallets Type = "wallets" // Represents workflows for tasks or projects Workflows Type = "workflows" // Represents workflows for tasks or projects ) diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index 6630ec3..3bd38b4 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -77,6 +77,9 @@ service LedgerService { rpc GetBalance (GetBalanceRequest) returns (BalanceResponse); rpc GetJournalEntry (GetEntryRequest) returns (JournalEntryResponse); rpc GetStatement (GetStatementRequest) returns (StatementResponse); + + // Lists ledger accounts for an organization. + rpc ListAccounts (ListAccountsRequest) returns (ListAccountsResponse); } message CreateAccountRequest { @@ -192,3 +195,11 @@ message StatementResponse { repeated JournalEntryResponse entries = 1; string next_cursor = 2; } + +message ListAccountsRequest { + string organization_ref = 1; +} + +message ListAccountsResponse { + repeated LedgerAccount accounts = 1; +} diff --git a/api/server/config.yml b/api/server/config.yml index cb4c79e..5a98f3a 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -85,6 +85,12 @@ api: chain: ARBITRUM_ONE token_symbol: USDT contract_address: "" + ledger: + address: sendico_ledger:50052 + address_env: LEDGER_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true app: diff --git a/api/server/go.mod b/api/server/go.mod index 02db4e0..af8a84d 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -4,13 +4,15 @@ go 1.25.3 replace github.com/tech/sendico/pkg => ../pkg +replace github.com/tech/sendico/ledger => ../ledger + replace github.com/tech/sendico/chain/gateway => ../chain/gateway require ( github.com/aws/aws-sdk-go-v2 v1.40.0 - github.com/aws/aws-sdk-go-v2/config v1.32.1 - github.com/aws/aws-sdk-go-v2/credentials v1.19.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 + github.com/aws/aws-sdk-go-v2/config v1.32.2 + github.com/aws/aws-sdk-go-v2/credentials v1.19.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -19,12 +21,14 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.11.1 github.com/tech/sendico/chain/gateway v0.1.0 + github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 go.mongodb.org/mongo-driver v1.17.6 go.uber.org/zap v1.27.1 golang.org/x/net v0.47.0 + google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 moul.io/chizap v1.0.3 ) @@ -49,10 +53,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect github.com/aws/smithy-go v1.23.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect @@ -133,5 +137,4 @@ require ( golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 920c713..fe215ae 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU= -github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= +github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= +github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= @@ -32,16 +32,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= diff --git a/api/server/interface/api/config.go b/api/server/interface/api/config.go index f21e3af..7b10963 100644 --- a/api/server/interface/api/config.go +++ b/api/server/interface/api/config.go @@ -9,6 +9,7 @@ type Config struct { Mw *mwa.Config `yaml:"middleware"` Storage *fsc.Config `yaml:"storage"` ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"` + Ledger *LedgerConfig `yaml:"ledger"` } type ChainGatewayConfig struct { @@ -25,3 +26,11 @@ type ChainGatewayAssetConfig struct { TokenSymbol string `yaml:"token_symbol"` ContractAddress string `yaml:"contract_address"` } + +type LedgerConfig struct { + Address string `yaml:"address"` + AddressEnv string `yaml:"address_env"` + DialTimeoutSeconds int `yaml:"dial_timeout_seconds"` + CallTimeoutSeconds int `yaml:"call_timeout_seconds"` + Insecure bool `yaml:"insecure"` +} diff --git a/api/server/interface/api/sresponse/ledger.go b/api/server/interface/api/sresponse/ledger.go new file mode 100644 index 0000000..84bcdee --- /dev/null +++ b/api/server/interface/api/sresponse/ledger.go @@ -0,0 +1,106 @@ +package sresponse + +import ( + "net/http" + "time" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" +) + +type ledgerAccount struct { + LedgerAccountRef string `json:"ledgerAccountRef"` + OrganizationRef string `json:"organizationRef"` + AccountCode string `json:"accountCode"` + AccountType string `json:"accountType"` + Currency string `json:"currency"` + Status string `json:"status"` + AllowNegative bool `json:"allowNegative"` + IsSettlement bool `json:"isSettlement"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +type ledgerAccountsResponse struct { + authResponse `json:",inline"` + Accounts []ledgerAccount `json:"accounts"` +} + +type ledgerMoney struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type ledgerBalance struct { + LedgerAccountRef string `json:"ledgerAccountRef"` + Balance *ledgerMoney `json:"balance,omitempty"` + Version int64 `json:"version"` + LastUpdated time.Time `json:"lastUpdated,omitempty"` +} + +type ledgerBalanceResponse struct { + authResponse `json:",inline"` + Balance ledgerBalance `json:"balance"` +} + +func LedgerAccounts(logger mlogger.Logger, accounts []*ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc { + dto := make([]ledgerAccount, 0, len(accounts)) + for _, acc := range accounts { + dto = append(dto, toLedgerAccount(acc)) + } + return response.Ok(logger, ledgerAccountsResponse{ + Accounts: dto, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, ledgerBalanceResponse{ + Balance: toLedgerBalance(resp), + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func toLedgerAccount(acc *ledgerv1.LedgerAccount) ledgerAccount { + if acc == nil { + return ledgerAccount{} + } + return ledgerAccount{ + LedgerAccountRef: acc.GetLedgerAccountRef(), + OrganizationRef: acc.GetOrganizationRef(), + AccountCode: acc.GetAccountCode(), + AccountType: acc.GetAccountType().String(), + Currency: acc.GetCurrency(), + Status: acc.GetStatus().String(), + AllowNegative: acc.GetAllowNegative(), + IsSettlement: acc.GetIsSettlement(), + Metadata: acc.GetMetadata(), + CreatedAt: acc.GetCreatedAt().AsTime(), + UpdatedAt: acc.GetUpdatedAt().AsTime(), + } +} + +func toLedgerBalance(resp *ledgerv1.BalanceResponse) ledgerBalance { + if resp == nil { + return ledgerBalance{} + } + return ledgerBalance{ + LedgerAccountRef: resp.GetLedgerAccountRef(), + Balance: toLedgerMoney(resp.GetBalance()), + Version: resp.GetVersion(), + LastUpdated: resp.GetLastUpdated().AsTime(), + } +} + +func toLedgerMoney(m *moneyv1.Money) *ledgerMoney { + if m == nil { + return nil + } + return &ledgerMoney{ + Amount: m.GetAmount(), + Currency: m.GetCurrency(), + } +} diff --git a/api/server/interface/api/sresponse/wallet.go b/api/server/interface/api/sresponse/wallet.go new file mode 100644 index 0000000..595bcfd --- /dev/null +++ b/api/server/interface/api/sresponse/wallet.go @@ -0,0 +1,132 @@ +package sresponse + +import ( + "net/http" + "time" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type walletAsset struct { + Chain string `json:"chain"` + TokenSymbol string `json:"tokenSymbol"` + ContractAddress string `json:"contractAddress"` +} + +type wallet struct { + WalletRef string `json:"walletRef"` + OrganizationRef string `json:"organizationRef"` + OwnerRef string `json:"ownerRef"` + Asset walletAsset `json:"asset"` + DepositAddress string `json:"depositAddress"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type walletsResponse struct { + authResponse `json:",inline"` + Wallets []wallet `json:"wallets"` + Page *paginationv1.CursorPageResponse `json:"page,omitempty"` +} + +type walletMoney struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type walletBalance struct { + Available *walletMoney `json:"available,omitempty"` + PendingInbound *walletMoney `json:"pendingInbound,omitempty"` + PendingOutbound *walletMoney `json:"pendingOutbound,omitempty"` + CalculatedAt string `json:"calculatedAt,omitempty"` +} + +type walletBalanceResponse struct { + authResponse `json:",inline"` + Balance walletBalance `json:"balance"` +} + +func Wallets(logger mlogger.Logger, resp *gatewayv1.ListManagedWalletsResponse, accessToken *TokenData) http.HandlerFunc { + dto := walletsResponse{ + Page: resp.GetPage(), + authResponse: authResponse{AccessToken: *accessToken}, + } + dto.Wallets = make([]wallet, 0, len(resp.GetWallets())) + for _, w := range resp.GetWallets() { + dto.Wallets = append(dto.Wallets, toWallet(w)) + } + return response.Ok(logger, dto) +} + +func WalletBalance(logger mlogger.Logger, bal *gatewayv1.WalletBalance, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, walletBalanceResponse{ + Balance: toWalletBalance(bal), + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func toWallet(w *gatewayv1.ManagedWallet) wallet { + if w == nil { + return wallet{} + } + asset := w.GetAsset() + chain := "" + token := "" + contract := "" + if asset != nil { + chain = asset.GetChain().String() + token = asset.GetTokenSymbol() + contract = asset.GetContractAddress() + } + return wallet{ + WalletRef: w.GetWalletRef(), + OrganizationRef: w.GetOrganizationRef(), + OwnerRef: w.GetOwnerRef(), + Asset: walletAsset{ + Chain: chain, + TokenSymbol: token, + ContractAddress: contract, + }, + DepositAddress: w.GetDepositAddress(), + Status: w.GetStatus().String(), + Metadata: w.GetMetadata(), + CreatedAt: tsToString(w.GetCreatedAt()), + UpdatedAt: tsToString(w.GetUpdatedAt()), + } +} + +func toWalletBalance(b *gatewayv1.WalletBalance) walletBalance { + if b == nil { + return walletBalance{} + } + return walletBalance{ + Available: toMoney(b.GetAvailable()), + PendingInbound: toMoney(b.GetPendingInbound()), + PendingOutbound: toMoney(b.GetPendingOutbound()), + CalculatedAt: tsToString(b.GetCalculatedAt()), + } +} + +func toMoney(m *moneyv1.Money) *walletMoney { + if m == nil { + return nil + } + return &walletMoney{ + Amount: m.GetAmount(), + Currency: m.GetCurrency(), + } +} + +func tsToString(ts *timestamppb.Timestamp) string { + if ts == nil { + return "" + } + return ts.AsTime().UTC().Format(time.RFC3339) +} diff --git a/api/server/interface/services/ledger/ledger.go b/api/server/interface/services/ledger/ledger.go new file mode 100644 index 0000000..77a1034 --- /dev/null +++ b/api/server/interface/services/ledger/ledger.go @@ -0,0 +1,11 @@ +package ledger + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/ledgerapiimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return ledgerapiimp.CreateAPI(a) +} diff --git a/api/server/interface/services/wallet/wallet.go b/api/server/interface/services/wallet/wallet.go new file mode 100644 index 0000000..fa56e0a --- /dev/null +++ b/api/server/interface/services/wallet/wallet.go @@ -0,0 +1,11 @@ +package wallet + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/walletapiimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return walletapiimp.CreateAPI(a) +} diff --git a/api/server/internal/api/api.go b/api/server/internal/api/api.go index d37506f..02301d5 100644 --- a/api/server/internal/api/api.go +++ b/api/server/internal/api/api.go @@ -18,6 +18,8 @@ import ( "github.com/tech/sendico/server/interface/services/organization" "github.com/tech/sendico/server/interface/services/permission" "github.com/tech/sendico/server/interface/services/site" + "github.com/tech/sendico/server/interface/services/wallet" + "github.com/tech/sendico/server/interface/services/ledger" "go.uber.org/zap" ) @@ -83,6 +85,8 @@ func (a *APIImp) installServices() error { srvf = append(srvf, logo.Create) srvf = append(srvf, permission.Create) srvf = append(srvf, site.Create) + srvf = append(srvf, wallet.Create) + srvf = append(srvf, ledger.Create) for _, v := range srvf { if err := a.addMicroservice(v); err != nil { diff --git a/api/server/internal/server/ledgerapiimp/balance.go b/api/server/internal/server/ledgerapiimp/balance.go new file mode 100644 index 0000000..4bf05be --- /dev/null +++ b/api/server/internal/server/ledgerapiimp/balance.go @@ -0,0 +1,52 @@ +package ledgerapiimp + +import ( + "errors" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "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/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *LedgerAPI) getBalance(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 balance", 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) + } + + accountRef := strings.TrimSpace(a.aph.GetID(r)) + if accountRef == "" { + return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), errors.New("ledger account reference is required")) + } + + ctx := r.Context() + res, err := a.enf.Enforce(ctx, a.balancePerm, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check ledger balance access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()), zap.String("ledger_account_ref", accountRef)) + return response.Auto(a.logger, a.Name(), err) + } + if !res { + a.logger.Debug("Access denied when reading ledger balance", zap.String(a.oph.Name(), orgRef.Hex()), zap.String("ledger_account_ref", accountRef)) + return response.AccessDenied(a.logger, a.Name(), "ledger balance read permission denied") + } + if a.client == nil { + return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured")) + } + + resp, err := a.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{ + LedgerAccountRef: accountRef, + }) + if err != nil { + a.logger.Warn("Failed to fetch ledger balance", zap.Error(err), zap.String("ledger_account_ref", accountRef)) + return response.Auto(a.logger, mservice.Ledger, err) + } + + return sresponse.LedgerBalance(a.logger, resp, token) +} diff --git a/api/server/internal/server/ledgerapiimp/list.go b/api/server/internal/server/ledgerapiimp/list.go new file mode 100644 index 0000000..d10963a --- /dev/null +++ b/api/server/internal/server/ledgerapiimp/list.go @@ -0,0 +1,46 @@ +package ledgerapiimp + +import ( + "errors" + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "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/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *LedgerAPI) listAccounts(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 list", 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() + res, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + if !res { + a.logger.Debug("Access denied when listing ledger accounts", zap.String(a.oph.Name(), orgRef.Hex())) + return response.AccessDenied(a.logger, a.Name(), "ledger accounts read permission denied") + } + if a.client == nil { + return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured")) + } + + resp, err := a.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{ + OrganizationRef: orgRef.Hex(), + }) + if err != nil { + a.logger.Warn("Failed to list ledger accounts", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, mservice.Ledger, err) + } + + return sresponse.LedgerAccounts(a.logger, resp.GetAccounts(), token) +} diff --git a/api/server/internal/server/ledgerapiimp/service.go b/api/server/internal/server/ledgerapiimp/service.go new file mode 100644 index 0000000..d9dd121 --- /dev/null +++ b/api/server/internal/server/ledgerapiimp/service.go @@ -0,0 +1,110 @@ +package ledgerapiimp + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + ledgerclient "github.com/tech/sendico/ledger/client" + api "github.com/tech/sendico/pkg/api/http" + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + eapi "github.com/tech/sendico/server/interface/api" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type ledgerClient interface { + ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) + GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) + Close() error +} + +type LedgerAPI struct { + logger mlogger.Logger + client ledgerClient + enf auth.Enforcer + oph mutil.ParamHelper + aph mutil.ParamHelper + permissionRef primitive.ObjectID + balancePerm primitive.ObjectID +} + +func (a *LedgerAPI) Name() mservice.Type { return mservice.LedgerAccounts } + +func (a *LedgerAPI) Finish(ctx context.Context) error { + if a.client != nil { + if err := a.client.Close(); err != nil { + a.logger.Warn("Failed to close ledger client", zap.Error(err)) + } + } + return nil +} + +func CreateAPI(apiCtx eapi.API) (*LedgerAPI, error) { + p := &LedgerAPI{ + logger: apiCtx.Logger().Named(mservice.LedgerAccounts), + enf: apiCtx.Permissions().Enforcer(), + oph: mutil.CreatePH(mservice.Organizations), + aph: mutil.CreatePH("ledger_account"), + } + + desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.LedgerAccounts) + if err != nil { + p.logger.Warn("Failed to fetch ledger accounts permission description", zap.Error(err)) + return nil, err + } + p.permissionRef = desc.ID + + bdesc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.LedgerBalances) + if err != nil { + p.logger.Warn("Failed to fetch ledger balances permission description", zap.Error(err)) + return nil, err + } + p.balancePerm = bdesc.ID + + if err := p.initLedgerClient(apiCtx.Config().Ledger); err != nil { + p.logger.Error("Failed to initialize ledger client", zap.Error(err)) + return nil, err + } + + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listAccounts) + apiCtx.Register().AccountHandler(p.Name(), p.aph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getBalance) + + return p, nil +} + +func (a *LedgerAPI) initLedgerClient(cfg *eapi.LedgerConfig) error { + if cfg == nil { + return merrors.InvalidArgument("ledger configuration is not provided") + } + + address := strings.TrimSpace(cfg.Address) + if address == "" { + address = strings.TrimSpace(os.Getenv(cfg.AddressEnv)) + } + if address == "" { + return merrors.InvalidArgument(fmt.Sprintf("ledger address is not specified and address env %s is empty", cfg.AddressEnv)) + } + + clientCfg := ledgerclient.Config{ + Address: address, + DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, + CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, + Insecure: cfg.Insecure, + } + + client, err := ledgerclient.New(context.Background(), clientCfg) + if err != nil { + return err + } + + a.client = client + return nil +} diff --git a/api/server/internal/server/walletapiimp/balance.go b/api/server/internal/server/walletapiimp/balance.go new file mode 100644 index 0000000..995193e --- /dev/null +++ b/api/server/internal/server/walletapiimp/balance.go @@ -0,0 +1,55 @@ +package walletapiimp + +import ( + "errors" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + "github.com/tech/sendico/server/interface/api/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *WalletAPI) getWalletBalance(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 wallet balance", 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) + } + walletRef := strings.TrimSpace(a.wph.GetID(r)) + if walletRef == "" { + return response.BadReference(a.logger, a.Name(), a.wph.Name(), a.wph.GetID(r), errors.New("wallet reference is required")) + } + + ctx := r.Context() + res, err := a.enf.Enforce(ctx, a.balancesPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check wallet balance permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()), zap.String("wallet_ref", walletRef)) + return response.Auto(a.logger, a.Name(), err) + } + if !res { + a.logger.Debug("Access denied when reading wallet balance", zap.String(a.oph.Name(), orgRef.Hex()), zap.String("wallet_ref", walletRef)) + return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied") + } + if a.chainGateway == nil { + return response.Internal(a.logger, mservice.ChainGateway, errors.New("chain gateway client is not configured")) + } + + resp, err := a.chainGateway.GetWalletBalance(ctx, &gatewayv1.GetWalletBalanceRequest{WalletRef: walletRef}) + if err != nil { + a.logger.Warn("Failed to fetch wallet balance", zap.Error(err), zap.String("wallet_ref", walletRef)) + return response.Auto(a.logger, mservice.ChainGateway, err) + } + + bal := resp.GetBalance() + if bal == nil { + a.logger.Warn("Wallet balance missing in response", zap.String("wallet_ref", walletRef)) + return response.Auto(a.logger, mservice.ChainGateway, errors.New("wallet balance not available")) + } + + return sresponse.WalletBalance(a.logger, bal, token) +} diff --git a/api/server/internal/server/walletapiimp/list.go b/api/server/internal/server/walletapiimp/list.go new file mode 100644 index 0000000..c5acc6c --- /dev/null +++ b/api/server/internal/server/walletapiimp/list.go @@ -0,0 +1,52 @@ +package walletapiimp + +import ( + "errors" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + "github.com/tech/sendico/server/interface/api/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *WalletAPI) listWallets(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 wallet list", 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() + res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + if !res { + a.logger.Debug("Access denied when listing organization wallets", zap.String(a.oph.Name(), orgRef.Hex())) + return response.AccessDenied(a.logger, a.Name(), "wallets read permission denied") + } + if a.chainGateway == nil { + return response.Internal(a.logger, mservice.ChainGateway, errors.New("chain gateway client is not configured")) + } + + req := &gatewayv1.ListManagedWalletsRequest{ + OrganizationRef: orgRef.Hex(), + } + if owner := strings.TrimSpace(r.URL.Query().Get("owner_ref")); owner != "" { + req.OwnerRef = owner + } + + resp, err := a.chainGateway.ListManagedWallets(ctx, req) + if err != nil { + a.logger.Warn("Failed to list managed wallets", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, mservice.ChainGateway, err) + } + + return sresponse.Wallets(a.logger, resp, token) +} diff --git a/api/server/internal/server/walletapiimp/service.go b/api/server/internal/server/walletapiimp/service.go new file mode 100644 index 0000000..9077933 --- /dev/null +++ b/api/server/internal/server/walletapiimp/service.go @@ -0,0 +1,115 @@ +package walletapiimp + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + chaingatewayclient "github.com/tech/sendico/chain/gateway/client" + api "github.com/tech/sendico/pkg/api/http" + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + eapi "github.com/tech/sendico/server/interface/api" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type WalletAPI struct { + logger mlogger.Logger + chainGateway chainWalletClient + enf auth.Enforcer + oph mutil.ParamHelper + wph mutil.ParamHelper + walletsPermissionRef primitive.ObjectID + balancesPermissionRef primitive.ObjectID +} + +type chainWalletClient interface { + ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) + GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) + Close() error +} + +func (a *WalletAPI) Name() mservice.Type { return mservice.ChainWallets } + +func (a *WalletAPI) Finish(ctx context.Context) error { + if a.chainGateway != nil { + if err := a.chainGateway.Close(); err != nil { + a.logger.Warn("Failed to close chain gateway client", zap.Error(err)) + } + } + return nil +} + +func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { + p := &WalletAPI{ + logger: apiCtx.Logger().Named(mservice.Wallets), + enf: apiCtx.Permissions().Enforcer(), + oph: mutil.CreatePH(mservice.Organizations), + wph: mutil.CreatePH(mservice.Wallets), + } + + walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets) + if err != nil { + p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err)) + return nil, err + } + p.walletsPermissionRef = walletsPolicy.ID + + balancesPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWalletBalances) + if err != nil { + p.logger.Warn("Failed to fetch chain wallet balances permission policy description", zap.Error(err)) + return nil, err + } + p.balancesPermissionRef = balancesPolicy.ID + + cfg := apiCtx.Config() + if cfg == nil { + p.logger.Error("Failed to fetch service configuration") + return nil, merrors.InvalidArgument("No configuration provided") + } + if err := p.initChainGateway(cfg.ChainGateway); err != nil { + p.logger.Error("Failed to initialize chain gateway client", zap.Error(err)) + return nil, err + } + + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets) + apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance) + + return p, nil +} + +func (a *WalletAPI) initChainGateway(cfg *eapi.ChainGatewayConfig) error { + if cfg == nil { + return merrors.InvalidArgument("chain gateway configuration is not provided") + } + + cfg.Address = strings.TrimSpace(cfg.Address) + if cfg.Address == "" { + cfg.Address = strings.TrimSpace(os.Getenv(cfg.AddressEnv)) + } + if cfg.Address == "" { + return merrors.InvalidArgument(fmt.Sprintf("chain gateway address is not specified and address env %s is empty", cfg.AddressEnv)) + } + + clientCfg := chaingatewayclient.Config{ + Address: cfg.Address, + DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, + CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, + Insecure: cfg.Insecure, + } + + client, err := chaingatewayclient.New(context.Background(), clientCfg) + if err != nil { + return err + } + + a.chainGateway = client + return nil +} diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 5d0b478..858686f 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -88,6 +88,7 @@ LEDGER_COMPOSE_PROJECT=sendico-ledger LEDGER_SERVICE_NAME=sendico_ledger LEDGER_GRPC_PORT=50052 LEDGER_METRICS_PORT=9401 +LEDGER_ADDRESS=sendico_ledger:50520 # Ledger Mongo settings LEDGER_MONGO_HOST=sendico_db1 diff --git a/ci/prod/compose/bff.yml b/ci/prod/compose/bff.yml index 9387d20..3322f48 100644 --- a/ci/prod/compose/bff.yml +++ b/ci/prod/compose/bff.yml @@ -29,7 +29,8 @@ services: NATS_PORT: ${NATS_PORT} NATS_USER: ${NATS_USER} NATS_PASSWORD: ${NATS_PASSWORD} - CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_ADDRESS} + CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT} + LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT} MONGO_HOST: ${MONGO_HOST} MONGO_PORT: ${MONGO_PORT} MONGO_DATABASE: ${MONGO_DATABASE} diff --git a/frontend/pweb/lib/providers/wallets.dart b/frontend/pweb/lib/providers/wallets.dart index 93b0661..86782c8 100644 --- a/frontend/pweb/lib/providers/wallets.dart +++ b/frontend/pweb/lib/providers/wallets.dart @@ -2,130 +2,89 @@ import 'package:flutter/material.dart'; import 'package:pweb/models/wallet.dart'; import 'package:pweb/services/wallets.dart'; - +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/utils/exception.dart'; class WalletsProvider with ChangeNotifier { final WalletsService _service; WalletsProvider(this._service); - List? _wallets; - bool _isLoading = false; - String? _error; - Wallet? _selectedWallet; - final bool _isHidden = true; + Resource> _resource = Resource(data: []); + Resource> get resource => _resource; - List? get wallets => _wallets; - bool get isLoading => _isLoading; - String? get error => _error; + List get wallets => _resource.data ?? []; + bool get isLoading => _resource.isLoading; + Exception? get error => _resource.error; + + Wallet? _selectedWallet; Wallet? get selectedWallet => _selectedWallet; + final bool _isHidden = true; bool get isHidden => _isHidden; - - + bool _isRefreshingBalances = false; + bool get isRefreshingBalances => _isRefreshingBalances; void selectWallet(Wallet wallet) { _selectedWallet = wallet; notifyListeners(); } - Future loadData() async { - _isLoading = true; - _error = null; - notifyListeners(); - + Future loadWalletsWithBalances() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); try { - _wallets = await _service.getWallets(); - } catch (e) { - _error = e.toString(); - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future getWalletById(String walletId) async { - _isLoading = true; - _error = null; - notifyListeners(); - - try { - final wallet = await _service.getWallet(walletId); - return wallet; - } catch (e) { - _error = e.toString(); - return null; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - void updateName(String walletRef, String newName) { - final index = _wallets?.indexWhere((w) => w.id == walletRef); - if (index != null && index >= 0) { - _wallets![index] = _wallets![index].copyWith(name: newName); - notifyListeners(); - } - } - - - void updateBalance(String walletRef, double newBalance) { - final index = _wallets?.indexWhere((w) => w.id == walletRef); - if (index != null && index >= 0) { - _wallets![index] = _wallets![index].copyWith(balance: newBalance); - notifyListeners(); - } - } - - Future updateWallet(Wallet wallet) async { - try { - await _service.updateWallet(); - final index = _wallets?.indexWhere((w) => w.id == wallet.id); - if (index != null && index >= 0) { - _wallets![index] = wallet; - notifyListeners(); + final base = await _service.getWallets(); + final withBalances = []; + for (final wallet in base) { + try { + final balance = await _service.getBalance(wallet.id); + withBalances.add(wallet.copyWith(balance: balance)); + } catch (e) { + _setResource(_resource.copyWith(error: toException(e))); + withBalances.add(wallet); + } } + _setResource(Resource(data: withBalances, isLoading: false, error: _resource.error)); } catch (e) { - _error = e.toString(); - notifyListeners(); + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); } } + Future refreshBalances() async { + if (wallets.isEmpty) return; + _isRefreshingBalances = true; + notifyListeners(); - Future addWallet(Wallet wallet) async { try { - final newWallet = await _service.createWallet(); // Pass the wallet parameter - _wallets = [...?_wallets, ]; // Add the new wallet - notifyListeners(); + final updated = []; + for (final wallet in wallets) { + final balance = await _service.getBalance(wallet.id); + updated.add(wallet.copyWith(balance: balance)); + } + _setResource(_resource.copyWith(data: updated)); } catch (e) { - _error = e.toString(); - notifyListeners(); - } - } - - Future deleteWallet(String walletId) async { - try { - await _service.deleteWallet(); // Pass the walletId parameter - _wallets?.removeWhere((w) => w.id == walletId); - notifyListeners(); - } catch (e) { - _error = e.toString(); + _setResource(_resource.copyWith(error: toException(e))); + } finally { + _isRefreshingBalances = false; notifyListeners(); } } void toggleVisibility(String walletId) { - final index = _wallets?.indexWhere((w) => w.id == walletId); - if (index != null && index >= 0) { - final wallet = _wallets![index]; - _wallets![index] = wallet.copyWith(isHidden: !wallet.isHidden); - - if (_selectedWallet?.id == walletId) { - _selectedWallet = _wallets![index]; - } - - notifyListeners(); + final index = wallets.indexWhere((w) => w.id == walletId); + if (index < 0) return; + final wallet = wallets[index]; + final updated = wallet.copyWith(isHidden: !wallet.isHidden); + final next = List.from(wallets); + next[index] = updated; + _setResource(_resource.copyWith(data: next)); + if (_selectedWallet?.id == walletId) { + _selectedWallet = updated; } } -} \ No newline at end of file + + void _setResource(Resource> newResource) { + _resource = newResource; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/services/wallets.dart b/frontend/pweb/lib/services/wallets.dart index 7f5d033..7b9cc5e 100644 --- a/frontend/pweb/lib/services/wallets.dart +++ b/frontend/pweb/lib/services/wallets.dart @@ -4,11 +4,7 @@ import 'package:pweb/models/wallet.dart'; abstract class WalletsService { Future> getWallets(); - Future> updateWallet(); - Future> createWallet(); - Future> deleteWallet(); - - Future getWallet(String walletRef); + Future getBalance(String walletRef); } class MockWalletsService implements WalletsService { @@ -31,11 +27,11 @@ class MockWalletsService implements WalletsService { } @override - Future> updateWallet() async => []; - - @override - Future> createWallet() async => []; - - @override - Future> deleteWallet() async => []; -} \ No newline at end of file + Future getBalance(String walletRef) async { + final wallet = _wallets.firstWhere( + (w) => w.id == walletRef, + orElse: () => throw Exception('Wallet not found'), + ); + return wallet.balance; + } +}