added comment for payment, changed intent and added amount ui in operations #719

Merged
tech merged 1 commits from SEND069 into main 2026-03-11 21:59:29 +00:00
26 changed files with 271 additions and 298 deletions

View File

@@ -1,8 +1,10 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/money.dart'; import 'package:pshared/data/dto/money.dart';
part 'operation.g.dart'; part 'operation.g.dart';
@JsonSerializable() @JsonSerializable()
class PaymentOperationDTO { class PaymentOperationDTO {
final String? stepRef; final String? stepRef;
@@ -11,7 +13,8 @@ class PaymentOperationDTO {
final String? code; final String? code;
final String? state; final String? state;
final String? label; final String? label;
final PaymentOperationMoneyDTO? money; final MoneyDTO? amount;
final MoneyDTO? convertedAmount;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final String? startedAt; final String? startedAt;
@@ -24,7 +27,8 @@ class PaymentOperationDTO {
this.code, this.code,
this.state, this.state,
this.label, this.label,
this.money, this.amount,
this.convertedAmount,
this.failureCode, this.failureCode,
this.failureReason, this.failureReason,
this.startedAt, this.startedAt,
@@ -35,29 +39,3 @@ class PaymentOperationDTO {
_$PaymentOperationDTOFromJson(json); _$PaymentOperationDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentOperationDTOToJson(this); Map<String, dynamic> toJson() => _$PaymentOperationDTOToJson(this);
} }
@JsonSerializable()
class PaymentOperationMoneyDTO {
final PaymentOperationMoneySnapshotDTO? planned;
final PaymentOperationMoneySnapshotDTO? executed;
const PaymentOperationMoneyDTO({this.planned, this.executed});
factory PaymentOperationMoneyDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentOperationMoneyDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentOperationMoneyDTOToJson(this);
}
@JsonSerializable()
class PaymentOperationMoneySnapshotDTO {
final MoneyDTO? amount;
final MoneyDTO? convertedAmount;
const PaymentOperationMoneySnapshotDTO({this.amount, this.convertedAmount});
factory PaymentOperationMoneySnapshotDTO.fromJson(
Map<String, dynamic> json,
) => _$PaymentOperationMoneySnapshotDTOFromJson(json);
Map<String, dynamic> toJson() =>
_$PaymentOperationMoneySnapshotDTOToJson(this);
}

View File

@@ -11,6 +11,7 @@ part 'payment.g.dart';
class PaymentDTO { class PaymentDTO {
final String? paymentRef; final String? paymentRef;
final String? state; final String? state;
final String? comment;
final PaymentResponseEndpointDTO? source; final PaymentResponseEndpointDTO? source;
final PaymentResponseEndpointDTO? destination; final PaymentResponseEndpointDTO? destination;
final String? failureCode; final String? failureCode;
@@ -23,6 +24,7 @@ class PaymentDTO {
const PaymentDTO({ const PaymentDTO({
this.paymentRef, this.paymentRef,
this.state, this.state,
this.comment,
this.source, this.source,
this.destination, this.destination,
this.failureCode, this.failureCode,

View File

@@ -2,6 +2,7 @@ import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/data/mapper/money.dart';
import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/execution_operation.dart';
extension PaymentOperationDTOMapper on PaymentOperationDTO { extension PaymentOperationDTOMapper on PaymentOperationDTO {
PaymentExecutionOperation toDomain() => PaymentExecutionOperation( PaymentExecutionOperation toDomain() => PaymentExecutionOperation(
stepRef: stepRef, stepRef: stepRef,
@@ -10,7 +11,8 @@ extension PaymentOperationDTOMapper on PaymentOperationDTO {
code: code, code: code,
state: state, state: state,
label: label, label: label,
money: money?.toDomain(), amount: amount?.toDomain(),
convertedAmount: convertedAmount?.toDomain(),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
startedAt: _parseDateTime(startedAt), startedAt: _parseDateTime(startedAt),
@@ -26,7 +28,8 @@ extension PaymentExecutionOperationMapper on PaymentExecutionOperation {
code: code, code: code,
state: state, state: state,
label: label, label: label,
money: money?.toDTO(), amount: amount?.toDTO(),
convertedAmount: convertedAmount?.toDTO(),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
startedAt: startedAt?.toUtc().toIso8601String(), 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) { DateTime? _parseDateTime(String? value) {
final normalized = value?.trim(); final normalized = value?.trim();
if (normalized == null || normalized.isEmpty) return null; if (normalized == null || normalized.isEmpty) return null;

View File

@@ -10,6 +10,7 @@ extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment( Payment toDomain() => Payment(
paymentRef: paymentRef, paymentRef: paymentRef,
state: state, state: state,
comment: comment,
source: source?.toDomain(), source: source?.toDomain(),
destination: destination?.toDomain(), destination: destination?.toDomain(),
orchestrationState: paymentOrchestrationStateFromValue(state), orchestrationState: paymentOrchestrationStateFromValue(state),
@@ -26,6 +27,7 @@ extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO( PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef, paymentRef: paymentRef,
state: state ?? paymentOrchestrationStateToValue(orchestrationState), state: state ?? paymentOrchestrationStateToValue(orchestrationState),
comment: comment,
source: source?.toDTO(), source: source?.toDTO(),
destination: destination?.toDTO(), destination: destination?.toDTO(),
failureCode: failureCode, failureCode: failureCode,

View File

@@ -1,5 +1,6 @@
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
class PaymentExecutionOperation { class PaymentExecutionOperation {
final String? stepRef; final String? stepRef;
final String? operationRef; final String? operationRef;
@@ -7,7 +8,8 @@ class PaymentExecutionOperation {
final String? code; final String? code;
final String? state; final String? state;
final String? label; final String? label;
final PaymentExecutionOperationMoney? money; final Money? amount;
final Money? convertedAmount;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final DateTime? startedAt; final DateTime? startedAt;
@@ -20,30 +22,11 @@ class PaymentExecutionOperation {
required this.code, required this.code,
required this.state, required this.state,
required this.label, required this.label,
required this.money, required this.amount,
required this.convertedAmount,
required this.failureCode, required this.failureCode,
required this.failureReason, required this.failureReason,
required this.startedAt, required this.startedAt,
required this.completedAt, 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,
});
}

View File

@@ -1,6 +1,7 @@
import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/status.dart';
class OperationItem { class OperationItem {
final OperationStatus status; final OperationStatus status;
final String? fileName; final String? fileName;

View File

@@ -7,6 +7,7 @@ import 'package:pshared/models/payment/state.dart';
class Payment { class Payment {
final String? paymentRef; final String? paymentRef;
final String? state; final String? state;
final String? comment;
final PaymentEndpoint? source; final PaymentEndpoint? source;
final PaymentEndpoint? destination; final PaymentEndpoint? destination;
final PaymentOrchestrationState orchestrationState; final PaymentOrchestrationState orchestrationState;
@@ -20,6 +21,7 @@ class Payment {
const Payment({ const Payment({
required this.paymentRef, required this.paymentRef,
required this.state, required this.state,
required this.comment,
required this.source, required this.source,
required this.destination, required this.destination,
required this.orchestrationState, required this.orchestrationState,

View File

@@ -2,16 +2,20 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
class PaymentAmountProvider with ChangeNotifier { class PaymentAmountProvider with ChangeNotifier {
double _amount = 10.0; double? _amount;
bool _payerCoversFee = true; bool _payerCoversFee = true;
SettlementMode _settlementMode = SettlementMode.fixSource; SettlementMode _settlementMode = SettlementMode.fixSource;
String _comment = '';
double get amount => _amount; double? get amount => _amount;
bool get payerCoversFee => _payerCoversFee; bool get payerCoversFee => _payerCoversFee;
SettlementMode get settlementMode => _settlementMode; SettlementMode get settlementMode => _settlementMode;
String get comment => _comment;
void setAmount(double value) { void setAmount(double? value) {
if (_amount == value) return;
_amount = value; _amount = value;
notifyListeners(); notifyListeners();
} }
@@ -26,4 +30,10 @@ class PaymentAmountProvider with ChangeNotifier {
_settlementMode = value; _settlementMode = value;
notifyListeners(); notifyListeners();
} }
void setComment(String value) {
if (_comment == value) return;
_comment = value;
notifyListeners();
}
} }

View File

@@ -8,6 +8,7 @@ import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/service.dart'; import 'package:pshared/service/payment/service.dart';
import 'package:pshared/utils/exception.dart'; import 'package:pshared/utils/exception.dart';
class PaymentsProvider with ChangeNotifier { class PaymentsProvider with ChangeNotifier {
OrganizationsProvider? _organizations; OrganizationsProvider? _organizations;
String? _loadedOrganizationRef; String? _loadedOrganizationRef;
@@ -253,9 +254,7 @@ class PaymentsProvider with ChangeNotifier {
return trimmed; return trimmed;
} }
String? _paymentKey(Payment payment) { String? _paymentKey(Payment payment) => _normalize(payment.paymentRef);
return _normalize(payment.paymentRef);
}
List<String>? _normalizeStates(List<String>? states) { List<String>? _normalizeStates(List<String>? states) {
if (states == null || states.isEmpty) return null; if (states == null || states.isEmpty) return null;

View File

@@ -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/card.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.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/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.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/payment/methods/type.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/settlement_mode.dart';
@@ -27,6 +25,7 @@ import 'package:pshared/utils/payment/fx_helpers.dart';
class QuotationIntentBuilder { class QuotationIntentBuilder {
static const String _settlementCurrency = 'RUB'; static const String _settlementCurrency = 'RUB';
static const String _addressBookCustomerFallbackId = 'address_book_customer';
PaymentIntent? build({ PaymentIntent? build({
required PaymentAmountProvider payment, required PaymentAmountProvider payment,
@@ -38,9 +37,11 @@ class QuotationIntentBuilder {
final sourceCurrency = source.selectedCurrencyCode; final sourceCurrency = source.selectedCurrencyCode;
final paymentData = flow.selectedPaymentData; final paymentData = flow.selectedPaymentData;
final selectedMethod = flow.selectedMethod; final selectedMethod = flow.selectedMethod;
final amountValue = payment.amount;
if (sourceMethod == null || sourceCurrency == null || paymentData == null) { if (sourceMethod == null || sourceCurrency == null || paymentData == null) {
return null; return null;
} }
if (amountValue == null) return null;
final customer = _buildCustomer( final customer = _buildCustomer(
recipient: recipients.currentObject, recipient: recipients.currentObject,
@@ -51,7 +52,7 @@ class QuotationIntentBuilder {
? _settlementCurrency ? _settlementCurrency
: sourceCurrency; : sourceCurrency;
final amount = Money( final amount = Money(
amount: payment.amount.toString(), amount: amountValue.toString(),
currency: amountCurrency, currency: amountCurrency,
); );
final isLedgerSource = source.selectedLedgerAccount != null; final isLedgerSource = source.selectedLedgerAccount != null;
@@ -65,6 +66,7 @@ class QuotationIntentBuilder {
isLedgerSource: isLedgerSource, isLedgerSource: isLedgerSource,
enabled: !isCryptoToCrypto, enabled: !isCryptoToCrypto,
); );
final comment = _resolveComment(payment.comment);
return PaymentIntent( return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
amount: amount, amount: amount,
@@ -75,6 +77,7 @@ class QuotationIntentBuilder {
? FeeTreatment.addToSource ? FeeTreatment.addToSource
: FeeTreatment.deductFromDestination, : FeeTreatment.deductFromDestination,
settlementMode: payment.settlementMode, settlementMode: payment.settlementMode,
comment: comment,
customer: customer, customer: customer,
); );
} }
@@ -134,17 +137,14 @@ class QuotationIntentBuilder {
required PaymentMethod? method, required PaymentMethod? method,
required PaymentMethodData? data, required PaymentMethodData? data,
}) { }) {
final id = recipient?.id ?? method?.recipientRef; final name = recipient?.name.trim();
if (id == null || id.isEmpty) return null; if (name == null || name.isEmpty) return null;
final id = recipient?.id.trim();
final customerId = id == null || id.isEmpty
? _addressBookCustomerFallbackId
: id;
final name = _resolveCustomerName( final parts = name.split(RegExp(r'\s+'));
method: method,
data: data,
recipient: recipient,
);
final parts = name == null || name.trim().isEmpty
? const <String>[]
: name.trim().split(RegExp(r'\s+'));
final firstName = parts.isNotEmpty ? parts.first : null; final firstName = parts.isNotEmpty ? parts.first : null;
final lastName = parts.length >= 2 ? parts.last : null; final lastName = parts.length >= 2 ? parts.last : null;
final middleName = parts.length > 2 final middleName = parts.length > 2
@@ -152,7 +152,7 @@ class QuotationIntentBuilder {
: null; : null;
return Customer( return Customer(
id: id, id: customerId,
firstName: firstName, firstName: firstName,
middleName: middleName, middleName: middleName,
lastName: lastName, 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({ String? _resolveCustomerCountry({
required PaymentMethod? method, required PaymentMethod? method,
required PaymentMethodData? data, required PaymentMethodData? data,
@@ -194,4 +167,9 @@ class QuotationIntentBuilder {
final card = method?.cardData ?? (data is CardPaymentMethod ? data : null); final card = method?.cardData ?? (data is CardPaymentMethod ? data : null);
return card?.country; return card?.country;
} }
String? _resolveComment(String comment) {
final normalized = comment.trim();
return normalized.isEmpty ? null : normalized;
}
} }

View File

@@ -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);
});
});
}

View File

@@ -145,16 +145,27 @@ RouteBase payoutShellRoute() => ShellRoute(
update: (context, verification, controller) => update: (context, verification, controller) =>
controller!..update(verification), controller!..update(verification),
), ),
ChangeNotifierProxyProvider4< ChangeNotifierProxyProvider5<
PaymentProvider, PaymentProvider,
QuotationProvider, QuotationProvider,
PaymentFlowProvider, PaymentFlowProvider,
PaymentAmountProvider,
RecipientsProvider, RecipientsProvider,
PaymentPageController PaymentPageController
>( >(
create: (_) => PaymentPageController(), create: (_) => PaymentPageController(),
update: (context, payment, quotation, flow, recipients, controller) => update:
controller!..update(payment, quotation, flow, recipients), (
context,
payment,
quotation,
flow,
amount,
recipients,
controller,
) =>
controller!
..update(payment, quotation, flow, amount, recipients),
), ),
ChangeNotifierProxyProvider< ChangeNotifierProxyProvider<
OrganizationsProvider, OrganizationsProvider,
@@ -301,6 +312,12 @@ RouteBase payoutShellRoute() => ShellRoute(
name: PayoutRoutes.payment, name: PayoutRoutes.payment,
path: PayoutRoutes.paymentPath, path: PayoutRoutes.paymentPath,
pageBuilder: (context, state) { pageBuilder: (context, state) {
final amountProvider = context.read<PaymentAmountProvider>();
if (amountProvider.comment.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
amountProvider.setComment('');
});
}
final fallbackDestination = PayoutDestination.dashboard; final fallbackDestination = PayoutDestination.dashboard;
return NoTransitionPage( return NoTransitionPage(

View File

@@ -8,6 +8,7 @@ import 'package:pshared/utils/money.dart';
import 'package:pweb/models/payment/amount/mode.dart'; import 'package:pweb/models/payment/amount/mode.dart';
class PaymentAmountFieldController extends ChangeNotifier { class PaymentAmountFieldController extends ChangeNotifier {
static const String _settlementCurrencyCode = 'RUB'; static const String _settlementCurrencyCode = 'RUB';
@@ -19,9 +20,9 @@ class PaymentAmountFieldController extends ChangeNotifier {
bool _isSyncingText = false; bool _isSyncingText = false;
PaymentAmountMode _mode = PaymentAmountMode.debit; PaymentAmountMode _mode = PaymentAmountMode.debit;
PaymentAmountFieldController({required double initialAmount}) PaymentAmountFieldController({required double? initialAmount})
: textController = TextEditingController( : textController = TextEditingController(
text: amountToString(initialAmount), text: initialAmount == null ? '' : amountToString(initialAmount),
); );
PaymentAmountMode get mode => _mode; PaymentAmountMode get mode => _mode;
@@ -57,9 +58,7 @@ class PaymentAmountFieldController extends ChangeNotifier {
void handleChanged(String value) { void handleChanged(String value) {
if (_isSyncingText) return; if (_isSyncingText) return;
final parsed = _parseAmount(value); final parsed = _parseAmount(value);
if (parsed != null) { _provider?.setAmount(parsed);
_provider?.setAmount(parsed);
}
} }
void handleModeChanged(PaymentAmountMode value) { void handleModeChanged(PaymentAmountMode value) {
@@ -130,11 +129,11 @@ class PaymentAmountFieldController extends ChangeNotifier {
return parsed.isNaN ? null : parsed; return parsed.isNaN ? null : parsed;
} }
void _syncTextWithAmount(double amount) { void _syncTextWithAmount(double? amount) {
final parsedText = _parseAmount(textController.text); 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; _isSyncingText = true;
textController.value = TextEditingValue( textController.value = TextEditingValue(
text: nextText, text: nextText,

View File

@@ -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();
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:pshared/provider/payment/flow.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/provider.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -14,6 +15,7 @@ class PaymentPageController extends ChangeNotifier {
PaymentProvider? _payment; PaymentProvider? _payment;
QuotationProvider? _quotation; QuotationProvider? _quotation;
PaymentFlowProvider? _flow; PaymentFlowProvider? _flow;
PaymentAmountProvider? _amount;
RecipientsProvider? _recipients; RecipientsProvider? _recipients;
bool _isSending = false; bool _isSending = false;
@@ -26,11 +28,13 @@ class PaymentPageController extends ChangeNotifier {
PaymentProvider payment, PaymentProvider payment,
QuotationProvider quotation, QuotationProvider quotation,
PaymentFlowProvider flow, PaymentFlowProvider flow,
PaymentAmountProvider amount,
RecipientsProvider recipients, RecipientsProvider recipients,
) { ) {
_payment = payment; _payment = payment;
_quotation = quotation; _quotation = quotation;
_flow = flow; _flow = flow;
_amount = amount;
_recipients = recipients; _recipients = recipients;
} }
@@ -59,6 +63,7 @@ class PaymentPageController extends ChangeNotifier {
_quotation?.reset(); _quotation?.reset();
_payment?.reset(); _payment?.reset();
_flow?.setManualPaymentData(null); _flow?.setManualPaymentData(null);
_amount?.setComment('');
_recipients?.setCurrentObject(null); _recipients?.setCurrentObject(null);
} }

View File

@@ -5,6 +5,7 @@ class CsvPayoutRow {
final int expMonth; final int expMonth;
final int expYear; final int expYear;
final String amount; final String amount;
final String? comment;
const CsvPayoutRow({ const CsvPayoutRow({
required this.pan, required this.pan,
@@ -13,5 +14,6 @@ class CsvPayoutRow {
required this.expMonth, required this.expMonth,
required this.expYear, required this.expYear,
required this.amount, required this.amount,
this.comment,
}); });
} }

View File

@@ -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<PaymentCommentFieldController>();
return TextField(
controller: controller.textController,
decoration: InputDecoration(
labelText: '${loc.comment} (${loc.optional})',
border: const OutlineInputBorder(),
),
onChanged: controller.handleChanged,
);
}
}

View File

@@ -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<PaymentAmountProvider>().comment;
return PaymentCommentFieldController(initialComment: initialComment);
},
update: (ctx, amountProvider, controller) {
controller!.update(amountProvider);
return controller;
},
child: const PaymentCommentField(),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:pweb/controllers/payouts/quotation.dart'; import 'package:pweb/controllers/payouts/quotation.dart';
import 'package:pweb/models/dashboard/quote_status_data.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/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/fee_payer.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart'; import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.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 _columnSpacing = 24;
static const double _narrowWidth = 560; static const double _narrowWidth = 560;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -66,6 +66,8 @@ class PaymentFormWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const PaymentAmountWidget(), const PaymentAmountWidget(),
const SizedBox(height: _mediumSpacing),
const PaymentCommentWidget(),
const SizedBox(height: _smallSpacing), const SizedBox(height: _smallSpacing),
FeePayerSwitch( FeePayerSwitch(
spacing: _smallSpacing, spacing: _smallSpacing,
@@ -104,12 +106,9 @@ class PaymentFormWidget extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(flex: 3, child: const PaymentAmountWidget()),
flex: 3,
child: const PaymentAmountWidget(),
),
const SizedBox(width: _columnSpacing), const SizedBox(width: _columnSpacing),
Expanded(flex: 2, child: quoteCard), Expanded(flex: 2, child: PaymentCommentWidget()),
], ],
), ),
const SizedBox(height: _smallSpacing), const SizedBox(height: _smallSpacing),
@@ -136,8 +135,9 @@ class PaymentFormWidget extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
autoRefreshSection, quoteCard,
], const SizedBox(height: _mediumSpacing),
autoRefreshSection],
), ),
), ),
], ],

View File

@@ -1,6 +1,5 @@
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
const String sampleFileName = 'sample.csv'; const String sampleFileName = 'sample.csv';
final List<CsvPayoutRow> sampleRows = [ final List<CsvPayoutRow> sampleRows = [
@@ -11,6 +10,7 @@ final List<CsvPayoutRow> sampleRows = [
expMonth: 12, expMonth: 12,
expYear: 27, expYear: 27,
amount: "500", amount: "500",
comment: "Salary payout",
), ),
CsvPayoutRow( CsvPayoutRow(
pan: "9022****12", pan: "9022****12",
@@ -27,16 +27,26 @@ final List<CsvPayoutRow> sampleRows = [
expMonth: 3, expMonth: 3,
expYear: 28, expYear: 28,
amount: "120", amount: "120",
comment: "Refund",
), ),
]; ];
String buildSampleCsvContent() { String buildSampleCsvContent() {
final rows = <String>[ final rows = <String>[
'pan,first_name,last_name,exp_month,exp_year,amount', 'pan,first_name,last_name,exp_month,exp_year,amount,comment',
...sampleRows.map( ...sampleRows.map(
(row) => (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'); 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"';
}

View File

@@ -6,10 +6,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleTable extends StatelessWidget { class FileFormatSampleTable extends StatelessWidget {
const FileFormatSampleTable({ const FileFormatSampleTable({super.key, required this.rows});
super.key,
required this.rows,
});
final List<CsvPayoutRow> rows; final List<CsvPayoutRow> rows;
@@ -24,6 +21,7 @@ class FileFormatSampleTable extends StatelessWidget {
DataColumn(label: Text(l10n.lastName)), DataColumn(label: Text(l10n.lastName)),
DataColumn(label: Text(l10n.expiryDate)), DataColumn(label: Text(l10n.expiryDate)),
DataColumn(label: Text(l10n.amountColumn)), DataColumn(label: Text(l10n.amountColumn)),
DataColumn(label: Text(l10n.commentColumn)),
], ],
rows: rows.map((row) { rows: rows.map((row) {
return DataRow( return DataRow(
@@ -35,6 +33,7 @@ class FileFormatSampleTable extends StatelessWidget {
Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'), Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'),
), ),
DataCell(Text(row.amount)), DataCell(Text(row.amount)),
DataCell(Text(row.comment ?? '')),
], ],
); );
}).toList(), }).toList(),

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.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/widgets/sidebar/destinations.dart';
import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payments/page_ui.dart';
@@ -46,6 +49,7 @@ class _PaymentPageState extends State<PaymentPage> {
@override @override
void dispose() { void dispose() {
context.read<PaymentAmountProvider>().setComment('');
_uiController.dispose(); _uiController.dispose();
super.dispose(); super.dispose();
} }

View File

@@ -4,6 +4,7 @@ import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pweb/utils/report/operations/state_mapper.dart'; import 'package:pweb/utils/report/operations/state_mapper.dart';
import 'package:pweb/pages/report/details/sections/operations/state_chip.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/time_format.dart';
import 'package:pweb/utils/report/operations/title_mapper.dart'; import 'package:pweb/utils/report/operations/title_mapper.dart';
@@ -30,6 +31,7 @@ class OperationHistoryTile extends StatelessWidget {
final operationLabel = operation.label?.trim(); final operationLabel = operation.label?.trim();
final stateView = resolveStepStateView(context, operation.state); final stateView = resolveStepStateView(context, operation.state);
final completedAt = formatCompletedAt(context, operation.completedAt); final completedAt = formatCompletedAt(context, operation.completedAt);
final amount = formatMoneyUi(context, operation.amount);
final canDownload = canDownloadDocument && onDownloadDocument != null; final canDownload = canDownloadDocument && onDownloadDocument != null;
return Column( return Column(
@@ -67,7 +69,14 @@ class OperationHistoryTile extends StatelessWidget {
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 4),
Text(
'${loc.amountColumn}: $amount',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (canDownload) ...[ if (canDownload) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton.icon( TextButton.icon(

View File

@@ -45,12 +45,12 @@ class MultipleCsvParser {
'expiry_year', 'expiry_year',
]); ]);
final amountIndex = _resolveHeaderIndex(header, const ['amount', 'sum']); final amountIndex = _resolveHeaderIndex(header, const ['amount', 'sum']);
final commentIndex = _resolveHeaderIndex(header, const ['comment']);
if (panIndex < 0 || if (panIndex < 0 ||
firstNameIndex < 0 || firstNameIndex < 0 ||
lastNameIndex < 0 || lastNameIndex < 0 ||
(expDateIndex < 0 && (expDateIndex < 0 && (expMonthIndex < 0 || expYearIndex < 0)) ||
(expMonthIndex < 0 || expYearIndex < 0)) ||
amountIndex < 0) { amountIndex < 0) {
throw FormatException( throw FormatException(
'CSV header must contain pan, first_name, last_name, amount columns and either exp_date/expiry or exp_month and exp_year', '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 expMonthRaw = _cell(raw, expMonthIndex);
final expYearRaw = _cell(raw, expYearIndex); final expYearRaw = _cell(raw, expYearIndex);
final amount = _normalizeAmount(_cell(raw, amountIndex)); final amount = _normalizeAmount(_cell(raw, amountIndex));
final comment = commentIndex >= 0 ? _cell(raw, commentIndex) : '';
if (pan.isEmpty) { if (pan.isEmpty) {
throw FormatException('CSV row ${i + 1}: pan is required'); throw FormatException('CSV row ${i + 1}: pan is required');
@@ -117,6 +118,7 @@ class MultipleCsvParser {
expMonth: expMonth, expMonth: expMonth,
expYear: expYear, expYear: expYear,
amount: amount, amount: amount,
comment: _normalizeComment(comment),
), ),
); );
} }
@@ -206,6 +208,11 @@ class MultipleCsvParser {
return value.trim().replaceAll(' ', '').replaceAll(',', '.'); return value.trim().replaceAll(' ', '').replaceAll(',', '.');
} }
String? _normalizeComment(String value) {
final normalized = value.trim();
return normalized.isEmpty ? null : normalized;
}
_ExpiryDate _parseExpiryDate(String value, int rowNumber) { _ExpiryDate _parseExpiryDate(String value, int rowNumber) {
final match = RegExp(r'^\s*(\d{1,2})\s*/\s*(\d{2})\s*$').firstMatch(value); final match = RegExp(r'^\s*(\d{1,2})\s*/\s*(\d{2})\s*$').firstMatch(value);
if (match == null) { if (match == null) {

View File

@@ -1,4 +1,5 @@
import 'package:pshared/models/money.dart'; 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/fees/treatment.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/kind.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'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
class MultipleIntentBuilder { class MultipleIntentBuilder {
static const String _currency = 'RUB'; static const String _currency = 'RUB';
@@ -23,22 +25,33 @@ class MultipleIntentBuilder {
); );
return rows 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 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( return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
source: sourceMethod, source: sourceMethod,
destination: CardPaymentMethod( destination: destination,
pan: row.pan,
firstName: row.firstName,
lastName: row.lastName,
expMonth: row.expMonth,
expYear: row.expYear,
),
amount: amount, amount: amount,
feeTreatment: FeeTreatment.addToSource, feeTreatment: FeeTreatment.addToSource,
settlementMode: SettlementMode.fixReceived, settlementMode: SettlementMode.fixReceived,
fx: fxIntent, fx: fxIntent,
comment: row.comment,
customer: Customer(
id: 'csv_row_${rowIndex + 1}',
firstName: destination.firstName,
lastName: destination.lastName,
),
); );
}) })
.toList(growable: false); .toList(growable: false);

View File

@@ -25,6 +25,7 @@ OperationItem mapPaymentToOperation(Payment payment) {
_firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-'; _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-';
final comment = final comment =
_firstNonEmpty([ _firstNonEmpty([
payment.comment,
payment.failureReason, payment.failureReason,
payment.failureCode, payment.failureCode,
payment.state, payment.state,