payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

@@ -5,7 +5,6 @@ import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
PaymentKind paymentKindFromValue(String? value) {
switch (value) {
case 'payout':
@@ -166,6 +165,7 @@ PaymentType endpointTypeFromValue(String? value) {
case 'managedWallet':
case 'managed_wallet':
return PaymentType.managedWallet;
case 'cryptoAddress':
case 'externalChain':
case 'external_chain':
return PaymentType.externalChain;
@@ -193,15 +193,15 @@ String endpointTypeToValue(PaymentType type) {
case PaymentType.ledger:
return 'ledger';
case PaymentType.managedWallet:
return 'managed_wallet';
return 'managedWallet';
case PaymentType.externalChain:
return 'external_chain';
return 'cryptoAddress';
case PaymentType.card:
return 'card';
case PaymentType.cardToken:
return 'card';
return 'cardToken';
case PaymentType.bankAccount:
return 'bank_account';
return 'bankAccount';
case PaymentType.iban:
return 'iban';
case PaymentType.wallet:

View File

@@ -1,30 +1,31 @@
import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDomain(),
metadata: metadata,
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
);
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDomain(),
metadata: metadata,
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
);
}
extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDTO(),
metadata: metadata,
createdAt: createdAt?.toUtc().toIso8601String(),
);
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state ?? paymentOrchestrationStateToValue(orchestrationState),
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDTO(),
metadata: metadata,
createdAt: createdAt?.toUtc().toIso8601String(),
);
}

View File

@@ -1,10 +1,11 @@
import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/state.dart';
class Payment {
final String? paymentRef;
final String? idempotencyKey;
final String? state;
final PaymentOrchestrationState orchestrationState;
final String? failureCode;
final String? failureReason;
final PaymentQuote? lastQuote;
@@ -15,6 +16,7 @@ class Payment {
required this.paymentRef,
required this.idempotencyKey,
required this.state,
required this.orchestrationState,
required this.failureCode,
required this.failureReason,
required this.lastQuote,
@@ -22,9 +24,12 @@ class Payment {
required this.createdAt,
});
bool get isPending => orchestrationState.isPending;
bool get isTerminal => orchestrationState.isTerminal;
bool get isFailure {
if ((failureCode ?? '').trim().isNotEmpty) return true;
final normalized = (state ?? '').trim().toLowerCase();
return normalized.contains('fail') || normalized.contains('cancel');
return orchestrationState == PaymentOrchestrationState.failed;
}
}

View File

@@ -0,0 +1,81 @@
enum PaymentOrchestrationState {
created,
executing,
needsAttention,
settled,
failed,
unspecified,
}
PaymentOrchestrationState paymentOrchestrationStateFromValue(String? value) {
final normalized = _normalizePaymentState(value);
switch (normalized) {
case 'CREATED':
case 'ACCEPTED':
return PaymentOrchestrationState.created;
case 'EXECUTING':
case 'PROCESSING':
case 'FUNDS_RESERVED':
case 'SUBMITTED':
return PaymentOrchestrationState.executing;
case 'NEEDS_ATTENTION':
return PaymentOrchestrationState.needsAttention;
case 'SETTLED':
case 'SUCCESS':
return PaymentOrchestrationState.settled;
case 'FAILED':
case 'CANCELLED':
return PaymentOrchestrationState.failed;
default:
return PaymentOrchestrationState.unspecified;
}
}
String paymentOrchestrationStateToValue(PaymentOrchestrationState state) {
switch (state) {
case PaymentOrchestrationState.created:
return 'orchestration_state_created';
case PaymentOrchestrationState.executing:
return 'orchestration_state_executing';
case PaymentOrchestrationState.needsAttention:
return 'orchestration_state_needs_attention';
case PaymentOrchestrationState.settled:
return 'orchestration_state_settled';
case PaymentOrchestrationState.failed:
return 'orchestration_state_failed';
case PaymentOrchestrationState.unspecified:
return 'orchestration_state_unspecified';
}
}
extension PaymentOrchestrationStateX on PaymentOrchestrationState {
bool get isTerminal {
switch (this) {
case PaymentOrchestrationState.settled:
case PaymentOrchestrationState.failed:
return true;
case PaymentOrchestrationState.created:
case PaymentOrchestrationState.executing:
case PaymentOrchestrationState.needsAttention:
case PaymentOrchestrationState.unspecified:
return false;
}
}
bool get isPending => !isTerminal;
}
String _normalizePaymentState(String? value) {
final trimmed = (value ?? '').trim().toUpperCase();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.startsWith('ORCHESTRATION_STATE_')) {
return trimmed.substring('ORCHESTRATION_STATE_'.length);
}
if (trimmed.startsWith('PAYMENT_STATE_')) {
return trimmed.substring('PAYMENT_STATE_'.length);
}
return trimmed;
}

View File

@@ -1,12 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/config/constants.dart';
abstract class Storable {
String get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime get updatedAt;
}
@immutable
@@ -23,11 +22,11 @@ class _StorableImp implements Storable {
required this.createdAt,
required this.updatedAt,
});
}
Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => _StorableImp(
id: id ?? Constants.nilObjectRef,
createdAt: createdAt ?? DateTime.now().toUtc(),
updatedAt: updatedAt ?? DateTime.now().toUtc(),
);
Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) =>
_StorableImp(
id: id ?? Constants.nilObjectRef,
createdAt: createdAt ?? DateTime.now().toUtc(),
updatedAt: updatedAt ?? DateTime.now().toUtc(),
);

View File

@@ -8,7 +8,6 @@ import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/service.dart';
import 'package:pshared/utils/exception.dart';
class PaymentsProvider with ChangeNotifier {
static const Duration _pendingRefreshInterval = Duration(seconds: 10);
@@ -20,8 +19,9 @@ class PaymentsProvider with ChangeNotifier {
bool _isLoadingMore = false;
String? _nextCursor;
int? _limit;
String? _sourceRef;
String? _destinationRef;
String? _quotationRef;
DateTime? _createdFrom;
DateTime? _createdTo;
List<String>? _states;
int _opSeq = 0;
@@ -32,7 +32,8 @@ class PaymentsProvider with ChangeNotifier {
List<Payment> get payments => _resource.data ?? [];
bool get isLoading => _resource.isLoading;
Exception? get error => _resource.error;
bool get isReady => _isLoaded && !_resource.isLoading && _resource.error == null;
bool get isReady =>
_isLoaded && !_resource.isLoading && _resource.error == null;
bool get isLoadingMore => _isLoadingMore;
String? get nextCursor => _nextCursor;
@@ -54,14 +55,16 @@ class PaymentsProvider with ChangeNotifier {
Future<void> refresh({
int? limit,
String? sourceRef,
String? destinationRef,
String? quotationRef,
DateTime? createdFrom,
DateTime? createdTo,
List<String>? states,
}) async {
await _refresh(
limit: limit,
sourceRef: sourceRef,
destinationRef: destinationRef,
quotationRef: quotationRef,
createdFrom: createdFrom,
createdTo: createdTo,
states: states,
showLoading: true,
updateError: true,
@@ -70,14 +73,16 @@ class PaymentsProvider with ChangeNotifier {
Future<void> refreshSilently({
int? limit,
String? sourceRef,
String? destinationRef,
String? quotationRef,
DateTime? createdFrom,
DateTime? createdTo,
List<String>? states,
}) async {
await _refresh(
limit: limit,
sourceRef: sourceRef,
destinationRef: destinationRef,
quotationRef: quotationRef,
createdFrom: createdFrom,
createdTo: createdTo,
states: states,
showLoading: false,
updateError: false,
@@ -87,10 +92,7 @@ class PaymentsProvider with ChangeNotifier {
void mergePayments(List<Payment> incoming) {
if (incoming.isEmpty) return;
final existing = List<Payment>.from(_resource.data ?? const []);
final combined = <Payment>[
...incoming,
...existing,
];
final combined = <Payment>[...incoming, ...existing];
final seen = <String>{};
final merged = <Payment>[];
@@ -110,8 +112,9 @@ class PaymentsProvider with ChangeNotifier {
Future<void> _refresh({
int? limit,
String? sourceRef,
String? destinationRef,
String? quotationRef,
DateTime? createdFrom,
DateTime? createdTo,
List<String>? states,
required bool showLoading,
required bool updateError,
@@ -120,8 +123,9 @@ class PaymentsProvider with ChangeNotifier {
if (org == null || !org.isOrganizationSet) return;
_limit = limit;
_sourceRef = _normalize(sourceRef);
_destinationRef = _normalize(destinationRef);
_quotationRef = _normalize(quotationRef);
_createdFrom = createdFrom?.toUtc();
_createdTo = createdTo?.toUtc();
_states = _normalizeStates(states);
_nextCursor = null;
_isLoadingMore = false;
@@ -129,7 +133,10 @@ class PaymentsProvider with ChangeNotifier {
final seq = ++_opSeq;
if (showLoading) {
_applyResource(_resource.copyWith(isLoading: true, error: null), notify: true);
_applyResource(
_resource.copyWith(isLoading: true, error: null),
notify: true,
);
}
try {
@@ -137,8 +144,9 @@ class PaymentsProvider with ChangeNotifier {
org.current.id,
limit: _limit,
cursor: null,
sourceRef: _sourceRef,
destinationRef: _destinationRef,
quotationRef: _quotationRef,
createdFrom: _createdFrom,
createdTo: _createdTo,
states: _states,
);
@@ -147,11 +155,7 @@ class PaymentsProvider with ChangeNotifier {
_isLoaded = true;
_nextCursor = _normalize(page.nextCursor);
_applyResource(
Resource(
data: page.items,
isLoading: false,
error: null,
),
Resource(data: page.items, isLoading: false, error: null),
notify: true,
);
} catch (e) {
@@ -162,10 +166,7 @@ class PaymentsProvider with ChangeNotifier {
notify: true,
);
} else if (showLoading) {
_applyResource(
_resource.copyWith(isLoading: false),
notify: true,
);
_applyResource(_resource.copyWith(isLoading: false), notify: true);
}
}
}
@@ -189,8 +190,9 @@ class PaymentsProvider with ChangeNotifier {
org.current.id,
limit: _limit,
cursor: cursor,
sourceRef: _sourceRef,
destinationRef: _destinationRef,
quotationRef: _quotationRef,
createdFrom: _createdFrom,
createdTo: _createdTo,
states: _states,
);
@@ -206,10 +208,7 @@ class PaymentsProvider with ChangeNotifier {
} catch (e) {
if (seq != _opSeq) return;
_applyResource(
_resource.copyWith(error: toException(e)),
notify: false,
);
_applyResource(_resource.copyWith(error: toException(e)), notify: false);
} finally {
if (seq == _opSeq) {
_isLoadingMore = false;
@@ -224,15 +223,19 @@ class PaymentsProvider with ChangeNotifier {
_isLoadingMore = false;
_nextCursor = null;
_limit = null;
_sourceRef = null;
_destinationRef = null;
_quotationRef = null;
_createdFrom = null;
_createdTo = null;
_states = null;
_resource = Resource(data: []);
_stopPendingRefreshTimer();
notifyListeners();
}
void _applyResource(Resource<List<Payment>> newResource, {required bool notify}) {
void _applyResource(
Resource<List<Payment>> newResource, {
required bool notify,
}) {
_resource = newResource;
_syncPendingRefresh();
if (notify) notifyListeners();
@@ -253,15 +256,15 @@ class PaymentsProvider with ChangeNotifier {
List<String>? _normalizeStates(List<String>? states) {
if (states == null || states.isEmpty) return null;
final normalized = states
.map((state) => state.trim())
.where((state) => state.isNotEmpty)
.toList();
.map((state) => state.trim())
.where((state) => state.isNotEmpty)
.toList();
if (normalized.isEmpty) return null;
return normalized;
}
void _syncPendingRefresh() {
final hasPending = payments.any(_isPending);
final hasPending = payments.any((payment) => payment.isPending);
if (!hasPending) {
_stopPendingRefreshTimer();
return;
@@ -286,8 +289,9 @@ class PaymentsProvider with ChangeNotifier {
try {
await refreshSilently(
limit: _limit,
sourceRef: _sourceRef,
destinationRef: _destinationRef,
quotationRef: _quotationRef,
createdFrom: _createdFrom,
createdTo: _createdTo,
states: _states,
);
} finally {
@@ -301,29 +305,9 @@ class PaymentsProvider with ChangeNotifier {
_isPendingRefreshInFlight = false;
}
bool _isPending(Payment payment) {
final raw = payment.state;
final trimmed = (raw ?? '').trim().toUpperCase();
final normalized = trimmed.startsWith('PAYMENT_STATE_')
? trimmed.substring('PAYMENT_STATE_'.length)
: trimmed;
switch (normalized) {
case 'SUCCESS':
case 'FAILED':
case 'CANCELLED':
return false;
case 'PROCESSING':
return true;
default:
return true;
}
}
@override
void dispose() {
_stopPendingRefreshTimer();
super.dispose();
}
}

View File

@@ -6,7 +6,7 @@ import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/api/responses/login_pending.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/login_outcome.dart';
@@ -31,21 +31,33 @@ class AuthorizationService {
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
service,
'/login',
LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(),
'/login',
LoginRequest(
login: login,
deviceId: deviceId,
clientId: Constants.clientId,
).toJson(),
);
if (response.containsKey('refreshToken')) {
return LoginOutcome.completed((await completeLogin(response)).account.toDomain());
return LoginOutcome.completed(
(await completeLogin(response)).account.toDomain(),
);
}
if (response.containsKey('pendingToken')) {
final pending = PendingLogin.fromResponse(
PendingLoginResponse.fromJson(response),
session: SessionIdentifier(clientId: Constants.clientId, deviceId: deviceId),
session: SessionIdentifier(
clientId: Constants.clientId,
deviceId: deviceId,
),
);
return LoginOutcome.pending(pending);
}
throw AuthenticationFailedException('Unexpected login response', Exception(response.toString()));
throw AuthenticationFailedException(
'Unexpected login response',
Exception(response.toString()),
);
}
static Future<void> _updateAccessToken(AccountResponse response) async {
@@ -57,13 +69,16 @@ class AuthorizationService {
return AuthorizationStorage.updateRefreshToken(response.refreshToken);
}
static Future<LoginResponse> _completeLogin(Map<String, dynamic> response) async {
static Future<LoginResponse> _completeLogin(
Map<String, dynamic> response,
) async {
final LoginResponse lr = LoginResponse.fromJson(response);
await _updateTokens(lr);
return lr;
}
static Future<LoginResponse> completeLogin(Map<String, dynamic> response) => _completeLogin(response);
static Future<LoginResponse> completeLogin(Map<String, dynamic> response) =>
_completeLogin(response);
static Future<Account> restore() async {
return (await TokenService.refreshAccessToken()).account.toDomain();
@@ -74,82 +89,123 @@ class AuthorizationService {
}
// Original AuthorizationService methods - keeping the interface unchanged
static Future<Map<String, dynamic>> getGETResponse(String service, String url) async {
static Future<Map<String, dynamic>> getGETResponse(
String service,
String url,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getGETResponse(service, url, authToken: token);
}
static Future<httpr.BinaryResponse> getGETBinaryResponse(String service, String url) async {
static Future<httpr.BinaryResponse> getGETBinaryResponse(
String service,
String url,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getBinaryGETResponse(service, url, authToken: token);
}
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async {
static Future<Map<String, dynamic>> getPOSTResponse(
String service,
String url,
Map<String, dynamic> body,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPOSTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getPUTResponse(String service, String url, Map<String, dynamic> body) async {
static Future<Map<String, dynamic>> getPUTResponse(
String service,
String url,
Map<String, dynamic> body,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPUTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getPATCHResponse(String service, String url, Map<String, dynamic> body) async {
static Future<Map<String, dynamic>> getPATCHResponse(
String service,
String url,
Map<String, dynamic> body,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPATCHResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getDELETEResponse(String service, String url, Map<String, dynamic> body) async {
static Future<Map<String, dynamic>> getDELETEResponse(
String service,
String url,
Map<String, dynamic> body,
) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getDELETEResponse(service, url, body, authToken: token);
}
static Future<String> getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List<int> bytes) async {
static Future<String> getFileUploadResponseAuth(
String service,
String url,
String fileName,
String fileType,
String mediaType,
List<int> bytes,
) async {
final token = await TokenService.getAccessTokenSafe();
final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: token);
final res = await httpr.getFileUploadResponse(
service,
url,
fileName,
fileType,
mediaType,
bytes,
authToken: token,
);
if (res == null) {
throw Exception('Upload failed');
}
return res.url;
}
static Future<bool> isAuthorizationStored() async => AuthorizationStorage.isAuthorizationStored();
static Future<bool> isAuthorizationStored() async =>
AuthorizationStorage.isAuthorizationStored();
/// Execute an operation with automatic token management and retry logic
static Future<T> executeWithAuth<T>(
Future<T> Function() operation,
String description, {
int? maxRetries,
}) async => AuthCircuitBreaker.execute(() async => RetryHelper.withExponentialBackoff(
operation,
maxRetries: maxRetries ?? 3,
initialDelay: Duration(milliseconds: 100),
maxDelay: Duration(seconds: 5),
shouldRetry: (error) => RetryHelper.isRetryableError(error),
));
}) async => AuthCircuitBreaker.execute(
() async => RetryHelper.withExponentialBackoff(
operation,
maxRetries: maxRetries ?? 3,
initialDelay: Duration(milliseconds: 100),
maxDelay: Duration(seconds: 5),
shouldRetry: (error) => RetryHelper.isRetryableError(error),
),
);
/// Handle 401 unauthorized errors with automatic token recovery
static Future<T> handleUnauthorized<T>(
Future<T> Function() operation,
String description,
) async {
_logger.warning('Handling unauthorized error with token recovery: $description');
return executeWithAuth(
() async {
try {
// Attempt token recovery first
await TokenService.handleUnauthorized();
// Retry the original operation
return await operation();
} catch (e) {
_logger.severe('Token recovery failed', e);
throw AuthenticationFailedException('Token recovery failed', toException(e));
}
},
'unauthorized recovery: $description',
_logger.warning(
'Handling unauthorized error with token recovery: $description',
);
return executeWithAuth(() async {
try {
// Attempt token recovery first
await TokenService.handleUnauthorized();
// Retry the original operation
return await operation();
} catch (e) {
_logger.severe('Token recovery failed', e);
throw AuthenticationFailedException(
'Token recovery failed',
toException(e),
);
}
}, 'unauthorized recovery: $description');
}
}

View File

@@ -2,10 +2,9 @@ import 'package:uuid/uuid.dart';
import 'package:logging/logging.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/service/secure_storage.dart';
class DeviceIdManager {
static final _logger = Logger('service.device_id');
@@ -15,7 +14,7 @@ class DeviceIdManager {
if (deviceId == null) {
_logger.fine('Device id is not set, generating new');
deviceId = (const Uuid()).v4();
deviceId = (const Uuid()).v4();
await SecureStorageService.set(_key, deviceId);
}

View File

@@ -12,7 +12,6 @@ import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/http/params.dart';
class PaymentService {
static final _logger = Logger('service.payment');
static const String _objectType = Services.payments;
@@ -21,17 +20,21 @@ class PaymentService {
String organizationRef, {
int? limit,
String? cursor,
String? sourceRef,
String? destinationRef,
String? quotationRef,
DateTime? createdFrom,
DateTime? createdTo,
List<String>? states,
}) async {
_logger.fine('Listing payments for organization $organizationRef');
final queryParams = <String, String>{};
if (sourceRef != null && sourceRef.isNotEmpty) {
queryParams['source_ref'] = sourceRef;
if (quotationRef != null && quotationRef.isNotEmpty) {
queryParams['quotation_ref'] = quotationRef;
}
if (destinationRef != null && destinationRef.isNotEmpty) {
queryParams['destination_ref'] = destinationRef;
if (createdFrom != null) {
queryParams['created_from'] = createdFrom.toUtc().toIso8601String();
}
if (createdTo != null) {
queryParams['created_to'] = createdTo.toUtc().toIso8601String();
}
if (states != null && states.isNotEmpty) {
queryParams['state'] = states.join(',');
@@ -43,9 +46,14 @@ class PaymentService {
cursor: cursor,
queryParams: queryParams,
);
final response = await AuthorizationService.getGETResponse(_objectType, url);
final response = await AuthorizationService.getGETResponse(
_objectType,
url,
);
final parsed = PaymentsResponse.fromJson(response);
final payments = parsed.payments.map((payment) => payment.toDomain()).toList();
final payments = parsed.payments
.map((payment) => payment.toDomain())
.toList();
return PaymentPage(items: payments, nextCursor: parsed.nextCursor);
}
@@ -53,16 +61,18 @@ class PaymentService {
String organizationRef, {
int? limit,
String? cursor,
String? sourceRef,
String? destinationRef,
String? quotationRef,
DateTime? createdFrom,
DateTime? createdTo,
List<String>? states,
}) async {
final page = await listPage(
organizationRef,
limit: limit,
cursor: cursor,
sourceRef: sourceRef,
destinationRef: destinationRef,
quotationRef: quotationRef,
createdFrom: createdFrom,
createdTo: createdTo,
states: states,
);
return page.items;
@@ -74,7 +84,9 @@ class PaymentService {
String? idempotencyKey,
Map<String, String>? metadata,
}) async {
_logger.fine('Executing payment for quotation $quotationRef in $organizationRef');
_logger.fine(
'Executing payment for quotation $quotationRef in $organizationRef',
);
final request = InitiatePaymentRequest(
idempotencyKey: idempotencyKey ?? Uuid().v4(),
quoteRef: quotationRef,
@@ -87,5 +99,4 @@ class PaymentService {
);
return PaymentResponse.fromJson(response).payment.toDomain();
}
}

View File

@@ -0,0 +1,122 @@
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',
idempotencyKey: 'idem-1',
state: 'orchestration_state_created',
orchestrationState: PaymentOrchestrationState.created,
failureCode: null,
failureReason: null,
lastQuote: null,
metadata: null,
createdAt: null,
);
const settled = Payment(
paymentRef: 'p-2',
idempotencyKey: 'idem-2',
state: 'orchestration_state_settled',
orchestrationState: PaymentOrchestrationState.settled,
failureCode: null,
failureReason: null,
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',
idempotencyKey: 'idem-3',
state: 'orchestration_state_executing',
orchestrationState: PaymentOrchestrationState.executing,
failureCode: 'failure_ledger',
failureReason: 'ledger failed',
lastQuote: null,
metadata: null,
createdAt: null,
);
const failedState = Payment(
paymentRef: 'p-4',
idempotencyKey: 'idem-4',
state: 'orchestration_state_failed',
orchestrationState: PaymentOrchestrationState.failed,
failureCode: null,
failureReason: null,
lastQuote: null,
metadata: null,
createdAt: null,
);
expect(withFailureCode.isFailure, isTrue);
expect(failedState.isFailure, isTrue);
});
});
}

View File

@@ -0,0 +1,113 @@
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/data/dto/money.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.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';
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: 'idem-1',
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',
settlementCurrency: 'USD',
),
);
final json =
jsonDecode(jsonEncode(request.toJson())) as Map<String, dynamic>;
expect(json['idempotencyKey'], equals('idem-1'));
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['settlement_currency'], equals('USD'));
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('initiate payment by quote keeps expected fields', () {
final request = InitiatePaymentRequest(
idempotencyKey: 'idem-2',
quoteRef: 'q-1',
metadata: const {'intent_ref': 'intent-1'},
);
final json = request.toJson();
expect(json['idempotencyKey'], equals('idem-2'));
expect(json['quoteRef'], equals('q-1'));
expect(
(json['metadata'] as Map<String, dynamic>)['intent_ref'],
equals('intent-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',
metadata: const {'client_payment_ref': 'cp-1'},
);
final json = request.toJson();
expect(json['idempotencyKey'], equals('idem-3'));
expect(json['quoteRef'], equals('q-2'));
expect(
(json['metadata'] as Map<String, dynamic>)['client_payment_ref'],
equals('cp-1'),
);
});
});
}

View File

@@ -1,30 +1,33 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:pweb/app/app.dart';
import 'package:pweb/providers/account.dart';
import 'package:pweb/providers/locale.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/locale.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const PayApp());
testWidgets('PayApp builds with required providers', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<LocaleProvider>(
create: (_) => PwebLocaleProvider(null),
),
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
create: (_) => PwebAccountProvider(),
update: (context, localeProvider, provider) =>
provider!..updateProvider(localeProvider),
),
],
child: const PayApp(),
),
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.byType(PayApp), findsOneWidget);
});
}