From 97b16542c2163c0916ca0465c4c8ce9eb8362489 Mon Sep 17 00:00:00 2001 From: Arseni Date: Thu, 5 Mar 2026 21:49:23 +0300 Subject: [PATCH] ledger top up functionality and few small fixes for project architechture and design --- .../mongo/store/treasury_telegram_users.go | 87 ------------ .../models/payment/operation_document.dart} | 8 +- .../pweb/lib/app/router/payout_routes.dart | 35 +++-- .../pweb/lib/app/router/payout_shell.dart | 17 +++ .../lib/controllers/payments/details.dart | 7 +- frontend/pweb/lib/l10n/en.arb | 2 +- frontend/pweb/lib/l10n/ru.arb | 2 +- .../lib/models/dashboard/balance_item.dart | 26 ++++ .../pweb/lib/models/documents/operation.dart | 9 -- .../lib/models/payment/payment_state.dart | 27 ---- .../buttons/balance/balance_item.dart | 21 --- .../pages/dashboard/buttons/balance/card.dart | 65 +-------- .../dashboard/buttons/balance/carousel.dart | 24 ++-- .../dashboard/buttons/balance/controller.dart | 29 ++-- .../dashboard/buttons/balance/ledger.dart | 130 ++---------------- .../buttons/balance/ledger_amount.dart | 55 ++++++++ .../balance/source/actions/ledger.dart | 44 ++++++ .../balance/source/actions/wallet.dart | 42 ++++++ .../buttons/balance/source/card.dart | 99 +++++++++++++ .../buttons/balance/source/card_layout.dart | 61 ++++++++ .../dashboard/buttons/balance/widget.dart | 4 +- .../pweb/lib/pages/dashboard/dashboard.dart | 3 + .../pages/dashboard/payouts/amount/feild.dart | 1 - .../dashboard/payouts/multiple/actions.dart | 2 +- .../payouts/single/address_book/avatar.dart | 4 +- .../single/address_book/short_list.dart | 64 ++++----- .../payouts/single/address_book/widget.dart | 52 +++---- .../pweb/lib/pages/payout_page/send/page.dart | 10 +- .../lib/pages/payout_page/send/page_view.dart | 7 +- .../widgets/payment_info/manual_details.dart | 51 +++++++ .../send/widgets/payment_info/section.dart | 13 +- .../send/widgets/recipient/section.dart | 2 +- .../send/widgets/recipient_details_card.dart | 37 +++-- .../wallet/edit/buttons/top_up.dart | 32 ++++- .../pweb/lib/providers/multiple_payouts.dart | 5 +- .../csv_parser.dart} | 0 .../intent_builder.dart} | 0 .../send => utils/payment}/page_handlers.dart | 23 +++- ...ation_flow.dart => verification_flow.dart} | 0 .../pweb/lib/utils/report/payment_mapper.dart | 7 +- .../pweb/lib/utils/report/source_filter.dart | 112 +++++++++++++++ 41 files changed, 764 insertions(+), 455 deletions(-) delete mode 100644 api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go rename frontend/{pweb/lib/models/report/operation/document.dart => pshared/lib/models/payment/operation_document.dart} (68%) create mode 100644 frontend/pweb/lib/models/dashboard/balance_item.dart delete mode 100644 frontend/pweb/lib/models/documents/operation.dart delete mode 100644 frontend/pweb/lib/models/payment/payment_state.dart delete mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/ledger_amount.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/ledger.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/wallet.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/source/card.dart create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/source/card_layout.dart create mode 100644 frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/manual_details.dart rename frontend/pweb/lib/utils/payment/{multiple_csv_parser.dart => multiple/csv_parser.dart} (100%) rename frontend/pweb/lib/utils/payment/{multiple_intent_builder.dart => multiple/intent_builder.dart} (100%) rename frontend/pweb/lib/{pages/payout_page/send => utils/payment}/page_handlers.dart (82%) rename frontend/pweb/lib/utils/payment/{payout_verification_flow.dart => verification_flow.dart} (100%) create mode 100644 frontend/pweb/lib/utils/report/source_filter.dart diff --git a/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go b/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go deleted file mode 100644 index 04c4e597..00000000 --- a/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go +++ /dev/null @@ -1,87 +0,0 @@ -package store - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/gateway/tgsettle/storage" - "github.com/tech/sendico/gateway/tgsettle/storage/model" - "github.com/tech/sendico/pkg/db/repository" - ri "github.com/tech/sendico/pkg/db/repository/index" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -const ( - treasuryTelegramUsersCollection = "treasury_telegram_users" - fieldTreasuryTelegramUserID = "telegramUserId" -) - -type TreasuryTelegramUsers struct { - logger mlogger.Logger - repo repository.Repository -} - -func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) { - if db == nil { - return nil, merrors.InvalidArgument("mongo database is nil") - } - if logger == nil { - logger = zap.NewNop() - } - logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection)) - - repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection) - if err := repo.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}}, - Unique: true, - }); err != nil { - logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID)) - return nil, err - } - - return &TreasuryTelegramUsers{ - logger: logger, - repo: repo, - }, nil -} - -func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) { - telegramUserID = strings.TrimSpace(telegramUserID) - if telegramUserID == "" { - return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id") - } - var result model.TreasuryTelegramUser - err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result) - if errors.Is(err, merrors.ErrNoData) { - return nil, nil - } - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID)) - } - return nil, err - } - result.TelegramUserID = strings.TrimSpace(result.TelegramUserID) - result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID) - if len(result.AllowedChatIDs) > 0 { - normalized := make([]string, 0, len(result.AllowedChatIDs)) - for _, next := range result.AllowedChatIDs { - next = strings.TrimSpace(next) - if next == "" { - continue - } - normalized = append(normalized, next) - } - result.AllowedChatIDs = normalized - } - if result.TelegramUserID == "" || result.LedgerAccountID == "" { - return nil, nil - } - return &result, nil -} - -var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil) diff --git a/frontend/pweb/lib/models/report/operation/document.dart b/frontend/pshared/lib/models/payment/operation_document.dart similarity index 68% rename from frontend/pweb/lib/models/report/operation/document.dart rename to frontend/pshared/lib/models/payment/operation_document.dart index 9379305e..9712a046 100644 --- a/frontend/pweb/lib/models/report/operation/document.dart +++ b/frontend/pshared/lib/models/payment/operation_document.dart @@ -1,9 +1,9 @@ -class OperationDocumentInfo { - final String operationRef; +class OperationDocumentRef { final String gatewayService; + final String operationRef; - const OperationDocumentInfo({ - required this.operationRef, + const OperationDocumentRef({ required this.gatewayService, + required this.operationRef, }); } diff --git a/frontend/pweb/lib/app/router/payout_routes.dart b/frontend/pweb/lib/app/router/payout_routes.dart index f1e69a33..60543fb9 100644 --- a/frontend/pweb/lib/app/router/payout_routes.dart +++ b/frontend/pweb/lib/app/router/payout_routes.dart @@ -25,6 +25,7 @@ class PayoutRoutes { static const walletTopUp = 'payout-wallet-top-up'; static const paymentTypeQuery = 'paymentType'; + static const destinationLedgerAccountRefQuery = 'destinationLedgerAccountRef'; static const reportPaymentIdQuery = 'paymentId'; static const dashboardPath = '/dashboard'; @@ -40,7 +41,6 @@ class PayoutRoutes { static const editWalletPath = '/methods/edit'; static const walletTopUpPath = '/wallet/top-up'; - static String nameFor(PayoutDestination destination) { switch (destination) { case PayoutDestination.dashboard: @@ -126,9 +126,13 @@ class PayoutRoutes { static Map buildQueryParameters({ PaymentType? paymentType, + String? destinationLedgerAccountRef, }) { final params = { if (paymentType != null) paymentTypeQuery: paymentType.name, + if (destinationLedgerAccountRef != null && + destinationLedgerAccountRef.trim().isNotEmpty) + destinationLedgerAccountRefQuery: destinationLedgerAccountRef.trim(), }; return params; } @@ -140,35 +144,44 @@ class PayoutRoutes { ? null : PaymentType.values.firstWhereOrNull((type) => type.name == raw); + static String? destinationLedgerAccountRefFromState(GoRouterState state) => + destinationLedgerAccountRefFromRaw( + state.uri.queryParameters[destinationLedgerAccountRefQuery], + ); + + static String? destinationLedgerAccountRefFromRaw(String? raw) { + final value = raw?.trim(); + if (value == null || value.isEmpty) return null; + return value; + } } extension PayoutNavigation on BuildContext { - void goToPayout(PayoutDestination destination) => goNamed(PayoutRoutes.nameFor(destination)); + void goToPayout(PayoutDestination destination) => + goNamed(PayoutRoutes.nameFor(destination)); - void pushToPayout(PayoutDestination destination) => pushNamed(PayoutRoutes.nameFor(destination)); + void pushToPayout(PayoutDestination destination) => + pushNamed(PayoutRoutes.nameFor(destination)); void goToPayment({ PaymentType? paymentType, - }) => - goNamed( + String? destinationLedgerAccountRef, + }) => goNamed( PayoutRoutes.payment, queryParameters: PayoutRoutes.buildQueryParameters( paymentType: paymentType, + destinationLedgerAccountRef: destinationLedgerAccountRef, ), ); void goToReportPayment(String paymentId) => goNamed( PayoutRoutes.reportPayment, - queryParameters: { - PayoutRoutes.reportPaymentIdQuery: paymentId, - }, + queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId}, ); void pushToReportPayment(String paymentId) => pushNamed( PayoutRoutes.reportPayment, - queryParameters: { - PayoutRoutes.reportPaymentIdQuery: paymentId, - }, + queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId}, ); void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp); diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 83b50dff..768b84b2 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -228,6 +228,7 @@ RouteBase payoutShellRoute() => ShellRoute( onGoToPaymentWithoutRecipient: (type) => _startPayment(context, recipient: null, paymentType: type), onTopUp: (wallet) => _openWalletTopUp(context, wallet), + onLedgerAddFunds: (account) => _openLedgerAddFunds(context, account), onWalletTap: (wallet) => _openWalletEdit(context, wallet), onLedgerTap: (account) => _openLedgerEdit(context, account), ), @@ -306,6 +307,8 @@ RouteBase payoutShellRoute() => ShellRoute( child: PaymentPage( onBack: (_) => _popOrGo(context), initialPaymentType: PayoutRoutes.paymentTypeFromState(state), + initialDestinationLedgerAccountRef: + PayoutRoutes.destinationLedgerAccountRefFromState(state), fallbackDestination: fallbackDestination, ), ); @@ -395,6 +398,20 @@ void _openLedgerEdit(BuildContext context, LedgerAccount account) { context.pushToEditWallet(); } +void _openLedgerAddFunds(BuildContext context, LedgerAccount account) { + context.read().selectLedgerByRef( + account.ledgerAccountRef, + ); + context.read().setCurrentObject(null); + context.pushNamed( + PayoutRoutes.payment, + queryParameters: PayoutRoutes.buildQueryParameters( + paymentType: PaymentType.ledger, + destinationLedgerAccountRef: account.ledgerAccountRef, + ), + ); +} + void _openWalletTopUp(BuildContext context, Wallet wallet) { context.read().selectWallet(wallet); context.pushToWalletTopUp(); diff --git a/frontend/pweb/lib/controllers/payments/details.dart b/frontend/pweb/lib/controllers/payments/details.dart index 71e7be0a..1c31439a 100644 --- a/frontend/pweb/lib/controllers/payments/details.dart +++ b/frontend/pweb/lib/controllers/payments/details.dart @@ -1,12 +1,13 @@ import 'package:flutter/foundation.dart'; +import 'package:pshared/models/payment/operation_document.dart'; import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/provider/payment/payments.dart'; -import 'package:pweb/models/documents/operation.dart'; import 'package:pweb/utils/report/operations/document_rule.dart'; + class PaymentDetailsController extends ChangeNotifier { PaymentDetailsController({required String paymentId}) : _paymentId = paymentId; @@ -20,7 +21,7 @@ class PaymentDetailsController extends ChangeNotifier { bool get isLoading => _payments?.isLoading ?? false; Exception? get error => _payments?.error; - OperationDocumentRequestModel? operationDocumentRequest( + OperationDocumentRef? operationDocumentRequest( PaymentExecutionOperation operation, ) { final current = _payment; @@ -33,7 +34,7 @@ class PaymentDetailsController extends ChangeNotifier { if (!isOperationDocumentEligible(operation.code)) return null; - return OperationDocumentRequestModel( + return OperationDocumentRef( gatewayService: gatewayService, operationRef: operationRef, ); diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 6c37a488..dbc8fee2 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -638,7 +638,7 @@ } } }, - "noFee": "No fee", + "noFee": "None", "recipientWillReceive": "Recipient will receive: {amount}", "@recipientWillReceive": { diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a1777eb1..ef8b6f04 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -638,7 +638,7 @@ } } }, - "noFee": "Нет комиссии", + "noFee": "Без оплаты", "recipientWillReceive": "Получатель получит: {amount}", "@recipientWillReceive": { diff --git a/frontend/pweb/lib/models/dashboard/balance_item.dart b/frontend/pweb/lib/models/dashboard/balance_item.dart new file mode 100644 index 00000000..713dc8fe --- /dev/null +++ b/frontend/pweb/lib/models/dashboard/balance_item.dart @@ -0,0 +1,26 @@ +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +sealed class BalanceItem { + const BalanceItem(); + + const factory BalanceItem.wallet(Wallet wallet) = WalletBalanceItem; + const factory BalanceItem.ledger(LedgerAccount account) = LedgerBalanceItem; + const factory BalanceItem.addAction() = AddBalanceActionItem; +} + +final class WalletBalanceItem extends BalanceItem { + final Wallet wallet; + + const WalletBalanceItem(this.wallet); +} + +final class LedgerBalanceItem extends BalanceItem { + final LedgerAccount account; + + const LedgerBalanceItem(this.account); +} + +final class AddBalanceActionItem extends BalanceItem { + const AddBalanceActionItem(); +} diff --git a/frontend/pweb/lib/models/documents/operation.dart b/frontend/pweb/lib/models/documents/operation.dart deleted file mode 100644 index c669d04c..00000000 --- a/frontend/pweb/lib/models/documents/operation.dart +++ /dev/null @@ -1,9 +0,0 @@ -class OperationDocumentRequestModel { - final String gatewayService; - final String operationRef; - - const OperationDocumentRequestModel({ - required this.gatewayService, - required this.operationRef, - }); -} diff --git a/frontend/pweb/lib/models/payment/payment_state.dart b/frontend/pweb/lib/models/payment/payment_state.dart deleted file mode 100644 index 814abf3c..00000000 --- a/frontend/pweb/lib/models/payment/payment_state.dart +++ /dev/null @@ -1,27 +0,0 @@ -enum PaymentState { - success, - failed, - cancelled, - processing, - unknown, -} - -PaymentState paymentStateFromRaw(String? raw) { - final trimmed = (raw ?? '').trim().toUpperCase(); - final normalized = trimmed.startsWith('PAYMENT_STATE_') - ? trimmed.substring('PAYMENT_STATE_'.length) - : trimmed; - - switch (normalized) { - case 'SUCCESS': - return PaymentState.success; - case 'FAILED': - return PaymentState.failed; - case 'CANCELLED': - return PaymentState.cancelled; - case 'PROCESSING': - return PaymentState.processing; - default: - return PaymentState.unknown; - } -} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart deleted file mode 100644 index 0e992d8a..00000000 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance_item.dart +++ /dev/null @@ -1,21 +0,0 @@ -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 55602f4c..a23e98df 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart @@ -1,17 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pshared/controllers/balance_mask/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/refresh_balance/wallet.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart'; class WalletCard extends StatelessWidget { @@ -28,56 +19,10 @@ class WalletCard extends StatelessWidget { @override Widget build(BuildContext 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: InkWell( - borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), - onTap: onTap, - child: SizedBox.expand( - child: Padding( - padding: WalletCardConfig.contentPadding, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BalanceHeader( - title: wallet.name, - subtitle: networkLabel, - badge: (symbol == null || symbol.isEmpty) ? null : symbol, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - BalanceAmount( - wallet: wallet, - onToggleMask: () { - context.read().toggleBalanceMask(wallet.id); - }, - ), - Column( - children: [ - WalletBalanceRefreshButton( - walletRef: wallet.id, - ), - BalanceAddFunds(onTopUp: onTopUp), - ], - ), - ], - ), - ], - ), - ), - ), - ), + return BalanceSourceCard.wallet( + wallet: wallet, + onTap: onTap, + onAddFunds: onTopUp, ); } } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart index 84f2dd0e..999aba51 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart @@ -4,18 +4,20 @@ import 'package:flutter/gestures.dart'; import 'package:pshared/models/ledger/account.dart'; import 'package:pshared/models/payment/wallet.dart'; +import 'package:pweb/models/dashboard/balance_item.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 BalanceCarousel extends StatefulWidget { final List items; final int currentIndex; final ValueChanged onIndexChanged; final ValueChanged onTopUp; + final ValueChanged onLedgerAddFunds; final ValueChanged onWalletTap; final ValueChanged onLedgerTap; @@ -25,6 +27,7 @@ class BalanceCarousel extends StatefulWidget { required this.currentIndex, required this.onIndexChanged, required this.onTopUp, + required this.onLedgerAddFunds, required this.onWalletTap, required this.onLedgerTap, }); @@ -101,17 +104,18 @@ class _BalanceCarouselState extends State { 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!), - onTap: () => widget.onWalletTap(item.wallet!), + final Widget card = switch (item) { + WalletBalanceItem(:final wallet) => WalletCard( + wallet: wallet, + onTopUp: () => widget.onTopUp(wallet), + onTap: () => widget.onWalletTap(wallet), ), - BalanceItemType.ledger => LedgerAccountCard( - account: item.account!, - onTap: () => widget.onLedgerTap(item.account!), + LedgerBalanceItem(:final account) => LedgerAccountCard( + account: account, + onTap: () => widget.onLedgerTap(account), + onAddFunds: () => widget.onLedgerAddFunds(account), ), - BalanceItemType.addAction => const AddBalanceCard(), + AddBalanceActionItem() => const AddBalanceCard(), }; return Padding( diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart index df2f6484..88176825 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/provider/ledger.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart'; +import 'package:pweb/models/dashboard/balance_item.dart'; + class BalanceCarouselController with ChangeNotifier { WalletsController? _walletsController; @@ -73,14 +74,19 @@ class BalanceCarouselController with ChangeNotifier { String? _currentWalletRef(List items, int index) { if (items.isEmpty || index < 0 || index >= items.length) return null; final current = items[index]; - if (!current.isWallet) return null; - return current.wallet?.id; + return switch (current) { + WalletBalanceItem(:final wallet) => wallet.id, + _ => null, + }; } int? _walletIndexByRef(List items, String? walletRef) { if (walletRef == null || walletRef.isEmpty) return null; final idx = items.indexWhere( - (item) => item.isWallet && item.wallet?.id == walletRef, + (item) => switch (item) { + WalletBalanceItem(:final wallet) => wallet.id == walletRef, + _ => false, + }, ); if (idx < 0) return null; return idx; @@ -97,17 +103,17 @@ class BalanceCarouselController with ChangeNotifier { for (var i = 0; i < left.length; i++) { final a = left[i]; final b = right[i]; - if (a.type != b.type) return false; + if (a.runtimeType != b.runtimeType) return false; if (_itemIdentity(a) != _itemIdentity(b)) return false; } return true; } - String _itemIdentity(BalanceItem item) => switch (item.type) { - BalanceItemType.wallet => item.wallet?.id ?? '', - BalanceItemType.ledger => item.account?.ledgerAccountRef ?? '', - BalanceItemType.addAction => 'add', + String _itemIdentity(BalanceItem item) => switch (item) { + WalletBalanceItem(:final wallet) => wallet.id, + LedgerBalanceItem(:final account) => account.ledgerAccountRef, + AddBalanceActionItem() => 'add', }; void _syncSelectedWallet() { @@ -115,9 +121,8 @@ class BalanceCarouselController with ChangeNotifier { if (walletsController == null || _items.isEmpty) return; final current = _items[_index]; - if (!current.isWallet || current.wallet == null) return; - - final wallet = current.wallet!; + if (current is! WalletBalanceItem) return; + final wallet = current.wallet; if (walletsController.selectedWallet?.id == wallet.id) return; walletsController.selectWallet(wallet); } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart index f69ffe59..69771c33 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart @@ -1,133 +1,27 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; import 'package:pshared/models/ledger/account.dart'; -import 'package:pshared/utils/currency.dart'; -import 'package:pshared/utils/money.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'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart'; class LedgerAccountCard extends StatelessWidget { final LedgerAccount account; + final VoidCallback onAddFunds; final VoidCallback? onTap; - const LedgerAccountCard({super.key, required this.account, this.onTap}); - - String _formatBalance() { - final money = account.balance?.balance; - if (money == null) return '--'; - - final amount = parseMoneyAmount(money.amount, fallback: double.nan); - if (amount.isNaN) { - 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}'; - } - } - - String _formatMaskedBalance() { - final currency = account.currency.trim(); - if (currency.isEmpty) return '••••'; - try { - final symbol = currencyCodeToSymbol(currencyStringToCode(currency)); - if (symbol.trim().isEmpty) { - return '•••• $currency'; - } - return '•••• $symbol'; - } catch (_) { - return '•••• $currency'; - } - } + const LedgerAccountCard({ + super.key, + required this.account, + required this.onAddFunds, + this.onTap, + }); @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - final loc = AppLocalizations.of(context)!; - final accountName = account.name.trim(); - final accountCode = account.accountCode.trim(); - final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger; - final subtitle = accountCode.isNotEmpty ? accountCode : null; - 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: InkWell( - borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), - onTap: onTap, - child: Padding( - padding: WalletCardConfig.contentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BalanceHeader(title: title, subtitle: subtitle, badge: badge), - Row( - children: [ - Consumer( - builder: (context, controller, _) { - final isMasked = controller.isBalanceMasked( - account.ledgerAccountRef, - ); - return Row( - children: [ - Text( - isMasked - ? _formatMaskedBalance() - : _formatBalance(), - style: textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () => controller.toggleBalanceMask( - account.ledgerAccountRef, - ), - child: Icon( - isMasked - ? Icons.visibility_off - : Icons.visibility, - size: 24, - color: colorScheme.onSurface, - ), - ), - ], - ); - }, - ), - const SizedBox(width: 12), - LedgerBalanceRefreshButton( - ledgerAccountRef: account.ledgerAccountRef, - ), - ], - ), - ], - ), - ), - ), + return BalanceSourceCard.ledger( + account: account, + onTap: onTap ?? () {}, + onAddFunds: onAddFunds, ); } } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger_amount.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger_amount.dart new file mode 100644 index 00000000..fe0c60c4 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger_amount.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/payout_page/wallet/edit/fields/ledger/balance_formatter.dart'; + + +class LedgerBalanceAmount extends StatelessWidget { + final LedgerAccount account; + + const LedgerBalanceAmount({super.key, required this.account}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Consumer( + builder: (context, controller, _) { + final isMasked = controller.isBalanceMasked(account.ledgerAccountRef); + final balance = isMasked + ? LedgerBalanceFormatter.formatMasked(account) + : LedgerBalanceFormatter.format(account); + + return Row( + children: [ + Flexible( + child: Text( + balance, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () { + controller.toggleBalanceMask(account.ledgerAccountRef); + }, + child: Icon( + isMasked ? Icons.visibility_off : Icons.visibility, + size: 24, + color: colorScheme.onSurface, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/ledger.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/ledger.dart new file mode 100644 index 00000000..3a58413a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/ledger.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/ledger.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LedgerSourceActions extends StatelessWidget { + final String ledgerAccountRef; + final VoidCallback onAddFunds; + + const LedgerSourceActions({ + super.key, + required this.ledgerAccountRef, + required this.onAddFunds, + }); + + @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, + ); + + return BalanceActionsBar( + isRefreshBusy: isBusy, + canRefresh: hasTarget, + onRefresh: () { + context.read().refreshBalance(ledgerAccountRef); + }, + onAddFunds: onAddFunds, + refreshLabel: loc.refreshBalance, + addFundsLabel: loc.addFunds, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/wallet.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/wallet.dart new file mode 100644 index 00000000..8c872026 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/actions/wallet.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/wallets.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class WalletSourceActions extends StatelessWidget { + final String walletRef; + final VoidCallback onAddFunds; + + const WalletSourceActions({ + super.key, + required this.walletRef, + required this.onAddFunds, + }); + + @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); + + return BalanceActionsBar( + isRefreshBusy: isBusy, + canRefresh: hasTarget, + onRefresh: () { + context.read().refreshBalance(walletRef); + }, + onAddFunds: onAddFunds, + refreshLabel: loc.refreshBalance, + addFundsLabel: loc.addFunds, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card.dart new file mode 100644 index 00000000..bf56777f --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/payment/source_type.dart'; +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/utils/l10n/chain.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/ledger_amount.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/source/actions/ledger.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/source/actions/wallet.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/source/card_layout.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class BalanceSourceCard extends StatelessWidget { + final PaymentSourceType _type; + final Wallet? _wallet; + final LedgerAccount? _ledgerAccount; + final VoidCallback onTap; + final VoidCallback onAddFunds; + + const BalanceSourceCard.wallet({ + super.key, + required Wallet wallet, + required this.onTap, + required this.onAddFunds, + }) : _type = PaymentSourceType.wallet, + _wallet = wallet, + _ledgerAccount = null; + + const BalanceSourceCard.ledger({ + super.key, + required LedgerAccount account, + required this.onTap, + required this.onAddFunds, + }) : _type = PaymentSourceType.ledger, + _wallet = null, + _ledgerAccount = account; + + @override + Widget build(BuildContext context) => switch (_type) { + PaymentSourceType.wallet => _buildWalletCard(context, _wallet!), + PaymentSourceType.ledger => _buildLedgerCard(context, _ledgerAccount!), + }; + + Widget _buildWalletCard(BuildContext context, Wallet wallet) { + final networkLabel = + (wallet.network == null || wallet.network == ChainNetwork.unspecified) + ? null + : wallet.network!.localizedName(context); + final symbol = wallet.tokenSymbol?.trim(); + + return BalanceSourceCardLayout( + title: wallet.name, + subtitle: networkLabel, + badge: (symbol == null || symbol.isEmpty) ? null : symbol, + onTap: onTap, + actions: WalletSourceActions( + walletRef: wallet.id, + onAddFunds: onAddFunds, + ), + amount: BalanceAmount( + wallet: wallet, + onToggleMask: () { + context.read().toggleBalanceMask(wallet.id); + }, + ), + ); + } + + Widget _buildLedgerCard(BuildContext context, LedgerAccount account) { + final loc = AppLocalizations.of(context)!; + final accountName = account.name.trim(); + final accountCode = account.accountCode.trim(); + final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger; + final subtitle = accountCode.isNotEmpty ? accountCode : null; + final badge = account.currency.trim().isEmpty + ? null + : account.currency.toUpperCase(); + + return BalanceSourceCardLayout( + title: title, + subtitle: subtitle, + badge: badge, + onTap: onTap, + actions: LedgerSourceActions( + ledgerAccountRef: account.ledgerAccountRef, + onAddFunds: onAddFunds, + ), + amount: LedgerBalanceAmount(account: account), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card_layout.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card_layout.dart new file mode 100644 index 00000000..9f85c09b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/source/card_layout.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; + + +class BalanceSourceCardLayout extends StatelessWidget { + final String title; + final String? subtitle; + final String? badge; + final Widget amount; + final Widget actions; + final VoidCallback onTap; + + const BalanceSourceCardLayout({ + super.key, + required this.title, + required this.subtitle, + required this.badge, + required this.amount, + required this.actions, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + color: colorScheme.onSecondary, + elevation: WalletCardConfig.elevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), + ), + child: InkWell( + borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), + onTap: onTap, + child: SizedBox.expand( + child: Padding( + padding: WalletCardConfig.contentPadding, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BalanceHeader(title: title, subtitle: subtitle, badge: badge), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: amount), + const SizedBox(width: 12), + actions, + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart index 06f976e9..65027d9c 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart @@ -12,15 +12,16 @@ import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class BalanceWidget extends StatelessWidget { final ValueChanged onTopUp; + final ValueChanged onLedgerAddFunds; final ValueChanged onWalletTap; final ValueChanged onLedgerTap; const BalanceWidget({ super.key, required this.onTopUp, + required this.onLedgerAddFunds, required this.onWalletTap, required this.onLedgerTap, }); @@ -49,6 +50,7 @@ class BalanceWidget extends StatelessWidget { currentIndex: carousel.index, onIndexChanged: carousel.onPageChanged, onTopUp: onTopUp, + onLedgerAddFunds: onLedgerAddFunds, onWalletTap: onWalletTap, onLedgerTap: onLedgerTap, ); diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index 8089857e..1de0a860 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -27,6 +27,7 @@ class DashboardPage extends StatefulWidget { final ValueChanged onRecipientSelected; final void Function(PaymentType type) onGoToPaymentWithoutRecipient; final ValueChanged onTopUp; + final ValueChanged onLedgerAddFunds; final ValueChanged onWalletTap; final ValueChanged onLedgerTap; @@ -35,6 +36,7 @@ class DashboardPage extends StatefulWidget { required this.onRecipientSelected, required this.onGoToPaymentWithoutRecipient, required this.onTopUp, + required this.onLedgerAddFunds, required this.onWalletTap, required this.onLedgerTap, }); @@ -90,6 +92,7 @@ class _DashboardPageState extends State { BalanceWidgetProviders( child: BalanceWidget( onTopUp: widget.onTopUp, + onLedgerAddFunds: widget.onLedgerAddFunds, onWalletTap: widget.onWalletTap, onLedgerTap: widget.onLedgerTap, ), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart index 70fc5cab..6f293385 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount/feild.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pweb/controllers/payments/amount_field.dart'; -import 'package:pweb/models/payment/amount/mode.dart'; import 'package:pweb/pages/dashboard/payouts/amount/mode/selector.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart index 9030afb9..9a09c800 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart @@ -6,7 +6,7 @@ import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart'; -import 'package:pweb/utils/payment/payout_verification_flow.dart'; +import 'package:pweb/utils/payment/verification_flow.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart index db03530e..118239f2 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/avatar.dart @@ -23,7 +23,7 @@ class RecipientAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final textColor = Theme.of(context).colorScheme.onPrimary; + final textColor = Theme.of(context).colorScheme.onSecondary; return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -31,7 +31,7 @@ class RecipientAvatar extends StatelessWidget { CircleAvatar( radius: avatarRadius, backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context).colorScheme.primaryFixed, child: avatarUrl == null ? Text( getInitials(name), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart index 512bec73..1c281645 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/short_list.dart @@ -7,54 +7,56 @@ import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart'; class ShortListAddressBookPayout extends StatelessWidget { final List recipients; final ValueChanged onSelected; - final Widget? trailing; + final Widget? leading; const ShortListAddressBookPayout({ super.key, required this.recipients, required this.onSelected, - this.trailing, + this.leading, }); static const double _avatarRadius = 20; static const double _avatarSize = 80; - static const EdgeInsets _padding = EdgeInsets.symmetric(horizontal: 10, vertical: 8); + static const EdgeInsets _padding = EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ); static const TextStyle _nameStyle = TextStyle(fontSize: 12); @override Widget build(BuildContext context) { - final trailingWidget = trailing; + final leadingWidget = leading; + final recipientItems = recipients.map((recipient) { + return Padding( + padding: _padding, + child: InkWell( + borderRadius: BorderRadius.circular(5), + hoverColor: Theme.of(context).colorScheme.onTertiary, + onTap: () => onSelected(recipient), + child: SizedBox( + height: _avatarSize, + width: _avatarSize, + child: RecipientAvatar( + isVisible: true, + name: recipient.name, + avatarUrl: recipient.avatarUrl, + avatarRadius: _avatarRadius, + nameStyle: _nameStyle, + ), + ), + ), + ); + }); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: - recipients.map((recipient) { - return Padding( - padding: _padding, - child: InkWell( - borderRadius: BorderRadius.circular(5), - hoverColor: Theme.of(context).colorScheme.primaryContainer, - onTap: () => onSelected(recipient), - child: SizedBox( - height: _avatarSize, - width: _avatarSize, - child: RecipientAvatar( - isVisible: true, - name: recipient.name, - avatarUrl: recipient.avatarUrl, - avatarRadius: _avatarRadius, - nameStyle: _nameStyle, - ), - ), - ), - ); - }).toList() - ..addAll( - trailingWidget == null - ? const [] - : [Padding(padding: _padding, child: trailingWidget)], - ), + children: [ + if (leadingWidget != null) + Padding(padding: _padding, child: leadingWidget), + ...recipientItems, + ], ), ); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart index df80ca77..c6d42d95 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/address_book/widget.dart @@ -21,10 +21,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AddressBookPayout extends StatefulWidget { final ValueChanged onSelected; - const AddressBookPayout({ - super.key, - required this.onSelected, - }); + const AddressBookPayout({super.key, required this.onSelected}); @override State createState() => _AddressBookPayoutState(); @@ -71,6 +68,7 @@ class _AddressBookPayoutState extends State { provider.setCurrentObject(null); context.pushNamed(PayoutRoutes.addRecipient); } + final filteredRecipients = filterRecipients( recipients: recipients, query: _query, @@ -81,16 +79,18 @@ class _AddressBookPayoutState extends State { } if (provider.error != null) { - return Center(child: Text(loc.notificationError(provider.error ?? loc.noErrorInformation))); + return Center( + child: Text( + loc.notificationError(provider.error ?? loc.noErrorInformation), + ), + ); } return SizedBox( height: _isExpanded ? _expandedHeight : _collapsedHeight, child: Card( margin: const EdgeInsets.all(_cardMargin), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 4, color: Theme.of(context).colorScheme.onSecondary, child: Padding( @@ -105,27 +105,27 @@ class _AddressBookPayoutState extends State { const SizedBox(height: _spacingBetween), Expanded( child: recipients.isEmpty - ? Center( - child: AddRecipientTile( - label: loc.addRecipient, - onTap: onAddRecipient, - ), - ) - : _isExpanded && filteredRecipients.isEmpty + ? Center( + child: AddRecipientTile( + label: loc.addRecipient, + onTap: onAddRecipient, + ), + ) + : _isExpanded && filteredRecipients.isEmpty ? AddressBookPlaceholder(text: loc.noRecipientsFound) : _isExpanded - ? LongListAddressBookPayout( - filteredRecipients: filteredRecipients, - onSelected: widget.onSelected, - ) - : ShortListAddressBookPayout( - recipients: recipients, - onSelected: widget.onSelected, - trailing: AddRecipientTile( - label: loc.addRecipient, - onTap: onAddRecipient, - ), + ? LongListAddressBookPayout( + filteredRecipients: filteredRecipients, + onSelected: widget.onSelected, + ) + : ShortListAddressBookPayout( + recipients: recipients, + onSelected: widget.onSelected, + leading: AddRecipientTile( + label: loc.addRecipient, + onTap: onAddRecipient, ), + ), ), ], ), diff --git a/frontend/pweb/lib/pages/payout_page/send/page.dart b/frontend/pweb/lib/pages/payout_page/send/page.dart index 7118ccf8..cea285e6 100644 --- a/frontend/pweb/lib/pages/payout_page/send/page.dart +++ b/frontend/pweb/lib/pages/payout_page/send/page.dart @@ -5,19 +5,21 @@ import 'package:pshared/models/recipient/recipient.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/controllers/payments/page_ui.dart'; -import 'package:pweb/pages/payout_page/send/page_handlers.dart'; +import 'package:pweb/utils/payment/page_handlers.dart'; import 'package:pweb/pages/payout_page/send/page_view.dart'; class PaymentPage extends StatefulWidget { final ValueChanged? onBack; final PaymentType? initialPaymentType; + final String? initialDestinationLedgerAccountRef; final PayoutDestination fallbackDestination; const PaymentPage({ super.key, this.onBack, this.initialPaymentType, + this.initialDestinationLedgerAccountRef, this.fallbackDestination = PayoutDestination.dashboard, }); @@ -34,7 +36,11 @@ class _PaymentPageState extends State { _uiController = PaymentPageUiController(); WidgetsBinding.instance.addPostFrameCallback( - (_) => initializePaymentPage(context, widget.initialPaymentType), + (_) => initializePaymentPage( + context, + widget.initialPaymentType, + destinationLedgerAccountRef: widget.initialDestinationLedgerAccountRef, + ), ); } diff --git a/frontend/pweb/lib/pages/payout_page/send/page_view.dart b/frontend/pweb/lib/pages/payout_page/send/page_view.dart index 442719c9..7915609a 100644 --- a/frontend/pweb/lib/pages/payout_page/send/page_view.dart +++ b/frontend/pweb/lib/pages/payout_page/send/page_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -14,6 +15,7 @@ import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/models/state/control_state.dart'; + class PaymentPageView extends StatelessWidget { final PaymentPageUiController uiController; final ValueChanged? onBack; @@ -47,6 +49,7 @@ class PaymentPageView extends StatelessWidget { final uiController = context.watch(); final methodsProvider = context.watch(); final recipientProvider = context.watch(); + final flowProvider = context.watch(); final quotationProvider = context.watch(); final verificationController = context .watch(); @@ -58,10 +61,12 @@ class PaymentPageView extends StatelessWidget { recipients: recipientProvider.recipients, query: uiController.query, ); + final hasDestinationSelection = + flowProvider.selectedPaymentData != null; final sendState = verificationController.isCooldownActiveFor(verificationContextKey) ? ControlState.disabled - : (recipient == null + : (!hasDestinationSelection ? ControlState.disabled : ControlState.enabled); diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/manual_details.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/manual_details.dart new file mode 100644 index 00000000..e9dfc9d7 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/manual_details.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/recipient/payment_method_draft.dart'; + +import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class PaymentInfoManualDetailsSection extends StatelessWidget { + final AppDimensions dimensions; + final String title; + final VisibilityState titleVisibility; + final PaymentMethodData data; + + const PaymentInfoManualDetailsSection({ + super.key, + required this.dimensions, + required this.title, + required this.titleVisibility, + required this.data, + }); + + @override + Widget build(BuildContext context) { + final entry = RecipientMethodDraft(type: data.type, data: data); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PaymentInfoHeader( + dimensions: dimensions, + title: title, + visibility: titleVisibility, + ), + PaymentMethodPanel( + selectedType: data.type, + selectedIndex: 0, + entries: [entry], + onRemove: (_) {}, + onChanged: (_, ignored) {}, + editState: ControlState.disabled, + deleteVisibility: VisibilityState.hidden, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart index fd4f6399..51419c09 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/payment_info/section.dart @@ -6,6 +6,7 @@ import 'package:pshared/provider/payment/flow.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_section.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart'; +import 'package:pweb/pages/payout_page/send/widgets/payment_info/manual_details.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_methods.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_recipient.dart'; import 'package:pweb/models/state/visibility.dart'; @@ -35,8 +36,9 @@ class PaymentInfoSection extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final flowProvider = context.watch(); + final manualData = flowProvider.manualPaymentData; - if (!flowProvider.hasRecipient) { + if (!flowProvider.hasRecipient && manualData == null) { return PaymentInfoNoRecipientSection( dimensions: dimensions, title: loc.paymentInfo, @@ -44,6 +46,15 @@ class PaymentInfoSection extends StatelessWidget { ); } + if (!flowProvider.hasRecipient && manualData != null) { + return PaymentInfoManualDetailsSection( + dimensions: dimensions, + title: loc.paymentInfo, + titleVisibility: titleVisibility, + data: manualData, + ); + } + final methods = flowProvider.methodsForRecipient; final types = visiblePaymentTypes; diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart index ad5f8f0e..17094754 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient/section.dart @@ -81,7 +81,7 @@ class RecipientSection extends StatelessWidget { ShortListAddressBookPayout( recipients: recipientProvider.recipients, onSelected: onRecipientSelected, - trailing: AddRecipientTile( + leading: AddRecipientTile( label: loc.addRecipient, onTap: onAddRecipient, ), diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart index f5bd0cc3..971062ae 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/recipient_details_card.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart'; @@ -46,24 +49,30 @@ class PaymentRecipientDetailsCard extends StatelessWidget { @override Widget build(BuildContext context) { + final flowProvider = context.watch(); + final isRecipientSelectionLocked = + !flowProvider.hasRecipient && flowProvider.manualPaymentData != null; + return PaymentSectionCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - RecipientSection( - recipient: recipient, - dimensions: dimensions, - recipientProvider: recipientProvider, - searchQuery: searchQuery, - filteredRecipients: filteredRecipients, - searchController: searchController, - searchFocusNode: searchFocusNode, - onSearchChanged: onSearchChanged, - onRecipientSelected: onRecipientSelected, - onRecipientCleared: onRecipientCleared, - onAddRecipient: onAddRecipient, - ), - SizedBox(height: dimensions.paddingMedium), + if (!isRecipientSelectionLocked) ...[ + RecipientSection( + recipient: recipient, + dimensions: dimensions, + recipientProvider: recipientProvider, + searchQuery: searchQuery, + filteredRecipients: filteredRecipients, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + onRecipientSelected: onRecipientSelected, + onRecipientCleared: onRecipientCleared, + onAddRecipient: onAddRecipient, + ), + SizedBox(height: dimensions.paddingMedium), + ], PaymentInfoSection( dimensions: dimensions, titleVisibility: VisibilityState.hidden, diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart index 772e766b..f70cf1f1 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:provider/provider.dart'; import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/payment/source_type.dart'; +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/app/router/payout_routes.dart'; @@ -17,11 +21,35 @@ class TopUpButton extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final source = context.watch(); - final canTopUp = source.selectedType == PaymentSourceType.wallet; + final selectedType = source.selectedType; + final selectedLedger = source.selectedLedgerAccount; + final canTopUp = + selectedType == PaymentSourceType.wallet || + (selectedType == PaymentSourceType.ledger && selectedLedger != null); return ElevatedButton( style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0), - onPressed: canTopUp ? () => context.pushToWalletTopUp() : null, + onPressed: !canTopUp + ? null + : () { + if (selectedType == PaymentSourceType.wallet) { + context.pushToWalletTopUp(); + return; + } + + if (selectedType == PaymentSourceType.ledger && + selectedLedger != null) { + context.read().setCurrentObject(null); + context.pushNamed( + PayoutRoutes.payment, + queryParameters: PayoutRoutes.buildQueryParameters( + paymentType: PaymentType.ledger, + destinationLedgerAccountRef: + selectedLedger.ledgerAccountRef, + ), + ); + } + }, child: Text(loc.topUpBalance), ); } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index d82b8568..e3537670 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -13,8 +13,9 @@ import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/state.dart'; -import 'package:pweb/utils/payment/multiple_csv_parser.dart'; -import 'package:pweb/utils/payment/multiple_intent_builder.dart'; +import 'package:pweb/utils/payment/multiple/csv_parser.dart'; +import 'package:pweb/utils/payment/multiple/intent_builder.dart'; + class MultiplePayoutsProvider extends ChangeNotifier { final MultipleCsvParser _csvParser; diff --git a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple/csv_parser.dart similarity index 100% rename from frontend/pweb/lib/utils/payment/multiple_csv_parser.dart rename to frontend/pweb/lib/utils/payment/multiple/csv_parser.dart diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple/intent_builder.dart similarity index 100% rename from frontend/pweb/lib/utils/payment/multiple_intent_builder.dart rename to frontend/pweb/lib/utils/payment/multiple/intent_builder.dart diff --git a/frontend/pweb/lib/pages/payout_page/send/page_handlers.dart b/frontend/pweb/lib/utils/payment/page_handlers.dart similarity index 82% rename from frontend/pweb/lib/pages/payout_page/send/page_handlers.dart rename to frontend/pweb/lib/utils/payment/page_handlers.dart index 76dac6b4..c29a1702 100644 --- a/frontend/pweb/lib/pages/payout_page/send/page_handlers.dart +++ b/frontend/pweb/lib/utils/payment/page_handlers.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/payment/flow.dart'; @@ -17,14 +18,30 @@ import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; import 'package:pweb/controllers/payments/page.dart'; import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart'; -import 'package:pweb/utils/payment/payout_verification_flow.dart'; +import 'package:pweb/utils/payment/verification_flow.dart'; + void initializePaymentPage( BuildContext context, - PaymentType? initialPaymentType, -) { + PaymentType? initialPaymentType, { + String? destinationLedgerAccountRef, +}) { final flowProvider = context.read(); + final recipientsProvider = context.read(); + flowProvider.setPreferredType(initialPaymentType); + + final destinationRef = destinationLedgerAccountRef?.trim(); + if (destinationRef != null && destinationRef.isNotEmpty) { + recipientsProvider.setCurrentObject(null); + flowProvider.setPreferredType(PaymentType.ledger); + flowProvider.setManualPaymentData( + LedgerPaymentMethod(ledgerAccountRef: destinationRef), + ); + return; + } + + flowProvider.setManualPaymentData(null); } void handleSearchChanged(PaymentPageUiController uiController, String query) { diff --git a/frontend/pweb/lib/utils/payment/payout_verification_flow.dart b/frontend/pweb/lib/utils/payment/verification_flow.dart similarity index 100% rename from frontend/pweb/lib/utils/payment/payout_verification_flow.dart rename to frontend/pweb/lib/utils/payment/verification_flow.dart diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index f41027c2..567d2353 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -1,12 +1,13 @@ +import 'package:pshared/models/payment/operation_document.dart'; import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/utils/money.dart'; -import 'package:pweb/models/report/operation/document.dart'; import 'package:pweb/utils/report/operations/document_rule.dart'; + OperationItem mapPaymentToOperation(Payment payment) { final debit = payment.lastQuote?.amounts?.sourceDebitTotal; final settlement = payment.lastQuote?.amounts?.destinationSettlement; @@ -55,7 +56,7 @@ OperationItem mapPaymentToOperation(Payment payment) { ); } -OperationDocumentInfo? _resolveOperationDocument(Payment payment) { +OperationDocumentRef? _resolveOperationDocument(Payment payment) { for (final operation in payment.operations) { final operationRef = operation.operationRef; final gatewayService = operation.gateway; @@ -64,7 +65,7 @@ OperationDocumentInfo? _resolveOperationDocument(Payment payment) { if (!isOperationDocumentEligible(operation.code)) continue; - return OperationDocumentInfo( + return OperationDocumentRef( operationRef: operationRef, gatewayService: gatewayService, ); diff --git a/frontend/pweb/lib/utils/report/source_filter.dart b/frontend/pweb/lib/utils/report/source_filter.dart new file mode 100644 index 00000000..da7f1369 --- /dev/null +++ b/frontend/pweb/lib/utils/report/source_filter.dart @@ -0,0 +1,112 @@ +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/source_type.dart'; + + +bool paymentMatchesSource( + Payment payment, { + required PaymentSourceType sourceType, + required String sourceRef, +}) { + final normalizedSourceRef = _normalize(sourceRef); + if (normalizedSourceRef == null) return false; + + final paymentSourceRef = _paymentSourceRef(payment, sourceType); + return paymentSourceRef != null && paymentSourceRef == normalizedSourceRef; +} + +String? _paymentSourceRef(Payment payment, PaymentSourceType sourceType) { + final fromIntent = _sourceRefFromIntent(payment.intent, sourceType); + if (fromIntent != null) return fromIntent; + return _sourceRefFromMetadata(payment.metadata, sourceType); +} + +String? _sourceRefFromIntent( + PaymentIntent? intent, + PaymentSourceType sourceType, +) { + final source = intent?.source; + if (source == null) return null; + + final fromIntentAttributes = _sourceRefFromMetadata( + intent?.attributes, + sourceType, + ); + if (fromIntentAttributes != null) return fromIntentAttributes; + + switch (sourceType) { + case PaymentSourceType.wallet: + return _walletSourceRef(source); + case PaymentSourceType.ledger: + return _ledgerSourceRef(source); + } +} + +String? _walletSourceRef(PaymentMethodData source) { + if (source is ManagedWalletPaymentMethod) { + return _normalize(source.managedWalletRef) ?? + _sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet); + } + if (source is WalletPaymentMethod) { + return _normalize(source.walletId) ?? + _sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet); + } + return null; +} + +String? _ledgerSourceRef(PaymentMethodData source) { + if (source is LedgerPaymentMethod) { + return _normalize(source.ledgerAccountRef) ?? + _sourceRefFromMetadata(source.metadata, PaymentSourceType.ledger); + } + return null; +} + +String? _sourceRefFromMetadata( + Map? metadata, + PaymentSourceType sourceType, +) { + if (metadata == null || metadata.isEmpty) return null; + + final keys = switch (sourceType) { + PaymentSourceType.wallet => const [ + 'source_wallet_ref', + 'managed_wallet_ref', + 'wallet_ref', + 'wallet_id', + 'source_wallet_id', + 'source_wallet_user_id', + 'wallet_user_id', + 'wallet_user_ref', + 'wallet_number', + 'source_wallet_number', + 'source_managed_wallet_ref', + 'source_ref', + ], + PaymentSourceType.ledger => const [ + 'source_ledger_account_ref', + 'ledger_account_ref', + 'source_account_code', + 'ledger_account_code', + 'account_code', + 'source_ref', + ], + }; + + for (final key in keys) { + final value = _normalize(metadata[key]); + if (value != null) return value; + } + + return null; +} + +String? _normalize(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) return null; + return normalized; +}