fix(shopinbit): defend against API contract drift
What changed, and why it matters
This commit is a defensive hardening patch for the ShopInBit integration in Stack Wallet. It changes how the app handles unexpected status strings and webhook event types from the ShopInBit API. Previously, unknown values were silently forced into a default known value (for example, an unrecognized ticket state was treated as a brand-new ticket). Now unknown values are explicitly marked as 'unknown,' preserved in raw form, and ignored rather than misapplied. This prevents the app from showing wrong order statuses or acting on unexpected events if the ShopInBit API changes its labels or adds new ones.
No immediate user action required. This is a hardening improvement. Users relying on ShopInBit should update to a build containing this commit to reduce the chance of order-status confusion if ShopInBit changes its API. Developers should verify that downstream consumers of `ShopInBitOrderModel.status` handle the `unknown`/`null` mapping case correctly and that webhook dispatch logic drops `WebhookEventType.unknown` events.
Security signals we found
Previously unrecognized API enum strings were silently coerced to defaults (TicketState.newTicket, WebhookEventType.ticketStateChanged), which could misrepresent order state or dispatch unknown events
Patch introduces explicit `unknown` sentinel enum values and nullable mapping return types
Raw API strings are now persisted alongside parsed enums to support future recovery/migration
Callers now skip status updates when the API value cannot be mapped, preventing regression of stored state
Unknown webhook event types are now logged and dropped instead of being routed to a state-change handler
Evidence from the diff
The patch removes unsafe firstWhere(..., orElse: () => <default>) mappings in TicketState.fromString and WebhookEventType.fromString, replacing them with explicit unknown sentinel values and logging. It adds stateRaw/statusRaw fields to preserve the original API strings, updates statusFromTicketState to return nullable ShopInBitOrderStatus? so callers do not overwrite local state with a default when mapping fails, and makes callers in shopinbit_ticket_detail.dart and shopinbit_tickets_view.dart only update status when a non-null mapping is returned. Generated Isar schema code is updated accordingly. The change is defensive and reduces the risk of state-machine confusion caused by API contract drift.
Changed components
lib/services/shopinbit/src/models/ticket.dartlib/services/shopinbit/src/models/webhook_event.dartlib/models/shopinbit/shopinbit_order_model.dartlib/models/isar/models/shopinbit_ticket.dartlib/models/isar/models/shopinbit_ticket.g.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages/shopinbit/shopinbit_tickets_view.dartInspect captured patch +290 / −15
diff --git a/lib/models/isar/models/shopinbit_ticket.dart b/lib/models/isar/models/shopinbit_ticket.dart
index 0a2ac53..4ccf629 100644
--- a/lib/models/isar/models/shopinbit_ticket.dart
+++ b/lib/models/isar/models/shopinbit_ticket.dart
@@ -16,6 +16,11 @@ class ShopInBitTicket {
late ShopInBitCategory category;
@enumerated
late ShopInBitOrderStatus status;
+ // Raw API state string (e.g. "OFFER AVAILABLE") preserved alongside the
+ // mapped enum. If ShopinBit renames a state or adds a new one, the enum
+ // will read as `pending` but `statusRaw` retains the canonical string so a
+ // future client update can re-derive the correct status via migration.
+ String? statusRaw;
late String requestDescription;
late String deliveryCountry;
late String? offerProductName;
diff --git a/lib/models/isar/models/shopinbit_ticket.g.dart b/lib/models/isar/models/shopinbit_ticket.g.dart
index ecd600a..0385ab2 100644
--- a/lib/models/isar/models/shopinbit_ticket.g.dart
+++ b/lib/models/isar/models/shopinbit_ticket.g.dart
@@ -131,8 +131,13 @@ const ShopInBitTicketSchema = CollectionSchema(
type: IsarType.byte,
enumMap: _ShopInBitTicketstatusEnumValueMap,
),
- r'ticketId': PropertySchema(
+ r'statusRaw': PropertySchema(
id: 22,
+ name: r'statusRaw',
+ type: IsarType.string,
+ ),
+ r'ticketId': PropertySchema(
+ id: 23,
name: r'ticketId',
type: IsarType.string,
),
@@ -229,6 +234,12 @@ int _shopInBitTicketEstimateSize(
bytesCount += 3 + object.shippingName.length * 3;
bytesCount += 3 + object.shippingPostalCode.length * 3;
bytesCount += 3 + object.shippingStreet.length * 3;
+ {
+ final value = object.statusRaw;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
bytesCount += 3 + object.ticketId.length * 3;
return bytesCount;
}
@@ -266,7 +277,8 @@ void _shopInBitTicketSerialize(
writer.writeString(offsets[19], object.shippingPostalCode);
writer.writeString(offsets[20], object.shippingStreet);
writer.writeByte(offsets[21], object.status.index);
- writer.writeString(offsets[22], object.ticketId);
+ writer.writeString(offsets[22], object.statusRaw);
+ writer.writeString(offsets[23], object.ticketId);
}
ShopInBitTicket _shopInBitTicketDeserialize(
@@ -310,7 +322,8 @@ ShopInBitTicket _shopInBitTicketDeserialize(
object.status =
_ShopInBitTicketstatusValueEnumMap[reader.readByteOrNull(offsets[21])] ??
ShopInBitOrderStatus.pending;
- object.ticketId = reader.readString(offsets[22]);
+ object.statusRaw = reader.readStringOrNull(offsets[22]);
+ object.ticketId = reader.readString(offsets[23]);
return object;
}
@@ -381,6 +394,8 @@ P _shopInBitTicketDeserializeProp<P>(
ShopInBitOrderStatus.pending)
as P;
case 22:
+ return (reader.readStringOrNull(offset)) as P;
+ case 23:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -3145,6 +3160,165 @@ extension ShopInBitTicketQueryFilter
});
}
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ const FilterCondition.isNull(property: r'statusRaw'),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ const FilterCondition.isNotNull(property: r'statusRaw'),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawEqualTo(String? value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.equalTo(
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.greaterThan(
+ include: include,
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.lessThan(
+ include: include,
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawBetween(
+ String? lower,
+ String? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.between(
+ property: r'statusRaw',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawStartsWith(String value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.startsWith(
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawEndsWith(String value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.endsWith(
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawContains(String value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.contains(
+ property: r'statusRaw',
+ value: value,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawMatches(String pattern, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.matches(
+ property: r'statusRaw',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.equalTo(property: r'statusRaw', value: ''),
+ );
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
+ statusRawIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(
+ FilterCondition.greaterThan(property: r'statusRaw', value: ''),
+ );
+ });
+ }
+
QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterFilterCondition>
ticketIdEqualTo(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -3595,6 +3769,20 @@ extension ShopInBitTicketQuerySortBy
});
}
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
+ sortByStatusRaw() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'statusRaw', Sort.asc);
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
+ sortByStatusRawDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'statusRaw', Sort.desc);
+ });
+ }
+
QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
sortByTicketId() {
return QueryBuilder.apply(this, (query) {
@@ -3917,6 +4105,20 @@ extension ShopInBitTicketQuerySortThenBy
});
}
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
+ thenByStatusRaw() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'statusRaw', Sort.asc);
+ });
+ }
+
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
+ thenByStatusRawDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'statusRaw', Sort.desc);
+ });
+ }
+
QueryBuilder<ShopInBitTicket, ShopInBitTicket, QAfterSortBy>
thenByTicketId() {
return QueryBuilder.apply(this, (query) {
@@ -4110,6 +4312,13 @@ extension ShopInBitTicketQueryWhereDistinct
});
}
+ QueryBuilder<ShopInBitTicket, ShopInBitTicket, QDistinct>
+ distinctByStatusRaw({bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'statusRaw', caseSensitive: caseSensitive);
+ });
+ }
+
QueryBuilder<ShopInBitTicket, ShopInBitTicket, QDistinct> distinctByTicketId({
bool caseSensitive = true,
}) {
@@ -4280,6 +4489,12 @@ extension ShopInBitTicketQueryProperty
});
}
+ QueryBuilder<ShopInBitTicket, String?, QQueryOperations> statusRawProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'statusRaw');
+ });
+ }
+
QueryBuilder<ShopInBitTicket, String, QQueryOperations> ticketIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'ticketId');
diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart
index f41aa49..aab436a 100644
--- a/lib/models/shopinbit/shopinbit_order_model.dart
+++ b/lib/models/shopinbit/shopinbit_order_model.dart
@@ -113,6 +113,19 @@ class ShopInBitOrderModel extends ChangeNotifier {
}
}
+ // The most recent raw API state string, persisted alongside _status so that
+ // we can recover from contract drift (renames / new states) without losing
+ // history. _status is the parsed/mapped value; _statusRaw is the source of
+ // truth straight from the API.
+ String? _statusRaw;
+ String? get statusRaw => _statusRaw;
+ set statusRaw(String? value) {
+ if (_statusRaw != value) {
+ _statusRaw = value;
+ notifyListeners();
+ }
+ }
+
String? _offerProductName;
String? get offerProductName => _offerProductName;
@@ -236,6 +249,7 @@ class ShopInBitOrderModel extends ChangeNotifier {
..displayName = _displayName
..category = _category ?? ShopInBitCategory.concierge
..status = _status
+ ..statusRaw = _statusRaw
..requestDescription = _requestDescription
..deliveryCountry = _deliveryCountry
..offerProductName = _offerProductName
@@ -271,6 +285,7 @@ class ShopInBitOrderModel extends ChangeNotifier {
.._apiTicketId = ticket.apiTicketId
.._ticketId = ticket.ticketId
.._status = ticket.status
+ .._statusRaw = ticket.statusRaw
.._requestDescription = ticket.requestDescription
.._deliveryCountry = ticket.deliveryCountry
.._offerProductName = ticket.offerProductName
@@ -298,7 +313,11 @@ class ShopInBitOrderModel extends ChangeNotifier {
.toList();
}
- static ShopInBitOrderStatus statusFromTicketState(TicketState state) {
+ // Returns null when the API state cannot be mapped (TicketState.unknown).
+ // Callers MUST treat null as "do not overwrite the locally stored status":
+ // silently coercing an unknown API state to a default (e.g. pending)
+ // would mask contract drift and look like data regression to the user.
+ static ShopInBitOrderStatus? statusFromTicketState(TicketState state) {
switch (state) {
case TicketState.newTicket:
return ShopInBitOrderStatus.pending;
@@ -323,6 +342,8 @@ class ShopInBitOrderModel extends ChangeNotifier {
return ShopInBitOrderStatus.cancelled;
case TicketState.refunded:
return ShopInBitOrderStatus.refunded;
+ case TicketState.unknown:
+ return null;
}
}
}
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 85ceb97..1ec9048 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -134,9 +134,13 @@ class _ShopInBitTicketDetailState extends State<ShopInBitTicketDetail> {
}
if (!statusResp.hasError && statusResp.value != null) {
- widget.model.status = ShopInBitOrderModel.statusFromTicketState(
+ final mapped = ShopInBitOrderModel.statusFromTicketState(
statusResp.value!.state,
);
+ // Always preserve the raw API string, even when mapping fails, so
+ // it can be recovered later.
+ widget.model.statusRaw = statusResp.value!.stateRaw;
+ if (mapped != null) widget.model.status = mapped;
}
if (widget.model.status == ShopInBitOrderStatus.offerAvailable &&
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index ce62d3b..208e382 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -127,9 +127,11 @@ class _ShopInBitTicketsViewState extends State<ShopInBitTicketsView> {
final statusResp = await service.client.getTicketStatus(ref.id);
if (statusResp.hasError || statusResp.value == null) continue;
- _tickets[localIdx].status = ShopInBitOrderModel.statusFromTicketState(
+ final mapped = ShopInBitOrderModel.statusFromTicketState(
statusResp.value!.state,
);
+ _tickets[localIdx].statusRaw = statusResp.value!.stateRaw;
+ if (mapped != null) _tickets[localIdx].status = mapped;
if (_tickets[localIdx].status == ShopInBitOrderStatus.offerAvailable &&
(_tickets[localIdx].offerProductName == null ||
diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart
index eec6dd3..9476da6 100644
--- a/lib/services/shopinbit/src/models/ticket.dart
+++ b/lib/services/shopinbit/src/models/ticket.dart
@@ -1,3 +1,5 @@
+import '../../../../utilities/logger.dart';
+
enum TicketState {
newTicket('NEW'),
checking('CHECKING'),
@@ -11,16 +13,25 @@ enum TicketState {
replyNeeded('REPLY NEEDED'),
closed('CLOSED'),
closedCancelled('CLOSED/CANCELLED'),
- merged('MERGED');
+ merged('MERGED'),
+ // Sentinel for any state string the API returns that this client does not
+ // recognise (e.g. the API added a new state, or renamed an existing one).
+ // Callers must handle this explicitly: treat as "do not trust", do not
+ // overwrite previously known good state with it.
+ unknown('UNKNOWN');
final String value;
const TicketState(this.value);
static TicketState fromString(String s) {
- return TicketState.values.firstWhere(
- (e) => e.value == s,
- orElse: () => TicketState.newTicket,
+ for (final e in TicketState.values) {
+ if (e.value == s) return e;
+ }
+ Logging.instance.w(
+ "ShopInBit: unrecognised TicketState '$s' from API: "
+ "mapping to TicketState.unknown",
);
+ return TicketState.unknown;
}
}
@@ -38,6 +49,10 @@ class TicketRef {
class TicketStatus {
final int ticketId;
final TicketState state;
+ // The raw 'state' string returned by the API. Preserved verbatim so that
+ // unknown / renamed states can be re-derived later via a client update,
+ // rather than being lost to TicketState.unknown.
+ final String stateRaw;
final DateTime updatedAt;
final DateTime? lastAgentMessageAt;
final String? paymentInvoiceStatus;
@@ -46,6 +61,7 @@ class TicketStatus {
TicketStatus({
required this.ticketId,
required this.state,
+ required this.stateRaw,
required this.updatedAt,
this.lastAgentMessageAt,
this.paymentInvoiceStatus,
@@ -53,9 +69,11 @@ class TicketStatus {
});
factory TicketStatus.fromJson(Map<String, dynamic> json) {
+ final rawState = json['state'] as String;
return TicketStatus(
ticketId: _toInt(json['ticket_id']),
- state: TicketState.fromString(json['state'] as String),
+ state: TicketState.fromString(rawState),
+ stateRaw: rawState,
updatedAt: DateTime.parse(json['updated_at'] as String),
lastAgentMessageAt: json['last_agent_message_at'] != null
? DateTime.parse(json['last_agent_message_at'] as String)
diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart
index 7bf4169..e1ff040 100644
--- a/lib/services/shopinbit/src/models/webhook_event.dart
+++ b/lib/services/shopinbit/src/models/webhook_event.dart
@@ -1,15 +1,25 @@
+import '../../../../utilities/logger.dart';
+
enum WebhookEventType {
ticketStateChanged('ticket.state_changed'),
- ticketMessageCreated('ticket.message_created');
+ ticketMessageCreated('ticket.message_created'),
+ // Sentinel for any webhook event_type the API sends that this client does
+ // not recognise. Callers MUST drop these events rather than dispatch them:
+ // coercing an unknown event onto a known handler is worse than ignoring it.
+ unknown('UNKNOWN');
final String value;
const WebhookEventType(this.value);
static WebhookEventType fromString(String s) {
- return WebhookEventType.values.firstWhere(
- (e) => e.value == s,
- orElse: () => WebhookEventType.ticketStateChanged,
+ for (final e in WebhookEventType.values) {
+ if (e.value == s) return e;
+ }
+ Logging.instance.w(
+ "ShopInBit: unrecognised WebhookEventType '$s' from API: "
+ "mapping to WebhookEventType.unknown (event will be dropped)",
);
+ return WebhookEventType.unknown;
}
}
Why this scored 37/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.