fix: race condition when refreshing all shopinbit tickets when not all tickets use the same customer key. Probably introduces bugs elsewhere now though...
What changed, and why it matters
This commit fixes a race condition in the Stack Wallet app's ShopinBit feature. Previously, the app stored one shared 'customer key' on the API client object. When refreshing multiple tickets that belonged to different customer keys, one ticket's key could overwrite another's mid-refresh, causing requests to be sent with the wrong key. The fix passes the correct customer key alongside each individual request instead of relying on shared state. The commit message notes the fix may introduce other bugs elsewhere.
Review the patch for completeness: verify every call site that now needs a customer key has been updated, and that no code path still mutates or relies on shared key state. Test concurrent ticket refreshes with mixed customer keys. Because the author warns of possible new bugs, run regression tests on all ShopinBit flows including car research, voucher checks, attachments, and webhooks.
Security signals we found
Race condition in multi-tenant/ticket API key handling
Shared mutable authentication state removed from client object
Per-request customer key now passed explicitly
Potential for wrong-customer-key requests before patch (information disclosure or cross-account access)
Commit author acknowledges possible regressions elsewhere
Evidence from the diff
The change removes the mutable externalCustomerKey setter on ShopInBitClient and instead requires a customerKey parameter on nearly every API method. Callers now read the per-ticket key from the local database and pass it explicitly. This prevents cross-request key contamination during concurrent refreshes of tickets that do not share the same customer key. The commit also changes resumable car-research invoice recovery from a single invoice to a list, and updates route argument types to carry the customer key through navigation.
Changed components
lib/services/shopinbit/src/client.dartlib/services/shopinbit/shopinbit_service.dartlib/pages/shopinbit/shopinbit_tickets_view.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_shipping_view.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages/shopinbit/shopinbit_car_fee_view.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/route_generator.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dartlib/services/shopinbit/src/api_response.dartInspect captured patch +231 / −118
diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
index 60ea3f4..9bb863e 100644
--- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
@@ -202,7 +202,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
if (_submitting) return;
setState(() => _submitting = true);
try {
- await ref.read(pShopinBitService).ensureCustomerKey();
+ final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
// Delivery address (always provided)
final deliveryName = _splitFullName(_nameController.text);
@@ -242,7 +242,11 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
final resp = await ref
.read(pShopinBitService)
.client
- .createCarResearchInvoice(billing: billing, request: request);
+ .createCarResearchInvoice(
+ billing: billing,
+ request: request,
+ customerKey: customerKey,
+ );
if (resp.hasError || resp.value == null) {
Logging.instance.e(
@@ -273,14 +277,14 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
// `GET /car-research/invoices/current` (see the requests list).
// Best-effort fee fetch; do not block navigation on fee parse failure.
- await _loadFee(invoice);
+ await _loadFee(invoice, customerKey);
if (!mounted) return;
unawaited(
Navigator.of(context).pushNamed(
ShopInBitCarResearchPaymentView.routeName,
- arguments: invoice,
+ arguments: (invoice: invoice, customerKey: customerKey),
),
);
} catch (e, s) {
@@ -314,7 +318,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
}
}
- Future<void> _loadFee(CarResearchInvoice invoice) async {
+ Future<void> _loadFee(CarResearchInvoice invoice, String customerKey) 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.
@@ -322,7 +326,10 @@ class _ShopInBitCarFeeViewState extends ConsumerState<ShopInBitCarFeeView> {
final resp = await ref
.read(pShopinBitService)
.client
- .getCarResearchInvoiceStatus(invoice.btcpayInvoice);
+ .getCarResearchInvoiceStatus(
+ invoice.btcpayInvoice,
+ customerKey: customerKey,
+ );
if (resp.hasError || resp.value == null) {
Logging.instance.i(
"CarResearch status response (car_fee_view): error "
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index eb1137b..f8367ab 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -30,11 +30,16 @@ import 'shopinbit_tickets_view.dart';
enum _PaymentFlowState { idle, polling, finalizing, complete, error }
class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget {
- const ShopInBitCarResearchPaymentView({super.key, required this.invoice});
+ const ShopInBitCarResearchPaymentView({
+ super.key,
+ required this.invoice,
+ required this.customerKey,
+ });
static const String routeName = "/shopInBitCarResearchPayment";
final CarResearchInvoice invoice;
+ final String customerKey;
@override
ConsumerState<ShopInBitCarResearchPaymentView> createState() =>
@@ -221,7 +226,10 @@ class _ShopInBitCarResearchPaymentViewState
final resp = await ref
.read(pShopinBitService)
.client
- .getCarResearchInvoiceStatus(widget.invoice.btcpayInvoice);
+ .getCarResearchInvoiceStatus(
+ widget.invoice.btcpayInvoice,
+ customerKey: widget.customerKey,
+ );
if (resp.hasError || resp.value == null) {
if (mounted) {
unawaited(
@@ -288,6 +296,7 @@ class _ShopInBitCarResearchPaymentViewState
// and creates the receipt and real car ticket even if this call fails.
final logResp = await client.logCarResearchPayment(
widget.invoice.btcpayInvoice,
+ customerKey: widget.customerKey,
);
if (logResp.hasError || logResp.value == null) {
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index 93a6974..af6974d 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_config.dart';
import '../../models/isar/models/ethereum/eth_contract.dart';
-import '../../providers/global/shopin_bit_service_provider.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
+import '../../services/shopinbit/src/client.dart';
import '../../services/shopinbit/src/models/payment.dart';
import '../../services/wallets.dart';
import '../../themes/stack_colors.dart';
@@ -229,18 +229,24 @@ Future<bool> tryNavigateToShopInBitWalletSend({
// recovery" guidance; PUT (which regenerates) only when GET shows none.
// Returns null on any failure so the view can fall back to polling.
Future<PaymentInfo?> fetchShopInBitPaymentInfo(
- WidgetRef ref,
+ ShopInBitClient client,
int apiTicketId,
+ String customerKey,
) async {
try {
- final client = ref.read(pShopinBitService).client;
- final getResp = await client.getPayment(apiTicketId);
+ final getResp = await client.getPayment(
+ apiTicketId,
+ customerKey: customerKey,
+ );
if (!getResp.hasError &&
getResp.value != null &&
getResp.value!.paymentLinks.isNotEmpty) {
return getResp.value;
}
- final putResp = await client.putPayment(apiTicketId);
+ final putResp = await client.putPayment(
+ apiTicketId,
+ customerKey: customerKey,
+ );
if (!putResp.hasError && putResp.value != null) {
return putResp.value;
}
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index 97888d0..2ad8237 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -74,6 +74,18 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
bool get _payNowEnabled => !_isExpiredOrInvalid && !_isTerminal;
+ String? _customerKeyCache;
+
+ Future<String> get _customerKey async {
+ _customerKeyCache ??=
+ (await ref
+ .read(pSharedDrift)
+ .shopInBitTicketsDao
+ .getByApiId(widget.apiTicketId))!
+ .customerKey;
+ return _customerKeyCache!;
+ }
+
@override
void initState() {
super.initState();
@@ -111,7 +123,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
final resp = await ref
.read(pShopinBitService)
.client
- .getPayment(widget.apiTicketId);
+ .getPayment(widget.apiTicketId, customerKey: await _customerKey);
if (!resp.hasError && resp.value != null && mounted) {
setState(() => _applyPaymentInfo(resp.value!));
if (_isTerminal) {
@@ -123,11 +135,15 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
Future<void> _refreshInvoice() async {
_pollTimer?.cancel();
+
+ final customerKey = await _customerKey;
+ if (!mounted) return;
+
final resp = await showLoading(
whileFuture: ref
.read(pShopinBitService)
.client
- .putPayment(widget.apiTicketId),
+ .putPayment(widget.apiTicketId, customerKey: customerKey),
context: context,
message: "Refreshing invoice",
);
@@ -140,11 +156,15 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
Future<void> _checkForPayment() async {
_pollTimer?.cancel();
+
+ final customerKey = await _customerKey;
+ if (!mounted) return;
+
final resp = await showLoading(
whileFuture: ref
.read(pShopinBitService)
.client
- .getPayment(widget.apiTicketId),
+ .getPayment(widget.apiTicketId, customerKey: customerKey),
context: context,
message: "Checking for payment",
);
diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart
index 1f3c412..82914b8 100644
--- a/lib/pages/shopinbit/shopinbit_shipping_view.dart
+++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
+import '../../providers/db/drift_provider.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
import '../../services/shopinbit/src/models/address.dart';
import '../../services/shopinbit/src/models/payment.dart';
@@ -195,6 +196,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
);
}
+ final thisTicket = await ref
+ .read(pSharedDrift)
+ .shopInBitTicketsDao
+ .getByApiId(widget.apiTicketId);
+
final resp = await ref
.read(pShopinBitService)
.client
@@ -209,6 +215,7 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
country: country,
),
billing: billingAddress,
+ customerKey: thisTicket!.customerKey,
);
if (resp.hasError) {
@@ -216,7 +223,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
debugPrint("submitAddress failed: ${resp.exception?.message}");
}
- paymentInfo = await fetchShopInBitPaymentInfo(ref, widget.apiTicketId);
+ paymentInfo = await fetchShopInBitPaymentInfo(
+ ref.read(pShopinBitService).client,
+ widget.apiTicketId,
+ thisTicket.customerKey,
+ );
} catch (e) {
debugPrint("submitAddress threw: $e");
} finally {
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 364407a..e3af19c 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import '../../db/drift/shared_db/shared_database.dart';
import '../../models/shopinbit/shopinbit_enums.dart';
+import '../../providers/db/drift_provider.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
import '../../services/shopinbit/src/models/message.dart';
import '../../themes/stack_colors.dart';
@@ -97,7 +98,13 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
_messageController.clear();
try {
- final ok = await ref.read(pShopinBitService).sendMessage(_id, text);
+ final thisTicket = await ref
+ .read(pSharedDrift)
+ .shopInBitTicketsDao
+ .getByApiId(_id);
+ final ok = await ref
+ .read(pShopinBitService)
+ .sendMessage(_id, text, thisTicket!.customerKey);
if (ok) {
// Pull the server's copy into the DB row, then drop our optimistic one.
await _refresh();
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index 3f941d5..87c9927 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -38,10 +38,10 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
bool _refreshing = false;
bool _resuming = false;
- // An unfinished car research fee invoice recovered from the server, if any.
+ // Some unfinished car research fee invoices recovered from the server, if any.
// The fee is paid before any ticket exists, so this is the only way to let
// the user resume it — there is no local "pending" row anymore.
- CarResearchInvoice? _resumableInvoice;
+ List<CarResearchInvoice>? _resumableInvoices;
@override
void initState() {
@@ -65,12 +65,13 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
/// Pull the most recent still-payable car research invoice from
/// `GET /car-research/invoices/current` so we can surface a "resume" entry.
Future<void> _loadResumableInvoice() async {
- CarResearchInvoice? resumable;
+ List<CarResearchInvoice>? resumable;
try {
+ final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
final resp = await ref
.read(pShopinBitService)
.client
- .getCurrentCarResearchInvoices();
+ .getCurrentCarResearchInvoices(customerKey: customerKey);
final invoices = resp.value;
if (invoices != null) {
for (final inv in invoices) {
@@ -80,10 +81,13 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
(inv.expiresAt!.isAfter(DateTime.now()) ||
carResearchIsFinalized(inv.status, inv.additional));
if (payable) {
- resumable = CarResearchInvoice(
- btcpayInvoice: inv.invoiceId,
- expiresAt: inv.expiresAt!,
- paymentLinks: inv.paymentLinks,
+ resumable ??= [];
+ resumable.add(
+ CarResearchInvoice(
+ btcpayInvoice: inv.invoiceId,
+ expiresAt: inv.expiresAt!,
+ paymentLinks: inv.paymentLinks,
+ ),
);
break;
}
@@ -98,17 +102,20 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
// Leave _resumableInvoice unchanged on failure.
return;
}
- if (mounted) setState(() => _resumableInvoice = resumable);
+ if (mounted) setState(() => _resumableInvoices = resumable);
}
Future<void> _resumeFlow(CarResearchInvoice invoice) async {
if (_resuming) return;
setState(() => _resuming = true);
try {
- await Navigator.of(context).pushNamed(
- ShopInBitCarResearchPaymentView.routeName,
- arguments: invoice,
- );
+ final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
+ if (mounted) {
+ await Navigator.of(context).pushNamed(
+ ShopInBitCarResearchPaymentView.routeName,
+ arguments: (invoice: invoice, customerKey: customerKey),
+ );
+ }
} finally {
if (mounted) setState(() => _resuming = false);
}
@@ -118,7 +125,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
required BuildContext context,
required bool isDesktop,
required List<ShopInBitTicket> tickets,
- required CarResearchInvoice? resumable,
+ required List<CarResearchInvoice>? resumable,
}) {
if (resumable == null && tickets.isEmpty) {
return [
@@ -136,20 +143,22 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
final children = <Widget>[];
if (resumable != null) {
- children.add(
- RoundedContainer(
- color: Theme.of(context).extension<StackColors>()!.popupBG,
- onPressed: _resuming ? null : () => unawaited(_resumeFlow(resumable)),
- child: _RequestRow(
- title: "Car Research (In Progress)",
- subtitle: _resuming
- ? "Opening your car research payment..."
- : "Tap to continue your car research payment",
- badgeText: "Resume",
- badgeColor: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorYellow,
- loading: _resuming,
+ children.addAll(
+ resumable.map(
+ (e) => RoundedContainer(
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ onPressed: _resuming ? null : () => unawaited(_resumeFlow(e)),
+ child: _RequestRow(
+ title: "Car Research (In Progress)",
+ subtitle: _resuming
+ ? "Opening your car research payment..."
+ : "Tap to continue your car research payment",
+ badgeText: "Resume",
+ badgeColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorYellow,
+ loading: _resuming,
+ ),
),
),
);
@@ -192,7 +201,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
final isDesktop = Util.isDesktop;
final tickets =
ref.watch(pShopInBitTickets).asData?.value ?? const <ShopInBitTicket>[];
- final resumable = _resumableInvoice;
+ final resumables = _resumableInvoices;
return ConditionalParent(
condition: isDesktop,
@@ -272,7 +281,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
context: context,
isDesktop: isDesktop,
tickets: tickets,
- resumable: resumable,
+ resumable: resumables,
),
],
),
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index a685528..cbe2a11 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -1242,10 +1242,13 @@ class RouteGenerator {
return _routeError("${settings.name} invalid args: ${args.toString()}");
case ShopInBitCarResearchPaymentView.routeName:
- if (args is CarResearchInvoice) {
+ if (args is ({CarResearchInvoice invoice, String customerKey})) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
- builder: (_) => ShopInBitCarResearchPaymentView(invoice: args),
+ builder: (_) => ShopInBitCarResearchPaymentView(
+ invoice: args.invoice,
+ customerKey: args.customerKey,
+ ),
settings: RouteSettings(name: settings.name),
);
}
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 1f33466..fc6c72c 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -29,7 +29,6 @@ class ShopInBitService {
final ShopInBitSetting? current = await db.shopInBitSettingsDao
.getCurrentSettings();
if (current != null) {
- client.externalCustomerKey = current.customerKey;
await db.shopInBitSettingsDao.touch(current.customerKey);
return current.customerKey;
}
@@ -48,7 +47,6 @@ class ShopInBitService {
/// settings. The UI filters tickets by the active key.
Future<String> useCustomerKey(String key) async {
await db.shopInBitSettingsDao.upsert(key);
- client.externalCustomerKey = key;
return key;
}
@@ -120,10 +118,15 @@ class ShopInBitService {
return ref;
}
- Future<bool> sendMessage(int apiTicketId, String message) async {
+ Future<bool> sendMessage(
+ int apiTicketId,
+ String message,
+ String customerKey,
+ ) async {
final ApiResponse<Map<String, dynamic>> resp = await client.sendMessage(
apiTicketId,
message,
+ customerKey: customerKey,
);
if (resp.hasError) return false;
unawaited(refreshOne(apiTicketId));
@@ -176,16 +179,13 @@ class ShopInBitService {
return;
}
- // Ensure the client points at the right key for this ticket's calls.
- client.externalCustomerKey = customerKey;
-
final ApiResponse<TicketFull> fullResp;
final ApiResponse<TicketStatus> statusResp;
final ApiResponse<List<TicketMessage>> messagesResp;
(fullResp, statusResp, messagesResp) = await (
- client.getTicketFull(id),
- client.getTicketStatus(id),
- client.getMessages(id),
+ client.getTicketFull(id, customerKey: customerKey),
+ client.getTicketStatus(id, customerKey: customerKey),
+ client.getMessages(id, customerKey: customerKey),
).wait;
if (existing == null) {
diff --git a/lib/services/shopinbit/src/api_response.dart b/lib/services/shopinbit/src/api_response.dart
index a1e9135..623afc3 100644
--- a/lib/services/shopinbit/src/api_response.dart
+++ b/lib/services/shopinbit/src/api_response.dart
@@ -3,8 +3,9 @@ import 'api_exception.dart';
class ApiResponse<T> {
final T? value;
final ApiException? exception;
+ final String? customerKey;
- ApiResponse({this.value, this.exception});
+ ApiResponse({this.value, this.exception, this.customerKey});
bool get hasError => exception != null;
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index c939c5a..80ee6dc 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -27,10 +27,6 @@ class ShopInBitClient {
final HTTP _httpClient;
final TokenManager _tokenManager;
- String? _externalCustomerKey;
-
- set externalCustomerKey(String? key) => _externalCustomerKey = key;
-
ShopInBitClient({
required this.accessKey,
required this.partnerSecret,
@@ -38,8 +34,7 @@ class ShopInBitClient {
this.sandbox = false,
String? externalCustomerKey,
HTTP? httpClient,
- }) : _externalCustomerKey = externalCustomerKey,
- _httpClient = httpClient ?? const HTTP(),
+ }) : _httpClient = httpClient ?? const HTTP(),
_tokenManager = TokenManager(
accessKey: accessKey,
partnerSecret: partnerSecret,
@@ -66,7 +61,7 @@ class ShopInBitClient {
return _request(
'GET',
'/generate-key',
- needsCustomerKey: false,
+ customerKey: null,
parse: (json) {
return json['external_customer_key'] as String;
},
@@ -74,19 +69,14 @@ class ShopInBitClient {
}
Future<ApiResponse<Map<String, dynamic>>> getHealth() async {
- return _request(
- 'GET',
- '/health',
- needsCustomerKey: false,
- parse: (json) => json,
- );
+ return _request('GET', '/health', customerKey: null, parse: (json) => json);
}
Future<ApiResponse<List<Map<String, dynamic>>>> getCountries() async {
return _requestRaw(
'GET',
'/meta/countries',
- needsCustomerKey: false,
+ customerKey: null,
needsAuth: false,
parse: (body) {
final decoded = jsonDecode(body);
@@ -127,22 +117,31 @@ class ShopInBitClient {
number: json['ticket_number'].toString(),
);
},
+ customerKey: externalCustomerKey,
);
}
- Future<ApiResponse<TicketStatus>> getTicketStatus(int ticketId) async {
+ Future<ApiResponse<TicketStatus>> getTicketStatus(
+ int ticketId, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/tickets/$ticketId/status',
parse: TicketStatus.fromJson,
+ customerKey: customerKey,
);
}
- Future<ApiResponse<TicketFull>> getTicketFull(int ticketId) async {
+ Future<ApiResponse<TicketFull>> getTicketFull(
+ int ticketId, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/tickets/$ticketId/full',
parse: TicketFull.fromJson,
+ customerKey: customerKey,
);
}
@@ -158,6 +157,7 @@ class ShopInBitClient {
.map((e) => TicketRef.fromJson(e as Map<String, dynamic>))
.toList();
},
+ customerKey: customerKey,
);
}
@@ -165,17 +165,22 @@ class ShopInBitClient {
Future<ApiResponse<Map<String, dynamic>>> sendMessage(
int ticketId,
- String message,
- ) async {
+ String message, {
+ required String customerKey,
+ }) async {
return _request(
'POST',
'/tickets/$ticketId/messages',
body: {'message': message},
parse: (json) => json,
+ customerKey: customerKey,
);
}
- Future<ApiResponse<List<TicketMessage>>> getMessages(int ticketId) async {
+ Future<ApiResponse<List<TicketMessage>>> getMessages(
+ int ticketId, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/tickets/$ticketId/messages',
@@ -185,6 +190,7 @@ class ShopInBitClient {
.map((e) => TicketMessage.fromJson(e as Map<String, dynamic>))
.toList();
},
+ customerKey: customerKey,
);
}
@@ -194,12 +200,14 @@ class ShopInBitClient {
int ticketId, {
required String message,
required List<Map<String, String>> attachments,
+ required String customerKey,
}) async {
return _request(
'POST',
'/tickets/$ticketId/attachments',
body: {'message': message, 'attachments': attachments},
parse: (json) => json,
+ customerKey: customerKey,
);
}
@@ -211,6 +219,7 @@ class ShopInBitClient {
/// [useQueryAuth] = true to append token and customer_key as query params.
Future<ApiResponse<Uri>> getAttachmentUrl(
String attachmentPath, {
+ String? customerKey,
bool useQueryAuth = false,
}) async {
try {
@@ -221,8 +230,7 @@ class ShopInBitClient {
uri = uri.replace(
queryParameters: {
'token': token,
- if (_externalCustomerKey != null)
- 'customer_key': _externalCustomerKey!,
+ if (customerKey != null) 'customer_key': customerKey,
},
);
}
@@ -235,13 +243,16 @@ class ShopInBitClient {
}
/// Download an attachment from `/attachment-proxy/<path>`.
- Future<ApiResponse<Response>> getAttachment(String attachmentPath) async {
+ Future<ApiResponse<Response>> getAttachment(
+ String attachmentPath, {
+ String? customerKey,
+ }) async {
try {
final token = await _tokenManager.getValidToken();
final resolved = _resolvePath('/attachment-proxy/$attachmentPath');
final uri = Uri.parse('$baseUrl$resolved');
Logging.instance.t("$_kTag GET $uri");
- final headers = _headers(token);
+ final headers = _headers(token, customerKey: customerKey);
final response = await _httpClient.get(
url: uri,
headers: headers,
@@ -279,6 +290,7 @@ class ShopInBitClient {
Future<ApiResponse<Map<String, dynamic>>> submitAddress(
int ticketId, {
required Address shipping,
+ required String customerKey,
Address? billing,
}) async {
return _request(
@@ -286,6 +298,7 @@ class ShopInBitClient {
'/tickets/$ticketId/address',
body: {'shipping': shipping.toJson(), 'billing': billing?.toJson()},
parse: (json) => json,
+ customerKey: customerKey,
);
}
@@ -295,11 +308,15 @@ class ShopInBitClient {
/// and any view that just wants to show the current invoice; per ShopinBit
/// 1.0.4 this endpoint is read-only and will not create or regenerate the
/// invoice. Call [putPayment] for that.
- Future<ApiResponse<PaymentInfo>> getPayment(int ticketId) async {
+ Future<ApiResponse<PaymentInfo>> getPayment(
+ int ticketId, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/tickets/$ticketId/payment',
parse: PaymentInfo.fromJson,
+ customerKey: customerKey,
);
}
@@ -308,23 +325,31 @@ class ShopInBitClient {
/// shipping/billing, seen the Terms & Conditions, and explicitly clicked
/// PAY NOW. Repeated calls regenerate the invoice and invalidate any in-
/// flight payment.
- Future<ApiResponse<PaymentInfo>> putPayment(int ticketId) async {
+ Future<ApiResponse<PaymentInfo>> putPayment(
+ int ticketId, {
+ required String customerKey,
+ }) async {
return _request(
'PUT',
'/tickets/$ticketId/payment',
parse: PaymentInfo.fromJson,
+ customerKey: customerKey,
);
}
// -- Vouchers --
/// Pre-check a voucher code (does not consume usage or create a ticket).
- Future<ApiResponse<VoucherInfo>> checkVoucher(String code) async {
+ Future<ApiResponse<VoucherInfo>> checkVoucher(
+ String code, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/vouchers/validate',
query: {'code': code},
parse: VoucherInfo.fromJson,
+ customerKey: customerKey,
);
}
@@ -334,6 +359,7 @@ class ShopInBitClient {
required String customerPseudonym,
required String serviceType,
required String comment,
+ required String customerKey,
String? deliveryCountry,
}) async {
return _request(
@@ -347,6 +373,7 @@ class ShopInBitClient {
if (deliveryCountry != null) 'delivery_country': deliveryCountry,
},
parse: VipRedemptionResult.fromJson,
+ customerKey: customerKey,
);
}
@@ -354,6 +381,7 @@ class ShopInBitClient {
Future<ApiResponse<CarResearchInvoice>> createCarResearchInvoice({
required Address billing,
+ required String customerKey,
CarResearchRequest? request,
}) async {
return _request(
@@ -362,17 +390,17 @@ class ShopInBitClient {
body: {
'billing': billing.toJson(),
if (request != null) 'request': request.toJson(),
- if (_externalCustomerKey != null)
- 'external_customer_key': _externalCustomerKey,
+ 'external_customer_key': customerKey,
},
parse: CarResearchInvoice.fromJson,
+ customerKey: customerKey,
);
}
/// 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 {
+ getCurrentCarResearchInvoices({required String customerKey}) async {
return _requestRaw(
'GET',
'/car-research/invoices/current',
@@ -390,31 +418,33 @@ class ShopInBitClient {
)
.toList();
},
+ customerKey: customerKey,
);
}
Future<ApiResponse<Map<String, dynamic>>> getCarResearchInvoiceStatus(
- String invoiceId,
- ) async {
+ String invoiceId, {
+ required String customerKey,
+ }) async {
return _request(
'GET',
'/car-research/invoice/$invoiceId/status',
parse: (json) => json,
+ customerKey: customerKey,
);
}
Future<ApiResponse<CarResearchPaymentResult>> logCarResearchPayment(
- String invoiceId,
- ) async {
+ String invoiceId, {
+
+ required String customerKey,
+ }) async {
return _request(
'POST',
'/car-research/log-payment',
- body: {
- 'invoice_id': invoiceId,
- if (_externalCustomerKey != null)
- 'external_customer_key': _externalCustomerKey,
- },
+ body: {'invoice_id': invoiceId, 'external_customer_key': customerKey},
parse: CarResearchPaymentResult.fromJson,
+ customerKey: customerKey,
);
}
@@ -428,6 +458,8 @@ class ShopInBitClient {
String? environment,
String? expirationTime,
int? ticketId,
+
+ required String customerKey,
}) async {
return _request(
'POST',
@@ -442,6 +474,7 @@ class ShopInBitClient {
if (ticketId != null) 'ticketId': ticketId,
},
parse: (json) => json,
+ customerKey: customerKey,
);
}
@@ -451,7 +484,7 @@ class ShopInBitClient {
return _request(
'GET',
'/partners/webhooks',
- needsCustomerKey: false,
+ customerKey: null,
parse: (json) {
if (json.containsKey('webhooks')) {
return (json['webhooks'] as List<dynamic>)
@@ -469,7 +502,7 @@ class ShopInBitClient {
return _request(
'POST',
'/partners/webhooks',
- needsCustomerKey: false,
+ customerKey: null,
body: {'webhook_url': webhookUrl, 'event_types': eventTypes},
parse: (json) => json,
);
@@ -481,7 +514,7 @@ class ShopInBitClient {
return _request(
'POST',
'/partners/webhooks/$webhookId/rotate',
- needsCustomerKey: false,
+ customerKey: null,
parse: (json) => json,
);
}
@@ -490,7 +523,7 @@ class ShopInBitClient {
return _request(
'DELETE',
'/partners/webhooks/$webhookId',
- needsCustomerKey: false,
+ customerKey: null,
parse: (_) {},
);
}
@@ -499,23 +532,27 @@ class ShopInBitClient {
Future<ApiResponse<Map<String, dynamic>>> sandboxSetState(
int ticketId,
- String state,
- ) async {
+ String state, {
+ required String customerKey,
+ }) async {
return _request(
'POST',
'/sandbox/state/$ticketId/$state',
parse: (json) => json,
+ customerKey: customerKey,
);
}
Future<ApiResponse<Map<String, dynamic>>> sandboxSetPayment(
int ticketId,
- String status,
- ) async {
+ String status, {
+ required String customerKey,
+ }) async {
return _request(
'POST',
'/sandbox/payment/$ticketId/$status',
parse: (json) => json,
+ customerKey: customerKey,
);
}
@@ -542,14 +579,14 @@ class ShopInBitClient {
return '/sandbox$path';
}
- Map<String, String> _headers(String token, {bool needsCustomerKey = true}) {
+ Map<String, String> _headers(String token, {String? customerKey}) {
final h = <String, String>{
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'Accept': 'application/json',
};
- if (needsCustomerKey && _externalCustomerKey != null) {
- h['External-Customer-Key'] = _externalCustomerKey!;
+ if (customerKey != null) {
+ h['External-Customer-Key'] = customerKey;
}
return h;
}
@@ -559,7 +596,7 @@ class ShopInBitClient {
String path, {
Map<String, dynamic>? body,
Map<String, String>? query,
- bool needsCustomerKey = true,
+ required String? customerKey,
bool needsAuth = true,
}) async {
final resolved = _resolvePath(path);
@@ -570,7 +607,7 @@ class ShopInBitClient {
final Map<String, String> headers;
if (needsAuth) {
final token = await _tokenManager.getValidToken();
- headers = _headers(token, needsCustomerKey: needsCustomerKey);
+ headers = _headers(token, customerKey: customerKey);
} else {
headers = {'Accept': 'application/json'};
}
@@ -632,7 +669,7 @@ class ShopInBitClient {
String path, {
Map<String, dynamic>? body,
Map<String, String>? query,
- bool needsCustomerKey = true,
+ required String? customerKey,
required T Function(Map<String, dynamic>) parse,
}) async {
try {
@@ -641,7 +678,7 @@ class ShopInBitClient {
path,
body: body,
query: query,
- needsCustomerKey: needsCustomerKey,
+ customerKey: customerKey,
);
final resolved = _resolvePath(path);
@@ -652,7 +689,7 @@ class ShopInBitClient {
return ApiResponse(value: parse({}));
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
- return ApiResponse(value: parse(json));
+ return ApiResponse(value: parse(json), customerKey: customerKey);
} else {
Logging.instance.w(
"$_kTag $method $resolved HTTP:${response.code} "
@@ -682,7 +719,7 @@ class ShopInBitClient {
String path, {
Map<String, dynamic>? body,
Map<String, String>? query,
- bool needsCustomerKey = true,
+ required String? customerKey,
bool needsAuth = true,
required T Function(String) parse,
}) async {
@@ -692,7 +729,7 @@ class ShopInBitClient {
path,
body: body,
query: query,
- needsCustomerKey: needsCustomerKey,
+ customerKey: customerKey,
needsAuth: needsAuth,
);
diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
index 74e272c..eed491d 100644
--- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
+++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
@@ -112,9 +112,12 @@ abstract final class NestedNavigatorDialogRouteGenerator {
);
case ShopInBitCarResearchPaymentView.routeName:
- if (args is CarResearchInvoice) {
+ if (args is ({CarResearchInvoice invoice, String customerKey})) {
return getRoute(
- builder: (_) => ShopInBitCarResearchPaymentView(invoice: args),
+ builder: (_) => ShopInBitCarResearchPaymentView(
+ invoice: args.invoice,
+ customerKey: args.customerKey,
+ ),
settings: RouteSettings(name: settings.name),
);
}
Why this scored 35/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.