accounts creation
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2026-01-23 00:13:43 +01:00
parent b677d37b99
commit 218c4e20b3
64 changed files with 1641 additions and 338 deletions

View File

@@ -7,12 +7,13 @@ import (
"strings" "strings"
"time" "time"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail" "github.com/tech/sendico/pkg/payments/rail"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" 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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
@@ -192,7 +193,6 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc
} }
params := map[string]interface{}{ params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()), "organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"account_code": strings.TrimSpace(req.GetAccountCode()),
"account_type": req.GetAccountType().String(), "account_type": req.GetAccountType().String(),
"status": req.GetStatus().String(), "status": req.GetStatus().String(),
"allow_negative": req.GetAllowNegative(), "allow_negative": req.GetAllowNegative(),
@@ -523,29 +523,13 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
} }
func parseAccountType(value string) ledgerv1.AccountType { func parseAccountType(value string) ledgerv1.AccountType {
switch strings.ToUpper(strings.TrimSpace(value)) { accountType, _ := ledgerconv.ParseAccountType(value)
case "ACCOUNT_TYPE_ASSET", "ASSET": return accountType
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
}
} }
func parseAccountStatus(value string) ledgerv1.AccountStatus { func parseAccountStatus(value string) ledgerv1.AccountStatus {
switch strings.ToUpper(strings.TrimSpace(value)) { status, _ := ledgerconv.ParseAccountStatus(value)
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": return status
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
} }
func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse { func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse {

View File

@@ -37,12 +37,6 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
return nil, err return nil, err
} }
accountCode := strings.TrimSpace(req.GetAccountCode())
if accountCode == "" {
return nil, merrors.InvalidArgument("account_code is required")
}
accountCode = strings.ToLower(accountCode)
currency := strings.TrimSpace(req.GetCurrency()) currency := strings.TrimSpace(req.GetCurrency())
if currency == "" { if currency == "" {
return nil, merrors.InvalidArgument("currency is required") return nil, merrors.InvalidArgument("currency is required")
@@ -85,7 +79,12 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
ownerRef = &ownerObjID ownerRef = &ownerObjID
} }
account := &model.Account{ const maxCreateAttempts = 3
var account *model.Account
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
accountID := primitive.NewObjectID()
accountCode := generateAccountCode(modelType, currency, accountID)
account = &model.Account{
AccountCode: accountCode, AccountCode: accountCode,
Currency: currency, Currency: currency,
AccountType: modelType, AccountType: modelType,
@@ -99,24 +98,18 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
account.Describable = *describable account.Describable = *describable
} }
account.OrganizationRef = orgRef account.OrganizationRef = orgRef
account.SetID(accountID)
err = s.storage.Accounts().Create(ctx, account) err = s.storage.Accounts().Create(ctx, account)
if err != nil { if err == nil {
if errors.Is(err, merrors.ErrDataConflict) { recordAccountOperation("create", "success")
existing, lookupErr := s.storage.Accounts().GetByAccountCode(ctx, orgRef, accountCode, currency)
if lookupErr != nil {
s.logger.Warn("duplicate account create but failed to load existing",
zap.Error(lookupErr),
mzap.ObjRef("organization_ref", orgRef),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to load existing account after conflict")
}
recordAccountOperation("create", "duplicate")
return &ledgerv1.CreateAccountResponse{ return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(existing), Account: toProtoAccount(account),
}, nil }, nil
} }
if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 {
continue
}
recordAccountOperation("create", "error") recordAccountOperation("create", "error")
s.logger.Warn("failed to create account", s.logger.Warn("failed to create account",
zap.Error(err), zap.Error(err),
@@ -125,11 +118,8 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
zap.String("currency", currency)) zap.String("currency", currency))
return nil, merrors.Internal("failed to create account") return nil, merrors.Internal("failed to create account")
} }
recordAccountOperation("create", "error")
recordAccountOperation("create", "success") return nil, merrors.Internal("failed to create account")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(account),
}, nil
} }
} }
@@ -349,3 +339,15 @@ func defaultSettlementAccountCode(currency string) string {
} }
return fmt.Sprintf("asset:settlement:%s", cleaned) return fmt.Sprintf("asset:settlement:%s", cleaned)
} }
func generateAccountCode(accountType model.AccountType, currency string, id primitive.ObjectID) string {
typePart := strings.ToLower(strings.TrimSpace(string(accountType)))
if typePart == "" {
typePart = "account"
}
currencyPart := strings.ToLower(strings.TrimSpace(currency))
if currencyPart == "" {
currencyPart = "na"
}
return fmt.Sprintf("%s:%s:%s", typePart, currencyPart, id.Hex())
}

View File

@@ -2,8 +2,8 @@ package ledger
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
@@ -23,6 +23,7 @@ type accountStoreStub struct {
existingErr error existingErr error
defaultSettlement *model.Account defaultSettlement *model.Account
defaultErr error defaultErr error
createErrs []error
} }
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error { func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
@@ -30,9 +31,17 @@ func (s *accountStoreStub) Create(_ context.Context, account *model.Account) err
if s.createErrSettlement != nil { if s.createErrSettlement != nil {
return s.createErrSettlement return s.createErrSettlement
} }
} else {
if len(s.createErrs) > 0 {
err := s.createErrs[0]
s.createErrs = s.createErrs[1:]
if err != nil {
return err
}
} else if s.createErr != nil { } else if s.createErr != nil {
return s.createErr return s.createErr
} }
}
if account.GetID() == nil || account.GetID().IsZero() { if account.GetID() == nil || account.GetID().IsZero() {
account.SetID(primitive.NewObjectID()) account.SetID(primitive.NewObjectID())
} }
@@ -94,7 +103,6 @@ func TestCreateAccountResponder_Success(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{ req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(), OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd", Currency: "usd",
AllowNegative: false, AllowNegative: false,
@@ -107,7 +115,11 @@ func TestCreateAccountResponder_Success(t *testing.T) {
require.NotNil(t, resp) require.NotNil(t, resp)
require.NotNil(t, resp.Account) require.NotNil(t, resp.Account)
require.Equal(t, "asset:cash:main", resp.Account.AccountCode) parts := strings.Split(resp.Account.AccountCode, ":")
require.Len(t, parts, 3)
require.Equal(t, "asset", parts[0])
require.Equal(t, "usd", parts[1])
require.Len(t, parts[2], 24)
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType) require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType)
require.Equal(t, "USD", resp.Account.Currency) require.Equal(t, "USD", resp.Account.Currency)
require.True(t, resp.Account.IsSettlement) require.True(t, resp.Account.IsSettlement)
@@ -129,7 +141,6 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{ req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(), OrganizationRef: orgRef.Hex(),
AccountCode: "liability:customer:1",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY,
Currency: "usd", Currency: "usd",
} }
@@ -146,40 +157,29 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
if acc.IsSettlement { if acc.IsSettlement {
settlement = acc settlement = acc
} }
if acc.AccountCode == "liability:customer:1" { if !acc.IsSettlement {
created = acc created = acc
} }
} }
require.NotNil(t, settlement) require.NotNil(t, settlement)
require.NotNil(t, created) require.NotNil(t, created)
parts := strings.Split(created.AccountCode, ":")
require.Len(t, parts, 3)
require.Equal(t, "liability", parts[0])
require.Equal(t, "usd", parts[1])
require.Len(t, parts[2], 24)
require.Equal(t, defaultSettlementAccountCode("USD"), settlement.AccountCode) require.Equal(t, defaultSettlementAccountCode("USD"), settlement.AccountCode)
require.Equal(t, model.AccountTypeAsset, settlement.AccountType) require.Equal(t, model.AccountTypeAsset, settlement.AccountType)
require.Equal(t, "USD", settlement.Currency) require.Equal(t, "USD", settlement.Currency)
require.True(t, settlement.AllowNegative) require.True(t, settlement.AllowNegative)
} }
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) { func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
t.Parallel() t.Parallel()
orgRef := primitive.NewObjectID() orgRef := primitive.NewObjectID()
existing := &model.Account{
AccountCode: "asset:cash:main",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
IsSettlement: true,
Metadata: map[string]string{"purpose": "existing"},
}
existing.OrganizationRef = orgRef
existing.SetID(primitive.NewObjectID())
existing.CreatedAt = time.Now().Add(-time.Hour).UTC()
existing.UpdatedAt = time.Now().UTC()
accountStore := &accountStoreStub{ accountStore := &accountStoreStub{
createErr: merrors.DataConflict("duplicate"), createErrs: []error{merrors.DataConflict("duplicate")},
existing: existing,
existingErr: nil,
} }
svc := &Service{ svc := &Service{
@@ -189,7 +189,6 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{ req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(), OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd", Currency: "usd",
} }
@@ -199,8 +198,15 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
require.NotNil(t, resp) require.NotNil(t, resp)
require.NotNil(t, resp.Account) require.NotNil(t, resp.Account)
require.Equal(t, existing.GetID().Hex(), resp.Account.LedgerAccountRef) require.Len(t, accountStore.created, 2)
require.Equal(t, existing.Metadata["purpose"], resp.Account.Metadata["purpose"]) var created *model.Account
for _, acc := range accountStore.created {
if !acc.IsSettlement {
created = acc
}
}
require.NotNil(t, created)
require.Equal(t, created.AccountCode, resp.Account.AccountCode)
} }
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) { func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
@@ -213,7 +219,6 @@ func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
req := &ledgerv1.CreateAccountRequest{ req := &ledgerv1.CreateAccountRequest{
OrganizationRef: primitive.NewObjectID().Hex(), OrganizationRef: primitive.NewObjectID().Hex(),
AccountCode: "asset:cash:main",
Currency: "USD", Currency: "USD",
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/ledger/internal/appversion"
"github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
@@ -57,9 +58,8 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
reader := params.New(req.GetParams()) reader := params.New(req.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref")) orgRef := strings.TrimSpace(reader.String("organization_ref"))
accountCode := strings.TrimSpace(reader.String("account_code")) if orgRef == "" {
if orgRef == "" || accountCode == "" { return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref and account_code are required", nil, "")}, nil
} }
accountType, err := parseLedgerAccountType(reader, "account_type") accountType, err := parseLedgerAccountType(reader, "account_type")
@@ -81,7 +81,6 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope
resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef, OrganizationRef: orgRef,
AccountCode: accountCode,
AccountType: accountType, AccountType: accountType,
Currency: currency, Currency: currency,
Status: status, Status: status,
@@ -312,7 +311,6 @@ func (c *connectorAdapter) ListOperations(ctx context.Context, req *connectorv1.
func ledgerOpenAccountParams() []*connectorv1.ParamSpec { func ledgerOpenAccountParams() []*connectorv1.ParamSpec {
return []*connectorv1.ParamSpec{ return []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the ledger account."}, {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the ledger account."},
{Key: "account_code", Type: connectorv1.ParamType_STRING, Required: true, Description: "Ledger account code."},
{Key: "account_type", Type: connectorv1.ParamType_STRING, Required: true, Description: "ASSET | LIABILITY | REVENUE | EXPENSE."}, {Key: "account_type", Type: connectorv1.ParamType_STRING, Required: true, Description: "ASSET | LIABILITY | REVENUE | EXPENSE."},
{Key: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."}, {Key: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."},
{Key: "allow_negative", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Allow negative balance."}, {Key: "allow_negative", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Allow negative balance."},
@@ -550,30 +548,16 @@ func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountT
} }
func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) { func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) {
switch strings.ToUpper(strings.TrimSpace(value)) { accountType, ok := ledgerconv.ParseAccountType(value)
case "ACCOUNT_TYPE_ASSET", "ASSET": if !ok || ledgerconv.IsAccountTypeUnspecified(value) {
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: invalid account_type") return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: invalid account_type")
} }
return accountType, nil
} }
func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.AccountStatus { func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.AccountStatus {
value := strings.ToUpper(strings.TrimSpace(reader.String(key))) status, _ := ledgerconv.ParseAccountStatus(reader.String(key))
switch value { return status
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
} }
func parseEventTime(reader params.Reader) *timestamppb.Timestamp { func parseEventTime(reader params.Reader) *timestamppb.Timestamp {

View File

@@ -0,0 +1,55 @@
package ledgerconv
import (
"strings"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
func ParseAccountType(value string) (ledgerv1.AccountType, bool) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ACCOUNT_TYPE_ASSET", "ASSET":
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, true
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, true
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, true
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, true
case "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED":
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, true
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, false
}
}
func ParseAccountStatus(value string) (ledgerv1.AccountStatus, bool) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, true
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN, true
case "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, true
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, false
}
}
func IsAccountTypeUnspecified(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED":
return true
default:
return false
}
}
func IsAccountStatusUnspecified(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED":
return true
default:
return false
}
}

View File

@@ -72,7 +72,8 @@ message PostingLine {
message CreateAccountRequest { message CreateAccountRequest {
string organization_ref = 1; string organization_ref = 1;
string owner_ref = 2; string owner_ref = 2;
string account_code = 3; reserved 3;
reserved "account_code";
AccountType account_type = 4; AccountType account_type = 4;
string currency = 5; string currency = 5;
AccountStatus status = 6; AccountStatus status = 6;

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
) )
type LedgerAccountType string type LedgerAccountType string
@@ -26,21 +27,16 @@ const (
) )
type CreateLedgerAccount struct { type CreateLedgerAccount struct {
AccountCode string `json:"accountCode"`
AccountType LedgerAccountType `json:"accountType"` AccountType LedgerAccountType `json:"accountType"`
Currency string `json:"currency"` Currency string `json:"currency"`
Status LedgerAccountStatus `json:"status,omitempty"`
AllowNegative bool `json:"allowNegative,omitempty"` AllowNegative bool `json:"allowNegative,omitempty"`
IsSettlement bool `json:"isSettlement,omitempty"` IsSettlement bool `json:"isSettlement,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Describable model.Describable `json:"describable"` Describable model.Describable `json:"describable"`
IsOrgWallet bool `json:"isOrgWallet"` OwnerRef *primitive.ObjectID `json:"ownerRef,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
func (r *CreateLedgerAccount) Validate() error { func (r *CreateLedgerAccount) Validate() error {
if strings.TrimSpace(r.AccountCode) == "" {
return merrors.InvalidArgument("accountCode is required", "accountCode")
}
if strings.TrimSpace(r.Currency) == "" { if strings.TrimSpace(r.Currency) == "" {
return merrors.InvalidArgument("currency is required", "currency") return merrors.InvalidArgument("currency is required", "currency")
} }

View File

@@ -1,9 +1,12 @@
package srequest package srequest
import "github.com/tech/sendico/pkg/model" import (
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type CreateWallet struct { type CreateWallet struct {
Description model.Describable `json:"description"` Description model.Describable `json:"description"`
IsOrgWallet bool `json:"isOrgWallet"`
Asset model.ChainAssetKey `json:"asset"` Asset model.ChainAssetKey `json:"asset"`
OwnerRef *primitive.ObjectID `json:"ownerRef,omitempty"`
} }

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
@@ -46,11 +47,6 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
if err != nil { if err != nil {
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)
} }
status, err := mapLedgerAccountStatus(payload.Status)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if a.client == nil { if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured")) return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
} }
@@ -71,17 +67,16 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
} }
} }
var ownerRef string var ownerRef string
if !payload.IsOrgWallet { if payload.OwnerRef != nil && !payload.OwnerRef.IsZero() {
ownerRef = account.ID.Hex() ownerRef = payload.OwnerRef.Hex()
} }
resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(), OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef, OwnerRef: ownerRef,
AccountCode: payload.AccountCode,
AccountType: accountType, AccountType: accountType,
Currency: payload.Currency, Currency: payload.Currency,
Status: status, Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
AllowNegative: payload.AllowNegative, AllowNegative: payload.AllowNegative,
IsSettlement: payload.IsSettlement, IsSettlement: payload.IsSettlement,
Metadata: payload.Metadata, Metadata: payload.Metadata,
@@ -102,7 +97,6 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
} }
payload.AccountCode = strings.TrimSpace(payload.AccountCode)
payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency)) payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency))
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name) payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
if payload.Describable.Description != nil { if payload.Describable.Description != nil {
@@ -123,31 +117,25 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc
} }
func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) { func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) {
switch strings.ToUpper(strings.TrimSpace(string(accountType))) { raw := string(accountType)
case "ACCOUNT_TYPE_ASSET", "ASSET": if ledgerconv.IsAccountTypeUnspecified(raw) {
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil
case "", "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED":
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("accountType is required", "accountType") return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("accountType is required", "accountType")
default: }
parsed, ok := ledgerconv.ParseAccountType(raw)
if !ok {
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("unsupported accountType: "+string(accountType), "accountType") return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("unsupported accountType: "+string(accountType), "accountType")
} }
return parsed, nil
} }
func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) { func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) {
switch strings.ToUpper(strings.TrimSpace(string(status))) { raw := string(status)
case "", "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED": if ledgerconv.IsAccountStatusUnspecified(raw) {
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": }
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, nil parsed, ok := ledgerconv.ParseAccountStatus(raw)
case "ACCOUNT_STATUS_FROZEN", "FROZEN": if !ok {
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN, nil
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status") return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status")
} }
return parsed, nil
} }

View File

@@ -57,8 +57,8 @@ func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresp
} }
var ownerRef string var ownerRef string
if !sr.IsOrgWallet { if sr.OwnerRef != nil && !sr.OwnerRef.IsZero() {
ownerRef = account.ID.Hex() ownerRef = sr.OwnerRef.Hex()
} }
passet, err := ast.Asset2Proto(&asset.Asset) passet, err := ast.Asset2Proto(&asset.Asset)
if err != nil { if err != nil {

View File

@@ -0,0 +1,32 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/ledger/type.dart';
part 'create.g.dart';
@JsonSerializable()
class CreateLedgerAccountRequest {
final Map<String, String>? metadata;
final String currency;
final bool allowNegative;
final bool isSettlement;
final DescribableDTO describable;
final String? ownerRef;
final LedgerAccountTypeDTO accountType;
const CreateLedgerAccountRequest({
this.metadata,
required this.currency,
required this.allowNegative,
required this.isSettlement,
required this.describable,
required this.accountType,
this.ownerRef,
});
factory CreateLedgerAccountRequest.fromJson(Map<String, dynamic> json) => _$CreateLedgerAccountRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateLedgerAccountRequestToJson(this);
}

View File

@@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'base.g.dart'; part 'base.g.dart';

View File

@@ -0,0 +1,25 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/wallet/chain_asset.dart';
part 'create.g.dart';
@JsonSerializable()
class CreateWalletRequest {
final Map<String, String>? metadata;
final DescribableDTO describable;
final String? ownerRef;
final ChainAssetDTO asset;
const CreateWalletRequest({
this.metadata,
required this.asset,
required this.describable,
this.ownerRef,
});
factory CreateWalletRequest.fromJson(Map<String, dynamic> json) => _$CreateWalletRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateWalletRequestToJson(this);
}

View File

@@ -60,22 +60,23 @@ class WalletsController with ChangeNotifier {
String? get selectedWalletRef => _selectedWalletRef; String? get selectedWalletRef => _selectedWalletRef;
void selectWallet(Wallet wallet) => selectWalletByRef(wallet.id); void selectWallet(Wallet wallet, {bool allowHidden = false}) =>
selectWalletByRef(wallet.id, allowHidden: allowHidden);
void selectWalletByRef(String walletRef) { void selectWalletByRef(String walletRef, {bool allowHidden = false}) {
if (_selectedWalletRef == walletRef) return; if (_selectedWalletRef == walletRef) return;
// Prevent selecting a hidden wallet // Prevent selecting a hidden wallet
if (!_visibleWalletRefs.contains(walletRef)) return; if (!allowHidden && !_visibleWalletRefs.contains(walletRef)) return;
_selectedWalletRef = walletRef; _selectedWalletRef = walletRef;
notifyListeners(); notifyListeners();
} }
/// Toggle wallet visibility /// Toggle wallet visibility
void toggleVisibility(String walletId) { void toggleVisibility(String accountRef) {
final existed = _visibleWalletRefs.remove(walletId); final existed = _visibleWalletRefs.remove(accountRef);
if (!existed) _visibleWalletRefs.add(walletId); if (!existed) _visibleWalletRefs.add(accountRef);
_selectedWalletRef = _resolveSelectedId( _selectedWalletRef = _resolveSelectedId(
currentRef: _selectedWalletRef, currentRef: _selectedWalletRef,

View File

@@ -2,6 +2,8 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart'; import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/dto/ledger/balance.dart'; import 'package:pshared/data/dto/ledger/balance.dart';
import 'package:pshared/data/dto/ledger/status.dart';
import 'package:pshared/data/dto/ledger/type.dart';
part 'account.g.dart'; part 'account.g.dart';
@@ -12,9 +14,9 @@ class LedgerAccountDTO {
final String organizationRef; final String organizationRef;
final String? ownerRef; final String? ownerRef;
final String accountCode; final String accountCode;
final String accountType; final LedgerAccountTypeDTO accountType;
final String currency; final String currency;
final String status; final LedgerAccountStatusDTO status;
final bool allowNegative; final bool allowNegative;
final bool isSettlement; final bool isSettlement;
final Map<String, String>? metadata; final Map<String, String>? metadata;

View File

@@ -0,0 +1,13 @@
import 'package:json_annotation/json_annotation.dart';
enum LedgerAccountStatusDTO {
@JsonValue('unspecified')
unspecified,
@JsonValue('active')
active,
@JsonValue('frozen')
frozen,
}

View File

@@ -0,0 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
enum LedgerAccountTypeDTO {
@JsonValue('unspecified')
unspecified,
@JsonValue('asset')
asset,
@JsonValue('liability')
liability,
@JsonValue('revenue')
revenue,
@JsonValue('expense')
expense,
}

View File

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

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'chain_asset.g.dart';
@JsonSerializable()
class ChainAssetDTO {
final String chain;
final String tokenSymbol;
const ChainAssetDTO({
required this.chain,
required this.tokenSymbol,
});
factory ChainAssetDTO.fromJson(Map<String, dynamic> json) => _$ChainAssetDTOFromJson(json);
Map<String, dynamic> toJson() => _$ChainAssetDTOToJson(this);
}

View File

@@ -1,6 +1,8 @@
import 'package:pshared/data/dto/ledger/account.dart'; import 'package:pshared/data/dto/ledger/account.dart';
import 'package:pshared/data/mapper/describable.dart'; import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/ledger/balance.dart'; import 'package:pshared/data/mapper/ledger/balance.dart';
import 'package:pshared/data/mapper/ledger/status.dart';
import 'package:pshared/data/mapper/ledger/type.dart';
import 'package:pshared/models/ledger/account.dart'; import 'package:pshared/models/ledger/account.dart';
@@ -10,9 +12,9 @@ extension LedgerAccountDTOMapper on LedgerAccountDTO {
organizationRef: organizationRef, organizationRef: organizationRef,
ownerRef: ownerRef, ownerRef: ownerRef,
accountCode: accountCode, accountCode: accountCode,
accountType: accountType, accountType: accountType.toDomain(),
currency: currency, currency: currency,
status: status, status: status.toDomain(),
allowNegative: allowNegative, allowNegative: allowNegative,
isSettlement: isSettlement, isSettlement: isSettlement,
metadata: metadata, metadata: metadata,
@@ -29,9 +31,9 @@ extension LedgerAccountModelMapper on LedgerAccount {
organizationRef: organizationRef, organizationRef: organizationRef,
ownerRef: ownerRef, ownerRef: ownerRef,
accountCode: accountCode, accountCode: accountCode,
accountType: accountType, accountType: accountType.toDTO(),
currency: currency, currency: currency,
status: status, status: status.toDTO(),
allowNegative: allowNegative, allowNegative: allowNegative,
isSettlement: isSettlement, isSettlement: isSettlement,
metadata: metadata, metadata: metadata,

View File

@@ -0,0 +1,29 @@
import 'package:pshared/data/dto/ledger/status.dart';
import 'package:pshared/models/ledger/status.dart';
extension LedgerAccountStatusDTOMapper on LedgerAccountStatusDTO {
LedgerAccountStatus toDomain() {
switch (this) {
case LedgerAccountStatusDTO.unspecified:
return LedgerAccountStatus.unspecified;
case LedgerAccountStatusDTO.active:
return LedgerAccountStatus.active;
case LedgerAccountStatusDTO.frozen:
return LedgerAccountStatus.frozen;
}
}
}
extension LedgerAccountStatusMapper on LedgerAccountStatus {
LedgerAccountStatusDTO toDTO() {
switch (this) {
case LedgerAccountStatus.unspecified:
return LedgerAccountStatusDTO.unspecified;
case LedgerAccountStatus.active:
return LedgerAccountStatusDTO.active;
case LedgerAccountStatus.frozen:
return LedgerAccountStatusDTO.frozen;
}
}
}

View File

@@ -0,0 +1,37 @@
import 'package:pshared/data/dto/ledger/type.dart';
import 'package:pshared/models/ledger/type.dart';
extension LedgerAccountTypeDTOMapper on LedgerAccountTypeDTO {
LedgerAccountType toDomain() {
switch (this) {
case LedgerAccountTypeDTO.unspecified:
return LedgerAccountType.unspecified;
case LedgerAccountTypeDTO.asset:
return LedgerAccountType.asset;
case LedgerAccountTypeDTO.liability:
return LedgerAccountType.liability;
case LedgerAccountTypeDTO.revenue:
return LedgerAccountType.revenue;
case LedgerAccountTypeDTO.expense:
return LedgerAccountType.expense;
}
}
}
extension LedgerAccountTypeModelMapper on LedgerAccountType {
LedgerAccountTypeDTO toDTO() {
switch (this) {
case LedgerAccountType.unspecified:
return LedgerAccountTypeDTO.unspecified;
case LedgerAccountType.asset:
return LedgerAccountTypeDTO.asset;
case LedgerAccountType.liability:
return LedgerAccountTypeDTO.liability;
case LedgerAccountType.revenue:
return LedgerAccountTypeDTO.revenue;
case LedgerAccountType.expense:
return LedgerAccountTypeDTO.expense;
}
}
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/wallet/chain_asset.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
extension ChainAssetDTOMapper on ChainAssetDTO {
ChainAsset toDomain() => ChainAsset(
chain: chainNetworkFromValue(chain),
tokenSymbol: tokenSymbol,
);
}
extension ChainAssetMapper on ChainAsset {
ChainAssetDTO toDTO() => ChainAssetDTO(
chain: chainNetworkToValue(chain),
tokenSymbol: tokenSymbol,
);
}

View File

@@ -1,5 +1,7 @@
import 'package:pshared/models/describable.dart'; import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/balance.dart'; import 'package:pshared/models/ledger/balance.dart';
import 'package:pshared/models/ledger/status.dart';
import 'package:pshared/models/ledger/type.dart';
class LedgerAccount implements Describable { class LedgerAccount implements Describable {
@@ -7,9 +9,9 @@ class LedgerAccount implements Describable {
final String organizationRef; final String organizationRef;
final String? ownerRef; final String? ownerRef;
final String accountCode; final String accountCode;
final String accountType; final LedgerAccountType accountType;
final String currency; final String currency;
final String status; final LedgerAccountStatus status;
final bool allowNegative; final bool allowNegative;
final bool isSettlement; final bool isSettlement;
final Map<String, String>? metadata; final Map<String, String>? metadata;

View File

@@ -0,0 +1,5 @@
enum LedgerAccountStatus {
unspecified,
active,
frozen,
}

View File

@@ -0,0 +1,7 @@
enum LedgerAccountType {
unspecified,
asset,
liability,
revenue,
expense,
}

View File

@@ -1,14 +1,12 @@
import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/wallet/chain_asset.dart';
class WalletAsset { class WalletAsset extends ChainAsset {
final ChainNetwork chain;
final String tokenSymbol;
final String contractAddress; final String contractAddress;
const WalletAsset({ const WalletAsset({
required this.chain, required super.chain,
required this.tokenSymbol, required super.tokenSymbol,
required this.contractAddress, required this.contractAddress,
}); });
} }

View File

@@ -0,0 +1,12 @@
import 'package:pshared/models/payment/chain_network.dart';
class ChainAsset {
final ChainNetwork chain;
final String tokenSymbol;
const ChainAsset({
required this.chain,
required this.tokenSymbol,
});
}

View File

@@ -6,6 +6,7 @@ import 'package:pshared/models/organization/employee.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart'; import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/accounts/employees.dart'; import 'package:pshared/service/accounts/employees.dart';
import 'package:pshared/utils/exception.dart';
class EmployeesProvider extends ChangeNotifier { class EmployeesProvider extends ChangeNotifier {
@@ -46,10 +47,7 @@ class EmployeesProvider extends ChangeNotifier {
error: null, error: null,
); );
} catch (e) { } catch (e) {
_employees = _employees.copyWith( _employees = _employees.copyWith(error: toException(e), isLoading: false);
error: e is Exception ? e : Exception('Unknown error: ${e.toString()}'),
isLoading: false,
);
} }
notifyListeners(); notifyListeners();

View File

@@ -4,8 +4,10 @@ import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart'; import 'package:pshared/provider/resource.dart';
@@ -159,6 +161,30 @@ class LedgerAccountsProvider with ChangeNotifier {
} }
} }
Future<void> create({
required Describable describable,
required Currency currency,
String? ownerRef,
}) async {
final org = _organizations;
if (org == null || !org.isOrganizationSet) return;
_applyResource(_resource.copyWith(isLoading: true, error: null), notify: true);
try {
await _service.create(
organizationRef: org.current.id,
currency: currency,
describable: describable,
ownerRef: ownerRef,
);
await loadAccountsWithBalances();
} catch (e) {
_applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true);
rethrow;
}
}
// ---------- internals ---------- // ---------- internals ----------
void _applyResource(Resource<List<LedgerAccount>> newResource, {required bool notify}) { void _applyResource(Resource<List<LedgerAccount>> newResource, {required bool notify}) {

View File

@@ -5,7 +5,9 @@ import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart'; import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/wallets.dart'; import 'package:pshared/service/payment/wallets.dart';
@@ -159,6 +161,30 @@ class WalletsProvider with ChangeNotifier {
} }
} }
Future<void> create({
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) async {
final org = _organizations;
if (org == null || !org.isOrganizationSet) return;
_applyResource(_resource.copyWith(isLoading: true, error: null), notify: true);
try {
await _service.create(
organizationRef: org.current.id,
describable: describable,
asset: asset,
ownerRef: ownerRef,
);
await loadWalletsWithBalances();
} catch (e) {
_applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true);
rethrow;
}
}
// ---------- internals ---------- // ---------- internals ----------
void _applyResource(Resource<List<Wallet>> newResource, {required bool notify}) { void _applyResource(Resource<List<Wallet>> newResource, {required bool notify}) {

View File

@@ -1,11 +1,18 @@
import 'package:pshared/api/requests/ledger/create.dart';
import 'package:pshared/api/responses/ledger/accounts.dart'; import 'package:pshared/api/responses/ledger/accounts.dart';
import 'package:pshared/api/responses/ledger/balance.dart'; import 'package:pshared/api/responses/ledger/balance.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/ledger/account.dart'; import 'package:pshared/data/mapper/ledger/account.dart';
import 'package:pshared/data/mapper/ledger/balance.dart'; import 'package:pshared/data/mapper/ledger/balance.dart';
import 'package:pshared/data/mapper/ledger/type.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/ledger/account.dart'; import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/ledger/balance.dart'; import 'package:pshared/models/ledger/balance.dart';
import 'package:pshared/models/ledger/type.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart'; import 'package:pshared/service/services.dart';
import 'package:pshared/utils/currency.dart';
class LedgerService { class LedgerService {
@@ -29,4 +36,22 @@ class LedgerService {
); );
return LedgerBalanceResponse.fromJson(json).balance.toDomain(); return LedgerBalanceResponse.fromJson(json).balance.toDomain();
} }
Future<void> create({
required String organizationRef,
required Describable describable,
required String? ownerRef,
required Currency currency,
}) async => AuthorizationService.getPOSTResponse(
_objectType,
'/$organizationRef',
CreateLedgerAccountRequest(
describable: describable.toDTO(),
ownerRef: ownerRef,
allowNegative: false,
isSettlement: false,
accountType: LedgerAccountType.asset.toDTO(),
currency: currencyCodeToString(currency),
).toJson(),
);
} }

View File

@@ -1,11 +1,19 @@
import 'package:pshared/data/mapper/wallet/ui.dart'; import 'package:pshared/data/mapper/wallet/ui.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/service/wallet.dart' as shared_wallet_service; import 'package:pshared/service/wallet.dart' as shared_wallet_service;
abstract class WalletsService { abstract class WalletsService {
Future<List<Wallet>> getWallets(String organizationRef); Future<List<Wallet>> getWallets(String organizationRef);
Future<double> getBalance(String organizationRef, String walletRef); Future<double> getBalance(String organizationRef, String walletRef);
Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
});
} }
class ApiWalletsService implements WalletsService { class ApiWalletsService implements WalletsService {
@@ -24,4 +32,17 @@ class ApiWalletsService implements WalletsService {
final amount = balance.available?.amount; final amount = balance.available?.amount;
return amount == null ? 0 : double.tryParse(amount) ?? 0; return amount == null ? 0 : double.tryParse(amount) ?? 0;
} }
@override
Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) => shared_wallet_service.WalletService.create(
organizationRef: organizationRef,
describable: describable,
asset: asset,
ownerRef: ownerRef,
);
} }

View File

@@ -1,7 +1,12 @@
import 'package:pshared/api/requests/wallet/create.dart';
import 'package:pshared/api/responses/wallet_balance.dart'; import 'package:pshared/api/responses/wallet_balance.dart';
import 'package:pshared/api/responses/wallets.dart'; import 'package:pshared/api/responses/wallets.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/data/mapper/wallet/chain_asset.dart';
import 'package:pshared/data/mapper/wallet/response.dart'; import 'package:pshared/data/mapper/wallet/response.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/wallet/balance.dart'; import 'package:pshared/models/wallet/balance.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/models/wallet/wallet.dart'; import 'package:pshared/models/wallet/wallet.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart'; import 'package:pshared/service/services.dart';
@@ -28,4 +33,19 @@ class WalletService {
); );
return WalletBalanceResponse.fromJson(json).toDomain(); return WalletBalanceResponse.fromJson(json).toDomain();
} }
static Future<void> create({
required String organizationRef,
required Describable describable,
required ChainAsset asset,
required String? ownerRef,
}) async => AuthorizationService.getPOSTResponse(
_objectType,
'/$organizationRef',
CreateWalletRequest(
asset: asset.toDTO(),
describable: describable.toDTO(),
ownerRef: ownerRef,
).toJson(),
);
} }

View File

@@ -279,7 +279,7 @@ void _openWalletEdit(
Wallet wallet, { Wallet wallet, {
required PayoutDestination returnTo, required PayoutDestination returnTo,
}) { }) {
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet, allowHidden: true);
context.pushToEditWallet(returnTo: returnTo); context.pushToEditWallet(returnTo: returnTo);
} }
@@ -288,7 +288,7 @@ void _openWalletTopUp(
Wallet wallet, { Wallet wallet, {
required PayoutDestination returnTo, required PayoutDestination returnTo,
}) { }) {
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet, allowHidden: true);
context.pushToWalletTopUp(returnTo: returnTo); context.pushToWalletTopUp(returnTo: returnTo);
} }

View File

@@ -608,6 +608,8 @@
"noRecipientsFound": "No recipients found for this query.", "noRecipientsFound": "No recipients found for this query.",
"sourceOfFunds": "Source of funds", "sourceOfFunds": "Source of funds",
"walletTopUp": "Top up", "walletTopUp": "Top up",
"errorCreateManagedWallet": "Failed to create managed wallet.",
"errorCreateLedgerAccount": "Failed to create ledger account.",
"englishLanguage": "English", "englishLanguage": "English",
"russianLanguage": "Russian", "russianLanguage": "Russian",
"germanLanguage": "German" "germanLanguage": "German"

View File

@@ -609,6 +609,8 @@
"noRecipientsFound": "Получатели по запросу не найдены.", "noRecipientsFound": "Получатели по запросу не найдены.",
"sourceOfFunds": "Источник средств", "sourceOfFunds": "Источник средств",
"walletTopUp": "Пополнение", "walletTopUp": "Пополнение",
"errorCreateManagedWallet": "Не удалось создать управляемый кошелек.",
"errorCreateLedgerAccount": "Не удалось создать счет бухгалтерской книги.",
"englishLanguage": "Английский", "englishLanguage": "Английский",
"russianLanguage": "Русский", "russianLanguage": "Русский",
"germanLanguage": "Немецкий" "germanLanguage": "Немецкий"

View File

@@ -16,8 +16,10 @@ import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/accounts/employees.dart'; import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/invitations.dart'; import 'package:pshared/provider/invitations.dart';
import 'package:pshared/service/ledger.dart';
import 'package:pshared/service/payment/wallets.dart'; import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart'; import 'package:pweb/app/app.dart';
@@ -96,6 +98,10 @@ void main() async {
create: (_) => WalletsProvider(ApiWalletsService()), create: (_) => WalletsProvider(ApiWalletsService()),
update: (context, organizations, provider) => provider!..update(organizations), update: (context, organizations, provider) => provider!..update(organizations),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, LedgerAccountsProvider>(
create: (_) => LedgerAccountsProvider(LedgerService()),
update: (context, organizations, provider) => provider!..update(organizations),
),
ChangeNotifierProxyProvider<WalletsProvider, WalletsController>( ChangeNotifierProxyProvider<WalletsProvider, WalletsController>(
create: (_) => WalletsController(), create: (_) => WalletsController(),
update: (_, wallets, controller) => controller!..update(wallets), update: (_, wallets, controller) => controller!..update(wallets),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AssetTypeField extends StatelessWidget {
final PaymentType value;
final ValueChanged<PaymentType?>? onChanged;
const AssetTypeField({
super.key,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DropdownButtonFormField<PaymentType>(
initialValue: value,
decoration: getInputDecoration(context, l10n.paymentType, true),
items: [
DropdownMenuItem(
value: PaymentType.managedWallet,
child: Text(l10n.paymentTypeManagedWallet),
),
DropdownMenuItem(
value: PaymentType.ledger,
child: Text(l10n.paymentTypeLedger),
),
],
onChanged: onChanged,
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class DialogCancelButton extends StatelessWidget {
final String label;
final bool isSaving;
final VoidCallback onCancel;
const DialogCancelButton({
super.key,
required this.label,
required this.isSaving,
required this.onCancel,
});
@override
Widget build(BuildContext context) => TextButton(
onPressed: isSaving ? null : onCancel,
child: Text(label),
);
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/dialog.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddBalanceCard extends StatelessWidget {
final VoidCallback? onTap;
const AddBalanceCard({super.key, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final textTheme = theme.textTheme;
final loc = AppLocalizations.of(context)!;
final borderRadius = BorderRadius.circular(WalletCardConfig.borderRadius);
final subtitle = '${loc.paymentTypeLedger} / ${loc.paymentTypeManagedWallet}';
final effectiveOnTap = onTap ?? () => showAddBalanceDialog(context);
return ClipRRect(
borderRadius: borderRadius,
child: DottedBorder(
options: RoundedRectDottedBorderOptions(
radius: Radius.circular(WalletCardConfig.borderRadius),
dashPattern: const [8, 5],
strokeWidth: 1.6,
color: colorScheme.primary.withAlpha(110),
),
child: Material(
color: colorScheme.primary.withAlpha(14),
child: InkWell(
onTap: effectiveOnTap,
child: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withAlpha(28),
),
child: Icon(
Icons.add_rounded,
size: 28,
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
loc.actionAddNew,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,8 @@
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
const String orgOwnerRef = '';
const Currency managedCurrencyDefault = Currency.usdt;
const Currency ledgerCurrencyDefault = Currency.rub;
const ChainNetwork managedNetworkDefault = ChainNetwork.tronMainnet;

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class DescriptionField extends StatelessWidget {
final TextEditingController controller;
const DescriptionField({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return TextFormField(
controller: controller,
decoration: getInputDecoration(context, '${l10n.comment} (${l10n.optional})', true),
style: getTextFieldStyle(context, true),
minLines: 2,
maxLines: 3,
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/form.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/cancel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/submit.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> showAddBalanceDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (dialogContext) => const AddBalanceDialog(),
);
class AddBalanceDialog extends StatefulWidget {
const AddBalanceDialog({super.key});
@override
State<AddBalanceDialog> createState() => _AddBalanceDialogState();
}
class _AddBalanceDialogState extends State<AddBalanceDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
PaymentType _assetType = PaymentType.managedWallet;
String _ownerRef = orgOwnerRef;
Currency _managedCurrency = managedCurrencyDefault;
ChainNetwork _network = managedNetworkDefault;
Currency _ledgerCurrency = ledgerCurrencyDefault;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _setAssetType(PaymentType? value) {
if (value == null) return;
setState(() => _assetType = value);
}
void _setOwnerRef(String? value) {
if (value == null) return;
setState(() => _ownerRef = value);
}
void _setManagedCurrency(Currency? value) {
if (value == null) return;
setState(() => _managedCurrency = value);
}
void _setNetwork(ChainNetwork? value) {
if (value == null) return;
setState(() => _network = value);
}
void _setLedgerCurrency(Currency? value) {
if (value == null) return;
setState(() => _ledgerCurrency = value);
}
Future<void> _submit() async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final l10n = AppLocalizations.of(context)!;
final name = _nameController.text.trim();
final description = _descriptionController.text.trim();
final employees = context.read<EmployeesProvider>().employees;
final effectiveOwnerRef = employees.any((employee) => employee.id == _ownerRef) ? _ownerRef : orgOwnerRef;
final isOrgWallet = effectiveOwnerRef == orgOwnerRef;
final owner = isOrgWallet ? null : employees.firstWhereOrNull((employee) => employee.id == effectiveOwnerRef);
final errorMessage = _assetType == PaymentType.managedWallet
? l10n.errorCreateManagedWallet
: l10n.errorCreateLedgerAccount;
final result = await executeActionWithNotification<bool>(
context: context,
errorMessage: errorMessage,
action: () async {
if (_assetType == PaymentType.managedWallet) {
await context.read<WalletsProvider>().create(
describable: newDescribable(name: name, description: description),
asset: ChainAsset(chain: _network, tokenSymbol: currencyCodeToString(_managedCurrency)),
ownerRef: owner?.id,
);
} else {
await context.read<LedgerAccountsProvider>().create(
describable: newDescribable(name: name, description: description),
currency: _ledgerCurrency,
ownerRef: owner?.id,
);
}
return true;
},
);
if (result == true && mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final orgName = context.select<OrganizationsProvider, String?>(
(provider) => provider.isOrganizationSet ? provider.current.name : null,
);
final employeesProvider = context.watch<EmployeesProvider>();
final employees = employeesProvider.employees;
final isSaving = context.watch<WalletsProvider>().isLoading ||
context.watch<LedgerAccountsProvider>().isLoading;
final ownerItems = <DropdownMenuItem<String>>[
DropdownMenuItem(
value: orgOwnerRef,
child: Text(orgName ?? l10n.companyName),
),
...employees.map((employee) => DropdownMenuItem(
value: employee.id,
child: Text(employee.fullName.isNotEmpty ? employee.fullName : employee.login),
)),
];
final resolvedOwnerRef = ownerItems.any((item) => item.value == _ownerRef)
? _ownerRef
: orgOwnerRef;
return AlertDialog(
title: Text(l10n.actionAddNew),
content: AddBalanceForm(
formKey: _formKey,
assetType: _assetType,
isSaving: isSaving,
ownerItems: ownerItems,
ownerValue: resolvedOwnerRef,
onAssetTypeChanged: _setAssetType,
onOwnerChanged: _setOwnerRef,
nameController: _nameController,
descriptionController: _descriptionController,
managedCurrency: _managedCurrency,
network: _network,
ledgerCurrency: _ledgerCurrency,
onManagedCurrencyChanged: _setManagedCurrency,
onNetworkChanged: _setNetwork,
onLedgerCurrencyChanged: _setLedgerCurrency,
showEmployeesLoading: employeesProvider.isLoading,
),
actions: [
DialogCancelButton(
label: l10n.cancel,
isSaving: isSaving,
onCancel: () => Navigator.of(context).pop(),
),
DialogSubmitButton(
label: l10n.add,
isSaving: isSaving,
onSubmit: _submit,
),
],
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class EmployeesLoadingIndicator extends StatelessWidget {
const EmployeesLoadingIndicator({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(top: 4),
child: LinearProgressIndicator(minHeight: 2),
);
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/asset_type_field.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/description.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/ledger_fields.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart';
class AddBalanceForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final PaymentType assetType;
final bool isSaving;
final List<DropdownMenuItem<String>> ownerItems;
final String ownerValue;
final ValueChanged<PaymentType?> onAssetTypeChanged;
final ValueChanged<String?> onOwnerChanged;
final TextEditingController nameController;
final TextEditingController descriptionController;
final Currency managedCurrency;
final ChainNetwork network;
final Currency ledgerCurrency;
final ValueChanged<Currency?> onManagedCurrencyChanged;
final ValueChanged<ChainNetwork?> onNetworkChanged;
final ValueChanged<Currency?> onLedgerCurrencyChanged;
final bool showEmployeesLoading;
const AddBalanceForm({
super.key,
required this.formKey,
required this.assetType,
required this.isSaving,
required this.ownerItems,
required this.ownerValue,
required this.onAssetTypeChanged,
required this.onOwnerChanged,
required this.nameController,
required this.descriptionController,
required this.managedCurrency,
required this.network,
required this.ledgerCurrency,
required this.onManagedCurrencyChanged,
required this.onNetworkChanged,
required this.onLedgerCurrencyChanged,
required this.showEmployeesLoading,
});
@override
Widget build(BuildContext context) => Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
AssetTypeField(
value: assetType,
onChanged: isSaving ? null : onAssetTypeChanged,
),
OwnerField(
value: ownerValue,
items: ownerItems,
onChanged: isSaving ? null : onOwnerChanged,
),
NameField(controller: nameController),
DescriptionField(controller: descriptionController),
if (assetType == PaymentType.managedWallet)
ManagedWalletFields(
currency: managedCurrency,
network: network,
onCurrencyChanged: isSaving ? null : onManagedCurrencyChanged,
onNetworkChanged: isSaving ? null : onNetworkChanged,
)
else
LedgerFields(
currency: ledgerCurrency,
onCurrencyChanged: isSaving ? null : onLedgerCurrencyChanged,
),
if (showEmployeesLoading) const EmployeesLoadingIndicator(),
],
),
),
);
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerFields extends StatelessWidget {
final Currency currency;
final ValueChanged<Currency?>? onCurrencyChanged;
const LedgerFields({
super.key,
required this.currency,
required this.onCurrencyChanged,
});
@override
Widget build(BuildContext context) => DropdownButtonFormField<Currency>(
initialValue: currency,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true),
items: [
DropdownMenuItem(
value: ledgerCurrencyDefault,
child: Text(currencyCodeToString(ledgerCurrencyDefault)),
),
],
onChanged: onCurrencyChanged,
);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class ManagedWalletFields extends StatelessWidget {
final Currency currency;
final ChainNetwork network;
final ValueChanged<Currency?>? onCurrencyChanged;
final ValueChanged<ChainNetwork?>? onNetworkChanged;
const ManagedWalletFields({
super.key,
required this.currency,
required this.network,
required this.onCurrencyChanged,
required this.onNetworkChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
spacing: 12,
children: [
DropdownButtonFormField<Currency>(
initialValue: currency,
decoration: getInputDecoration(context, l10n.currency, true),
items: [
DropdownMenuItem(
value: managedCurrencyDefault,
child: Text(currencyCodeToString(managedCurrencyDefault)),
),
],
onChanged: onCurrencyChanged,
),
DropdownButtonFormField<ChainNetwork>(
initialValue: network,
decoration: getInputDecoration(context, l10n.walletTopUpNetworkLabel, true),
items: [
DropdownMenuItem(
value: managedNetworkDefault,
child: Text(managedNetworkDefault.localizedName(context)),
),
],
onChanged: onNetworkChanged,
),
],
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class NameField extends StatelessWidget {
final TextEditingController controller;
const NameField({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) => TextFormField(
controller: controller,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.accountName, true),
style: getTextFieldStyle(context, true),
validator: (value) => (value == null || value.trim().isEmpty) ? AppLocalizations.of(context)!.errorNameMissing : null,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OwnerField extends StatelessWidget {
final String value;
final List<DropdownMenuItem<String>> items;
final ValueChanged<String?>? onChanged;
const OwnerField({
super.key,
required this.value,
required this.items,
required this.onChanged,
});
@override
Widget build(BuildContext context) => DropdownButtonFormField<String>(
initialValue: value,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.colDataOwner, true),
items: items,
onChanged: onChanged,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class DialogSubmitButton extends StatelessWidget {
final String label;
final bool isSaving;
final VoidCallback onSubmit;
const DialogSubmitButton({
super.key,
required this.label,
required this.isSaving,
required this.onSubmit,
});
@override
Widget build(BuildContext context) => ElevatedButton(
onPressed: isSaving ? null : onSubmit,
child: isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
);
}

View File

@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart'; import 'package:pshared/controllers/wallets.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart'; import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -22,38 +24,69 @@ class BalanceWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final walletsController = context.watch<WalletsController>(); final walletsController = context.watch<WalletsController>();
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final carousel = context.watch<CarouselIndexController>(); final carousel = context.watch<CarouselIndexController>();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
if (walletsController.isLoading) { final wallets = walletsController.wallets;
final accounts = ledgerProvider.accounts;
final isLoading = walletsController.isLoading &&
ledgerProvider.isLoading &&
wallets.isEmpty &&
accounts.isEmpty;
if (isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final wallets = walletsController.wallets; final items = [
...wallets.map(BalanceItem.wallet),
...accounts.map(BalanceItem.ledger),
const BalanceItem.addAction(),
];
if (wallets.isEmpty) { if (items.isEmpty) {
return Center(child: Text(loc.noWalletsAvailable)); return const SizedBox.shrink();
} }
// Ensure index is always valid when wallets list changes // Ensure index is always valid when list changes
carousel.setIndex(carousel.index, wallets.length); carousel.setIndex(carousel.index, items.length);
final index = carousel.index; final index = carousel.index;
final wallet = wallets[index]; final current = items[index];
// Single source of truth: controller // Single source of truth: controller
if (current.isWallet) {
final wallet = current.wallet!;
if (walletsController.selectedWallet?.id != wallet.id) { if (walletsController.selectedWallet?.id != wallet.id) {
walletsController.selectWallet(wallet); walletsController.selectWallet(wallet);
} }
}
return WalletCarousel( final carouselWidget = BalanceCarousel(
wallets: wallets, items: items,
currentIndex: index, currentIndex: index,
onIndexChanged: (i) { onIndexChanged: (i) {
carousel.setIndex(i, wallets.length); carousel.setIndex(i, items.length);
walletsController.selectWallet(wallets[i]); final next = items[carousel.index];
if (next.isWallet) {
walletsController.selectWallet(next.wallet!);
}
}, },
onTopUp: onTopUp, onTopUp: onTopUp,
); );
if (wallets.isEmpty && accounts.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(child: Text(loc.noWalletsAvailable)),
const SizedBox(height: 12),
carouselWidget,
],
);
}
return carouselWidget;
} }
} }

View File

@@ -0,0 +1,21 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
enum BalanceItemType { wallet, ledger, addAction }
class BalanceItem {
final BalanceItemType type;
final Wallet? wallet;
final LedgerAccount? account;
const BalanceItem.wallet(this.wallet) : type = BalanceItemType.wallet, account = null;
const BalanceItem.ledger(this.account) : type = BalanceItemType.ledger, wallet = null;
const BalanceItem.addAction() : type = BalanceItemType.addAction, wallet = null, account = null;
bool get isWallet => type == BalanceItemType.wallet;
bool get isLedger => type == BalanceItemType.ledger;
bool get isAdd => type == BalanceItemType.addAction;
}

View File

@@ -4,12 +4,16 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart'; import 'package:pshared/controllers/wallets.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletCard extends StatelessWidget { class WalletCard extends StatelessWidget {
@@ -24,20 +28,29 @@ class WalletCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
return Card( return Card(
color: Theme.of(context).colorScheme.onSecondary, color: Theme.of(context).colorScheme.onSecondary,
elevation: WalletCardConfig.elevation, elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
), ),
child: SizedBox.expand(
child: Padding( child: Padding(
padding: WalletCardConfig.contentPadding, padding: WalletCardConfig.contentPadding,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
BalanceHeader( BalanceHeader(
walletNetwork: wallet.network, title: loc.paymentTypeCryptoWallet,
tokenSymbol: wallet.tokenSymbol, subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
), ),
Row( Row(
children: [ children: [
@@ -48,18 +61,16 @@ class WalletCard extends StatelessWidget {
}, },
), ),
WalletBalanceRefreshButton( WalletBalanceRefreshButton(
walletId: wallet.id, walletRef: wallet.id,
), ),
], ],
), ),
BalanceAddFunds( BalanceAddFunds(onTopUp: onTopUp),
onTopUp: () {
onTopUp();
},
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,43 +1,117 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/card.dart'; import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart'; import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
import 'package:pweb/pages/dashboard/buttons/balance/ledger.dart';
class WalletCarousel extends StatelessWidget { class BalanceCarousel extends StatefulWidget {
final List<Wallet> wallets; final List<BalanceItem> items;
final int currentIndex; final int currentIndex;
final ValueChanged<int> onIndexChanged; final ValueChanged<int> onIndexChanged;
final ValueChanged<Wallet> onTopUp; final ValueChanged<Wallet> onTopUp;
const WalletCarousel({ const BalanceCarousel({
super.key, super.key,
required this.wallets, required this.items,
required this.currentIndex, required this.currentIndex,
required this.onIndexChanged, required this.onIndexChanged,
required this.onTopUp, required this.onTopUp,
}); });
@override
State<BalanceCarousel> createState() => _BalanceCarouselState();
}
class _BalanceCarouselState extends State<BalanceCarousel> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(
initialPage: widget.currentIndex,
viewportFraction: WalletCardConfig.viewportFraction,
);
}
@override
void didUpdateWidget(covariant BalanceCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (!mounted) return;
if (_controller.hasClients) {
final currentPage = _controller.page?.round();
if (currentPage != widget.currentIndex) {
_controller.jumpToPage(widget.currentIndex);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _goToPage(int index) {
if (!_controller.hasClients) return;
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (wallets.isEmpty) { if (widget.items.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final safeIndex = currentIndex.clamp(0, wallets.length - 1); final safeIndex = widget.currentIndex.clamp(0, widget.items.length - 1);
final wallet = wallets[safeIndex]; final scrollBehavior = ScrollConfiguration.of(context).copyWith(
dragDevices: const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
},
);
return Column( return Column(
children: [ children: [
SizedBox( SizedBox(
height: WalletCardConfig.cardHeight, height: WalletCardConfig.cardHeight,
child: Padding( child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: ScrollConfiguration(
behavior: scrollBehavior,
child: PageView.builder(
controller: _controller,
onPageChanged: widget.onIndexChanged,
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final Widget card = switch (item.type) {
BalanceItemType.wallet => WalletCard(
wallet: item.wallet!,
onTopUp: () => widget.onTopUp(item.wallet!),
),
BalanceItemType.ledger => LedgerAccountCard(account: item.account!),
BalanceItemType.addAction => const AddBalanceCard(),
};
return Padding(
padding: WalletCardConfig.cardPadding, padding: WalletCardConfig.cardPadding,
child: WalletCard( child: card,
wallet: wallet, );
onTopUp: () => onTopUp(wallet), },
),
), ),
), ),
), ),
@@ -47,20 +121,18 @@ class WalletCarousel extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
onPressed: safeIndex > 0 onPressed: safeIndex > 0
? () => onIndexChanged(safeIndex - 1) ? () => _goToPage(safeIndex - 1)
: null, : null,
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
CarouselIndicator( CarouselIndicator(
itemCount: wallets.length, itemCount: widget.items.length,
index: safeIndex, index: safeIndex,
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
IconButton( IconButton(
onPressed: safeIndex < wallets.length - 1 onPressed: safeIndex < widget.items.length - 1 ? () => _goToPage(safeIndex + 1) : null,
? () => onIndexChanged(safeIndex + 1)
: null,
icon: const Icon(Icons.arrow_forward), icon: const Icon(Icons.arrow_forward),
), ),
], ],

View File

@@ -1,29 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceHeader extends StatelessWidget { class BalanceHeader extends StatelessWidget {
final ChainNetwork? walletNetwork; final String title;
final String? tokenSymbol; final String? subtitle;
final String? badge;
const BalanceHeader({ const BalanceHeader({
super.key, super.key,
this.walletNetwork, required this.title,
this.tokenSymbol, this.subtitle,
this.badge,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!; final subtitleText = subtitle?.trim();
final symbol = tokenSymbol?.trim(); final badgeText = badge?.trim();
final networkLabel = (walletNetwork == null || walletNetwork == ChainNetwork.unspecified)
? null
: walletNetwork!.localizedName(context);
return Row( return Row(
children: [ children: [
@@ -32,14 +27,14 @@ class BalanceHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
loc.paymentTypeCryptoWallet, title,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
), ),
if (networkLabel != null) if (subtitleText != null && subtitleText.isNotEmpty)
Text( Text(
networkLabel, subtitleText,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -48,7 +43,7 @@ class BalanceHeader extends StatelessWidget {
], ],
), ),
), ),
if (symbol != null && symbol.isNotEmpty) ...[ if (badgeText != null && badgeText.isNotEmpty) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
@@ -57,7 +52,7 @@ class BalanceHeader extends StatelessWidget {
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
symbol, badgeText,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account;
const LedgerAccountCard({
super.key,
required this.account,
});
String _formatBalance() {
final money = account.balance?.balance;
if (money == null) return '--';
final amount = double.tryParse(money.amount);
if (amount == null) {
return '${money.amount} ${money.currency}';
}
try {
final currency = currencyStringToCode(money.currency);
final symbol = currencyCodeToSymbol(currency);
if (symbol.trim().isEmpty) {
return '${amountToString(amount)} ${money.currency}';
}
return '${amountToString(amount)} $symbol';
} catch (_) {
return '${amountToString(amount)} ${money.currency}';
}
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!;
final subtitle = account.name.isNotEmpty ? account.name : account.accountCode;
final badge = account.currency.trim().isEmpty ? null : account.currency.toUpperCase();
return Card(
color: colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
),
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
title: loc.paymentTypeLedger,
subtitle: subtitle.isNotEmpty ? subtitle : null,
badge: badge,
),
Row(
children: [
Text(
_formatBalance(),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 12),
LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
),
],
),
],
),
),
);
}
}

View File

@@ -17,7 +17,7 @@ import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -87,7 +87,7 @@ class PaymentPageContent extends StatelessWidget {
if (selectedWalletId == null) { if (selectedWalletId == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return WalletBalanceRefreshButton(walletId: selectedWalletId); return WalletBalanceRefreshButton(walletRef: selectedWalletId);
}, },
), ),
], ],

View File

@@ -8,7 +8,7 @@ import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/visibility.dart'; import 'package:pweb/models/visibility.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -55,7 +55,7 @@ class WalletCard extends StatelessWidget {
}, },
), ),
WalletBalanceRefreshButton( WalletBalanceRefreshButton(
walletId: wallet.id, walletRef: wallet.id,
iconOnly: VisibilityState.hidden, iconOnly: VisibilityState.hidden,
), ),
], ],

View File

@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/wallets.dart'; import 'package:pshared/controllers/wallets.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/widgets/wallet_balance_refresh_button.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
class WalletEditFields extends StatelessWidget { class WalletEditFields extends StatelessWidget {
@@ -33,7 +33,7 @@ class WalletEditFields extends StatelessWidget {
onToggleVisibility: () => controller.toggleVisibility(wallet.id), onToggleVisibility: () => controller.toggleVisibility(wallet.id),
), ),
), ),
WalletBalanceRefreshButton(walletId: wallet.id), WalletBalanceRefreshButton(walletRef: wallet.id),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/visibility.dart';
class BalanceRefreshButton extends StatelessWidget {
final bool isBusy;
final bool enabled;
final VoidCallback onPressed;
final VisibilityState iconOnly;
final String label;
final String tooltip;
final double iconSize;
const BalanceRefreshButton({
super.key,
required this.isBusy,
required this.enabled,
required this.onPressed,
required this.iconOnly,
required this.label,
required this.tooltip,
this.iconSize = 18,
});
@override
Widget build(BuildContext context) {
final canPress = enabled && !isBusy;
if (iconOnly == VisibilityState.hidden) {
return IconButton(
tooltip: tooltip,
onPressed: canPress ? onPressed : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
}
return TextButton.icon(
onPressed: canPress ? onPressed : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(label),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/widgets/refresh_balance/button.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerBalanceRefreshButton extends StatelessWidget {
final String ledgerAccountRef;
final VisibilityState iconOnly;
final double iconSize = 18;
const LedgerBalanceRefreshButton({
super.key,
required this.ledgerAccountRef,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = ledgerProvider.isWalletRefreshing(ledgerAccountRef) || ledgerProvider.isLoading;
final hasTarget = ledgerProvider.accounts.any((a) => a.ledgerAccountRef == ledgerAccountRef);
void refresh() {
final provider = context.read<LedgerAccountsProvider>();
provider.refreshBalance(ledgerAccountRef);
}
return BalanceRefreshButton(
isBusy: isBusy,
enabled: hasTarget,
onPressed: refresh,
iconOnly: iconOnly,
label: loc.refreshBalance,
tooltip: loc.refreshBalance,
iconSize: iconSize,
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/widgets/refresh_balance/button.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletBalanceRefreshButton extends StatelessWidget {
final String walletRef;
final VisibilityState iconOnly;
final double iconSize = 18;
const WalletBalanceRefreshButton({
super.key,
required this.walletRef,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final walletsProvider = context.watch<WalletsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = walletsProvider.isWalletRefreshing(walletRef) || walletsProvider.isLoading;
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletRef);
void refresh() {
final provider = context.read<WalletsProvider>();
provider.refreshBalance(walletRef);
}
return BalanceRefreshButton(
isBusy: isBusy,
enabled: hasTarget,
onPressed: refresh,
iconOnly: iconOnly,
label: loc.refreshBalance,
tooltip: loc.refreshBalance,
iconSize: iconSize,
);
}
}

View File

@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletBalanceRefreshButton extends StatelessWidget {
final String walletId;
final VisibilityState iconOnly;
final double iconSize = 18;
const WalletBalanceRefreshButton({
super.key,
required this.walletId,
this.iconOnly = VisibilityState.visible,
});
@override
Widget build(BuildContext context) {
final walletsProvider = context.watch<WalletsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy = walletsProvider.isWalletRefreshing(walletId) || walletsProvider.isLoading;
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletId);
void refresh() {
final provider = context.read<WalletsProvider>();
provider.refreshBalance(walletId);
}
if (iconOnly == VisibilityState.hidden) {
return IconButton(
tooltip: loc.refreshBalance,
onPressed: hasTarget && !isBusy ? refresh : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
}
return TextButton.icon(
onPressed: hasTarget && !isBusy ? refresh : null,
icon: isBusy
? SizedBox(
width: iconSize,
height: iconSize,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(loc.refreshBalance),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}