fix(shopinbit): open the real car ticket after the research fee, not the receipt
What changed, and why it matters
This commit fixes a bug in the Stack Wallet app's ShopInBit car-research payment flow. Previously, after a customer paid the research fee, the app tried to open the wrong ticket (a partner-only fee receipt that the customer cannot view), which could leave the user stuck or confused. The fix adds logic to find and open the actual customer-facing car-research ticket instead, with retries and a clearer message if it isn't ready yet.
Treat as a routine functional/UX bug fix rather than a security patch. Review the authorization model to confirm partner receipts are never returned to customer keys, and consider server-side enforcement so clients do not need to filter inaccessible tickets. No urgent security action is indicated by the diff alone.
Security signals we found
Incorrect ticket ID used after payment could expose or reference a partner-scoped receipt not intended for customer access
Customer key receives 403 on fee receipt, indicating an authorization boundary between partner and customer ticket scopes
UI fallback now avoids navigating to a non-existent or inaccessible ticket, reducing user confusion and potential error-state leakage
Retry loop with bounded attempts and delay added for eventual consistency of customer-facing ticket creation
Evidence from the diff
The patch removes _resolveRealTicket from the payment view and introduces ShopInBitService.adoptRealCarTicket(int receiptTicketId). The new method queries tickets by customer key, filters out the receipt ID and already-known tickets, hydrates the newest unknown ticket via _refreshRef, and returns its API ticket ID. The UI now retries up to five times with 1.5-second delays; if no real ticket appears, it shows a ‘finalizing’ dialog and returns to the requests list. This resolves a mismatch where log-payment returned a partner-scoped fee receipt ID that the customer key could not poll (403).
Changed components
lib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/services/shopinbit/shopinbit_service.dartShopInBit car research payment flowShopInBit ticket hydration and local database syncInspect captured patch +64 / −35
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index eb1137b..e2cac8b 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -9,7 +9,6 @@ import '../../notifications/show_flush_bar.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
import '../../providers/providers.dart';
import '../../services/shopinbit/src/models/car_research.dart';
-import '../../services/shopinbit/src/models/ticket.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
import '../../utilities/logger.dart';
@@ -314,27 +313,42 @@ class _ShopInBitCarResearchPaymentViewState
final result = logResp.value!;
- // log-payment returns the partner-scoped fee receipt, which the customer
- // key cannot poll. Pull the customer-facing car research ticket the
- // backend created from the cached request into the local DB, then open
- // it. `refreshAll` inserts it so the order-created view can read it.
- await service.refreshAll();
- final realTicket = await _resolveRealTicket(result.ticketId);
+ // log-payment gives us the fee receipt id, which the customer key can't
+ // poll; the real car ticket is a separate id. Find and open it, retrying
+ // since it can take a beat to show up in by-customer.
+ int? realId;
+ for (int attempt = 0; attempt < 5 && realId == null; attempt++) {
+ realId = await service.adoptRealCarTicket(result.ticketId);
+ if (realId == null && attempt < 4) {
+ await Future<void>.delayed(const Duration(milliseconds: 1500));
+ }
+ }
if (!mounted) return;
setState(() => _flowState = _PaymentFlowState.complete);
- if (realTicket != null) {
+ if (realId != null) {
unawaited(
- Navigator.of(context).pushNamed(
- ShopInBitOrderCreated.routeName,
- arguments: realTicket.id,
- ),
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId),
);
} else {
- // Backend has not surfaced the ticket yet; the requests list will pick
- // it up on its next refresh.
- _popToTickets();
+ // The real ticket hasn't surfaced yet; the requests list will pick it
+ // up on its next refresh.
+ await showDialog<void>(
+ context: context,
+ useRootNavigator: Util.isDesktop,
+ builder: (context) => StackOkDialog(
+ title: "Payment received",
+ maxWidth: Util.isDesktop ? 500 : null,
+ message:
+ "We're finalizing your car research request. It will appear "
+ "in My Requests shortly.",
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
+ if (mounted) _popToTickets();
}
} catch (e) {
if (mounted) {
@@ -353,26 +367,6 @@ class _ShopInBitCarResearchPaymentViewState
}
}
- /// Find the customer-facing car research ticket the backend created from the
- /// cached request, excluding the partner-scoped fee receipt. Returns the
- /// newest match, or null if none is visible yet.
- Future<TicketRef?> _resolveRealTicket(int receiptTicketId) async {
- final service = ref.read(pShopinBitService);
- try {
- final customerKey = await service.ensureCustomerKey();
- final resp = await service.client.getTicketsByCustomer(customerKey);
- if (resp.hasError || resp.value == null) return null;
-
- final candidates =
- resp.value!.where((t) => t.id != receiptTicketId).toList()
- ..sort((a, b) => b.id.compareTo(a.id));
-
- return candidates.isEmpty ? null : candidates.first;
- } catch (_) {
- return null;
- }
- }
-
void _copyAddress(BuildContext context) {
final addr = _currentAddress;
if (addr.isEmpty) return;
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 1f33466..85ef51c 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -120,6 +120,41 @@ class ShopInBitService {
return ref;
}
+ /// log-payment returns the fee *receipt* id, which the customer key can't
+ /// poll (403s). The real car ticket is a separate id that does show up in
+ /// by-customer. Grab the newest ticket we don't already track (not the
+ /// receipt), hydrate just that one, and return its id; null if not there yet.
+ Future<int?> adoptRealCarTicket(int receiptTicketId) async {
+ final String key = await ensureCustomerKey();
+ final ApiResponse<List<TicketRef>> resp = await client.getTicketsByCustomer(
+ key,
+ );
+ if (resp.hasError || resp.value == null) return null;
+
+ final Set<int> known = (await db.shopInBitTicketsDao.getByCustomerKey(
+ key,
+ )).map((t) => t.apiTicketId).toSet();
+
+ final List<TicketRef> candidates =
+ resp.value!
+ .where((t) => t.id != receiptTicketId && !known.contains(t.id))
+ .toList()
+ ..sort((a, b) => b.id.compareTo(a.id));
+
+ // Newest first; the receipt 403s (no row written) so it gets skipped.
+ for (final TicketRef ref in candidates) {
+ try {
+ await _refreshRef(ref, key);
+ } catch (_) {
+ // try the next candidate
+ }
+ if (await db.shopInBitTicketsDao.getByApiId(ref.id) != null) {
+ return ref.id;
+ }
+ }
+ return null;
+ }
+
Future<bool> sendMessage(int apiTicketId, String message) async {
final ApiResponse<Map<String, dynamic>> resp = await client.sendMessage(
apiTicketId,
Why this scored 23/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.