diff --git a/frontend/pshared/lib/data/dto/payment/operation.dart b/frontend/pshared/lib/data/dto/payment/operation.dart index 98cfbc44..232560d4 100644 --- a/frontend/pshared/lib/data/dto/payment/operation.dart +++ b/frontend/pshared/lib/data/dto/payment/operation.dart @@ -1,8 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; + import 'package:pshared/data/dto/money.dart'; part 'operation.g.dart'; + @JsonSerializable() class PaymentOperationDTO { final String? stepRef; @@ -11,7 +13,8 @@ class PaymentOperationDTO { final String? code; final String? state; final String? label; - final PaymentOperationMoneyDTO? money; + final MoneyDTO? amount; + final MoneyDTO? convertedAmount; final String? failureCode; final String? failureReason; final String? startedAt; @@ -24,7 +27,8 @@ class PaymentOperationDTO { this.code, this.state, this.label, - this.money, + this.amount, + this.convertedAmount, this.failureCode, this.failureReason, this.startedAt, @@ -35,29 +39,3 @@ class PaymentOperationDTO { _$PaymentOperationDTOFromJson(json); Map toJson() => _$PaymentOperationDTOToJson(this); } - -@JsonSerializable() -class PaymentOperationMoneyDTO { - final PaymentOperationMoneySnapshotDTO? planned; - final PaymentOperationMoneySnapshotDTO? executed; - - const PaymentOperationMoneyDTO({this.planned, this.executed}); - - factory PaymentOperationMoneyDTO.fromJson(Map json) => - _$PaymentOperationMoneyDTOFromJson(json); - Map toJson() => _$PaymentOperationMoneyDTOToJson(this); -} - -@JsonSerializable() -class PaymentOperationMoneySnapshotDTO { - final MoneyDTO? amount; - final MoneyDTO? convertedAmount; - - const PaymentOperationMoneySnapshotDTO({this.amount, this.convertedAmount}); - - factory PaymentOperationMoneySnapshotDTO.fromJson( - Map json, - ) => _$PaymentOperationMoneySnapshotDTOFromJson(json); - Map toJson() => - _$PaymentOperationMoneySnapshotDTOToJson(this); -} diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index f0d2d9b4..6532485f 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -11,6 +11,7 @@ part 'payment.g.dart'; class PaymentDTO { final String? paymentRef; final String? state; + final String? comment; final PaymentResponseEndpointDTO? source; final PaymentResponseEndpointDTO? destination; final String? failureCode; @@ -23,6 +24,7 @@ class PaymentDTO { const PaymentDTO({ this.paymentRef, this.state, + this.comment, this.source, this.destination, this.failureCode, diff --git a/frontend/pshared/lib/data/mapper/payment/operation.dart b/frontend/pshared/lib/data/mapper/payment/operation.dart index 0e486bba..3dec1220 100644 --- a/frontend/pshared/lib/data/mapper/payment/operation.dart +++ b/frontend/pshared/lib/data/mapper/payment/operation.dart @@ -2,6 +2,7 @@ import 'package:pshared/data/dto/payment/operation.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/models/payment/execution_operation.dart'; + extension PaymentOperationDTOMapper on PaymentOperationDTO { PaymentExecutionOperation toDomain() => PaymentExecutionOperation( stepRef: stepRef, @@ -10,7 +11,8 @@ extension PaymentOperationDTOMapper on PaymentOperationDTO { code: code, state: state, label: label, - money: money?.toDomain(), + amount: amount?.toDomain(), + convertedAmount: convertedAmount?.toDomain(), failureCode: failureCode, failureReason: failureReason, startedAt: _parseDateTime(startedAt), @@ -26,7 +28,8 @@ extension PaymentExecutionOperationMapper on PaymentExecutionOperation { code: code, state: state, label: label, - money: money?.toDTO(), + amount: amount?.toDTO(), + convertedAmount: convertedAmount?.toDTO(), failureCode: failureCode, failureReason: failureReason, startedAt: startedAt?.toUtc().toIso8601String(), @@ -34,38 +37,6 @@ extension PaymentExecutionOperationMapper on PaymentExecutionOperation { ); } -extension PaymentOperationMoneyDTOMapper on PaymentOperationMoneyDTO { - PaymentExecutionOperationMoney toDomain() => PaymentExecutionOperationMoney( - planned: planned?.toDomain(), - executed: executed?.toDomain(), - ); -} - -extension PaymentExecutionOperationMoneyMapper - on PaymentExecutionOperationMoney { - PaymentOperationMoneyDTO toDTO() => PaymentOperationMoneyDTO( - planned: planned?.toDTO(), - executed: executed?.toDTO(), - ); -} - -extension PaymentOperationMoneySnapshotDTOMapper - on PaymentOperationMoneySnapshotDTO { - PaymentExecutionOperationMoneySnapshot toDomain() => - PaymentExecutionOperationMoneySnapshot( - amount: amount?.toDomain(), - convertedAmount: convertedAmount?.toDomain(), - ); -} - -extension PaymentExecutionOperationMoneySnapshotMapper - on PaymentExecutionOperationMoneySnapshot { - PaymentOperationMoneySnapshotDTO toDTO() => PaymentOperationMoneySnapshotDTO( - amount: amount?.toDTO(), - convertedAmount: convertedAmount?.toDTO(), - ); -} - DateTime? _parseDateTime(String? value) { final normalized = value?.trim(); if (normalized == null || normalized.isEmpty) return null; diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index 7043dbdb..198016a2 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -10,6 +10,7 @@ extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( paymentRef: paymentRef, state: state, + comment: comment, source: source?.toDomain(), destination: destination?.toDomain(), orchestrationState: paymentOrchestrationStateFromValue(state), @@ -26,6 +27,7 @@ extension PaymentMapper on Payment { PaymentDTO toDTO() => PaymentDTO( paymentRef: paymentRef, state: state ?? paymentOrchestrationStateToValue(orchestrationState), + comment: comment, source: source?.toDTO(), destination: destination?.toDTO(), failureCode: failureCode, diff --git a/frontend/pshared/lib/models/payment/execution_operation.dart b/frontend/pshared/lib/models/payment/execution_operation.dart index 9f7f3785..8ab82ea3 100644 --- a/frontend/pshared/lib/models/payment/execution_operation.dart +++ b/frontend/pshared/lib/models/payment/execution_operation.dart @@ -1,5 +1,6 @@ import 'package:pshared/models/money.dart'; + class PaymentExecutionOperation { final String? stepRef; final String? operationRef; @@ -7,7 +8,8 @@ class PaymentExecutionOperation { final String? code; final String? state; final String? label; - final PaymentExecutionOperationMoney? money; + final Money? amount; + final Money? convertedAmount; final String? failureCode; final String? failureReason; final DateTime? startedAt; @@ -20,30 +22,11 @@ class PaymentExecutionOperation { required this.code, required this.state, required this.label, - required this.money, + required this.amount, + required this.convertedAmount, required this.failureCode, required this.failureReason, required this.startedAt, required this.completedAt, }); } - -class PaymentExecutionOperationMoney { - final PaymentExecutionOperationMoneySnapshot? planned; - final PaymentExecutionOperationMoneySnapshot? executed; - - const PaymentExecutionOperationMoney({ - required this.planned, - required this.executed, - }); -} - -class PaymentExecutionOperationMoneySnapshot { - final Money? amount; - final Money? convertedAmount; - - const PaymentExecutionOperationMoneySnapshot({ - required this.amount, - required this.convertedAmount, - }); -} diff --git a/frontend/pshared/lib/models/payment/operation.dart b/frontend/pshared/lib/models/payment/operation.dart index a8ba37b9..66d00a2e 100644 --- a/frontend/pshared/lib/models/payment/operation.dart +++ b/frontend/pshared/lib/models/payment/operation.dart @@ -1,6 +1,7 @@ import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/status.dart'; + class OperationItem { final OperationStatus status; final String? fileName; diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 3d89ca71..ecc00fb4 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -7,6 +7,7 @@ import 'package:pshared/models/payment/state.dart'; class Payment { final String? paymentRef; final String? state; + final String? comment; final PaymentEndpoint? source; final PaymentEndpoint? destination; final PaymentOrchestrationState orchestrationState; @@ -20,6 +21,7 @@ class Payment { const Payment({ required this.paymentRef, required this.state, + required this.comment, required this.source, required this.destination, required this.orchestrationState, diff --git a/frontend/pshared/lib/provider/payment/amount.dart b/frontend/pshared/lib/provider/payment/amount.dart index 19c7d5fc..e7098f2f 100644 --- a/frontend/pshared/lib/provider/payment/amount.dart +++ b/frontend/pshared/lib/provider/payment/amount.dart @@ -2,16 +2,20 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; + class PaymentAmountProvider with ChangeNotifier { - double _amount = 10.0; + double? _amount; bool _payerCoversFee = true; SettlementMode _settlementMode = SettlementMode.fixSource; + String _comment = ''; - double get amount => _amount; + double? get amount => _amount; bool get payerCoversFee => _payerCoversFee; SettlementMode get settlementMode => _settlementMode; + String get comment => _comment; - void setAmount(double value) { + void setAmount(double? value) { + if (_amount == value) return; _amount = value; notifyListeners(); } @@ -26,4 +30,10 @@ class PaymentAmountProvider with ChangeNotifier { _settlementMode = value; notifyListeners(); } + + void setComment(String value) { + if (_comment == value) return; + _comment = value; + notifyListeners(); + } } diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index 341be14d..e26484d4 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -8,6 +8,7 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/service.dart'; import 'package:pshared/utils/exception.dart'; + class PaymentsProvider with ChangeNotifier { OrganizationsProvider? _organizations; String? _loadedOrganizationRef; @@ -253,9 +254,7 @@ class PaymentsProvider with ChangeNotifier { return trimmed; } - String? _paymentKey(Payment payment) { - return _normalize(payment.paymentRef); - } + String? _paymentKey(Payment payment) => _normalize(payment.paymentRef); List? _normalizeStates(List? states) { if (states == null || states.isEmpty) return null; diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index fb278b4b..477a39ab 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -10,10 +10,8 @@ import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/data.dart'; -import 'package:pshared/models/payment/methods/iban.dart'; import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; -import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; @@ -27,6 +25,7 @@ import 'package:pshared/utils/payment/fx_helpers.dart'; class QuotationIntentBuilder { static const String _settlementCurrency = 'RUB'; + static const String _addressBookCustomerFallbackId = 'address_book_customer'; PaymentIntent? build({ required PaymentAmountProvider payment, @@ -38,9 +37,11 @@ class QuotationIntentBuilder { final sourceCurrency = source.selectedCurrencyCode; final paymentData = flow.selectedPaymentData; final selectedMethod = flow.selectedMethod; + final amountValue = payment.amount; if (sourceMethod == null || sourceCurrency == null || paymentData == null) { return null; } + if (amountValue == null) return null; final customer = _buildCustomer( recipient: recipients.currentObject, @@ -51,7 +52,7 @@ class QuotationIntentBuilder { ? _settlementCurrency : sourceCurrency; final amount = Money( - amount: payment.amount.toString(), + amount: amountValue.toString(), currency: amountCurrency, ); final isLedgerSource = source.selectedLedgerAccount != null; @@ -65,6 +66,7 @@ class QuotationIntentBuilder { isLedgerSource: isLedgerSource, enabled: !isCryptoToCrypto, ); + final comment = _resolveComment(payment.comment); return PaymentIntent( kind: PaymentKind.payout, amount: amount, @@ -75,6 +77,7 @@ class QuotationIntentBuilder { ? FeeTreatment.addToSource : FeeTreatment.deductFromDestination, settlementMode: payment.settlementMode, + comment: comment, customer: customer, ); } @@ -134,17 +137,14 @@ class QuotationIntentBuilder { required PaymentMethod? method, required PaymentMethodData? data, }) { - final id = recipient?.id ?? method?.recipientRef; - if (id == null || id.isEmpty) return null; + final name = recipient?.name.trim(); + if (name == null || name.isEmpty) return null; + final id = recipient?.id.trim(); + final customerId = id == null || id.isEmpty + ? _addressBookCustomerFallbackId + : id; - final name = _resolveCustomerName( - method: method, - data: data, - recipient: recipient, - ); - final parts = name == null || name.trim().isEmpty - ? const [] - : name.trim().split(RegExp(r'\s+')); + final parts = name.split(RegExp(r'\s+')); final firstName = parts.isNotEmpty ? parts.first : null; final lastName = parts.length >= 2 ? parts.last : null; final middleName = parts.length > 2 @@ -152,7 +152,7 @@ class QuotationIntentBuilder { : null; return Customer( - id: id, + id: customerId, firstName: firstName, middleName: middleName, lastName: lastName, @@ -160,33 +160,6 @@ class QuotationIntentBuilder { ); } - String? _resolveCustomerName({ - required PaymentMethod? method, - required PaymentMethodData? data, - required Recipient? recipient, - }) { - final card = method?.cardData ?? (data is CardPaymentMethod ? data : null); - if (card != null) { - final fullName = '${card.firstName} ${card.lastName}'.trim(); - if (fullName.isNotEmpty) return fullName; - } - - final iban = method?.ibanData ?? (data is IbanPaymentMethod ? data : null); - if (iban != null && iban.accountHolder.trim().isNotEmpty) { - return iban.accountHolder.trim(); - } - - final bank = - method?.bankAccountData ?? - (data is RussianBankAccountPaymentMethod ? data : null); - if (bank != null && bank.recipientName.trim().isNotEmpty) { - return bank.recipientName.trim(); - } - - final recipientName = recipient?.name.trim(); - return recipientName?.isNotEmpty == true ? recipientName : null; - } - String? _resolveCustomerCountry({ required PaymentMethod? method, required PaymentMethodData? data, @@ -194,4 +167,9 @@ class QuotationIntentBuilder { final card = method?.cardData ?? (data is CardPaymentMethod ? data : null); return card?.country; } + + String? _resolveComment(String comment) { + final normalized = comment.trim(); + return normalized.isEmpty ? null : normalized; + } } diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart deleted file mode 100644 index b16cff74..00000000 --- a/frontend/pshared/test/payment/payment_state_model_test.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:pshared/models/payment/payment.dart'; -import 'package:pshared/models/payment/state.dart'; -import 'package:test/test.dart'; - -void main() { - group('PaymentOrchestrationState parser', () { - test('maps v2 orchestration states', () { - expect( - paymentOrchestrationStateFromValue('orchestration_state_created'), - PaymentOrchestrationState.created, - ); - expect( - paymentOrchestrationStateFromValue('ORCHESTRATION_STATE_EXECUTING'), - PaymentOrchestrationState.executing, - ); - expect( - paymentOrchestrationStateFromValue( - 'orchestration_state_needs_attention', - ), - PaymentOrchestrationState.needsAttention, - ); - expect( - paymentOrchestrationStateFromValue('orchestration_state_settled'), - PaymentOrchestrationState.settled, - ); - expect( - paymentOrchestrationStateFromValue('orchestration_state_failed'), - PaymentOrchestrationState.failed, - ); - }); - - test('maps legacy payment states for compatibility', () { - expect( - paymentOrchestrationStateFromValue('payment_state_accepted'), - PaymentOrchestrationState.created, - ); - expect( - paymentOrchestrationStateFromValue('payment_state_submitted'), - PaymentOrchestrationState.executing, - ); - expect( - paymentOrchestrationStateFromValue('payment_state_settled'), - PaymentOrchestrationState.settled, - ); - expect( - paymentOrchestrationStateFromValue('payment_state_cancelled'), - PaymentOrchestrationState.failed, - ); - }); - - test('unknown state maps to unspecified', () { - expect( - paymentOrchestrationStateFromValue('something_else'), - PaymentOrchestrationState.unspecified, - ); - expect( - paymentOrchestrationStateFromValue(null), - PaymentOrchestrationState.unspecified, - ); - }); - }); - - group('Payment model state helpers', () { - test('isPending and isTerminal are derived from typed state', () { - const created = Payment( - paymentRef: 'p-1', - state: 'orchestration_state_created', - source: null, - destination: null, - orchestrationState: PaymentOrchestrationState.created, - failureCode: null, - failureReason: null, - operations: [], - lastQuote: null, - metadata: null, - createdAt: null, - ); - const settled = Payment( - paymentRef: 'p-2', - state: 'orchestration_state_settled', - source: null, - destination: null, - orchestrationState: PaymentOrchestrationState.settled, - failureCode: null, - failureReason: null, - operations: [], - lastQuote: null, - metadata: null, - createdAt: null, - ); - - expect(created.isPending, isTrue); - expect(created.isTerminal, isFalse); - expect(settled.isPending, isFalse); - expect(settled.isTerminal, isTrue); - }); - - test('isFailure handles both explicit code and failed state', () { - const withFailureCode = Payment( - paymentRef: 'p-3', - state: 'orchestration_state_executing', - source: null, - destination: null, - orchestrationState: PaymentOrchestrationState.executing, - failureCode: 'failure_ledger', - failureReason: 'ledger failed', - operations: [], - lastQuote: null, - metadata: null, - createdAt: null, - ); - const failedState = Payment( - paymentRef: 'p-4', - state: 'orchestration_state_failed', - source: null, - destination: null, - orchestrationState: PaymentOrchestrationState.failed, - failureCode: null, - failureReason: null, - operations: [], - lastQuote: null, - metadata: null, - createdAt: null, - ); - - expect(withFailureCode.isFailure, isTrue); - expect(failedState.isFailure, isTrue); - }); - }); -} diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index eda9c9b7..eb856533 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -145,16 +145,27 @@ RouteBase payoutShellRoute() => ShellRoute( update: (context, verification, controller) => controller!..update(verification), ), - ChangeNotifierProxyProvider4< + ChangeNotifierProxyProvider5< PaymentProvider, QuotationProvider, PaymentFlowProvider, + PaymentAmountProvider, RecipientsProvider, PaymentPageController >( create: (_) => PaymentPageController(), - update: (context, payment, quotation, flow, recipients, controller) => - controller!..update(payment, quotation, flow, recipients), + update: + ( + context, + payment, + quotation, + flow, + amount, + recipients, + controller, + ) => + controller! + ..update(payment, quotation, flow, amount, recipients), ), ChangeNotifierProxyProvider< OrganizationsProvider, @@ -301,6 +312,12 @@ RouteBase payoutShellRoute() => ShellRoute( name: PayoutRoutes.payment, path: PayoutRoutes.paymentPath, pageBuilder: (context, state) { + final amountProvider = context.read(); + if (amountProvider.comment.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + amountProvider.setComment(''); + }); + } final fallbackDestination = PayoutDestination.dashboard; return NoTransitionPage( diff --git a/frontend/pweb/lib/controllers/payments/amount_field.dart b/frontend/pweb/lib/controllers/payments/amount_field.dart index ccc946b3..8f94958b 100644 --- a/frontend/pweb/lib/controllers/payments/amount_field.dart +++ b/frontend/pweb/lib/controllers/payments/amount_field.dart @@ -8,6 +8,7 @@ import 'package:pshared/utils/money.dart'; import 'package:pweb/models/payment/amount/mode.dart'; + class PaymentAmountFieldController extends ChangeNotifier { static const String _settlementCurrencyCode = 'RUB'; @@ -19,9 +20,9 @@ class PaymentAmountFieldController extends ChangeNotifier { bool _isSyncingText = false; PaymentAmountMode _mode = PaymentAmountMode.debit; - PaymentAmountFieldController({required double initialAmount}) + PaymentAmountFieldController({required double? initialAmount}) : textController = TextEditingController( - text: amountToString(initialAmount), + text: initialAmount == null ? '' : amountToString(initialAmount), ); PaymentAmountMode get mode => _mode; @@ -57,9 +58,7 @@ class PaymentAmountFieldController extends ChangeNotifier { void handleChanged(String value) { if (_isSyncingText) return; final parsed = _parseAmount(value); - if (parsed != null) { - _provider?.setAmount(parsed); - } + _provider?.setAmount(parsed); } void handleModeChanged(PaymentAmountMode value) { @@ -130,11 +129,11 @@ class PaymentAmountFieldController extends ChangeNotifier { return parsed.isNaN ? null : parsed; } - void _syncTextWithAmount(double amount) { + void _syncTextWithAmount(double? amount) { final parsedText = _parseAmount(textController.text); - if (parsedText != null && parsedText == amount) return; + if (parsedText == amount) return; - final nextText = amountToString(amount); + final nextText = amount == null ? '' : amountToString(amount); _isSyncingText = true; textController.value = TextEditingValue( text: nextText, diff --git a/frontend/pweb/lib/controllers/payments/comment_field.dart b/frontend/pweb/lib/controllers/payments/comment_field.dart new file mode 100644 index 00000000..87efae69 --- /dev/null +++ b/frontend/pweb/lib/controllers/payments/comment_field.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/payment/amount.dart'; + + +class PaymentCommentFieldController extends ChangeNotifier { + final TextEditingController textController; + + PaymentAmountProvider? _provider; + bool _isSyncingText = false; + + PaymentCommentFieldController({required String initialComment}) + : textController = TextEditingController(text: initialComment); + + void update(PaymentAmountProvider provider) { + if (identical(_provider, provider)) { + _syncTextWithComment(provider.comment); + return; + } + _provider?.removeListener(_handleProviderChanged); + _provider = provider; + _provider?.addListener(_handleProviderChanged); + _syncTextWithComment(provider.comment); + } + + void handleChanged(String value) { + if (_isSyncingText) return; + _provider?.setComment(value); + } + + void _handleProviderChanged() { + final provider = _provider; + if (provider == null) return; + _syncTextWithComment(provider.comment); + } + + void _syncTextWithComment(String comment) { + if (textController.text == comment) return; + _isSyncingText = true; + textController.value = TextEditingValue( + text: comment, + selection: TextSelection.collapsed(offset: comment.length), + ); + _isSyncingText = false; + } + + @override + void dispose() { + _provider?.removeListener(_handleProviderChanged); + textController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/payments/page.dart b/frontend/pweb/lib/controllers/payments/page.dart index 1d39d249..8358233a 100644 --- a/frontend/pweb/lib/controllers/payments/page.dart +++ b/frontend/pweb/lib/controllers/payments/page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -14,6 +15,7 @@ class PaymentPageController extends ChangeNotifier { PaymentProvider? _payment; QuotationProvider? _quotation; PaymentFlowProvider? _flow; + PaymentAmountProvider? _amount; RecipientsProvider? _recipients; bool _isSending = false; @@ -26,11 +28,13 @@ class PaymentPageController extends ChangeNotifier { PaymentProvider payment, QuotationProvider quotation, PaymentFlowProvider flow, + PaymentAmountProvider amount, RecipientsProvider recipients, ) { _payment = payment; _quotation = quotation; _flow = flow; + _amount = amount; _recipients = recipients; } @@ -59,6 +63,7 @@ class PaymentPageController extends ChangeNotifier { _quotation?.reset(); _payment?.reset(); _flow?.setManualPaymentData(null); + _amount?.setComment(''); _recipients?.setCurrentObject(null); } diff --git a/frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart b/frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart index 7fa773d5..da6cf019 100644 --- a/frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart +++ b/frontend/pweb/lib/models/payment/multiple_payouts/csv_row.dart @@ -5,6 +5,7 @@ class CsvPayoutRow { final int expMonth; final int expYear; final String amount; + final String? comment; const CsvPayoutRow({ required this.pan, @@ -13,5 +14,6 @@ class CsvPayoutRow { required this.expMonth, required this.expYear, required this.amount, + this.comment, }); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/comment/field.dart b/frontend/pweb/lib/pages/dashboard/payouts/comment/field.dart new file mode 100644 index 00000000..2b8b4f44 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/comment/field.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/payments/comment_field.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentCommentField extends StatelessWidget { + const PaymentCommentField({super.key}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final controller = context.watch(); + + return TextField( + controller: controller.textController, + decoration: InputDecoration( + labelText: '${loc.comment} (${loc.optional})', + border: const OutlineInputBorder(), + ), + onChanged: controller.handleChanged, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/comment/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/comment/widget.dart new file mode 100644 index 00000000..95cf956d --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/comment/widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/amount.dart'; + +import 'package:pweb/controllers/payments/comment_field.dart'; +import 'package:pweb/pages/dashboard/payouts/comment/field.dart'; + + +class PaymentCommentWidget extends StatelessWidget { + const PaymentCommentWidget({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider< + PaymentAmountProvider, + PaymentCommentFieldController + >( + create: (ctx) { + final initialComment = ctx.read().comment; + return PaymentCommentFieldController(initialComment: initialComment); + }, + update: (ctx, amountProvider, controller) { + controller!.update(amountProvider); + return controller; + }, + child: const PaymentCommentField(), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/form.dart index f0ff3e93..cc41a0c9 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/form.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/form.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:pweb/controllers/payouts/quotation.dart'; import 'package:pweb/models/dashboard/quote_status_data.dart'; import 'package:pweb/pages/dashboard/payouts/amount/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/comment/widget.dart'; import 'package:pweb/pages/dashboard/payouts/fee_payer.dart'; import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart'; import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart'; @@ -23,7 +24,6 @@ class PaymentFormWidget extends StatelessWidget { static const double _columnSpacing = 24; static const double _narrowWidth = 560; - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -66,6 +66,8 @@ class PaymentFormWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const PaymentAmountWidget(), + const SizedBox(height: _mediumSpacing), + const PaymentCommentWidget(), const SizedBox(height: _smallSpacing), FeePayerSwitch( spacing: _smallSpacing, @@ -104,12 +106,9 @@ class PaymentFormWidget extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - flex: 3, - child: const PaymentAmountWidget(), - ), + Expanded(flex: 3, child: const PaymentAmountWidget()), const SizedBox(width: _columnSpacing), - Expanded(flex: 2, child: quoteCard), + Expanded(flex: 2, child: PaymentCommentWidget()), ], ), const SizedBox(height: _smallSpacing), @@ -136,8 +135,9 @@ class PaymentFormWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - autoRefreshSection, - ], + quoteCard, + const SizedBox(height: _mediumSpacing), + autoRefreshSection], ), ), ], diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart index 0a871e58..7fc2731e 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/data.dart @@ -1,6 +1,5 @@ import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; - const String sampleFileName = 'sample.csv'; final List sampleRows = [ @@ -11,6 +10,7 @@ final List sampleRows = [ expMonth: 12, expYear: 27, amount: "500", + comment: "Salary payout", ), CsvPayoutRow( pan: "9022****12", @@ -27,16 +27,26 @@ final List sampleRows = [ expMonth: 3, expYear: 28, amount: "120", + comment: "Refund", ), ]; String buildSampleCsvContent() { final rows = [ - 'pan,first_name,last_name,exp_month,exp_year,amount', + 'pan,first_name,last_name,exp_month,exp_year,amount,comment', ...sampleRows.map( (row) => - '${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}', + '${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount},${_escapeCsvCell(row.comment)}', ), ]; return rows.join('\n'); } + +String _escapeCsvCell(String? value) { + if (value == null || value.isEmpty) return ''; + if (!value.contains(',') && !value.contains('"') && !value.contains('\n')) { + return value; + } + final escaped = value.replaceAll('"', '""'); + return '"$escaped"'; +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart index f6781882..66484ff1 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/sample/table.dart @@ -6,10 +6,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class FileFormatSampleTable extends StatelessWidget { - const FileFormatSampleTable({ - super.key, - required this.rows, - }); + const FileFormatSampleTable({super.key, required this.rows}); final List rows; @@ -24,6 +21,7 @@ class FileFormatSampleTable extends StatelessWidget { DataColumn(label: Text(l10n.lastName)), DataColumn(label: Text(l10n.expiryDate)), DataColumn(label: Text(l10n.amountColumn)), + DataColumn(label: Text(l10n.commentColumn)), ], rows: rows.map((row) { return DataRow( @@ -35,6 +33,7 @@ class FileFormatSampleTable extends StatelessWidget { Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'), ), DataCell(Text(row.amount)), + DataCell(Text(row.comment ?? '')), ], ); }).toList(), diff --git a/frontend/pweb/lib/pages/payout_page/send/page.dart b/frontend/pweb/lib/pages/payout_page/send/page.dart index cea285e6..d11beeac 100644 --- a/frontend/pweb/lib/pages/payout_page/send/page.dart +++ b/frontend/pweb/lib/pages/payout_page/send/page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/amount.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/controllers/payments/page_ui.dart'; @@ -46,6 +49,7 @@ class _PaymentPageState extends State { @override void dispose() { + context.read().setComment(''); _uiController.dispose(); super.dispose(); } diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart index 9a0dbd6c..4c867008 100644 --- a/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart +++ b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart @@ -4,6 +4,7 @@ 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/money_display.dart'; import 'package:pweb/utils/report/operations/time_format.dart'; import 'package:pweb/utils/report/operations/title_mapper.dart'; @@ -30,6 +31,7 @@ class OperationHistoryTile extends StatelessWidget { final operationLabel = operation.label?.trim(); final stateView = resolveStepStateView(context, operation.state); final completedAt = formatCompletedAt(context, operation.completedAt); + final amount = formatMoneyUi(context, operation.amount); final canDownload = canDownloadDocument && onDownloadDocument != null; return Column( @@ -67,7 +69,14 @@ class OperationHistoryTile extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), - ), + ), + const SizedBox(height: 4), + Text( + '${loc.amountColumn}: $amount', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), if (canDownload) ...[ const SizedBox(height: 8), TextButton.icon( diff --git a/frontend/pweb/lib/utils/payment/multiple/csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple/csv_parser.dart index f2a0af1c..776e9da3 100644 --- a/frontend/pweb/lib/utils/payment/multiple/csv_parser.dart +++ b/frontend/pweb/lib/utils/payment/multiple/csv_parser.dart @@ -45,12 +45,12 @@ class MultipleCsvParser { 'expiry_year', ]); final amountIndex = _resolveHeaderIndex(header, const ['amount', 'sum']); + final commentIndex = _resolveHeaderIndex(header, const ['comment']); if (panIndex < 0 || firstNameIndex < 0 || lastNameIndex < 0 || - (expDateIndex < 0 && - (expMonthIndex < 0 || expYearIndex < 0)) || + (expDateIndex < 0 && (expMonthIndex < 0 || expYearIndex < 0)) || amountIndex < 0) { throw FormatException( 'CSV header must contain pan, first_name, last_name, amount columns and either exp_date/expiry or exp_month and exp_year', @@ -67,6 +67,7 @@ class MultipleCsvParser { final expMonthRaw = _cell(raw, expMonthIndex); final expYearRaw = _cell(raw, expYearIndex); final amount = _normalizeAmount(_cell(raw, amountIndex)); + final comment = commentIndex >= 0 ? _cell(raw, commentIndex) : ''; if (pan.isEmpty) { throw FormatException('CSV row ${i + 1}: pan is required'); @@ -117,6 +118,7 @@ class MultipleCsvParser { expMonth: expMonth, expYear: expYear, amount: amount, + comment: _normalizeComment(comment), ), ); } @@ -206,6 +208,11 @@ class MultipleCsvParser { return value.trim().replaceAll(' ', '').replaceAll(',', '.'); } + String? _normalizeComment(String value) { + final normalized = value.trim(); + return normalized.isEmpty ? null : normalized; + } + _ExpiryDate _parseExpiryDate(String value, int rowNumber) { final match = RegExp(r'^\s*(\d{1,2})\s*/\s*(\d{2})\s*$').firstMatch(value); if (match == null) { diff --git a/frontend/pweb/lib/utils/payment/multiple/intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple/intent_builder.dart index 3bba0d20..c779b5aa 100644 --- a/frontend/pweb/lib/utils/payment/multiple/intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple/intent_builder.dart @@ -1,4 +1,5 @@ import 'package:pshared/models/money.dart'; +import 'package:pshared/models/payment/customer.dart'; import 'package:pshared/models/payment/fees/treatment.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/kind.dart'; @@ -9,6 +10,7 @@ import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; + class MultipleIntentBuilder { static const String _currency = 'RUB'; @@ -23,22 +25,33 @@ class MultipleIntentBuilder { ); return rows - .map((row) { + .asMap() + .entries + .map((entry) { + final rowIndex = entry.key; + final row = entry.value; final amount = Money(amount: row.amount, currency: _currency); + final destination = CardPaymentMethod( + pan: row.pan, + firstName: row.firstName, + lastName: row.lastName, + expMonth: row.expMonth, + expYear: row.expYear, + ); return PaymentIntent( kind: PaymentKind.payout, source: sourceMethod, - destination: CardPaymentMethod( - pan: row.pan, - firstName: row.firstName, - lastName: row.lastName, - expMonth: row.expMonth, - expYear: row.expYear, - ), + destination: destination, amount: amount, feeTreatment: FeeTreatment.addToSource, settlementMode: SettlementMode.fixReceived, fx: fxIntent, + comment: row.comment, + customer: Customer( + id: 'csv_row_${rowIndex + 1}', + firstName: destination.firstName, + lastName: destination.lastName, + ), ); }) .toList(growable: false); diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index b57da710..d7ef5012 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -25,6 +25,7 @@ OperationItem mapPaymentToOperation(Payment payment) { _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-'; final comment = _firstNonEmpty([ + payment.comment, payment.failureReason, payment.failureCode, payment.state,