Skip to content

Commit 2e1e129

Browse files
fix(buy): surface order-creation errors and clarify payout wallet vs method
- Show the network on default wallets in the buy dropdown: Instant payments (L-BTC on Liquid) / Secure Bitcoin (BTC on Bitcoin chain). External and custom wallets keep their own names. - Split the confirm page's Payout method row into Payout wallet and Payout method, sharing the network phrases with the dropdown. - Success message now includes what was paid: 'You bought {amount} with {fiatAmount}' from the order's payin side. - Below-minimum (and any other) order-creation failures are no longer silent: the amount screen renders limit errors with the server's amount and currency, and everything else through a neutral fallback message. Previously only below-min/above-max variants rendered, and most server errors never mapped to them, leaving Continue dead. - Make the createOrder error parsing total via a shared Never-typed helper across buy, sell, pay and withdraw: an error response can no longer fall through to the result cast. Parses the server's singular reason and new plural reasons shapes (API-Orders#859), tolerating empty and limit-less entries. - Stop converting fiat limit amounts with btcToSats: a 20 CAD minimum rendered as 2,000,000,000 sats. Error entities now carry amount and currency verbatim; the render site formats fiat as fiat. - Repair broken translations the changed keys touched (zh/fa/th/tr buyYouBought, zh payout-method and external-wallet strings). Closes #2515 Closes #2516 Closes #2517 Closes #2518
1 parent 33a1ff1 commit 2e1e129

39 files changed

Lines changed: 785 additions & 210 deletions

lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart

Lines changed: 106 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -171,29 +171,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
171171
final error = resp.data['error'];
172172
if (statusCode != 200) throw Exception('Failed to create order');
173173
if (error != null) {
174-
final reason = error['data']['reason'];
175-
if (reason != null) {
176-
final limitReason = reason['limit'];
177-
if (limitReason != null) {
178-
final isBelowLimit =
179-
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
180-
final limitAmount = limitReason['amount'] as String;
181-
final limitCurrency = limitReason['currencyCode'] as String;
182-
if (isBelowLimit) {
183-
throw BullBitcoinApiMinAmountException(
184-
minAmount: double.parse(limitAmount),
185-
currency: limitCurrency,
186-
);
187-
} else {
188-
throw BullBitcoinApiMaxAmountException(
189-
maxAmount: double.parse(limitAmount),
190-
currency: limitCurrency,
191-
);
192-
}
193-
}
194-
} else {
195-
throw Exception('Failed to create buy order: ${error['message']}');
196-
}
174+
_throwOrderApiError(error, 'Failed to create buy order');
197175
}
198176
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
199177
}
@@ -357,29 +335,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
357335
final error = resp.data['error'];
358336
if (statusCode != 200) throw Exception('Failed to create sell order');
359337
if (error != null) {
360-
final reason = error['data']['reason'];
361-
if (reason != null) {
362-
final limitReason = reason['limit'];
363-
if (limitReason != null) {
364-
final isBelowLimit =
365-
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
366-
final limitAmount = limitReason['amount'] as String;
367-
final limitCurrency = limitReason['currencyCode'] as String;
368-
if (isBelowLimit) {
369-
throw BullBitcoinApiMinAmountException(
370-
minAmount: double.parse(limitAmount),
371-
currency: limitCurrency,
372-
);
373-
} else {
374-
throw BullBitcoinApiMaxAmountException(
375-
maxAmount: double.parse(limitAmount),
376-
currency: limitCurrency,
377-
);
378-
}
379-
}
380-
} else {
381-
throw Exception('Failed to create sell order: ${error['message']}');
382-
}
338+
_throwOrderApiError(error, 'Failed to create sell order');
383339
}
384340
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
385341
}
@@ -424,31 +380,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
424380
throw Exception('Failed to create sell to recipient order');
425381
}
426382
if (error != null) {
427-
final reason = error['data']['reason'];
428-
if (reason != null) {
429-
final limitReason = reason['limit'];
430-
if (limitReason != null) {
431-
final isBelowLimit =
432-
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
433-
final limitAmount = limitReason['amount'] as String;
434-
final limitCurrency = limitReason['currencyCode'] as String;
435-
if (isBelowLimit) {
436-
throw BullBitcoinApiMinAmountException(
437-
minAmount: double.parse(limitAmount),
438-
currency: limitCurrency,
439-
);
440-
} else {
441-
throw BullBitcoinApiMaxAmountException(
442-
maxAmount: double.parse(limitAmount),
443-
currency: limitCurrency,
444-
);
445-
}
446-
}
447-
} else {
448-
throw Exception(
449-
'Failed to create sell to recipient order: ${error['message']}',
450-
);
451-
}
383+
_throwOrderApiError(error, 'Failed to create sell to recipient order');
452384
}
453385

454386
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
@@ -492,32 +424,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
492424
final error = resp.data['error'];
493425
if (statusCode != 200) throw Exception('Failed to create withdrawal order');
494426
if (error != null) {
495-
final reason = error['data']['reason'];
496-
if (reason != null) {
497-
final limitReason = reason['limit'];
498-
if (limitReason != null) {
499-
final isBelowLimit =
500-
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
501-
final limitAmount = limitReason['amount'] as String;
502-
final limitCurrency = limitReason['currencyCode'] as String;
503-
if (isBelowLimit) {
504-
throw BullBitcoinApiMinAmountException(
505-
minAmount: double.parse(limitAmount),
506-
currency: limitCurrency,
507-
);
508-
} else {
509-
throw BullBitcoinApiMaxAmountException(
510-
maxAmount: double.parse(limitAmount),
511-
currency: limitCurrency,
512-
);
513-
}
514-
}
515-
throw Exception('Failed to create withdrawal order: $reason');
516-
} else {
517-
throw Exception(
518-
'Failed to create withdrawal order: ${error['message']}',
519-
);
520-
}
427+
_throwOrderApiError(error, 'Failed to create withdrawal order');
521428
}
522429
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
523430
}
@@ -882,6 +789,108 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
882789
}
883790
}
884791

792+
const _minLimitOperator = 'GREATER_THAN_OR_EQUAL';
793+
const _maxLimitOperator = 'LESS_THAN_OR_EQUAL';
794+
795+
/// A single limit that the api rejected an order against.
796+
class _OrderLimit {
797+
final double amount;
798+
final String currency;
799+
final String conditionalOperator;
800+
801+
const _OrderLimit({
802+
required this.amount,
803+
required this.currency,
804+
required this.conditionalOperator,
805+
});
806+
807+
/// Returns null for anything that isn't a limit we can act on, so a payload
808+
/// change on the api side degrades to the generic error instead of throwing
809+
/// a cast error.
810+
static _OrderLimit? tryParse(dynamic json) {
811+
if (json is! Map) return null;
812+
final rawAmount = json['amount'];
813+
final amount = switch (rawAmount) {
814+
final num n => n.toDouble(),
815+
final String s => double.tryParse(s),
816+
_ => null,
817+
};
818+
final currency = json['currencyCode'];
819+
final conditionalOperator = json['conditionalOperator'];
820+
if (amount == null ||
821+
currency is! String ||
822+
conditionalOperator is! String) {
823+
return null;
824+
}
825+
return _OrderLimit(
826+
amount: amount,
827+
currency: currency,
828+
conditionalOperator: conditionalOperator,
829+
);
830+
}
831+
}
832+
833+
/// Translates a JSON-RPC `error` object from the orders api into a typed
834+
/// exception. Always throws: an error response must never fall through to
835+
/// parsing `result`.
836+
///
837+
/// Two payload shapes are supported. A single rejected payment option carries
838+
/// `data.reason.limit`; when every payment option was rejected the aggregate
839+
/// error carries one structured reason per rejection in `data.reasons`.
840+
///
841+
/// `reasons` is legitimately empty for rejections that produce no structured
842+
/// reason (group-access denial, non-positive amounts), so an empty or absent
843+
/// array has to reach the generic error rather than be treated as a bug. The
844+
/// aggregate also carries a legacy `data.details`, always an array of nulls;
845+
/// it is deliberately never read.
846+
Never _throwOrderApiError(dynamic error, String contextMessage) {
847+
final errorMap = error is Map ? error : const <dynamic, dynamic>{};
848+
final data = errorMap['data'];
849+
final dataMap = data is Map ? data : const <dynamic, dynamic>{};
850+
851+
final limits = <_OrderLimit>[];
852+
final reasons = dataMap['reasons'];
853+
if (reasons is List) {
854+
for (final reason in reasons) {
855+
final limit = _OrderLimit.tryParse(
856+
reason is Map ? reason['limit'] : null,
857+
);
858+
if (limit != null) limits.add(limit);
859+
}
860+
}
861+
final singleReason = dataMap['reason'];
862+
final singleLimit = _OrderLimit.tryParse(
863+
singleReason is Map ? singleReason['limit'] : null,
864+
);
865+
if (singleLimit != null) limits.add(singleLimit);
866+
867+
final operators = limits.map((l) => l.conditionalOperator).toSet();
868+
// Amounts are only comparable within one currency, and a single message can
869+
// only name one limit, so anything mixed falls back to the generic error.
870+
final currencies = limits.map((l) => l.currency).toSet();
871+
if (operators.length == 1 && currencies.length == 1) {
872+
switch (operators.single) {
873+
case _minLimitOperator:
874+
// Every option was below its minimum, so the lowest minimum is the
875+
// amount that unlocks at least one of them.
876+
final min = limits.reduce((a, b) => a.amount <= b.amount ? a : b);
877+
throw BullBitcoinApiMinAmountException(
878+
minAmount: min.amount,
879+
currency: min.currency,
880+
);
881+
case _maxLimitOperator:
882+
final max = limits.reduce((a, b) => a.amount >= b.amount ? a : b);
883+
throw BullBitcoinApiMaxAmountException(
884+
maxAmount: max.amount,
885+
currency: max.currency,
886+
);
887+
}
888+
}
889+
890+
final message = errorMap['message'];
891+
throw Exception('$contextMessage${message is String ? ': $message' : ''}');
892+
}
893+
885894
class BullBitcoinApiMinAmountException extends BullException {
886895
final double minAmount;
887896
final String currency;

lib/core/exchange/data/repository/exchange_order_repository_impl.dart

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import 'package:bb_mobile/core/exchange/domain/errors/pay_error.dart';
77
import 'package:bb_mobile/core/exchange/domain/errors/sell_error.dart';
88
import 'package:bb_mobile/core/exchange/domain/errors/withdraw_error.dart';
99
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
10-
import 'package:bb_mobile/core/utils/amount_conversions.dart';
1110
import 'package:bb_mobile/core/utils/logger.dart';
1211
import 'package:bb_mobile/features/dca/domain/dca.dart';
1312

@@ -183,13 +182,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {
183182

184183
return order;
185184
} on BullBitcoinApiMinAmountException catch (e) {
186-
final minAmountBtc = e.minAmount;
187-
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
188-
throw BuyError.belowMinAmount(minAmountSat: minAmountSat);
185+
throw BuyError.belowMinAmount(
186+
minAmount: e.minAmount,
187+
currency: e.currency,
188+
);
189189
} on BullBitcoinApiMaxAmountException catch (e) {
190-
final maxAmountBtc = e.maxAmount;
191-
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
192-
throw BuyError.aboveMaxAmount(maxAmountSat: maxAmountSat);
190+
throw BuyError.aboveMaxAmount(
191+
maxAmount: e.maxAmount,
192+
currency: e.currency,
193+
);
193194
} catch (e) {
194195
throw Exception('Failed to place buy order: $e');
195196
}
@@ -221,13 +222,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {
221222

222223
return order;
223224
} on BullBitcoinApiMinAmountException catch (e) {
224-
final minAmountBtc = e.minAmount;
225-
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
226-
throw SellError.belowMinAmount(minAmountSat: minAmountSat);
225+
throw SellError.belowMinAmount(
226+
minAmount: e.minAmount,
227+
currency: e.currency,
228+
);
227229
} on BullBitcoinApiMaxAmountException catch (e) {
228-
final maxAmountBtc = e.maxAmount;
229-
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
230-
throw SellError.aboveMaxAmount(maxAmountSat: maxAmountSat);
230+
throw SellError.aboveMaxAmount(
231+
maxAmount: e.maxAmount,
232+
currency: e.currency,
233+
);
231234
} catch (e) {
232235
throw Exception('Failed to place sell order: $e');
233236
}
@@ -262,13 +265,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {
262265

263266
return order;
264267
} on BullBitcoinApiMinAmountException catch (e) {
265-
final minAmountBtc = e.minAmount;
266-
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
267-
throw PayError.belowMinAmount(minAmountSat: minAmountSat);
268+
throw PayError.belowMinAmount(
269+
minAmount: e.minAmount,
270+
currency: e.currency,
271+
);
268272
} on BullBitcoinApiMaxAmountException catch (e) {
269-
final maxAmountBtc = e.maxAmount;
270-
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
271-
throw PayError.aboveMaxAmount(maxAmountSat: maxAmountSat);
273+
throw PayError.aboveMaxAmount(
274+
maxAmount: e.maxAmount,
275+
currency: e.currency,
276+
);
272277
} catch (e) {
273278
throw Exception('Failed to place pay order: $e');
274279
}
@@ -524,13 +529,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {
524529

525530
return order;
526531
} on BullBitcoinApiMinAmountException catch (e) {
527-
final minAmountBtc = e.minAmount;
528-
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
529-
throw WithdrawError.belowMinAmount(minAmountSat: minAmountSat);
532+
throw WithdrawError.belowMinAmount(
533+
minAmount: e.minAmount,
534+
currency: e.currency,
535+
);
530536
} on BullBitcoinApiMaxAmountException catch (e) {
531-
final maxAmountBtc = e.maxAmount;
532-
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
533-
throw WithdrawError.aboveMaxAmount(maxAmountSat: maxAmountSat);
537+
throw WithdrawError.aboveMaxAmount(
538+
maxAmount: e.maxAmount,
539+
currency: e.currency,
540+
);
534541
} catch (e) {
535542
throw Exception('Failed to create withdrawal order: $e');
536543
}

lib/core/exchange/domain/errors/buy_error.dart

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ part 'buy_error.freezed.dart';
77
@freezed
88
sealed class BuyError with _$BuyError {
99
const factory BuyError.unauthenticated() = UnauthenticatedBuyError;
10-
const factory BuyError.belowMinAmount({required int minAmountSat}) =
11-
BelowMinAmountBuyError;
12-
const factory BuyError.aboveMaxAmount({required int maxAmountSat}) =
13-
AboveMaxAmountBuyError;
10+
11+
/// [minAmount] is denominated in [currency], which the api picks and can be
12+
/// either a fiat currency or BTC/LBTC.
13+
const factory BuyError.belowMinAmount({
14+
required double minAmount,
15+
required String currency,
16+
}) = BelowMinAmountBuyError;
17+
const factory BuyError.aboveMaxAmount({
18+
required double maxAmount,
19+
required String currency,
20+
}) = AboveMaxAmountBuyError;
1421
const factory BuyError.insufficientFunds() = InsufficientFundsBuyError;
1522
const factory BuyError.orderNotFound() = OrderNotFoundBuyError;
1623
const factory BuyError.orderAlreadyConfirmed() =
@@ -23,11 +30,12 @@ sealed class BuyError with _$BuyError {
2330
/// Returns the localized error message.
2431
String toTranslated(BuildContext context) => when(
2532
unauthenticated: () => context.loc.buyUnauthenticatedError,
26-
belowMinAmount: (_) => context.loc.buyBelowMinAmountError,
27-
aboveMaxAmount: (_) => context.loc.buyAboveMaxAmountError,
33+
belowMinAmount: (_, _) => context.loc.buyBelowMinAmountError,
34+
aboveMaxAmount: (_, _) => context.loc.buyAboveMaxAmountError,
2835
insufficientFunds: () => context.loc.buyInsufficientFundsError,
2936
orderNotFound: () => context.loc.buyOrderNotFoundError,
3037
orderAlreadyConfirmed: () => context.loc.buyOrderAlreadyConfirmedError,
31-
unexpected: (message) => message,
38+
// The raw message is logged by the bloc; it is never fit to show to a user.
39+
unexpected: (_) => context.loc.buyUnexpectedError,
3240
);
3341
}

0 commit comments

Comments
 (0)