From 218c4e20b371399569dd3fa5b2d02619d88c0dae Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Jan 2026 00:13:43 +0100 Subject: [PATCH] accounts creation --- api/ledger/client/client.go | 38 ++-- .../internal/service/ledger/accounts.go | 80 ++++---- .../internal/service/ledger/accounts_test.go | 63 +++--- .../internal/service/ledger/connector.go | 32 +-- api/pkg/ledgerconv/account.go | 55 ++++++ api/proto/ledger/v1/ledger.proto | 3 +- api/server/interface/api/srequest/ledger.go | 10 +- api/server/interface/api/srequest/wallet.go | 7 +- .../internal/server/ledgerapiimp/create.go | 44 ++--- .../internal/server/walletapiimp/create.go | 4 +- .../lib/api/requests/ledger/create.dart | 32 +++ .../lib/api/requests/payment/base.dart | 1 - .../lib/api/requests/wallet/create.dart | 25 +++ frontend/pshared/lib/controllers/wallets.dart | 13 +- .../pshared/lib/data/dto/ledger/account.dart | 6 +- .../pshared/lib/data/dto/ledger/status.dart | 13 ++ .../pshared/lib/data/dto/ledger/type.dart | 19 ++ .../pshared/lib/data/dto/wallet/asset.dart | 11 +- .../lib/data/dto/wallet/chain_asset.dart | 18 ++ .../lib/data/mapper/ledger/account.dart | 10 +- .../lib/data/mapper/ledger/status.dart | 29 +++ .../pshared/lib/data/mapper/ledger/type.dart | 37 ++++ .../lib/data/mapper/wallet/chain_asset.dart | 18 ++ .../pshared/lib/models/ledger/account.dart | 6 +- .../pshared/lib/models/ledger/status.dart | 5 + frontend/pshared/lib/models/ledger/type.dart | 7 + frontend/pshared/lib/models/wallet/asset.dart | 10 +- .../lib/models/wallet/chain_asset.dart | 12 ++ .../lib/provider/accounts/employees.dart | 6 +- frontend/pshared/lib/provider/ledger.dart | 28 ++- .../pshared/lib/provider/payment/wallets.dart | 26 +++ frontend/pshared/lib/service/ledger.dart | 25 +++ .../pshared/lib/service/payment/wallets.dart | 21 ++ frontend/pshared/lib/service/wallet.dart | 20 ++ .../pweb/lib/app/router/payout_shell.dart | 4 +- frontend/pweb/lib/l10n/en.arb | 2 + frontend/pweb/lib/l10n/ru.arb | 2 + frontend/pweb/lib/main.dart | 8 +- .../buttons/balance/add/asset_type_field.dart | 39 ++++ .../dashboard/buttons/balance/add/cancel.dart | 21 ++ .../dashboard/buttons/balance/add/card.dart | 84 ++++++++ .../buttons/balance/add/constants.dart | 8 + .../buttons/balance/add/description.dart | 27 +++ .../dashboard/buttons/balance/add/dialog.dart | 184 ++++++++++++++++++ .../add/employees_loading_indicator.dart | 12 ++ .../dashboard/buttons/balance/add/form.dart | 90 +++++++++ .../buttons/balance/add/ledger_fields.dart | 34 ++++ .../balance/add/managed_wallet_fields.dart | 59 ++++++ .../dashboard/buttons/balance/add/name.dart | 23 +++ .../dashboard/buttons/balance/add/owner.dart | 27 +++ .../dashboard/buttons/balance/add/submit.dart | 27 +++ .../dashboard/buttons/balance/balance.dart | 59 ++++-- .../buttons/balance/balance_item.dart | 21 ++ .../pages/dashboard/buttons/balance/card.dart | 69 ++++--- .../dashboard/buttons/balance/carousel.dart | 106 ++++++++-- .../dashboard/buttons/balance/header.dart | 31 ++- .../dashboard/buttons/balance/ledger.dart | 85 ++++++++ .../payment_methods/payment_page/content.dart | 4 +- .../lib/pages/payout_page/wallet/card.dart | 4 +- .../pages/payout_page/wallet/edit/fields.dart | 4 +- .../lib/widgets/refresh_balance/button.dart | 57 ++++++ .../lib/widgets/refresh_balance/ledger.dart | 46 +++++ .../lib/widgets/refresh_balance/wallet.dart | 46 +++++ .../wallet_balance_refresh_button.dart | 62 ------ 64 files changed, 1641 insertions(+), 338 deletions(-) create mode 100644 api/pkg/ledgerconv/account.go create mode 100644 frontend/pshared/lib/api/requests/ledger/create.dart create mode 100644 frontend/pshared/lib/api/requests/wallet/create.dart create mode 100644 frontend/pshared/lib/data/dto/ledger/status.dart create mode 100644 frontend/pshared/lib/data/dto/ledger/type.dart create mode 100644 frontend/pshared/lib/data/dto/wallet/chain_asset.dart create mode 100644 frontend/pshared/lib/data/mapper/ledger/status.dart create mode 100644 frontend/pshared/lib/data/mapper/ledger/type.dart create mode 100644 frontend/pshared/lib/data/mapper/wallet/chain_asset.dart create mode 100644 frontend/pshared/lib/models/ledger/status.dart create mode 100644 frontend/pshared/lib/models/ledger/type.dart create mode 100644 frontend/pshared/lib/models/wallet/chain_asset.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/asset_type_field.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/cancel.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/card.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/constants.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/description.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/dialog.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/name.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/submit.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart create mode 100644 frontend/pweb/lib/widgets/refresh_balance/button.dart create mode 100644 frontend/pweb/lib/widgets/refresh_balance/ledger.dart create mode 100644 frontend/pweb/lib/widgets/refresh_balance/wallet.dart delete mode 100644 frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index 683c886a..a9ae7dbf 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -7,12 +7,13 @@ import ( "strings" "time" + "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/payments/rail" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -192,7 +193,6 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc } params := map[string]interface{}{ "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), - "account_code": strings.TrimSpace(req.GetAccountCode()), "account_type": req.GetAccountType().String(), "status": req.GetStatus().String(), "allow_negative": req.GetAllowNegative(), @@ -523,29 +523,13 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc } func parseAccountType(value string) ledgerv1.AccountType { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "ACCOUNT_TYPE_ASSET", "ASSET": - 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 - } + accountType, _ := ledgerconv.ParseAccountType(value) + return accountType } func parseAccountStatus(value string) ledgerv1.AccountStatus { - switch strings.ToUpper(strings.TrimSpace(value)) { - 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 - } + status, _ := ledgerconv.ParseAccountStatus(value) + return status } func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse { @@ -553,11 +537,11 @@ func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntry return nil } entry := &ledgerv1.JournalEntryResponse{ - EntryRef: strings.TrimSpace(op.GetOperationId()), - EntryType: entryTypeFromOperation(op.GetType()), - Description: operationDescription(op), - EventTime: op.GetCreatedAt(), - Lines: postingLinesFromOperation(op), + EntryRef: strings.TrimSpace(op.GetOperationId()), + EntryType: entryTypeFromOperation(op.GetType()), + Description: operationDescription(op), + EventTime: op.GetCreatedAt(), + Lines: postingLinesFromOperation(op), LedgerAccountRefs: ledgerAccountRefsFromOperation(op), } return entry diff --git a/api/ledger/internal/service/ledger/accounts.go b/api/ledger/internal/service/ledger/accounts.go index de6084e1..124bd557 100644 --- a/api/ledger/internal/service/ledger/accounts.go +++ b/api/ledger/internal/service/ledger/accounts.go @@ -37,12 +37,6 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create 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()) if currency == "" { return nil, merrors.InvalidArgument("currency is required") @@ -85,38 +79,37 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create ownerRef = &ownerObjID } - account := &model.Account{ - AccountCode: accountCode, - Currency: currency, - AccountType: modelType, - Status: modelStatus, - AllowNegative: req.GetAllowNegative(), - IsSettlement: req.GetIsSettlement(), - Metadata: metadata, - OwnerRef: ownerRef, - } - if describable != nil { - account.Describable = *describable - } - account.OrganizationRef = orgRef + 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, + Currency: currency, + AccountType: modelType, + Status: modelStatus, + AllowNegative: req.GetAllowNegative(), + IsSettlement: req.GetIsSettlement(), + Metadata: metadata, + OwnerRef: ownerRef, + } + if describable != nil { + account.Describable = *describable + } + account.OrganizationRef = orgRef + account.SetID(accountID) - err = s.storage.Accounts().Create(ctx, account) - if err != nil { - if errors.Is(err, merrors.ErrDataConflict) { - 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") + err = s.storage.Accounts().Create(ctx, account) + if err == nil { + recordAccountOperation("create", "success") return &ledgerv1.CreateAccountResponse{ - Account: toProtoAccount(existing), + Account: toProtoAccount(account), }, nil } + if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 { + continue + } recordAccountOperation("create", "error") s.logger.Warn("failed to create account", zap.Error(err), @@ -125,11 +118,8 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create zap.String("currency", currency)) return nil, merrors.Internal("failed to create account") } - - recordAccountOperation("create", "success") - return &ledgerv1.CreateAccountResponse{ - Account: toProtoAccount(account), - }, nil + recordAccountOperation("create", "error") + return nil, merrors.Internal("failed to create account") } } @@ -349,3 +339,15 @@ func defaultSettlementAccountCode(currency string) string { } 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()) +} diff --git a/api/ledger/internal/service/ledger/accounts_test.go b/api/ledger/internal/service/ledger/accounts_test.go index 19494bd4..cb433056 100644 --- a/api/ledger/internal/service/ledger/accounts_test.go +++ b/api/ledger/internal/service/ledger/accounts_test.go @@ -2,8 +2,8 @@ package ledger import ( "context" + "strings" "testing" - "time" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson/primitive" @@ -23,6 +23,7 @@ type accountStoreStub struct { existingErr error defaultSettlement *model.Account defaultErr error + createErrs []error } func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error { @@ -30,8 +31,16 @@ func (s *accountStoreStub) Create(_ context.Context, account *model.Account) err if s.createErrSettlement != nil { return s.createErrSettlement } - } else if s.createErr != nil { - return s.createErr + } 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 { + return s.createErr + } } if account.GetID() == nil || account.GetID().IsZero() { account.SetID(primitive.NewObjectID()) @@ -94,7 +103,6 @@ func TestCreateAccountResponder_Success(t *testing.T) { req := &ledgerv1.CreateAccountRequest{ OrganizationRef: orgRef.Hex(), - AccountCode: "asset:cash:main", AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, Currency: "usd", AllowNegative: false, @@ -107,7 +115,11 @@ func TestCreateAccountResponder_Success(t *testing.T) { require.NotNil(t, resp) 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, "USD", resp.Account.Currency) require.True(t, resp.Account.IsSettlement) @@ -129,7 +141,6 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) { req := &ledgerv1.CreateAccountRequest{ OrganizationRef: orgRef.Hex(), - AccountCode: "liability:customer:1", AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, Currency: "usd", } @@ -146,40 +157,29 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) { if acc.IsSettlement { settlement = acc } - if acc.AccountCode == "liability:customer:1" { + if !acc.IsSettlement { created = acc } } require.NotNil(t, settlement) 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, model.AccountTypeAsset, settlement.AccountType) require.Equal(t, "USD", settlement.Currency) require.True(t, settlement.AllowNegative) } -func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) { +func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) { t.Parallel() 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{ - createErr: merrors.DataConflict("duplicate"), - existing: existing, - existingErr: nil, + createErrs: []error{merrors.DataConflict("duplicate")}, } svc := &Service{ @@ -189,7 +189,6 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) { req := &ledgerv1.CreateAccountRequest{ OrganizationRef: orgRef.Hex(), - AccountCode: "asset:cash:main", AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, Currency: "usd", } @@ -199,8 +198,15 @@ func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) { require.NotNil(t, resp) require.NotNil(t, resp.Account) - require.Equal(t, existing.GetID().Hex(), resp.Account.LedgerAccountRef) - require.Equal(t, existing.Metadata["purpose"], resp.Account.Metadata["purpose"]) + require.Len(t, accountStore.created, 2) + 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) { @@ -213,7 +219,6 @@ func TestCreateAccountResponder_InvalidAccountType(t *testing.T) { req := &ledgerv1.CreateAccountRequest{ OrganizationRef: primitive.NewObjectID().Hex(), - AccountCode: "asset:cash:main", Currency: "USD", } diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go index 117b8b9f..1c31e9ad 100644 --- a/api/ledger/internal/service/ledger/connector.go +++ b/api/ledger/internal/service/ledger/connector.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" @@ -57,9 +58,8 @@ func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.Ope reader := params.New(req.GetParams()) orgRef := strings.TrimSpace(reader.String("organization_ref")) - accountCode := strings.TrimSpace(reader.String("account_code")) - if orgRef == "" || accountCode == "" { - return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref and account_code are required", nil, "")}, nil + if orgRef == "" { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil } 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{ OrganizationRef: orgRef, - AccountCode: accountCode, AccountType: accountType, Currency: currency, Status: status, @@ -312,7 +311,6 @@ func (c *connectorAdapter) ListOperations(ctx context.Context, req *connectorv1. func ledgerOpenAccountParams() []*connectorv1.ParamSpec { return []*connectorv1.ParamSpec{ {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: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."}, {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) { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "ACCOUNT_TYPE_ASSET", "ASSET": - return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil - case "ACCOUNT_TYPE_LIABILITY", "LIABILITY": - return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil - case "ACCOUNT_TYPE_REVENUE", "REVENUE": - return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil - case "ACCOUNT_TYPE_EXPENSE", "EXPENSE": - return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil - default: + accountType, ok := ledgerconv.ParseAccountType(value) + if !ok || ledgerconv.IsAccountTypeUnspecified(value) { 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 { - value := strings.ToUpper(strings.TrimSpace(reader.String(key))) - switch value { - 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 - } + status, _ := ledgerconv.ParseAccountStatus(reader.String(key)) + return status } func parseEventTime(reader params.Reader) *timestamppb.Timestamp { diff --git a/api/pkg/ledgerconv/account.go b/api/pkg/ledgerconv/account.go new file mode 100644 index 00000000..71037da0 --- /dev/null +++ b/api/pkg/ledgerconv/account.go @@ -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 + } +} diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index be7666cb..e695a3c9 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -72,7 +72,8 @@ message PostingLine { message CreateAccountRequest { string organization_ref = 1; string owner_ref = 2; - string account_code = 3; + reserved 3; + reserved "account_code"; AccountType account_type = 4; string currency = 5; AccountStatus status = 6; diff --git a/api/server/interface/api/srequest/ledger.go b/api/server/interface/api/srequest/ledger.go index 24585d61..dabaa99c 100644 --- a/api/server/interface/api/srequest/ledger.go +++ b/api/server/interface/api/srequest/ledger.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/bson/primitive" ) type LedgerAccountType string @@ -26,21 +27,16 @@ const ( ) type CreateLedgerAccount struct { - AccountCode string `json:"accountCode"` AccountType LedgerAccountType `json:"accountType"` Currency string `json:"currency"` - Status LedgerAccountStatus `json:"status,omitempty"` AllowNegative bool `json:"allowNegative,omitempty"` IsSettlement bool `json:"isSettlement,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` 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 { - if strings.TrimSpace(r.AccountCode) == "" { - return merrors.InvalidArgument("accountCode is required", "accountCode") - } if strings.TrimSpace(r.Currency) == "" { return merrors.InvalidArgument("currency is required", "currency") } diff --git a/api/server/interface/api/srequest/wallet.go b/api/server/interface/api/srequest/wallet.go index dd9390e5..8dbaabd5 100644 --- a/api/server/interface/api/srequest/wallet.go +++ b/api/server/interface/api/srequest/wallet.go @@ -1,9 +1,12 @@ 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 { Description model.Describable `json:"description"` - IsOrgWallet bool `json:"isOrgWallet"` Asset model.ChainAssetKey `json:"asset"` + OwnerRef *primitive.ObjectID `json:"ownerRef,omitempty"` } diff --git a/api/server/internal/server/ledgerapiimp/create.go b/api/server/internal/server/ledgerapiimp/create.go index 273d232e..30c77d5e 100644 --- a/api/server/internal/server/ledgerapiimp/create.go +++ b/api/server/internal/server/ledgerapiimp/create.go @@ -6,6 +6,7 @@ import ( "strings" "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/model" "github.com/tech/sendico/pkg/mservice" @@ -46,11 +47,6 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token if err != nil { return response.BadPayload(a.logger, a.Name(), err) } - status, err := mapLedgerAccountStatus(payload.Status) - if err != nil { - return response.BadPayload(a.logger, a.Name(), err) - } - if a.client == nil { return response.Internal(a.logger, mservice.Ledger, 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 - if !payload.IsOrgWallet { - ownerRef = account.ID.Hex() + if payload.OwnerRef != nil && !payload.OwnerRef.IsZero() { + ownerRef = payload.OwnerRef.Hex() } resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ OrganizationRef: orgRef.Hex(), OwnerRef: ownerRef, - AccountCode: payload.AccountCode, AccountType: accountType, Currency: payload.Currency, - Status: status, + Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, AllowNegative: payload.AllowNegative, IsSettlement: payload.IsSettlement, Metadata: payload.Metadata, @@ -102,7 +97,6 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) } - payload.AccountCode = strings.TrimSpace(payload.AccountCode) payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency)) payload.Describable.Name = strings.TrimSpace(payload.Describable.Name) if payload.Describable.Description != nil { @@ -123,31 +117,25 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc } func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) { - switch strings.ToUpper(strings.TrimSpace(string(accountType))) { - case "ACCOUNT_TYPE_ASSET", "ASSET": - return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil - case "ACCOUNT_TYPE_LIABILITY", "LIABILITY": - return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil - case "ACCOUNT_TYPE_REVENUE", "REVENUE": - return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil - case "ACCOUNT_TYPE_EXPENSE", "EXPENSE": - return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil - case "", "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED": + raw := string(accountType) + if ledgerconv.IsAccountTypeUnspecified(raw) { 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 parsed, nil } func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) { - switch strings.ToUpper(strings.TrimSpace(string(status))) { - case "", "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED": + raw := string(status) + if ledgerconv.IsAccountStatusUnspecified(raw) { return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil - case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": - return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, nil - case "ACCOUNT_STATUS_FROZEN", "FROZEN": - return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN, nil - default: + } + parsed, ok := ledgerconv.ParseAccountStatus(raw) + if !ok { return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status") } + return parsed, nil } diff --git a/api/server/internal/server/walletapiimp/create.go b/api/server/internal/server/walletapiimp/create.go index 469686b2..a688b2ac 100644 --- a/api/server/internal/server/walletapiimp/create.go +++ b/api/server/internal/server/walletapiimp/create.go @@ -57,8 +57,8 @@ func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresp } var ownerRef string - if !sr.IsOrgWallet { - ownerRef = account.ID.Hex() + if sr.OwnerRef != nil && !sr.OwnerRef.IsZero() { + ownerRef = sr.OwnerRef.Hex() } passet, err := ast.Asset2Proto(&asset.Asset) if err != nil { diff --git a/frontend/pshared/lib/api/requests/ledger/create.dart b/frontend/pshared/lib/api/requests/ledger/create.dart new file mode 100644 index 00000000..ac32bf67 --- /dev/null +++ b/frontend/pshared/lib/api/requests/ledger/create.dart @@ -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? 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 json) => _$CreateLedgerAccountRequestFromJson(json); + Map toJson() => _$CreateLedgerAccountRequestToJson(this); + +} diff --git a/frontend/pshared/lib/api/requests/payment/base.dart b/frontend/pshared/lib/api/requests/payment/base.dart index 8072c917..82d9ab8a 100644 --- a/frontend/pshared/lib/api/requests/payment/base.dart +++ b/frontend/pshared/lib/api/requests/payment/base.dart @@ -1,6 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; - part 'base.g.dart'; diff --git a/frontend/pshared/lib/api/requests/wallet/create.dart b/frontend/pshared/lib/api/requests/wallet/create.dart new file mode 100644 index 00000000..a330350d --- /dev/null +++ b/frontend/pshared/lib/api/requests/wallet/create.dart @@ -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? 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 json) => _$CreateWalletRequestFromJson(json); + Map toJson() => _$CreateWalletRequestToJson(this); +} diff --git a/frontend/pshared/lib/controllers/wallets.dart b/frontend/pshared/lib/controllers/wallets.dart index 09bee327..56e3d11a 100644 --- a/frontend/pshared/lib/controllers/wallets.dart +++ b/frontend/pshared/lib/controllers/wallets.dart @@ -60,22 +60,23 @@ class WalletsController with ChangeNotifier { 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; // Prevent selecting a hidden wallet - if (!_visibleWalletRefs.contains(walletRef)) return; + if (!allowHidden && !_visibleWalletRefs.contains(walletRef)) return; _selectedWalletRef = walletRef; notifyListeners(); } /// Toggle wallet visibility - void toggleVisibility(String walletId) { - final existed = _visibleWalletRefs.remove(walletId); - if (!existed) _visibleWalletRefs.add(walletId); + void toggleVisibility(String accountRef) { + final existed = _visibleWalletRefs.remove(accountRef); + if (!existed) _visibleWalletRefs.add(accountRef); _selectedWalletRef = _resolveSelectedId( currentRef: _selectedWalletRef, diff --git a/frontend/pshared/lib/data/dto/ledger/account.dart b/frontend/pshared/lib/data/dto/ledger/account.dart index 92c59117..eb13d6e7 100644 --- a/frontend/pshared/lib/data/dto/ledger/account.dart +++ b/frontend/pshared/lib/data/dto/ledger/account.dart @@ -2,6 +2,8 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:pshared/data/dto/describable.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'; @@ -12,9 +14,9 @@ class LedgerAccountDTO { final String organizationRef; final String? ownerRef; final String accountCode; - final String accountType; + final LedgerAccountTypeDTO accountType; final String currency; - final String status; + final LedgerAccountStatusDTO status; final bool allowNegative; final bool isSettlement; final Map? metadata; diff --git a/frontend/pshared/lib/data/dto/ledger/status.dart b/frontend/pshared/lib/data/dto/ledger/status.dart new file mode 100644 index 00000000..b12ca7dd --- /dev/null +++ b/frontend/pshared/lib/data/dto/ledger/status.dart @@ -0,0 +1,13 @@ +import 'package:json_annotation/json_annotation.dart'; + + +enum LedgerAccountStatusDTO { + @JsonValue('unspecified') + unspecified, + + @JsonValue('active') + active, + + @JsonValue('frozen') + frozen, +} diff --git a/frontend/pshared/lib/data/dto/ledger/type.dart b/frontend/pshared/lib/data/dto/ledger/type.dart new file mode 100644 index 00000000..8da0f2b6 --- /dev/null +++ b/frontend/pshared/lib/data/dto/ledger/type.dart @@ -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, +} diff --git a/frontend/pshared/lib/data/dto/wallet/asset.dart b/frontend/pshared/lib/data/dto/wallet/asset.dart index a747bcb7..b29f4663 100644 --- a/frontend/pshared/lib/data/dto/wallet/asset.dart +++ b/frontend/pshared/lib/data/dto/wallet/asset.dart @@ -1,20 +1,21 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/wallet/chain_asset.dart'; + part 'asset.g.dart'; @JsonSerializable() -class WalletAssetDTO { - final String chain; - final String tokenSymbol; +class WalletAssetDTO extends ChainAssetDTO { final String contractAddress; const WalletAssetDTO({ - required this.chain, - required this.tokenSymbol, + required super.chain, + required super.tokenSymbol, required this.contractAddress, }); factory WalletAssetDTO.fromJson(Map json) => _$WalletAssetDTOFromJson(json); + @override Map toJson() => _$WalletAssetDTOToJson(this); } diff --git a/frontend/pshared/lib/data/dto/wallet/chain_asset.dart b/frontend/pshared/lib/data/dto/wallet/chain_asset.dart new file mode 100644 index 00000000..d8cb91fc --- /dev/null +++ b/frontend/pshared/lib/data/dto/wallet/chain_asset.dart @@ -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 json) => _$ChainAssetDTOFromJson(json); + Map toJson() => _$ChainAssetDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/mapper/ledger/account.dart b/frontend/pshared/lib/data/mapper/ledger/account.dart index 0a7c17e7..488d98ae 100644 --- a/frontend/pshared/lib/data/mapper/ledger/account.dart +++ b/frontend/pshared/lib/data/mapper/ledger/account.dart @@ -1,6 +1,8 @@ import 'package:pshared/data/dto/ledger/account.dart'; import 'package:pshared/data/mapper/describable.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'; @@ -10,9 +12,9 @@ extension LedgerAccountDTOMapper on LedgerAccountDTO { organizationRef: organizationRef, ownerRef: ownerRef, accountCode: accountCode, - accountType: accountType, + accountType: accountType.toDomain(), currency: currency, - status: status, + status: status.toDomain(), allowNegative: allowNegative, isSettlement: isSettlement, metadata: metadata, @@ -29,9 +31,9 @@ extension LedgerAccountModelMapper on LedgerAccount { organizationRef: organizationRef, ownerRef: ownerRef, accountCode: accountCode, - accountType: accountType, + accountType: accountType.toDTO(), currency: currency, - status: status, + status: status.toDTO(), allowNegative: allowNegative, isSettlement: isSettlement, metadata: metadata, diff --git a/frontend/pshared/lib/data/mapper/ledger/status.dart b/frontend/pshared/lib/data/mapper/ledger/status.dart new file mode 100644 index 00000000..3f67aed9 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/ledger/status.dart @@ -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; + } + } +} diff --git a/frontend/pshared/lib/data/mapper/ledger/type.dart b/frontend/pshared/lib/data/mapper/ledger/type.dart new file mode 100644 index 00000000..be214b8f --- /dev/null +++ b/frontend/pshared/lib/data/mapper/ledger/type.dart @@ -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; + } + } +} diff --git a/frontend/pshared/lib/data/mapper/wallet/chain_asset.dart b/frontend/pshared/lib/data/mapper/wallet/chain_asset.dart new file mode 100644 index 00000000..5a8e3528 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/wallet/chain_asset.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/models/ledger/account.dart b/frontend/pshared/lib/models/ledger/account.dart index d569104f..b1ec7ae9 100644 --- a/frontend/pshared/lib/models/ledger/account.dart +++ b/frontend/pshared/lib/models/ledger/account.dart @@ -1,5 +1,7 @@ import 'package:pshared/models/describable.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 { @@ -7,9 +9,9 @@ class LedgerAccount implements Describable { final String organizationRef; final String? ownerRef; final String accountCode; - final String accountType; + final LedgerAccountType accountType; final String currency; - final String status; + final LedgerAccountStatus status; final bool allowNegative; final bool isSettlement; final Map? metadata; diff --git a/frontend/pshared/lib/models/ledger/status.dart b/frontend/pshared/lib/models/ledger/status.dart new file mode 100644 index 00000000..195cf0c5 --- /dev/null +++ b/frontend/pshared/lib/models/ledger/status.dart @@ -0,0 +1,5 @@ +enum LedgerAccountStatus { + unspecified, + active, + frozen, +} diff --git a/frontend/pshared/lib/models/ledger/type.dart b/frontend/pshared/lib/models/ledger/type.dart new file mode 100644 index 00000000..5c9a8e2a --- /dev/null +++ b/frontend/pshared/lib/models/ledger/type.dart @@ -0,0 +1,7 @@ +enum LedgerAccountType { + unspecified, + asset, + liability, + revenue, + expense, +} diff --git a/frontend/pshared/lib/models/wallet/asset.dart b/frontend/pshared/lib/models/wallet/asset.dart index 9cdb8ef4..08fb274e 100644 --- a/frontend/pshared/lib/models/wallet/asset.dart +++ b/frontend/pshared/lib/models/wallet/asset.dart @@ -1,14 +1,12 @@ -import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/wallet/chain_asset.dart'; -class WalletAsset { - final ChainNetwork chain; - final String tokenSymbol; +class WalletAsset extends ChainAsset { final String contractAddress; const WalletAsset({ - required this.chain, - required this.tokenSymbol, + required super.chain, + required super.tokenSymbol, required this.contractAddress, }); } diff --git a/frontend/pshared/lib/models/wallet/chain_asset.dart b/frontend/pshared/lib/models/wallet/chain_asset.dart new file mode 100644 index 00000000..65c05358 --- /dev/null +++ b/frontend/pshared/lib/models/wallet/chain_asset.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/provider/accounts/employees.dart b/frontend/pshared/lib/provider/accounts/employees.dart index f2c373d1..652775c2 100644 --- a/frontend/pshared/lib/provider/accounts/employees.dart +++ b/frontend/pshared/lib/provider/accounts/employees.dart @@ -6,6 +6,7 @@ import 'package:pshared/models/organization/employee.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/accounts/employees.dart'; +import 'package:pshared/utils/exception.dart'; class EmployeesProvider extends ChangeNotifier { @@ -46,10 +47,7 @@ class EmployeesProvider extends ChangeNotifier { error: null, ); } catch (e) { - _employees = _employees.copyWith( - error: e is Exception ? e : Exception('Unknown error: ${e.toString()}'), - isLoading: false, - ); + _employees = _employees.copyWith(error: toException(e), isLoading: false); } notifyListeners(); diff --git a/frontend/pshared/lib/provider/ledger.dart b/frontend/pshared/lib/provider/ledger.dart index d5359fdc..f70c6d5a 100644 --- a/frontend/pshared/lib/provider/ledger.dart +++ b/frontend/pshared/lib/provider/ledger.dart @@ -4,8 +4,10 @@ import 'dart:math'; import 'package:flutter/foundation.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/provider/organizations.dart'; import 'package:pshared/provider/resource.dart'; @@ -159,6 +161,30 @@ class LedgerAccountsProvider with ChangeNotifier { } } + Future 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 ---------- void _applyResource(Resource> newResource, {required bool notify}) { diff --git a/frontend/pshared/lib/provider/payment/wallets.dart b/frontend/pshared/lib/provider/payment/wallets.dart index b3c75c9f..107ab91d 100644 --- a/frontend/pshared/lib/provider/payment/wallets.dart +++ b/frontend/pshared/lib/provider/payment/wallets.dart @@ -5,7 +5,9 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; +import 'package:pshared/models/describable.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/resource.dart'; import 'package:pshared/service/payment/wallets.dart'; @@ -159,6 +161,30 @@ class WalletsProvider with ChangeNotifier { } } + Future 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 ---------- void _applyResource(Resource> newResource, {required bool notify}) { diff --git a/frontend/pshared/lib/service/ledger.dart b/frontend/pshared/lib/service/ledger.dart index 6b0e5ea5..d8374296 100644 --- a/frontend/pshared/lib/service/ledger.dart +++ b/frontend/pshared/lib/service/ledger.dart @@ -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/balance.dart'; +import 'package:pshared/data/mapper/describable.dart'; import 'package:pshared/data/mapper/ledger/account.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/balance.dart'; +import 'package:pshared/models/ledger/type.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; +import 'package:pshared/utils/currency.dart'; class LedgerService { @@ -29,4 +36,22 @@ class LedgerService { ); return LedgerBalanceResponse.fromJson(json).balance.toDomain(); } + + Future 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(), + ); } diff --git a/frontend/pshared/lib/service/payment/wallets.dart b/frontend/pshared/lib/service/payment/wallets.dart index 761dfbaf..ba43f16f 100644 --- a/frontend/pshared/lib/service/payment/wallets.dart +++ b/frontend/pshared/lib/service/payment/wallets.dart @@ -1,11 +1,19 @@ 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/wallet/chain_asset.dart'; import 'package:pshared/service/wallet.dart' as shared_wallet_service; abstract class WalletsService { Future> getWallets(String organizationRef); Future getBalance(String organizationRef, String walletRef); + Future create({ + required String organizationRef, + required Describable describable, + required ChainAsset asset, + required String? ownerRef, + }); } class ApiWalletsService implements WalletsService { @@ -24,4 +32,17 @@ class ApiWalletsService implements WalletsService { final amount = balance.available?.amount; return amount == null ? 0 : double.tryParse(amount) ?? 0; } + + @override + Future 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, + ); } diff --git a/frontend/pshared/lib/service/wallet.dart b/frontend/pshared/lib/service/wallet.dart index e3d554bd..15d0b1e4 100644 --- a/frontend/pshared/lib/service/wallet.dart +++ b/frontend/pshared/lib/service/wallet.dart @@ -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/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/models/describable.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/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; @@ -28,4 +33,19 @@ class WalletService { ); return WalletBalanceResponse.fromJson(json).toDomain(); } + + static Future 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(), + ); } diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index ee55711e..a40abd6d 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -279,7 +279,7 @@ void _openWalletEdit( Wallet wallet, { required PayoutDestination returnTo, }) { - context.read().selectWallet(wallet); + context.read().selectWallet(wallet, allowHidden: true); context.pushToEditWallet(returnTo: returnTo); } @@ -288,7 +288,7 @@ void _openWalletTopUp( Wallet wallet, { required PayoutDestination returnTo, }) { - context.read().selectWallet(wallet); + context.read().selectWallet(wallet, allowHidden: true); context.pushToWalletTopUp(returnTo: returnTo); } diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 610c9293..0275b3db 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -608,6 +608,8 @@ "noRecipientsFound": "No recipients found for this query.", "sourceOfFunds": "Source of funds", "walletTopUp": "Top up", + "errorCreateManagedWallet": "Failed to create managed wallet.", + "errorCreateLedgerAccount": "Failed to create ledger account.", "englishLanguage": "English", "russianLanguage": "Russian", "germanLanguage": "German" diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index e9329b6a..e34181b3 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -609,6 +609,8 @@ "noRecipientsFound": "Получатели по запросу не найдены.", "sourceOfFunds": "Источник средств", "walletTopUp": "Пополнение", + "errorCreateManagedWallet": "Не удалось создать управляемый кошелек.", + "errorCreateLedgerAccount": "Не удалось создать счет бухгалтерской книги.", "englishLanguage": "Английский", "russianLanguage": "Русский", "germanLanguage": "Немецкий" diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index e041c1e9..e3867b89 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -16,8 +16,10 @@ import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/accounts/employees.dart'; import 'package:pshared/provider/recipient/pmethods.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/invitations.dart'; +import 'package:pshared/service/ledger.dart'; import 'package:pshared/service/payment/wallets.dart'; import 'package:pweb/app/app.dart'; @@ -96,6 +98,10 @@ void main() async { create: (_) => WalletsProvider(ApiWalletsService()), update: (context, organizations, provider) => provider!..update(organizations), ), + ChangeNotifierProxyProvider( + create: (_) => LedgerAccountsProvider(LedgerService()), + update: (context, organizations, provider) => provider!..update(organizations), + ), ChangeNotifierProxyProvider( create: (_) => WalletsController(), update: (_, wallets, controller) => controller!..update(wallets), @@ -111,4 +117,4 @@ void main() async { ), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/asset_type_field.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/asset_type_field.dart new file mode 100644 index 00000000..6dff4f58 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/asset_type_field.dart @@ -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? onChanged; + + const AssetTypeField({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return DropdownButtonFormField( + 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, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/cancel.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/cancel.dart new file mode 100644 index 00000000..bce92a00 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/cancel.dart @@ -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), + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/card.dart new file mode 100644 index 00000000..888b6b94 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/card.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/constants.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/constants.dart new file mode 100644 index 00000000..e7927fe4 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/constants.dart @@ -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; diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/description.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/description.dart new file mode 100644 index 00000000..bc06971f --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/description.dart @@ -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, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/dialog.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/dialog.dart new file mode 100644 index 00000000..a7c4ce62 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/dialog.dart @@ -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 showAddBalanceDialog(BuildContext context) => showDialog( + context: context, + builder: (dialogContext) => const AddBalanceDialog(), +); + +class AddBalanceDialog extends StatefulWidget { + const AddBalanceDialog({super.key}); + + @override + State createState() => _AddBalanceDialogState(); +} + +class _AddBalanceDialogState extends State { + final _formKey = GlobalKey(); + 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 _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().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( + context: context, + errorMessage: errorMessage, + action: () async { + if (_assetType == PaymentType.managedWallet) { + await context.read().create( + describable: newDescribable(name: name, description: description), + asset: ChainAsset(chain: _network, tokenSymbol: currencyCodeToString(_managedCurrency)), + ownerRef: owner?.id, + ); + } else { + await context.read().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( + (provider) => provider.isOrganizationSet ? provider.current.name : null, + ); + final employeesProvider = context.watch(); + final employees = employeesProvider.employees; + final isSaving = context.watch().isLoading || + context.watch().isLoading; + + final ownerItems = >[ + 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, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart new file mode 100644 index 00000000..e109e52b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart @@ -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), + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart new file mode 100644 index 00000000..c8ce9b85 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart @@ -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 formKey; + final PaymentType assetType; + final bool isSaving; + final List> ownerItems; + final String ownerValue; + final ValueChanged onAssetTypeChanged; + final ValueChanged onOwnerChanged; + final TextEditingController nameController; + final TextEditingController descriptionController; + final Currency managedCurrency; + final ChainNetwork network; + final Currency ledgerCurrency; + final ValueChanged onManagedCurrencyChanged; + final ValueChanged onNetworkChanged; + final ValueChanged 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(), + ], + ), + ), + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart new file mode 100644 index 00000000..13afdadd --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart @@ -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? onCurrencyChanged; + + const LedgerFields({ + super.key, + required this.currency, + required this.onCurrencyChanged, + }); + + @override + Widget build(BuildContext context) => DropdownButtonFormField( + initialValue: currency, + decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true), + items: [ + DropdownMenuItem( + value: ledgerCurrencyDefault, + child: Text(currencyCodeToString(ledgerCurrencyDefault)), + ), + ], + onChanged: onCurrencyChanged, + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart new file mode 100644 index 00000000..7f16a800 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart @@ -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? onCurrencyChanged; + final ValueChanged? 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( + initialValue: currency, + decoration: getInputDecoration(context, l10n.currency, true), + items: [ + DropdownMenuItem( + value: managedCurrencyDefault, + child: Text(currencyCodeToString(managedCurrencyDefault)), + ), + ], + onChanged: onCurrencyChanged, + ), + DropdownButtonFormField( + initialValue: network, + decoration: getInputDecoration(context, l10n.walletTopUpNetworkLabel, true), + items: [ + DropdownMenuItem( + value: managedNetworkDefault, + child: Text(managedNetworkDefault.localizedName(context)), + ), + ], + onChanged: onNetworkChanged, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/name.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/name.dart new file mode 100644 index 00000000..3d34c2a0 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/name.dart @@ -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, + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart new file mode 100644 index 00000000..e370a55b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/owner.dart @@ -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> items; + final ValueChanged? onChanged; + + const OwnerField({ + super.key, + required this.value, + required this.items, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) => DropdownButtonFormField( + initialValue: value, + decoration: getInputDecoration(context, AppLocalizations.of(context)!.colDataOwner, true), + items: items, + onChanged: onChanged, + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/submit.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/submit.dart new file mode 100644 index 00000000..b28afb35 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/submit.dart @@ -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), + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart index 4c257aa1..0b303c16 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/controllers/wallets.dart'; +import 'package:pshared/provider/ledger.dart'; import 'package:pshared/models/payment/wallet.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/balance_item.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -22,38 +24,69 @@ class BalanceWidget extends StatelessWidget { @override Widget build(BuildContext context) { final walletsController = context.watch(); + final ledgerProvider = context.watch(); final carousel = context.watch(); 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()); } - final wallets = walletsController.wallets; + final items = [ + ...wallets.map(BalanceItem.wallet), + ...accounts.map(BalanceItem.ledger), + const BalanceItem.addAction(), + ]; - if (wallets.isEmpty) { - return Center(child: Text(loc.noWalletsAvailable)); + if (items.isEmpty) { + return const SizedBox.shrink(); } - // Ensure index is always valid when wallets list changes - carousel.setIndex(carousel.index, wallets.length); + // Ensure index is always valid when list changes + carousel.setIndex(carousel.index, items.length); final index = carousel.index; - final wallet = wallets[index]; + final current = items[index]; // Single source of truth: controller - if (walletsController.selectedWallet?.id != wallet.id) { - walletsController.selectWallet(wallet); + if (current.isWallet) { + final wallet = current.wallet!; + if (walletsController.selectedWallet?.id != wallet.id) { + walletsController.selectWallet(wallet); + } } - return WalletCarousel( - wallets: wallets, + final carouselWidget = BalanceCarousel( + items: items, currentIndex: index, onIndexChanged: (i) { - carousel.setIndex(i, wallets.length); - walletsController.selectWallet(wallets[i]); + carousel.setIndex(i, items.length); + final next = items[carousel.index]; + if (next.isWallet) { + walletsController.selectWallet(next.wallet!); + } }, 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; } } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart new file mode 100644 index 00000000..0e992d8a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart @@ -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; +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart index 327843b8..fc255063 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart @@ -4,12 +4,16 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/wallets.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/amount.dart'; import 'package:pweb/pages/dashboard/buttons/balance/config.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 { @@ -24,42 +28,49 @@ class WalletCard extends StatelessWidget { @override 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( color: Theme.of(context).colorScheme.onSecondary, elevation: WalletCardConfig.elevation, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), ), - child: Padding( - padding: WalletCardConfig.contentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BalanceHeader( - walletNetwork: wallet.network, - tokenSymbol: wallet.tokenSymbol, - ), - Row( - children: [ - BalanceAmount( - wallet: wallet, - onToggleVisibility: () { - context.read().toggleVisibility(wallet.id); - }, - ), - WalletBalanceRefreshButton( - walletId: wallet.id, - ), - ], - ), - BalanceAddFunds( - onTopUp: () { - onTopUp(); - }, - ), - ], + child: SizedBox.expand( + child: Padding( + padding: WalletCardConfig.contentPadding, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BalanceHeader( + title: loc.paymentTypeCryptoWallet, + subtitle: networkLabel, + badge: (symbol == null || symbol.isEmpty) ? null : symbol, + ), + Row( + children: [ + BalanceAmount( + wallet: wallet, + onToggleVisibility: () { + context.read().toggleVisibility(wallet.id); + }, + ), + WalletBalanceRefreshButton( + walletRef: wallet.id, + ), + ], + ), + BalanceAddFunds(onTopUp: onTopUp), + ], + ), ), ), + ); } } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart index 15ae1750..4861d928 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart @@ -1,43 +1,117 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.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/config.dart'; import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/ledger.dart'; -class WalletCarousel extends StatelessWidget { - final List wallets; +class BalanceCarousel extends StatefulWidget { + final List items; final int currentIndex; final ValueChanged onIndexChanged; final ValueChanged onTopUp; - const WalletCarousel({ + const BalanceCarousel({ super.key, - required this.wallets, + required this.items, required this.currentIndex, required this.onIndexChanged, required this.onTopUp, }); + @override + State createState() => _BalanceCarouselState(); +} + +class _BalanceCarouselState extends State { + 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 Widget build(BuildContext context) { - if (wallets.isEmpty) { + if (widget.items.isEmpty) { return const SizedBox.shrink(); } - final safeIndex = currentIndex.clamp(0, wallets.length - 1); - final wallet = wallets[safeIndex]; + final safeIndex = widget.currentIndex.clamp(0, widget.items.length - 1); + final scrollBehavior = ScrollConfiguration.of(context).copyWith( + dragDevices: const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + }, + ); return Column( children: [ SizedBox( height: WalletCardConfig.cardHeight, - child: Padding( - padding: WalletCardConfig.cardPadding, - child: WalletCard( - wallet: wallet, - onTopUp: () => onTopUp(wallet), + 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, + child: card, + ); + }, + ), ), ), ), @@ -47,20 +121,18 @@ class WalletCarousel extends StatelessWidget { children: [ IconButton( onPressed: safeIndex > 0 - ? () => onIndexChanged(safeIndex - 1) + ? () => _goToPage(safeIndex - 1) : null, icon: const Icon(Icons.arrow_back), ), const SizedBox(width: 16), CarouselIndicator( - itemCount: wallets.length, + itemCount: widget.items.length, index: safeIndex, ), const SizedBox(width: 16), IconButton( - onPressed: safeIndex < wallets.length - 1 - ? () => onIndexChanged(safeIndex + 1) - : null, + onPressed: safeIndex < widget.items.length - 1 ? () => _goToPage(safeIndex + 1) : null, icon: const Icon(Icons.arrow_forward), ), ], diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart index b3155c43..a00a2075 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart @@ -1,29 +1,24 @@ 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 { - final ChainNetwork? walletNetwork; - final String? tokenSymbol; + final String title; + final String? subtitle; + final String? badge; const BalanceHeader({ super.key, - this.walletNetwork, - this.tokenSymbol, + required this.title, + this.subtitle, + this.badge, }); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - final loc = AppLocalizations.of(context)!; - final symbol = tokenSymbol?.trim(); - final networkLabel = (walletNetwork == null || walletNetwork == ChainNetwork.unspecified) - ? null - : walletNetwork!.localizedName(context); + final subtitleText = subtitle?.trim(); + final badgeText = badge?.trim(); return Row( children: [ @@ -32,14 +27,14 @@ class BalanceHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - loc.paymentTypeCryptoWallet, + title, style: textTheme.titleMedium?.copyWith( color: colorScheme.onSurface, ), ), - if (networkLabel != null) + if (subtitleText != null && subtitleText.isNotEmpty) Text( - networkLabel, + subtitleText, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, 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), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -57,7 +52,7 @@ class BalanceHeader extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Text( - symbol, + badgeText, style: textTheme.bodyMedium?.copyWith( color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart new file mode 100644 index 00000000..b4defe51 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart @@ -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, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart index 0eac324a..195627ad 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart @@ -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/utils/dimensions.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'; @@ -87,7 +87,7 @@ class PaymentPageContent extends StatelessWidget { if (selectedWalletId == null) { return const SizedBox.shrink(); } - return WalletBalanceRefreshButton(walletId: selectedWalletId); + return WalletBalanceRefreshButton(walletRef: selectedWalletId); }, ), ], diff --git a/frontend/pweb/lib/pages/payout_page/wallet/card.dart b/frontend/pweb/lib/pages/payout_page/wallet/card.dart index bfcf182b..8ea9e08f 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/card.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/card.dart @@ -8,7 +8,7 @@ import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/models/visibility.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'; @@ -55,7 +55,7 @@ class WalletCard extends StatelessWidget { }, ), WalletBalanceRefreshButton( - walletId: wallet.id, + walletRef: wallet.id, iconOnly: VisibilityState.hidden, ), ], diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/fields.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/fields.dart index 819b1744..31770b04 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/fields.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/fields.dart @@ -6,7 +6,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/wallets.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 { @@ -33,7 +33,7 @@ class WalletEditFields extends StatelessWidget { onToggleVisibility: () => controller.toggleVisibility(wallet.id), ), ), - WalletBalanceRefreshButton(walletId: wallet.id), + WalletBalanceRefreshButton(walletRef: wallet.id), ], ), const SizedBox(height: 8), diff --git a/frontend/pweb/lib/widgets/refresh_balance/button.dart b/frontend/pweb/lib/widgets/refresh_balance/button.dart new file mode 100644 index 00000000..982d8cbe --- /dev/null +++ b/frontend/pweb/lib/widgets/refresh_balance/button.dart @@ -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), + ); + } +} diff --git a/frontend/pweb/lib/widgets/refresh_balance/ledger.dart b/frontend/pweb/lib/widgets/refresh_balance/ledger.dart new file mode 100644 index 00000000..7a96913f --- /dev/null +++ b/frontend/pweb/lib/widgets/refresh_balance/ledger.dart @@ -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(); + 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(); + provider.refreshBalance(ledgerAccountRef); + } + + return BalanceRefreshButton( + isBusy: isBusy, + enabled: hasTarget, + onPressed: refresh, + iconOnly: iconOnly, + label: loc.refreshBalance, + tooltip: loc.refreshBalance, + iconSize: iconSize, + ); + } +} diff --git a/frontend/pweb/lib/widgets/refresh_balance/wallet.dart b/frontend/pweb/lib/widgets/refresh_balance/wallet.dart new file mode 100644 index 00000000..b3fc1f1a --- /dev/null +++ b/frontend/pweb/lib/widgets/refresh_balance/wallet.dart @@ -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(); + 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(); + provider.refreshBalance(walletRef); + } + + return BalanceRefreshButton( + isBusy: isBusy, + enabled: hasTarget, + onPressed: refresh, + iconOnly: iconOnly, + label: loc.refreshBalance, + tooltip: loc.refreshBalance, + iconSize: iconSize, + ); + } +} diff --git a/frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart b/frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart deleted file mode 100644 index 2fdc2d85..00000000 --- a/frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart +++ /dev/null @@ -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(); - 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(); - 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), - ); - } -}