feat(shopinbit): resume car research from server-side current invoices
What changed, and why it matters
This commit changes how the Stack Wallet app resumes a 'car research' purchase when a user taps an in-progress ticket. Instead of relying only on invoice details saved on the phone, it now first asks the ShopInBit server for the current invoice list and uses that to decide whether to continue to payment or start over. The change appears to be a feature/robustness improvement, not a security fix, but it does introduce a new network call and slightly different logic for matching invoices.
Treat as a normal feature commit. If reviewing for security, verify that getCurrentCarResearchInvoices and the CarResearchCurrentInvoice model enforce authentication/authorization on the server side, and that the client does not blindly trust invoice IDs or payment links returned by the server. No immediate security action is indicated by the diff alone.
Security signals we found
New async network call introduced in UI resume path
Server invoice list now trusted alongside local state for payment navigation
Invoice selection falls back to local row if server returns no payable match
No visible input sanitization or signature verification changes
Evidence from the diff
The patch refactors _resumeFlow in shopinbit_tickets_view.dart from a synchronous local-only decision to an async flow that calls getCurrentCarResearchInvoices() via pShopinBitService. It adds _liveInvoiceFrom to select a payable invoice, preferring server-returned current invoices and falling back to the local drift row. It also wraps the network call in showLoading and uses unawaited on the button handler. No input validation, cryptographic, or authorization changes are visible in the diff.
Changed components
lib/pages/shopinbit/shopinbit_tickets_view.dartShopInBit car research ticket resume flowInspect captured patch +66 / −17
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index 8226f81..b267e38 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -9,9 +9,11 @@ import "../../db/drift/shared_db/shared_database.dart";
import "../../models/shopinbit/shopinbit_order_model.dart";
import "../../providers/db/drift_provider.dart";
import "../../providers/global/shopin_bit_orders_provider.dart";
+import "../../providers/global/shopin_bit_service_provider.dart";
import "../../services/shopinbit/src/models/car_research.dart";
import "../../themes/stack_colors.dart";
import "../../utilities/assets.dart";
+import "../../utilities/show_loading.dart";
import "../../utilities/text_styles.dart";
import "../../utilities/util.dart";
import "../../widgets/background.dart";
@@ -74,34 +76,81 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
}
}
- void _resumeFlow(ShopInBitTicket pending) {
+ Future<void> _resumeFlow(ShopInBitTicket pending) async {
final model = ShopInBitOrderModel.fromDriftRow(pending);
+
+ // Recover the live invoice from the server first so resume works even if
+ // local invoice state was lost.
+ final response = await showLoading(
+ context: context,
+ rootNavigator: true,
+ message: "Checking your car research payment",
+ whileFuture: ref
+ .read(pShopinBitService)
+ .client
+ .getCurrentCarResearchInvoices(),
+ delay: const Duration(seconds: 1),
+ );
+ if (!mounted) return;
+
+ final invoice = _liveInvoiceFrom(response?.value, pending);
+
+ if (invoice != null) {
+ await Navigator.of(context).pushNamed(
+ ShopInBitCarResearchPaymentView.routeName,
+ arguments: (model, invoice),
+ );
+ } else {
+ // No recoverable invoice anywhere: re-create one from the fee view.
+ await Navigator.of(
+ context,
+ ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model);
+ }
+ }
+
+ /// Pick a still-payable invoice, preferring the server's current invoices
+ /// and falling back to locally stored invoice state.
+ CarResearchInvoice? _liveInvoiceFrom(
+ List<CarResearchCurrentInvoice>? current,
+ ShopInBitTicket pending,
+ ) {
+ if (current != null && current.isNotEmpty) {
+ final match = current.firstWhere(
+ (i) => i.invoiceId == pending.carResearchInvoiceId,
+ orElse: () => current.first,
+ );
+ final payable =
+ match.expiresAt != null &&
+ match.paymentLinks.isNotEmpty &&
+ (match.expiresAt!.isAfter(DateTime.now()) ||
+ carResearchIsFinalized(match.status, match.additional));
+ if (payable) {
+ return CarResearchInvoice(
+ btcpayInvoice: match.invoiceId,
+ expiresAt: match.expiresAt!,
+ paymentLinks: match.paymentLinks,
+ );
+ }
+ }
+
final expiresAt = pending.carResearchExpiresAt;
final linksJson = pending.carResearchPaymentLinks;
-
+ final invoiceId = pending.carResearchInvoiceId;
if (expiresAt != null &&
expiresAt.isAfter(DateTime.now()) &&
- linksJson != null) {
- // Invoice still live: navigate directly to payment view.
+ linksJson != null &&
+ invoiceId != null) {
final links = (jsonDecode(linksJson) as Map<String, dynamic>).map(
(k, v) => MapEntry(k, v as String),
);
- final invoice = CarResearchInvoice(
- btcpayInvoice: pending.carResearchInvoiceId!,
+ return CarResearchInvoice(
+ btcpayInvoice: invoiceId,
expiresAt: expiresAt,
paymentLinks: links,
);
-
- Navigator.of(context).pushNamed(
- ShopInBitCarResearchPaymentView.routeName,
- arguments: (model, invoice),
- );
- } else {
- // Invoice expired: navigate to fee view.
- Navigator.of(
- context,
- ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model);
}
+
+ return null;
}
static String _categoryLabel(ShopInBitCategory? category) =>
@@ -137,7 +186,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
children.add(
RoundedContainer(
color: Theme.of(context).extension<StackColors>()!.popupBG,
- onPressed: () => _resumeFlow(pending),
+ onPressed: () => unawaited(_resumeFlow(pending)),
child: _RequestRow(
title: "Car Research (In Progress)",
subtitle: "Tap to continue your car research payment",
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.