fix(shopinbit): handle no_payment_required as fully covered
What changed, and why it matters
This commit fixes a UI/workflow bug in Stack Wallet's ShopInBit integration. When an order is fully covered by a voucher or store credit, the payment status becomes 'no_payment_required' and the invoice has no payment addresses. Previously the app treated this as a failed payment load and blocked the user; now it shows a 'fully covered' screen and lets the user continue. It is a functional bug fix rather than a security vulnerability.
No security action required; treat as a normal functional fix. If desired, verify that _status values are validated/normalized by the backend and that 'no_payment_required' cannot be spoofed by a malicious response to skip payment on an order that actually requires funds.
Security signals we found
UI state handling for an untrusted/external status string
Change to payment-flow guard condition
No cryptographic, authentication, or authorization changes
Evidence from the diff
The patch adds handling for a new ShopInBit order status ‘no_payment_required’. In shopinbit_payment_view.dart it introduces _isNoPaymentRequired, disables the ‘Pay now’ button, hides the coin/payment rows, and renders a success message with a navigation button. In shopinbit_shipping_view.dart it changes the guard that opens the payment view so that an empty paymentLinks list is accepted when status is ‘no_payment_required’. Without this change, users with voucher/credit-covered orders would hit the error path and be unable to proceed.
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_shipping_view.dartInspect captured patch +50 / −3
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index c6b5b4b..ee51597 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -76,6 +76,9 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
bool get _isExpiredOrInvalid => _status == 'expired' || _status == 'invalid';
+ // Voucher/credit fully covers the amount: no wallet options, nothing to pay.
+ bool get _isNoPaymentRequired => _status == 'no_payment_required';
+
bool get _isTerminal => const {
'paid',
'paid_over',
@@ -83,7 +86,8 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
'payment_processing',
}.contains(_status);
- bool get _payNowEnabled => !_isExpiredOrInvalid && !_isTerminal;
+ bool get _payNowEnabled =>
+ !_isExpiredOrInvalid && !_isTerminal && !_isNoPaymentRequired;
String? _customerKeyCache;
@@ -673,9 +677,48 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView>
onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests,
),
],
+ if (_isNoPaymentRequired) ...[
+ SizedBox(height: isDesktop ? 16 : 8),
+ RoundedWhiteContainer(
+ child: Row(
+ children: [
+ SvgPicture.asset(
+ Assets.svg.checkCircle,
+ width: 20,
+ height: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorGreen,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "No payment required. Your order is fully covered.",
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorGreen,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ PrimaryButton(
+ label: _canReturnToRequest ? "Back to Request" : "View My Requests",
+ onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests,
+ ),
+ ],
SizedBox(height: isDesktop ? 24 : 16),
// Coin list (replaces tab selector + QR + address + global button)
- if (!_isExpiredOrInvalid) ...coinRows,
+ if (!_isExpiredOrInvalid && !_isNoPaymentRequired) ...coinRows,
],
);
diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart
index ef560cf..c7419ef 100644
--- a/lib/pages/shopinbit/shopinbit_shipping_view.dart
+++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart
@@ -237,7 +237,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
if (!mounted) return;
- if (paymentInfo == null || paymentInfo.paymentLinks.isEmpty) {
+ // no_payment_required legitimately has empty payment_links (voucher/credit
+ // covers it): open the payment view, which shows a "covered" state.
+ if (paymentInfo == null ||
+ (paymentInfo.paymentLinks.isEmpty &&
+ paymentInfo.status != 'no_payment_required')) {
// No live invoice; don't open a payment view with empty addresses.
await _showPaymentLoadError(
"We couldn't load the payment details for this order. "
Why this scored 21/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.