diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f21595..da162ef1 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -194,6 +194,8 @@ "portfolio": "Bestand", "portfolioDevelopment": "Bestandsentwicklung", "postcodeAbr": "PLZ", + "priceProviderUnavailableDescription": "Der externe Kursanbieter Aktionariat liefert aktuell keine Kurse. Das Problem liegt bei Aktionariat – nicht bei RealUnit oder der App. Sobald Aktionariat wieder Kurse liefert, funktioniert alles automatisch. Bitte später erneut versuchen.", + "priceProviderUnavailableTitle": "Problem beim Kursanbieter (Aktionariat)", "proofDocument": "Nachweis-Dokument", "purposeOfPayment": "Verwendungszweck", "qrCode": "QR-Code", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 56e4db7f..752b84bd 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -194,6 +194,8 @@ "portfolio": "Portfolio", "portfolioDevelopment": "Portfolio development", "postcodeAbr": "Post code", + "priceProviderUnavailableDescription": "The external price provider Aktionariat is currently not delivering prices. The problem is on Aktionariat's side, not with RealUnit or the app. Everything will work again automatically as soon as Aktionariat delivers prices. Please try again later.", + "priceProviderUnavailableTitle": "Problem with the price provider (Aktionariat)", "proofDocument": "Proof document", "purposeOfPayment": "Purpose of payment", "qrCode": "QR code", diff --git a/lib/packages/service/dfx/models/payment/payment_info_error.dart b/lib/packages/service/dfx/models/payment/payment_info_error.dart index 7f4c7104..3659c89d 100644 --- a/lib/packages/service/dfx/models/payment/payment_info_error.dart +++ b/lib/packages/service/dfx/models/payment/payment_info_error.dart @@ -3,5 +3,6 @@ enum PaymentInfoError { kycRequired, minAmountNotMet, bitboxDisconnected, + priceSourceUnavailable, unknown, } diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index 6c48989f..c72ea91a 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:async/async.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; @@ -80,6 +81,16 @@ class BuyPaymentInfoCubit extends Cubit { ); } on BitboxNotConnectedException { return const BuyPaymentInfoFailure(PaymentInfoError.bitboxDisconnected); + } on ApiException catch (e) { + // 503 / PRICE_SOURCE_UNAVAILABLE means the external price provider + // (Aktionariat) is down, so no quote can be built — surface that + // explicitly instead of a generic failure. Must stay below the + // KYC/Registration clauses (those are ApiException subclasses). + if (e.statusCode == 503 || e.code == 'PRICE_SOURCE_UNAVAILABLE') { + return const BuyPaymentInfoFailure(PaymentInfoError.priceSourceUnavailable); + } + developer.log(e.toString()); + return const BuyPaymentInfoFailure(PaymentInfoError.unknown); } catch (e) { developer.log(e.toString()); return const BuyPaymentInfoFailure(PaymentInfoError.unknown); diff --git a/lib/screens/buy/widgets/payment_information.dart b/lib/screens/buy/widgets/payment_information.dart index c3a9f1b5..a3985cc6 100644 --- a/lib/screens/buy/widgets/payment_information.dart +++ b/lib/screens/buy/widgets/payment_information.dart @@ -43,6 +43,11 @@ class PaymentInformation extends StatelessWidget { title: S.of(context).bitboxDisconnectedTitle, description: S.of(context).bitboxDisconnectedDescription, ); + } else if (error == PaymentInfoError.priceSourceUnavailable) { + return PaymentActionRequired( + title: S.of(context).priceProviderUnavailableTitle, + description: S.of(context).priceProviderUnavailableDescription, + ); } else if (error == PaymentInfoError.unknown) { return PaymentActionRequired( title: S.of(context).paymentInformationFailed, diff --git a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart index d3bb617f..e2055c4e 100644 --- a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart +++ b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; @@ -97,6 +98,27 @@ class SellPaymentInfoCubit extends Cubit { message: e.toString(), ), ); + } on ApiException catch (e) { + // 503 / PRICE_SOURCE_UNAVAILABLE = external price provider (Aktionariat) + // down → no quote possible. Must stay below the KYC/Registration clauses + // (those are ApiException subclasses). + if (isClosed) return; + if (e.statusCode == 503 || e.code == 'PRICE_SOURCE_UNAVAILABLE') { + emit( + SellPaymentInfoFailure( + PaymentInfoError.priceSourceUnavailable, + message: e.message, + ), + ); + return; + } + developer.log(e.toString()); + emit( + SellPaymentInfoFailure( + PaymentInfoError.unknown, + message: e.toString(), + ), + ); } catch (e) { developer.log(e.toString()); if (isClosed) return; diff --git a/lib/screens/sell/widgets/sell_button.dart b/lib/screens/sell/widgets/sell_button.dart index e3ea0581..a15f6b84 100644 --- a/lib/screens/sell/widgets/sell_button.dart +++ b/lib/screens/sell/widgets/sell_button.dart @@ -44,6 +44,17 @@ class SellButton extends StatelessWidget { } return; } + if (state.error == .priceSourceUnavailable) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).priceProviderUnavailableTitle), + backgroundColor: RealUnitColors.status.red600, + ), + ); + } + return; + } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/test/goldens/screens/buy/buy_golden_test.dart b/test/goldens/screens/buy/buy_golden_test.dart index cc117dbd..3d6beda2 100644 --- a/test/goldens/screens/buy/buy_golden_test.dart +++ b/test/goldens/screens/buy/buy_golden_test.dart @@ -227,5 +227,24 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + 'price source (Aktionariat) unavailable failure', + fileName: 'buy_price_source_unavailable', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.priceSourceUnavailable), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + fiatText: '100', + sharesText: '1.00', + currency: Currency.chf, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png new file mode 100644 index 00000000..79b136dd Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_price_source_unavailable.png differ diff --git a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart index f417758d..d3d221b2 100644 --- a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart @@ -146,9 +146,9 @@ void main() { }); group('$PaymentInfoError', () { - test('has the five documented variants', () { + test('has the six documented variants', () { // Pin the wire contract — any new variant has to be added intentionally. - expect(PaymentInfoError.values, hasLength(5)); + expect(PaymentInfoError.values, hasLength(6)); expect( PaymentInfoError.values.toSet(), { @@ -156,6 +156,7 @@ void main() { PaymentInfoError.kycRequired, PaymentInfoError.minAmountNotMet, PaymentInfoError.bitboxDisconnected, + PaymentInfoError.priceSourceUnavailable, PaymentInfoError.unknown, }, ); diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 41fc1edb..995d49d3 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; @@ -230,6 +231,50 @@ void main() { expect(f.error, PaymentInfoError.bitboxDisconnected); }); + test('ApiException 503 → Failure(priceSourceUnavailable)', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const ApiException( + statusCode: 503, + code: 'PRICE_SOURCE_UNAVAILABLE', + message: 'RealUnit price source (Aktionariat) is currently unavailable', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); + }); + + test('ApiException with code PRICE_SOURCE_UNAVAILABLE (non-503) → priceSourceUnavailable', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const ApiException( + statusCode: 500, + code: 'PRICE_SOURCE_UNAVAILABLE', + message: 'unavailable', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); + }); + + test('other ApiException (e.g. 400) → Failure(unknown)', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const ApiException(statusCode: 400, code: 'BAD_REQUEST', message: 'bad'), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect((cubit.state as BuyPaymentInfoFailure).error, PaymentInfoError.unknown); + }); + test('does not emit after close', () async { final completer = Completer(); when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart index 339e47a8..11ee70e1 100644 --- a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; @@ -280,6 +281,32 @@ void main() { expect(f.message, contains('network')); }); + test('ApiException 503 → Failure(priceSourceUnavailable)', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => throw const ApiException( + statusCode: 503, + code: 'PRICE_SOURCE_UNAVAILABLE', + message: 'RealUnit price source (Aktionariat) is currently unavailable', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + expect((cubit.state as SellPaymentInfoFailure).error, PaymentInfoError.priceSourceUnavailable); + }); + + test('other ApiException (e.g. 400) → Failure(unknown)', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))).thenAnswer( + (_) async => throw const ApiException(statusCode: 400, code: 'BAD_REQUEST', message: 'bad'), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + expect((cubit.state as SellPaymentInfoFailure).error, PaymentInfoError.unknown); + }); + test( 'negative amount is sent to service (UI prevents this via digitsOnly formatter)', () async {