3 Commits

Author SHA1 Message Date
Stephan D
48ccbb1c82 implemented backend wallet service connection
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 00:48:00 +01:00
Stephan D
68f0a1048f implemented backend wallets/ledger accounts listing 2025-11-25 23:38:10 +01:00
Stephan D
be913bf96c + logout connected 2025-11-25 21:37:22 +01:00
50 changed files with 1299 additions and 160 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
*.pb.gw.go
pubspec.lock
.DS_Store
update_dep.sh

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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"`
}

View File

@@ -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(),
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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}

View File

@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/wallet/balance.dart';
part 'wallet_balance.g.dart';
@JsonSerializable(explicitToJson: true)
class WalletBalanceResponse {
final WalletBalanceDTO balance;
const WalletBalanceResponse({required this.balance});
factory WalletBalanceResponse.fromJson(Map<String, dynamic> json) => _$WalletBalanceResponseFromJson(json);
Map<String, dynamic> toJson() => _$WalletBalanceResponseToJson(this);
}

View File

@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/wallet/wallet.dart';
part 'wallets.g.dart';
@JsonSerializable(explicitToJson: true)
class WalletsResponse {
final List<WalletDTO> wallets;
const WalletsResponse({required this.wallets});
factory WalletsResponse.fromJson(Map<String, dynamic> json) => _$WalletsResponseFromJson(json);
Map<String, dynamic> toJson() => _$WalletsResponseToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'asset.g.dart';
@JsonSerializable()
class WalletAssetDTO {
final String chain;
final String tokenSymbol;
final String contractAddress;
const WalletAssetDTO({
required this.chain,
required this.tokenSymbol,
required this.contractAddress,
});
factory WalletAssetDTO.fromJson(Map<String, dynamic> json) => _$WalletAssetDTOFromJson(json);
Map<String, dynamic> toJson() => _$WalletAssetDTOToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/wallet/money.dart';
part 'balance.g.dart';
@JsonSerializable()
class WalletBalanceDTO {
final MoneyDTO? available;
final MoneyDTO? pendingInbound;
final MoneyDTO? pendingOutbound;
final String? calculatedAt;
const WalletBalanceDTO({
required this.available,
required this.pendingInbound,
required this.pendingOutbound,
required this.calculatedAt,
});
factory WalletBalanceDTO.fromJson(Map<String, dynamic> json) => _$WalletBalanceDTOFromJson(json);
Map<String, dynamic> toJson() => _$WalletBalanceDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'money.g.dart';
@JsonSerializable()
class MoneyDTO {
final String amount;
final String currency;
const MoneyDTO({
required this.amount,
required this.currency,
});
factory MoneyDTO.fromJson(Map<String, dynamic> json) => _$MoneyDTOFromJson(json);
Map<String, dynamic> toJson() => _$MoneyDTOToJson(this);
}

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/wallet/asset.dart';
part 'wallet.g.dart';
@JsonSerializable()
class WalletDTO {
final String walletRef;
final String organizationRef;
final String ownerRef;
final WalletAssetDTO asset;
final String depositAddress;
final String status;
final Map<String, String>? metadata;
final String? createdAt;
final String? updatedAt;
const WalletDTO({
required this.walletRef,
required this.organizationRef,
required this.ownerRef,
required this.asset,
required this.depositAddress,
required this.status,
this.metadata,
this.createdAt,
this.updatedAt,
});
factory WalletDTO.fromJson(Map<String, dynamic> json) => _$WalletDTOFromJson(json);
Map<String, dynamic> toJson() => _$WalletDTOToJson(this);
}

View File

@@ -0,0 +1,15 @@
import 'package:pshared/data/dto/wallet/balance.dart';
import 'package:pshared/data/mapper/wallet/money.dart';
import 'package:pshared/models/wallet/balance.dart';
extension WalletBalanceDTOMapper on WalletBalanceDTO {
WalletBalance toDomain() => WalletBalance(
available: available?.toDomain(),
pendingInbound: pendingInbound?.toDomain(),
pendingOutbound: pendingOutbound?.toDomain(),
calculatedAt: (calculatedAt == null || calculatedAt!.isEmpty)
? null
: DateTime.tryParse(calculatedAt!),
);
}

View File

@@ -0,0 +1,10 @@
import 'package:pshared/data/dto/wallet/money.dart';
import 'package:pshared/models/wallet/money.dart';
extension MoneyDTOMapper on MoneyDTO {
WalletMoney toDomain() => WalletMoney(
amount: amount,
currency: currency,
);
}

View File

@@ -0,0 +1,14 @@
import 'package:pshared/api/responses/wallet_balance.dart';
import 'package:pshared/api/responses/wallets.dart';
import 'package:pshared/data/mapper/wallet/balance.dart';
import 'package:pshared/data/mapper/wallet/wallet.dart';
import 'package:pshared/models/wallet/balance.dart';
import 'package:pshared/models/wallet/wallet.dart';
extension WalletsResponseMapper on WalletsResponse {
List<WalletModel> toDomain() => wallets.map((w) => w.toDomain()).toList();
}
extension WalletBalanceResponseMapper on WalletBalanceResponse {
WalletBalance toDomain() => balance.toDomain();
}

View File

@@ -0,0 +1,26 @@
import 'package:pshared/data/dto/wallet/balance.dart';
import 'package:pshared/data/dto/wallet/wallet.dart';
import 'package:pshared/data/mapper/wallet/balance.dart';
import 'package:pshared/data/mapper/wallet/money.dart';
import 'package:pshared/models/wallet/wallet.dart';
extension WalletDTOMapper on WalletDTO {
WalletModel toDomain({WalletBalanceDTO? balance}) => WalletModel(
walletRef: walletRef,
organizationRef: organizationRef,
ownerRef: ownerRef,
asset: WalletAsset(
chain: asset.chain,
tokenSymbol: asset.tokenSymbol,
contractAddress: asset.contractAddress,
),
depositAddress: depositAddress,
status: status,
metadata: metadata,
createdAt: (createdAt == null || createdAt!.isEmpty) ? null : DateTime.tryParse(createdAt!),
updatedAt: (updatedAt == null || updatedAt!.isEmpty) ? null : DateTime.tryParse(updatedAt!),
balance: balance?.toDomain(),
availableMoney: balance?.available?.toDomain(),
);
}

View File

@@ -0,0 +1,16 @@
import 'package:pshared/models/wallet/money.dart';
class WalletBalance {
final WalletMoney? available;
final WalletMoney? pendingInbound;
final WalletMoney? pendingOutbound;
final DateTime? calculatedAt;
const WalletBalance({
required this.available,
required this.pendingInbound,
required this.pendingOutbound,
required this.calculatedAt,
});
}

View File

@@ -0,0 +1,9 @@
class WalletMoney {
final String amount;
final String currency;
const WalletMoney({
required this.amount,
required this.currency,
});
}

View File

@@ -0,0 +1,62 @@
import 'package:pshared/models/wallet/balance.dart';
import 'package:pshared/models/wallet/money.dart';
class WalletAsset {
final String chain;
final String tokenSymbol;
final String contractAddress;
const WalletAsset({
required this.chain,
required this.tokenSymbol,
required this.contractAddress,
});
}
class WalletModel {
final String walletRef;
final String organizationRef;
final String ownerRef;
final WalletAsset asset;
final String depositAddress;
final String status;
final Map<String, String>? metadata;
final DateTime? createdAt;
final DateTime? updatedAt;
final WalletBalance? balance;
final WalletMoney? availableMoney;
const WalletModel({
required this.walletRef,
required this.organizationRef,
required this.ownerRef,
required this.asset,
required this.depositAddress,
required this.status,
this.metadata,
this.createdAt,
this.updatedAt,
this.balance,
this.availableMoney,
});
WalletModel copyWith({
WalletBalance? balance,
WalletMoney? availableMoney,
}) {
return WalletModel(
walletRef: walletRef,
organizationRef: organizationRef,
ownerRef: ownerRef,
asset: asset,
depositAddress: depositAddress,
status: status,
metadata: metadata,
createdAt: createdAt,
updatedAt: updatedAt,
balance: balance ?? this.balance,
availableMoney: availableMoney ?? this.availableMoney,
);
}
}

View File

@@ -37,7 +37,7 @@ class AccountService {
static Future<void> logout() async {
_logger.fine('Logging out');
await AuthorizationService.logout();
return AuthorizationService.logout();
}
static Future<Account> _getAccount(Future<Map<String, dynamic>> future) async {

View File

@@ -7,6 +7,7 @@ class Services {
static const String organization = 'organizations';
static const String permission = 'permissions';
static const String storage = 'storage';
static const String chainWallets = 'chain_wallets';
static const String amplitude = 'amplitude';
static const String clients = 'clients';

View File

@@ -0,0 +1,31 @@
import 'package:pshared/api/responses/wallet_balance.dart';
import 'package:pshared/api/responses/wallets.dart';
import 'package:pshared/data/mapper/wallet/response.dart';
import 'package:pshared/models/wallet/balance.dart';
import 'package:pshared/models/wallet/wallet.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
class WalletService {
static const String _objectType = Services.chainWallets;
static Future<List<WalletModel>> list(String organizationRef) async {
final json = await AuthorizationService.getGETResponse(
_objectType,
'/$organizationRef',
);
return WalletsResponse.fromJson(json).toDomain();
}
static Future<WalletBalance> getBalance({
required String organizationRef,
required String walletRef,
}) async {
final json = await AuthorizationService.getGETResponse(
_objectType,
'/$organizationRef/$walletRef/balance',
);
return WalletBalanceResponse.fromJson(json).toDomain();
}
}

View File

@@ -1,5 +1,9 @@
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/page_params.dart';
import 'package:pweb/pages/2fa/page.dart';
@@ -32,7 +36,11 @@ GoRouter createRouter() => GoRouter(
name: Pages.sfactor.name,
path: routerPage(Pages.sfactor),
builder: (context, _) => TwoFactorCodePage(
onVerificationSuccess: () => context.goNamed(Pages.dashboard.name),
onVerificationSuccess: () {
// trigger organization load
context.read<OrganizationsProvider>().load();
context.goNamed(Pages.dashboard.name);
},
),
),
GoRoute(

View File

@@ -0,0 +1,26 @@
import 'package:pshared/models/wallet/wallet.dart' as domain;
import 'package:pweb/models/currency.dart';
import 'package:pweb/models/wallet.dart';
extension WalletUiMapper on domain.WalletModel {
Wallet toUi() {
final amountStr = availableMoney?.amount ?? balance?.available?.amount ?? '0';
final currencyStr = availableMoney?.currency ?? balance?.available?.currency ?? Currency.usd.toString().toUpperCase();
final parsedAmount = double.tryParse(amountStr) ?? 0;
final currency = Currency.values.firstWhere(
(c) => c.name.toUpperCase() == currencyStr.toUpperCase(),
orElse: () => Currency.usd,
);
return Wallet(
id: walletRef,
walletUserID: walletRef,
name: metadata?['name'] ?? walletRef,
balance: parsedAmount,
currency: currency,
isHidden: true,
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
);
}
}

View File

@@ -72,8 +72,9 @@ void main() async {
ChangeNotifierProvider(
create: (_) => PaymentMethodsProvider(service: MockPaymentMethodsService())..loadMethods(),
),
ChangeNotifierProvider(
create: (_) => WalletsProvider(MockWalletsService())..loadData(),
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
create: (_) => WalletsProvider(ApiWalletsService()),
update: (context, organizations, provider) => provider!..update(organizations),
),
ChangeNotifierProvider(
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(),

View File

@@ -8,6 +8,7 @@ class Wallet {
final double balance;
final Currency currency;
final bool isHidden;
final DateTime calculatedAt;
Wallet({
required this.id,
@@ -15,6 +16,7 @@ class Wallet {
required this.name,
required this.balance,
required this.currency,
required this.calculatedAt,
this.isHidden = true,
});
@@ -25,14 +27,13 @@ class Wallet {
Currency? currency,
String? walletUserID,
bool? isHidden,
}) {
return Wallet(
id: id ?? this.id,
name: name ?? this.name,
balance: balance ?? this.balance,
currency: currency ?? this.currency,
walletUserID: walletUserID ?? this.walletUserID,
isHidden: isHidden ?? this.isHidden,
);
}
}) => Wallet(
id: id ?? this.id,
name: name ?? this.name,
balance: balance ?? this.balance,
currency: currency ?? this.currency,
walletUserID: walletUserID ?? this.walletUserID,
isHidden: isHidden ?? this.isHidden,
calculatedAt: calculatedAt,
);
}

View File

@@ -44,6 +44,7 @@ class _LoginFormState extends State<LoginForm> {
locale: context.read<LocaleProvider>().locale.languageCode,
);
if (outcome.isPending) {
// TODO: fix context usage
navigateAndReplace(context, Pages.sfactor);
} else {
onLogin();

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/error/snackbar.dart';
class WalletEditHeader extends StatefulWidget {
@@ -85,10 +86,11 @@ class _WalletEditHeaderState extends State<WalletEditHeader> {
icon: const Icon(Icons.check),
color: theme.colorScheme.primary,
onPressed: () async {
provider.updateName(wallet.id, _controller.text);
await provider.updateWallet(wallet.copyWith(name: _controller.text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Wallet name saved')),
await executeActionWithNotification(
context: context,
action: () async => await provider.updateWallet(wallet.copyWith(name: _controller.text)),
errorMessage: 'Failed to update wallet name',
successMessage: 'Wallet name saved',
);
setState(() {
_isEditing = false;

View File

@@ -1,131 +1,103 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/utils/exception.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/services/wallets.dart';
class WalletsProvider with ChangeNotifier {
final WalletsService _service;
late OrganizationsProvider _organizations;
WalletsProvider(this._service);
List<Wallet>? _wallets;
bool _isLoading = false;
String? _error;
Wallet? _selectedWallet;
final bool _isHidden = true;
Resource<List<Wallet>> _resource = Resource(data: []);
Resource<List<Wallet>> get resource => _resource;
List<Wallet>? get wallets => _wallets;
bool get isLoading => _isLoading;
String? get error => _error;
List<Wallet> 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 update(OrganizationsProvider organizations) {
_organizations = organizations;
if (_organizations.isOrganizationSet) loadWalletsWithBalances();
}
Future<Wallet> updateWallet(Wallet newWallet) {
throw Exception('update wallet is not implemented');
}
void selectWallet(Wallet wallet) {
_selectedWallet = wallet;
notifyListeners();
}
Future<void> loadData() async {
_isLoading = true;
_error = null;
notifyListeners();
Future<void> loadWalletsWithBalances() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
_wallets = await _service.getWallets();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<Wallet?> 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<void> 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(_organizations.current.id);
final withBalances = <Wallet>[];
for (final wallet in base) {
try {
final balance = await _service.getBalance(_organizations.current.id, 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<void> refreshBalances() async {
if (wallets.isEmpty) return;
_isRefreshingBalances = true;
notifyListeners();
Future<void> addWallet(Wallet wallet) async {
try {
final newWallet = await _service.createWallet(); // Pass the wallet parameter
_wallets = [...?_wallets, ]; // Add the new wallet
notifyListeners();
final updated = <Wallet>[];
for (final wallet in wallets) {
final balance = await _service.getBalance(_organizations.current.id, wallet.id);
updated.add(wallet.copyWith(balance: balance));
}
_setResource(_resource.copyWith(data: updated));
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
Future<void> 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<Wallet>.from(wallets);
next[index] = updated;
_setResource(_resource.copyWith(data: next));
if (_selectedWallet?.id == walletId) {
_selectedWallet = updated;
}
}
}
void _setResource(Resource<List<Wallet>> newResource) {
_resource = newResource;
notifyListeners();
}
}

View File

@@ -1,41 +1,50 @@
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
import 'package:pweb/models/currency.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/data/mappers/wallet_ui.dart';
abstract class WalletsService {
Future<List<Wallet>> getWallets();
Future<List<Wallet>> updateWallet();
Future<List<Wallet>> createWallet();
Future<List<Wallet>> deleteWallet();
Future<Wallet> getWallet(String walletRef);
Future<List<Wallet>> getWallets(String organizationRef);
Future<double> getBalance(String organizationRef, String walletRef);
}
class MockWalletsService implements WalletsService {
final List<Wallet> _wallets = [
Wallet(id: '1124', walletUserID: 'WA-12345667', name: 'Main Wallet', balance: 10000000.0, currency: Currency.rub),
Wallet(id: '2124', walletUserID: 'WA-76654321', name: 'Savings', balance: 2500.5, currency: Currency.usd),
Wallet(id: '1124', walletUserID: 'WA-12345667', name: 'Main Wallet', balance: 10000000.0, currency: Currency.rub, calculatedAt: DateTime.now()),
Wallet(id: '2124', walletUserID: 'WA-76654321', name: 'Savings', balance: 2500.5, currency: Currency.usd, calculatedAt: DateTime.now()),
];
@override
Future<List<Wallet>> getWallets() async {
Future<List<Wallet>> getWallets(String _) async {
return _wallets;
}
@override
Future<Wallet> getWallet(String walletId) async {
return _wallets.firstWhere(
(wallet) => wallet.id == walletId,
Future<double> getBalance(String _, String walletRef) async {
final wallet = _wallets.firstWhere(
(w) => w.id == walletRef,
orElse: () => throw Exception('Wallet not found'),
);
return wallet.balance;
}
}
class ApiWalletsService implements WalletsService {
@override
Future<List<Wallet>> getWallets(String organizationRef) async {
final models = await shared_wallet_service.WalletService.list(organizationRef);
return models.map((m) => m.toUi()).toList();
}
@override
Future<List<Wallet>> updateWallet() async => [];
@override
Future<List<Wallet>> createWallet() async => [];
@override
Future<List<Wallet>> deleteWallet() async => [];
}
Future<double> getBalance(String organizationRef, String walletRef) async {
final balance = await shared_wallet_service.WalletService.getBalance(
organizationRef: organizationRef,
walletRef: walletRef,
);
final amount = balance.available?.amount;
return amount == null ? 0 : double.tryParse(amount) ?? 0;
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pweb/utils/error_handler.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/content.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -52,13 +53,18 @@ Future<T?> executeActionWithNotification<T>({
required BuildContext context,
required Future<T> Function() action,
required String errorMessage,
String? successMessage,
int delaySeconds = 3,
}) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final localizations = AppLocalizations.of(context)!;
try {
return await action();
final res = await action();
if (successMessage != null) {
notifyUserX(scaffoldMessenger, successMessage, delaySeconds: delaySeconds);
}
return res;
} catch (e) {
// Report the error using your existing notifier.
notifyUserOfErrorX(

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/payment_methods/page.dart';
@@ -9,9 +11,9 @@ import 'package:pweb/pages/payout_page/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/widgets/appbar/app_bar.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/sidebar.dart';
@@ -19,9 +21,10 @@ import 'package:pweb/widgets/sidebar/sidebar.dart';
class PageSelector extends StatelessWidget {
const PageSelector({super.key});
void _logout(BuildContext context) => context.read<AccountProvider>().logout();
@override
Widget build(BuildContext context) {
final provider = context.watch<PageSelectorProvider>();
Widget build(BuildContext context) => Consumer<PageSelectorProvider>(builder:(context, provider, _) {
Widget content;
switch (provider.selected) {
@@ -87,7 +90,7 @@ class PageSelector extends StatelessWidget {
appBar: PayoutAppBar(
title: Text(provider.selected.localizedLabel(context)),
onAddFundsPressed: () {},
onLogout: () => debugPrint('Logout clicked'),
onLogout: () => _logout(context),
),
body: Padding(
padding: const EdgeInsets.only(left: 200, top: 40, right: 200),
@@ -98,12 +101,12 @@ class PageSelector extends StatelessWidget {
PayoutSidebar(
selected: provider.selected,
onSelected: provider.selectPage,
onLogout: () => debugPrint('Logout clicked'),
onLogout: () => _logout(context),
),
Expanded(child: content),
],
),
),
);
}
});
}

View File

@@ -8,6 +8,7 @@ import Foundation
import amplitude_flutter
import file_selector_macos
import flutter_timezone
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AmplitudeFlutterPlugin.register(with: registry.registrar(forPlugin: "AmplitudeFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))