fix(shopinbit): migrate car research flow to API v1.0.6
What changed, and why it matters
This commit updates the Stack Wallet app's 'ShopinBit car research' feature to work with a newer version of the partner API (v1.0.6). It mainly changes how the app checks whether a customer has paid the car-research fee and how it finds the resulting support ticket. The old code relied on a separate 'log payment' API call that is being removed; the new code reads the payment/ticket status directly from a status endpoint. There is no obvious security bug being fixed, but the change removes a workaround where payment confirmation was partly driven by the client calling a logging endpoint.
Treat as a routine API-migration commit. Reviewers should verify that the new status polling correctly handles all terminal states and that removing the log-payment endpoint does not create a race condition where a paid invoice is never marked finalized. No immediate security response is indicated by the diff alone.
Security signals we found
Removes client-side 'log payment' call that previously triggered server-side finalization/receipt creation
Adds typed status model with nullable ticket IDs and defensive int parsing
Makes CarResearchRequest required when creating the invoice, preventing a previously optional parameter that caused a 422
Adds fallback by-customer ticket lookup with explicit exclusion of the receipt ticket id
No explicit security advisory, CVE, or researcher attribution in commit or supplied references
Evidence from the diff
The diff migrates the car-research flow from a client-driven payment-logging model to a polling model against GET /car-research/invoice/{id}/status. Key changes: (1) createCarResearchInvoice now requires a non-null CarResearchRequest; (2) getCarResearchInvoiceStatus returns a typed CarResearchInvoiceStatus with a finalized boolean and optional realTicketId/receiptTicketId; (3) logCarResearchPayment and the CarResearchPaymentResult model are deleted; (4) the UI now trusts the finalized flag and falls back to a by-customer ticket scan using the receipt ticket id. The commit is framed as a migration/fix, not a security patch.
Changed components
lib/pages/shopinbit/shopinbit_car_fee_view.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/services/shopinbit/shopinbit_service.dartlib/services/shopinbit/src/client.dartlib/services/shopinbit/src/models/car_research.dartInspect captured patch +106 / −75
diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
index 60ea3f4..c297be0 100644
--- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
@@ -315,9 +315,8 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
}
Future<void> _loadFee(CarResearchInvoice invoice) async {
- // Keep status call for visibility into any future API changes surfacing
- // a fee field. Today the endpoint returns only {status, additional}, so
- // we source the displayed amount from the BIP21 payment URIs instead.
+ // Still hit status for logging; it has no fee field, so the amount comes
+ // from the BIP21 payment URIs.
try {
final resp = await ref
.read(pShopinBitService)
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index d8f78bd..08c81eb 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -50,10 +50,15 @@ class _ShopInBitCarResearchPaymentViewState
static const Duration _kMaxPollInterval = Duration(seconds: 120);
Duration _pollInterval = _kBasePollInterval;
- Map<String, dynamic>? _status;
+ CarResearchInvoiceStatus? _status;
_PaymentFlowState _flowState = _PaymentFlowState.idle;
String _statusString = "ready_to_pay";
String? _additional;
+ bool _finalized = false;
+ // From the finalized status: the real ticket is the customer chat, the
+ // receipt is just the paid-fee receipt.
+ int? _realTicketId;
+ int? _receiptTicketId;
List<String> _methods = [];
List<String> _addresses = [];
int _selectedMethod = 0;
@@ -61,7 +66,9 @@ class _ShopInBitCarResearchPaymentViewState
String get _currentAddress =>
_selectedMethod < _addresses.length ? _addresses[_selectedMethod] : "";
- bool get _isTerminal => carResearchIsFinalized(_statusString, _additional);
+ // Trust the `finalized` flag; fall back to the status/additional heuristic.
+ bool get _isTerminal =>
+ _finalized || carResearchIsFinalized(_statusString, _additional);
bool get _payNowEnabled =>
!_isTerminal && _flowState == _PaymentFlowState.idle;
@@ -145,10 +152,9 @@ class _ShopInBitCarResearchPaymentViewState
}
String get _displayedFee {
- // API status endpoint does not expose a fee field (confirmed: returns
- // only {status, additional}). Parse the amount from the BIP21 payment
- // URI for the currently-selected method, fall back to the 223.00 EUR
- // business-rule value if no parse succeeds.
+ // The status endpoint has no fee field, so parse the amount from the
+ // selected method's BIP21 URI, falling back to the 223.00 EUR business
+ // rule.
final links = widget.invoice.paymentLinks;
if (_selectedMethod < _methods.length) {
final methodKey = _methods[_selectedMethod];
@@ -339,8 +345,13 @@ class _ShopInBitCarResearchPaymentViewState
);
setState(() {
_status = resp.value!;
- _statusString = _status!["status"]?.toString() ?? _statusString;
- _additional = _status!["additional"]?.toString();
+ _statusString = _status!.status.isNotEmpty
+ ? _status!.status
+ : _statusString;
+ _additional = _status!.additional;
+ _finalized = _status!.finalized;
+ _realTicketId = _status!.realTicketId;
+ _receiptTicketId = _status!.receiptTicketId;
});
if (_isTerminal) {
_pollTimer?.cancel();
@@ -377,38 +388,29 @@ class _ShopInBitCarResearchPaymentViewState
_pollTimer?.cancel();
final service = ref.read(pShopinBitService);
- final client = service.client;
try {
- // Best-effort: the BTCPay webhook is the failsafe that finalizes the fee
- // and creates the receipt and real car ticket even if this call fails.
- final logResp = await client.logCarResearchPayment(
- widget.invoice.btcpayInvoice,
- );
-
- if (logResp.hasError || logResp.value == null) {
- // Payment is confirmed but we could not log it. The webhook will
- // finalize it server side, so offer the user a shortcut to their
- // requests where the finalized ticket will appear.
- await _showFinalizingFallback();
- return;
- }
-
- final result = logResp.value!;
-
- // 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
- // for a while since it can take a beat to show up in by-customer. Back
- // off between tries (2s, 4s, 8s... capped at 15s) so we don't hammer the
- // by-customer endpoint while we wait.
- int? realId;
+ // The finalized status usually gives us the real car ticket id (the
+ // customer chat), so open that. It can be null for a bit (sandbox, or
+ // while the ticket is still being created), so fall back to by-customer,
+ // using the receipt id to skip the receipt ticket. The BTCPay webhook
+ // creates the ticket either way.
+ int? realId = _realTicketId;
const int maxAttempts = 8;
for (
int attempt = 0;
attempt < maxAttempts && realId == null;
attempt++
) {
- realId = await service.adoptRealCarTicket(result.ticketId);
+ // Re-poll the status first in case the real ticket id has appeared.
+ final statusResp = await service.client.getCarResearchInvoiceStatus(
+ widget.invoice.btcpayInvoice,
+ );
+ realId = statusResp.value?.realTicketId;
+ // Fall back to the by-customer heuristic, excluding the receipt id.
+ realId ??= await service.adoptRealCarTicket(
+ statusResp.value?.receiptTicketId ?? _receiptTicketId ?? 0,
+ );
if (realId == null && attempt < maxAttempts - 1) {
final int seconds = (1 << (attempt + 1)).clamp(2, 15).toInt();
await Future<void>.delayed(Duration(seconds: seconds));
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index b9f7a37..0061eca 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -181,10 +181,14 @@ 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.
+ /// Fallback for finding the real car research ticket when the status endpoint
+ /// hasn't populated real_ticket_id yet (sandbox, or briefly while the ticket
+ /// is being created).
+ ///
+ /// The fee receipt id can't be polled by the customer key (403s); the real
+ /// ticket is a separate id that shows up in by-customer. Grab the newest
+ /// ticket we don't already track (not the receipt), hydrate it, and return
+ /// its id, or null if it's not there yet.
Future<int?> adoptRealCarTicket(int receiptTicketId) async {
final String key = await ensureCustomerKey();
final ApiResponse<List<TicketRef>> resp = await _ticketsByCustomer(key);
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index f3909ee..8eb3855 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -358,16 +358,20 @@ class ShopInBitClient {
// -- Car Research Fee --
+ /// Create the car research fee invoice. Both [billing] and [request] are
+ /// required; without a request the server returns 422 and creates nothing.
+ /// The stored request lets the backend build the customer-facing car ticket
+ /// once the fee is paid.
Future<ApiResponse<CarResearchInvoice>> createCarResearchInvoice({
required Address billing,
- CarResearchRequest? request,
+ required CarResearchRequest request,
}) async {
return _request(
'POST',
'/car-research/invoice',
body: {
'billing': billing.toJson(),
- if (request != null) 'request': request.toJson(),
+ 'request': request.toJson(),
if (_externalCustomerKey != null)
'external_customer_key': _externalCustomerKey,
},
@@ -399,28 +403,16 @@ class ShopInBitClient {
);
}
- Future<ApiResponse<Map<String, dynamic>>> getCarResearchInvoiceStatus(
+ /// Poll the car research invoice status. Read-only: it never confirms
+ /// payment. Once [CarResearchInvoiceStatus.finalized] is true the response
+ /// carries the receipt and real ticket references.
+ Future<ApiResponse<CarResearchInvoiceStatus>> getCarResearchInvoiceStatus(
String invoiceId,
) async {
return _request(
'GET',
'/car-research/invoice/$invoiceId/status',
- parse: (json) => json,
- );
- }
-
- Future<ApiResponse<CarResearchPaymentResult>> logCarResearchPayment(
- String invoiceId,
- ) async {
- return _request(
- 'POST',
- '/car-research/log-payment',
- body: {
- 'invoice_id': invoiceId,
- if (_externalCustomerKey != null)
- 'external_customer_key': _externalCustomerKey,
- },
- parse: CarResearchPaymentResult.fromJson,
+ parse: CarResearchInvoiceStatus.fromJson,
);
}
diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart
index 93a1985..8a8a9a7 100644
--- a/lib/services/shopinbit/src/models/car_research.dart
+++ b/lib/services/shopinbit/src/models/car_research.dart
@@ -57,9 +57,12 @@ class CarResearchCurrentInvoice {
}
}
-/// 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.
+/// Whether a car research invoice status counts as paid/finalized.
+///
+/// Prefer the `finalized` boolean from the status endpoint (see
+/// [CarResearchInvoiceStatus.finalized]). This is the fallback for the raw
+/// status/additional strings: Processing, Settled, or Expired with PaidLate,
+/// plus lowercase values for older concierge-style statuses.
bool carResearchIsFinalized(String? status, String? additional) {
final s = (status ?? '').toLowerCase().trim();
final a = (additional ?? '').toLowerCase().trim();
@@ -100,25 +103,56 @@ class CarResearchInvoice {
}
}
-class CarResearchPaymentResult {
+/// Result of GET /car-research/invoice/{invoice_id}/status.
+///
+/// Read-only: it never confirms payment, so poll until [finalized] is true.
+/// Once finalized it carries the created ticket references:
+///
+/// * [realTicketId] / [realTicketNumber]: the customer-facing car research
+/// chat. Open this for the customer after payment.
+/// * [receiptTicketId] / [receiptTicketNumber]: the paid-fee receipt only;
+/// do NOT use it as the active customer chat.
+///
+/// The sandbox populates only the receipt references and leaves the real ticket
+/// fields null, so [realTicketId] is nullable.
+class CarResearchInvoiceStatus {
final String status;
- final int ticketId;
- final String ticketNumber;
- final String externalCustomerKey;
+ final String? additional;
+ final bool finalized;
+ final int? receiptTicketId;
+ final String? receiptTicketNumber;
+ final int? realTicketId;
+ final String? realTicketNumber;
+ final String? externalCustomerKey;
- CarResearchPaymentResult({
+ CarResearchInvoiceStatus({
required this.status,
- required this.ticketId,
- required this.ticketNumber,
- required this.externalCustomerKey,
+ this.additional,
+ required this.finalized,
+ this.receiptTicketId,
+ this.receiptTicketNumber,
+ this.realTicketId,
+ this.realTicketNumber,
+ this.externalCustomerKey,
});
- factory CarResearchPaymentResult.fromJson(Map<String, dynamic> json) {
- return CarResearchPaymentResult(
- status: json['status'] as String,
- ticketId: int.tryParse(json['ticket_id'].toString()) ?? 0,
- ticketNumber: json['ticket_number'] as String,
- externalCustomerKey: json['external_customer_key'] as String,
+ factory CarResearchInvoiceStatus.fromJson(Map<String, dynamic> json) {
+ int? toIntOrNull(dynamic v) {
+ if (v == null) return null;
+ if (v is int) return v;
+ if (v is double) return v.toInt();
+ return int.tryParse(v.toString());
+ }
+
+ return CarResearchInvoiceStatus(
+ status: json['status']?.toString() ?? '',
+ additional: json['additional']?.toString(),
+ finalized: json['finalized'] == true,
+ receiptTicketId: toIntOrNull(json['receipt_ticket_id']),
+ receiptTicketNumber: json['receipt_ticket_number']?.toString(),
+ realTicketId: toIntOrNull(json['real_ticket_id']),
+ realTicketNumber: json['real_ticket_number']?.toString(),
+ externalCustomerKey: json['external_customer_key']?.toString(),
);
}
}
Why this scored 16/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.