Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 134 additions & 7 deletions lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:bb_mobile/core/exchange/data/models/funding_details_request_para
import 'package:bb_mobile/core/exchange/data/models/order_model.dart';
import 'package:bb_mobile/core/exchange/data/models/user_preference_payload_model.dart';
import 'package:bb_mobile/core/exchange/data/models/user_summary_model.dart';
import 'package:bb_mobile/core/exchange/data/models/virtual_iban_recipient_model.dart';
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/utils/logger.dart' show log;
import 'package:bb_mobile/features/dca/domain/dca.dart';
Expand Down Expand Up @@ -477,9 +478,13 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
final error = resp.data['error'];
if (statusCode != 200) throw Exception('Failed to create withdrawal order');
if (error != null) {
final reason = error['data']['reason'];
final limitReason = reason['limit'];
if (limitReason != null) {
final data = error['data'];
final apiError = data is Map<String, dynamic> ? data['apiError'] : null;
final reason = data is Map<String, dynamic> ? data['reason'] : null;
final limitReason = reason is Map<String, dynamic>
? reason['limit']
: null;
if (limitReason is Map<String, dynamic>) {
final isBelowLimit =
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
final limitAmount = limitReason['amount'] as String;
Expand All @@ -496,7 +501,10 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
);
}
}
throw Exception('Failed to create withdrawal order: $reason');
final message = apiError is Map<String, dynamic>
? apiError['message']
: error['message'];
throw Exception('Failed to create withdrawal order: $message');
}
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
}
Expand Down Expand Up @@ -598,7 +606,126 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
return resp.data['result'] as Map<String, dynamic>;
}

// ==================== Recipients API ====================
// ==================== Virtual IBAN API (from HEAD) ====================

/// Gets the user's Virtual IBAN details by listing recipients filtered by FR_VIRTUAL_ACCOUNT type.
/// Returns null if no Virtual IBAN has been created yet.
Future<VirtualIbanRecipientModel?> getVirtualIbanDetails({
required String apiKey,
}) async {
final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'listMyRecipients',
'params': {
'filters': {
'recipientType': ['FR_VIRTUAL_ACCOUNT'],
},
},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to get virtual IBAN details');
}

final error = resp.data['error'];
if (error != null) {
throw Exception('Failed to get virtual IBAN details: $error');
}

final elements = resp.data['result']['elements'] as List<dynamic>?;
if (elements == null || elements.isEmpty) {
return null;
}

return VirtualIbanRecipientModel.fromJson(
elements.first as Map<String, dynamic>,
);
}

/// Creates a Virtual IBAN (FR_VIRTUAL_ACCOUNT) for the user.
Future<VirtualIbanRecipientModel> createVirtualIban({
required String apiKey,
}) async {
final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'createMyRecipient',
'params': {
'element': {'recipientType': 'FR_VIRTUAL_ACCOUNT', 'isOwner': true},
},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to create virtual IBAN');
}

final error = resp.data['error'];
if (error != null) {
final message = error['message'] ?? 'Unknown error';
throw Exception('Failed to create virtual IBAN: $message');
}

final result = resp.data['result'] as Map<String, dynamic>?;
final element = result?['element'] as Map<String, dynamic>?;
if (element == null) {
throw Exception('Failed to create virtual IBAN: Invalid response');
}

return VirtualIbanRecipientModel.fromJson(element);
}

/// Creates an FR_PAYEE recipient from a Virtual IBAN.
/// This is needed when making withdrawals to the user's own Virtual IBAN.
Future<VirtualIbanRecipientModel> createFrPayeeRecipient({
required String apiKey,
required String iban,
}) async {
final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'createMyRecipient',
'params': {
'element': {
'recipientType': 'FR_PAYEE',
'isOwner': true,
'iban': iban,
},
},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to create FR_PAYEE recipient');
}

final error = resp.data['error'];
if (error != null) {
final message = error['message'] ?? 'Unknown error';
throw Exception('Failed to create FR_PAYEE recipient: $message');
}

final result = resp.data['result'] as Map<String, dynamic>?;
final element = result?['element'] as Map<String, dynamic>?;
if (element == null) {
throw Exception('Failed to create FR_PAYEE recipient: Invalid response');
}

return VirtualIbanRecipientModel.fromJson(element);
}

// ==================== Recipients API (from develop) ====================

/// List recipients with optional filters for default wallets
Future<List<Map<String, dynamic>>> listMyRecipients({
Expand Down Expand Up @@ -743,7 +870,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
}
}

// ==================== Order Stats API ====================
// ==================== Order Stats API (from develop) ====================

/// Get order statistics for the user
Future<Map<String, dynamic>> getOrderStats({required String apiKey}) async {
Expand All @@ -770,7 +897,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
return resp.data['result']['element'] as Map<String, dynamic>;
}

// ==================== KYC Upload API ====================
// ==================== KYC Upload API (from develop) ====================

/// Upload a KYC document file using multipart form (same as BB-Exchange)
Future<void> uploadKycDocument({
Expand Down
13 changes: 13 additions & 0 deletions lib/core/exchange/data/mappers/user_summary_mapper.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:bb_mobile/core/exchange/data/models/user_summary_model.dart';
import 'package:bb_mobile/core/exchange/domain/value_objects/user_address.dart';
import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart';

class UserSummaryMapper {
Expand All @@ -14,13 +15,25 @@ class UserSummaryMapper {
currency: model.currency,
dca: model.dca.toEntity(),
autoBuy: _mapUserAutoBuy(model.autoBuy),
address: model.address != null ? _mapUserAddress(model.address!) : null,
emailNotificationsEnabled: model.emailNotificationsEnabled,
kycDocumentStatus: model.kycDocumentStatus != null
? _mapKycDocumentStatus(model.kycDocumentStatus!)
: null,
);
}

static UserAddress _mapUserAddress(UserAddressModel model) {
return UserAddress(
street1: model.street1,
street2: model.street2,
city: model.city,
province: model.province,
postalCode: model.postalCode,
countryCode: model.countryCode,
);
}

static UserProfile _mapUserProfile(UserProfileModel model) {
return UserProfile(firstName: model.firstName, lastName: model.lastName);
}
Expand Down
25 changes: 25 additions & 0 deletions lib/core/exchange/data/mappers/virtual_iban_recipient_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:bb_mobile/core/exchange/data/models/virtual_iban_recipient_model.dart';
import 'package:bb_mobile/core/exchange/domain/entity/virtual_iban_recipient.dart';

/// Mapper for converting between VirtualIbanRecipientModel and VirtualIbanRecipient entity.
class VirtualIbanRecipientMapper {
static VirtualIbanRecipient fromModelToEntity(
VirtualIbanRecipientModel model,
) {
return VirtualIbanRecipient(
recipientId: model.recipientId,
iban: model.iban,
bicCode: model.bicCode,
bankAddress: model.bankAddress,
ibanCountry: model.ibanCountry,
frAccountId: model.frAccountId,
frUserId: model.frUserId,
frPayeeId: model.frPayeeId,
isOwner: model.isOwner,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
);
}
}


6 changes: 6 additions & 0 deletions lib/core/exchange/data/models/funding_details_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ sealed class FundingDetailsModel with _$FundingDetailsModel {
beneficiaryName: beneficiaryName!,
cvu: cvu!,
);
case FundingMethod.confidentialSepa:
// Confidential SEPA uses Virtual IBAN which is handled separately
// through the VirtualIbanBloc and not through funding details API.
throw UnsupportedError(
'Confidential SEPA funding details should be fetched through Virtual IBAN flow',
);
}
}
}
31 changes: 31 additions & 0 deletions lib/core/exchange/data/models/user_summary_model.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/exchange/domain/value_objects/user_address.dart';
import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart';
import 'package:bb_mobile/features/dca/domain/dca.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
Expand All @@ -19,6 +20,7 @@ sealed class UserSummaryModel with _$UserSummaryModel {
String? currency,
required UserDcaModel dca,
required UserAutoBuyModel autoBuy,
UserAddressModel? address,
@Default(true) bool emailNotificationsEnabled,
UserKycDocumentStatusModel? kycDocumentStatus,
}) = _UserSummaryModel;
Expand All @@ -40,12 +42,41 @@ sealed class UserSummaryModel with _$UserSummaryModel {
currency: currency,
dca: dca.toEntity(),
autoBuy: autoBuy.toEntity(),
address: address?.toEntity(),
emailNotificationsEnabled: emailNotificationsEnabled,
kycDocumentStatus: kycDocumentStatus?.toEntity(),
);
}
}

@freezed
sealed class UserAddressModel with _$UserAddressModel {
const factory UserAddressModel({
required String street1,
String? street2,
required String city,
String? province,
required String postalCode,
required String countryCode,
}) = _UserAddressModel;

factory UserAddressModel.fromJson(Map<String, dynamic> json) =>
_$UserAddressModelFromJson(json);

const UserAddressModel._();

UserAddress toEntity() {
return UserAddress(
street1: street1,
street2: street2,
city: city,
province: province,
postalCode: postalCode,
countryCode: countryCode,
);
}
}

@freezed
sealed class UserProfileModel with _$UserProfileModel {
const factory UserProfileModel({
Expand Down
27 changes: 27 additions & 0 deletions lib/core/exchange/data/models/virtual_iban_recipient_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:freezed_annotation/freezed_annotation.dart';

part 'virtual_iban_recipient_model.freezed.dart';
part 'virtual_iban_recipient_model.g.dart';

/// Model for Virtual IBAN recipient API responses.
@freezed
sealed class VirtualIbanRecipientModel with _$VirtualIbanRecipientModel {
const factory VirtualIbanRecipientModel({
required String recipientId,
String? iban,
String? bicCode,
String? bankAddress,
String? ibanCountry,
String? frAccountId,
String? frUserId,
String? frPayeeId,
@Default(false) bool isOwner,
String? createdAt,
String? updatedAt,
String? recipientType,
}) = _VirtualIbanRecipientModel;

factory VirtualIbanRecipientModel.fromJson(Map<String, dynamic> json) =>
_$VirtualIbanRecipientModelFromJson(json);
}

Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class ExchangeFundingRepositoryImpl implements ExchangeFundingRepository {
);
}

// Confidential SEPA uses Virtual IBAN which is handled separately
if (fundingMethod == FundingMethod.confidentialSepa) {
throw UnsupportedError(
'Confidential SEPA funding details should be fetched through Virtual IBAN flow',
);
}

final fundingDetailsRequestParams = FundingDetailsRequestParamsModel(
jurisdiction: jurisdiction.code,
paymentMethod: switch (fundingMethod) {
Expand All @@ -49,6 +56,8 @@ class ExchangeFundingRepositoryImpl implements ExchangeFundingRepository {
FundingMethod.canadaPost => 'canadaPost',
FundingMethod.instantSepa => 'instantSepa',
FundingMethod.regularSepa => 'regularSepa',
FundingMethod.confidentialSepa =>
'confidentialSepa', // This won't be reached
FundingMethod.speiTransfer => 'spei',
FundingMethod.sinpe => 'sinpe',
FundingMethod.crIbanCrc => 'CRIbanCRC',
Expand Down
Loading