feat(shopinbit): recover requestDescription and detect travel on restore
What changed, and why it matters
This commit fixes a small data-recovery bug in Stack Wallet's integration with the ShopInBit shopping service. When a user restored their wallet or reopened an existing support ticket, the app had been storing an empty description and could not tell whether the ticket was for travel or the general concierge service. The patch recovers the original request text from the first message and uses a simple text pattern to detect travel bookings. There is no sign this exposes user funds, private keys, or allows remote attacks; it is a correctness improvement for how existing ticket data is displayed and categorized.
Treat as a routine functional fix. Reviewers may optionally verify that _extractRequestDescription handles unexpected HTML safely and that the 'Arrangement: ' heuristic does not misclassify concierge messages, but no security response is indicated.
Security signals we found
No cryptographic, networking, or permission changes
No input from untrusted remote sources beyond existing API messages
Regex parsing of HTML/message content is fragile but not obviously exploitable
Change is purely local data recovery/display logic
Evidence from the diff
The change is localized to lib/services/shopinbit/shopinbit_service.dart. It replaces a hard-coded empty requestDescription with a value parsed from the first non-agent ticket message, stripping
and other HTML tags. It also extends _inferCategoryFromMessages to classify messages starting with ‘Arrangement: ’ as ShopInBitCategory.travel, in addition to the existing car-research-fee detection. The implementation relies on fragile regex heuristics against user-visible message content, but the diff itself is a straightforward data-parsing fix with no network, cryptographic, or permission changes.
Changed components
lib/services/shopinbit/shopinbit_service.dartShopInBit ticket restore / category inferenceShopInBit request description displayInspect captured patch +30 / −8
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index d87b8b2..af9825e 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -136,6 +136,7 @@ class ShopInBitService {
final feeTicketNumber = category == ShopInBitCategory.car
? _extractFeeTicketNumber(apiMessages)
: null;
+ final requestDescription = _extractRequestDescription(apiMessages);
final messages = apiMessages
.map(
@@ -153,7 +154,7 @@ class ShopInBitService {
category: Value(category),
status: Value(mappedStatus),
statusRaw: Value(statusResp.value!.stateRaw),
- requestDescription: const Value(""),
+ requestDescription: Value(requestDescription),
deliveryCountry: const Value(""),
offerProductName: Value(offerProductName),
offerPrice: Value(offerPrice),
@@ -180,18 +181,27 @@ class ShopInBitService {
}
}
-// The API does not return service_type for existing tickets, so we infer
-// category from the first user message. Stack Wallet's car flow always seeds
-// the comment with this exact phrase; travel cannot be distinguished from
-// concierge because Stack Wallet sends travel as service_type="concierge" too.
+// Infer category from the first user message. The car flow always seeds
+// the comment with the "car research fee" line; travel requests built by
+// _buildRequestDescription always start with "Arrangement: " followed by
+// structured labels. Both are fragile against template changes in the form.
final RegExp _kCarResearchFeeRegex = RegExp(r'car research fee \(#([^)]+)\)');
+final RegExp _kTravelArrangementRegex = RegExp(
+ r'^Arrangement:\s',
+ multiLine: true,
+);
ShopInBitCategory _inferCategoryFromMessages(List<TicketMessage> messages) {
final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
if (firstUser == null) return ShopInBitCategory.concierge;
- return _kCarResearchFeeRegex.hasMatch(firstUser.content)
- ? ShopInBitCategory.car
- : ShopInBitCategory.concierge;
+ final content = firstUser.content;
+ if (_kCarResearchFeeRegex.hasMatch(content)) {
+ return ShopInBitCategory.car;
+ }
+ if (_kTravelArrangementRegex.hasMatch(content)) {
+ return ShopInBitCategory.travel;
+ }
+ return ShopInBitCategory.concierge;
}
String? _extractFeeTicketNumber(List<TicketMessage> messages) {
@@ -199,3 +209,15 @@ String? _extractFeeTicketNumber(List<TicketMessage> messages) {
if (firstUser == null) return null;
return _kCarResearchFeeRegex.firstMatch(firstUser.content)?.group(1);
}
+
+// The original `comment` passed to POST /requests becomes the first user message.
+final RegExp _kHtmlTagRegex = RegExp(r'<[^>]+>');
+
+String _extractRequestDescription(List<TicketMessage> messages) {
+ final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
+ if (firstUser == null) return "";
+ return firstUser.content
+ .replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
+ .replaceAll(_kHtmlTagRegex, '')
+ .trim();
+}
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.