Skip to content

Commit eb061c4

Browse files
Merge branch 'develop' into feat/screenshot-preference
2 parents e16c4aa + a242425 commit eb061c4

5 files changed

Lines changed: 251 additions & 15 deletions

File tree

lib/features/buy/ui/screens/buy_success_screen.dart

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,10 @@ class _BuySuccessScreenState extends State<BuySuccessScreen> {
130130
BBButton.big(
131131
label: context.loc.buyViewDetails,
132132
onPressed: () {
133-
final txId = buyOrder.payjoin?.txid;
134-
if (txId != null) {
135-
context.pushNamed(
136-
TransactionsRoute.payjoinTransactionDetailsByTxId.name,
137-
pathParameters: {'txId': txId},
138-
);
139-
} else {
140-
context.pushNamed(
141-
TransactionsRoute.orderTransactionDetails.name,
142-
pathParameters: {'orderId': buyOrder.orderId},
143-
);
144-
}
133+
context.pushNamed(
134+
TransactionsRoute.orderTransactionDetails.name,
135+
pathParameters: {'orderId': buyOrder.orderId},
136+
);
145137
},
146138
bgColor: context.appColors.secondary,
147139
textColor: context.appColors.onSecondary,

lib/features/transactions/application/usecases/get_transactions_by_tx_id_usecase.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ class GetTransactionsByTxIdUsecase {
9999
} else if (orderSwap != null) {
100100
return [Transaction(orderSwap: orderSwap)];
101101
} else if (payjoins.isNotEmpty) {
102-
return payjoins.map((pj) => Transaction(payjoin: pj)).toList();
102+
return payjoins
103+
.map((pj) => Transaction(payjoin: pj, order: order))
104+
.toList();
103105
} else if (order != null) {
104106
return [Transaction(order: order)];
105107
} else {

lib/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,10 @@ class TransactionDetailsCubit extends Cubit<TransactionDetailsState> {
804804

805805
// Check if a transaction with the same transaction ID can be found
806806
// and initialize the transaction details with it.
807-
final txId = order.transactionId;
807+
// The payjoin txid is the fallback: the exchange can know the payjoin it
808+
// broadcast before it reports its own payout txid, and without this the
809+
// screen would sit on order-only details until that txid lands.
810+
final txId = order.transactionId ?? order.payjoin?.txid;
808811
if (txId != null) {
809812
try {
810813
final txs = await _getTransactionsByTxIdUsecase.execute(txId);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
2+
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
3+
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
4+
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
5+
import 'package:bb_mobile/core/swaps/domain/repositories/swap_history_repository.dart';
6+
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart';
7+
import 'package:bb_mobile/features/transactions/application/usecases/get_transaction_order_swaps_usecase.dart';
8+
import 'package:bb_mobile/features/transactions/application/usecases/get_transactions_by_tx_id_usecase.dart';
9+
import 'package:bull_payjoin/bull_payjoin.dart';
10+
import 'package:flutter_test/flutter_test.dart';
11+
import 'package:mocktail/mocktail.dart';
12+
import 'package:primitives/primitives.dart';
13+
14+
class _MockSettingsRepository extends Mock implements SettingsRepository {}
15+
16+
class _MockWalletTransactionRepository extends Mock
17+
implements WalletTransactionRepository {}
18+
19+
class _MockSwapHistoryRepository extends Mock
20+
implements SwapHistoryRepository {}
21+
22+
class _MockPayjoinSessions extends Mock implements PayjoinSessions {}
23+
24+
class _MockExchangeOrderRepository extends Mock
25+
implements ExchangeOrderRepository {}
26+
27+
class _MockGetTransactionOrderSwapsUsecase extends Mock
28+
implements GetTransactionOrderSwapsUsecase {}
29+
30+
const _txId = 'tx-1';
31+
32+
void main() {
33+
late _MockSettingsRepository settingsRepository;
34+
late _MockWalletTransactionRepository walletTransactionRepository;
35+
late _MockSwapHistoryRepository swapHistoryRepository;
36+
late _MockPayjoinSessions payjoinSessions;
37+
late _MockExchangeOrderRepository mainnetOrderRepository;
38+
late _MockExchangeOrderRepository testnetOrderRepository;
39+
late _MockGetTransactionOrderSwapsUsecase getTransactionOrderSwapsUsecase;
40+
late GetTransactionsByTxIdUsecase usecase;
41+
42+
setUp(() {
43+
settingsRepository = _MockSettingsRepository();
44+
walletTransactionRepository = _MockWalletTransactionRepository();
45+
swapHistoryRepository = _MockSwapHistoryRepository();
46+
payjoinSessions = _MockPayjoinSessions();
47+
mainnetOrderRepository = _MockExchangeOrderRepository();
48+
testnetOrderRepository = _MockExchangeOrderRepository();
49+
getTransactionOrderSwapsUsecase = _MockGetTransactionOrderSwapsUsecase();
50+
usecase = GetTransactionsByTxIdUsecase(
51+
settingsRepository: settingsRepository,
52+
walletTransactionRepository: walletTransactionRepository,
53+
boltzSwapRepository: swapHistoryRepository,
54+
payjoinSessions: payjoinSessions,
55+
mainnetExchangeOrderRepository: mainnetOrderRepository,
56+
testnetExchangeOrderRepository: testnetOrderRepository,
57+
getTransactionOrderSwapsUsecase: getTransactionOrderSwapsUsecase,
58+
);
59+
60+
when(() => settingsRepository.fetch()).thenAnswer((_) async => _settings());
61+
when(
62+
() => walletTransactionRepository.getWalletTransactions(
63+
txId: any(named: 'txId'),
64+
),
65+
).thenAnswer((_) async => []);
66+
when(
67+
() => swapHistoryRepository.getSwapByTxId(any()),
68+
).thenAnswer((_) async => null);
69+
when(
70+
() => getTransactionOrderSwapsUsecase.execute(),
71+
).thenAnswer((_) async => []);
72+
});
73+
74+
test(
75+
'keeps the order when only a payjoin session is found for the txid',
76+
() async {
77+
final payjoin = _payjoinSession();
78+
final order = _buyOrder();
79+
when(
80+
() => payjoinSessions.byTransactionId(_txId),
81+
).thenAnswer((_) async => Ok([payjoin]));
82+
when(
83+
() => mainnetOrderRepository.getOrderByTxId(_txId),
84+
).thenAnswer((_) async => order);
85+
86+
final transactions = await usecase.execute(_txId);
87+
88+
expect(transactions, hasLength(1));
89+
expect(transactions.single.payjoin, payjoin);
90+
// Without this the order-anchored details screen renders as a payjoin
91+
// session until the wallet indexes the broadcast.
92+
expect(transactions.single.order, order);
93+
},
94+
);
95+
96+
test('still returns the payjoin when no order matches the txid', () async {
97+
final payjoin = _payjoinSession();
98+
when(
99+
() => payjoinSessions.byTransactionId(_txId),
100+
).thenAnswer((_) async => Ok([payjoin]));
101+
when(
102+
() => mainnetOrderRepository.getOrderByTxId(_txId),
103+
).thenAnswer((_) async => null);
104+
105+
final transactions = await usecase.execute(_txId);
106+
107+
expect(transactions, hasLength(1));
108+
expect(transactions.single.payjoin, payjoin);
109+
expect(transactions.single.order, isNull);
110+
});
111+
}
112+
113+
SettingsEntity _settings() => const SettingsEntity(
114+
environment: Environment.mainnet,
115+
bitcoinUnit: BitcoinUnit.btc,
116+
currencyCode: 'CAD',
117+
);
118+
119+
PayjoinSession _payjoinSession() => PayjoinReceiverSession(
120+
status: PayjoinStatus.completed,
121+
id: 'payjoin-1',
122+
network: BitcoinNetwork.mainnet,
123+
walletId: 'wallet-1',
124+
createdAt: DateTime.utc(2026, 8, 19),
125+
expiresAt: DateTime.utc(2026, 8, 20),
126+
payjoinUri: 'bitcoin:address?pj=https://payjoin.example',
127+
transactionId: _txId,
128+
);
129+
130+
Order _buyOrder() => Order.buy(
131+
orderId: 'order-1',
132+
orderType: OrderType.buy,
133+
message: OrderMessage(code: '', message: ''),
134+
orderNumber: 1,
135+
payinAmount: 100,
136+
payinCurrency: 'CAD',
137+
payoutAmount: 0.001,
138+
payoutCurrency: 'BTC',
139+
payinMethod: OrderPaymentMethod.eTransfer,
140+
payoutMethod: OrderPaymentMethod.bitcoin,
141+
orderStatus: OrderStatus.inProgress,
142+
payinStatus: OrderPayinStatus.completed,
143+
payoutStatus: OrderPayoutStatus.completed,
144+
createdAt: DateTime.utc(2026, 8, 19),
145+
payjoinDetails: OrderPayjoinDetails(txid: _txId),
146+
isTestnet: false,
147+
);

test/features/transactions/presentation/blocs/transaction_details/transaction_details_cubit_test.dart

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:bb_mobile/core/entities/signer_entity.dart' show SignerEntity;
4+
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
45
import 'package:bb_mobile/core/exchange/domain/usecases/get_order_usercase.dart';
56
import 'package:bb_mobile/core/swaps/domain/usecases/get_swap_usecase.dart';
67
import 'package:bb_mobile/core/swaps/domain/usecases/watch_swap_usecase.dart';
@@ -145,6 +146,7 @@ void main() {
145146
late _MockBroadcastOriginalTransactionUsecase broadcastOriginalTransaction;
146147
late _MockGetTransactionOrderSwapUsecase getTransactionOrderSwap;
147148
late _MockWatchTransactionOrderSwapUsecase watchTransactionOrderSwap;
149+
late _MockGetOrderUsecase getOrder;
148150

149151
TransactionDetailsCubit buildCubit() => TransactionDetailsCubit(
150152
getWalletUsecase: getWallet,
@@ -155,7 +157,7 @@ void main() {
155157
getSwapUsecase: _MockGetSwapUsecase(),
156158
getPayjoinByIdUsecase: getPayjoinById,
157159
getPayjoinByTxIdUsecase: getPayjoinByTxId,
158-
getOrderUsecase: _MockGetOrderUsecase(),
160+
getOrderUsecase: getOrder,
159161
watchSwapUsecase: _MockWatchSwapUsecase(),
160162
watchPayjoinUsecase: watchPayjoin,
161163
watchTransactionOrderSwapUsecase: watchTransactionOrderSwap,
@@ -178,6 +180,7 @@ void main() {
178180
broadcastOriginalTransaction = _MockBroadcastOriginalTransactionUsecase();
179181
getTransactionOrderSwap = _MockGetTransactionOrderSwapUsecase();
180182
watchTransactionOrderSwap = _MockWatchTransactionOrderSwapUsecase();
183+
getOrder = _MockGetOrderUsecase();
181184

182185
when(() => broadcastOriginalTransaction.canExecute(any())).thenAnswer((
183186
invocation,
@@ -1313,8 +1316,97 @@ void main() {
13131316
},
13141317
);
13151318
});
1319+
1320+
group('TransactionDetailsCubit.initByOrderId transaction resolution', () {
1321+
// _loadDetailsByOrderId looks the txid up, then hands it to
1322+
// initByWalletTxId, which looks it up again — so the usecase is called
1323+
// more than once per init. What matters is which txid was asked for, not
1324+
// how often, hence capture over a call count.
1325+
void expectResolvedTxIds(String txId) {
1326+
final resolved = verify(
1327+
() => getTransactionsByTxId.execute(captureAny()),
1328+
).captured;
1329+
expect(resolved, isNotEmpty);
1330+
expect(resolved, everyElement(txId));
1331+
}
1332+
1333+
test('resolves the payjoin txid when the order has no txid yet', () async {
1334+
// The exchange can know the payjoin it broadcast before it reports its
1335+
// own payout txid. Without the fallback the screen stays on order-only
1336+
// details, with no payjoin shown and no watcher armed.
1337+
final order = _buyOrder(payjoinTxId: 'payjoin-txid');
1338+
when(
1339+
() => getOrder.execute(orderId: 'order-1'),
1340+
).thenAnswer((_) async => order);
1341+
when(() => getTransactionsByTxId.execute(any())).thenAnswer(
1342+
(_) async => [Transaction(payjoin: _receiver(), order: order)],
1343+
);
1344+
1345+
final cubit = buildCubit();
1346+
addTearDown(cubit.close);
1347+
await cubit.initByOrderId('order-1');
1348+
1349+
expectResolvedTxIds('payjoin-txid');
1350+
});
1351+
1352+
test('prefers the order txid when both are known', () async {
1353+
final order = _buyOrder(
1354+
bitcoinTransactionId: 'payout-txid',
1355+
payjoinTxId: 'payjoin-txid',
1356+
);
1357+
when(
1358+
() => getOrder.execute(orderId: 'order-1'),
1359+
).thenAnswer((_) async => order);
1360+
when(() => getTransactionsByTxId.execute(any())).thenAnswer(
1361+
(_) async => [Transaction(payjoin: _receiver(), order: order)],
1362+
);
1363+
1364+
final cubit = buildCubit();
1365+
addTearDown(cubit.close);
1366+
await cubit.initByOrderId('order-1');
1367+
1368+
expectResolvedTxIds('payout-txid');
1369+
});
1370+
1371+
test('falls back to order-only details when no txid is known', () async {
1372+
final order = _buyOrder();
1373+
when(
1374+
() => getOrder.execute(orderId: 'order-1'),
1375+
).thenAnswer((_) async => order);
1376+
1377+
final cubit = buildCubit();
1378+
addTearDown(cubit.close);
1379+
await cubit.initByOrderId('order-1');
1380+
1381+
verifyNever(() => getTransactionsByTxId.execute(any()));
1382+
expect(cubit.state.transaction?.order, order);
1383+
});
1384+
});
13161385
}
13171386

1387+
Order _buyOrder({String? bitcoinTransactionId, String? payjoinTxId}) =>
1388+
Order.buy(
1389+
orderId: 'order-1',
1390+
orderType: OrderType.buy,
1391+
message: OrderMessage(code: '', message: ''),
1392+
orderNumber: 1,
1393+
payinAmount: 100,
1394+
payinCurrency: 'CAD',
1395+
payoutAmount: 0.001,
1396+
payoutCurrency: 'BTC',
1397+
payinMethod: OrderPaymentMethod.eTransfer,
1398+
payoutMethod: OrderPaymentMethod.bitcoin,
1399+
orderStatus: OrderStatus.inProgress,
1400+
payinStatus: OrderPayinStatus.completed,
1401+
payoutStatus: OrderPayoutStatus.completed,
1402+
createdAt: DateTime.utc(2026, 8, 19),
1403+
bitcoinTransactionId: bitcoinTransactionId,
1404+
payjoinDetails: payjoinTxId == null
1405+
? null
1406+
: OrderPayjoinDetails(txid: payjoinTxId),
1407+
isTestnet: false,
1408+
);
1409+
13181410
OrderSwapRecord _receiveOrderSwap() {
13191411
final createdAt = DateTime.utc(2026, 8, 10);
13201412
return OrderSwapRecord(

0 commit comments

Comments
 (0)