Compare commits
11 Commits
5d443230f4
...
SEND024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5447433b5d | ||
| dedde76dd7 | |||
|
|
9e747e7251 | ||
| 33647a0f3d | |||
|
|
890f78a42e | ||
| c0ba167f69 | |||
|
|
3aa5d56cc3 | ||
| 326fc5a885 | |||
|
|
43edbc109d | ||
| 12700c5595 | |||
|
|
4da9e0b522 |
@@ -15,3 +15,6 @@ messaging:
|
||||
broker_name: Discovery Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
|
||||
registry:
|
||||
kv_ttl_seconds: 3600
|
||||
|
||||
@@ -16,12 +16,17 @@ type config struct {
|
||||
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||
Messaging *msg.Config `yaml:"messaging"`
|
||||
Metrics *metricsConfig `yaml:"metrics"`
|
||||
Registry *registryConfig `yaml:"registry"`
|
||||
}
|
||||
|
||||
type metricsConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
type registryConfig struct {
|
||||
KVTTLSeconds *int `yaml:"kv_ttl_seconds"`
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/discovery/internal/appversion"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -23,7 +25,16 @@ func (i *Imp) startDiscovery(cfg *config) error {
|
||||
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
||||
|
||||
registry := discovery.NewRegistry()
|
||||
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery))
|
||||
var registryOpts []discovery.RegistryOption
|
||||
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
|
||||
ttlSeconds := *cfg.Registry.KVTTLSeconds
|
||||
if ttlSeconds < 0 {
|
||||
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
|
||||
ttlSeconds = 0
|
||||
}
|
||||
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
|
||||
}
|
||||
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery), registryOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -373,6 +373,20 @@ func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWall
|
||||
if asset.GetTokenSymbol() == "" {
|
||||
asset.TokenSymbol = strings.TrimSpace(tokenFromAssetString(account.GetAsset()))
|
||||
}
|
||||
describable := account.GetDescribable()
|
||||
label := strings.TrimSpace(account.GetLabel())
|
||||
if describable == nil {
|
||||
if label != "" {
|
||||
describable = &describablev1.Describable{Name: label}
|
||||
}
|
||||
} else if strings.TrimSpace(describable.GetName()) == "" && label != "" {
|
||||
desc := strings.TrimSpace(describable.GetDescription())
|
||||
if desc == "" {
|
||||
describable = &describablev1.Describable{Name: label}
|
||||
} else {
|
||||
describable = &describablev1.Describable{Name: label, Description: &desc}
|
||||
}
|
||||
}
|
||||
return &chainv1.ManagedWallet{
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
@@ -382,9 +396,7 @@ func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWall
|
||||
Status: managedWalletStatusFromAccount(account.GetState()),
|
||||
CreatedAt: account.GetCreatedAt(),
|
||||
UpdatedAt: account.GetUpdatedAt(),
|
||||
Describable: &describablev1.Describable{
|
||||
Name: strings.TrimSpace(account.GetLabel()),
|
||||
},
|
||||
Describable: describable,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account {
|
||||
ProviderDetails: details,
|
||||
CreatedAt: wallet.GetCreatedAt(),
|
||||
UpdatedAt: wallet.GetUpdatedAt(),
|
||||
Describable: wallet.GetDescribable(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +395,7 @@ func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountSta
|
||||
|
||||
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
||||
if op == nil {
|
||||
return nil, fmt.Errorf("transfer: operation is required")
|
||||
return nil, merrors.InvalidArgument("transfer: operation is required")
|
||||
}
|
||||
if to := op.GetTo(); to != nil {
|
||||
if account := to.GetAccount(); account != nil {
|
||||
@@ -404,7 +405,7 @@ func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.Trans
|
||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("transfer: to.account or to.external is required")
|
||||
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
||||
}
|
||||
|
||||
func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money {
|
||||
@@ -451,12 +452,12 @@ func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown {
|
||||
|
||||
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("money is required")
|
||||
return nil, merrors.InvalidArgument("money is required")
|
||||
}
|
||||
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
|
||||
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
|
||||
if amount == "" || currency == "" {
|
||||
return nil, fmt.Errorf("money is required")
|
||||
return nil, merrors.InvalidArgument("money is required")
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Amount: amount,
|
||||
@@ -575,11 +576,11 @@ func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset,
|
||||
network = networkFromAssetString(assetString)
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("asset: token_symbol is required")
|
||||
return nil, merrors.InvalidArgument("asset: token_symbol is required")
|
||||
}
|
||||
chain := shared.ChainEnumFromName(network)
|
||||
if chain == chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
|
||||
return nil, fmt.Errorf("asset: network is required")
|
||||
return nil, merrors.InvalidArgument("asset: network is required")
|
||||
}
|
||||
return &chainv1.Asset{
|
||||
Chain: chain,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var (
|
||||
errHexEmpty = errors.New("hex value is empty")
|
||||
errHexInvalid = errors.New("invalid hex number")
|
||||
errHexOutOfRange = errors.New("hex number out of range")
|
||||
errHexEmpty = merrors.InvalidArgument("hex value is empty")
|
||||
errHexInvalid = merrors.InvalidArgument("invalid hex number")
|
||||
errHexOutOfRange = merrors.InvalidArgument("hex number out of range")
|
||||
)
|
||||
|
||||
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||
|
||||
@@ -458,7 +458,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
||||
|
||||
func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error {
|
||||
if svc == nil {
|
||||
return errors.New("nil service provided for callback server")
|
||||
return merrors.InvalidArgument("nil service provided for callback server")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
i.logger.Info("Monetix callback server disabled: address is empty")
|
||||
|
||||
@@ -3,7 +3,6 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -124,26 +123,26 @@ func mntxOperationParams() []*connectorv1.OperationParamSpec {
|
||||
|
||||
func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) {
|
||||
if op == nil {
|
||||
return 0, "", fmt.Errorf("payout: operation is required")
|
||||
return 0, "", merrors.InvalidArgument("payout: operation is required")
|
||||
}
|
||||
currency := currencyFromOperation(op)
|
||||
if currency == "" {
|
||||
return 0, "", fmt.Errorf("payout: currency is required")
|
||||
return 0, "", merrors.InvalidArgument("payout: currency is required")
|
||||
}
|
||||
if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 {
|
||||
return minor, currency, nil
|
||||
}
|
||||
money := op.GetMoney()
|
||||
if money == nil {
|
||||
return 0, "", fmt.Errorf("payout: money is required")
|
||||
return 0, "", merrors.InvalidArgument("payout: money is required")
|
||||
}
|
||||
amount := strings.TrimSpace(money.GetAmount())
|
||||
if amount == "" {
|
||||
return 0, "", fmt.Errorf("payout: amount is required")
|
||||
return 0, "", merrors.InvalidArgument("payout: amount is required")
|
||||
}
|
||||
dec, err := decimal.NewFromString(amount)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("payout: invalid amount")
|
||||
return 0, "", merrors.InvalidArgument("payout: invalid amount")
|
||||
}
|
||||
minor := dec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||
return minor, currency, nil
|
||||
|
||||
@@ -3,7 +3,6 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
@@ -139,7 +138,7 @@ func tgsettleOperationParams() []*connectorv1.OperationParamSpec {
|
||||
|
||||
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
||||
if op == nil {
|
||||
return nil, fmt.Errorf("transfer: operation is required")
|
||||
return nil, merrors.InvalidArgument("transfer: operation is required")
|
||||
}
|
||||
if to := op.GetTo(); to != nil {
|
||||
if account := to.GetAccount(); account != nil {
|
||||
@@ -149,7 +148,7 @@ func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.Trans
|
||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("transfer: to.account or to.external is required")
|
||||
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
||||
}
|
||||
|
||||
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
|
||||
|
||||
@@ -2,12 +2,12 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var ErrDuplicate = errors.New("payment gateway storage: duplicate record")
|
||||
var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
|
||||
|
||||
type Repository interface {
|
||||
Payments() PaymentsStore
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
@@ -196,12 +197,23 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc
|
||||
"allow_negative": req.GetAllowNegative(),
|
||||
"is_settlement": req.GetIsSettlement(),
|
||||
}
|
||||
label := ""
|
||||
if desc := req.GetDescribable(); desc != nil {
|
||||
label = strings.TrimSpace(desc.GetName())
|
||||
if desc.Description != nil {
|
||||
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||
if trimmed != "" {
|
||||
params["description"] = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(req.GetMetadata()) > 0 {
|
||||
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||
}
|
||||
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
|
||||
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||
Asset: strings.TrimSpace(req.GetCurrency()),
|
||||
Label: label,
|
||||
Params: structFromMap(params),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -469,6 +481,18 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
|
||||
if ref := account.GetRef(); ref != nil {
|
||||
accountID = strings.TrimSpace(ref.GetAccountId())
|
||||
}
|
||||
describable := account.GetDescribable()
|
||||
label := strings.TrimSpace(account.GetLabel())
|
||||
if describable == nil && label != "" {
|
||||
describable = &describablev1.Describable{Name: label}
|
||||
} else if describable != nil && strings.TrimSpace(describable.GetName()) == "" && label != "" {
|
||||
desc := strings.TrimSpace(describable.GetDescription())
|
||||
if desc == "" {
|
||||
describable = &describablev1.Describable{Name: label}
|
||||
} else {
|
||||
describable = &describablev1.Describable{Name: label, Description: &desc}
|
||||
}
|
||||
}
|
||||
return &ledgerv1.LedgerAccount{
|
||||
LedgerAccountRef: accountID,
|
||||
OrganizationRef: strings.TrimSpace(account.GetOwnerRef()),
|
||||
@@ -480,6 +504,7 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
|
||||
IsSettlement: isSettlement,
|
||||
CreatedAt: account.GetCreatedAt(),
|
||||
UpdatedAt: account.GetUpdatedAt(),
|
||||
Describable: describable,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,18 @@ package ledger
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.uber.org/zap"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -57,11 +62,19 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !req.GetIsSettlement() {
|
||||
if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
metadata := req.GetMetadata()
|
||||
if len(metadata) == 0 {
|
||||
metadata = nil
|
||||
}
|
||||
|
||||
describable := describableFromProto(req.GetDescribable())
|
||||
|
||||
account := &model.Account{
|
||||
AccountCode: accountCode,
|
||||
Currency: currency,
|
||||
@@ -71,6 +84,9 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
||||
IsSettlement: req.GetIsSettlement(),
|
||||
Metadata: metadata,
|
||||
}
|
||||
if describable != nil {
|
||||
account.Describable = *describable
|
||||
}
|
||||
account.OrganizationRef = orgRef
|
||||
|
||||
err = s.storage.Accounts().Create(ctx, account)
|
||||
@@ -204,5 +220,115 @@ func toProtoAccount(account *model.Account) *ledgerv1.LedgerAccount {
|
||||
Metadata: metadata,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Describable: describableToProto(account.Describable),
|
||||
}
|
||||
}
|
||||
|
||||
func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
|
||||
if desc == nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(desc.GetName())
|
||||
var description *string
|
||||
if desc.Description != nil {
|
||||
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||
if trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name == "" && description == nil {
|
||||
return nil
|
||||
}
|
||||
return &pmodel.Describable{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func describableToProto(desc pmodel.Describable) *describablev1.Describable {
|
||||
name := strings.TrimSpace(desc.Name)
|
||||
var description *string
|
||||
if desc.Description != nil {
|
||||
trimmed := strings.TrimSpace(*desc.Description)
|
||||
if trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name == "" && description == nil {
|
||||
return nil
|
||||
}
|
||||
return &describablev1.Describable{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) {
|
||||
if s.storage == nil || s.storage.Accounts() == nil {
|
||||
return nil, errStorageNotInitialized
|
||||
}
|
||||
normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency))
|
||||
if normalizedCurrency == "" {
|
||||
return nil, merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
|
||||
if err == nil {
|
||||
return account, nil
|
||||
}
|
||||
if !errors.Is(err, storage.ErrAccountNotFound) {
|
||||
s.logger.Warn("failed to resolve default settlement account",
|
||||
zap.Error(err),
|
||||
zap.String("organizationRef", orgRef.Hex()),
|
||||
zap.String("currency", normalizedCurrency))
|
||||
return nil, merrors.Internal("failed to resolve settlement account")
|
||||
}
|
||||
|
||||
accountCode := defaultSettlementAccountCode(normalizedCurrency)
|
||||
description := "Auto-created default settlement account"
|
||||
account = &model.Account{
|
||||
AccountCode: accountCode,
|
||||
AccountType: model.AccountTypeAsset,
|
||||
Currency: normalizedCurrency,
|
||||
Status: model.AccountStatusActive,
|
||||
AllowNegative: true,
|
||||
IsSettlement: true,
|
||||
}
|
||||
account.OrganizationRef = orgRef
|
||||
account.Name = fmt.Sprintf("Settlement %s", normalizedCurrency)
|
||||
account.Description = &description
|
||||
|
||||
if err := s.storage.Accounts().Create(ctx, account); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookupErr := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
|
||||
if lookupErr == nil && existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
s.logger.Warn("duplicate settlement account create but failed to load existing",
|
||||
zap.Error(lookupErr),
|
||||
zap.String("organizationRef", orgRef.Hex()),
|
||||
zap.String("currency", normalizedCurrency))
|
||||
return nil, merrors.Internal("failed to resolve settlement account after conflict")
|
||||
}
|
||||
s.logger.Warn("failed to create default settlement account",
|
||||
zap.Error(err),
|
||||
zap.String("organizationRef", orgRef.Hex()),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("accountCode", accountCode))
|
||||
return nil, merrors.Internal("failed to create settlement account")
|
||||
}
|
||||
|
||||
s.logger.Info("default settlement account created",
|
||||
zap.String("organizationRef", orgRef.Hex()),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("accountCode", accountCode))
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func defaultSettlementAccountCode(currency string) string {
|
||||
cleaned := strings.ToLower(strings.TrimSpace(currency))
|
||||
if cleaned == "" {
|
||||
return "asset:settlement"
|
||||
}
|
||||
return fmt.Sprintf("asset:settlement:%s", cleaned)
|
||||
}
|
||||
|
||||
@@ -16,14 +16,21 @@ import (
|
||||
)
|
||||
|
||||
type accountStoreStub struct {
|
||||
createErr error
|
||||
created []*model.Account
|
||||
existing *model.Account
|
||||
existingErr error
|
||||
createErr error
|
||||
createErrSettlement error
|
||||
created []*model.Account
|
||||
existing *model.Account
|
||||
existingErr error
|
||||
defaultSettlement *model.Account
|
||||
defaultErr error
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
|
||||
if s.createErr != nil {
|
||||
if account.IsSettlement {
|
||||
if s.createErrSettlement != nil {
|
||||
return s.createErrSettlement
|
||||
}
|
||||
} else if s.createErr != nil {
|
||||
return s.createErr
|
||||
}
|
||||
if account.GetID() == nil || account.GetID().IsZero() {
|
||||
@@ -47,6 +54,12 @@ func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Acco
|
||||
}
|
||||
|
||||
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
|
||||
if s.defaultErr != nil {
|
||||
return nil, s.defaultErr
|
||||
}
|
||||
if s.defaultSettlement != nil {
|
||||
return s.defaultSettlement, nil
|
||||
}
|
||||
return nil, storage.ErrAccountNotFound
|
||||
}
|
||||
|
||||
@@ -104,6 +117,47 @@ func TestCreateAccountResponder_Success(t *testing.T) {
|
||||
require.Len(t, accountStore.created, 1)
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
accountStore := &accountStoreStub{}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
storage: &repositoryStub{accounts: accountStore},
|
||||
}
|
||||
|
||||
req := &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
AccountCode: "liability:customer:1",
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY,
|
||||
Currency: "usd",
|
||||
}
|
||||
|
||||
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Account)
|
||||
require.Len(t, accountStore.created, 2)
|
||||
|
||||
var settlement *model.Account
|
||||
var created *model.Account
|
||||
for _, acc := range accountStore.created {
|
||||
if acc.IsSettlement {
|
||||
settlement = acc
|
||||
}
|
||||
if acc.AccountCode == "liability:customer:1" {
|
||||
created = acc
|
||||
}
|
||||
}
|
||||
require.NotNil(t, settlement)
|
||||
require.NotNil(t, created)
|
||||
require.Equal(t, defaultSettlementAccountCode("USD"), settlement.AccountCode)
|
||||
require.Equal(t, model.AccountTypeAsset, settlement.AccountType)
|
||||
require.Equal(t, "USD", settlement.Currency)
|
||||
require.True(t, settlement.AllowNegative)
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/ledger/internal/appversion"
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
@@ -76,6 +77,7 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
|
||||
|
||||
status := parseLedgerAccountStatus(reader, "status")
|
||||
metadata := mergeMetadata(reader.StringMap("metadata"), req.GetLabel(), req.GetOwnerRef(), req.GetCorrelationId(), req.GetParentIntentId())
|
||||
describable := describableFromLabel(req.GetLabel(), reader.String("description"))
|
||||
|
||||
resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: orgRef,
|
||||
@@ -86,6 +88,7 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
|
||||
AllowNegative: reader.Bool("allow_negative"),
|
||||
IsSettlement: reader.Bool("is_settlement"),
|
||||
Metadata: metadata,
|
||||
Describable: describable,
|
||||
})
|
||||
if err != nil {
|
||||
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
|
||||
@@ -340,6 +343,7 @@ func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Acco
|
||||
"allow_negative": account.GetAllowNegative(),
|
||||
"is_settlement": account.GetIsSettlement(),
|
||||
})
|
||||
describable := ledgerAccountDescribable(account)
|
||||
return &connectorv1.Account{
|
||||
Ref: &connectorv1.AccountRef{
|
||||
ConnectorId: ledgerConnectorID,
|
||||
@@ -353,6 +357,7 @@ func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Acco
|
||||
ProviderDetails: details,
|
||||
CreatedAt: account.GetCreatedAt(),
|
||||
UpdatedAt: account.GetUpdatedAt(),
|
||||
Describable: describable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +372,71 @@ func ledgerAccountState(status ledgerv1.AccountStatus) connectorv1.AccountState
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerAccountDescribable(account *ledgerv1.LedgerAccount) *describablev1.Describable {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
if desc := cleanedDescribable(account.GetDescribable()); desc != nil {
|
||||
return desc
|
||||
}
|
||||
metadata := account.GetMetadata()
|
||||
name := ""
|
||||
if metadata != nil {
|
||||
if v := strings.TrimSpace(metadata["name"]); v != "" {
|
||||
name = v
|
||||
} else if v := strings.TrimSpace(metadata["label"]); v != "" {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(account.GetAccountCode())
|
||||
}
|
||||
desc := ""
|
||||
if metadata != nil {
|
||||
desc = strings.TrimSpace(metadata["description"])
|
||||
}
|
||||
if name == "" && desc == "" {
|
||||
return nil
|
||||
}
|
||||
if desc == "" {
|
||||
return &describablev1.Describable{Name: name}
|
||||
}
|
||||
return &describablev1.Describable{Name: name, Description: &desc}
|
||||
}
|
||||
|
||||
func describableFromLabel(label, description string) *describablev1.Describable {
|
||||
label = strings.TrimSpace(label)
|
||||
description = strings.TrimSpace(description)
|
||||
if label == "" && description == "" {
|
||||
return nil
|
||||
}
|
||||
if description == "" {
|
||||
return &describablev1.Describable{Name: label}
|
||||
}
|
||||
return &describablev1.Describable{Name: label, Description: &description}
|
||||
}
|
||||
|
||||
func cleanedDescribable(desc *describablev1.Describable) *describablev1.Describable {
|
||||
if desc == nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(desc.GetName())
|
||||
var description *string
|
||||
if desc.Description != nil {
|
||||
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||
if trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name == "" && description == nil {
|
||||
return nil
|
||||
}
|
||||
return &describablev1.Describable{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerReceipt(ref string, status connectorv1.OperationStatus) *connectorv1.OperationReceipt {
|
||||
return &connectorv1.OperationReceipt{
|
||||
OperationId: strings.TrimSpace(ref),
|
||||
@@ -454,7 +524,7 @@ func operationAccountID(party *connectorv1.OperationParty) string {
|
||||
func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountType, error) {
|
||||
value, ok := reader.Value(key)
|
||||
if !ok {
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: account_type is required")
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: account_type is required")
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
@@ -466,7 +536,7 @@ func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountT
|
||||
case int64:
|
||||
return ledgerv1.AccountType(v), nil
|
||||
default:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: account_type is required")
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: account_type is required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +551,7 @@ func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) {
|
||||
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil
|
||||
default:
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: invalid account_type")
|
||||
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: invalid account_type")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,15 +588,15 @@ func parseLedgerCharges(reader params.Reader) ([]*ledgerv1.PostingLine, error) {
|
||||
for i, item := range items {
|
||||
raw, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("charges[%d]: invalid charge entry", i)
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: invalid charge entry", i))
|
||||
}
|
||||
accountRef := strings.TrimSpace(fmt.Sprint(raw["ledger_account_ref"]))
|
||||
if accountRef == "" {
|
||||
return nil, fmt.Errorf("charges[%d]: ledger_account_ref is required", i)
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: ledger_account_ref is required", i))
|
||||
}
|
||||
money, err := parseMoneyFromMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("charges[%d]: %w", i, err)
|
||||
return nil, merrors.InvalidArgumentWrap(err, fmt.Sprintf("charges[%d]: invalid money", i))
|
||||
}
|
||||
lineType := parseLedgerLineType(fmt.Sprint(raw["line_type"]))
|
||||
result = append(result, &ledgerv1.PostingLine{
|
||||
@@ -553,12 +623,12 @@ func parseLedgerLineType(value string) ledgerv1.LineType {
|
||||
|
||||
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("money is required")
|
||||
return nil, merrors.InvalidArgument("money is required")
|
||||
}
|
||||
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
|
||||
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
|
||||
if amount == "" || currency == "" {
|
||||
return nil, fmt.Errorf("money is required")
|
||||
return nil, merrors.InvalidArgument("money is required")
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Amount: amount,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type Account struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.PermissionBound `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
|
||||
AccountCode string `bson:"accountCode" json:"accountCode"` // e.g., "asset:cash:usd"
|
||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||
|
||||
@@ -2,7 +2,6 @@ package notificationimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -75,7 +74,7 @@ func (m *confirmationManager) Stop() {
|
||||
|
||||
func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error {
|
||||
if m == nil {
|
||||
return errors.New("confirmation manager is nil")
|
||||
return merrors.Internal("confirmation manager is nil")
|
||||
}
|
||||
if request == nil {
|
||||
return merrors.InvalidArgument("confirmation request is nil", "request")
|
||||
@@ -338,25 +337,25 @@ var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
|
||||
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, "empty", errors.New("empty reply")
|
||||
return nil, "empty", merrors.InvalidArgument("empty reply")
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
|
||||
return nil, "missing_currency", errors.New("currency is required")
|
||||
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
return nil, "missing_amount", errors.New("amount is required")
|
||||
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
return nil, "format", errors.New("reply format is invalid")
|
||||
return nil, "format", merrors.InvalidArgument("reply format is invalid")
|
||||
}
|
||||
amount := parts[0]
|
||||
currency := parts[1]
|
||||
if !amountPattern.MatchString(amount) {
|
||||
return nil, "invalid_amount", errors.New("amount format is invalid")
|
||||
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
|
||||
}
|
||||
if !currencyPattern.MatchString(currency) {
|
||||
return nil, "invalid_currency", errors.New("currency format is invalid")
|
||||
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
|
||||
11
api/pkg/db/chainassets/assets.go
Normal file
11
api/pkg/db/chainassets/assets.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package chainassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type DB interface {
|
||||
Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/db/chainassets"
|
||||
"github.com/tech/sendico/pkg/db/confirmation"
|
||||
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
|
||||
"github.com/tech/sendico/pkg/db/invitation"
|
||||
@@ -22,6 +23,8 @@ type Factory interface {
|
||||
NewRefreshTokensDB() (refreshtokens.DB, error)
|
||||
NewConfirmationsDB() (confirmation.DB, error)
|
||||
|
||||
NewChainAsstesDB() (chainassets.DB, error)
|
||||
|
||||
NewAccountDB() (account.DB, error)
|
||||
NewOrganizationDB() (organization.DB, error)
|
||||
NewInvitationsDB() (invitation.DB, error)
|
||||
|
||||
73
api/pkg/db/internal/mongo/chainassetsdb/db.go
Normal file
73
api/pkg/db/internal/mongo/chainassetsdb/db.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package chainassetsdb
|
||||
|
||||
import (
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/template"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ChainAssetsDB struct {
|
||||
template.DBImp[*model.ChainAssetDescription]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
|
||||
p := &ChainAssetsDB{
|
||||
DBImp: *template.Create[*model.ChainAssetDescription](logger, mservice.ChainAssets, db),
|
||||
}
|
||||
|
||||
// 1) Canonical lookup: enforce single (chain, tokenSymbol)
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Name: "idx_chain_symbol",
|
||||
Unique: true,
|
||||
Keys: []ri.Key{
|
||||
{Field: "asset.chain", Sort: ri.Asc},
|
||||
{Field: "asset.tokenSymbol", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2) Prevent duplicate contracts inside the same chain, but only when contract exists
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Name: "idx_chain_contract_unique",
|
||||
Unique: true,
|
||||
Sparse: true,
|
||||
Keys: []ri.Key{
|
||||
{Field: "asset.chain", Sort: ri.Asc},
|
||||
{Field: "asset.contractAddress", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index (chain, contract) unique", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3) Fast contract lookup, skip docs without contractAddress (native assets)
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Name: "idx_contract_lookup",
|
||||
Sparse: true,
|
||||
Keys: []ri.Key{
|
||||
{Field: "asset.contractAddress", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index contract lookup", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4) List assets per chain
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Name: "idx_chain_list",
|
||||
Keys: []ri.Key{
|
||||
{Field: "asset.chain", Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
p.Logger.Error("failed index chain list", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
18
api/pkg/db/internal/mongo/chainassetsdb/resolve.go
Normal file
18
api/pkg/db/internal/mongo/chainassetsdb/resolve.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package chainassetsdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func (db *ChainAssetsDB) Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error) {
|
||||
var assetDescription model.ChainAssetDescription
|
||||
assetField := repository.Field("asset")
|
||||
q := repository.Query().And(
|
||||
repository.Query().Filter(assetField.Dot("chain"), chainAsset.Chain),
|
||||
repository.Query().Filter(assetField.Dot("tokenSymbol"), chainAsset.TokenSymbol),
|
||||
)
|
||||
return &assetDescription, db.DBImp.FindOne(ctx, q, &assetDescription)
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/db/chainassets"
|
||||
"github.com/tech/sendico/pkg/db/confirmation"
|
||||
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb"
|
||||
"github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb"
|
||||
"github.com/tech/sendico/pkg/db/internal/mongo/confirmationdb"
|
||||
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
|
||||
"github.com/tech/sendico/pkg/db/internal/mongo/organizationdb"
|
||||
@@ -312,6 +314,10 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (db *DB) NewChainAsstesDB() (chainassets.DB, error) {
|
||||
return chainassetsdb.Create(db.logger, db.db())
|
||||
}
|
||||
|
||||
func (db *DB) Permissions() auth.Provider {
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
|
||||
if def.PartialFilter != nil {
|
||||
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
|
||||
}
|
||||
if def.Sparse {
|
||||
opts.SetSparse(def.Sparse)
|
||||
}
|
||||
|
||||
_, err := r.collection.Indexes().CreateOne(
|
||||
context.Background(),
|
||||
|
||||
@@ -18,6 +18,7 @@ type Key struct {
|
||||
type Definition struct {
|
||||
Keys []Key // mandatory, at least one element
|
||||
Unique bool // unique constraint?
|
||||
Sparse bool // sparse?
|
||||
TTL *int32 // seconds; nil means “no TTL”
|
||||
Name string // optional explicit name
|
||||
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
|
||||
|
||||
@@ -25,9 +25,10 @@ type Announcer struct {
|
||||
}
|
||||
|
||||
func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer {
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery")
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("discovery")
|
||||
announce = normalizeAnnouncement(announce)
|
||||
if announce.Service == "" {
|
||||
announce.Service = strings.TrimSpace(sender)
|
||||
@@ -132,14 +133,14 @@ func (a *Announcer) sendHeartbeat() {
|
||||
}
|
||||
|
||||
func (a *Announcer) logInfo(message string, fields ...zap.Field) {
|
||||
if a.logger == nil {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.logger.Info(message, fields...)
|
||||
}
|
||||
|
||||
func (a *Announcer) logWarn(message string, fields ...zap.Field) {
|
||||
if a.logger == nil {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.logger.Warn(message, fields...)
|
||||
|
||||
@@ -3,7 +3,6 @@ package discovery
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -29,11 +29,12 @@ type Client struct {
|
||||
|
||||
func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, sender string) (*Client, error) {
|
||||
if msgBroker == nil {
|
||||
return nil, errors.New("discovery client: broker is nil")
|
||||
return nil, merrors.InvalidArgument("discovery client: broker is nil")
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_client")
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("discovery_client")
|
||||
if producer == nil {
|
||||
producer = msgproducer.NewProducer(logger, msgBroker)
|
||||
}
|
||||
@@ -56,7 +57,7 @@ func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil {
|
||||
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil {
|
||||
client.logger.Warn("Discovery lookup consumer stopped", zap.String("event", LookupResponseEvent().ToString()), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -81,7 +82,7 @@ func (c *Client) Close() {
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
|
||||
if c == nil || c.producer == nil {
|
||||
return LookupResponse{}, errors.New("discovery client: producer not configured")
|
||||
return LookupResponse{}, merrors.Internal("discovery client: producer not configured")
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
ch := make(chan LookupResponse, 1)
|
||||
@@ -131,7 +132,7 @@ func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error
|
||||
}
|
||||
|
||||
func (c *Client) logWarn(message string, fields ...zap.Field) {
|
||||
if c == nil || c.logger == nil {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.logger.Warn(message, fields...)
|
||||
|
||||
@@ -3,21 +3,43 @@ package discovery
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultKVBucket = "discovery_registry"
|
||||
kvTTLSecondsEnv = "DISCOVERY_KV_TTL_SECONDS"
|
||||
defaultKVTTLSeconds = 3600
|
||||
)
|
||||
const DefaultKVBucket = "discovery_registry"
|
||||
|
||||
type kvStoreOptions struct {
|
||||
ttl time.Duration
|
||||
ttlSet bool
|
||||
}
|
||||
|
||||
type KVStoreOption func(*kvStoreOptions)
|
||||
|
||||
func WithKVTTL(ttl time.Duration) KVStoreOption {
|
||||
return func(opts *kvStoreOptions) {
|
||||
if opts == nil {
|
||||
return
|
||||
}
|
||||
opts.ttl = ttl
|
||||
opts.ttlSet = true
|
||||
}
|
||||
}
|
||||
|
||||
func newKVStoreOptions(opts ...KVStoreOption) kvStoreOptions {
|
||||
var options kvStoreOptions
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&options)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type KVStore struct {
|
||||
logger mlogger.Logger
|
||||
@@ -25,18 +47,20 @@ type KVStore struct {
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string) (*KVStore, error) {
|
||||
func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string, opts ...KVStoreOption) (*KVStore, error) {
|
||||
if js == nil {
|
||||
return nil, errors.New("discovery kv: jetstream is nil")
|
||||
return nil, merrors.InvalidArgument("discovery kv: jetstream is nil")
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_kv")
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("discovery_kv")
|
||||
bucket = strings.TrimSpace(bucket)
|
||||
if bucket == "" {
|
||||
bucket = DefaultKVBucket
|
||||
}
|
||||
ttl := kvTTL(logger)
|
||||
options := newKVStoreOptions(opts...)
|
||||
ttl := options.ttl
|
||||
kv, err := js.KeyValue(bucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
@@ -46,9 +70,9 @@ func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string)
|
||||
History: 1,
|
||||
TTL: ttl,
|
||||
})
|
||||
if err == nil && logger != nil {
|
||||
if err == nil {
|
||||
fields := []zap.Field{zap.String("bucket", bucket)}
|
||||
if ttl > 0 {
|
||||
if options.ttlSet {
|
||||
fields = append(fields, zap.Duration("ttl", ttl))
|
||||
}
|
||||
logger.Info("Discovery KV bucket created", fields...)
|
||||
@@ -57,7 +81,7 @@ func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if ttl > 0 {
|
||||
} else if options.ttlSet {
|
||||
ensureKVTTL(logger, js, kv, bucket, ttl)
|
||||
}
|
||||
|
||||
@@ -68,33 +92,13 @@ func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func kvTTL(logger mlogger.Logger) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(kvTTLSecondsEnv))
|
||||
if raw == "" {
|
||||
return time.Duration(defaultKVTTLSeconds) * time.Second
|
||||
}
|
||||
secs, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("Invalid discovery KV TTL seconds, using default", zap.String("env", kvTTLSecondsEnv), zap.String("value", raw), zap.Int("default_seconds", defaultKVTTLSeconds))
|
||||
}
|
||||
return time.Duration(defaultKVTTLSeconds) * time.Second
|
||||
}
|
||||
if secs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
func ensureKVTTL(logger mlogger.Logger, js nats.JetStreamContext, kv nats.KeyValue, bucket string, ttl time.Duration) {
|
||||
if kv == nil || js == nil || ttl <= 0 {
|
||||
if kv == nil || js == nil {
|
||||
return
|
||||
}
|
||||
status, err := kv.Status()
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("Failed to read discovery KV status", zap.String("bucket", bucket), zap.Error(err))
|
||||
}
|
||||
logger.Warn("Failed to read discovery KV status", zap.String("bucket", bucket), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if status.TTL() == ttl {
|
||||
@@ -103,38 +107,32 @@ func ensureKVTTL(logger mlogger.Logger, js nats.JetStreamContext, kv nats.KeyVal
|
||||
stream := "KV_" + bucket
|
||||
info, err := js.StreamInfo(stream)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("Failed to read discovery KV stream info", zap.String("bucket", bucket), zap.String("stream", stream), zap.Error(err))
|
||||
}
|
||||
logger.Warn("Failed to read discovery KV stream info", zap.String("bucket", bucket), zap.String("stream", stream), zap.Error(err))
|
||||
return
|
||||
}
|
||||
cfg := info.Config
|
||||
cfg.MaxAge = ttl
|
||||
if _, err := js.UpdateStream(&cfg); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("Failed to update discovery KV TTL", zap.String("bucket", bucket), zap.Duration("ttl", ttl), zap.Error(err))
|
||||
}
|
||||
logger.Warn("Failed to update discovery KV TTL", zap.String("bucket", bucket), zap.Duration("ttl", ttl), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("Discovery KV TTL updated", zap.String("bucket", bucket), zap.Duration("ttl", ttl))
|
||||
}
|
||||
logger.Info("Discovery KV TTL updated", zap.String("bucket", bucket), zap.Duration("ttl", ttl))
|
||||
}
|
||||
|
||||
func (s *KVStore) Put(entry RegistryEntry) error {
|
||||
if s == nil || s.kv == nil {
|
||||
return errors.New("discovery kv: not configured")
|
||||
return merrors.Internal("discovery kv: not configured")
|
||||
}
|
||||
key := registryEntryKey(normalizeEntry(entry))
|
||||
if key == "" {
|
||||
return errors.New("discovery kv: entry key is empty")
|
||||
return merrors.InvalidArgument("discovery kv: entry key is empty")
|
||||
}
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.kv.Put(kvKeyFromRegistryKey(key), payload)
|
||||
if err != nil && s.logger != nil {
|
||||
if err != nil {
|
||||
fields := append(entryFields(entry), zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
|
||||
s.logger.Warn("Failed to persist discovery entry", fields...)
|
||||
}
|
||||
@@ -143,13 +141,13 @@ func (s *KVStore) Put(entry RegistryEntry) error {
|
||||
|
||||
func (s *KVStore) Delete(id string) error {
|
||||
if s == nil || s.kv == nil {
|
||||
return errors.New("discovery kv: not configured")
|
||||
return merrors.Internal("discovery kv: not configured")
|
||||
}
|
||||
key := kvKeyFromRegistryKey(id)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.kv.Delete(key); err != nil && s.logger != nil {
|
||||
if err := s.kv.Delete(key); err != nil {
|
||||
s.logger.Warn("Failed to delete discovery entry", zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
@@ -158,7 +156,7 @@ func (s *KVStore) Delete(id string) error {
|
||||
|
||||
func (s *KVStore) WatchAll() (nats.KeyWatcher, error) {
|
||||
if s == nil || s.kv == nil {
|
||||
return nil, errors.New("discovery kv: not configured")
|
||||
return nil, merrors.Internal("discovery kv: not configured")
|
||||
}
|
||||
return s.kv.WatchAll()
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ type jsonEnvelope struct {
|
||||
|
||||
func (e *jsonEnvelope) Serialize() ([]byte, error) {
|
||||
if e.payload == nil {
|
||||
return nil, errors.New("discovery envelope payload is nil")
|
||||
return nil, merrors.InvalidArgument("discovery envelope payload is nil")
|
||||
}
|
||||
data, err := json.Marshal(e.payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,12 +3,12 @@ package discovery
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
@@ -17,6 +17,17 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RegistryOption func(*RegistryService)
|
||||
|
||||
func WithRegistryKVTTL(ttl time.Duration) RegistryOption {
|
||||
return func(s *RegistryService) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.kvOptions = append(s.kvOptions, WithKVTTL(ttl))
|
||||
}
|
||||
}
|
||||
|
||||
type RegistryService struct {
|
||||
logger mlogger.Logger
|
||||
registry *Registry
|
||||
@@ -25,6 +36,7 @@ type RegistryService struct {
|
||||
consumers []consumerHandler
|
||||
kv *KVStore
|
||||
kvWatcher nats.KeyWatcher
|
||||
kvOptions []KVStoreOption
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
@@ -36,16 +48,17 @@ type consumerHandler struct {
|
||||
event string
|
||||
}
|
||||
|
||||
func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
|
||||
func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string, opts ...RegistryOption) (*RegistryService, error) {
|
||||
if msgBroker == nil {
|
||||
return nil, errors.New("discovery registry: broker is nil")
|
||||
return nil, merrors.InvalidArgument("discovery registry: broker is nil", "broker")
|
||||
}
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_registry")
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("discovery registry: no logger provided", "logger")
|
||||
}
|
||||
logger = logger.Named("discovery_registry")
|
||||
sender = strings.TrimSpace(sender)
|
||||
if sender == "" {
|
||||
sender = "discovery"
|
||||
@@ -74,6 +87,11 @@ func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg
|
||||
producer: producer,
|
||||
sender: sender,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(svc)
|
||||
}
|
||||
}
|
||||
svc.consumers = []consumerHandler{
|
||||
{consumer: serviceConsumer, event: ServiceAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error {
|
||||
return svc.handleAnnounce(ctx, env)
|
||||
@@ -103,7 +121,7 @@ func (s *RegistryService) Start() {
|
||||
for _, ch := range s.consumers {
|
||||
ch := ch
|
||||
go func() {
|
||||
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil {
|
||||
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil {
|
||||
s.logger.Warn("Discovery consumer stopped with error", zap.String("event", ch.event), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -247,7 +265,7 @@ func (s *RegistryService) initKV(msgBroker mb.Broker) {
|
||||
s.logWarn("Discovery KV disabled: JetStream not configured")
|
||||
return
|
||||
}
|
||||
store, err := NewKVStore(s.logger, js, "")
|
||||
store, err := NewKVStore(s.logger, js, "", s.kvOptions...)
|
||||
if err != nil {
|
||||
s.logWarn("Failed to initialise discovery KV store", zap.Error(err))
|
||||
return
|
||||
@@ -331,21 +349,21 @@ func (s *RegistryService) persistEntry(entry RegistryEntry) {
|
||||
}
|
||||
|
||||
func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
|
||||
if s.logger == nil {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Warn(message, fields...)
|
||||
}
|
||||
|
||||
func (s *RegistryService) logDebug(message string, fields ...zap.Field) {
|
||||
if s.logger == nil {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Debug(message, fields...)
|
||||
}
|
||||
|
||||
func (s *RegistryService) logInfo(message string, fields ...zap.Field) {
|
||||
if s.logger == nil {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Info(message, fields...)
|
||||
|
||||
@@ -2,12 +2,12 @@ package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -23,21 +23,22 @@ type RegistryWatcher struct {
|
||||
|
||||
func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Registry) (*RegistryWatcher, error) {
|
||||
if msgBroker == nil {
|
||||
return nil, errors.New("discovery watcher: broker is nil")
|
||||
return nil, merrors.InvalidArgument("discovery watcher: broker is nil")
|
||||
}
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_watcher")
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("discovery logger: logger must be provided")
|
||||
}
|
||||
logger = logger.Named("discovery_watcher")
|
||||
provider, ok := msgBroker.(jetStreamProvider)
|
||||
if !ok {
|
||||
return nil, errors.New("discovery watcher: jetstream not available")
|
||||
return nil, merrors.Internal("discovery watcher: jetstream not available")
|
||||
}
|
||||
js := provider.JetStream()
|
||||
if js == nil {
|
||||
return nil, errors.New("discovery watcher: jetstream not configured")
|
||||
return nil, merrors.Internal("discovery watcher: jetstream not configured")
|
||||
}
|
||||
store, err := NewKVStore(logger, js, "")
|
||||
if err != nil {
|
||||
@@ -53,16 +54,14 @@ func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Re
|
||||
|
||||
func (w *RegistryWatcher) Start() error {
|
||||
if w == nil || w.kv == nil {
|
||||
return errors.New("discovery watcher: not configured")
|
||||
return merrors.Internal("discovery watcher: not configured")
|
||||
}
|
||||
watcher, err := w.kv.WatchAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.watcher = watcher
|
||||
if w.logger != nil {
|
||||
w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket()))
|
||||
}
|
||||
w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket()))
|
||||
go w.consume(watcher)
|
||||
return nil
|
||||
}
|
||||
@@ -75,9 +74,7 @@ func (w *RegistryWatcher) Stop() {
|
||||
if w.watcher != nil {
|
||||
_ = w.watcher.Stop()
|
||||
}
|
||||
if w.logger != nil {
|
||||
w.logger.Info("Discovery registry watcher stopped")
|
||||
}
|
||||
w.logger.Info("Discovery registry watcher stopped")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,7 +93,7 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
|
||||
initialCount := 0
|
||||
for entry := range watcher.Updates() {
|
||||
if entry == nil {
|
||||
if initial && w.logger != nil {
|
||||
if initial {
|
||||
fields := []zap.Field{zap.Int("entries", initialCount)}
|
||||
if w.kv != nil {
|
||||
fields = append(fields, zap.String("bucket", w.kv.Bucket()))
|
||||
@@ -113,7 +110,7 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
|
||||
case nats.KeyValueDelete, nats.KeyValuePurge:
|
||||
key := registryKeyFromKVKey(entry.Key())
|
||||
if key != "" {
|
||||
if w.registry.Delete(key) && w.logger != nil {
|
||||
if w.registry.Delete(key) {
|
||||
w.logger.Info("Discovery registry entry removed", zap.String("key", key))
|
||||
}
|
||||
}
|
||||
@@ -125,13 +122,11 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
|
||||
|
||||
var payload RegistryEntry
|
||||
if err := json.Unmarshal(entry.Value(), &payload); err != nil {
|
||||
if w.logger != nil {
|
||||
w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err))
|
||||
}
|
||||
w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
result := w.registry.UpsertEntry(payload, time.Now())
|
||||
if w.logger != nil && (result.IsNew || result.BecameHealthy) {
|
||||
if result.IsNew || result.BecameHealthy {
|
||||
fields := append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))
|
||||
w.logger.Info("Discovery registry entry updated from KV", fields...)
|
||||
}
|
||||
|
||||
26
api/pkg/model/chainasset.go
Normal file
26
api/pkg/model/chainasset.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
)
|
||||
|
||||
type ChainAssetKey struct {
|
||||
Chain ChainNetwork `bson:"chain" json:"chain" yaml:"chain" mapstructure:"chain"`
|
||||
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol" yaml:"tokenSymbol" mapstructure:"tokenSymbol"`
|
||||
}
|
||||
|
||||
type ChainAsset struct {
|
||||
ChainAssetKey `bson:",inline" json:",inline"`
|
||||
ContractAddress *string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
|
||||
}
|
||||
|
||||
type ChainAssetDescription struct {
|
||||
storable.Storable `bson:",inline" json:",inline"`
|
||||
Describable `bson:",inline" json:",inline"`
|
||||
Asset ChainAsset `bson:"asset" json:"asset"`
|
||||
}
|
||||
|
||||
func Collection(*ChainAssetDescription) mservice.Type {
|
||||
return mservice.ChainAssets
|
||||
}
|
||||
11
api/pkg/model/chains.go
Normal file
11
api/pkg/model/chains.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type ChainNetwork string
|
||||
|
||||
const (
|
||||
ChainNetworkARB ChainNetwork = "arbitrum_one"
|
||||
ChainNetworkEthMain ChainNetwork = "ethereum_mainnet"
|
||||
ChainNetworkTronMain ChainNetwork = "tron_mainnet"
|
||||
ChainNetworkTronNile ChainNetwork = "tron_nile"
|
||||
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
||||
)
|
||||
@@ -5,50 +5,51 @@ import "github.com/tech/sendico/pkg/merrors"
|
||||
type Type = string
|
||||
|
||||
const (
|
||||
Accounts Type = "accounts" // Represents user accounts in the system
|
||||
Confirmations Type = "confirmations" // Represents confirmation code flows
|
||||
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
|
||||
Discovery Type = "discovery" // Represents service discovery registry
|
||||
Site Type = "site" // Represents public site endpoints
|
||||
Changes Type = "changes" // Tracks changes made to resources
|
||||
Clients Type = "clients" // Represents client information
|
||||
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
|
||||
MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice
|
||||
PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice
|
||||
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
|
||||
FeePlans Type = "fee_plans" // Represents fee plans microservice
|
||||
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
|
||||
Invitations Type = "invitations" // Represents invitations sent to users
|
||||
Invoices Type = "invoices" // Represents invoices
|
||||
Logo Type = "logo" // Represents logos for organizations or projects
|
||||
Ledger Type = "ledger" // Represents ledger microservice
|
||||
LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice
|
||||
LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice
|
||||
LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice
|
||||
LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice
|
||||
LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice
|
||||
LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice
|
||||
PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice
|
||||
ChainWallets Type = "chain_wallets" // Represents managed chain wallets
|
||||
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
|
||||
ChainTransfers Type = "chain_transfers" // Represents chain transfers
|
||||
ChainDeposits Type = "chain_deposits" // Represents chain deposits
|
||||
Notifications Type = "notifications" // Represents notifications sent to users
|
||||
Organizations Type = "organizations" // Represents organizations in the system
|
||||
Payments Type = "payments" // Represents payments service
|
||||
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
|
||||
Accounts Type = "accounts" // Represents user accounts in the system
|
||||
Confirmations Type = "confirmations" // Represents confirmation code flows
|
||||
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
|
||||
Discovery Type = "discovery" // Represents service discovery registry
|
||||
Site Type = "site" // Represents public site endpoints
|
||||
Changes Type = "changes" // Tracks changes made to resources
|
||||
Clients Type = "clients" // Represents client information
|
||||
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
|
||||
MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice
|
||||
PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice
|
||||
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
|
||||
FeePlans Type = "fee_plans" // Represents fee plans microservice
|
||||
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
|
||||
Invitations Type = "invitations" // Represents invitations sent to users
|
||||
Invoices Type = "invoices" // Represents invoices
|
||||
Logo Type = "logo" // Represents logos for organizations or projects
|
||||
Ledger Type = "ledger" // Represents ledger microservice
|
||||
LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice
|
||||
LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice
|
||||
LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice
|
||||
LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice
|
||||
LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice
|
||||
LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice
|
||||
PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice
|
||||
ChainAssets Type = "chain_assets" // Represents managed chain assets
|
||||
ChainWallets Type = "chain_wallets" // Represents managed chain wallets
|
||||
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
|
||||
ChainTransfers Type = "chain_transfers" // Represents chain transfers
|
||||
ChainDeposits Type = "chain_deposits" // Represents chain deposits
|
||||
Notifications Type = "notifications" // Represents notifications sent to users
|
||||
Organizations Type = "organizations" // Represents organizations in the system
|
||||
Payments Type = "payments" // Represents payments service
|
||||
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
|
||||
PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates
|
||||
PaymentMethods Type = "payment_methods" // Represents payment methods service
|
||||
Permissions Type = "permissions" // Represents permissiosns service
|
||||
Policies Type = "policies" // Represents access control policies
|
||||
PolicyAssignements Type = "policy_assignments" // Represents policy assignments database
|
||||
Recipients Type = "recipients" // Represents payment recipients
|
||||
RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication
|
||||
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
|
||||
PaymentMethods Type = "payment_methods" // Represents payment methods service
|
||||
Permissions Type = "permissions" // Represents permissiosns service
|
||||
Policies Type = "policies" // Represents access control policies
|
||||
PolicyAssignements Type = "policy_assignments" // Represents policy assignments database
|
||||
Recipients Type = "recipients" // Represents payment recipients
|
||||
RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication
|
||||
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
|
||||
)
|
||||
|
||||
func StringToSType(s string) (Type, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/connector/v1;connectorv1"
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "common/describable/v1/describable.proto";
|
||||
import "common/money/v1/money.proto";
|
||||
import "common/pagination/v1/cursor.proto";
|
||||
|
||||
@@ -127,10 +128,11 @@ message Account {
|
||||
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
|
||||
AccountState state = 4;
|
||||
string label = 5;
|
||||
string owner_ref = 6;
|
||||
string owner_ref = 6; // optional account_ref; empty means organization-owned
|
||||
google.protobuf.Struct provider_details = 7;
|
||||
google.protobuf.Timestamp created_at = 8;
|
||||
google.protobuf.Timestamp updated_at = 9;
|
||||
common.describable.v1.Describable describable = 10;
|
||||
}
|
||||
|
||||
message Balance {
|
||||
@@ -186,7 +188,7 @@ message OpenAccountRequest {
|
||||
AccountKind kind = 2;
|
||||
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
|
||||
string label = 4;
|
||||
string owner_ref = 5;
|
||||
string owner_ref = 5; // optional account_ref; empty means organization-owned
|
||||
google.protobuf.Struct params = 6;
|
||||
string correlation_id = 7;
|
||||
string parent_intent_id = 8;
|
||||
@@ -210,6 +212,7 @@ message ListAccountsRequest {
|
||||
AccountKind kind = 2;
|
||||
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
|
||||
common.pagination.v1.CursorPageRequest page = 4;
|
||||
string organization_ref = 5; // optional org scope (preferred over owner_ref)
|
||||
}
|
||||
|
||||
message ListAccountsResponse {
|
||||
|
||||
@@ -5,6 +5,7 @@ package ledger.v1;
|
||||
option go_package = "github.com/tech/sendico/pkg/proto/ledger/v1;ledgerv1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "common/describable/v1/describable.proto";
|
||||
import "common/money/v1/money.proto";
|
||||
|
||||
// ===== Enums =====
|
||||
@@ -55,26 +56,29 @@ message LedgerAccount {
|
||||
map<string, string> metadata = 9;
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp updated_at = 11;
|
||||
common.describable.v1.Describable describable = 12;
|
||||
}
|
||||
|
||||
// A single posting line (mirrors your PostingLine model)
|
||||
message PostingLine {
|
||||
string ledger_account_ref = 1;
|
||||
common.money.v1.Money money = 2;
|
||||
LineType line_type = 3; // MAIN, FEE, SPREAD, ...
|
||||
string ledger_account_ref = 1;
|
||||
common.money.v1.Money money = 2;
|
||||
LineType line_type = 3; // MAIN, FEE, SPREAD, ...
|
||||
}
|
||||
|
||||
// ===== Requests/Responses =====
|
||||
|
||||
message CreateAccountRequest {
|
||||
string organization_ref = 1;
|
||||
string account_code = 2;
|
||||
AccountType account_type = 3;
|
||||
string currency = 4;
|
||||
AccountStatus status = 5;
|
||||
bool allow_negative = 6;
|
||||
bool is_settlement = 7;
|
||||
map<string, string> metadata = 8;
|
||||
string owner_ref = 2;
|
||||
string account_code = 3;
|
||||
AccountType account_type = 4;
|
||||
string currency = 5;
|
||||
AccountStatus status = 6;
|
||||
bool allow_negative = 7;
|
||||
bool is_settlement = 8;
|
||||
map<string, string> metadata = 9;
|
||||
common.describable.v1.Describable describable = 10;
|
||||
}
|
||||
|
||||
message CreateAccountResponse {
|
||||
@@ -124,12 +128,12 @@ message FXRequest {
|
||||
string from_ledger_account_ref = 3;
|
||||
string to_ledger_account_ref = 4;
|
||||
|
||||
common.money.v1.Money from_money = 5; // debited
|
||||
common.money.v1.Money to_money = 6; // credited
|
||||
string rate = 7; // quoted rate as string (snapshot for audit)
|
||||
common.money.v1.Money from_money = 5; // debited
|
||||
common.money.v1.Money to_money = 6; // credited
|
||||
string rate = 7; // quoted rate as string (snapshot for audit)
|
||||
|
||||
string description = 8;
|
||||
repeated PostingLine charges = 9; // FEE/SPREAD lines
|
||||
repeated PostingLine charges = 9; // FEE/SPREAD lines
|
||||
map<string, string> metadata = 10;
|
||||
google.protobuf.Timestamp event_time = 11;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type LedgerAccountType string
|
||||
@@ -32,6 +33,8 @@ type CreateLedgerAccount struct {
|
||||
AllowNegative bool `json:"allowNegative,omitempty"`
|
||||
IsSettlement bool `json:"isSettlement,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Describable model.Describable `json:"describable"`
|
||||
IsOrgWallet bool `json:"isOrgWallet"`
|
||||
}
|
||||
|
||||
func (r *CreateLedgerAccount) Validate() error {
|
||||
|
||||
@@ -12,6 +12,8 @@ type Signup struct {
|
||||
Organization model.Describable `json:"organization"`
|
||||
OrganizationTimeZone string `json:"organizationTimeZone"`
|
||||
OwnerRole model.Describable `json:"ownerRole"`
|
||||
CryptoWallet model.Describable `json:"cryptoWallet"`
|
||||
LedgerWallet model.Describable `json:"ledgerWallet"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON enforces strict parsing to catch malformed or unexpected fields.
|
||||
|
||||
9
api/server/interface/api/srequest/wallet.go
Normal file
9
api/server/interface/api/srequest/wallet.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package srequest
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type CreateWallet struct {
|
||||
Description model.Describable `json:"description"`
|
||||
IsOrgWallet bool `json:"isOrgWallet"`
|
||||
Asset model.ChainAssetKey `json:"asset"`
|
||||
}
|
||||
45
api/server/internal/mutil/proto/chain.go
Normal file
45
api/server/internal/mutil/proto/chain.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
func Network2Proto(network model.ChainNetwork) (chainv1.ChainNetwork, error) {
|
||||
switch network {
|
||||
case model.ChainNetworkARB:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||
case model.ChainNetworkEthMain:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||
case model.ChainNetworkTronMain:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||
case model.ChainNetworkTronNile:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
||||
case model.ChainNetworkUnspecified:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
|
||||
default:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("Unkwnown chain network value '%s'", network), "network")
|
||||
}
|
||||
}
|
||||
|
||||
func Asset2Proto(asset *model.ChainAsset) (*chainv1.Asset, error) {
|
||||
if asset == nil {
|
||||
return nil, merrors.InvalidArgument("Asset must be provided", "asset")
|
||||
}
|
||||
netw, err := Network2Proto(asset.Chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contract string
|
||||
if asset.ContractAddress != nil {
|
||||
contract = *asset.ContractAddress
|
||||
}
|
||||
return &chainv1.Asset{
|
||||
Chain: netw,
|
||||
TokenSymbol: asset.TokenSymbol,
|
||||
ContractAddress: contract,
|
||||
}, nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
@@ -69,7 +70,7 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
|
||||
var sr srequest.Signup
|
||||
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
|
||||
a.logger.Warn("Failed to decode signup request", zap.Error(err))
|
||||
return response.BadRequest(a.logger, a.Name(), "", err.Error())
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
|
||||
@@ -251,8 +252,11 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
|
||||
req := &chainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
OrganizationRef: org.ID.Hex(),
|
||||
OwnerRef: org.ID.Hex(),
|
||||
Asset: a.chainAsset,
|
||||
Describable: &describablev1.Describable{
|
||||
Name: sr.CryptoWallet.Name,
|
||||
Description: sr.CryptoWallet.Description,
|
||||
},
|
||||
Asset: a.chainAsset,
|
||||
Metadata: map[string]string{
|
||||
"source": "signup",
|
||||
"login": sr.Account.Login,
|
||||
|
||||
@@ -15,14 +15,20 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errConfirmationNotFound = errors.New("confirmation not found or expired")
|
||||
errConfirmationUsed = errors.New("confirmation already used")
|
||||
errConfirmationMismatch = errors.New("confirmation code mismatch")
|
||||
errConfirmationAttemptsExceeded = errors.New("confirmation attempts exceeded")
|
||||
errConfirmationCooldown = errors.New("confirmation cooldown active")
|
||||
errConfirmationResendLimit = errors.New("confirmation resend limit reached")
|
||||
errConfirmationNotFound confirmationError = "confirmation not found or expired"
|
||||
errConfirmationUsed confirmationError = "confirmation already used"
|
||||
errConfirmationMismatch confirmationError = "confirmation code mismatch"
|
||||
errConfirmationAttemptsExceeded confirmationError = "confirmation attempts exceeded"
|
||||
errConfirmationCooldown confirmationError = "confirmation cooldown active"
|
||||
errConfirmationResendLimit confirmationError = "confirmation resend limit reached"
|
||||
)
|
||||
|
||||
type confirmationError string
|
||||
|
||||
func (e confirmationError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
type ConfirmationStore struct {
|
||||
db confirmation.DB
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package ledgerapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
@@ -24,7 +24,7 @@ func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *s
|
||||
|
||||
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"))
|
||||
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), merrors.InvalidArgument("ledger account reference is required"))
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
@@ -38,7 +38,7 @@ func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *s
|
||||
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"))
|
||||
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
|
||||
}
|
||||
|
||||
resp, err := a.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{
|
||||
|
||||
@@ -2,7 +2,6 @@ package ledgerapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
@@ -52,11 +52,32 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
|
||||
}
|
||||
|
||||
if a.client == nil {
|
||||
return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured"))
|
||||
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
|
||||
}
|
||||
|
||||
var describable *describablev1.Describable
|
||||
name := strings.TrimSpace(payload.Describable.Name)
|
||||
var description *string
|
||||
if payload.Describable.Description != nil {
|
||||
trimmed := strings.TrimSpace(*payload.Describable.Description)
|
||||
if trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name != "" || description != nil {
|
||||
describable = &describablev1.Describable{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
var ownerRef string
|
||||
if !payload.IsOrgWallet {
|
||||
ownerRef = account.ID.Hex()
|
||||
}
|
||||
|
||||
resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
OwnerRef: ownerRef,
|
||||
AccountCode: payload.AccountCode,
|
||||
AccountType: accountType,
|
||||
Currency: payload.Currency,
|
||||
@@ -64,6 +85,7 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
|
||||
AllowNegative: payload.AllowNegative,
|
||||
IsSettlement: payload.IsSettlement,
|
||||
Metadata: payload.Metadata,
|
||||
Describable: describable,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create ledger account", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
@@ -76,19 +98,28 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
|
||||
func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload := &srequest.CreateLedgerAccount{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
payload := srequest.CreateLedgerAccount{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
|
||||
}
|
||||
payload.AccountCode = strings.TrimSpace(payload.AccountCode)
|
||||
payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency))
|
||||
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
|
||||
if payload.Describable.Description != nil {
|
||||
trimmed := strings.TrimSpace(*payload.Describable.Description)
|
||||
if trimmed == "" {
|
||||
payload.Describable.Description = nil
|
||||
} else {
|
||||
payload.Describable.Description = &trimmed
|
||||
}
|
||||
}
|
||||
if len(payload.Metadata) == 0 {
|
||||
payload.Metadata = nil
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package ledgerapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
@@ -32,7 +32,7 @@ func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token
|
||||
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"))
|
||||
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
|
||||
}
|
||||
|
||||
resp, err := a.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{
|
||||
|
||||
@@ -3,13 +3,13 @@ package paymentapiimp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
@@ -21,7 +21,7 @@ const discoveryLookupTimeout = 3 * time.Second
|
||||
|
||||
func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
if a.discovery == nil {
|
||||
return response.Internal(a.logger, a.Name(), errors.New("discovery client is not configured"))
|
||||
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery client is not configured"))
|
||||
}
|
||||
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
@@ -55,7 +55,7 @@ func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Accou
|
||||
|
||||
func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
if a.refreshConsumer == nil {
|
||||
return response.Internal(a.logger, a.Name(), errors.New("discovery refresh consumer is not configured"))
|
||||
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery refresh consumer is not configured"))
|
||||
}
|
||||
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package walletapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
@@ -23,7 +23,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
}
|
||||
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"))
|
||||
return response.BadReference(a.logger, a.Name(), a.wph.Name(), a.wph.GetID(r), merrors.InvalidArgument("wallet reference is required"))
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
@@ -37,7 +37,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
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"))
|
||||
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
|
||||
}
|
||||
|
||||
resp, err := a.chainGateway.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: walletRef})
|
||||
@@ -49,7 +49,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
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 response.Auto(a.logger, mservice.ChainGateway, merrors.Internal("wallet balance not available"))
|
||||
}
|
||||
|
||||
return sresponse.WalletBalance(a.logger, bal, token)
|
||||
|
||||
98
api/server/internal/server/walletapiimp/create.go
Normal file
98
api/server/internal/server/walletapiimp/create.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package walletapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
ast "github.com/tech/sendico/server/internal/mutil/proto"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *WalletAPI) create(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)
|
||||
}
|
||||
|
||||
var sr srequest.CreateWallet
|
||||
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
|
||||
a.logger.Warn("Failed to decode wallet creation request request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r), mzap.StorableRef(account))
|
||||
return response.AccessDenied(a.logger, a.Name(), "wallets creation permission denied")
|
||||
}
|
||||
|
||||
asset, err := a.assets.Resolve(ctx, sr.Asset)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to resolve asset", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("chain", string(sr.Asset.Chain)), zap.String("token", sr.Asset.TokenSymbol))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if a.chainGateway == nil {
|
||||
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
|
||||
}
|
||||
|
||||
var ownerRef string
|
||||
if !sr.IsOrgWallet {
|
||||
ownerRef = account.ID.Hex()
|
||||
}
|
||||
passet, err := ast.Asset2Proto(&asset.Asset)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to convert asset to proto asset", zap.Error(err),
|
||||
mzap.StorableRef(asset), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
req := &chainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
OwnerRef: ownerRef,
|
||||
Describable: &describablev1.Describable{
|
||||
Name: sr.Description.Name,
|
||||
Description: sr.Description.Description,
|
||||
},
|
||||
Asset: passet,
|
||||
Metadata: map[string]string{
|
||||
"source": "create",
|
||||
"login": account.Login,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create managed wallet", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
|
||||
return response.Auto(a.logger, a.Name(), merrors.Internal("chain gateway returned empty wallet reference"))
|
||||
}
|
||||
|
||||
a.logger.Info("Managed wallet created for organization", mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", resp.Wallet.WalletRef), mzap.StorableRef(account))
|
||||
|
||||
return sresponse.Success(a.logger, token)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package walletapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
@@ -33,7 +33,7 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
|
||||
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"))
|
||||
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
|
||||
}
|
||||
|
||||
req := &chainv1.ListManagedWalletsRequest{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
chaingatewayclient "github.com/tech/sendico/gateway/chain/client"
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/chainassets"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
@@ -28,9 +29,11 @@ type WalletAPI struct {
|
||||
wph mutil.ParamHelper
|
||||
walletsPermissionRef primitive.ObjectID
|
||||
balancesPermissionRef primitive.ObjectID
|
||||
assets chainassets.DB
|
||||
}
|
||||
|
||||
type chainWalletClient interface {
|
||||
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
|
||||
ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
|
||||
GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
|
||||
Close() error
|
||||
@@ -55,6 +58,12 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
|
||||
wph: mutil.CreatePH(mservice.Wallets),
|
||||
}
|
||||
|
||||
var err error
|
||||
if p.assets, err = apiCtx.DBFactory().NewChainAsstesDB(); err != nil {
|
||||
p.logger.Warn("Failed to create asstes db", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -81,6 +90,7 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
|
||||
|
||||
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)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.create)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -12,14 +12,18 @@ part 'signup.g.dart';
|
||||
class SignupRequest {
|
||||
final AccountData account;
|
||||
final DescribableDTO organization;
|
||||
final String organizationTimeZone;
|
||||
final DescribableDTO ownerRole;
|
||||
final DescribableDTO cryptoWallet;
|
||||
final DescribableDTO ledgerWallet;
|
||||
final String organizationTimeZone;
|
||||
|
||||
const SignupRequest({
|
||||
required this.account,
|
||||
required this.organization,
|
||||
required this.organizationTimeZone,
|
||||
required this.ownerRole,
|
||||
required this.cryptoWallet,
|
||||
required this.ledgerWallet,
|
||||
});
|
||||
|
||||
factory SignupRequest.build({
|
||||
@@ -27,11 +31,15 @@ class SignupRequest {
|
||||
required Describable organization,
|
||||
required String organizationTimeZone,
|
||||
required Describable ownerRole,
|
||||
required Describable cryptoWallet,
|
||||
required Describable ledgerWallet,
|
||||
}) => SignupRequest(
|
||||
account: account,
|
||||
organization: organization.toDTO(),
|
||||
organizationTimeZone: organizationTimeZone,
|
||||
ownerRole: ownerRole.toDTO(),
|
||||
cryptoWallet: cryptoWallet.toDTO(),
|
||||
ledgerWallet: ledgerWallet.toDTO(),
|
||||
);
|
||||
|
||||
factory SignupRequest.fromJson(Map<String, dynamic> json) => _$SignupRequestFromJson(json);
|
||||
|
||||
22
frontend/pshared/lib/api/responses/invitations.dart
Normal file
22
frontend/pshared/lib/api/responses/invitations.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/api/responses/base.dart';
|
||||
import 'package:pshared/api/responses/token.dart';
|
||||
import 'package:pshared/data/dto/invitation/invitation.dart';
|
||||
|
||||
part 'invitations.g.dart';
|
||||
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class InvitationsResponse extends BaseAuthorizedResponse {
|
||||
final List<InvitationDTO> invitations;
|
||||
|
||||
const InvitationsResponse({
|
||||
required super.accessToken,
|
||||
required this.invitations,
|
||||
});
|
||||
|
||||
factory InvitationsResponse.fromJson(Map<String, dynamic> json) => _$InvitationsResponseFromJson(json);
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$InvitationsResponseToJson(this);
|
||||
}
|
||||
56
frontend/pshared/lib/data/dto/invitation/invitation.dart
Normal file
56
frontend/pshared/lib/data/dto/invitation/invitation.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/data/dto/date_time.dart';
|
||||
import 'package:pshared/data/dto/permissions/bound.dart';
|
||||
|
||||
part 'invitation.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class InvitationContentDTO {
|
||||
final String email;
|
||||
final String name;
|
||||
final String comment;
|
||||
|
||||
const InvitationContentDTO({
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.comment,
|
||||
});
|
||||
|
||||
factory InvitationContentDTO.fromJson(Map<String, dynamic> json) => _$InvitationContentDTOFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$InvitationContentDTOToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class InvitationDTO extends PermissionBoundDTO {
|
||||
final String roleRef;
|
||||
final String inviterRef;
|
||||
final String status;
|
||||
|
||||
@UtcIso8601Converter()
|
||||
final DateTime expiresAt;
|
||||
|
||||
@JsonKey(name: 'description')
|
||||
final InvitationContentDTO content;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isArchived;
|
||||
|
||||
const InvitationDTO({
|
||||
required super.id,
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
required super.permissionRef,
|
||||
required super.organizationRef,
|
||||
required this.roleRef,
|
||||
required this.inviterRef,
|
||||
required this.status,
|
||||
required this.expiresAt,
|
||||
required this.content,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
factory InvitationDTO.fromJson(Map<String, dynamic> json) => _$InvitationDTOFromJson(json);
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$InvitationDTOToJson(this);
|
||||
}
|
||||
78
frontend/pshared/lib/data/mapper/invitation/invitation.dart
Normal file
78
frontend/pshared/lib/data/mapper/invitation/invitation.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:pshared/data/dto/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
import 'package:pshared/models/organization/bound.dart';
|
||||
import 'package:pshared/models/permissions/bound.dart';
|
||||
import 'package:pshared/models/storable.dart';
|
||||
|
||||
|
||||
extension InvitationModelMapper on Invitation {
|
||||
InvitationDTO toDTO() => InvitationDTO(
|
||||
id: storable.id,
|
||||
createdAt: storable.createdAt,
|
||||
updatedAt: storable.updatedAt,
|
||||
permissionRef: permissionBound.permissionRef,
|
||||
organizationRef: permissionBound.organizationRef,
|
||||
roleRef: roleRef,
|
||||
inviterRef: inviterRef,
|
||||
status: _statusToValue(status),
|
||||
expiresAt: expiresAt,
|
||||
content: InvitationContentDTO(
|
||||
email: content.email,
|
||||
name: content.name,
|
||||
comment: content.comment,
|
||||
),
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}
|
||||
|
||||
extension InvitationDTOMapper on InvitationDTO {
|
||||
Invitation toDomain() => Invitation(
|
||||
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
|
||||
permissionBound: newPermissionBound(
|
||||
organizationBound: newOrganizationBound(organizationRef: organizationRef),
|
||||
permissionRef: permissionRef,
|
||||
),
|
||||
roleRef: roleRef,
|
||||
inviterRef: inviterRef,
|
||||
status: _statusFromValue(status),
|
||||
expiresAt: expiresAt.toUtc(),
|
||||
content: InvitationContent(
|
||||
email: content.email,
|
||||
name: content.name,
|
||||
comment: content.comment,
|
||||
),
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}
|
||||
|
||||
InvitationStatus _statusFromValue(String value) {
|
||||
switch (value) {
|
||||
case 'sent':
|
||||
return InvitationStatus.sent;
|
||||
case 'accepted':
|
||||
return InvitationStatus.accepted;
|
||||
case 'declined':
|
||||
return InvitationStatus.declined;
|
||||
case 'revoked':
|
||||
return InvitationStatus.revoked;
|
||||
case 'created':
|
||||
default:
|
||||
return InvitationStatus.created;
|
||||
}
|
||||
}
|
||||
|
||||
String _statusToValue(InvitationStatus status) {
|
||||
switch (status) {
|
||||
case InvitationStatus.sent:
|
||||
return 'sent';
|
||||
case InvitationStatus.accepted:
|
||||
return 'accepted';
|
||||
case InvitationStatus.declined:
|
||||
return 'declined';
|
||||
case InvitationStatus.revoked:
|
||||
return 'revoked';
|
||||
case InvitationStatus.created:
|
||||
return 'created';
|
||||
}
|
||||
}
|
||||
111
frontend/pshared/lib/models/invitation/invitation.dart
Normal file
111
frontend/pshared/lib/models/invitation/invitation.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:pshared/models/organization/bound.dart';
|
||||
import 'package:pshared/models/permissions/bound.dart';
|
||||
import 'package:pshared/models/permissions/bound/storable.dart';
|
||||
import 'package:pshared/models/storable.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
|
||||
|
||||
class InvitationContent {
|
||||
final String email;
|
||||
final String name;
|
||||
final String comment;
|
||||
|
||||
const InvitationContent({
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.comment,
|
||||
});
|
||||
|
||||
InvitationContent copyWith({
|
||||
String? email,
|
||||
String? name,
|
||||
String? comment,
|
||||
}) => InvitationContent(
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
comment: comment ?? this.comment,
|
||||
);
|
||||
}
|
||||
|
||||
class Invitation implements PermissionBoundStorable {
|
||||
final Storable storable;
|
||||
final PermissionBound permissionBound;
|
||||
final String roleRef;
|
||||
final String inviterRef;
|
||||
final InvitationStatus status;
|
||||
final DateTime expiresAt;
|
||||
final InvitationContent content;
|
||||
final bool isArchived;
|
||||
|
||||
Invitation({
|
||||
required this.storable,
|
||||
required this.permissionBound,
|
||||
required this.roleRef,
|
||||
required this.inviterRef,
|
||||
required this.status,
|
||||
required this.expiresAt,
|
||||
required this.content,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
String get id => storable.id;
|
||||
@override
|
||||
DateTime get createdAt => storable.createdAt;
|
||||
@override
|
||||
DateTime get updatedAt => storable.updatedAt;
|
||||
|
||||
@override
|
||||
String get permissionRef => permissionBound.permissionRef;
|
||||
@override
|
||||
String get organizationRef => permissionBound.organizationRef;
|
||||
|
||||
String get inviteeDisplayName => content.name.isNotEmpty ? content.name : content.email;
|
||||
bool get isExpired => expiresAt.isBefore(DateTime.now().toUtc());
|
||||
bool get isPending => status == InvitationStatus.created || status == InvitationStatus.sent;
|
||||
|
||||
Invitation copyWith({
|
||||
Storable? storable,
|
||||
PermissionBound? permissionBound,
|
||||
String? roleRef,
|
||||
String? inviterRef,
|
||||
InvitationStatus? status,
|
||||
DateTime? expiresAt,
|
||||
InvitationContent? content,
|
||||
bool? isArchived,
|
||||
}) => Invitation(
|
||||
storable: storable ?? this.storable,
|
||||
permissionBound: permissionBound ?? this.permissionBound,
|
||||
roleRef: roleRef ?? this.roleRef,
|
||||
inviterRef: inviterRef ?? this.inviterRef,
|
||||
status: status ?? this.status,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
content: content ?? this.content,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
);
|
||||
}
|
||||
|
||||
Invitation newInvitation({
|
||||
required String organizationRef,
|
||||
required String roleRef,
|
||||
required String inviterRef,
|
||||
required String email,
|
||||
String name = '',
|
||||
String comment = '',
|
||||
InvitationStatus status = InvitationStatus.created,
|
||||
DateTime? expiresAt,
|
||||
bool isArchived = false,
|
||||
String? permissionRef,
|
||||
}) => Invitation(
|
||||
storable: newStorable(),
|
||||
permissionBound: newPermissionBound(
|
||||
organizationBound: newOrganizationBound(organizationRef: organizationRef),
|
||||
permissionRef: permissionRef,
|
||||
),
|
||||
roleRef: roleRef,
|
||||
inviterRef: inviterRef,
|
||||
status: status,
|
||||
expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(days: 7)),
|
||||
content: InvitationContent(email: email, name: name, comment: comment),
|
||||
isArchived: isArchived,
|
||||
);
|
||||
7
frontend/pshared/lib/models/invitation/status.dart
Normal file
7
frontend/pshared/lib/models/invitation/status.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
enum InvitationStatus {
|
||||
created,
|
||||
sent,
|
||||
accepted,
|
||||
declined,
|
||||
revoked,
|
||||
}
|
||||
@@ -165,8 +165,10 @@ class AccountProvider extends ChangeNotifier {
|
||||
Future<void> signup({
|
||||
required AccountData account,
|
||||
required Describable organization,
|
||||
required String timezone,
|
||||
required Describable ownerRole,
|
||||
required Describable cryptoWallet,
|
||||
required Describable ledgerWallet,
|
||||
required String timezone,
|
||||
}) async {
|
||||
_setResource(_resource.copyWith(isLoading: true, error: null));
|
||||
try {
|
||||
@@ -176,6 +178,8 @@ class AccountProvider extends ChangeNotifier {
|
||||
organization: organization,
|
||||
organizationTimeZone: timezone,
|
||||
ownerRole: ownerRole,
|
||||
cryptoWallet: cryptoWallet,
|
||||
ledgerWallet: ledgerWallet,
|
||||
),
|
||||
);
|
||||
// Signup might not automatically log in the user,
|
||||
|
||||
64
frontend/pshared/lib/provider/invitations.dart
Normal file
64
frontend/pshared/lib/provider/invitations.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:pshared/data/mapper/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/template.dart';
|
||||
import 'package:pshared/service/invitation/service.dart';
|
||||
|
||||
|
||||
class InvitationsProvider extends GenericProvider<Invitation> {
|
||||
InvitationsProvider() : super(service: InvitationService.basicService);
|
||||
|
||||
late OrganizationsProvider _organizations;
|
||||
String? _loadedOrganizationId;
|
||||
|
||||
List<Invitation> get invitations => List<Invitation>.unmodifiable(items);
|
||||
|
||||
void updateProviders(OrganizationsProvider organizations) {
|
||||
_organizations = organizations;
|
||||
if (_organizations.isOrganizationSet) {
|
||||
final organizationId = _organizations.current.id;
|
||||
if (_loadedOrganizationId != organizationId) {
|
||||
_loadedOrganizationId = organizationId;
|
||||
load(organizationId, organizationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Invitation> sendInvitation({
|
||||
required String email,
|
||||
required String roleRef,
|
||||
required String inviterRef,
|
||||
String name = '',
|
||||
String comment = '',
|
||||
DateTime? expiresAt,
|
||||
}) async {
|
||||
final invitation = newInvitation(
|
||||
organizationRef: _organizations.current.id,
|
||||
roleRef: roleRef,
|
||||
inviterRef: inviterRef,
|
||||
email: email,
|
||||
name: name,
|
||||
comment: comment,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
|
||||
return createObject(_organizations.current.id, invitation.toDTO().toJson());
|
||||
}
|
||||
|
||||
Future<void> updateInvitation(Invitation invitation) {
|
||||
return update(invitation.toDTO().toJson());
|
||||
}
|
||||
|
||||
Future<void> revokeInvitation(Invitation invitation) {
|
||||
return updateInvitation(invitation.copyWith(status: InvitationStatus.revoked));
|
||||
}
|
||||
|
||||
Future<void> setInvitationArchived(Invitation invitation, bool archived) {
|
||||
return setArchived(
|
||||
organizationRef: _organizations.current.id,
|
||||
objectRef: invitation.id,
|
||||
newIsArchived: archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/pshared/lib/service/invitation/service.dart
Normal file
45
frontend/pshared/lib/service/invitation/service.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:pshared/api/responses/invitations.dart';
|
||||
import 'package:pshared/data/mapper/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/service/services.dart';
|
||||
import 'package:pshared/service/template.dart';
|
||||
|
||||
|
||||
class InvitationService {
|
||||
static const String _objectType = Services.invitations;
|
||||
|
||||
static final BasicService<Invitation> _basicService = BasicService<Invitation>(
|
||||
objectType: _objectType,
|
||||
fromJson: (json) => InvitationsResponse.fromJson(json).invitations.map((dto) => dto.toDomain()).toList(),
|
||||
);
|
||||
|
||||
static BasicService<Invitation> get basicService => _basicService;
|
||||
|
||||
static Future<List<Invitation>> list(String organizationRef, String parentRef) {
|
||||
return _basicService.list(organizationRef, parentRef);
|
||||
}
|
||||
|
||||
static Future<List<Invitation>> create(String organizationRef, Invitation invitation) {
|
||||
return _basicService.create(organizationRef, invitation.toDTO().toJson());
|
||||
}
|
||||
|
||||
static Future<List<Invitation>> update(Invitation invitation) {
|
||||
return _basicService.update(invitation.toDTO().toJson());
|
||||
}
|
||||
|
||||
static Future<List<Invitation>> delete(Invitation invitation) {
|
||||
return _basicService.delete(invitation.id);
|
||||
}
|
||||
|
||||
static Future<List<Invitation>> archive({
|
||||
required String organizationRef,
|
||||
required Invitation invitation,
|
||||
required bool archived,
|
||||
}) {
|
||||
return _basicService.archive(
|
||||
organizationRef: organizationRef,
|
||||
objectRef: invitation.id,
|
||||
newIsArchived: archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class PayoutRoutes {
|
||||
static const dashboard = 'dashboard';
|
||||
static const sendPayout = payment;
|
||||
static const recipients = 'payout-recipients';
|
||||
static const invitations = 'payout-invitations';
|
||||
static const addRecipient = 'payout-add-recipient';
|
||||
static const payment = 'payout-payment';
|
||||
static const settings = 'payout-settings';
|
||||
@@ -26,6 +27,7 @@ class PayoutRoutes {
|
||||
|
||||
static const dashboardPath = '/dashboard';
|
||||
static const recipientsPath = '/dashboard/recipients';
|
||||
static const invitationsPath = '/dashboard/invitations';
|
||||
static const addRecipientPath = '/dashboard/recipients/add';
|
||||
static const paymentPath = '/dashboard/payment';
|
||||
static const settingsPath = '/dashboard/settings';
|
||||
@@ -39,14 +41,16 @@ class PayoutRoutes {
|
||||
case PayoutDestination.dashboard:
|
||||
return dashboard;
|
||||
case PayoutDestination.sendPayout:
|
||||
return payment;
|
||||
case PayoutDestination.recipients:
|
||||
return recipients;
|
||||
case PayoutDestination.addrecipient:
|
||||
return addRecipient;
|
||||
case PayoutDestination.payment:
|
||||
return payment;
|
||||
case PayoutDestination.settings:
|
||||
return payment;
|
||||
case PayoutDestination.recipients:
|
||||
return recipients;
|
||||
case PayoutDestination.invitations:
|
||||
return invitations;
|
||||
case PayoutDestination.addrecipient:
|
||||
return addRecipient;
|
||||
case PayoutDestination.payment:
|
||||
return payment;
|
||||
case PayoutDestination.settings:
|
||||
return settings;
|
||||
case PayoutDestination.reports:
|
||||
return reports;
|
||||
@@ -64,14 +68,16 @@ class PayoutRoutes {
|
||||
case PayoutDestination.dashboard:
|
||||
return dashboardPath;
|
||||
case PayoutDestination.sendPayout:
|
||||
return paymentPath;
|
||||
case PayoutDestination.recipients:
|
||||
return recipientsPath;
|
||||
case PayoutDestination.addrecipient:
|
||||
return addRecipientPath;
|
||||
case PayoutDestination.payment:
|
||||
return paymentPath;
|
||||
case PayoutDestination.settings:
|
||||
return paymentPath;
|
||||
case PayoutDestination.recipients:
|
||||
return recipientsPath;
|
||||
case PayoutDestination.invitations:
|
||||
return invitationsPath;
|
||||
case PayoutDestination.addrecipient:
|
||||
return addRecipientPath;
|
||||
case PayoutDestination.payment:
|
||||
return paymentPath;
|
||||
case PayoutDestination.settings:
|
||||
return settingsPath;
|
||||
case PayoutDestination.reports:
|
||||
return reportsPath;
|
||||
@@ -89,13 +95,15 @@ class PayoutRoutes {
|
||||
case dashboard:
|
||||
return PayoutDestination.dashboard;
|
||||
case sendPayout:
|
||||
return PayoutDestination.payment;
|
||||
case recipients:
|
||||
return PayoutDestination.recipients;
|
||||
case addRecipient:
|
||||
return PayoutDestination.addrecipient;
|
||||
case settings:
|
||||
return PayoutDestination.settings;
|
||||
return PayoutDestination.payment;
|
||||
case recipients:
|
||||
return PayoutDestination.recipients;
|
||||
case invitations:
|
||||
return PayoutDestination.invitations;
|
||||
case addRecipient:
|
||||
return PayoutDestination.addrecipient;
|
||||
case settings:
|
||||
return PayoutDestination.settings;
|
||||
case reports:
|
||||
return PayoutDestination.reports;
|
||||
case methods:
|
||||
@@ -174,4 +182,4 @@ extension PayoutNavigation on BuildContext {
|
||||
PayoutRoutes.editWallet,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pweb/pages/address_book/form/page.dart';
|
||||
import 'package:pweb/pages/address_book/page/page.dart';
|
||||
import 'package:pweb/pages/dashboard/dashboard.dart';
|
||||
import 'package:pweb/pages/invitations/page.dart';
|
||||
import 'package:pweb/pages/payment_methods/page.dart';
|
||||
import 'package:pweb/pages/payout_page/page.dart';
|
||||
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
|
||||
@@ -86,6 +87,13 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: PayoutRoutes.invitations,
|
||||
path: PayoutRoutes.invitationsPath,
|
||||
pageBuilder: (_, __) => const NoTransitionPage(
|
||||
child: InvitationsPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: PayoutRoutes.addRecipient,
|
||||
path: PayoutRoutes.addRecipientPath,
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"errorVerificationTokenNotFound": "Account for verification not found. Sign up again",
|
||||
"created": "Created",
|
||||
"edited": "Edited",
|
||||
"errorDataConflict": "We can’t process your data because it has conflicting or contradictory information.",
|
||||
"errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.",
|
||||
"errorAccessDenied": "You do not have permission to access this resource. If you need access, please contact an administrator.",
|
||||
"errorBrokenPayload": "The data you sent is invalid or incomplete. Please check your submission and try again.",
|
||||
"errorInvalidArgument": "One or more arguments are invalid. Verify your input and try again.",
|
||||
@@ -66,6 +66,7 @@
|
||||
"showDetailsAction": "Show Details",
|
||||
"errorLogin": "Error logging in",
|
||||
"errorCreatingInvitation": "Failed to create invitaiton",
|
||||
"errorLoadingInvitations": "Failed to load invitations",
|
||||
"@errorCreatingInvitation": {
|
||||
"description": "Error message displayed when invitation creation fails"
|
||||
},
|
||||
@@ -93,6 +94,7 @@
|
||||
"payoutNavDashboard": "Dashboard",
|
||||
"payoutNavSendPayout": "Send payout",
|
||||
"payoutNavRecipients": "Recipients",
|
||||
"payoutNavInvitations": "Invitations",
|
||||
"payoutNavReports": "Reports",
|
||||
"payoutNavSettings": "Settings",
|
||||
"payoutNavLogout": "Logout",
|
||||
@@ -185,6 +187,47 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"invitationsTitle": "Invite your teammates",
|
||||
"invitationsSubtitle": "Send invitations for restricted employee accounts and see their status in one place.",
|
||||
"invitationCreateTitle": "New invitation",
|
||||
"invitationEmailLabel": "Work email",
|
||||
"invitationNameLabel": "Full name",
|
||||
"invitationRoleLabel": "Role",
|
||||
"invitationMessageLabel": "Message (optional)",
|
||||
"invitationExpiresIn": "Expires in {days} days",
|
||||
"@invitationExpiresIn": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invitationSendButton": "Send invitation",
|
||||
"invitationCreatedSuccess": "Invitation sent",
|
||||
"invitationSearchHint": "Search invitations",
|
||||
"invitationFilterAll": "All",
|
||||
"invitationFilterPending": "Pending",
|
||||
"invitationFilterAccepted": "Accepted",
|
||||
"invitationFilterDeclined": "Declined",
|
||||
"invitationFilterRevoked": "Revoked",
|
||||
"invitationFilterExpired": "Expired",
|
||||
"invitationFilterArchived": "Archived",
|
||||
"invitationListEmpty": "No invitations yet",
|
||||
"invitationStatusPending": "Pending",
|
||||
"invitationStatusAccepted": "Accepted",
|
||||
"invitationStatusDeclined": "Declined",
|
||||
"invitationStatusRevoked": "Revoked",
|
||||
"invitationStatusExpired": "Expired",
|
||||
"invitationExpires": "Expires {date}",
|
||||
"invitationExpired": "Expired {date}",
|
||||
"invitationInvitedBy": "Invited by",
|
||||
"invitationArchiveAction": "Archive",
|
||||
"invitationRevokeAction": "Revoke",
|
||||
"invitationArchived": "Invitation archived",
|
||||
"invitationRevoked": "Invitation revoked",
|
||||
"invitationArchiveFailed": "Could not archive the invitation",
|
||||
"invitationRevokeFailed": "Could not revoke the invitation",
|
||||
"invitationUnknownRole": "Unknown role",
|
||||
|
||||
"operationfryTitle": "Operation history",
|
||||
"@operationfryTitle": {
|
||||
@@ -474,6 +517,10 @@
|
||||
"optional": "optional",
|
||||
"ownerRole": "Organization Owner",
|
||||
"ownerRoleDescription": "This role is granted to the organization’s creator, providing full administrative privileges",
|
||||
"cryptoWallet": "Crypto",
|
||||
"cryptoWalletDesc": "TRC-20 USDT",
|
||||
"ledgerWallet": "Internal",
|
||||
"ledgerWalletDesc": "RUB wallet for settlements",
|
||||
"accountVerificationFailed": "Oops! We failed to verify your account. Please, contact support",
|
||||
"verifyAccount": "Account Verification",
|
||||
"verificationFailed": "Verification Failed",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова",
|
||||
"created": "Создано",
|
||||
"edited": "Изменено",
|
||||
"errorDataConflict": "Мы не можем обработать ваши данные, так как они содержат конфликтующую или противоречивую информацию.",
|
||||
"errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.",
|
||||
"errorAccessDenied": "У вас нет разрешения на доступ к этому ресурсу. Если вам нужен доступ, пожалуйста, обратитесь к администратору.",
|
||||
"errorBrokenPayload": "Отправленные данные недействительны или неполны. Пожалуйста, проверьте введенные данные и попробуйте снова.",
|
||||
"errorInvalidArgument": "Один или несколько аргументов недействительны. Проверьте введенные данные и попробуйте снова.",
|
||||
@@ -66,6 +66,7 @@
|
||||
"showDetailsAction": "Показать детали",
|
||||
"errorLogin": "Ошибка входа",
|
||||
"errorCreatingInvitation": "Не удалось создать приглашение",
|
||||
"errorLoadingInvitations": "Не удалось загрузить приглашения",
|
||||
"@errorCreatingInvitation": {
|
||||
"description": "Сообщение об ошибке, отображаемое при неудачном создании приглашения"
|
||||
},
|
||||
@@ -93,6 +94,7 @@
|
||||
"payoutNavDashboard": "Дашборд",
|
||||
"payoutNavSendPayout": "Отправить выплату",
|
||||
"payoutNavRecipients": "Получатели",
|
||||
"payoutNavInvitations": "Приглашения",
|
||||
"payoutNavReports": "Отчеты",
|
||||
"payoutNavSettings": "Настройки",
|
||||
"payoutNavLogout": "Выйти",
|
||||
@@ -185,6 +187,47 @@
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"back": "Назад",
|
||||
"invitationsTitle": "Пригласите сотрудников",
|
||||
"invitationsSubtitle": "Отправляйте приглашения сотрудникам с ограниченными аккаунтами и отслеживайте статусы.",
|
||||
"invitationCreateTitle": "Новое приглашение",
|
||||
"invitationEmailLabel": "Рабочий email",
|
||||
"invitationNameLabel": "Полное имя",
|
||||
"invitationRoleLabel": "Роль",
|
||||
"invitationMessageLabel": "Сообщение (необязательно)",
|
||||
"invitationExpiresIn": "Истекает через {days} дн.",
|
||||
"@invitationExpiresIn": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invitationSendButton": "Отправить приглашение",
|
||||
"invitationCreatedSuccess": "Приглашение отправлено",
|
||||
"invitationSearchHint": "Поиск приглашений",
|
||||
"invitationFilterAll": "Все",
|
||||
"invitationFilterPending": "В ожидании",
|
||||
"invitationFilterAccepted": "Принятые",
|
||||
"invitationFilterDeclined": "Отклоненные",
|
||||
"invitationFilterRevoked": "Отозванные",
|
||||
"invitationFilterExpired": "Истекшие",
|
||||
"invitationFilterArchived": "Архив",
|
||||
"invitationListEmpty": "Пока нет приглашений",
|
||||
"invitationStatusPending": "В ожидании",
|
||||
"invitationStatusAccepted": "Принято",
|
||||
"invitationStatusDeclined": "Отклонено",
|
||||
"invitationStatusRevoked": "Отозвано",
|
||||
"invitationStatusExpired": "Истекло",
|
||||
"invitationExpires": "Истекает {date}",
|
||||
"invitationExpired": "Истекло {date}",
|
||||
"invitationInvitedBy": "Пригласил",
|
||||
"invitationArchiveAction": "Архивировать",
|
||||
"invitationRevokeAction": "Отозвать",
|
||||
"invitationArchived": "Приглашение архивировано",
|
||||
"invitationRevoked": "Приглашение отозвано",
|
||||
"invitationArchiveFailed": "Не удалось архивировать приглашение",
|
||||
"invitationRevokeFailed": "Не удалось отозвать приглашение",
|
||||
"invitationUnknownRole": "Неизвестная роль",
|
||||
|
||||
"operationfryTitle": "История операций",
|
||||
"@operationfryTitle": {
|
||||
@@ -475,6 +518,10 @@
|
||||
|
||||
"ownerRole": "Владелец организации",
|
||||
"ownerRoleDescription": "Эта роль предоставляется создателю организации и даёт ему полные административные права",
|
||||
"cryptoWallet": "Крипто",
|
||||
"cryptoWalletDesc": "TRC-20 USDT",
|
||||
"ledgerWallet": "Внутренний",
|
||||
"ledgerWalletDesc": "RUB кошелек для расчетов",
|
||||
"accountVerificationFailed": "Упс! Не удалось подтвердить ваш аккаунт. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"verifyAccount": "Подтвердить аккаунт",
|
||||
"verificationFailed": "Ошибка подтверждения",
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:pshared/provider/locale.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/accounts/employees.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/provider/payment/flow.dart';
|
||||
import 'package:pshared/provider/payment/provider.dart';
|
||||
@@ -19,6 +20,7 @@ import 'package:pshared/provider/payment/quotation.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
import 'package:pshared/provider/payment/wallets.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/service/payment/wallets.dart';
|
||||
|
||||
@@ -72,6 +74,10 @@ void main() async {
|
||||
create: (_) => PermissionsProvider(),
|
||||
update: (context, orgnization, provider) => provider!..update(orgnization),
|
||||
),
|
||||
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
|
||||
create: (_) => EmployeesProvider(),
|
||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => CarouselIndexProvider()),
|
||||
|
||||
ChangeNotifierProvider(
|
||||
@@ -81,6 +87,10 @@ void main() async {
|
||||
create: (_) => RecipientsProvider(),
|
||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
||||
),
|
||||
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
|
||||
create: (_) => InvitationsProvider(),
|
||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
||||
),
|
||||
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
|
||||
create: (_) => PaymentMethodsProvider(),
|
||||
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
|
||||
|
||||
9
frontend/pweb/lib/models/invitation_filter.dart
Normal file
9
frontend/pweb/lib/models/invitation_filter.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
enum InvitationFilter {
|
||||
all,
|
||||
pending,
|
||||
accepted,
|
||||
declined,
|
||||
revoked,
|
||||
expired,
|
||||
archived,
|
||||
}
|
||||
129
frontend/pweb/lib/pages/invitations/page.dart
Normal file
129
frontend/pweb/lib/pages/invitations/page.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/resources.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/header.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/form/form.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/list/list.dart';
|
||||
import 'package:pweb/pages/loader.dart';
|
||||
import 'package:pweb/widgets/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationsPage extends StatefulWidget {
|
||||
const InvitationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<InvitationsPage> createState() => _InvitationsPageState();
|
||||
}
|
||||
|
||||
class _InvitationsPageState extends State<InvitationsPage> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
|
||||
String? _selectedRoleRef;
|
||||
int _expiryDays = 7;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_bootstrapRoleSelection();
|
||||
}
|
||||
|
||||
void _bootstrapRoleSelection() {
|
||||
final roles = context.read<PermissionsProvider>().roleDescriptions;
|
||||
if (_selectedRoleRef == null && roles.isNotEmpty) {
|
||||
_selectedRoleRef = roles.first.storable.id;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_nameController.dispose();
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendInvitation() async {
|
||||
final form = _formKey.currentState;
|
||||
if (form == null || !form.validate()) return;
|
||||
|
||||
final account = context.read<AccountProvider>().account;
|
||||
if (account == null) return;
|
||||
final permissions = context.read<PermissionsProvider>();
|
||||
final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id;
|
||||
if (roleRef == null) return;
|
||||
|
||||
final invitations = context.read<InvitationsProvider>();
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => invitations.sendInvitation(
|
||||
email: _emailController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
comment: _messageController.text.trim(),
|
||||
roleRef: roleRef,
|
||||
inviterRef: account.id,
|
||||
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
|
||||
),
|
||||
successMessage: loc.invitationCreatedSuccess,
|
||||
errorMessage: loc.errorCreatingInvitation,
|
||||
);
|
||||
|
||||
_emailController.clear();
|
||||
_nameController.clear();
|
||||
_messageController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final permissions = context.watch<PermissionsProvider>();
|
||||
|
||||
if (!permissions.canRead(ResourceType.invitations)) {
|
||||
return PageViewLoader(
|
||||
child: Center(child: Text(loc.errorAccessDenied)),
|
||||
);
|
||||
}
|
||||
|
||||
return PageViewLoader(
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InvitationsHeader(loc: loc),
|
||||
const SizedBox(height: 16),
|
||||
InvitationsForm(
|
||||
formKey: _formKey,
|
||||
emailController: _emailController,
|
||||
nameController: _nameController,
|
||||
messageController: _messageController,
|
||||
expiryDays: _expiryDays,
|
||||
onExpiryChanged: (value) => setState(() => _expiryDays = value),
|
||||
selectedRoleRef: _selectedRoleRef,
|
||||
onRoleChanged: (role) => setState(() => _selectedRoleRef = role),
|
||||
canCreate: permissions.canCreate(ResourceType.invitations),
|
||||
onSubmit: _sendInvitation,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const InvitationsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
|
||||
import 'package:pweb/widgets/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationCardActions extends StatelessWidget {
|
||||
final Invitation invitation;
|
||||
|
||||
const InvitationCardActions({super.key, required this.invitation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (invitation.isPending && !invitation.isExpired)
|
||||
TextButton.icon(
|
||||
onPressed: () => _revokeInvitation(context),
|
||||
icon: const Icon(Icons.block),
|
||||
label: Text(loc.invitationRevokeAction),
|
||||
),
|
||||
if (!invitation.isArchived)
|
||||
TextButton.icon(
|
||||
onPressed: () => _archiveInvitation(context),
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
label: Text(loc.invitationArchiveAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveInvitation(BuildContext context) async {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final provider = context.read<InvitationsProvider>();
|
||||
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => provider.setInvitationArchived(invitation, true),
|
||||
successMessage: loc.invitationArchived,
|
||||
errorMessage: loc.invitationArchiveFailed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _revokeInvitation(BuildContext context) async {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final provider = context.read<InvitationsProvider>();
|
||||
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () => provider.revokeInvitation(invitation),
|
||||
successMessage: loc.invitationRevoked,
|
||||
errorMessage: loc.invitationRevokeFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
frontend/pweb/lib/pages/invitations/widgets/card/card.dart
Normal file
15
frontend/pweb/lib/pages/invitations/widgets/card/card.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/card/view.dart';
|
||||
|
||||
|
||||
class InvitationsCard extends StatelessWidget {
|
||||
final Invitation invitation;
|
||||
|
||||
const InvitationsCard({super.key, required this.invitation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InvitationCardView(invitation: invitation);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationCardDetails extends StatelessWidget {
|
||||
final Invitation invitation;
|
||||
final String roleLabel;
|
||||
final String inviterName;
|
||||
final DateFormat dateFormat;
|
||||
final AppLocalizations loc;
|
||||
|
||||
const InvitationCardDetails({
|
||||
super.key,
|
||||
required this.invitation,
|
||||
required this.roleLabel,
|
||||
required this.inviterName,
|
||||
required this.dateFormat,
|
||||
required this.loc,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
InvitationInfoRow(
|
||||
icon: Icons.badge_outlined,
|
||||
label: loc.invitationRoleLabel,
|
||||
value: roleLabel,
|
||||
),
|
||||
InvitationInfoRow(
|
||||
icon: Icons.schedule_outlined,
|
||||
label: invitation.isExpired
|
||||
? loc.invitationExpired(dateFormat.format(invitation.expiresAt.toLocal()))
|
||||
: loc.invitationExpires(dateFormat.format(invitation.expiresAt.toLocal())),
|
||||
value: '',
|
||||
),
|
||||
InvitationInfoRow(
|
||||
icon: Icons.person_outline,
|
||||
label: loc.invitationInvitedBy,
|
||||
value: inviterName,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (invitation.content.comment.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
invitation.content.comment,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
49
frontend/pweb/lib/pages/invitations/widgets/card/header.dart
Normal file
49
frontend/pweb/lib/pages/invitations/widgets/card/header.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
|
||||
|
||||
class InvitationCardHeader extends StatelessWidget {
|
||||
final Invitation invitation;
|
||||
final String statusLabel;
|
||||
final Color statusColor;
|
||||
|
||||
const InvitationCardHeader({
|
||||
super.key,
|
||||
required this.invitation,
|
||||
required this.statusLabel,
|
||||
required this.statusColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
invitation.inviteeDisplayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
invitation.content.email,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
backgroundColor: statusColor.withAlpha(40),
|
||||
label: Text(
|
||||
statusLabel,
|
||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
import 'package:pshared/models/permissions/descriptions/role.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationInfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const InvitationInfoRow({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.hintColor),
|
||||
const SizedBox(width: 6),
|
||||
if (value.isEmpty)
|
||||
Text(label, style: theme.textTheme.bodyMedium)
|
||||
else
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: theme.textTheme.bodyMedium,
|
||||
children: [
|
||||
TextSpan(text: '$label: ', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
TextSpan(text: value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String invitationStatusLabel(Invitation invitation, AppLocalizations loc) {
|
||||
if (invitation.isExpired && invitation.isPending) {
|
||||
return loc.invitationStatusExpired;
|
||||
}
|
||||
switch (invitation.status) {
|
||||
case InvitationStatus.created:
|
||||
case InvitationStatus.sent:
|
||||
return loc.invitationStatusPending;
|
||||
case InvitationStatus.accepted:
|
||||
return loc.invitationStatusAccepted;
|
||||
case InvitationStatus.declined:
|
||||
return loc.invitationStatusDeclined;
|
||||
case InvitationStatus.revoked:
|
||||
return loc.invitationStatusRevoked;
|
||||
}
|
||||
}
|
||||
|
||||
Color invitationStatusColor(ThemeData theme, Invitation invitation) {
|
||||
if (invitation.isExpired && invitation.isPending) {
|
||||
return theme.disabledColor;
|
||||
}
|
||||
switch (invitation.status) {
|
||||
case InvitationStatus.created:
|
||||
case InvitationStatus.sent:
|
||||
return Colors.amber.shade800;
|
||||
case InvitationStatus.accepted:
|
||||
return Colors.green.shade700;
|
||||
case InvitationStatus.declined:
|
||||
case InvitationStatus.revoked:
|
||||
return Colors.red.shade700;
|
||||
}
|
||||
}
|
||||
|
||||
String invitationRoleLabel(List<RoleDescription> roles, Invitation invitation, AppLocalizations loc) {
|
||||
final role = roles.firstWhereOrNull((r) => r.storable.id == invitation.roleRef);
|
||||
return role?.describable.name ?? loc.invitationUnknownRole;
|
||||
}
|
||||
72
frontend/pweb/lib/pages/invitations/widgets/card/view.dart
Normal file
72
frontend/pweb/lib/pages/invitations/widgets/card/view.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/resources.dart';
|
||||
import 'package:pshared/provider/accounts/employees.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/card/actions.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/card/details.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/card/header.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationCardView extends StatelessWidget {
|
||||
final Invitation invitation;
|
||||
|
||||
const InvitationCardView({super.key, required this.invitation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final permissions = context.watch<PermissionsProvider>();
|
||||
final employees = context.watch<EmployeesProvider>();
|
||||
final dateFormat = DateFormat.yMMMd().add_Hm();
|
||||
|
||||
final statusLabel = invitationStatusLabel(invitation, loc);
|
||||
final statusColor = invitationStatusColor(theme, invitation);
|
||||
final roleLabel = invitationRoleLabel(permissions.roleDescriptions, invitation, loc);
|
||||
final inviterName = employees.getEmployee(invitation.inviterRef)?.fullName ?? loc.unknown;
|
||||
final canUpdate = permissions.canUpdate(ResourceType.invitations);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor.withAlpha(20)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InvitationCardHeader(
|
||||
invitation: invitation,
|
||||
statusLabel: statusLabel,
|
||||
statusColor: statusColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InvitationCardDetails(
|
||||
invitation: invitation,
|
||||
roleLabel: roleLabel,
|
||||
inviterName: inviterName,
|
||||
dateFormat: dateFormat,
|
||||
loc: loc,
|
||||
),
|
||||
if (canUpdate) ...[
|
||||
const SizedBox(height: 10),
|
||||
InvitationCardActions(invitation: invitation),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/models/invitation_filter.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationFilterChips extends StatelessWidget {
|
||||
final InvitationFilter selectedFilter;
|
||||
final ValueChanged<InvitationFilter> onSelected;
|
||||
|
||||
const InvitationFilterChips({
|
||||
super.key,
|
||||
required this.selectedFilter,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: InvitationFilter.values.map(
|
||||
(filter) => ChoiceChip(
|
||||
label: Text(invitationFilterLabel(filter, loc)),
|
||||
selected: selectedFilter == filter,
|
||||
onSelected: (_) => onSelected(filter),
|
||||
),
|
||||
).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/models/invitation/status.dart';
|
||||
|
||||
import 'package:pweb/models/invitation_filter.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
String invitationFilterLabel(InvitationFilter filter, AppLocalizations loc) {
|
||||
switch (filter) {
|
||||
case InvitationFilter.all:
|
||||
return loc.invitationFilterAll;
|
||||
case InvitationFilter.pending:
|
||||
return loc.invitationFilterPending;
|
||||
case InvitationFilter.accepted:
|
||||
return loc.invitationFilterAccepted;
|
||||
case InvitationFilter.declined:
|
||||
return loc.invitationFilterDeclined;
|
||||
case InvitationFilter.revoked:
|
||||
return loc.invitationFilterRevoked;
|
||||
case InvitationFilter.expired:
|
||||
return loc.invitationFilterExpired;
|
||||
case InvitationFilter.archived:
|
||||
return loc.invitationFilterArchived;
|
||||
}
|
||||
}
|
||||
|
||||
bool invitationFilterMatches(InvitationFilter filter, Invitation inv) {
|
||||
switch (filter) {
|
||||
case InvitationFilter.pending:
|
||||
return inv.isPending && !inv.isExpired;
|
||||
case InvitationFilter.accepted:
|
||||
return inv.status == InvitationStatus.accepted;
|
||||
case InvitationFilter.declined:
|
||||
return inv.status == InvitationStatus.declined;
|
||||
case InvitationFilter.revoked:
|
||||
return inv.status == InvitationStatus.revoked;
|
||||
case InvitationFilter.expired:
|
||||
return inv.isExpired && inv.isPending;
|
||||
case InvitationFilter.archived:
|
||||
return inv.isArchived;
|
||||
case InvitationFilter.all:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationFormActions extends StatelessWidget {
|
||||
final int expiryDays;
|
||||
final ValueChanged<int> onExpiryChanged;
|
||||
final bool canCreate;
|
||||
final bool hasRoles;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const InvitationFormActions({
|
||||
super.key,
|
||||
required this.expiryDays,
|
||||
required this.onExpiryChanged,
|
||||
required this.canCreate,
|
||||
required this.hasRoles,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Row(
|
||||
children: [
|
||||
Text(loc.invitationExpiresIn(expiryDays)),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
label: '$expiryDays',
|
||||
min: 1,
|
||||
max: 30,
|
||||
value: expiryDays.toDouble(),
|
||||
onChanged: (value) => onExpiryChanged(value.round()),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canCreate && hasRoles ? onSubmit : null,
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
label: Text(loc.invitationSendButton),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
89
frontend/pweb/lib/pages/invitations/widgets/form/fields.dart
Normal file
89
frontend/pweb/lib/pages/invitations/widgets/form/fields.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/permissions/descriptions/role.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationFormFields extends StatelessWidget {
|
||||
final List<RoleDescription> roles;
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController messageController;
|
||||
final String? selectedRoleRef;
|
||||
final ValueChanged<String?> onRoleChanged;
|
||||
|
||||
const InvitationFormFields({
|
||||
super.key,
|
||||
required this.roles,
|
||||
required this.emailController,
|
||||
required this.nameController,
|
||||
required this.messageController,
|
||||
required this.selectedRoleRef,
|
||||
required this.onRoleChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: TextFormField(
|
||||
controller: emailController,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.invitationEmailLabel,
|
||||
prefixIcon: const Icon(Icons.alternate_email_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) => (value == null || value.trim().isEmpty)
|
||||
? loc.errorEmailMissing
|
||||
: null,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.invitationNameLabel,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null),
|
||||
items: roles.map((role) => DropdownMenuItem(
|
||||
value: role.storable.id,
|
||||
child: Text(role.describable.name),
|
||||
)).toList(),
|
||||
onChanged: roles.isEmpty ? null : onRoleChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.invitationRoleLabel,
|
||||
prefixIcon: const Icon(Icons.security_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: messageController,
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.invitationMessageLabel,
|
||||
prefixIcon: const Icon(Icons.chat_bubble_outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/pweb/lib/pages/invitations/widgets/form/form.dart
Normal file
45
frontend/pweb/lib/pages/invitations/widgets/form/form.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/form/view.dart';
|
||||
|
||||
|
||||
class InvitationsForm extends StatelessWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController messageController;
|
||||
final int expiryDays;
|
||||
final ValueChanged<int> onExpiryChanged;
|
||||
final String? selectedRoleRef;
|
||||
final ValueChanged<String?> onRoleChanged;
|
||||
final bool canCreate;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const InvitationsForm({
|
||||
super.key,
|
||||
required this.formKey,
|
||||
required this.emailController,
|
||||
required this.nameController,
|
||||
required this.messageController,
|
||||
required this.expiryDays,
|
||||
required this.onExpiryChanged,
|
||||
required this.selectedRoleRef,
|
||||
required this.onRoleChanged,
|
||||
required this.canCreate,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InvitationFormView(
|
||||
formKey: formKey,
|
||||
emailController: emailController,
|
||||
nameController: nameController,
|
||||
messageController: messageController,
|
||||
expiryDays: expiryDays,
|
||||
onExpiryChanged: onExpiryChanged,
|
||||
selectedRoleRef: selectedRoleRef,
|
||||
onRoleChanged: onRoleChanged,
|
||||
canCreate: canCreate,
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
83
frontend/pweb/lib/pages/invitations/widgets/form/view.dart
Normal file
83
frontend/pweb/lib/pages/invitations/widgets/form/view.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/form/actions.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/form/fields.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationFormView extends StatelessWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController messageController;
|
||||
final int expiryDays;
|
||||
final ValueChanged<int> onExpiryChanged;
|
||||
final String? selectedRoleRef;
|
||||
final ValueChanged<String?> onRoleChanged;
|
||||
final bool canCreate;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const InvitationFormView({
|
||||
super.key,
|
||||
required this.formKey,
|
||||
required this.emailController,
|
||||
required this.nameController,
|
||||
required this.messageController,
|
||||
required this.expiryDays,
|
||||
required this.onExpiryChanged,
|
||||
required this.selectedRoleRef,
|
||||
required this.onRoleChanged,
|
||||
required this.canCreate,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final roles = context.watch<PermissionsProvider>().roleDescriptions;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.surfaceContainerHighest.withAlpha(40),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loc.invitationCreateTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InvitationFormFields(
|
||||
roles: roles,
|
||||
emailController: emailController,
|
||||
nameController: nameController,
|
||||
messageController: messageController,
|
||||
selectedRoleRef: selectedRoleRef,
|
||||
onRoleChanged: onRoleChanged,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InvitationFormActions(
|
||||
expiryDays: expiryDays,
|
||||
onExpiryChanged: onExpiryChanged,
|
||||
canCreate: canCreate,
|
||||
hasRoles: roles.isNotEmpty,
|
||||
onSubmit: onSubmit,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
frontend/pweb/lib/pages/invitations/widgets/header.dart
Normal file
26
frontend/pweb/lib/pages/invitations/widgets/header.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationsHeader extends StatelessWidget {
|
||||
final AppLocalizations loc;
|
||||
|
||||
const InvitationsHeader({super.key, required this.loc});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loc.invitationsTitle,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
loc.invitationsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
36
frontend/pweb/lib/pages/invitations/widgets/list/body.dart
Normal file
36
frontend/pweb/lib/pages/invitations/widgets/list/body.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/card/card.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationListBody extends StatelessWidget {
|
||||
final List<Invitation> invitations;
|
||||
|
||||
const InvitationListBody({super.key, required this.invitations});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
if (invitations.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(loc.invitationListEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: invitations.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => InvitationsCard(invitation: invitations[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
frontend/pweb/lib/pages/invitations/widgets/list/list.dart
Normal file
16
frontend/pweb/lib/pages/invitations/widgets/list/list.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/invitations/widgets/list/view.dart';
|
||||
|
||||
|
||||
class InvitationsList extends StatefulWidget {
|
||||
const InvitationsList({super.key});
|
||||
|
||||
@override
|
||||
State<InvitationsList> createState() => _InvitationsListState();
|
||||
}
|
||||
|
||||
class _InvitationsListState extends State<InvitationsList> {
|
||||
@override
|
||||
Widget build(BuildContext context) => const InvitationListView();
|
||||
}
|
||||
111
frontend/pweb/lib/pages/invitations/widgets/list/view.dart
Normal file
111
frontend/pweb/lib/pages/invitations/widgets/list/view.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/invitation/invitation.dart';
|
||||
import 'package:pshared/provider/invitations.dart';
|
||||
|
||||
import 'package:pweb/models/invitation_filter.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/filter/chips.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/list/body.dart';
|
||||
import 'package:pweb/pages/invitations/widgets/search_field.dart';
|
||||
import 'package:pweb/widgets/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class InvitationListView extends StatefulWidget {
|
||||
const InvitationListView({super.key});
|
||||
|
||||
@override
|
||||
State<InvitationListView> createState() => _InvitationListViewState();
|
||||
}
|
||||
|
||||
class _InvitationListViewState extends State<InvitationListView> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
InvitationFilter _filter = InvitationFilter.all;
|
||||
String _query = '';
|
||||
Object? _lastError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setQuery(String query) {
|
||||
setState(() => _query = query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
void _setFilter(InvitationFilter filter) {
|
||||
setState(() => _filter = filter);
|
||||
}
|
||||
|
||||
void _notifyError(BuildContext context, Object error, AppLocalizations loc) {
|
||||
if (identical(error, _lastError)) {
|
||||
return;
|
||||
}
|
||||
_lastError = error;
|
||||
postNotifyUserOfErrorX(
|
||||
context: context,
|
||||
errorSituation: loc.errorLoadingInvitations,
|
||||
exception: error,
|
||||
);
|
||||
}
|
||||
|
||||
List<Invitation> _filteredInvitations(List<Invitation> invitations) {
|
||||
final showArchived = _filter == InvitationFilter.archived;
|
||||
Iterable<Invitation> filtered = invitations
|
||||
.where((inv) => showArchived ? inv.isArchived : !inv.isArchived)
|
||||
.where((inv) => invitationFilterMatches(_filter, inv));
|
||||
|
||||
if (_query.isNotEmpty) {
|
||||
filtered = filtered.where((inv) {
|
||||
return inv.inviteeDisplayName.toLowerCase().contains(_query)
|
||||
|| inv.content.email.toLowerCase().contains(_query);
|
||||
});
|
||||
}
|
||||
|
||||
final sorted = filtered.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final provider = context.watch<InvitationsProvider>();
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
_notifyError(context, provider.error!, loc);
|
||||
} else {
|
||||
_lastError = null;
|
||||
}
|
||||
|
||||
final invitations = _filteredInvitations(provider.invitations);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InvitationSearchField(
|
||||
controller: _searchController,
|
||||
hintText: loc.invitationSearchHint,
|
||||
onChanged: _setQuery,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InvitationFilterChips(
|
||||
selectedFilter: _filter,
|
||||
onSelected: _setFilter,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InvitationListBody(invitations: invitations),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class InvitationSearchField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const InvitationSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
hintText: hintText,
|
||||
),
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,14 @@ class SignUpFormState extends State<SignUpForm> {
|
||||
name: locs.ownerRole,
|
||||
description: locs.ownerRoleDescription,
|
||||
),
|
||||
cryptoWallet: newDescribable(
|
||||
name: locs.cryptoWallet,
|
||||
description: locs.cryptoWalletDesc,
|
||||
),
|
||||
ledgerWallet: newDescribable(
|
||||
name: locs.ledgerWallet,
|
||||
description: locs.ledgerWalletDesc,
|
||||
),
|
||||
);
|
||||
onSignUp();
|
||||
return 'ok';
|
||||
|
||||
@@ -7,6 +7,7 @@ enum PayoutDestination {
|
||||
dashboard(Icons.dashboard_outlined, 'dashboard'),
|
||||
sendPayout(Icons.send_outlined, 'sendPayout'),
|
||||
recipients(Icons.people_outline, 'recipients'),
|
||||
invitations(Icons.mark_email_read_outlined, 'invitations'),
|
||||
reports(Icons.insert_chart, 'reports'),
|
||||
settings(Icons.settings_outlined, 'settings'),
|
||||
methods(Icons.credit_card, 'methods'),
|
||||
@@ -29,13 +30,15 @@ enum PayoutDestination {
|
||||
case PayoutDestination.sendPayout:
|
||||
return loc.payoutNavSendPayout;
|
||||
case PayoutDestination.recipients:
|
||||
return loc.payoutNavRecipients;
|
||||
return loc.payoutNavRecipients;
|
||||
case PayoutDestination.reports:
|
||||
return loc.payoutNavReports;
|
||||
case PayoutDestination.settings:
|
||||
return loc.payoutNavSettings;
|
||||
case PayoutDestination.methods:
|
||||
return loc.payoutNavMethods;
|
||||
case PayoutDestination.invitations:
|
||||
return loc.payoutNavInvitations;
|
||||
case PayoutDestination.payment:
|
||||
return loc.payout;
|
||||
case PayoutDestination.addrecipient:
|
||||
|
||||
@@ -41,6 +41,7 @@ class PageSelector extends StatelessWidget {
|
||||
PayoutDestination.methods,
|
||||
PayoutDestination.editwallet,
|
||||
PayoutDestination.walletTopUp,
|
||||
PayoutDestination.invitations,
|
||||
}
|
||||
: PayoutDestination.values.toSet();
|
||||
|
||||
@@ -103,6 +104,9 @@ class PageSelector extends StatelessWidget {
|
||||
if (location.startsWith(PayoutRoutes.recipientsPath)) {
|
||||
return PayoutDestination.recipients;
|
||||
}
|
||||
if (location.startsWith(PayoutRoutes.invitationsPath)) {
|
||||
return PayoutDestination.invitations;
|
||||
}
|
||||
if (location.startsWith(PayoutRoutes.settingsPath)) {
|
||||
return PayoutDestination.settings;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class PayoutSidebar extends StatelessWidget {
|
||||
<PayoutDestination>[
|
||||
PayoutDestination.dashboard,
|
||||
PayoutDestination.recipients,
|
||||
PayoutDestination.invitations,
|
||||
PayoutDestination.methods,
|
||||
PayoutDestination.reports,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user