feat(shopinbit): add car request payload and invoice recovery to client
What changed, and why it matters
This commit adds two new features to the ShopInBit integration in Stack Wallet: it lets users attach a car-research request to a fee invoice, and it lets them retrieve any unpaid car-research invoices so they can finish paying. The code itself is a normal feature addition and does not appear to introduce an obvious security vulnerability. The main thing to watch is that the new API calls send personal/pseudonymous data and payment links over the network, so they rely on the existing HTTPS and authentication plumbing being correct.
Treat as a routine feature commit. As a defensive check, reviewers should verify that _request/_requestRaw enforces TLS certificate validation, attaches authentication correctly, and does not log the new request body or payment links. Consider adding server-response schema validation and graceful handling of unexpected JSON shapes to harden the new endpoints.
Security signals we found
New network API surface added (POST /car-research/invoice with optional request body, GET /car-research/invoices/current)
User-provided strings (customerPseudonym, comment, deliveryCountry) serialized to JSON and sent to backend
Payment links parsed from server response and stored in Map<String, String>
Status normalization helper uses lowercase string comparison for invoice lifecycle states
No input validation/sanitization visible in the new model code
No secrets, tokens, or credentials added in the diff
Evidence from the diff
The patch extends lib/services/shopinbit/src/client.dart with an optional CarResearchRequest payload in createCarResearchInvoice and a new getCurrentCarResearchInvoices() endpoint consumer. It also adds model classes and a status-normalization helper in lib/services/shopinbit/src/models/car_research.dart. There is no direct evidence in the diff of injection, broken auth, cryptographic, or secret-exposure flaws. The new code reuses the existing _request/_requestRaw HTTP client, so its security posture depends on that client’s TLS, header, and token handling. The fromJson parsing uses casts and DateTime.tryParse, which could throw or mis-parse malformed server responses, but these are robustness issues rather than clearly exploitable security bugs.
Changed components
lib/services/shopinbit/src/client.dartlib/services/shopinbit/src/models/car_research.dartShopInBit car research / concierge featureInspect captured patch +106 / −0
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index ad695e2..1ca8197 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -355,12 +355,14 @@ class ShopInBitClient {
Future<ApiResponse<CarResearchInvoice>> createCarResearchInvoice({
required Address billing,
+ CarResearchRequest? request,
}) async {
return _request(
'POST',
'/car-research/invoice',
body: {
'billing': billing.toJson(),
+ if (request != null) 'request': request.toJson(),
if (_externalCustomerKey != null)
'external_customer_key': _externalCustomerKey,
},
@@ -368,6 +370,31 @@ class ShopInBitClient {
);
}
+ /// Unresolved car research invoices for the current partner/customer pair.
+ /// Used to recover a fee payment the user started but did not finish.
+ Future<ApiResponse<List<CarResearchCurrentInvoice>>>
+ getCurrentCarResearchInvoices() async {
+ return _requestRaw(
+ 'GET',
+ '/car-research/invoices/current',
+ parse: (body) {
+ if (body.isEmpty) return <CarResearchCurrentInvoice>[];
+ final decoded = jsonDecode(body);
+ final list = decoded is List
+ ? decoded
+ : (decoded as Map<String, dynamic>)['invoices'] as List? ??
+ const [];
+ return list
+ .map(
+ (e) => CarResearchCurrentInvoice.fromJson(
+ e as Map<String, dynamic>,
+ ),
+ )
+ .toList();
+ },
+ );
+ }
+
Future<ApiResponse<Map<String, dynamic>>> getCarResearchInvoiceStatus(
String invoiceId,
) async {
diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart
index ea1eceb..e5bf15b 100644
--- a/lib/services/shopinbit/src/models/car_research.dart
+++ b/lib/services/shopinbit/src/models/car_research.dart
@@ -1,3 +1,82 @@
+/// Optional request payload cached with a car research fee invoice. When
+/// provided, the backend creates the real car research ticket itself after the
+/// fee is paid (the BTCPay webhook failsafe), so the client does not have to.
+class CarResearchRequest {
+ final String customerPseudonym;
+ final String comment;
+ final String deliveryCountry;
+
+ CarResearchRequest({
+ required this.customerPseudonym,
+ required this.comment,
+ required this.deliveryCountry,
+ });
+
+ Map<String, dynamic> toJson() => {
+ 'customer_pseudonym': customerPseudonym,
+ 'comment': comment,
+ 'delivery_country': deliveryCountry,
+ };
+}
+
+/// An unresolved car research invoice returned by
+/// GET /car-research/invoices/current, used to recover a payment the user
+/// started but did not finish.
+class CarResearchCurrentInvoice {
+ final String invoiceId;
+ final String status;
+ final String? additional;
+ final DateTime? expiresAt;
+ final Map<String, String> paymentLinks;
+ final bool hasRequestPayload;
+ final DateTime? createdAt;
+
+ CarResearchCurrentInvoice({
+ required this.invoiceId,
+ required this.status,
+ required this.additional,
+ required this.expiresAt,
+ required this.paymentLinks,
+ required this.hasRequestPayload,
+ required this.createdAt,
+ });
+
+ factory CarResearchCurrentInvoice.fromJson(Map<String, dynamic> json) {
+ final linksRaw = json['payment_links'] as Map<String, dynamic>? ?? {};
+ final expiresRaw = json['expires_at'] as String?;
+ final createdRaw = json['created_at'] as String?;
+ return CarResearchCurrentInvoice(
+ invoiceId: json['invoice_id'] as String,
+ status: json['status'] as String? ?? '',
+ additional: json['additional'] as String?,
+ expiresAt: expiresRaw == null ? null : DateTime.tryParse(expiresRaw),
+ paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)),
+ hasRequestPayload: json['has_request_payload'] as bool? ?? false,
+ createdAt: createdRaw == null ? null : DateTime.tryParse(createdRaw),
+ );
+ }
+}
+
+/// Whether a car research invoice status counts as paid/finalized per the
+/// ShopinBit 1.0.4 rules: Processing, Settled, or Expired with PaidLate. The
+/// extra lowercase values keep older concierge-style statuses working.
+bool carResearchIsFinalized(String? status, String? additional) {
+ final s = (status ?? '').toLowerCase().trim();
+ final a = (additional ?? '').toLowerCase().trim();
+ if (s == 'processing' || s == 'settled') return true;
+ if (s == 'expired' && a == 'paidlate') return true;
+ return const {
+ 'paid',
+ 'paid_over',
+ 'paid_late',
+ 'payment_processing',
+ 'confirmed',
+ 'complete',
+ 'completed',
+ 'finalized',
+ }.contains(s);
+}
+
class CarResearchInvoice {
final String btcpayInvoice;
final DateTime expiresAt;
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.