Merge pull request 'fix for quote and operations addition' (#729) from SEND073 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #729
This commit was merged in pull request #729.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
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/payment/operation_money.dart';
|
||||||
|
|
||||||
part 'operation.g.dart';
|
part 'operation.g.dart';
|
||||||
|
|
||||||
@@ -13,8 +13,7 @@ class PaymentOperationDTO {
|
|||||||
final String? code;
|
final String? code;
|
||||||
final String? state;
|
final String? state;
|
||||||
final String? label;
|
final String? label;
|
||||||
final MoneyDTO? amount;
|
final PaymentOperationMoneyDTO? money;
|
||||||
final MoneyDTO? convertedAmount;
|
|
||||||
final String? failureCode;
|
final String? failureCode;
|
||||||
final String? failureReason;
|
final String? failureReason;
|
||||||
final String? startedAt;
|
final String? startedAt;
|
||||||
@@ -27,8 +26,7 @@ class PaymentOperationDTO {
|
|||||||
this.code,
|
this.code,
|
||||||
this.state,
|
this.state,
|
||||||
this.label,
|
this.label,
|
||||||
this.amount,
|
this.money,
|
||||||
this.convertedAmount,
|
|
||||||
this.failureCode,
|
this.failureCode,
|
||||||
this.failureReason,
|
this.failureReason,
|
||||||
this.startedAt,
|
this.startedAt,
|
||||||
|
|||||||
31
frontend/pshared/lib/data/dto/payment/operation_money.dart
Normal file
31
frontend/pshared/lib/data/dto/payment/operation_money.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:pshared/data/dto/money.dart';
|
||||||
|
|
||||||
|
part 'operation_money.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:pshared/data/dto/payment/operation.dart';
|
import 'package:pshared/data/dto/payment/operation.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/operation_money.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';
|
||||||
|
|
||||||
@@ -11,8 +12,7 @@ extension PaymentOperationDTOMapper on PaymentOperationDTO {
|
|||||||
code: code,
|
code: code,
|
||||||
state: state,
|
state: state,
|
||||||
label: label,
|
label: label,
|
||||||
amount: amount?.toDomain(),
|
money: money?.toDomain(),
|
||||||
convertedAmount: convertedAmount?.toDomain(),
|
|
||||||
failureCode: failureCode,
|
failureCode: failureCode,
|
||||||
failureReason: failureReason,
|
failureReason: failureReason,
|
||||||
startedAt: _parseDateTime(startedAt),
|
startedAt: _parseDateTime(startedAt),
|
||||||
@@ -28,8 +28,7 @@ extension PaymentExecutionOperationMapper on PaymentExecutionOperation {
|
|||||||
code: code,
|
code: code,
|
||||||
state: state,
|
state: state,
|
||||||
label: label,
|
label: label,
|
||||||
amount: amount?.toDTO(),
|
money: money?.toDTO(),
|
||||||
convertedAmount: convertedAmount?.toDTO(),
|
|
||||||
failureCode: failureCode,
|
failureCode: failureCode,
|
||||||
failureReason: failureReason,
|
failureReason: failureReason,
|
||||||
startedAt: startedAt?.toUtc().toIso8601String(),
|
startedAt: startedAt?.toUtc().toIso8601String(),
|
||||||
@@ -37,6 +36,35 @@ extension PaymentExecutionOperationMapper on PaymentExecutionOperation {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PaymentOperationMoneyDTOMapper on PaymentOperationMoneyDTO {
|
||||||
|
PaymentOperationMoney toDomain() => PaymentOperationMoney(
|
||||||
|
planned: planned?.toDomain(),
|
||||||
|
executed: executed?.toDomain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentOperationMoneyMapper on PaymentOperationMoney {
|
||||||
|
PaymentOperationMoneyDTO toDTO() => PaymentOperationMoneyDTO(
|
||||||
|
planned: planned?.toDTO(),
|
||||||
|
executed: executed?.toDTO(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentOperationMoneySnapshotDTOMapper
|
||||||
|
on PaymentOperationMoneySnapshotDTO {
|
||||||
|
PaymentOperationMoneySnapshot toDomain() => PaymentOperationMoneySnapshot(
|
||||||
|
amount: amount?.toDomain(),
|
||||||
|
convertedAmount: convertedAmount?.toDomain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentOperationMoneySnapshotMapper on PaymentOperationMoneySnapshot {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import 'package:money2/money2.dart';
|
import 'package:money2/money2.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentOperationMoneySnapshot {
|
||||||
|
final Money? amount;
|
||||||
|
final Money? convertedAmount;
|
||||||
|
|
||||||
|
const PaymentOperationMoneySnapshot({
|
||||||
|
required this.amount,
|
||||||
|
required this.convertedAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentOperationMoney {
|
||||||
|
final PaymentOperationMoneySnapshot? planned;
|
||||||
|
final PaymentOperationMoneySnapshot? executed;
|
||||||
|
|
||||||
|
const PaymentOperationMoney({required this.planned, required this.executed});
|
||||||
|
}
|
||||||
|
|
||||||
class PaymentExecutionOperation {
|
class PaymentExecutionOperation {
|
||||||
final String? stepRef;
|
final String? stepRef;
|
||||||
final String? operationRef;
|
final String? operationRef;
|
||||||
@@ -8,8 +25,7 @@ class PaymentExecutionOperation {
|
|||||||
final String? code;
|
final String? code;
|
||||||
final String? state;
|
final String? state;
|
||||||
final String? label;
|
final String? label;
|
||||||
final Money? amount;
|
final PaymentOperationMoney? money;
|
||||||
final Money? convertedAmount;
|
|
||||||
final String? failureCode;
|
final String? failureCode;
|
||||||
final String? failureReason;
|
final String? failureReason;
|
||||||
final DateTime? startedAt;
|
final DateTime? startedAt;
|
||||||
@@ -22,11 +38,18 @@ class PaymentExecutionOperation {
|
|||||||
required this.code,
|
required this.code,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.amount,
|
required this.money,
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Money? get amount => money?.executed?.amount ?? money?.planned?.amount;
|
||||||
|
Money? get convertedAmount =>
|
||||||
|
money?.executed?.convertedAmount ?? money?.planned?.convertedAmount;
|
||||||
|
Money? get plannedAmount => money?.planned?.amount;
|
||||||
|
Money? get plannedConvertedAmount => money?.planned?.convertedAmount;
|
||||||
|
Money? get executedAmount => money?.executed?.amount;
|
||||||
|
Money? get executedConvertedAmount => money?.executed?.convertedAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class OperationItem {
|
|||||||
final PaymentMethod? paymentMethod;
|
final PaymentMethod? paymentMethod;
|
||||||
final String name;
|
final String name;
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
final String comment;
|
// final String comment;
|
||||||
|
|
||||||
OperationItem({
|
OperationItem({
|
||||||
required this.status,
|
required this.status,
|
||||||
@@ -34,6 +34,6 @@ class OperationItem {
|
|||||||
this.paymentMethod,
|
this.paymentMethod,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.date,
|
required this.date,
|
||||||
required this.comment,
|
// required this.comment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import 'package:pshared/api/requests/payment/quotes.dart';
|
import 'package:pshared/api/requests/payment/quotes.dart';
|
||||||
import 'package:pshared/data/mapper/payment/intent/payment.dart';
|
import 'package:pshared/data/mapper/payment/intent/payment.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/quote/quotes.dart';
|
import 'package:pshared/models/payment/quote/quotes.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
import 'package:pshared/provider/payment/auto_refresh.dart';
|
import 'package:pshared/provider/payment/auto_refresh.dart';
|
||||||
@@ -12,6 +13,7 @@ import 'package:pshared/provider/resource.dart';
|
|||||||
import 'package:pshared/service/payment/multiple.dart';
|
import 'package:pshared/service/payment/multiple.dart';
|
||||||
import 'package:pshared/utils/exception.dart';
|
import 'package:pshared/utils/exception.dart';
|
||||||
|
|
||||||
|
|
||||||
class MultiQuotationProvider extends ChangeNotifier {
|
class MultiQuotationProvider extends ChangeNotifier {
|
||||||
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
||||||
|
|
||||||
@@ -77,6 +79,11 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
if (intents.isEmpty) {
|
if (intents.isEmpty) {
|
||||||
throw StateError('At least one payment intent is required');
|
throw StateError('At least one payment intent is required');
|
||||||
}
|
}
|
||||||
|
if (intents.any((intent) => !_isQuotable(intent))) {
|
||||||
|
throw StateError(
|
||||||
|
'Each payment intent must include kind, source, destination, and amount',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_lastIntents = List<PaymentIntent>.from(intents);
|
_lastIntents = List<PaymentIntent>.from(intents);
|
||||||
_lastPreviewOnly = previewOnly;
|
_lastPreviewOnly = previewOnly;
|
||||||
@@ -135,6 +142,13 @@ class MultiQuotationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isQuotable(PaymentIntent intent) {
|
||||||
|
if (intent.kind == PaymentKind.unspecified) return false;
|
||||||
|
if (intent.source == null) return false;
|
||||||
|
if (intent.destination == null) return false;
|
||||||
|
return intent.amount != null;
|
||||||
|
}
|
||||||
|
|
||||||
void _setResource(Resource<PaymentQuotes> quotation) {
|
void _setResource(Resource<PaymentQuotes> quotation) {
|
||||||
_quotation = quotation;
|
_quotation = quotation;
|
||||||
_syncAutoRefresh();
|
_syncAutoRefresh();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:pshared/api/requests/payment/quote.dart';
|
|||||||
import 'package:pshared/controllers/payment/source.dart';
|
import 'package:pshared/controllers/payment/source.dart';
|
||||||
import 'package:pshared/data/mapper/payment/intent/payment.dart';
|
import 'package:pshared/data/mapper/payment/intent/payment.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/quote/quote.dart';
|
import 'package:pshared/models/payment/quote/quote.dart';
|
||||||
import 'package:pshared/models/auto_refresh_mode.dart';
|
import 'package:pshared/models/auto_refresh_mode.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
@@ -52,13 +53,17 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
) {
|
) {
|
||||||
_organizations = venue;
|
_organizations = venue;
|
||||||
_sourceCurrencyCode = source.selectedCurrencyCode;
|
_sourceCurrencyCode = source.selectedCurrencyCode;
|
||||||
final intent = _intentBuilder.build(
|
final builtIntent = _intentBuilder.build(
|
||||||
payment: payment,
|
payment: payment,
|
||||||
source: source,
|
source: source,
|
||||||
flow: flow,
|
flow: flow,
|
||||||
recipients: recipients,
|
recipients: recipients,
|
||||||
);
|
);
|
||||||
if (intent == null) return;
|
if (!_isQuotable(builtIntent)) {
|
||||||
|
_clearQuotation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final intent = builtIntent!;
|
||||||
final intentKey = _buildIntentKey(intent);
|
final intentKey = _buildIntentKey(intent);
|
||||||
final lastIntent = _lastIntent;
|
final lastIntent = _lastIntent;
|
||||||
if (lastIntent != null && intentKey == _buildIntentKey(lastIntent)) return;
|
if (lastIntent != null && intentKey == _buildIntentKey(lastIntent)) return;
|
||||||
@@ -100,12 +105,19 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<PaymentQuote?> refreshQuotation() async {
|
Future<PaymentQuote?> refreshQuotation() async {
|
||||||
final intent = _lastIntent;
|
final lastIntent = _lastIntent;
|
||||||
if (intent == null) return null;
|
if (!_isQuotable(lastIntent)) {
|
||||||
return getQuotation(intent);
|
_clearQuotation();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getQuotation(lastIntent!);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
|
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
|
||||||
|
if (!_isQuotable(intent)) {
|
||||||
|
_clearQuotation();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!_organizations.isOrganizationSet) {
|
if (!_organizations.isOrganizationSet) {
|
||||||
throw StateError('Organization is not set');
|
throw StateError('Organization is not set');
|
||||||
}
|
}
|
||||||
@@ -138,8 +150,20 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
_isLoaded = false;
|
_isLoaded = false;
|
||||||
_lastIntent = null;
|
|
||||||
_sourceCurrencyCode = null;
|
_sourceCurrencyCode = null;
|
||||||
|
_clearQuotation();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isQuotable(PaymentIntent? intent) {
|
||||||
|
if (intent == null) return false;
|
||||||
|
if (intent.kind == PaymentKind.unspecified) return false;
|
||||||
|
if (intent.source == null) return false;
|
||||||
|
if (intent.destination == null) return false;
|
||||||
|
return intent.amount != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearQuotation() {
|
||||||
|
_lastIntent = null;
|
||||||
_setResource(Resource(data: null, isLoading: false, error: null));
|
_setResource(Resource(data: null, isLoading: false, error: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
import 'package:pshared/api/requests/payment/initiate.dart';
|
|
||||||
import 'package:pshared/api/requests/payment/initiate_payments.dart';
|
|
||||||
import 'package:pshared/api/requests/payment/quote.dart';
|
|
||||||
import 'package:pshared/api/responses/payment/payment.dart';
|
|
||||||
import 'package:pshared/api/responses/payment/quotation.dart';
|
|
||||||
import 'package:pshared/data/dto/money.dart';
|
|
||||||
import 'package:pshared/data/dto/payment/currency_pair.dart';
|
|
||||||
import 'package:pshared/data/dto/payment/endpoint.dart';
|
|
||||||
import 'package:pshared/data/dto/payment/intent/fx.dart';
|
|
||||||
import 'package:pshared/data/dto/payment/intent/payment.dart';
|
|
||||||
import 'package:pshared/data/mapper/payment/payment.dart';
|
|
||||||
import 'package:pshared/data/mapper/payment/payment_response.dart';
|
|
||||||
import 'package:pshared/models/payment/asset.dart';
|
|
||||||
import 'package:pshared/models/payment/chain_network.dart';
|
|
||||||
import 'package:pshared/models/payment/methods/card_token.dart';
|
|
||||||
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
|
||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
|
||||||
import 'package:pshared/models/payment/methods/wallet.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('Payment request DTO contract', () {
|
|
||||||
test('serializes endpoint types to backend canonical values', () {
|
|
||||||
final managed = ManagedWalletPaymentMethod(
|
|
||||||
managedWalletRef: 'mw-1',
|
|
||||||
).toDTO();
|
|
||||||
final external = CryptoAddressPaymentMethod(
|
|
||||||
asset: const PaymentAsset(
|
|
||||||
chain: ChainNetwork.tronMainnet,
|
|
||||||
tokenSymbol: 'USDT',
|
|
||||||
),
|
|
||||||
address: 'TXYZ',
|
|
||||||
).toDTO();
|
|
||||||
final cardToken = CardTokenPaymentMethod(
|
|
||||||
token: 'tok_1',
|
|
||||||
maskedPan: '4111',
|
|
||||||
).toDTO();
|
|
||||||
|
|
||||||
expect(managed.type, equals('managedWallet'));
|
|
||||||
expect(external.type, equals('cryptoAddress'));
|
|
||||||
expect(cardToken.type, equals('cardToken'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quote payment request uses expected backend field names', () {
|
|
||||||
final request = QuotePaymentRequest(
|
|
||||||
idempotencyKey: '',
|
|
||||||
previewOnly: true,
|
|
||||||
intent: const PaymentIntentDTO(
|
|
||||||
kind: 'payout',
|
|
||||||
source: PaymentEndpointDTO(
|
|
||||||
type: 'ledger',
|
|
||||||
data: {'ledger_account_ref': 'ledger:src'},
|
|
||||||
),
|
|
||||||
destination: PaymentEndpointDTO(
|
|
||||||
type: 'cardToken',
|
|
||||||
data: {'token': 'tok_1', 'masked_pan': '4111'},
|
|
||||||
),
|
|
||||||
amount: MoneyDTO(amount: '10', currency: 'USD'),
|
|
||||||
settlementMode: 'fix_received',
|
|
||||||
comment: 'invoice-7',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final json =
|
|
||||||
jsonDecode(jsonEncode(request.toJson())) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
expect(json['idempotencyKey'], equals(''));
|
|
||||||
expect(json['previewOnly'], isTrue);
|
|
||||||
expect(json['intent'], isA<Map<String, dynamic>>());
|
|
||||||
|
|
||||||
final intent = json['intent'] as Map<String, dynamic>;
|
|
||||||
expect(intent['kind'], equals('payout'));
|
|
||||||
expect(intent['settlement_mode'], equals('fix_received'));
|
|
||||||
expect(intent['comment'], equals('invoice-7'));
|
|
||||||
expect(intent.containsKey('settlement_currency'), isFalse);
|
|
||||||
|
|
||||||
final source = intent['source'] as Map<String, dynamic>;
|
|
||||||
final destination = intent['destination'] as Map<String, dynamic>;
|
|
||||||
expect(source['type'], equals('ledger'));
|
|
||||||
expect(destination['type'], equals('cardToken'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quote payment request serializes fx side to backend value', () {
|
|
||||||
final request = QuotePaymentRequest(
|
|
||||||
idempotencyKey: '',
|
|
||||||
previewOnly: true,
|
|
||||||
intent: const PaymentIntentDTO(
|
|
||||||
kind: 'payout',
|
|
||||||
source: PaymentEndpointDTO(
|
|
||||||
type: 'managedWallet',
|
|
||||||
data: {'managed_wallet_ref': 'mw-1'},
|
|
||||||
),
|
|
||||||
destination: PaymentEndpointDTO(
|
|
||||||
type: 'cardToken',
|
|
||||||
data: {'token': 'tok_1', 'masked_pan': '4111'},
|
|
||||||
),
|
|
||||||
amount: MoneyDTO(amount: '10', currency: 'USDT'),
|
|
||||||
settlementMode: 'fix_source',
|
|
||||||
fx: FxIntentDTO(
|
|
||||||
pair: CurrencyPairDTO(base: 'RUB', quote: 'USDT'),
|
|
||||||
side: 'buy_base_sell_quote',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final json =
|
|
||||||
jsonDecode(jsonEncode(request.toJson())) as Map<String, dynamic>;
|
|
||||||
final intent = json['intent'] as Map<String, dynamic>;
|
|
||||||
final fx = intent['fx'] as Map<String, dynamic>;
|
|
||||||
expect(fx['side'], equals('buy_base_sell_quote'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quote response parses backend fx quote pricedAtUnixMs', () {
|
|
||||||
final response = PaymentQuoteResponse.fromJson({
|
|
||||||
'accessToken': {'token': 'token', 'expiration': '2026-02-25T00:00:00Z'},
|
|
||||||
'idempotencyKey': 'idem-1',
|
|
||||||
'quote': {
|
|
||||||
'quoteRef': 'q-1',
|
|
||||||
'intentRef': 'intent-1',
|
|
||||||
'amounts': {
|
|
||||||
'sourcePrincipal': {'amount': '10', 'currency': 'USDT'},
|
|
||||||
'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'},
|
|
||||||
'destinationSettlement': {'amount': '760', 'currency': 'RUB'},
|
|
||||||
},
|
|
||||||
'fees': {
|
|
||||||
'lines': [
|
|
||||||
{
|
|
||||||
'ledgerAccountRef': 'ledger:fees',
|
|
||||||
'amount': {'amount': '0.75', 'currency': 'USDT'},
|
|
||||||
'lineType': 'posting_line_type_fee',
|
|
||||||
'side': 'entry_side_debit',
|
|
||||||
'meta': {'fee_target': 'wallet'},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'fxQuote': {
|
|
||||||
'quoteRef': 'fx-1',
|
|
||||||
'baseCurrency': 'USDT',
|
|
||||||
'quoteCurrency': 'RUB',
|
|
||||||
'side': 'sell_base_buy_quote',
|
|
||||||
'price': '76',
|
|
||||||
'baseAmount': {'amount': '10', 'currency': 'USDT'},
|
|
||||||
'quoteAmount': {'amount': '760', 'currency': 'RUB'},
|
|
||||||
'expiresAtUnixMs': 1771945907749,
|
|
||||||
'pricedAtUnixMs': 1771945907000,
|
|
||||||
'provider': 'binance',
|
|
||||||
'rateRef': 'rate-1',
|
|
||||||
'firm': false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000));
|
|
||||||
expect(response.quote.intentRef, equals('intent-1'));
|
|
||||||
expect(response.quote.amounts?.sourceDebitTotal?.amount, equals('10.75'));
|
|
||||||
expect(response.quote.fees?.lines?.length, equals(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initiate payment by quote keeps expected fields', () {
|
|
||||||
final request = InitiatePaymentRequest(
|
|
||||||
idempotencyKey: 'idem-2',
|
|
||||||
quoteRef: 'q-1',
|
|
||||||
clientPaymentRef: 'cp-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
final json = request.toJson();
|
|
||||||
expect(json['idempotencyKey'], equals('idem-2'));
|
|
||||||
expect(json['quoteRef'], equals('q-1'));
|
|
||||||
expect(json['clientPaymentRef'], equals('cp-1'));
|
|
||||||
expect(json.containsKey('intent'), isTrue);
|
|
||||||
expect(json['intent'], isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initiate multi payments request keeps expected fields', () {
|
|
||||||
final request = InitiatePaymentsRequest(
|
|
||||||
idempotencyKey: 'idem-3',
|
|
||||||
quoteRef: 'q-2',
|
|
||||||
clientPaymentRef: 'cp-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
final json = request.toJson();
|
|
||||||
expect(json['idempotencyKey'], equals('idem-3'));
|
|
||||||
expect(json['quoteRef'], equals('q-2'));
|
|
||||||
expect(json['clientPaymentRef'], equals('cp-1'));
|
|
||||||
expect(json.containsKey('intentRef'), isFalse);
|
|
||||||
expect(json.containsKey('intentRefs'), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'payment response parses source and destination endpoint snapshots',
|
|
||||||
() {
|
|
||||||
final response = PaymentResponse.fromJson({
|
|
||||||
'accessToken': {
|
|
||||||
'token': 'token',
|
|
||||||
'expiration': '2026-02-25T00:00:00Z',
|
|
||||||
},
|
|
||||||
'payment': {
|
|
||||||
'paymentRef': 'pay-1',
|
|
||||||
'state': 'orchestration_state_created',
|
|
||||||
'source': {
|
|
||||||
'type': 'wallet',
|
|
||||||
'data': {'walletId': 'wallet-1'},
|
|
||||||
},
|
|
||||||
'destination': {'paymentMethodRef': 'pm-123'},
|
|
||||||
'operations': [],
|
|
||||||
'meta': {'quotationRef': 'quote-1'},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
final payment = response.payment.toDomain();
|
|
||||||
expect(payment.paymentRef, equals('pay-1'));
|
|
||||||
expect(payment.source, isNotNull);
|
|
||||||
expect(payment.destination, isNotNull);
|
|
||||||
expect(payment.destination?.paymentMethodRef, equals('pm-123'));
|
|
||||||
expect(payment.source?.method, isA<WalletPaymentMethod>());
|
|
||||||
|
|
||||||
final sourceMethod = payment.source?.method as WalletPaymentMethod;
|
|
||||||
expect(sourceMethod.walletId, equals('wallet-1'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -27,11 +27,15 @@ class OperationHistoryTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final title = resolveOperationTitle(loc, operation.code);
|
|
||||||
final operationLabel = operation.label?.trim();
|
final operationLabel = operation.label?.trim();
|
||||||
|
final title = (operationLabel != null && operationLabel.isNotEmpty)
|
||||||
|
? operationLabel
|
||||||
|
: resolveOperationTitle(loc, operation.code);
|
||||||
|
// final operationComment = operation.comment?.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 amount = formatMoneyUi(context, operation.amount);
|
||||||
|
final convertedAmount = formatMoneyUi(context, operation.convertedAmount);
|
||||||
final canDownload = canDownloadDocument && onDownloadDocument != null;
|
final canDownload = canDownloadDocument && onDownloadDocument != null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -52,17 +56,6 @@ class OperationHistoryTile extends StatelessWidget {
|
|||||||
StepStateChip(view: stateView),
|
StepStateChip(view: stateView),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (operationLabel != null &&
|
|
||||||
operationLabel.isNotEmpty &&
|
|
||||||
operationLabel != title) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
operationLabel,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'${loc.completedAtLabel}: $completedAt',
|
'${loc.completedAtLabel}: $completedAt',
|
||||||
@@ -77,6 +70,24 @@ class OperationHistoryTile extends StatelessWidget {
|
|||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (operation.convertedAmount != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${loc.toAmountColumn}: $convertedAmount',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// if (operationComment != null && operationComment.isNotEmpty) ...[
|
||||||
|
// const SizedBox(height: 4),
|
||||||
|
// Text(
|
||||||
|
// '${loc.comment}: $operationComment',
|
||||||
|
// 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(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class OperationRow {
|
|||||||
DataCell(Text(op.cardNumber ?? '-')),
|
DataCell(Text(op.cardNumber ?? '-')),
|
||||||
DataCell(Text(op.name)),
|
DataCell(Text(op.name)),
|
||||||
DataCell(Text(dateLabel)),
|
DataCell(Text(dateLabel)),
|
||||||
DataCell(Text(op.comment)),
|
// DataCell(Text(op.comment)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import 'package:pshared/models/payment/operation.dart';
|
|
||||||
import 'package:pshared/models/payment/status.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class OperationService {
|
|
||||||
Future<List<OperationItem>> fetchOperations() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
return [
|
|
||||||
OperationItem(
|
|
||||||
status: OperationStatus.error,
|
|
||||||
fileName: 'cards_payout_sample_june.csv',
|
|
||||||
amount: 10,
|
|
||||||
currency: 'EUR',
|
|
||||||
toAmount: 10,
|
|
||||||
toCurrency: 'EUR',
|
|
||||||
payId: '860163800',
|
|
||||||
cardNumber: null,
|
|
||||||
name: 'John Snow',
|
|
||||||
date: DateTime(2025, 7, 14, 19, 59, 2),
|
|
||||||
comment: 'EUR visa',
|
|
||||||
),
|
|
||||||
OperationItem(
|
|
||||||
status: OperationStatus.processing,
|
|
||||||
fileName: 'cards_payout_sample_june.csv',
|
|
||||||
amount: 10,
|
|
||||||
currency: 'EUR',
|
|
||||||
toAmount: 10,
|
|
||||||
toCurrency: 'EUR',
|
|
||||||
payId: '860163700',
|
|
||||||
cardNumber: null,
|
|
||||||
name: 'Baltasar Gelt',
|
|
||||||
date: DateTime(2025, 7, 14, 19, 59, 2),
|
|
||||||
comment: 'EUR master',
|
|
||||||
),
|
|
||||||
OperationItem(
|
|
||||||
status: OperationStatus.error,
|
|
||||||
fileName: 'cards_payout_sample_june.csv',
|
|
||||||
amount: 10,
|
|
||||||
currency: 'EUR',
|
|
||||||
toAmount: 10,
|
|
||||||
toCurrency: 'EUR',
|
|
||||||
payId: '40000000****0077',
|
|
||||||
cardNumber: '40000000****0077',
|
|
||||||
name: 'John Snow',
|
|
||||||
date: DateTime(2025, 7, 14, 19, 23, 22),
|
|
||||||
comment: 'EUR visa',
|
|
||||||
),
|
|
||||||
OperationItem(
|
|
||||||
status: OperationStatus.success,
|
|
||||||
fileName: null,
|
|
||||||
amount: 10,
|
|
||||||
currency: 'EUR',
|
|
||||||
toAmount: 10,
|
|
||||||
toCurrency: 'EUR',
|
|
||||||
payId: '54133300****0019',
|
|
||||||
cardNumber: '54133300****0019',
|
|
||||||
name: 'Baltasar Gelt',
|
|
||||||
date: DateTime(2025, 7, 14, 19, 23, 21),
|
|
||||||
comment: 'EUR master',
|
|
||||||
),
|
|
||||||
OperationItem(
|
|
||||||
status: OperationStatus.success,
|
|
||||||
fileName: null,
|
|
||||||
amount: 130,
|
|
||||||
currency: 'EUR',
|
|
||||||
toAmount: 130,
|
|
||||||
toCurrency: 'EUR',
|
|
||||||
payId: '54134300****0019',
|
|
||||||
cardNumber: '54153300****0019',
|
|
||||||
name: 'Ivan Brokov',
|
|
||||||
date: DateTime(2025, 7, 15, 19, 23, 21),
|
|
||||||
comment: 'EUR master 2',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add real API:
|
|
||||||
// Future<List<OperationItem>> fetchOperations() async {
|
|
||||||
// final response = await _httpClient.get('/api/operations');
|
|
||||||
// return (response.data as List)
|
|
||||||
// .map((json) => OperationItem.fromJson(json))
|
|
||||||
// .toList();
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -23,14 +23,15 @@ OperationItem mapPaymentToOperation(Payment payment) {
|
|||||||
final payId = _firstNonEmpty([payment.paymentRef]) ?? '-';
|
final payId = _firstNonEmpty([payment.paymentRef]) ?? '-';
|
||||||
final name =
|
final name =
|
||||||
_firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-';
|
_firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-';
|
||||||
final comment =
|
// final comment =
|
||||||
_firstNonEmpty([
|
// _firstNonEmpty([
|
||||||
payment.comment,
|
// ...payment.operations.map((operation) => operation.comment),
|
||||||
payment.failureReason,
|
// payment.comment,
|
||||||
payment.failureCode,
|
// payment.failureReason,
|
||||||
payment.state,
|
// payment.failureCode,
|
||||||
]) ??
|
// payment.state,
|
||||||
'';
|
// ]) ??
|
||||||
|
// '';
|
||||||
final operationDocument = _resolveOperationDocument(payment);
|
final operationDocument = _resolveOperationDocument(payment);
|
||||||
|
|
||||||
return OperationItem(
|
return OperationItem(
|
||||||
@@ -47,7 +48,7 @@ OperationItem mapPaymentToOperation(Payment payment) {
|
|||||||
cardNumber: null,
|
cardNumber: null,
|
||||||
name: name,
|
name: name,
|
||||||
date: resolvePaymentDate(payment),
|
date: resolvePaymentDate(payment),
|
||||||
comment: comment,
|
// comment: comment,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -480,12 +480,9 @@ components:
|
|||||||
label:
|
label:
|
||||||
description: Human-readable operation label.
|
description: Human-readable operation label.
|
||||||
type: string
|
type: string
|
||||||
amount:
|
money:
|
||||||
description: Primary money amount associated with the operation.
|
description: Planned and executed monetary snapshots for the operation.
|
||||||
$ref: ../common/money.yaml#/components/schemas/Money
|
$ref: ./payment.yaml#/components/schemas/PaymentOperationMoney
|
||||||
convertedAmount:
|
|
||||||
description: Secondary amount for conversion operations (for example FX convert output amount).
|
|
||||||
$ref: ../common/money.yaml#/components/schemas/Money
|
|
||||||
operationRef:
|
operationRef:
|
||||||
description: External operation reference identifier reported by the gateway.
|
description: External operation reference identifier reported by the gateway.
|
||||||
type: string
|
type: string
|
||||||
@@ -507,6 +504,30 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|
||||||
|
PaymentOperationMoney:
|
||||||
|
description: Planned and executed monetary snapshots associated with the operation.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
planned:
|
||||||
|
description: Monetary values planned for the operation before execution.
|
||||||
|
$ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot
|
||||||
|
executed:
|
||||||
|
description: Monetary values observed after the operation was executed.
|
||||||
|
$ref: ./payment.yaml#/components/schemas/PaymentOperationMoneySnapshot
|
||||||
|
|
||||||
|
PaymentOperationMoneySnapshot:
|
||||||
|
description: Monetary snapshot containing base and converted amounts for an operation state.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
description: Primary money amount associated with the operation snapshot.
|
||||||
|
$ref: ../common/money.yaml#/components/schemas/Money
|
||||||
|
convertedAmount:
|
||||||
|
description: Secondary amount for conversion operations in the same snapshot.
|
||||||
|
$ref: ../common/money.yaml#/components/schemas/Money
|
||||||
|
|
||||||
Payment:
|
Payment:
|
||||||
description: Persisted payment aggregate with status, quote, and operation history.
|
description: Persisted payment aggregate with status, quote, and operation history.
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user