What changed, and why it matters
This commit updates the Stack Wallet app's ShopInBit payment flow to support a new version of the ShopInBit backend API (1.0.7). It mainly adds handling for expired and underpaid invoices, allowing users to refresh or retry invoices instead of being stuck. There is no clear security vulnerability in the diff itself; it appears to be a feature/bug-fix update for payment recovery.
Treat as a routine feature/bug-fix update. Review the new retryCarResearchInvoice endpoint and ensure the server validates that retry requests are authorized for the supplied customerKey/invoiceId pair. Verify that paymentLinks values are sanitized before being rendered or passed to wallet send flows. No immediate security response is indicated by the diff alone.
Security signals we found
Adds async request-id guards to prevent stale network responses from overwriting refreshed invoice state
Adds retry/refresh API call paths for expired and underpaid invoices
Removes app lifecycle observer that paused polling while backgrounded
Updates invoice status parsing to include payment_links map
No explicit security fix language in commit title or message
Evidence from the diff
The patch modifies the ShopInBit integration to handle additional invoice statuses (‘expired’, ‘underpaid_expired’, ‘underpaid’) and introduces a retry/refresh mechanism for car-research invoices. It adds request-id tracking to prevent stale async callbacks from updating state after a refresh, removes WidgetsBindingObserver lifecycle polling pausing, and updates model parsing for payment_links in status responses. The changes are defensive in nature and do not introduce obvious injection, authentication bypass, or cryptographic flaws based on the diff alone.
Changed components
lib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_tickets_view.dartlib/services/shopinbit/src/client.dartlib/services/shopinbit/src/models/car_research.dartInspect captured patch +308 / −110
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 6afe283..dcd4553 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -45,9 +45,9 @@ class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget {
}
class _ShopInBitCarResearchPaymentViewState
- extends ConsumerState<ShopInBitCarResearchPaymentView>
- with WidgetsBindingObserver {
+ extends ConsumerState<ShopInBitCarResearchPaymentView> {
Timer? _pollTimer;
+ int _statusRequestId = 0;
static const Duration _kBasePollInterval = Duration(seconds: 15);
static const Duration _kMaxPollInterval = Duration(seconds: 120);
@@ -60,6 +60,8 @@ class _ShopInBitCarResearchPaymentViewState
bool _finalized = false;
// The real car ticket id (the customer chat) from the finalized status.
int? _realTicketId;
+ late String _invoiceId;
+ Map<String, String> _paymentLinks = {};
List<String> _methods = [];
List<String> _addresses = [];
int _selectedMethod = 0;
@@ -71,8 +73,26 @@ class _ShopInBitCarResearchPaymentViewState
bool get _isTerminal =>
_finalized || carResearchIsFinalized(_statusString, _additional);
+ String get _normalizedStatus => _statusString.toLowerCase().trim();
+
+ bool get _needsReplacement =>
+ !_isTerminal &&
+ const {'expired', 'underpaid_expired'}.contains(_normalizedStatus);
+
bool get _payNowEnabled =>
- !_isTerminal && _flowState == _PaymentFlowState.idle;
+ !_isTerminal &&
+ !_needsReplacement &&
+ _methods.isNotEmpty &&
+ _flowState == _PaymentFlowState.idle;
+
+ void _setPaymentLinks(Map<String, String> links) {
+ _paymentLinks = Map<String, String>.from(links);
+ _methods = links.keys.map((k) => k.toUpperCase()).toList();
+ _addresses = links.values.toList();
+ if (_selectedMethod >= _methods.length) {
+ _selectedMethod = 0;
+ }
+ }
Future<void> _confirmPayment() async {
// Keep polling while the user is in the send flow.
@@ -121,7 +141,9 @@ class _ShopInBitCarResearchPaymentViewState
try {
await _pollStatus();
if (!mounted) return;
- if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) {
+ if (!_isTerminal &&
+ !_needsReplacement &&
+ _flowState != _PaymentFlowState.finalizing) {
unawaited(
showFloatingFlushBar(
type: FlushBarType.info,
@@ -153,10 +175,13 @@ class _ShopInBitCarResearchPaymentViewState
}
String get _displayedFee {
+ if (_needsReplacement) {
+ return "Invoice expired";
+ }
// 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;
+ final links = _paymentLinks;
if (_selectedMethod < _methods.length) {
final methodKey = _methods[_selectedMethod];
// _methods holds upper-cased keys; links map may be case-sensitive.
@@ -176,13 +201,20 @@ class _ShopInBitCarResearchPaymentViewState
}
}
}
- return "223.00 EUR";
+ return _normalizedStatus == "underpaid"
+ ? "See payment option"
+ : "223.00 EUR";
}
String get _statusLabel {
- switch (_statusString) {
+ switch (_normalizedStatus) {
case "payment_processing":
return "Confirming...";
+ case "underpaid":
+ return "Additional payment required";
+ case "expired":
+ case "underpaid_expired":
+ return "Invoice expired";
case "paid":
case "paid_over":
case "paid_late":
@@ -196,10 +228,8 @@ class _ShopInBitCarResearchPaymentViewState
@override
void initState() {
super.initState();
- WidgetsBinding.instance.addObserver(this);
- final links = widget.invoice.paymentLinks;
- _methods = links.keys.map((k) => k.toUpperCase()).toList();
- _addresses = links.values.toList();
+ _invoiceId = widget.invoice.btcpayInvoice;
+ _setPaymentLinks(widget.invoice.paymentLinks);
// Kick off an immediate poll then start periodic polling.
unawaited(_pollStatus());
_scheduleNextPoll();
@@ -207,24 +237,10 @@ class _ShopInBitCarResearchPaymentViewState
@override
void dispose() {
- WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
super.dispose();
}
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- // Don't poll while backgrounded; resume fresh when we come back.
- if (state == AppLifecycleState.resumed) {
- if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) {
- _pollInterval = _kBasePollInterval;
- _scheduleNextPoll();
- }
- } else {
- _pollTimer?.cancel();
- }
- }
-
void _scheduleNextPoll() {
_pollTimer?.cancel();
_pollTimer = Timer(_pollInterval, _pollTick);
@@ -236,6 +252,7 @@ class _ShopInBitCarResearchPaymentViewState
final bool ok = await _pollStatus();
if (!mounted) return;
if (_isTerminal ||
+ _needsReplacement ||
_flowState == _PaymentFlowState.finalizing ||
_flowState == _PaymentFlowState.complete) {
return;
@@ -305,27 +322,91 @@ class _ShopInBitCarResearchPaymentViewState
}
}
+ Future<void> _refreshInvoice() async {
+ if (_flowState != _PaymentFlowState.idle || !_needsReplacement) return;
+ _pollTimer?.cancel();
+ final oldInvoiceId = _invoiceId;
+ final requestId = ++_statusRequestId;
+ setState(() => _flowState = _PaymentFlowState.polling);
+ try {
+ final resp = await ref
+ .read(pShopinBitService)
+ .client
+ .retryCarResearchInvoice(
+ invoiceId: oldInvoiceId,
+ customerKey: widget.customerKey,
+ );
+ if (!mounted ||
+ requestId != _statusRequestId ||
+ oldInvoiceId != _invoiceId) {
+ return;
+ }
+ final invoice = resp.valueOrThrow;
+ setState(() {
+ _invoiceId = invoice.btcpayInvoice;
+ _status = null;
+ _statusString = "ready_to_pay";
+ _additional = null;
+ _finalized = false;
+ _realTicketId = null;
+ _setPaymentLinks(invoice.paymentLinks);
+ _flowState = _PaymentFlowState.idle;
+ });
+ _pollInterval = _kBasePollInterval;
+ _scheduleNextPoll();
+ } catch (e, s) {
+ if (!mounted ||
+ requestId != _statusRequestId ||
+ oldInvoiceId != _invoiceId) {
+ return;
+ }
+ Logging.instance.e(
+ "Car research invoice refresh failed",
+ error: e,
+ stackTrace: s,
+ );
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: e.toString(),
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ if (mounted && _flowState == _PaymentFlowState.polling) {
+ setState(() => _flowState = _PaymentFlowState.idle);
+ }
+ }
+ }
+
/// Fetch invoice status once and apply it. Returns false on any failure so
/// the periodic driver can back off instead of polling at full rate.
Future<bool> _pollStatus() async {
+ final requestedInvoiceId = _invoiceId;
+ final requestId = ++_statusRequestId;
try {
final service = ref.read(pShopinBitService);
final resp = await service.client.getCarResearchInvoiceStatus(
- widget.invoice.btcpayInvoice,
+ requestedInvoiceId,
customerKey: widget.customerKey,
);
+ if (!mounted ||
+ requestId != _statusRequestId ||
+ requestedInvoiceId != _invoiceId) {
+ return true;
+ }
if (resp.hasError || resp.value == null) {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.warning,
- message:
- resp.exception?.message ?? "Failed to fetch invoice status",
- context: context,
- ),
- );
- }
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message:
+ resp.exception?.message ?? "Failed to fetch invoice status",
+ context: context,
+ ),
+ );
return false;
}
@@ -383,13 +464,17 @@ class _ShopInBitCarResearchPaymentViewState
}
}
- if (!mounted) return true;
+ if (!mounted ||
+ requestId != _statusRequestId ||
+ requestedInvoiceId != _invoiceId) {
+ return true;
+ }
Logging.instance.i(
"CarResearch status response (payment_view): ${resp.value}",
);
Logging.instance.i(
"CarResearch paymentLinks (payment_view): "
- "${widget.invoice.paymentLinks}",
+ "${resp.value!.paymentLinks}",
);
setState(() {
_status = resp.value!;
@@ -399,13 +484,27 @@ class _ShopInBitCarResearchPaymentViewState
_additional = _status!.additional;
_finalized = _status!.finalized;
_realTicketId = _status!.realTicketId;
+ if (_needsReplacement) {
+ _setPaymentLinks(const {});
+ } else if (_normalizedStatus == 'underpaid') {
+ _setPaymentLinks(_status!.paymentLinks);
+ } else if (_status!.paymentLinks.isNotEmpty) {
+ _setPaymentLinks(_status!.paymentLinks);
+ }
});
if (_isTerminal) {
_pollTimer?.cancel();
await _finalizePayment();
+ } else if (_needsReplacement) {
+ _pollTimer?.cancel();
}
return true;
} catch (e, s) {
+ if (!mounted ||
+ requestId != _statusRequestId ||
+ requestedInvoiceId != _invoiceId) {
+ return true;
+ }
Logging.instance.e(
"ticket status polling issue",
error: e,
@@ -447,6 +546,7 @@ class _ShopInBitCarResearchPaymentViewState
void _onOwnedCoinTap(int methodIndex) {
if (!_payNowEnabled) return;
+ if (methodIndex >= _methods.length) return;
setState(() => _selectedMethod = methodIndex);
unawaited(_confirmPayment());
}
@@ -515,22 +615,44 @@ class _ShopInBitCarResearchPaymentViewState
),
),
SizedBox(height: isDesktop ? 24 : 16),
- ShopInBitPaymentMethodList(
- methods: _methods,
- addresses: _addresses,
- enabled: _payNowEnabled,
- onPayFromWallet: _onOwnedCoinTap,
- onCheckForPayment: (methodIndex) {
- _selectedMethod = methodIndex;
- unawaited(_checkForPayment());
- },
- ),
+ if (_needsReplacement)
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ "This invoice expired. Refresh it to continue payment.",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ const SizedBox(height: 8),
+ SecondaryButton(
+ label: "Refresh Invoice",
+ onPressed: _flowState == _PaymentFlowState.idle
+ ? _refreshInvoice
+ : null,
+ ),
+ ],
+ ),
+ )
+ else
+ ShopInBitPaymentMethodList(
+ methods: _methods,
+ addresses: _addresses,
+ enabled: _payNowEnabled,
+ onPayFromWallet: _onOwnedCoinTap,
+ onCheckForPayment: (methodIndex) {
+ _selectedMethod = methodIndex;
+ unawaited(_checkForPayment());
+ },
+ ),
if (_flowState == _PaymentFlowState.polling ||
_flowState == _PaymentFlowState.finalizing) ...[
SizedBox(height: isDesktop ? 24 : 16),
PrimaryButton(
label: _flowState == _PaymentFlowState.polling
- ? "Checking..."
+ ? (_needsReplacement ? "Refreshing..." : "Checking...")
: "Processing...",
enabled: false,
onPressed: null,
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index 50ed7f4..c5b4156 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -228,7 +228,7 @@ Future<bool> tryNavigateToShopInBitWalletSend({
// Fetches the live payment info for a ticket so the caller can pass it into
// the payment view as an arg (rather than loading it after the view is up).
// GET first to reuse an existing invoice per the spec's "page reload
-// recovery" guidance; PUT (which regenerates) only when GET shows none.
+// recovery" guidance. Retry stale invoices and create only when not started.
// Returns null on any failure so the view can fall back to polling.
Future<PaymentInfo?> fetchShopInBitPaymentInfo(
ShopInBitClient client,
@@ -240,14 +240,24 @@ Future<PaymentInfo?> fetchShopInBitPaymentInfo(
apiTicketId,
customerKey: customerKey,
);
- if (!getResp.hasError &&
- getResp.value != null &&
- getResp.value!.paymentLinks.isNotEmpty) {
- return getResp.value;
+ if (getResp.hasError || getResp.value == null) {
+ return null;
}
+
+ final paymentInfo = getResp.value!;
+ final retry = const {
+ 'expired',
+ 'invalid',
+ 'underpaid_expired',
+ }.contains(paymentInfo.status);
+ if (!retry && paymentInfo.status != 'not_started') {
+ return paymentInfo;
+ }
+
final putResp = await client.putPayment(
apiTicketId,
customerKey: customerKey,
+ retry: retry,
);
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 8ee0af3..e0d8b6f 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -47,8 +47,7 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget {
_ShopInBitPaymentViewState();
}
-class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
- with WidgetsBindingObserver {
+class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
int _selectedMethod = 0;
Timer? _pollTimer;
int _paymentRequestId = 0;
@@ -70,7 +69,8 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
String get _status => _paymentInfo?.status ?? 'ready_to_pay';
- bool get _isExpiredOrInvalid => _status == 'expired' || _status == 'invalid';
+ bool get _isExpiredOrInvalid =>
+ const {'expired', 'invalid', 'underpaid_expired'}.contains(_status);
// Voucher/credit fully covers the amount: no wallet options, nothing to pay.
bool get _isNoPaymentRequired => _status == 'no_payment_required';
@@ -100,7 +100,6 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
@override
void initState() {
super.initState();
- WidgetsBinding.instance.addObserver(this);
_applyPaymentInfo(widget.paymentInfo);
if (widget.apiTicketId != 0) {
_startPolling();
@@ -109,29 +108,23 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
@override
void dispose() {
- WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
super.dispose();
}
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- if (widget.apiTicketId == 0) return;
- // Don't poll while backgrounded; resume fresh when we come back.
- if (state == AppLifecycleState.resumed) {
- if (!_isTerminal) _startPolling();
- } else {
- _pollTimer?.cancel();
- _paymentRequestId++;
- }
- }
-
void _applyPaymentInfo(PaymentInfo info) {
_paymentInfo = info;
final links = info.paymentLinks;
- if (links.isNotEmpty) {
+ if (!_isExpiredOrInvalid && links.isNotEmpty) {
_methods = links.keys.map((k) => k.toUpperCase()).toList();
_addresses = links.values.toList();
+ if (_selectedMethod >= _methods.length) {
+ _selectedMethod = 0;
+ }
+ } else {
+ _methods = [];
+ _addresses = [];
+ _selectedMethod = 0;
}
}
@@ -172,7 +165,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
);
}
if (!mounted || requestId != _paymentRequestId) return;
- if (_isTerminal) {
+ if (_isTerminal || _isExpiredOrInvalid) {
_pollTimer?.cancel();
return;
}
@@ -208,7 +201,9 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
if (resp != null && !resp.hasError && resp.value != null) {
setState(() => _applyPaymentInfo(resp.value!));
}
- _startPolling();
+ if (!_isExpiredOrInvalid) {
+ _startPolling();
+ }
}
Future<void> _checkForPayment() async {
@@ -249,7 +244,9 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
- message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.",
+ message:
+ "Additional payment is required. "
+ "Use one of the updated payment options.",
context: context,
),
);
@@ -276,7 +273,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
if (!mounted || requestId != _paymentRequestId) return;
}
- if (!_isTerminal) {
+ if (!_isTerminal && !_isExpiredOrInvalid) {
_startPolling();
}
}
@@ -419,9 +416,8 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
const SizedBox(width: 8),
Expanded(
child: Text(
- "Payment underpaid. Remaining: "
- "${_paymentInfo?.due ?? '?'} EUR. "
- "Please send the remaining amount.",
+ "Additional payment is required. "
+ "Please use one of the updated payment options.",
style:
(isDesktop
? STextStyles.desktopTextExtraExtraSmall(
@@ -457,7 +453,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
const SizedBox(width: 8),
Expanded(
child: Text(
- "Invoice expired.",
+ "Invoice expired. Refresh it to continue payment.",
style:
(isDesktop
? STextStyles.desktopTextExtraExtraSmall(
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index 7b185f9..ed62427 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -21,6 +21,7 @@ import "../../widgets/dialogs/s_dialog.dart";
import "../../widgets/loading_indicator.dart";
import "../../widgets/refresh_control.dart";
import "../../widgets/rounded_container.dart";
+import "../../widgets/stack_dialog.dart";
import "shopinbit_car_research_payment_view.dart";
import "shopinbit_ticket_detail.dart";
@@ -41,7 +42,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
// 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.
- List<CarResearchInvoice>? _resumableInvoices;
+ List<CarResearchCurrentInvoice>? _resumableInvoices;
@override
void initState() {
@@ -62,10 +63,18 @@ 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.
+ bool _needsReplacement(CarResearchCurrentInvoice invoice) {
+ return !carResearchIsFinalized(invoice.status, invoice.additional) &&
+ const {
+ 'expired',
+ 'underpaid_expired',
+ }.contains(invoice.status.toLowerCase().trim());
+ }
+
+ /// Pull still-payable car research invoices from
+ /// `GET /car-research/invoices/current` so they can be resumed.
Future<void> _loadResumableInvoice() async {
- List<CarResearchInvoice>? resumable;
+ final resumable = <CarResearchCurrentInvoice>[];
try {
final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
final resp = await ref
@@ -75,28 +84,30 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
final invoices = resp.value;
if (invoices != null) {
for (final inv in invoices) {
+ final finalized = carResearchIsFinalized(inv.status, inv.additional);
final payable =
inv.expiresAt != null &&
- inv.paymentLinks.isNotEmpty &&
- // Spec: expired unresolved invoices stay recoverable until
- // expires_at + 24h.
- (inv.expiresAt!
- .add(const Duration(hours: 24))
- .isAfter(DateTime.now()) ||
- carResearchIsFinalized(inv.status, inv.additional));
+ (finalized ||
+ (inv.expiresAt!
+ .add(const Duration(hours: 24))
+ .isAfter(DateTime.now()) &&
+ (_needsReplacement(inv)
+ ? inv.hasRequestPayload
+ : inv.paymentLinks.isNotEmpty)));
if (payable) {
- resumable ??= [];
- resumable.add(
- CarResearchInvoice(
- btcpayInvoice: inv.invoiceId,
- expiresAt: inv.expiresAt!,
- paymentLinks: inv.paymentLinks,
- ),
- );
- break;
+ resumable.add(inv);
}
}
}
+ resumable.sort((a, b) {
+ final aNeedsReplacement = _needsReplacement(a);
+ final bNeedsReplacement = _needsReplacement(b);
+ if (aNeedsReplacement != bNeedsReplacement) {
+ return aNeedsReplacement ? 1 : -1;
+ }
+ final oldest = DateTime.fromMillisecondsSinceEpoch(0);
+ return (b.createdAt ?? oldest).compareTo(a.createdAt ?? oldest);
+ });
} catch (e, s) {
Logging.instance.e(
"_loadResumableInvoice failed",
@@ -106,19 +117,56 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
// Leave _resumableInvoice unchanged on failure.
return;
}
- if (mounted) setState(() => _resumableInvoices = resumable);
+ if (mounted) {
+ setState(() => _resumableInvoices = resumable.isEmpty ? null : resumable);
+ }
}
- Future<void> _resumeFlow(CarResearchInvoice invoice) async {
+ Future<void> _resumeFlow(CarResearchCurrentInvoice currentInvoice) async {
if (_resuming) return;
setState(() => _resuming = true);
try {
final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
+ CarResearchInvoice invoice;
+ if (_needsReplacement(currentInvoice)) {
+ final resp = await ref
+ .read(pShopinBitService)
+ .client
+ .retryCarResearchInvoice(
+ invoiceId: currentInvoice.invoiceId,
+ customerKey: customerKey,
+ );
+ invoice = resp.valueOrThrow;
+ } else {
+ invoice = CarResearchInvoice(
+ btcpayInvoice: currentInvoice.invoiceId,
+ expiresAt: currentInvoice.expiresAt!,
+ paymentLinks: currentInvoice.paymentLinks,
+ );
+ }
+
if (mounted) {
await Navigator.of(context).pushNamed(
ShopInBitCarResearchPaymentView.routeName,
arguments: (invoice: invoice, customerKey: customerKey),
);
+ if (mounted) {
+ await _loadResumableInvoice();
+ }
+ }
+ } catch (e, s) {
+ Logging.instance.e("_resumeFlow failed", error: e, stackTrace: s);
+ if (mounted) {
+ await showDialog<void>(
+ context: context,
+ useRootNavigator: Util.isDesktop,
+ builder: (context) => StackOkDialog(
+ title: "Failed to resume payment",
+ maxWidth: Util.isDesktop ? 500 : null,
+ message: e.toString(),
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
}
} finally {
if (mounted) setState(() => _resuming = false);
@@ -129,7 +177,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
required BuildContext context,
required bool isDesktop,
required List<ShopInBitTicket> tickets,
- required List<CarResearchInvoice>? resumable,
+ required List<CarResearchCurrentInvoice>? resumable,
}) {
if (resumable == null && tickets.isEmpty) {
return [
@@ -147,16 +195,18 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
final children = <Widget>[];
if (resumable != null) {
- children.addAll(
- resumable.map(
- (e) => RoundedContainer(
+ for (var i = 0; i < resumable.length; i++) {
+ if (i > 0) children.add(SizedBox(height: isDesktop ? 16 : 12));
+ final invoice = resumable[i];
+ children.add(
+ RoundedContainer(
color: Theme.of(context).extension<StackColors>()!.popupBG,
- onPressed: _resuming ? null : () => unawaited(_resumeFlow(e)),
+ onPressed: _resuming ? null : () => unawaited(_resumeFlow(invoice)),
child: _RequestRow(
title: "Car Research (In Progress)",
subtitle: _resuming
? "Opening your car research payment..."
- : "Tap to continue your car research payment",
+ : "${invoice.status} • Invoice ${invoice.invoiceId}",
badgeText: "Resume",
badgeColor: Theme.of(
context,
@@ -164,8 +214,8 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
loading: _resuming,
),
),
- ),
- );
+ );
+ }
if (tickets.isNotEmpty) {
children.add(SizedBox(height: isDesktop ? 16 : 12));
}
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index d6c20d0..eee9018 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -470,6 +470,21 @@ class ShopInBitClient {
);
}
+ /// Replace [invoiceId] using the billing/request payload already stored by
+ /// the server.
+ Future<ApiResponse<CarResearchInvoice>> retryCarResearchInvoice({
+ required String invoiceId,
+ required String customerKey,
+ }) async {
+ return _request(
+ 'POST',
+ '/car-research/invoice',
+ body: {'invoice_id': invoiceId, 'retry': true},
+ 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>>>
diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart
index b770fc2..4290c53 100644
--- a/lib/services/shopinbit/src/models/car_research.dart
+++ b/lib/services/shopinbit/src/models/car_research.dart
@@ -119,6 +119,7 @@ class CarResearchInvoice {
class CarResearchInvoiceStatus {
final String status;
final String? additional;
+ final Map<String, String> paymentLinks;
final bool finalized;
final int? receiptTicketId;
final String? receiptTicketNumber;
@@ -129,6 +130,7 @@ class CarResearchInvoiceStatus {
CarResearchInvoiceStatus({
required this.status,
this.additional,
+ required this.paymentLinks,
required this.finalized,
this.receiptTicketId,
this.receiptTicketNumber,
@@ -138,9 +140,11 @@ class CarResearchInvoiceStatus {
});
factory CarResearchInvoiceStatus.fromJson(Map<String, dynamic> json) {
+ final linksRaw = json['payment_links'] as Map<String, dynamic>? ?? {};
return CarResearchInvoiceStatus(
status: json['status'] as String,
additional: json['additional']?.toString(),
+ paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)),
finalized: json['finalized'] as bool,
receiptTicketId: json['receipt_ticket_id'] as int?,
receiptTicketNumber: json['receipt_ticket_number'] as String?,
@@ -154,6 +158,7 @@ class CarResearchInvoiceStatus {
return {
"status": status,
"additional": additional,
+ "payment_links": paymentLinks,
"finalized": finalized,
"receipt_ticket_id": receiptTicketId,
"receipt_ticket_number": receiptTicketNumber,
Why this scored 23/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.