fix(shopinbit): GET payment first, PUT only if no live invoice
What changed, and why it matters
This commit changes how Stack Wallet's ShopInBit payment screen fetches an invoice. Previously it always created or regenerated a payment invoice with a PUT request. Now it first checks with a GET request and only creates a new invoice if no live one exists. This is a defensive fix to avoid overwriting or regenerating an existing valid invoice, which could disrupt a payment in progress or cause funds to be sent to a stale address.
Review the ShopInBit service client to confirm `getPayment` and `putPayment` error handling and state machine are consistent with the 1.0.4 spec. Consider adding tests for page-reload recovery and expired/invalid invoice scenarios. No urgent security patch appears required, but treat as a correctness/stability improvement.
Security signals we found
Avoids unnecessary invoice regeneration that could invalidate a prior payment request
Prevents potential race where a user reloads the payment view and a live invoice is overwritten
Uses GET-before-PUT idempotency pattern for payment state recovery
Comment explicitly references spec guidance and server response semantics
Evidence from the diff
The patch modifies _loadPayment() in shopinbit_payment_view.dart. The previous implementation called putPayment() directly, which per the 1.0.4 spec regenerates the invoice. The new implementation first calls getPayment() and reuses the returned PaymentInfo if paymentLinks is non-empty. Only when the GET response indicates no live invoice (empty paymentLinks, covering fresh/expired/invalid cases) does it fall back to putPayment(). This aligns with the spec’s ‘page reload recovery’ guidance.
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartShopInBit payment flow_loadPayment() methodInspect captured patch +22 / −8
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index 23afcb3..38895fd 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -121,17 +121,31 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
} catch (_) {}
}
- // Entered from the shipping view's PAY NOW button: create the invoice
- // via PUT per the 1.0.4 spec. GET no longer creates invoices.
+ // The shipping view's PAY NOW button is the only path into this view today,
+ // but we still GET first per the 1.0.4 spec's "page reload recovery"
+ // guidance: if a live invoice already exists for this ticket, reuse it. PUT
+ // (which regenerates) only when GET shows there isn't one. An empty
+ // paymentLinks map covers all "no live invoice" cases the server returns
+ // (fresh ticket, expired, invalid) and a non-empty map covers everything
+ // worth preserving (live, paid, paid_late, processing).
Future<void> _loadPayment() async {
setState(() => _loading = true);
try {
- final resp = await ref
- .read(pShopinBitService)
- .client
- .putPayment(widget.model.apiTicketId);
- if (!resp.hasError && resp.value != null) {
- _applyPaymentInfo(resp.value!);
+ final client = ref.read(pShopinBitService).client;
+ final getResp = await client.getPayment(widget.model.apiTicketId);
+ PaymentInfo? info;
+ if (!getResp.hasError &&
+ getResp.value != null &&
+ getResp.value!.paymentLinks.isNotEmpty) {
+ info = getResp.value!;
+ } else {
+ final putResp = await client.putPayment(widget.model.apiTicketId);
+ if (!putResp.hasError && putResp.value != null) {
+ info = putResp.value!;
+ }
+ }
+ if (info != null) {
+ _applyPaymentInfo(info);
}
} catch (_) {
// Fall back to local/dummy data
Why this scored 32/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.