How to Test Refund Flow on Android (Complete Guide)
Refunds are a critical touchpoint between a user and an app’s business logic. When a user initiates a refund, the system must reverse a financial transaction, update inventory or entitlements, emit th
Why Refund Flow Testing Matters
Refunds are a critical touchpoint between a user and an app’s business logic. When a user initiates a refund, the system must reverse a financial transaction, update inventory or entitlements, emit the correct notifications, and persist an audit trail that satisfies both internal finance teams and external regulators. A failure in any of these steps can lead to:
- Financial loss – double‑refunds, missed reversals, or incorrect amounts that hit the merchant’s bottom line.
- Customer trust erosion – users who see a pending refund that never arrives are likely to charge back, leave negative reviews, or abandon the platform.
- Regulatory exposure – many jurisdictions require timely refund processing and clear communication; missing SLAs can trigger fines.
- Operational overhead – support teams spend time reconciling mismatched states, which diverts effort from feature work.
Because refunds often involve multiple services (payment gateway, order service, inventory, notification, and analytics), they are prone to integration bugs that only surface under specific conditions such as poor network, race conditions, or unusual user behavior. A dedicated test effort that covers happy paths, error paths, and edge cases reduces the chance that these issues reach production.
Common Failure Points in Production
Understanding where refunds break helps prioritize test cases. The following patterns appear repeatedly in post‑mortems across Android apps that handle in‑app purchases, subscription cancellations, or e‑commerce returns.
| Failure Category | Typical Symptom | Root Cause |
|---|---|---|
| Payment gateway mismatch | Refund amount differs from original charge | Gateway returns a different currency or applies fees not mirrored in app state |
| State desync | UI shows “Refunded” but backend still marks order as “Pending” | Async callback not handled or missed due to process kill |
| Missing receipt validation | Refund processed without verifying purchase token | Trusting client‑side data; attacker can replay old token |
| Accessibility block | TalkBack users cannot reach the “Confirm Refund” button | Button lacks content‑description or is hidden behind a modal |
| Network retry loop | Indefinite spinner after tapping refund due to 504 errors | No exponential backoff or user‑visible error state |
| Localization glitch | Refund amount displays with wrong decimal separator | Hard‑coded format string instead of using NumberFormat.getCurrencyInstance() |
| Security leakage | Refund screen logs full payment token in Logcat | Debug logging left enabled in release builds |
| Race condition | Rapid double‑tap creates two refund requests | No idempotency key or UI disabling during request |
These categories inform the test matrix that follows. By mapping each symptom to a concrete test condition, you can verify that the app’s defensive measures (idempotency tokens, receipt verification, UI locks, accessibility labels, proper error handling) are present and functional.
Test Matrix Overview
A structured matrix ensures coverage across functional, non‑functional, and persona‑driven dimensions. Below is a comprehensive table that you can copy into a test‑management tool or spreadsheet. Each row represents a test scenario; columns indicate the dimension being validated.
Functional Test Matrix
| ID | Scenario | Preconditions | Steps | Expected Result | Pass/Fail Criteria |
|---|---|---|---|---|---|
| F1 | Happy‑path refund for consumable IAP | User owns a valid consumable item, network stable | 1. Open app → Store → My Purchases → Select item → Tap “Refund” → Confirm in dialog | Refund succeeds, item removed from inventory, gateway returns SUCCESS, UI shows “Refunded” toast, analytics event refund_success logged | Verify item gone, receipt invalidated, correct amount returned, no error dialogs |
| F2 | Refund with insufficient funds (gateway returns INSUFFICIENT_FUNDS) | Same as F1, but gateway mocked to return error | Same steps as F1 | Error dialog shown, UI retains item, no analytics success event, error logged | Confirm UI shows retry option, state unchanged, no financial side‑effect |
| F3 | Network loss during refund request | Same as F1, enable airplane mode after tapping Confirm | Same steps as F1 | App shows “Network error” toast, retains item, offers retry, no duplicate request sent | Verify only one network call logged, UI not stuck in loading |
| F4 | Duplicate tap protection | Same as F1, user taps Confirm twice quickly | Same steps as F1 | Only one refund request sent, second tap ignored or shows “Already processing” | Check request count via proxy, UI disabled after first tap |
| F5 | Partial refund (e.g., shipping fee non‑refundable) | Order includes item + shipping, policy states shipping non‑refundable | Same steps as F1, but select “Refund item only” | Refund amount equals item price, shipping fee retained, UI shows correct breakdown | Verify amount matches expectation, order status reflects partial refund |
| F6 | Refund after app upgrade (migration) | User had purchased item on version 1.0, app upgraded to 2.0 with new purchase schema | Launch upgraded app, navigate to purchase list, attempt refund | Refund works as if purchase token still valid, migration script correctly maps old token | Confirm token mapping, no crash, correct UI |
| F7 | Refund cancellation by user | Same as F1, user taps “Cancel” in confirmation dialog | Same steps as F1 until dialog, then tap Cancel | No request sent, UI returns to purchase screen, item unchanged | Verify no network call, state unchanged |
| F8 | Refund with expired purchase token (simulated) | Token expires after 24h in sandbox, wait >24h then attempt refund | Same steps as F1 | Gateway returns TOKEN_EXPIRED, app shows “Purchase not found” error, no state change | Verify error handling, no crash |
| F9 | Refund triggering inventory restock | Item is inventory‑tracked, stock count = 0 before refund | Same steps as F1 | After refund, stock count increments by 1, backend reflects update | Check inventory API or DB entry |
| F10 | Refund receipt verification failure | Tampered receipt sent to backend | Same steps as F1, but modify receipt signature | Backend rejects refund, UI shows error, no financial change | Verify signature validation logs, UI error |
Non‑functional & Persona Matrix
| ID | Scenario | Persona | Preconditions | Steps | Expected Result |
|---|---|---|---|---|---|
| N1 | Screen‑reader navigation | Accessibility (TalkBack) user | TalkBack enabled, refund screen loaded | Swipe to locate “Refund” button, double‑tap to activate | Button announces label and state, activation works |
| N2 | Color‑blind contrast | Low‑vision user | Developer options → Simulate color blindness | Observe refund screen | All actionable elements meet WCAG AA contrast (≥4.5:1) |
| N3 | Large font scaling | Elderly user | System font size set to 200% | Navigate to refund flow | Text scales, UI elements not clipped, buttons remain tappable |
| N4 | Impatient user (rapid taps) | Power‑user | Device performance normal | Tap Refund button 5 times within 2 seconds | UI prevents multiple requests, shows single in‑progress indicator |
| N5 | Adversarial input (SQLi attempt) | Security‑focused tester | Burp Suite proxy configured to intercept refund API | Inject ' OR 1=1 -- into any free‑text field (e.g., notes) | Backend sanitizes input, returns validation error, no DB error |
| N6 | Curious user (exploratory) | Novice | No prior knowledge of refund policy | Wander through menus, tap random icons, eventually find refund | Flow discoverable within ≤3 taps from home screen, clear scent |
| N7 | Novice user (guidance needed) | First‑time buyer | App shows tutorial on first launch | Follow tutorial prompts to request refund | Tooltips guide user to correct screen, no confusion |
| N8 | Elderly user with tremor | Motor‑impairment | Enable “Touch accommodation” (iOS‑like) via accessibility service | Attempt to tap small “Confirm” button | Button has sufficient hit‑target (≥48dp) or provides alternate larger target |
| N9 | Privacy‑conscious user | Privacy advocate | App requests permission to read SMS for OTP (if used) | Decline permission, attempt refund | Refund proceeds without SMS permission, or explains why permission is needed |
| N10 | Localization (right‑to‑left) | Arabic/Hebrew user | System language set to Arabic | Navigate to refund screen | Layout mirrors, text aligned correctly, no overlapping |
These tables give you a concrete starting point. In practice, you would expand each ID with detailed test data (amounts, currencies, gateway mock responses) and assign owners for execution.
Manual Testing Approach
Even with strong automation, manual exploratory testing remains indispensable for refund flows because it captures nuances that scripts assume away—such as the feel of a button, the clarity of an error message, or the way a user reacts to a delayed response.
1. Environment Setup
- Device matrix – Use at least three physical devices representing different API levels (e.g., Android 10, 12, 13) and varying screen sizes. Include one device with TalkBack enabled and another with font scaling set to 200%.
- Network simulation – Enable the Android Studio “Network Profiler” or use
tc/netemviaadb shellto introduce latency (200ms), packet loss (5%), or bandwidth throttling (500kbps). - Payment gateway sandbox – Configure your gateway (e.g., Stripe, Braintree, Google Play Billing) to return controllable responses. Most providers offer a test mode where you can trigger specific error codes by sending special test tokens or amounts.
- Logging – Enable
adb logcat -v threadtime > refund_log.txtbefore starting the test. Filter for your app’s tag and for gateway SDK tags to capture request/response payloads. - Data reset – Between test runs, clear app data (
adb shell pm clear com.example.app) or use a backend admin API to revert purchase states, ensuring a clean slate.
2. Step‑by‑Step Manual Test Script (Happy Path)
- Launch the app from the home screen.
- Log in (if required) using a test account that has a known consumable purchase.
- Navigate to Store → My Purchases. Verify the purchased item appears with correct price and purchase date.
- Tap the item to open its detail screen.
- Locate the Refund button (should be labeled “Request Refund” or similar).
- Long‑press the button to confirm TalkBack reads the label and state (if accessibility testing).
- Tap the button once. A confirmation dialog appears with title “Refund Request” and body “Are you sure you want to refund $X.XX?”.
- Verify the dialog contains a primary action labeled “Refund” and a secondary action labeled “Cancel”.
- Tap Refund.
- Observe UI: a progress spinner should appear, the button should be disabled, and no further taps should be accepted.
- Wait for the network call to complete (watch logcat for HTTP 200 from gateway).
- Upon success, the spinner disappears, a toast “Refund processed” shows, the item disappears from the list, and an analytics event
refund_successis logged. - Verify the backend: call your order service API (or check the DB) to confirm the order status is
REFUNDEDand the refund amount matches the original purchase. - Perform a negative check: try to refund the same item again; the UI should either hide the button or show an error “Already refunded”.
3. Observables to Capture
| Observable | Tool/Method | Acceptance |
|---|---|---|
| Network request count & payload | adb logcat + grep or mitmproxy | Exactly one POST to /refund with correct amount, currency, and idempotency token |
| UI state changes | Espresso‑style manual observation or UI Automator script | Button disabled during request, re‑enabled on error/completion |
| Toast/Snackbar messages | Visual inspection | Correct spelling, duration ≥2s, disappears automatically |
| Analytics fire | Firebase DebugView or custom logger | refund_success event with parameters amount, currency, item_id |
| Accessibility labels | TalkBack feedback | All actionable elements have contentDescription that describes purpose |
| Error handling | Simulated gateway errors | App shows user‑friendly message, does not crash, state unchanged |
| Security leakage | logcat filter for token | No raw payment token or receipt appears in logs (except maybe hashed) |
4. Manual Edge‑Case Checks
- Network loss mid‑request – Turn on airplane mode after tapping Confirm; ensure the app shows a retry option and does not send a second request when connectivity returns.
- Device rotation – Rotate screen while the progress spinner is visible; verify UI stays intact and no duplicate request is made.
- Low memory – Use
adb shell am kill com.example.appafter initiating request (simulating process kill) then relaunch; confirm the app either resumes the request safely or shows an error without double charging. - Multiple refunds in quick succession – Rapidly tap Refund on three different items; ensure each request gets a unique idempotency token and the backend processes them independently.
- Foreign currency – Change device locale to a region using a different currency (e.g., Japan → Yen); confirm amounts display with correct symbol and that the gateway receives the proper currency code.
5. Post‑Test Cleanup
- Clear logs (
adb logcat -c). - Reset device network settings (
adb shell svc wifi disable && enable). - Re‑purchase any consumed items via a test harness or backend script to keep the test account in a known state for the next tester.
Manual testing gives you confidence that the flow feels right, but it is time‑consuming and prone to human omission. The next section shows how to supplement it with automation that can be run on every commit.
Automated Testing Approaches
Automation accelerates regression detection and enables continuous validation of refund logic. On Android, you have several layers to consider: unit tests for pure logic, instrumentation tests for UI behavior, and end‑to‑end tests that interact with real or mocked services. Below we break down each layer, provide concrete code snippets, and discuss tooling choices.
1. Unit Testing the Refund Use‑Case
Isolate the business rule that decides whether a refund is allowed, builds the request payload, and handles the gateway response. Use JUnit5 with Mockito for dependencies.
// RefundUseCaseTest.kt
class RefundUseCaseTest {
private lateinit var repo: MockPurchaseRepository
private lateinit var gateway: MockPaymentGateway
private lateinit var useCase: RefundUseCase
@BeforeEach
fun setUp() {
repo = mock()
gateway = mock()
useCase = RefundUseCase(repo, gateway)
}
@Test
fun `happy path builds correct request and updates state`() {
// Given a valid purchase
val purchase = Purchase(
id = "p_123",
sku = "cons_001",
amountMicros = 499_0000, // $4.99
currency = "USD",
state = PurchaseState.OWNED
)
`when`(repo.getPurchase("p_123")).thenReturn(purchase)
`when`(gateway.refund(any())).thenReturn(RefundResult.Success)
// When
val result = useCase.execute("p_123")
// Then
assertTrue(result.isSuccess)
verify(gateway).refund(argThat {
it.purchaseId == "p_123" &&
it.amountMicros == 499_0000 &&
it.currency == "USD" &&
it.idempotencyKey.isNotBlank()
})
verify(repo).updatePurchaseState("p_123", PurchaseState.REFUNDED)
}
@Test
fun `gateway insufficient funds leads to error state`() {
val purchase = Purchase(
id = "p_456",
sku = "cons_002",
amountMicros = 999_0000,
currency = "USD",
state = PurchaseState.OWNED
)
`when`(repo.getPurchase("p_456")).thenReturn(purchase)
`when`(gateway.refund(any())).thenReturn(RefundResult.Failure(
errorCode = "INSUFFICIENT_FUNDS",
message = "Insufficient funds in merchant account"
))
val result = useCase.execute("p_456")
assertFalse(result.isSuccess)
assertEquals("INSUFFICIENT_FUNDS", result.errorCode)
verify(repo, never()).updatePurchaseState(any(), any())
}
}
*Key points*
- The use case is pure Kotlin; no Android framework needed, so tests run fast on the JVM.
- Mocking the gateway lets you simulate every error code without hitting a real provider.
- Assert that an idempotency key is generated (UUID or HMAC) to guard against duplicate requests.
Run unit tests with Gradle:
./gradlew testDebugUnitTest
2. Instrumentation UI Tests with Espresso
Espresso validates that the UI reacts correctly to user gestures and that the underlying view model or presenter processes outcomes as expected. Use IdlingResource to wait for network calls.
// RefundFlowTest.kt
@RunWith(AndroidJUnit4::class)
class RefundFlowTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
private val idlingResource = object : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = "NetworkIdlingResource"
override fun isIdleNow(): Boolean = !NetworkMonitor.isRequestInFlight()
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
this.callback = callback
}
}
@Before
fun registerIdling() {
IdlingRegistry.getInstance().register(idlingResource)
}
@After
fun unregisterIdling() {
IdlingRegistry.getInstance().unregister(idlingResource)
}
@Test
fun refundHappyPath_showsSuccessToast_andRemovesItem() {
// Seed a purchase via dependency injection or fake repository
TestUtil.seedPurchase(activityRule.scenario, Purchase(
id = "p_seeded",
sku = "cons_003",
amountMicros = 199_0000,
currency = "USD",
state = PurchaseState.OWNED
))
// Open My Purchases
onView(withId(R.id.nav_my_purchases)).perform(click())
onView(withText("cons_003")).perform(click())
// Request refund
onView(withId(R.id.btn_refund)).perform(click())
onView(withId(R.id.btn_confirm_refund)).perform(click())
// Wait for network idle, then check toast
onView(withText(containsString("Refund processed"))).inRoot(ToastMatcher())
.check(matches(isDisplayed()))
// Item should be gone
onView(withText("cons_003")).check(doesNotExist())
}
@Test
fun refundNetworkError_showsRetryOption() {
TestUtil.seedPurchase(activityRule.scenario, Purchase(
id = "p_err",
sku = "cons_004",
amountMicros = 299_0000,
currency = "EUR",
state = PurchaseState.OWNED
))
onView(withId(R.id.nav_my_purchases)).perform(click())
onView(withText("cons_004")).perform(click())
onView(withId(R.id.btn_refund)).perform(click())
onView(withId(R.id.btn_confirm_refund)).perform(click())
// Simulate network failure via MockWebServer
MockWebServerUtil.enqueueFailure(HttpURLConnection.HTTP_GATEWAY_TIMEOUT)
onView(withText(containsString("Network error"))).inRoot(ToastMatcher())
.check(matches(isDisplayed()))
onView(withId(R.id.btn_retry)).check(matches(isEnabled()))
}
}
*Explanation*
TestUtil.seedPurchaseinjects a fake purchase into the repository via a test‑only DI module (e.g., Hilt test bindings).MockWebServerUtil(from OkHttp’sMockWebServer) lets you enqueue specific HTTP responses or failures without touching a real gateway.- The
NetworkIdlingResourceensures Espresso waits for asynchronous calls before asserting UI.
Run instrumentation tests on a device or emulator:
./gradlew connectedAndroidTest
3. End‑to‑End Tests with Appium (Real Device / Emulator)
Appium drives the actual Android UI, making it suitable for verifying that the whole stack—including native dialogs, system overlays, and inter‑app communication (e.g., Google Play Billing flow)—behaves as expected. Below is a Python‑based Appium script that mirrors the happy path and also tests an error case.
# test_refund_flow.py
import time
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
def get_driver():
options = UiAutomator2Options()
options.set_platform_name("Android")
options.set_device_name("emulator-5554")
options.set_app_package("com.example.app")
options.set_app_activity(".MainActivity")
options.set_automation_name("UiAutomator2")
return webdriver.Remote("http://127.0.0.1:4723/wd/hub", options=options)
def test_refund_happy_path():
driver = get_driver()
try:
# Assume user already logged in and has a purchase
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "My Purchases").click()
time.sleep(1)
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().text("cons_005")').click()
driver.find_element(AppiumBy.ID, "com.example.app:id/btn_refund").click()
driver.find_element(AppiumBy.ID, "com.example.app:id/btn_confirm_refund").click()
# Wait for success toast
success_toast = webdriver.WebDriverWait(driver, 15).until(
lambda d: d.find_element(AppiumBy.XPATH,
"//android.widget.Toast[contains(@text,'Refund processed')]"))
assert success_toast.is_displayed()
# Verify item removed
assert len(driver.find_elements(AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().text("cons_005")')) == 0
finally:
driver.quit()
def test_refund_insufficient_funds():
driver = get_driver()
try:
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "My Purchases").click()
time.sleep(1)
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().text("cons_006")').click()
driver.find_element(AppiumBy.ID, "com.example.app:id/btn_refund").click()
driver.find_element(AppiumBy.ID, "com.example.app:id/btn_confirm_refund").click()
# Mock server returns 402 Payment Required
error_toast = webdriver.WebDriverWait(driver, 15).until(
lambda d: d.find_element(AppiumBy.XPATH,
"//android.widget.Toast[contains(@text,'Unable to process refund')]"))
assert error_toast.is_displayed()
# Item still present
assert len(driver.find_elements(AppiumBy.ANDROID_UIAUTOMATOR,
'new UiSelector().text("cons_006")')) == 1
finally:
driver.quit()
Setup
- Start Appium server:
appium --allow-insecure=chromedriver_autodownload. - Ensure the emulator or device is connected (
adb devices). - Use a test backend that can return specific HTTP status codes based on request headers or URL query parameters (many teams expose a
/mock/refundendpoint for this purpose).
Run the script:
pip install appium pytest
pytest test_refund_flow.py
4. Leveraging SUSA for Autonomous, Persona‑Driven Exploration
SUSA (susatest.com) can be pointed at your APK or a internal test build and will automatically exercise the refund flow using its built‑in personas. Unlike scripted tests, SUSA does not know the exact UI hierarchy ahead of time; it discovers screens by interacting with the app, applies each persona’s behavior profile, and logs any anomalies it encounters.
How to invoke SUSA for refund testing
# Install the agent (once)
pip install susatest-agent
# Point SUSA at your debug APK
susatest run \
--apk path/to/app-debug.apk \
--mode exploratory \
--personas curious impatient novice adversarial elderly accessibility power_user \
--target-flow refund \
--output-dir ./susa-reports/refund \
--max-depth 6 \
--timeout-per-action 2s
*What SUSA does*
- Entry point detection – It launches the app and begins tapping, scrolling, and typing based on the selected personas.
- Flow recognition – Using heuristics (e.g., presence of a “Refund” button, text containing “refund”, or a navigation path that matches known purchase screens), SUSA tags screens that belong to the refund flow.
- Persona variation –
- *Curious* may wander into settings before attempting a refund, uncovering hidden entry points.
- *Impatient* will rapid‑tap buttons, exposing missing debouncing.
- *Accessibility* enables TalkBack and verifies that all actionable nodes have proper labels.
- *Adversarial* attempts to inject special characters into any editable field (e.g., a notes box attached to the refund reason) and watches for crash or error leakage.
- Result reporting – After the run, SUSA emits a JSON report with PASS/FAIL verdicts per detected flow, screenshots of failure states, and a list of discovered dead ends (e.g., a button that leads to a blank screen).
Why this catches bugs scripts miss
- Scripts assume a fixed navigation path; if a redesign adds a new entry point (e.g., a banner on the home screen that says “Need a refund? Tap here”), a scripted test would never see it unless explicitly updated. SUSA’s exploratory nature will eventually tap the banner and follow the new path.
- Personas simulate real‑world variability: an elderly user with tremor may long‑press a button instead of tapping, revealing that the UI relies on a quick click for state changes.
- Adversarial actions can uncover input validation gaps that only appear when a user pastes a long string or attempts a SQL‑like payload—something a typical happy‑path script would ignore.
You can schedule SUSA runs nightly on your CI pipeline to complement your unit and instrumentation suites, gaining confidence that the refund flow remains robust under real‑world usage patterns.
Edge Cases and Production‑Only Bugs
Even with exhaustive test matrices, certain defects only manifest after the app reaches real users. Below are categories of production‑only refund bugs, their typical triggers, and concrete strategies to detect them early.
4.1. Network Flakiness & Partial Failures
*Problem*: The refund request succeeds at the gateway, but the response is delayed or gets corrupted, causing the client to timeout while the server has already recorded the refund. The app may then show an error and allow the user to retry, leading to a double refund.
*Detection*:
- Use a proxy (e.g.,
mitmproxy) to inject artificial latency (2–5 s) and occasional TCP resets on the refund endpoint. - Verify that the client treats a timeout as a transient error, disables the retry button after a single attempt, and shows a clear message (“We’re still processing your refund. Please wait.”).
- Check that the backend implements idempotency—if the same request arrives twice, the second should be recognized as a duplicate and return the original result without creating a second refund.
*Mitigation*:
- Generate a UUID‑based idempotency key on the client and include it in every refund request.
- On the server, store the key with a TTL (e.g., 24 h) and return the cached outcome on replay.
- Show a persistent in‑app notification (not just a toast) that remains until the refund state is confirmed via a polling or push update.
4.2. Timezone & Currency Conversion Issues
*Problem*: A user in New Zealand purchases an item priced in USD. The refund is processed in the merchant’s local timezone (UTC‑5) but the app displays the amount using the device’s timezone, leading to a mismatch in the shown date/time or an incorrect conversion if the app mistakenly applies a FX rate twice.
*Detection*:
- Set the device timezone to a non‑UTC offset (e.g.,
adb shell setprop persist.sys.timezone Pacific/Auckland). - Make a purchase with a known amount, then trigger a refund and verify that the timestamp shown in the refund receipt matches the UTC time stored on the backend (you can query the backend API).
- For multi‑currency apps, change the device locale to a region that uses a different currency symbol (e.g.,
ja_JP) and confirm that the amount is displayed with the correct symbol and that no conversion is applied unless the policy explicitly allows refunds in the local currency.
*Mitigation*:
- Always store timestamps in UTC and convert to local time only for presentation, using
java.time.ZonedDateTime. - Keep the currency code alongside the amount; never infer currency from the locale.
- Use
NumberFormat.getCurrencyInstance(Locale)for UI formatting andCurrency.getInstance(currencyCode)for backend amounts.
4.3. Race Conditions During App Updates
*Problem*: A user has a pending refund request when an OTA update installs. The update process kills the app; upon restart, the app may resend the refund request because the in‑flight flag was lost, or it may show a stale UI that still says “Processing…” while the refund already completed.
*Detection*:
- Simulate an update by installing a new version of the APK while a network request is in flight (
adb install -r new_version.apk). - Observe whether the app duplicates the request or gets stuck in a loading state.
- Verify that the app persists a “refundInProgress” flag to
SharedPreferencesor aRoomdatabase before making the network call and clears it only on a definitive success or error response.
*Mitigation*:
- Use a work‑managed approach: defer the refund request to
WorkManagerwith a uniqueworkNamebased on the purchase ID. WorkManager survives process kills and will guarantee exactly‑one execution (if you setExistingWorkPolicy.KEEP). - On app start, query any pending WorkManager jobs and reconcile UI state accordingly.
4.4. Partial Refunds & Mixed Payment Methods
*Problem*: An order contains a subscription (recurring) and a one‑time add‑on purchased via a different payment method (e.g., PayPal). The refund policy states that only the add‑on is refundable, but the backend incorrectly attempts to refund the subscription, causing a service interruption.
*Detection*:
- Create a test order that mixes payment methods
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free