Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -398,49 +398,43 @@ class RecipientDetailsDto {
);

// COSTA RICA
// ownerName is nullable by design for the SINPE types: the server fills it
// from a Ridivi lookup that can fail or be empty on older records.
// Requiring it here silently dropped those recipients (#2529).
case RecipientType.sinpeIbanUsd:
if (iban == null) {
throw StateError('iban is required for SINPE_IBAN_USD.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_IBAN_USD.');
}
return SinpeIbanUsdDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
iban: iban!,
ownerName: ownerName!,
ownerName: ownerName,
);

case RecipientType.sinpeIbanCrc:
if (iban == null) {
throw StateError('iban is required for SINPE_IBAN_CRC.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_IBAN_CRC.');
}
return SinpeIbanCrcDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
iban: iban!,
ownerName: ownerName!,
ownerName: ownerName,
);

case RecipientType.sinpeMovilCrc:
if (phoneNumber == null) {
throw StateError('phoneNumber is required for SINPE_MOVIL_CRC.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_MOVIL_CRC.');
}
return SinpeMovilCrcDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
phoneNumber: phoneNumber!,
ownerName: ownerName!,
ownerName: ownerName,
);

// ARGENTINA
Expand Down
42 changes: 21 additions & 21 deletions lib/features/recipients/domain/value_objects/recipient_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -378,39 +378,45 @@ class SpeiCardMxnDetails extends RecipientDetails {
}

// ── SINPE (CRC/USD)
//
// The owner name comes from a Ridivi lookup on the server, which can fail or be
// empty on older records, so it is nullable by design (#2529). Display sites
// fall back to the label and then to the account identifier.
String? _nullIfBlank(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}

@immutable
class SinpeIbanUsdDetails extends RecipientDetails {
final String iban;
final String ownerName;
final String? ownerName;

const SinpeIbanUsdDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.iban,
required this.ownerName,
this.ownerName,
});

factory SinpeIbanUsdDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String iban,
required String ownerName,
String? ownerName,
}) {
if (iban.trim().isEmpty) {
throw ArgumentError('IBAN cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeIbanUsdDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
iban: iban.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand All @@ -421,36 +427,33 @@ class SinpeIbanUsdDetails extends RecipientDetails {
@immutable
class SinpeIbanCrcDetails extends RecipientDetails {
final String iban;
final String ownerName;
final String? ownerName;

const SinpeIbanCrcDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.iban,
required this.ownerName,
this.ownerName,
});

factory SinpeIbanCrcDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String iban,
required String ownerName,
String? ownerName,
}) {
if (iban.trim().isEmpty) {
throw ArgumentError('IBAN cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeIbanCrcDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
iban: iban.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand All @@ -461,36 +464,33 @@ class SinpeIbanCrcDetails extends RecipientDetails {
@immutable
class SinpeMovilCrcDetails extends RecipientDetails {
final String phoneNumber;
final String ownerName;
final String? ownerName;

const SinpeMovilCrcDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.phoneNumber,
required this.ownerName,
this.ownerName,
});

factory SinpeMovilCrcDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String phoneNumber,
required String ownerName,
String? ownerName,
}) {
if (phoneNumber.trim().isEmpty) {
throw ArgumentError('Phone number cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeMovilCrcDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
phoneNumber: phoneNumber.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,21 @@ sealed class RecipientViewModel with _$RecipientViewModel {
if (label != null && label!.isNotEmpty) return label!;
return null;

// ownerName is often absent for the SINPE types (#2529), so fall all the
// way back to the account identifier rather than showing nothing.
case RecipientType.sinpeIbanUsd:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
return null;

case RecipientType.sinpeIbanCrc:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
if (iban != null && iban!.isNotEmpty) return iban!;
return null;

case RecipientType.sinpeMovilCrc:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
if (phoneNumber != null && phoneNumber!.isNotEmpty) {
return phoneNumber!;
}
return null;
case RecipientType.bankAccountArgentina:
if (name != null && name!.isNotEmpty) return name!;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:bb_mobile/features/recipients/application/dtos/recipient_details_dto.dart';
import 'package:bb_mobile/features/recipients/domain/value_objects/recipient_details.dart';
import 'package:bb_mobile/features/recipients/domain/value_objects/recipient_type.dart';
import 'package:bb_mobile/features/recipients/interface_adapters/presenters/models/recipient_view_model.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('nullable SINPE owner name', () {
test('mobile recipient remains valid without an owner name', () {
const dto = RecipientDetailsDto(
recipientType: RecipientType.sinpeMovilCrc,
phoneNumber: '8888-8888',
);

final details = dto.toDomain() as SinpeMovilCrcDetails;

expect(details.phoneNumber, '8888-8888');
expect(details.ownerName, isNull);
});

test('IBAN recipients remain valid without an owner name', () {
for (final type in [
RecipientType.sinpeIbanCrc,
RecipientType.sinpeIbanUsd,
]) {
final dto = RecipientDetailsDto(
recipientType: type,
iban: 'CR05015202001026284066',
);
final details = dto.toDomain();
final ownerName = switch (details) {
SinpeIbanCrcDetails(:final ownerName) => ownerName,
SinpeIbanUsdDetails(:final ownerName) => ownerName,
_ => throw StateError('Unexpected details type'),
};

expect(ownerName, isNull);
}
});

test('display name falls back to label then identifier', () {
const labeled = RecipientViewModel(
id: 'recipient-1',
type: RecipientType.sinpeMovilCrc,
label: 'Family',
phoneNumber: '8888-8888',
);
const unlabeled = RecipientViewModel(
id: 'recipient-2',
type: RecipientType.sinpeMovilCrc,
phoneNumber: '8888-8888',
);

expect(labeled.displayName, 'Family');
expect(unlabeled.displayName, '8888-8888');
});
});
}
Loading