fix(shopinbit): surface parse errors for required ticket fields
What changed, and why it matters
This commit changes how the Stack Wallet app parses data from its ShopInBit partner service. Previously, missing or malformed fields were silently replaced with empty strings, zero, or the current date/time. Now the app throws visible errors instead. That is generally a good defensive change, but it removes some safety comments and fallback behavior for unknown ticket states, and it makes parsing stricter. The patch is small and appears aimed at surfacing bugs rather than introducing a vulnerability, but it is only a partial hardening of the parsing layer.
Treat as a routine hardening commit. Review whether the stricter casts are wrapped by adequate error handling upstream so users see graceful failures rather than crashes. Re-add or preserve the defensive design documentation for the TicketState.unknown sentinel, and consider adding unit tests for malformed API responses. No urgent security response is indicated by the diff alone.
Security signals we found
Stricter JSON parsing with explicit exceptions instead of silent fallback values
Removal of defensive comments describing unknown-state sentinel handling
Direct 'as String' casts on fields that previously tolerated null/malformed values
No new validation, authentication, or cryptographic controls added
Small, localized change in a third-party integration model layer
Evidence from the diff
The patch tightens JSON deserialization in lib/services/shopinbit/src/models/ticket.dart and voucher.dart. It replaces lenient casts/fallbacks (e.g. json[‘number’]?.toString() ?? ‘’, DateTime.tryParse(… ) ?? DateTime.now(), int.tryParse(…) ?? 0) with direct casts and explicit FormatException throws in _toInt. It also removes documentation comments that described the ‘unknown’ TicketState sentinel and the stateRaw preservation mechanism. The change makes parse failures visible, which helps detect API contract mismatches, but it does not add input validation, sanitization, or bounds checks beyond throwing on bad types. The removal of comments does not change runtime behavior, but it weakens future maintainability of the defensive sentinel design.
Changed components
lib/services/shopinbit/src/models/ticket.dartlib/services/shopinbit/src/models/voucher.dartShopInBit ticket/voucher deserialization logicInspect captured patch +14 / −24
diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart
index 63ad123..174b6ac 100644
--- a/lib/services/shopinbit/src/models/ticket.dart
+++ b/lib/services/shopinbit/src/models/ticket.dart
@@ -14,10 +14,6 @@ enum TicketState {
closed('CLOSED'),
closedCancelled('CLOSED/CANCELLED'),
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;
@@ -51,10 +47,7 @@ class TicketRef {
TicketRef({required this.id, required this.number});
factory TicketRef.fromJson(Map<String, dynamic> json) {
- return TicketRef(
- id: _toInt(json['id']),
- number: json['number']?.toString() ?? '',
- );
+ return TicketRef(id: _toInt(json['id']), number: json['number'] as String);
}
Map<String, dynamic> toMap() {
@@ -68,9 +61,6 @@ 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;
@@ -88,17 +78,15 @@ class TicketStatus {
});
factory TicketStatus.fromJson(Map<String, dynamic> json) {
- final rawState = (json['state'] ?? '') as String;
+ final rawState = json['state'] as String;
return TicketStatus(
ticketId: _toInt(json['ticket_id']),
state: TicketState.fromString(rawState),
stateRaw: rawState,
- updatedAt:
- DateTime.tryParse(json['updated_at']?.toString() ?? '') ??
- DateTime.now(),
- lastAgentMessageAt: DateTime.tryParse(
- json['last_agent_message_at']?.toString() ?? '',
- ),
+ updatedAt: DateTime.parse(json['updated_at'] as String),
+ lastAgentMessageAt: json['last_agent_message_at'] != null
+ ? DateTime.parse(json['last_agent_message_at'] as String)
+ : null,
paymentInvoiceStatus: json['payment_invoice_status'] as String?,
trackingLink: json['tracking_link'] as String?,
);
@@ -147,7 +135,7 @@ class TicketFull {
factory TicketFull.fromJson(Map<String, dynamic> json) {
return TicketFull(
id: _toInt(json['id']),
- number: json['number']?.toString() ?? '',
+ number: json['number'] as String,
productName: json['product_name'] as String?,
customerPrice: json['customer_price'] as String?,
partnerPrice: json['partner_price'] as String?,
@@ -155,9 +143,7 @@ class TicketFull {
netPurchasePrice: json['net_purchase_price'] as String?,
netShippingCosts: json['net_shipping_costs'] as String?,
deliveryCountry:
- json['delivery_country'] as String? ??
- json['deliverycountry'] as String? ??
- '',
+ (json['delivery_country'] ?? json['deliverycountry']) as String,
vatRate: int.tryParse(json['vat_rate'].toString()),
);
}
@@ -183,5 +169,9 @@ class TicketFull {
int _toInt(dynamic value) {
if (value is int) return value;
- return int.tryParse(value.toString()) ?? 0;
+ final parsed = int.tryParse(value.toString());
+ if (parsed == null) {
+ throw FormatException("ShopInBit: expected an integer, got '$value'");
+ }
+ return parsed;
}
diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart
index e65b304..97d048a 100644
--- a/lib/services/shopinbit/src/models/voucher.dart
+++ b/lib/services/shopinbit/src/models/voucher.dart
@@ -62,7 +62,7 @@ class VipRedemptionResult {
return VipRedemptionResult(
ticketId: json['ticket_id'] is int
? json['ticket_id'] as int
- : int.tryParse(json['ticket_id'].toString()) ?? 0,
+ : int.parse(json['ticket_id'].toString()),
ticketNumber: json['ticket_number'] as String,
externalCustomerKey: json['external_customer_key'] as String,
voucherCode: json['voucher_code'] as String,
Why this scored 29/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.