diff --git a/frontend/pshared/lib/data/dto/payment/operation.dart b/frontend/pshared/lib/data/dto/payment/operation.dart new file mode 100644 index 00000000..f10db2c2 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/operation.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'operation.g.dart'; + +@JsonSerializable() +class PaymentOperationDTO { + final String? stepRef; + final String? operationRef; + final String? gateway; + final String? code; + final String? state; + final String? label; + final String? failureCode; + final String? failureReason; + final String? startedAt; + final String? completedAt; + + const PaymentOperationDTO({ + this.stepRef, + this.operationRef, + this.gateway, + this.code, + this.state, + this.label, + this.failureCode, + this.failureReason, + this.startedAt, + this.completedAt, + }); + + factory PaymentOperationDTO.fromJson(Map json) => + _$PaymentOperationDTOFromJson(json); + Map toJson() => _$PaymentOperationDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index 8cb38541..3c67c1fa 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/payment/operation.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart'; part 'payment.g.dart'; @@ -12,6 +13,7 @@ class PaymentDTO { final String? state; final String? failureCode; final String? failureReason; + final List operations; final PaymentQuoteDTO? lastQuote; final Map? metadata; final String? createdAt; @@ -22,6 +24,7 @@ class PaymentDTO { this.state, this.failureCode, this.failureReason, + this.operations = const [], this.lastQuote, this.metadata, this.createdAt, diff --git a/frontend/pshared/lib/data/mapper/ledger/account.dart b/frontend/pshared/lib/data/mapper/ledger/account.dart index f49130ea..6197c936 100644 --- a/frontend/pshared/lib/data/mapper/ledger/account.dart +++ b/frontend/pshared/lib/data/mapper/ledger/account.dart @@ -9,22 +9,33 @@ import 'package:pshared/models/ledger/account.dart'; extension LedgerAccountDTOMapper on LedgerAccountDTO { - LedgerAccount toDomain() => LedgerAccount( - ledgerAccountRef: ledgerAccountRef, - organizationRef: organizationRef, - ownerRef: ownerRef, - accountCode: accountCode, - accountType: accountType.toDomain(), - currency: currency, - status: status.toDomain(), - allowNegative: allowNegative, - role: role.toDomain(), - metadata: metadata, - createdAt: createdAt, - updatedAt: updatedAt, - describable: describable?.toDomain() ?? newDescribable(name: '', description: null), - balance: balance?.toDomain(), - ); + LedgerAccount toDomain() { + final mappedDescribable = describable?.toDomain(); + final fallbackName = metadata?['name']?.trim() ?? ''; + final name = mappedDescribable?.name.trim().isNotEmpty == true + ? mappedDescribable!.name + : fallbackName; + + return LedgerAccount( + ledgerAccountRef: ledgerAccountRef, + organizationRef: organizationRef, + ownerRef: ownerRef, + accountCode: accountCode, + accountType: accountType.toDomain(), + currency: currency, + status: status.toDomain(), + allowNegative: allowNegative, + role: role.toDomain(), + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + describable: newDescribable( + name: name, + description: mappedDescribable?.description, + ), + balance: balance?.toDomain(), + ); + } } extension LedgerAccountModelMapper on LedgerAccount { diff --git a/frontend/pshared/lib/data/mapper/payment/operation.dart b/frontend/pshared/lib/data/mapper/payment/operation.dart new file mode 100644 index 00000000..0b086d6f --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/operation.dart @@ -0,0 +1,39 @@ +import 'package:pshared/data/dto/payment/operation.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; + + +extension PaymentOperationDTOMapper on PaymentOperationDTO { + PaymentExecutionOperation toDomain() => PaymentExecutionOperation( + stepRef: stepRef, + operationRef: operationRef, + gateway: gateway, + code: code, + state: state, + label: label, + failureCode: failureCode, + failureReason: failureReason, + startedAt: _parseDateTime(startedAt), + completedAt: _parseDateTime(completedAt), + ); +} + +extension PaymentExecutionOperationMapper on PaymentExecutionOperation { + PaymentOperationDTO toDTO() => PaymentOperationDTO( + stepRef: stepRef, + operationRef: operationRef, + gateway: gateway, + code: code, + state: state, + label: label, + failureCode: failureCode, + failureReason: failureReason, + startedAt: startedAt?.toUtc().toIso8601String(), + completedAt: completedAt?.toUtc().toIso8601String(), + ); +} + +DateTime? _parseDateTime(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) return null; + return DateTime.tryParse(normalized); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index f8f5b5e9..d79fa17f 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,8 +1,10 @@ import 'package:pshared/data/dto/payment/payment.dart'; +import 'package:pshared/data/mapper/payment/operation.dart'; import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; + extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( paymentRef: paymentRef, @@ -11,6 +13,7 @@ extension PaymentDTOMapper on PaymentDTO { orchestrationState: paymentOrchestrationStateFromValue(state), failureCode: failureCode, failureReason: failureReason, + operations: operations.map((item) => item.toDomain()).toList(), lastQuote: lastQuote?.toDomain(), metadata: metadata, createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), @@ -24,6 +27,7 @@ extension PaymentMapper on Payment { state: state ?? paymentOrchestrationStateToValue(orchestrationState), failureCode: failureCode, failureReason: failureReason, + operations: operations.map((item) => item.toDTO()).toList(), lastQuote: lastQuote?.toDTO(), metadata: metadata, createdAt: createdAt?.toUtc().toIso8601String(), diff --git a/frontend/pshared/lib/l10n/en.arb b/frontend/pshared/lib/l10n/en.arb index 21b64145..be98af88 100644 --- a/frontend/pshared/lib/l10n/en.arb +++ b/frontend/pshared/lib/l10n/en.arb @@ -10,11 +10,31 @@ "@operationStatusProcessing": { "description": "Label for the “processing” operation status" }, + "operationStatusPending": "Pending", + "@operationStatusPending": { + "description": "Label for the “pending” operation status" + }, + "operationStatusRetrying": "Retrying", + "@operationStatusRetrying": { + "description": "Label for the “retrying” operation status" + }, "operationStatusSuccess": "Success", "@operationStatusSuccess": { "description": "Label for the “success” operation status" }, + "operationStatusSkipped": "Skipped", + "@operationStatusSkipped": { + "description": "Label for the “skipped” operation status" + }, + "operationStatusCancelled": "Cancelled", + "@operationStatusCancelled": { + "description": "Label for the “cancelled” operation status" + }, + "operationStatusNeedsAttention": "Needs attention", + "@operationStatusNeedsAttention": { + "description": "Label for the “needs attention” operation status" + }, "operationStatusError": "Error", "@operationStatusError": { diff --git a/frontend/pshared/lib/l10n/ru.arb b/frontend/pshared/lib/l10n/ru.arb index ce6efa49..4ebbbc41 100644 --- a/frontend/pshared/lib/l10n/ru.arb +++ b/frontend/pshared/lib/l10n/ru.arb @@ -10,11 +10,31 @@ "@operationStatusProcessing": { "description": "Label for the “processing” operation status" }, + "operationStatusPending": "В ожидании", + "@operationStatusPending": { + "description": "Label for the “pending” operation status" + }, + "operationStatusRetrying": "Повтор", + "@operationStatusRetrying": { + "description": "Label for the “retrying” operation status" + }, "operationStatusSuccess": "Успех", "@operationStatusSuccess": { "description": "Label for the “success” operation status" }, + "operationStatusSkipped": "Пропущен", + "@operationStatusSkipped": { + "description": "Label for the “skipped” operation status" + }, + "operationStatusCancelled": "Отменен", + "@operationStatusCancelled": { + "description": "Label for the “cancelled” operation status" + }, + "operationStatusNeedsAttention": "Требует внимания", + "@operationStatusNeedsAttention": { + "description": "Label for the “needs attention” operation status" + }, "operationStatusError": "Ошибка", "@operationStatusError": { diff --git a/frontend/pshared/lib/models/payment/execution_operation.dart b/frontend/pshared/lib/models/payment/execution_operation.dart new file mode 100644 index 00000000..427f93da --- /dev/null +++ b/frontend/pshared/lib/models/payment/execution_operation.dart @@ -0,0 +1,25 @@ +class PaymentExecutionOperation { + final String? stepRef; + final String? operationRef; + final String? gateway; + final String? code; + final String? state; + final String? label; + final String? failureCode; + final String? failureReason; + final DateTime? startedAt; + final DateTime? completedAt; + + const PaymentExecutionOperation({ + required this.stepRef, + required this.operationRef, + required this.gateway, + required this.code, + required this.state, + required this.label, + required this.failureCode, + required this.failureReason, + required this.startedAt, + required this.completedAt, + }); +} diff --git a/frontend/pshared/lib/models/payment/operation.dart b/frontend/pshared/lib/models/payment/operation.dart index 2beb03af..a8ba37b9 100644 --- a/frontend/pshared/lib/models/payment/operation.dart +++ b/frontend/pshared/lib/models/payment/operation.dart @@ -1,7 +1,6 @@ import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/status.dart'; - class OperationItem { final OperationStatus status; final String? fileName; @@ -11,6 +10,8 @@ class OperationItem { final String toCurrency; final String payId; final String? paymentRef; + final String? operationRef; + final String? gatewayService; final String? cardNumber; final PaymentMethod? paymentMethod; final String name; @@ -26,6 +27,8 @@ class OperationItem { required this.toCurrency, required this.payId, this.paymentRef, + this.operationRef, + this.gatewayService, this.cardNumber, this.paymentMethod, required this.name, diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 97e4d99e..90dc2156 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,3 +1,4 @@ +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/state.dart'; @@ -8,6 +9,7 @@ class Payment { final PaymentOrchestrationState orchestrationState; final String? failureCode; final String? failureReason; + final List operations; final PaymentQuote? lastQuote; final Map? metadata; final DateTime? createdAt; @@ -19,6 +21,7 @@ class Payment { required this.orchestrationState, required this.failureCode, required this.failureReason, + required this.operations, required this.lastQuote, required this.metadata, required this.createdAt, diff --git a/frontend/pshared/lib/models/payment/status.dart b/frontend/pshared/lib/models/payment/status.dart index e1665e21..d293d00e 100644 --- a/frontend/pshared/lib/models/payment/status.dart +++ b/frontend/pshared/lib/models/payment/status.dart @@ -2,24 +2,35 @@ import 'package:flutter/widgets.dart'; import 'package:pshared/generated/i18n/ps_localizations.dart'; - enum OperationStatus { + pending, processing, + retrying, success, + skipped, + cancelled, + needsAttention, error, } - extension OperationStatusX on OperationStatus { - /// Returns the localized string for this status, - /// e.g. “Processing”, “Success”, “Error”. String localized(BuildContext context) { final loc = PSLocalizations.of(context)!; switch (this) { + case OperationStatus.pending: + return loc.operationStatusPending; case OperationStatus.processing: return loc.operationStatusProcessing; + case OperationStatus.retrying: + return loc.operationStatusRetrying; case OperationStatus.success: return loc.operationStatusSuccess; + case OperationStatus.skipped: + return loc.operationStatusSkipped; + case OperationStatus.cancelled: + return loc.operationStatusCancelled; + case OperationStatus.needsAttention: + return loc.operationStatusNeedsAttention; case OperationStatus.error: return loc.operationStatusError; } diff --git a/frontend/pshared/lib/service/payment/documents.dart b/frontend/pshared/lib/service/payment/documents.dart index 8394ebf3..ec05f41e 100644 --- a/frontend/pshared/lib/service/payment/documents.dart +++ b/frontend/pshared/lib/service/payment/documents.dart @@ -9,13 +9,27 @@ class PaymentDocumentsService { static final _logger = Logger('service.payment_documents'); static const String _objectType = Services.payments; - static Future getAct(String organizationRef, String paymentRef) async { - final encodedRef = Uri.encodeQueryComponent(paymentRef); - final url = '/documents/act/$organizationRef?payment_ref=$encodedRef'; - _logger.fine('Downloading act document for payment $paymentRef'); - final response = await AuthorizationService.getGETBinaryResponse(_objectType, url); - final filename = _filenameFromDisposition(response.header('content-disposition')) ?? - 'act_$paymentRef.pdf'; + static Future getOperationDocument( + String organizationRef, + String gatewayService, + String operationRef, + ) async { + final query = { + 'gateway_service': gatewayService, + 'operation_ref': operationRef, + }; + final queryString = Uri(queryParameters: query).query; + final url = '/documents/operation/$organizationRef?$queryString'; + _logger.fine( + 'Downloading operation document for operation $operationRef in gateway $gatewayService', + ); + final response = await AuthorizationService.getGETBinaryResponse( + _objectType, + url, + ); + final filename = + _filenameFromDisposition(response.header('content-disposition')) ?? + 'operation_$operationRef.pdf'; final mimeType = response.header('content-type') ?? 'application/pdf'; return DownloadedFile( bytes: response.bytes, diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart index f461951a..5797458d 100644 --- a/frontend/pshared/test/payment/payment_state_model_test.dart +++ b/frontend/pshared/test/payment/payment_state_model_test.dart @@ -69,6 +69,7 @@ void main() { orchestrationState: PaymentOrchestrationState.created, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -80,6 +81,7 @@ void main() { orchestrationState: PaymentOrchestrationState.settled, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -99,6 +101,7 @@ void main() { orchestrationState: PaymentOrchestrationState.executing, failureCode: 'failure_ledger', failureReason: 'ledger failed', + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -110,6 +113,7 @@ void main() { orchestrationState: PaymentOrchestrationState.failed, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 76baec6b..e9cc92c6 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -205,13 +205,13 @@ RouteBase payoutShellRoute() => ShellRoute( ), ChangeNotifierProxyProvider2< MultiplePayoutsProvider, - WalletsController, + PaymentSourceController, MultiplePayoutsController >( create: (_) => MultiplePayoutsController(csvInput: WebCsvInputService()), - update: (context, provider, wallets, controller) => - controller!..update(provider, wallets), + update: (context, provider, sourceController, controller) => + controller!..update(provider, sourceController), ), ], child: PageSelector(child: child, routerState: state), diff --git a/frontend/pweb/lib/controllers/operations/report_operations.dart b/frontend/pweb/lib/controllers/operations/report_operations.dart index 1e3f5841..0ac5f1fe 100644 --- a/frontend/pweb/lib/controllers/operations/report_operations.dart +++ b/frontend/pweb/lib/controllers/operations/report_operations.dart @@ -7,7 +7,7 @@ import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pweb/models/state/load_more_state.dart'; -import 'package:pweb/utils/report/operations.dart'; +import 'package:pweb/utils/report/operations/operations.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; diff --git a/frontend/pweb/lib/controllers/payments/details.dart b/frontend/pweb/lib/controllers/payments/details.dart index 33f8b2d8..2258a203 100644 --- a/frontend/pweb/lib/controllers/payments/details.dart +++ b/frontend/pweb/lib/controllers/payments/details.dart @@ -1,15 +1,17 @@ import 'package:flutter/foundation.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; +import 'package:pweb/models/documents/operation.dart'; +import 'package:pweb/utils/report/operations/document_rule.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; - class PaymentDetailsController extends ChangeNotifier { PaymentDetailsController({required String paymentId}) - : _paymentId = paymentId; + : _paymentId = paymentId; PaymentsProvider? _payments; String _paymentId; @@ -23,12 +25,44 @@ class PaymentDetailsController extends ChangeNotifier { bool get canDownload { final current = _payment; if (current == null) return false; - final status = statusFromPayment(current); - final paymentRef = current.paymentRef ?? ''; - return status == OperationStatus.success && - paymentRef.trim().isNotEmpty; + if (statusFromPayment(current) != OperationStatus.success) return false; + return primaryOperationDocumentRequest != null; } + OperationDocumentRequestModel? get primaryOperationDocumentRequest { + final current = _payment; + if (current == null) return null; + for (final operation in current.operations) { + final request = operationDocumentRequest(operation); + if (request != null) { + return request; + } + } + return null; + } + + OperationDocumentRequestModel? operationDocumentRequest( + PaymentExecutionOperation operation, + ) { + final current = _payment; + if (current == null) return null; + + final operationRef = operation.operationRef; + if (operationRef == null || operationRef.isEmpty) return null; + final gatewayService = operation.gateway; + if (gatewayService == null || gatewayService.isEmpty) return null; + + if (!isOperationDocumentEligible(operation.code)) return null; + + return OperationDocumentRequestModel( + gatewayService: gatewayService, + operationRef: operationRef, + ); + } + + bool canDownloadOperationDocument(PaymentExecutionOperation operation) => + operationDocumentRequest(operation) != null; + void update(PaymentsProvider provider, String paymentId) { if (_paymentId != paymentId) { _paymentId = paymentId; diff --git a/frontend/pweb/lib/controllers/payments/recent_payments.dart b/frontend/pweb/lib/controllers/payments/recent_payments.dart index 72497a65..e5b6770f 100644 --- a/frontend/pweb/lib/controllers/payments/recent_payments.dart +++ b/frontend/pweb/lib/controllers/payments/recent_payments.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/provider/payment/payments.dart'; -import 'package:pweb/utils/report/operations.dart'; +import 'package:pweb/utils/report/operations/operations.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; diff --git a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart index c857af08..439df064 100644 --- a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/quote/status_type.dart'; @@ -15,15 +15,17 @@ import 'package:pweb/services/payments/csv_input.dart'; class MultiplePayoutsController extends ChangeNotifier { final CsvInputService _csvInput; MultiplePayoutsProvider? _provider; - WalletsController? _wallets; + PaymentSourceController? _sourceController; _PickState _pickState = _PickState.idle; Exception? _uiError; - MultiplePayoutsController({ - required CsvInputService csvInput, - }) : _csvInput = csvInput; + MultiplePayoutsController({required CsvInputService csvInput}) + : _csvInput = csvInput; - void update(MultiplePayoutsProvider provider, WalletsController wallets) { + void update( + MultiplePayoutsProvider provider, + PaymentSourceController sourceController, + ) { var shouldNotify = false; if (!identical(_provider, provider)) { _provider?.removeListener(_onProviderChanged); @@ -31,10 +33,10 @@ class MultiplePayoutsController extends ChangeNotifier { _provider?.addListener(_onProviderChanged); shouldNotify = true; } - if (!identical(_wallets, wallets)) { - _wallets?.removeListener(_onWalletsChanged); - _wallets = wallets; - _wallets?.addListener(_onWalletsChanged); + if (!identical(_sourceController, sourceController)) { + _sourceController?.removeListener(_onSourceChanged); + _sourceController = sourceController; + _sourceController?.addListener(_onSourceChanged); shouldNotify = true; } if (shouldNotify) { @@ -58,7 +60,7 @@ class MultiplePayoutsController extends ChangeNotifier { _provider?.quoteStatusType ?? QuoteStatusType.missing; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; - bool get canSend => _provider?.canSend ?? false; + bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null; Money? get aggregateDebitAmount => _provider?.aggregateDebitAmountFor(_selectedWallet); Money? get requestedSentAmount => _provider?.requestedSentAmount; @@ -128,11 +130,11 @@ class MultiplePayoutsController extends ChangeNotifier { notifyListeners(); } - void _onWalletsChanged() { + void _onSourceChanged() { notifyListeners(); } - Wallet? get _selectedWallet => _wallets?.selectedWallet; + Wallet? get _selectedWallet => _sourceController?.selectedWallet; void _setUiError(Object error) { _uiError = error is Exception ? error : Exception(error.toString()); @@ -150,7 +152,7 @@ class MultiplePayoutsController extends ChangeNotifier { @override void dispose() { _provider?.removeListener(_onProviderChanged); - _wallets?.removeListener(_onWalletsChanged); + _sourceController?.removeListener(_onSourceChanged); super.dispose(); } } diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 81a72e58..6c37a488 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -403,6 +403,34 @@ "idempotencyKeyLabel": "Idempotency key", "quoteIdLabel": "Quote ID", "createdAtLabel": "Created at", + "completedAtLabel": "Completed at", + "operationStepStateSkipped": "Skipped", + "operationStepStateNeedsAttention": "Needs attention", + "operationStepStateRetrying": "Retrying", + "paymentOperationPair": "{operation} {action}", + "@paymentOperationPair": { + "description": "Title pattern for one payment execution operation line in payment details", + "placeholders": { + "operation": { + "type": "String" + }, + "action": { + "type": "String" + } + } + }, + "paymentOperationCardPayout": "Card payout", + "paymentOperationCrypto": "Crypto", + "paymentOperationSettlement": "Settlement", + "paymentOperationLedger": "Ledger", + "paymentOperationActionSend": "Send", + "paymentOperationActionObserve": "Observe", + "paymentOperationActionFxConvert": "FX convert", + "paymentOperationActionCredit": "Credit", + "paymentOperationActionBlock": "Block", + "paymentOperationActionDebit": "Debit", + "paymentOperationActionRelease": "Release", + "paymentOperationActionMove": "Move", "debitAmountLabel": "You pay", "debitSettlementAmountLabel": "Debit settlement amount", "expectedSettlementAmountLabel": "Recipient gets", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 91a447dd..a1777eb1 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -403,6 +403,34 @@ "idempotencyKeyLabel": "Ключ идемпотентности", "quoteIdLabel": "ID котировки", "createdAtLabel": "Создан", + "completedAtLabel": "Завершено", + "operationStepStateSkipped": "Пропущен", + "operationStepStateNeedsAttention": "Требует внимания", + "operationStepStateRetrying": "Повтор", + "paymentOperationPair": "{operation} {action}", + "@paymentOperationPair": { + "description": "Шаблон заголовка строки шага выполнения платежа в деталях платежа", + "placeholders": { + "operation": { + "type": "String" + }, + "action": { + "type": "String" + } + } + }, + "paymentOperationCardPayout": "Выплата на карту", + "paymentOperationCrypto": "Крипто", + "paymentOperationSettlement": "Расчётный контур", + "paymentOperationLedger": "Леджер", + "paymentOperationActionSend": "Отправка", + "paymentOperationActionObserve": "Проверка", + "paymentOperationActionFxConvert": "FX-конверсия", + "paymentOperationActionCredit": "Зачисление", + "paymentOperationActionBlock": "Блокировка", + "paymentOperationActionDebit": "Списание", + "paymentOperationActionRelease": "Разблокировка", + "paymentOperationActionMove": "Перемещение", "debitAmountLabel": "Вы платите", "debitSettlementAmountLabel": "Списано к зачислению", "expectedSettlementAmountLabel": "Получателю поступит", diff --git a/frontend/pweb/lib/models/documents/operation.dart b/frontend/pweb/lib/models/documents/operation.dart new file mode 100644 index 00000000..c669d04c --- /dev/null +++ b/frontend/pweb/lib/models/documents/operation.dart @@ -0,0 +1,9 @@ +class OperationDocumentRequestModel { + final String gatewayService; + final String operationRef; + + const OperationDocumentRequestModel({ + required this.gatewayService, + required this.operationRef, + }); +} diff --git a/frontend/pweb/lib/models/payment/source_funds.dart b/frontend/pweb/lib/models/payment/source_funds.dart new file mode 100644 index 00000000..2120f972 --- /dev/null +++ b/frontend/pweb/lib/models/payment/source_funds.dart @@ -0,0 +1,6 @@ +enum SourceOfFundsVisibleState { + headerAction, + summary, + quoteStatus, + sendAction, +} \ No newline at end of file diff --git a/frontend/pweb/lib/models/report/operation/document.dart b/frontend/pweb/lib/models/report/operation/document.dart new file mode 100644 index 00000000..9379305e --- /dev/null +++ b/frontend/pweb/lib/models/report/operation/document.dart @@ -0,0 +1,9 @@ +class OperationDocumentInfo { + final String operationRef; + final String gatewayService; + + const OperationDocumentInfo({ + required this.operationRef, + required this.gatewayService, + }); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart index 6f352655..55602f4c 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart @@ -13,8 +13,6 @@ 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/generated/i18n/app_localizations.dart'; - class WalletCard extends StatelessWidget { final Wallet wallet; @@ -30,7 +28,6 @@ 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); @@ -53,11 +50,12 @@ class WalletCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ BalanceHeader( - title: loc.paymentTypeCryptoWallet, + title: wallet.name, subtitle: networkLabel, badge: (symbol == null || symbol.isEmpty) ? null : symbol, ), Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ BalanceAmount( wallet: wallet, @@ -65,12 +63,16 @@ class WalletCard extends StatelessWidget { context.read().toggleBalanceMask(wallet.id); }, ), - WalletBalanceRefreshButton( - walletRef: wallet.id, + Column( + children: [ + WalletBalanceRefreshButton( + walletRef: wallet.id, + ), + BalanceAddFunds(onTopUp: onTopUp), + ], ), ], ), - BalanceAddFunds(onTopUp: onTopUp), ], ), ), diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart index 269b3539..5748c7e0 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart @@ -17,10 +17,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class LedgerAccountCard extends StatelessWidget { final LedgerAccount account; - const LedgerAccountCard({ - super.key, - required this.account, - }); + const LedgerAccountCard({super.key, required this.account}); String _formatBalance() { final money = account.balance?.balance; @@ -62,8 +59,13 @@ class LedgerAccountCard extends StatelessWidget { 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(); + 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, @@ -76,16 +78,14 @@ class LedgerAccountCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - BalanceHeader( - title: loc.paymentTypeLedger, - subtitle: subtitle.isNotEmpty ? subtitle : null, - badge: badge, - ), + BalanceHeader(title: title, subtitle: subtitle, badge: badge), Row( children: [ Consumer( builder: (context, controller, _) { - final isMasked = controller.isBalanceMasked(account.ledgerAccountRef); + final isMasked = controller.isBalanceMasked( + account.ledgerAccountRef, + ); return Row( children: [ Text( @@ -97,7 +97,9 @@ class LedgerAccountCard extends StatelessWidget { ), const SizedBox(width: 12), GestureDetector( - onTap: () => controller.toggleBalanceMask(account.ledgerAccountRef), + onTap: () => controller.toggleBalanceMask( + account.ledgerAccountRef, + ), child: Icon( isMasked ? Icons.visibility_off : Icons.visibility, size: 24, diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart deleted file mode 100644 index 67e3294b..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class SourceQuotePanelHeader extends StatelessWidget { - const SourceQuotePanelHeader({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - return Text( - l10n.sourceOfFunds, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart index fd58b055..238d33f0 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart @@ -2,93 +2,133 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; 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/models/payment/source_funds.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/actions.dart'; -import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/header.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart'; import 'package:pweb/pages/payout_page/send/widgets/send_button.dart'; -import 'package:pweb/widgets/payment/source_wallet_selector.dart'; +import 'package:pweb/widgets/payment/source_of_funds_panel.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/view.dart'; import 'package:pweb/widgets/cooldown_hint.dart'; +import 'package:pweb/widgets/refresh_balance/wallet.dart'; import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; class SourceQuotePanel extends StatelessWidget { - const SourceQuotePanel({ - super.key, - required this.controller, - required this.walletsController, - }); + const SourceQuotePanel({super.key, required this.controller}); final MultiplePayoutsController controller; - final WalletsController walletsController; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final verificationController = - context.watch(); + final l10n = AppLocalizations.of(context)!; + final sourceController = context.watch(); + final verificationController = context + .watch(); final quotationProvider = context.watch(); - final verificationContextKey = quotationProvider.quotation?.quoteRef ?? + final verificationContextKey = + quotationProvider.quotation?.quoteRef ?? quotationProvider.quotation?.idempotencyKey; final isCooldownActive = verificationController.isCooldownActiveFor( verificationContextKey, ); final canSend = controller.canSend && !isCooldownActive; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.colorScheme.outlineVariant), + return SourceOfFundsPanel( + title: l10n.sourceOfFunds, + sourceSelector: SourceWalletSelector( + sourceController: sourceController, + isBusy: controller.isBusy, ), + visibleStates: const { + SourceOfFundsVisibleState.headerAction, + SourceOfFundsVisibleState.summary, + SourceOfFundsVisibleState.quoteStatus, + SourceOfFundsVisibleState.sendAction, + }, + stateWidgets: { + SourceOfFundsVisibleState.headerAction: _MultipleRefreshAction( + sourceController: sourceController, + ), + SourceOfFundsVisibleState.summary: SourceQuoteSummary( + controller: controller, + spacing: 12, + ), + SourceOfFundsVisibleState.quoteStatus: MultipleQuoteStatusCard( + controller: controller, + ), + SourceOfFundsVisibleState.sendAction: _MultipleSendAction( + controller: controller, + canSend: canSend, + isCooldownActive: isCooldownActive, + verificationController: verificationController, + verificationContextKey: verificationContextKey, + ), + }, + ); + } +} + +class _MultipleRefreshAction extends StatelessWidget { + const _MultipleRefreshAction({required this.sourceController}); + + final PaymentSourceController sourceController; + + @override + Widget build(BuildContext context) { + final selectedWallet = sourceController.selectedWallet; + if (selectedWallet == null) { + return const SizedBox.shrink(); + } + + return WalletBalanceRefreshButton(walletRef: selectedWallet.id); + } +} + +class _MultipleSendAction extends StatelessWidget { + const _MultipleSendAction({ + required this.controller, + required this.canSend, + required this.isCooldownActive, + required this.verificationController, + required this.verificationContextKey, + }); + + final MultiplePayoutsController controller; + final bool canSend; + final bool isCooldownActive; + final PayoutVerificationController verificationController; + final String? verificationContextKey; + + @override + Widget build(BuildContext context) { + return Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - SourceQuotePanelHeader(), - const SizedBox(height: 8), - SourceWalletSelector( - walletsController: walletsController, - isBusy: controller.isBusy, + SendButton( + onPressed: () => handleMultiplePayoutSend(context, controller), + state: controller.isSending + ? ControlState.loading + : canSend + ? ControlState.enabled + : ControlState.disabled, ), - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - SourceQuoteSummary(controller: controller, spacing: 12), - const SizedBox(height: 12), - MultipleQuoteStatusCard(controller: controller), - const SizedBox(height: 12), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SendButton( - onPressed: () => handleMultiplePayoutSend(context, controller), - state: controller.isSending - ? ControlState.loading - : canSend - ? ControlState.enabled - : ControlState.disabled, - ), - if (isCooldownActive) ...[ - const SizedBox(height: 8), - CooldownHint( - seconds: verificationController.cooldownRemainingSecondsFor( - verificationContextKey, - ), - ), - ], - ], + if (isCooldownActive) ...[ + const SizedBox(height: 8), + CooldownHint( + seconds: verificationController.cooldownRemainingSecondsFor( + verificationContextKey, + ), ), - ), + ], ], ), ); } - } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart index be4b1fab..7656329d 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; - import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart'; @@ -9,14 +7,9 @@ import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_ class UploadCsvLayout extends StatelessWidget { - const UploadCsvLayout({ - super.key, - required this.controller, - required this.walletsController, - }); + const UploadCsvLayout({super.key, required this.controller}); final MultiplePayoutsController controller; - final WalletsController walletsController; @override Widget build(BuildContext context) { @@ -27,28 +20,17 @@ class UploadCsvLayout extends StatelessWidget { if (!useHorizontal) { return Column( children: [ - PanelCard( - child: UploadPanel( - controller: controller, - ), - ), + PanelCard(child: UploadPanel(controller: controller)), if (hasFile) ...[ const SizedBox(height: 12), - SourceQuotePanel( - controller: controller, - walletsController: walletsController, - ), + SourceQuotePanel(controller: controller), ], ], ); } if (!hasFile) { - return PanelCard( - child: UploadPanel( - controller: controller, - ), - ); + return PanelCard(child: UploadPanel(controller: controller)); } return IntrinsicHeight( @@ -57,19 +39,12 @@ class UploadCsvLayout extends StatelessWidget { children: [ Expanded( flex: 3, - child: PanelCard( - child: UploadPanel( - controller: controller, - ), - ), + child: PanelCard(child: UploadPanel(controller: controller)), ), const SizedBox(width: 12), Expanded( flex: 5, - child: SourceQuotePanel( - controller: controller, - walletsController: walletsController, - ), + child: SourceQuotePanel(controller: controller), ), ], ), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart index 3be2a39f..31190c17 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart @@ -6,7 +6,6 @@ import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart'; - class UploadCSVSection extends StatelessWidget { const UploadCSVSection({super.key}); @@ -22,10 +21,7 @@ class UploadCSVSection extends StatelessWidget { children: [ UploadCsvHeader(theme: theme), const SizedBox(height: _verticalSpacing), - UploadCsvLayout( - controller: controller, - walletsController: context.watch(), - ), + UploadCsvLayout(controller: controller), ], ); } diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart index 25bd0dc4..edca5186 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/payment/source.dart'; -import 'package:pweb/widgets/payment/source_wallet_selector.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/view.dart'; class PaymentMethodSelector extends StatelessWidget { const PaymentMethodSelector({super.key}); diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart index 3cc76c8c..a938d566 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart @@ -4,13 +4,14 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/payment/source.dart'; +import 'package:pweb/models/payment/source_funds.dart'; import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart'; -import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; -import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/payment/source_of_funds_panel.dart'; import 'package:pweb/widgets/refresh_balance/ledger.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart'; + class PaymentSourceOfFundsCard extends StatelessWidget { final AppDimensions dimensions; final String title; @@ -23,38 +24,33 @@ class PaymentSourceOfFundsCard extends StatelessWidget { @override Widget build(BuildContext context) { - return PaymentSectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: SectionTitle(title)), - Consumer( - builder: (context, provider, _) { - final selectedWallet = provider.selectedWallet; - if (selectedWallet != null) { - return WalletBalanceRefreshButton( - walletRef: selectedWallet.id, - ); - } + return SourceOfFundsPanel( + title: title, + selectorSpacing: dimensions.paddingSmall, + sourceSelector: const PaymentMethodSelector(), + visibleStates: const { + SourceOfFundsVisibleState.headerAction, + }, + stateWidgets: { + SourceOfFundsVisibleState + .headerAction: Consumer( + builder: (context, provider, _) { + final selectedWallet = provider.selectedWallet; + if (selectedWallet != null) { + return WalletBalanceRefreshButton(walletRef: selectedWallet.id); + } - final selectedLedger = provider.selectedLedgerAccount; - if (selectedLedger != null) { - return LedgerBalanceRefreshButton( - ledgerAccountRef: selectedLedger.ledgerAccountRef, - ); - } + final selectedLedger = provider.selectedLedgerAccount; + if (selectedLedger != null) { + return LedgerBalanceRefreshButton( + ledgerAccountRef: selectedLedger.ledgerAccountRef, + ); + } - return const SizedBox.shrink(); - }, - ), - ], - ), - SizedBox(height: dimensions.paddingSmall), - const PaymentMethodSelector(), - ], - ), + return const SizedBox.shrink(); + }, + ), + }, ); } } diff --git a/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart index 1ddb222c..d81b9a7b 100644 --- a/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart +++ b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart @@ -2,12 +2,9 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/operation.dart'; - bool shouldShowToAmount(OperationItem operation) { if (operation.toCurrency.trim().isEmpty) return false; - if (operation.currency.trim().isEmpty) return true; - if (operation.currency != operation.toCurrency) return true; - return (operation.toAmount - operation.amount).abs() > 0.0001; + return true; } String formatOperationTime(BuildContext context, DateTime date) { diff --git a/frontend/pweb/lib/pages/report/details/content.dart b/frontend/pweb/lib/pages/report/details/content.dart index d40dd0a8..d882b0b6 100644 --- a/frontend/pweb/lib/pages/report/details/content.dart +++ b/frontend/pweb/lib/pages/report/details/content.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pweb/pages/report/details/header.dart'; @@ -13,12 +14,17 @@ class PaymentDetailsContent extends StatelessWidget { final Payment payment; final VoidCallback onBack; final VoidCallback? onDownloadAct; + final bool Function(PaymentExecutionOperation operation)? + canDownloadOperationDocument; + final ValueChanged? onDownloadOperationDocument; const PaymentDetailsContent({ super.key, required this.payment, required this.onBack, this.onDownloadAct, + this.canDownloadOperationDocument, + this.onDownloadOperationDocument, }); @override @@ -29,17 +35,15 @@ class PaymentDetailsContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PaymentDetailsHeader( - title: loc.paymentInfo, - onBack: onBack, - ), + PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack), const SizedBox(height: 16), - PaymentSummaryCard( + PaymentSummaryCard(payment: payment, onDownloadAct: onDownloadAct), + const SizedBox(height: 16), + PaymentDetailsSections( payment: payment, - onDownloadAct: onDownloadAct, + canDownloadOperationDocument: canDownloadOperationDocument, + onDownloadOperationDocument: onDownloadOperationDocument, ), - const SizedBox(height: 16), - PaymentDetailsSections(payment: payment), ], ), ); diff --git a/frontend/pweb/lib/pages/report/details/page.dart b/frontend/pweb/lib/pages/report/details/page.dart index f7839b9d..998d2b3b 100644 --- a/frontend/pweb/lib/pages/report/details/page.dart +++ b/frontend/pweb/lib/pages/report/details/page.dart @@ -19,17 +19,17 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PaymentDetailsPage extends StatelessWidget { final String paymentId; - const PaymentDetailsPage({ - super.key, - required this.paymentId, - }); + const PaymentDetailsPage({super.key, required this.paymentId}); @override Widget build(BuildContext context) { - return ChangeNotifierProxyProvider( + return ChangeNotifierProxyProvider< + PaymentsProvider, + PaymentDetailsController + >( create: (_) => PaymentDetailsController(paymentId: paymentId), - update: (_, payments, controller) => controller! - ..update(payments, paymentId), + update: (_, payments, controller) => + controller!..update(payments, paymentId), child: const _PaymentDetailsView(), ); } @@ -65,8 +65,27 @@ class _PaymentDetailsView extends StatelessWidget { payment: payment, onBack: () => _handleBack(context), onDownloadAct: controller.canDownload - ? () => downloadPaymentAct(context, payment.paymentRef ?? '') + ? () { + final request = controller.primaryOperationDocumentRequest; + if (request == null) return; + downloadPaymentAct( + context, + gatewayService: request.gatewayService, + operationRef: request.operationRef, + ); + } : null, + canDownloadOperationDocument: + controller.canDownloadOperationDocument, + onDownloadOperationDocument: (operation) { + final request = controller.operationDocumentRequest(operation); + if (request == null) return; + downloadPaymentAct( + context, + gatewayService: request.gatewayService, + operationRef: request.operationRef, + ); + }, ); }, ), diff --git a/frontend/pweb/lib/pages/report/details/sections.dart b/frontend/pweb/lib/pages/report/details/sections.dart index a4419f86..79f69eca 100644 --- a/frontend/pweb/lib/pages/report/details/sections.dart +++ b/frontend/pweb/lib/pages/report/details/sections.dart @@ -1,36 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pweb/pages/report/details/sections/fx.dart'; -import 'package:pweb/pages/report/details/sections/metadata.dart'; +import 'package:pweb/pages/report/details/sections/operations/section.dart'; class PaymentDetailsSections extends StatelessWidget { final Payment payment; + final bool Function(PaymentExecutionOperation operation)? + canDownloadOperationDocument; + final ValueChanged? onDownloadOperationDocument; const PaymentDetailsSections({ super.key, required this.payment, + this.canDownloadOperationDocument, + this.onDownloadOperationDocument, }); @override Widget build(BuildContext context) { final hasFx = _hasFxQuote(payment); - if (!hasFx) { - return PaymentMetadataSection(payment: payment); - } + final hasOperations = payment.operations.isNotEmpty; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(child: PaymentFxSection(payment: payment)), - const SizedBox(width: 16), - Expanded(child: PaymentMetadataSection(payment: payment)), + if (hasFx) ...[ + PaymentFxSection(payment: payment), + const SizedBox(height: 16), + ], + if (hasOperations) ...[ + PaymentOperationsSection( + payment: payment, + canDownloadDocument: canDownloadOperationDocument, + onDownloadDocument: onDownloadOperationDocument, + ), + const SizedBox(height: 16), + ], ], ); } bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null; - } diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/section.dart b/frontend/pweb/lib/pages/report/details/sections/operations/section.dart new file mode 100644 index 00000000..792907cf --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/section.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/execution_operation.dart'; +import 'package:pshared/models/payment/payment.dart'; + +import 'package:pweb/pages/report/details/section.dart'; +import 'package:pweb/pages/report/details/sections/operations/tile.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentOperationsSection extends StatelessWidget { + final Payment payment; + final bool Function(PaymentExecutionOperation operation)? canDownloadDocument; + final ValueChanged? onDownloadDocument; + + const PaymentOperationsSection({ + super.key, + required this.payment, + this.canDownloadDocument, + this.onDownloadDocument, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final operations = payment.operations; + if (operations.isEmpty) { + return const SizedBox.shrink(); + } + + final children = []; + for (var i = 0; i < operations.length; i++) { + final operation = operations[i]; + final canDownload = canDownloadDocument?.call(operation) ?? false; + children.add( + OperationHistoryTile( + operation: operation, + canDownloadDocument: canDownload, + onDownloadDocument: canDownload && onDownloadDocument != null + ? () => onDownloadDocument!(operation) + : null, + ), + ); + if (i < operations.length - 1) { + children.addAll([ + const SizedBox(height: 8), + Divider( + height: 1, + color: Theme.of(context).dividerColor.withAlpha(20), + ), + const SizedBox(height: 8), + ]); + } + } + + return DetailsSection(title: loc.operationfryTitle, children: children); + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart b/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart new file mode 100644 index 00000000..e859bc1e --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/payment/status_view.dart'; + + +class StepStateChip extends StatelessWidget { + final StatusView view; + + const StepStateChip({super.key, required this.view}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: view.backgroundColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + view.label.toUpperCase(), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: view.foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart new file mode 100644 index 00000000..2afbf9f2 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/execution_operation.dart'; + +import 'package:pweb/utils/report/operations/state_mapper.dart'; +import 'package:pweb/pages/report/details/sections/operations/state_chip.dart'; +import 'package:pweb/utils/report/operations/time_format.dart'; +import 'package:pweb/utils/report/operations/title_mapper.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class OperationHistoryTile extends StatelessWidget { + final PaymentExecutionOperation operation; + final bool canDownloadDocument; + final VoidCallback? onDownloadDocument; + + const OperationHistoryTile({ + super.key, + required this.operation, + required this.canDownloadDocument, + this.onDownloadDocument, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final title = resolveOperationTitle(loc, operation.code); + final stateView = resolveStepStateView(context, operation.state); + final completedAt = formatCompletedAt(context, operation.completedAt); + final canDownload = canDownloadDocument && onDownloadDocument != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + StepStateChip(view: stateView), + ], + ), + const SizedBox(height: 6), + Text( + '${loc.completedAtLabel}: $completedAt', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (canDownload) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: onDownloadDocument, + icon: const Icon(Icons.download), + label: Text(loc.downloadAct), + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart index 80041e68..ae3c19f8 100644 --- a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart +++ b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart @@ -42,7 +42,9 @@ class PaymentSummaryCard extends StatelessWidget { final feeLabel = formatMoney(fee); final paymentRef = (payment.paymentRef ?? '').trim(); - final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel; + final showToAmount = toAmountLabel != '-'; + final showFee = payment.lastQuote != null; + final feeText = feeLabel != '-' ? loc.fee(feeLabel) : loc.fee(loc.noFee); final showPaymentId = paymentRef.isNotEmpty; final amountParts = splitAmount(amountLabel); @@ -85,10 +87,10 @@ class PaymentSummaryCard extends StatelessWidget { icon: Icons.south_east, text: loc.recipientWillReceive(toAmountLabel), ), - if (feeLabel != '-') + if (showFee) InfoLine( icon: Icons.receipt_long_outlined, - text: loc.fee(feeLabel), + text: feeText, muted: true, ), if (onDownloadAct != null) ...[ diff --git a/frontend/pweb/lib/pages/report/table/badge.dart b/frontend/pweb/lib/pages/report/table/badge.dart index 0ca4ea93..d42ae2d2 100644 --- a/frontend/pweb/lib/pages/report/table/badge.dart +++ b/frontend/pweb/lib/pages/report/table/badge.dart @@ -14,37 +14,34 @@ class OperationStatusBadge extends StatelessWidget { const OperationStatusBadge({super.key, required this.status}); - Color _badgeColor(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return operationStatusView(l10n, status).color; - } - - Color _textColor(Color background) { - // computeLuminance returns 0 for black, 1 for white - return background.computeLuminance() > 0.5 ? Colors.black : Colors.white; - } - @override Widget build(BuildContext context) { - final label = status.localized(context); - final bg = _badgeColor(context); - final fg = _textColor(bg); + final l10n = AppLocalizations.of(context)!; + final view = operationStatusView( + l10n, + Theme.of(context).colorScheme, + status, + ); + final label = view.label; + final bg = view.backgroundColor; + final fg = view.foregroundColor; return badges.Badge( badgeStyle: badges.BadgeStyle( shape: badges.BadgeShape.square, badgeColor: bg, - borderRadius: BorderRadius.circular(12), // fully rounded + borderRadius: BorderRadius.circular(12), // fully rounded padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2 // tighter padding + horizontal: 6, + vertical: 2, // tighter padding ), ), badgeContent: Text( - label.toUpperCase(), // or keep sentence case + label.toUpperCase(), // or keep sentence case style: TextStyle( color: fg, - fontSize: 11, // smaller text - fontWeight: FontWeight.w500, // medium weight + fontSize: 11, // smaller text + fontWeight: FontWeight.w500, // medium weight ), ), ); diff --git a/frontend/pweb/lib/pages/report/table/filters.dart b/frontend/pweb/lib/pages/report/table/filters.dart index 5390b284..c6d426d9 100644 --- a/frontend/pweb/lib/pages/report/table/filters.dart +++ b/frontend/pweb/lib/pages/report/table/filters.dart @@ -31,9 +31,7 @@ class OperationFilters extends StatelessWidget { : '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}'; return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 0, child: Padding( padding: const EdgeInsets.all(16), @@ -61,12 +59,12 @@ class OperationFilters extends StatelessWidget { OutlinedButton.icon( onPressed: onPickRange, icon: const Icon(Icons.date_range_outlined, size: 18), - label: Text( - periodLabel, - overflow: TextOverflow.ellipsis, - ), + label: Text(periodLabel, overflow: TextOverflow.ellipsis), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), @@ -76,11 +74,7 @@ class OperationFilters extends StatelessWidget { Wrap( spacing: 10, runSpacing: 8, - children: const [ - OperationStatus.success, - OperationStatus.processing, - OperationStatus.error, - ].map((status) { + children: OperationStatus.values.map((status) { final label = status.localized(context); final isSelected = selectedStatuses.contains(status); return FilterChip( diff --git a/frontend/pweb/lib/pages/report/table/row.dart b/frontend/pweb/lib/pages/report/table/row.dart index a8797bc5..3ee87b52 100644 --- a/frontend/pweb/lib/pages/report/table/row.dart +++ b/frontend/pweb/lib/pages/report/table/row.dart @@ -9,7 +9,6 @@ import 'package:pweb/utils/report/download_act.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class OperationRow { static DataRow build(OperationItem op, BuildContext context) { final isUnknownDate = op.date.millisecondsSinceEpoch == 0; @@ -18,29 +17,37 @@ class OperationRow { final dateLabel = isUnknownDate ? '-' : '${TimeOfDay.fromDateTime(localDate).format(context)}\n' - '${localDate.toIso8601String().split("T").first}'; + '${localDate.toIso8601String().split("T").first}'; - final canDownload = op.status == OperationStatus.success && - (op.paymentRef ?? '').trim().isNotEmpty; + final canDownload = + op.status == OperationStatus.success && + (op.operationRef ?? '').trim().isNotEmpty && + (op.gatewayService ?? '').trim().isNotEmpty; final documentCell = canDownload ? TextButton.icon( - onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''), + onPressed: () => downloadPaymentAct( + context, + gatewayService: op.gatewayService ?? '', + operationRef: op.operationRef ?? '', + ), icon: const Icon(Icons.download), label: Text(loc.downloadAct), ) : Text(op.fileName ?? ''); - return DataRow(cells: [ - DataCell(OperationStatusBadge(status: op.status)), - DataCell(documentCell), - DataCell(Text('${amountToString(op.amount)} ${op.currency}')), - DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')), - DataCell(Text(op.payId)), - DataCell(Text(op.cardNumber ?? '-')), - DataCell(Text(op.name)), - DataCell(Text(dateLabel)), - DataCell(Text(op.comment)), - ]); + return DataRow( + cells: [ + DataCell(OperationStatusBadge(status: op.status)), + DataCell(documentCell), + DataCell(Text('${amountToString(op.amount)} ${op.currency}')), + DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')), + DataCell(Text(op.payId)), + DataCell(Text(op.cardNumber ?? '-')), + DataCell(Text(op.name)), + DataCell(Text(dateLabel)), + DataCell(Text(op.comment)), + ], + ); } } diff --git a/frontend/pweb/lib/utils/payment/operation_code.dart b/frontend/pweb/lib/utils/payment/operation_code.dart new file mode 100644 index 00000000..d3aa4aac --- /dev/null +++ b/frontend/pweb/lib/utils/payment/operation_code.dart @@ -0,0 +1,23 @@ +class OperationCodePair { + final String operation; + final String action; + + const OperationCodePair({required this.operation, required this.action}); +} + +OperationCodePair? parseOperationCodePair(String? code) { + final normalized = code?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) return null; + + final parts = normalized.split('.').where((part) => part.isNotEmpty).toList(); + if (parts.length >= 4 && (parts.first == 'hop' || parts.first == 'edge')) { + return OperationCodePair(operation: parts[2], action: parts[3]); + } + if (parts.length >= 2) { + return OperationCodePair( + operation: parts[parts.length - 2], + action: parts.last, + ); + } + return null; +} diff --git a/frontend/pweb/lib/utils/payment/status_view.dart b/frontend/pweb/lib/utils/payment/status_view.dart index eb660e51..71f90e87 100644 --- a/frontend/pweb/lib/utils/payment/status_view.dart +++ b/frontend/pweb/lib/utils/payment/status_view.dart @@ -7,50 +7,150 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class StatusView { final String label; - final Color color; + final Color backgroundColor; + final Color foregroundColor; - const StatusView(this.label, this.color); -} + StatusView({ + required this.label, + required this.backgroundColor, + Color? foregroundColor, + }) : foregroundColor = + foregroundColor ?? + (backgroundColor.computeLuminance() > 0.5 + ? Colors.black + : Colors.white); -StatusView statusView(AppLocalizations l10n, String? raw) { - final trimmed = (raw ?? '').trim(); - final upper = trimmed.toUpperCase(); - final normalized = upper.startsWith('PAYMENT_STATE_') - ? upper.substring('PAYMENT_STATE_'.length) - : upper; - - switch (normalized) { - case 'SETTLED': - return StatusView(l10n.paymentStatusPending, Colors.orange); - case 'SUCCESS': - return StatusView(l10n.paymentStatusSuccessful, Colors.green); - case 'FUNDS_RESERVED': - return StatusView(l10n.paymentStatusReserved, Colors.blue); - case 'ACCEPTED': - return StatusView(l10n.paymentStatusProcessing, Colors.orange); - case 'SUBMITTED': - return StatusView(l10n.paymentStatusProcessing, Colors.blue); - case 'FAILED': - return StatusView(l10n.paymentStatusFailed, Colors.red); - case 'CANCELLED': - return StatusView(l10n.paymentStatusCancelled, Colors.grey); - case 'UNSPECIFIED': - case '': - default: - return StatusView(l10n.paymentStatusPending, Colors.grey); - } + Color get color => backgroundColor; } StatusView operationStatusView( AppLocalizations l10n, + ColorScheme scheme, OperationStatus status, ) { - switch (status) { - case OperationStatus.success: - return statusView(l10n, 'SUCCESS'); - case OperationStatus.error: - return statusView(l10n, 'FAILED'); - case OperationStatus.processing: - return statusView(l10n, 'ACCEPTED'); + return operationStatusViewFromToken( + l10n, + scheme, + operationStatusTokenFromEnum(status), + ); +} + +StatusView operationStatusViewFromToken( + AppLocalizations l10n, + ColorScheme scheme, + String? rawState, { + String? fallbackLabel, +}) { + final token = normalizeOperationStatusToken(rawState); + switch (token) { + case 'success': + case 'succeeded': + case 'completed': + case 'confirmed': + case 'settled': + return StatusView( + label: l10n.operationStatusSuccessful, + backgroundColor: scheme.tertiaryContainer, + foregroundColor: scheme.onTertiaryContainer, + ); + case 'skipped': + return StatusView( + label: l10n.operationStepStateSkipped, + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + ); + case 'error': + case 'failed': + case 'rejected': + case 'aborted': + return StatusView( + label: l10n.operationStatusUnsuccessful, + backgroundColor: scheme.errorContainer, + foregroundColor: scheme.onErrorContainer, + ); + case 'cancelled': + case 'canceled': + return StatusView( + label: l10n.paymentStatusCancelled, + backgroundColor: scheme.surfaceContainerHighest, + foregroundColor: scheme.onSurfaceVariant, + ); + case 'processing': + case 'running': + case 'executing': + case 'in_progress': + case 'started': + return StatusView( + label: l10n.paymentStatusProcessing, + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + ); + case 'pending': + case 'queued': + case 'waiting': + case 'created': + case 'scheduled': + return StatusView( + label: l10n.operationStatusPending, + backgroundColor: scheme.secondary, + foregroundColor: scheme.onSecondary, + ); + case 'needs_attention': + return StatusView( + label: l10n.operationStepStateNeedsAttention, + backgroundColor: scheme.tertiary, + foregroundColor: scheme.onTertiary, + ); + case 'retrying': + return StatusView( + label: l10n.operationStepStateRetrying, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + ); + default: + return StatusView( + label: fallbackLabel ?? humanizeOperationStatusToken(token), + backgroundColor: scheme.surfaceContainerHighest, + foregroundColor: scheme.onSurfaceVariant, + ); } } + +String operationStatusTokenFromEnum(OperationStatus status) { + switch (status) { + case OperationStatus.pending: + return 'pending'; + case OperationStatus.processing: + return 'processing'; + case OperationStatus.retrying: + return 'retrying'; + case OperationStatus.success: + return 'success'; + case OperationStatus.skipped: + return 'skipped'; + case OperationStatus.cancelled: + return 'cancelled'; + case OperationStatus.needsAttention: + return 'needs_attention'; + case OperationStatus.error: + return 'error'; + } +} + +String normalizeOperationStatusToken(String? state) { + final normalized = (state ?? '').trim().toLowerCase(); + if (normalized.isEmpty) return 'pending'; + return normalized + .replaceFirst(RegExp(r'^step_execution_state_'), '') + .replaceFirst(RegExp(r'^orchestration_state_'), ''); +} + +String humanizeOperationStatusToken(String token) { + final parts = token.split('_').where((part) => part.isNotEmpty).toList(); + if (parts.isEmpty) return token; + return parts + .map( + (part) => '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}', + ) + .join(' '); +} diff --git a/frontend/pweb/lib/utils/report/download_act.dart b/frontend/pweb/lib/utils/report/download_act.dart index 27930664..55e7b8ea 100644 --- a/frontend/pweb/lib/utils/report/download_act.dart +++ b/frontend/pweb/lib/utils/report/download_act.dart @@ -10,14 +10,18 @@ import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - -Future downloadPaymentAct(BuildContext context, String paymentRef) async { +Future downloadPaymentAct( + BuildContext context, { + required String gatewayService, + required String operationRef, +}) async { final organizations = context.read(); if (!organizations.isOrganizationSet) { return; } - final trimmed = paymentRef.trim(); - if (trimmed.isEmpty) { + final gateway = gatewayService.trim(); + final operation = operationRef.trim(); + if (gateway.isEmpty || operation.isEmpty) { return; } @@ -25,9 +29,10 @@ Future downloadPaymentAct(BuildContext context, String paymentRef) async { await executeActionWithNotification( context: context, action: () async { - final file = await PaymentDocumentsService.getAct( + final file = await PaymentDocumentsService.getOperationDocument( organizations.current.id, - trimmed, + gateway, + operation, ); await downloadFile(file); }, diff --git a/frontend/pweb/lib/utils/report/operations/document_rule.dart b/frontend/pweb/lib/utils/report/operations/document_rule.dart new file mode 100644 index 00000000..8ebe2601 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/document_rule.dart @@ -0,0 +1,14 @@ +import 'package:pweb/utils/payment/operation_code.dart'; + +const String _documentOperation = 'card_payout'; +const String _documentAction = 'send'; + +bool isOperationDocumentEligible(String? operationCode) { + final pair = parseOperationCodePair(operationCode); + if (pair == null) return false; + return _isDocumentOperationPair(pair); +} + +bool _isDocumentOperationPair(OperationCodePair pair) { + return pair.operation == _documentOperation && pair.action == _documentAction; +} diff --git a/frontend/pweb/lib/utils/report/operations.dart b/frontend/pweb/lib/utils/report/operations/operations.dart similarity index 100% rename from frontend/pweb/lib/utils/report/operations.dart rename to frontend/pweb/lib/utils/report/operations/operations.dart diff --git a/frontend/pweb/lib/utils/report/operations/state_mapper.dart b/frontend/pweb/lib/utils/report/operations/state_mapper.dart new file mode 100644 index 00000000..073f87fd --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/state_mapper.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/payment/status_view.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +StatusView resolveStepStateView(BuildContext context, String? rawState) { + final loc = AppLocalizations.of(context)!; + final scheme = Theme.of(context).colorScheme; + return operationStatusViewFromToken(loc, scheme, rawState); +} diff --git a/frontend/pweb/lib/utils/report/operations/time_format.dart b/frontend/pweb/lib/utils/report/operations/time_format.dart new file mode 100644 index 00000000..95aabac1 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/time_format.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pweb/utils/report/format.dart'; + + +String formatCompletedAt(BuildContext context, DateTime? completedAt) { + final value = meaningfulDate(completedAt); + return formatDateLabel(context, value); +} + +DateTime? meaningfulDate(DateTime? value) { + if (value == null) return null; + if (value.year <= 1) return null; + return value; +} diff --git a/frontend/pweb/lib/utils/report/operations/title_mapper.dart b/frontend/pweb/lib/utils/report/operations/title_mapper.dart new file mode 100644 index 00000000..21517199 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/title_mapper.dart @@ -0,0 +1,59 @@ +import 'package:pweb/utils/payment/operation_code.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String resolveOperationTitle(AppLocalizations loc, String? code) { + final pair = parseOperationCodePair(code); + if (pair == null) return '-'; + + final operation = _localizedOperation(loc, pair.operation); + final action = _localizedAction(loc, pair.action); + return loc.paymentOperationPair(operation, action); +} + +String _localizedOperation(AppLocalizations loc, String operation) { + switch (operation) { + case 'card_payout': + return loc.paymentOperationCardPayout; + case 'crypto': + return loc.paymentOperationCrypto; + case 'settlement': + return loc.paymentOperationSettlement; + case 'ledger': + return loc.paymentOperationLedger; + default: + return _humanizeToken(operation); + } +} + +String _localizedAction(AppLocalizations loc, String action) { + switch (action) { + case 'send': + return loc.paymentOperationActionSend; + case 'observe': + return loc.paymentOperationActionObserve; + case 'fx_convert': + return loc.paymentOperationActionFxConvert; + case 'credit': + return loc.paymentOperationActionCredit; + case 'block': + return loc.paymentOperationActionBlock; + case 'debit': + return loc.paymentOperationActionDebit; + case 'release': + return loc.paymentOperationActionRelease; + case 'move': + return loc.paymentOperationActionMove; + default: + return _humanizeToken(action); + } +} + +String _humanizeToken(String token) { + final parts = token.split('_').where((part) => part.isNotEmpty).toList(); + if (parts.isEmpty) return token; + return parts + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); +} diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index 0aef8575..f41027c2 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -4,6 +4,8 @@ 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; @@ -33,6 +35,7 @@ OperationItem mapPaymentToOperation(Payment payment) { payment.state, ]) ?? ''; + final operationDocument = _resolveOperationDocument(payment); return OperationItem( status: statusFromPayment(payment), @@ -43,6 +46,8 @@ OperationItem mapPaymentToOperation(Payment payment) { toCurrency: toCurrency, payId: payId, paymentRef: payment.paymentRef, + operationRef: operationDocument?.operationRef, + gatewayService: operationDocument?.gatewayService, cardNumber: null, name: name, date: resolvePaymentDate(payment), @@ -50,17 +55,37 @@ OperationItem mapPaymentToOperation(Payment payment) { ); } +OperationDocumentInfo? _resolveOperationDocument(Payment payment) { + for (final operation in payment.operations) { + final operationRef = operation.operationRef; + final gatewayService = operation.gateway; + if (operationRef == null || operationRef.isEmpty) continue; + if (gatewayService == null || gatewayService.isEmpty) continue; + + if (!isOperationDocumentEligible(operation.code)) continue; + + return OperationDocumentInfo( + operationRef: operationRef, + gatewayService: gatewayService, + ); + } + return null; +} + OperationStatus statusFromPayment(Payment payment) { switch (payment.orchestrationState) { case PaymentOrchestrationState.failed: return OperationStatus.error; case PaymentOrchestrationState.settled: return OperationStatus.success; - case PaymentOrchestrationState.created: - case PaymentOrchestrationState.executing: case PaymentOrchestrationState.needsAttention: - case PaymentOrchestrationState.unspecified: + return OperationStatus.needsAttention; + case PaymentOrchestrationState.created: + return OperationStatus.pending; + case PaymentOrchestrationState.executing: return OperationStatus.processing; + case PaymentOrchestrationState.unspecified: + return OperationStatus.pending; } } diff --git a/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart b/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart new file mode 100644 index 00000000..84aeeb79 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/payment/source_funds.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; + + +class SourceOfFundsPanel extends StatelessWidget { + const SourceOfFundsPanel({ + super.key, + required this.title, + required this.sourceSelector, + this.visibleStates = const {}, + this.stateWidgets = const {}, + this.selectorSpacing = 8, + this.sectionSpacing = 12, + this.padding, + }); + + final String title; + final Widget sourceSelector; + final Set visibleStates; + final Map stateWidgets; + final double selectorSpacing; + final double sectionSpacing; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final headerAction = _stateWidget(SourceOfFundsVisibleState.headerAction); + final headerActions = headerAction == null + ? const [] + : [headerAction]; + final bodySections = _buildBodySections(); + + return PaymentSectionCard( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: SectionTitle(title)), + ...headerActions, + ], + ), + SizedBox(height: selectorSpacing), + sourceSelector, + if (bodySections.isNotEmpty) ...[ + SizedBox(height: sectionSpacing), + const Divider(height: 1), + SizedBox(height: sectionSpacing), + ...bodySections, + ], + ], + ), + ); + } + + List _buildBodySections() { + const orderedStates = [ + SourceOfFundsVisibleState.summary, + SourceOfFundsVisibleState.quoteStatus, + SourceOfFundsVisibleState.sendAction, + ]; + + final sections = []; + for (final state in orderedStates) { + final section = _stateWidget(state); + if (section == null) continue; + if (sections.isNotEmpty) { + sections.add(SizedBox(height: sectionSpacing)); + } + sections.add(section); + } + return sections; + } + + Widget? _stateWidget(SourceOfFundsVisibleState state) { + if (!visibleStates.contains(state)) return null; + return stateWidgets[state]; + } +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart deleted file mode 100644 index d7ca0714..00000000 --- a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/controllers/payment/source.dart'; -import 'package:pshared/models/ledger/account.dart'; -import 'package:pshared/models/payment/source_type.dart'; -import 'package:pshared/models/payment/wallet.dart'; -import 'package:pshared/utils/currency.dart'; -import 'package:pshared/utils/money.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -typedef _SourceOptionKey = ({PaymentSourceType type, String ref}); - -class SourceWalletSelector extends StatelessWidget { - const SourceWalletSelector({ - super.key, - this.walletsController, - this.sourceController, - this.isBusy = false, - this.onChanged, - }) : assert( - (walletsController != null) != (sourceController != null), - 'Provide either walletsController or sourceController', - ); - - final WalletsController? walletsController; - final PaymentSourceController? sourceController; - final bool isBusy; - final ValueChanged? onChanged; - - @override - Widget build(BuildContext context) { - final source = sourceController; - if (source != null) { - final selectedWallet = source.selectedWallet; - final selectedLedger = source.selectedLedgerAccount; - final selectedValue = switch (source.selectedType) { - PaymentSourceType.wallet => - selectedWallet == null ? null : _walletKey(selectedWallet.id), - PaymentSourceType.ledger => - selectedLedger == null - ? null - : _ledgerKey(selectedLedger.ledgerAccountRef), - null => null, - }; - - return _buildSourceSelector( - context: context, - wallets: source.wallets, - ledgerAccounts: source.ledgerAccounts, - selectedValue: selectedValue, - onChanged: (value) { - if (value.type == PaymentSourceType.wallet) { - source.selectWalletByRef(value.ref); - final selected = source.selectedWallet; - if (selected != null) { - onChanged?.call(selected); - } - return; - } - - if (value.type == PaymentSourceType.ledger) { - source.selectLedgerByRef(value.ref); - } - }, - ); - } - - final wallets = walletsController!; - return _buildSourceSelector( - context: context, - wallets: wallets.wallets, - ledgerAccounts: const [], - selectedValue: wallets.selectedWalletRef == null - ? null - : _walletKey(wallets.selectedWalletRef!), - onChanged: (value) { - if (value.type != PaymentSourceType.wallet) return; - wallets.selectWalletByRef(value.ref); - final selected = wallets.selectedWallet; - if (selected != null) { - onChanged?.call(selected); - } - }, - ); - } - - Widget _buildSourceSelector({ - required BuildContext context, - required List wallets, - required List ledgerAccounts, - required _SourceOptionKey? selectedValue, - required ValueChanged<_SourceOptionKey> onChanged, - }) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - if (wallets.isEmpty && ledgerAccounts.isEmpty) { - return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall); - } - - final items = >[ - ...wallets.map((wallet) { - return DropdownMenuItem<_SourceOptionKey>( - value: _walletKey(wallet.id), - child: Text( - '${wallet.name} - ${_walletBalance(wallet)}', - overflow: TextOverflow.ellipsis, - ), - ); - }), - ...ledgerAccounts.map((ledger) { - return DropdownMenuItem<_SourceOptionKey>( - value: _ledgerKey(ledger.ledgerAccountRef), - child: Text( - '${ledger.name} - ${_ledgerBalance(ledger)}', - overflow: TextOverflow.ellipsis, - ), - ); - }), - ]; - - final knownValues = items - .map((item) => item.value) - .whereType<_SourceOptionKey>() - .toSet(); - final effectiveValue = knownValues.contains(selectedValue) - ? selectedValue - : null; - - return DropdownButtonFormField<_SourceOptionKey>( - initialValue: effectiveValue, - isExpanded: true, - decoration: InputDecoration( - labelText: l10n.whereGetMoney, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - items: items, - onChanged: isBusy - ? null - : (value) { - if (value == null) return; - onChanged(value); - }, - ); - } - - _SourceOptionKey _walletKey(String walletRef) => - (type: PaymentSourceType.wallet, ref: walletRef); - - _SourceOptionKey _ledgerKey(String ledgerAccountRef) => - (type: PaymentSourceType.ledger, ref: ledgerAccountRef); - - String _walletBalance(Wallet wallet) { - final symbol = currencyCodeToSymbol(wallet.currency); - return '$symbol ${amountToString(wallet.balance)}'; - } - - String _ledgerBalance(LedgerAccount account) { - final money = account.balance?.balance; - final rawAmount = money?.amount.trim(); - final amount = parseMoneyAmount(rawAmount, fallback: double.nan); - final amountText = amount.isNaN - ? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount) - : amountToString(amount); - - final currencyCode = (money?.currency ?? account.currency) - .trim() - .toUpperCase(); - final symbol = currencySymbolFromCode(currencyCode); - if (symbol != null && symbol.trim().isNotEmpty) { - return '$symbol $amountText'; - } - if (currencyCode.isNotEmpty) { - return '$amountText $currencyCode'; - } - return amountText; - } -} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/balance_formatter.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/balance_formatter.dart new file mode 100644 index 00000000..37e68bc1 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/balance_formatter.dart @@ -0,0 +1,31 @@ +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; + + +String walletBalance(Wallet wallet) { + final symbol = currencyCodeToSymbol(wallet.currency); + return '$symbol ${amountToString(wallet.balance)}'; +} + +String ledgerBalance(LedgerAccount account) { + final money = account.balance?.balance; + final rawAmount = money?.amount.trim(); + final amount = parseMoneyAmount(rawAmount, fallback: double.nan); + final amountText = amount.isNaN + ? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount) + : amountToString(amount); + + final currencyCode = (money?.currency ?? account.currency) + .trim() + .toUpperCase(); + final symbol = currencySymbolFromCode(currencyCode); + if (symbol != null && symbol.trim().isNotEmpty) { + return '$symbol $amountText'; + } + if (currencyCode.isNotEmpty) { + return '$amountText $currencyCode'; + } + return amountText; +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/display_name.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/display_name.dart new file mode 100644 index 00000000..495954ef --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/display_name.dart @@ -0,0 +1,19 @@ +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String walletDisplayName(Wallet wallet, AppLocalizations l10n) { + return sourceDisplayName(name: wallet.name, fallback: l10n.paymentTypeWallet); +} + +String ledgerDisplayName(LedgerAccount ledger, AppLocalizations l10n) { + return sourceDisplayName(name: ledger.name, fallback: l10n.paymentTypeLedger); +} + +String sourceDisplayName({required String name, required String fallback}) { + final normalized = name.trim(); + if (normalized.isNotEmpty) return normalized; + return fallback; +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/dropdown_items.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/dropdown_items.dart new file mode 100644 index 00000000..28405370 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/dropdown_items.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/widgets/payment/source_wallet_selector/balance_formatter.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/display_name.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/options.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +List> buildSourceSelectorItems({ + required List wallets, + required List ledgerAccounts, + required AppLocalizations l10n, +}) { + return >[ + ...wallets.map((wallet) { + return DropdownMenuItem( + value: walletOptionKey(wallet.id), + child: Text( + '${walletDisplayName(wallet, l10n)} - ${walletBalance(wallet)}', + overflow: TextOverflow.ellipsis, + ), + ); + }), + ...ledgerAccounts.map((ledger) { + return DropdownMenuItem( + value: ledgerOptionKey(ledger.ledgerAccountRef), + child: Text( + '${ledgerDisplayName(ledger, l10n)} - ${ledgerBalance(ledger)}', + overflow: TextOverflow.ellipsis, + ), + ); + }), + ]; +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/options.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/options.dart new file mode 100644 index 00000000..0efed2b3 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/options.dart @@ -0,0 +1,25 @@ +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/models/payment/source_type.dart'; + + +typedef SourceOptionKey = ({PaymentSourceType type, String ref}); + +SourceOptionKey walletOptionKey(String walletRef) => + (type: PaymentSourceType.wallet, ref: walletRef); + +SourceOptionKey ledgerOptionKey(String ledgerAccountRef) => + (type: PaymentSourceType.ledger, ref: ledgerAccountRef); + +SourceOptionKey? resolveSelectedSourceOption(PaymentSourceController source) { + final selectedWallet = source.selectedWallet; + final selectedLedger = source.selectedLedgerAccount; + return switch (source.selectedType) { + PaymentSourceType.wallet => + selectedWallet == null ? null : walletOptionKey(selectedWallet.id), + PaymentSourceType.ledger => + selectedLedger == null + ? null + : ledgerOptionKey(selectedLedger.ledgerAccountRef), + null => null, + }; +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/selector_field.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/selector_field.dart new file mode 100644 index 00000000..e897d151 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/selector_field.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/widgets/payment/source_wallet_selector/dropdown_items.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/options.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +Widget buildSourceSelectorField({ + required BuildContext context, + required List wallets, + required List ledgerAccounts, + required SourceOptionKey? selectedValue, + required ValueChanged onChanged, + required bool isBusy, +}) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + if (wallets.isEmpty && ledgerAccounts.isEmpty) { + return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall); + } + + final items = buildSourceSelectorItems( + wallets: wallets, + ledgerAccounts: ledgerAccounts, + l10n: l10n, + ); + + final knownValues = items + .map((item) => item.value) + .whereType() + .toSet(); + final effectiveValue = knownValues.contains(selectedValue) + ? selectedValue + : null; + + return DropdownButtonFormField( + initialValue: effectiveValue, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.whereGetMoney, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + items: items, + onChanged: isBusy + ? null + : (value) { + if (value == null) return; + onChanged(value); + }, + ); +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector/view.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector/view.dart new file mode 100644 index 00000000..2eaaf1d1 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector/view.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/models/payment/source_type.dart'; +import 'package:pshared/models/payment/wallet.dart'; + +import 'package:pweb/widgets/payment/source_wallet_selector/options.dart'; +import 'package:pweb/widgets/payment/source_wallet_selector/selector_field.dart'; + + +class SourceWalletSelector extends StatelessWidget { + const SourceWalletSelector({ + super.key, + required this.sourceController, + this.isBusy = false, + this.onChanged, + }); + + final PaymentSourceController sourceController; + final bool isBusy; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final source = sourceController; + + return buildSourceSelectorField( + context: context, + wallets: source.wallets, + ledgerAccounts: source.ledgerAccounts, + selectedValue: resolveSelectedSourceOption(source), + onChanged: (value) => _onSourceChanged(source, value), + isBusy: isBusy, + ); + } + + void _onSourceChanged(PaymentSourceController source, SourceOptionKey value) { + if (value.type == PaymentSourceType.wallet) { + source.selectWalletByRef(value.ref); + final selected = source.selectedWallet; + if (selected != null) { + onChanged?.call(selected); + } + return; + } + + if (value.type == PaymentSourceType.ledger) { + source.selectLedgerByRef(value.ref); + } + } +}