diff --git a/.gitignore b/.gitignore index ce510890a..39f9dcc1f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ android/app/.cxx /build/ *.g.dart *.lock +# Keep Fastlane bundler lockfiles tracked for reproducible store deploys +!android/Gemfile.lock +!ios/Gemfile.lock # Symbolication related app.*.symbols diff --git a/android/Gemfile b/android/Gemfile index 857b93c4a..9d1a21137 100644 --- a/android/Gemfile +++ b/android/Gemfile @@ -1,4 +1,4 @@ source "https://rubygems.org" -gem "fastlane", ">= 2.220" +gem "fastlane", "2.232.2" gem "ostruct" \ No newline at end of file diff --git a/android/Gemfile.lock b/android/Gemfile.lock new file mode 100644 index 000000000..18db747a0 --- /dev/null +++ b/android/Gemfile.lock @@ -0,0 +1,237 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1220.0) + aws-sdk-core (3.242.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.0.1) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.8.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.232.2) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.96.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.61.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.5.0) + google-cloud-storage (1.58.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.18.1) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.19.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.2) + rake (13.3.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.2.1) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane (= 2.232.2) + ostruct + +BUNDLED WITH + 2.7.2 diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f215950..da162ef19 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 56e4db7f0..752b84bda 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 7f4c7104b..3659c89da 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 6c48989fa..c72ea91a6 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 c3a9f1b52..a3985cc68 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 d3bb617fa..e2055c4e6 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 e3ea05813..a15f6b845 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 cc117dbdd..3d6beda2e 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 000000000..79b136dd1 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 f417758d6..d3d221b20 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 41fc1edb0..995d49d3e 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 339e47a8d..11ee70e16 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 {