@@ -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+
885894class BullBitcoinApiMinAmountException extends BullException {
886895 final double minAmount;
887896 final String currency;
0 commit comments