Skip to content

Commit 2e495dc

Browse files
fix(sell): close the post-broadcast double-payment window
After broadcasting, the confirm handler fetched the order; any failure in that fetch re-enabled Confirm with the transaction already on the wire, so a second tap re-ran prepare, sign and broadcast and could pay the order twice. Concurrent handlers (fee recalculation, utxo load, order poll, price-lock refresh) also emitted pre-await snapshots that could silently revert in-flight state. - Latch on broadcast: payinBroadcastTxid is emitted immediately after the transaction is on the wire (bitcoin and liquid). A latched bloc never re-enters the send path, never surfaces a retryable error, and keeps Confirm disabled while the existing poll carries the order to success. - Merge concurrent emits into live state so the latch survives fee recalculation, utxo loads and an order poll spanning the broadcast. - Price-lock refresh no longer clears the in-flight flag; a failure after the deadline re-arms the countdown so the price refreshes. - Show an in-flight spinner and status next to Confirm; disable Advanced settings during confirmation. - Success state now carries the post-broadcast order instead of the stale pre-broadcast snapshot. - Sell success screen: adds 'You sold {amount} for {fiatAmount}', gates the balance-credit message on balance payouts, and closes (button and back gesture) to wallet home like buy, on a new shared success scaffold widget. - SINPE recipients: ownerName is optional, matching the server schema; display falls back to label, then phone or IBAN. Recipients missing the Ridivi-derived name are no longer dropped. Closes #2522 Closes #2523 Closes #2529 Closes #2530
1 parent 33a1ff1 commit 2e495dc

11 files changed

Lines changed: 863 additions & 169 deletions

File tree

lib/core/exchange/domain/entity/order.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,21 @@ enum OrderPaymentMethod {
231231
final String value;
232232
const OrderPaymentMethod(this.value);
233233

234+
/// The payin/payout methods that debit or credit one of the user's in-app
235+
/// fiat balances instead of an external account. Refund-to-balance methods
236+
/// are deliberately excluded: they are not selectable as a payout.
237+
static const balanceMethods = <OrderPaymentMethod>{
238+
cadBalance,
239+
eurBalance,
240+
mxnBalance,
241+
arsBalance,
242+
copBalance,
243+
crcBalance,
244+
usdBalance,
245+
};
246+
247+
bool get isBalance => balanceMethods.contains(this);
248+
234249
static OrderPaymentMethod fromValue(String value) {
235250
return OrderPaymentMethod.values.firstWhere(
236251
(e) => e.value == value,
@@ -562,6 +577,10 @@ sealed class Order with _$Order {
562577
bool get isPayinCompleted => payinStatus == OrderPayinStatus.completed;
563578
bool get isPayoutCompleted => payoutStatus == OrderPayoutStatus.completed;
564579

580+
/// Whether the payout credits one of the user's in-app fiat balances rather
581+
/// than an external recipient.
582+
bool get isBalancePayout => payoutMethod.isBalance;
583+
565584
bool isCompleted() => orderStatus == OrderStatus.completed;
566585
bool isProcessing() => orderStatus == OrderStatus.inProgress;
567586
bool isCancelled() => orderStatus == OrderStatus.canceled;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import 'package:bb_mobile/core/themes/app_theme.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:gap/gap.dart';
4+
5+
/// Shared layout for the exchange flows' success screens (buy, sell, pay) so
6+
/// they stay structurally aligned: a success icon, a headline, an optional
7+
/// amount line and message, and a bottom action area.
8+
///
9+
/// Back navigation is intercepted; the flow can only be left through [onClose],
10+
/// which is called both by the close button and by a back gesture.
11+
class SuccessScreenScaffold extends StatelessWidget {
12+
const SuccessScreenScaffold({
13+
super.key,
14+
required this.title,
15+
required this.headline,
16+
required this.onClose,
17+
this.icon,
18+
this.amountLine,
19+
this.message,
20+
this.actions = const <Widget>[],
21+
});
22+
23+
/// App bar title, usually the name of the flow.
24+
final String title;
25+
26+
/// Headline under the icon, e.g. "Order completed!".
27+
final String headline;
28+
29+
/// Leaves the flow. Called by the close button and by a back gesture.
30+
final VoidCallback onClose;
31+
32+
/// Defaults to a large success check mark.
33+
final Widget? icon;
34+
35+
/// Line under the headline, e.g. "You sold 100 000 sats for $50.00".
36+
final String? amountLine;
37+
38+
/// Explanatory content under the amount line. Styled as centered body text
39+
/// unless the widget overrides it, so a plain [Text] is enough.
40+
final Widget? message;
41+
42+
/// Pinned to the bottom of the screen, typically buttons.
43+
final List<Widget> actions;
44+
45+
@override
46+
Widget build(BuildContext context) {
47+
return PopScope(
48+
canPop: false,
49+
onPopInvokedWithResult: (didPop, _) {
50+
if (didPop) return;
51+
onClose();
52+
},
53+
child: Scaffold(
54+
appBar: AppBar(
55+
title: Text(title),
56+
automaticallyImplyLeading: false,
57+
actions: [
58+
IconButton(icon: const Icon(Icons.close), onPressed: onClose),
59+
],
60+
),
61+
body: SafeArea(
62+
child: Center(
63+
child: Padding(
64+
padding: const EdgeInsets.symmetric(horizontal: 24.0),
65+
child: Column(
66+
mainAxisAlignment: .center,
67+
children: [
68+
icon ??
69+
Icon(
70+
Icons.check_circle,
71+
size: 100,
72+
color: context.appColors.success,
73+
),
74+
const Gap(20),
75+
Text(
76+
headline,
77+
style: context.font.titleLarge,
78+
textAlign: .center,
79+
),
80+
if (amountLine != null) ...[
81+
const Gap(8),
82+
Text(
83+
amountLine!,
84+
style: context.font.bodyLarge,
85+
textAlign: .center,
86+
),
87+
],
88+
if (message != null) ...[
89+
const Gap(10),
90+
DefaultTextStyle.merge(
91+
style: context.font.bodyMedium,
92+
textAlign: .center,
93+
child: message!,
94+
),
95+
],
96+
],
97+
),
98+
),
99+
),
100+
),
101+
bottomNavigationBar: actions.isEmpty
102+
? null
103+
: SafeArea(
104+
child: Padding(
105+
padding: const EdgeInsets.all(16.0),
106+
child: Column(mainAxisSize: .min, children: actions),
107+
),
108+
),
109+
),
110+
);
111+
}
112+
}

lib/features/recipients/application/dtos/recipient_details_dto.dart

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -397,49 +397,43 @@ class RecipientDetailsDto {
397397
);
398398

399399
// COSTA RICA
400+
// ownerName is nullable by design for the SINPE types: the server fills it
401+
// from a Ridivi lookup that can fail or be empty on older records.
402+
// Requiring it here silently dropped those recipients (#2529).
400403
case RecipientType.sinpeIbanUsd:
401404
if (iban == null) {
402405
throw StateError('iban is required for SINPE_IBAN_USD.');
403406
}
404-
if (ownerName == null) {
405-
throw StateError('ownerName is required for SINPE_IBAN_USD.');
406-
}
407407
return SinpeIbanUsdDetails.create(
408408
label: label,
409409
isDefault: def,
410410
isOwner: isOwner,
411411
iban: iban!,
412-
ownerName: ownerName!,
412+
ownerName: ownerName,
413413
);
414414

415415
case RecipientType.sinpeIbanCrc:
416416
if (iban == null) {
417417
throw StateError('iban is required for SINPE_IBAN_CRC.');
418418
}
419-
if (ownerName == null) {
420-
throw StateError('ownerName is required for SINPE_IBAN_CRC.');
421-
}
422419
return SinpeIbanCrcDetails.create(
423420
label: label,
424421
isDefault: def,
425422
isOwner: isOwner,
426423
iban: iban!,
427-
ownerName: ownerName!,
424+
ownerName: ownerName,
428425
);
429426

430427
case RecipientType.sinpeMovilCrc:
431428
if (phoneNumber == null) {
432429
throw StateError('phoneNumber is required for SINPE_MOVIL_CRC.');
433430
}
434-
if (ownerName == null) {
435-
throw StateError('ownerName is required for SINPE_MOVIL_CRC.');
436-
}
437431
return SinpeMovilCrcDetails.create(
438432
label: label,
439433
isDefault: def,
440434
isOwner: isOwner,
441435
phoneNumber: phoneNumber!,
442-
ownerName: ownerName!,
436+
ownerName: ownerName,
443437
);
444438

445439
// ARGENTINA

lib/features/recipients/domain/value_objects/recipient_details.dart

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -378,39 +378,45 @@ class SpeiCardMxnDetails extends RecipientDetails {
378378
}
379379

380380
// ── SINPE (CRC/USD)
381+
//
382+
// The owner name comes from a Ridivi lookup on the server, which can fail or be
383+
// empty on older records, so it is nullable by design (#2529). Display sites
384+
// fall back to the label and then to the account identifier.
385+
String? _nullIfBlank(String? value) {
386+
final trimmed = value?.trim();
387+
return trimmed == null || trimmed.isEmpty ? null : trimmed;
388+
}
389+
381390
@immutable
382391
class SinpeIbanUsdDetails extends RecipientDetails {
383392
final String iban;
384-
final String ownerName;
393+
final String? ownerName;
385394

386395
const SinpeIbanUsdDetails._({
387396
super.label,
388397
super.isDefault = false,
389398
super.isOwner,
390399
required this.iban,
391-
required this.ownerName,
400+
this.ownerName,
392401
});
393402

394403
factory SinpeIbanUsdDetails.create({
395404
String? label,
396405
bool isDefault = false,
397406
bool? isOwner,
398407
required String iban,
399-
required String ownerName,
408+
String? ownerName,
400409
}) {
401410
if (iban.trim().isEmpty) {
402411
throw ArgumentError('IBAN cannot be empty');
403412
}
404-
if (ownerName.trim().isEmpty) {
405-
throw ArgumentError('Owner name cannot be empty');
406-
}
407413

408414
return SinpeIbanUsdDetails._(
409415
label: label,
410416
isDefault: isDefault,
411417
isOwner: isOwner,
412418
iban: iban.trim(),
413-
ownerName: ownerName.trim(),
419+
ownerName: _nullIfBlank(ownerName),
414420
);
415421
}
416422

@@ -421,36 +427,33 @@ class SinpeIbanUsdDetails extends RecipientDetails {
421427
@immutable
422428
class SinpeIbanCrcDetails extends RecipientDetails {
423429
final String iban;
424-
final String ownerName;
430+
final String? ownerName;
425431

426432
const SinpeIbanCrcDetails._({
427433
super.label,
428434
super.isDefault = false,
429435
super.isOwner,
430436
required this.iban,
431-
required this.ownerName,
437+
this.ownerName,
432438
});
433439

434440
factory SinpeIbanCrcDetails.create({
435441
String? label,
436442
bool isDefault = false,
437443
bool? isOwner,
438444
required String iban,
439-
required String ownerName,
445+
String? ownerName,
440446
}) {
441447
if (iban.trim().isEmpty) {
442448
throw ArgumentError('IBAN cannot be empty');
443449
}
444-
if (ownerName.trim().isEmpty) {
445-
throw ArgumentError('Owner name cannot be empty');
446-
}
447450

448451
return SinpeIbanCrcDetails._(
449452
label: label,
450453
isDefault: isDefault,
451454
isOwner: isOwner,
452455
iban: iban.trim(),
453-
ownerName: ownerName.trim(),
456+
ownerName: _nullIfBlank(ownerName),
454457
);
455458
}
456459

@@ -461,36 +464,33 @@ class SinpeIbanCrcDetails extends RecipientDetails {
461464
@immutable
462465
class SinpeMovilCrcDetails extends RecipientDetails {
463466
final String phoneNumber;
464-
final String ownerName;
467+
final String? ownerName;
465468

466469
const SinpeMovilCrcDetails._({
467470
super.label,
468471
super.isDefault = false,
469472
super.isOwner,
470473
required this.phoneNumber,
471-
required this.ownerName,
474+
this.ownerName,
472475
});
473476

474477
factory SinpeMovilCrcDetails.create({
475478
String? label,
476479
bool isDefault = false,
477480
bool? isOwner,
478481
required String phoneNumber,
479-
required String ownerName,
482+
String? ownerName,
480483
}) {
481484
if (phoneNumber.trim().isEmpty) {
482485
throw ArgumentError('Phone number cannot be empty');
483486
}
484-
if (ownerName.trim().isEmpty) {
485-
throw ArgumentError('Owner name cannot be empty');
486-
}
487487

488488
return SinpeMovilCrcDetails._(
489489
label: label,
490490
isDefault: isDefault,
491491
isOwner: isOwner,
492492
phoneNumber: phoneNumber.trim(),
493-
ownerName: ownerName.trim(),
493+
ownerName: _nullIfBlank(ownerName),
494494
);
495495
}
496496

lib/features/recipients/interface_adapters/presenters/models/recipient_view_model.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,21 @@ sealed class RecipientViewModel with _$RecipientViewModel {
138138
if (label != null && label!.isNotEmpty) return label!;
139139
return null;
140140

141+
// ownerName is often absent for the SINPE types (#2529), so fall all the
142+
// way back to the account identifier rather than showing nothing.
141143
case RecipientType.sinpeIbanUsd:
142-
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
143-
if (label != null && label!.isNotEmpty) return label!;
144-
return null;
145-
146144
case RecipientType.sinpeIbanCrc:
147145
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
148146
if (label != null && label!.isNotEmpty) return label!;
147+
if (iban != null && iban!.isNotEmpty) return iban!;
149148
return null;
150149

151150
case RecipientType.sinpeMovilCrc:
152151
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
153152
if (label != null && label!.isNotEmpty) return label!;
153+
if (phoneNumber != null && phoneNumber!.isNotEmpty) {
154+
return phoneNumber!;
155+
}
154156
return null;
155157
case RecipientType.bankAccountArgentina:
156158
if (name != null && name!.isNotEmpty) return name!;

0 commit comments

Comments
 (0)