Skip to content

Conversation

@comst19
Copy link
Collaborator

@comst19 comst19 commented Dec 18, 2025

1. ⭐️ 변경된 내용

  • Billing 모듈 Singleton Scope로 변경
  • 매칭 화면 진입 시 미처리 구매 내역 체크 로직 추가
  • 미처리 구매 내역이 있다면 처리 로직 추가
  • Billing 모듈에서 구매검증 까지 완료 후 결과를 flow로 주도록 변경

2. 🖼️ 스크린샷(선택)

3. 💡 알게된 부분

4. 📌 이 부분은 꼭 봐주세요!

Summary by CodeRabbit

  • New Features

    • Purchases now include userId, emit explicit purchaseResult events (Success/Pending/Failure/Cancelled), and support checking/processing unprocessed purchases.
    • Store and Matching screens observe purchaseResult to show spinners, snackbars and refresh data.
  • Bug Fixes

    • Improved purchase verification, error handling and aggregation of unacknowledged rewards.
  • Chores

    • Billing integration moved to singleton scope; Android Billing updated to v8.2.1. Added new in-app payment error codes.
  • Tests

    • Added FakeBillingHelper for deterministic billing tests.

✏️ Tip: You can customize this high-level summary in your review settings.

@comst19 comst19 requested review from kkh725 and tgyuuAn December 18, 2025 13:36
@comst19 comst19 self-assigned this Dec 18, 2025
@comst19 comst19 added 리뷰 원해요🔥 피어의 리뷰를 기다리는 ing.. 🔥 ㅁㅅ민수 labels Dec 18, 2025
viewModel.handlePurchaseEvent(product)
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분 purchaseProduct 를 앱 전체에서 수집해야하는걸로 이해했는데, 매칭 route 안의 LaunchedEffect 안에 두면 매칭라우트를 벗어날 때 코루틴이 종료되지않나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공통 로직으로 통합할 경우 스토어와의 관찰 우선순위에 따라 사이드 이펙트가 발생할 수 있어, 현재는 화면별로 로직을 분리했습니다.

또한 유저의 퍼즐 수를 표시하는 곳은 매칭 메인과 스토어 둘 뿐이기 때문에 해당 화면에 진입 시 하면 될 거라고 생각했습니다.

viewModel.billingHelper.purchaseProduct.collect { product ->
viewModel.handlePurchaseEvent(product)
}
}
Copy link
Collaborator

@kkh725 kkh725 Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 비슷한 의문입니도

(추가) 뷰모델의 purchaseProduct collect를 왜 screen으로 빼셨는지 ??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

매칭 로직 먼저 짜고 그대로 가져온 거 같네요, 이건 뷰모델로 넣어도 될 거 같습니다

viewModelScope.launch {
val job1 = launch { getUserPuzzle() }
val job2 = launch { getAvailableProducts() }
val job3 = launch { getUserInfo() }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소하지만 job Naming을 좀 더 알아보기 좋게만들면 좋을듯합니다

billingHelper.verifyPurchaseProduct(product)
}.onSuccess {
initPuzzleCount()
}.onFailure{ }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 Fail 처리는 구현이 안된건가요? 아니면 todo 인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유저한테는 백그라운드 기능이라 .onFailure는 지우겠습니다

@kkh725
Copy link
Collaborator

kkh725 commented Dec 18, 2025

고생하셨습니다 ~!

@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

Tip

For best results, initiate chat on the files or code changes.

@comst19: Hello! How can I assist you with this PR? I can help with:

  • Reviewing the changes
  • Analyzing specific code sections
  • Generating tests
  • Explaining implementation details
  • Or any other questions you might have

Please let me know what you'd like me to do! 😊

@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

@comst19 네, PR을 리뷰하겠습니다. 미처리 구매 내역 검증 및 재화 지급 처리 관련 변경사항들을 확인해보겠습니다.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

Walkthrough

Replaces StateFlow-based purchase signaling with a Channel-backed Flow of typed PurchaseResult; makes purchaseProduct suspend and require userId; adds purchase verification, query and checkUnprocessedPurchases; removes release(); adds FakeBillingHelper; moves billing DI to singleton scope; updates Matching and Store features and bumps Android Billing to 8.2.1.

Changes

Cohort / File(s) Summary
Billing Interface
core/billing/src/main/java/com/puzzle/billing/domain/BillingHelper.kt
Replaces val purchaseProduct: StateFlow<...> with val purchaseResult: Flow<PurchaseResult>; purchaseProduct(...) is now suspend and accepts userId: String; removes release(); adds suspend fun checkUnprocessedPurchases().
Billing Implementation
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt
Replaces StateFlow with Channel<PurchaseResult> exposed as Flow; introduces CoroutineScope (SupervisorJob, Main.immediate); updates PurchasesUpdatedListener to process and verify purchases, emit PurchaseResult (Success/Failure/Pending/Cancelled); adds helpers handleSinglePurchase/fetchPurchaseReward/queryPurchases/checkUnprocessedPurchases/connectGooglePlayIfNeeded; purchaseProduct is suspend and sets obfuscatedAccountId(userId); removes public release().
Purchase model
core/billing/src/main/java/com/puzzle/billing/model/PurchaseResult.kt
Adds sealed PurchaseResult with variants: Success(rewardPuzzle: Int), Failure(throwable: Throwable), Pending, Cancelled.
Billing Test Double
core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt
Adds FakeBillingHelper implementing BillingHelper; exposes purchaseResult via Channel→Flow; no-op purchaseProduct/checkUnprocessedPurchases for tests.
DI / Data Module
core/data/src/main/java/com/puzzle/data/di/DataModule.kt
Adds binding bindPaymentRepository(PaymentRepositoryImpl); changes BillingModule install scope @InstallIn(ViewModelComponent::class)@InstallIn(SingletonComponent::class) and scope @ViewModelScoped@Singleton.
Feature dependency
feature/matching/build.gradle.kts
Adds implementation(projects.core.billing) dependency.
Matching feature
feature/matching/src/main/java/.../MatchingScreen.kt, .../MatchingViewModel.kt, .../MatchingViewModelTest.kt
Injects BillingHelper into MatchingViewModel; renames init*fetch*; calls billingHelper.checkUnprocessedPurchases() during init; adds collectPurchaseResult() to observe purchaseResult; screen uses lifecycle-aware collection; tests use FakeBillingHelper.
Store feature / contract
feature/store/src/main/java/.../StoreScreen.kt, .../StoreViewModel.kt, .../contract/StoreSideEffect.kt, .../contract/StoreState.kt
StoreSideEffect.PurchaseProduct now includes userId: String; StoreScreen/StoreViewModel pass userId to billingHelper.purchaseProduct; StoreViewModel adds getUserInfo() (stores userId), renames fetchers, removes onCleared()/billing release, handles purchaseResult (Success refreshes puzzle count); StoreState adds userId: Int = -1 and isPurchase: Boolean.
UI loading adjustments
feature/store/src/main/java/.../CompactStoreLoadingScreen.kt, .../CompactStoreScreen.kt, .../ExpandedStoreLoadingScreen.kt, .../ExpandedStoreScreen.kt, .../StoreScreen.kt
Adds guarded loading branches and replaces prior full-screen loading with compact persistent layout, BackHandler for purchase flow, SpinnerOverlay, and adjusted animation timings.
Build config
gradle/libs.versions.toml
Bumps androidBilling version 8.0.08.2.1.
Domain errors
core/domain/src/main/java/com/puzzle/domain/model/error/HttpResponseException.kt
Adds InAppPayment PieceErrorCode entries: VERIFICATION_FAILED, RECEIPT_ALREADY_USED, DUPLICATE_PAYMENT, PAYMENT_ALREADY_COMPLETED.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as UI (Store / Matching)
    participant VM as ViewModel
    participant BH as BillingHelper (interface)
    participant BI as BillingHelperImpl
    participant BC as BillingClient
    participant Repo as PaymentRepository

    UI->>VM: OnPurchaseClick(product, userId)
    VM->>BH: purchaseProduct(activity, product, userId)
    BH->>BI: start purchase (setObfuscatedAccountId = userId)
    BI->>BC: launchBillingFlow(...)
    BC-->>BI: onPurchasesUpdated(resultCode, purchases)
    alt OK && purchases != null
        BI->>BI: filter PURCHASED purchases
        loop per purchased item
            BI->>Repo: verifyPurchaseProduct(purchase)
            alt verification success
                BI-->>BH: emit PurchaseResult.Success(reward)
            else verification failure
                BI-->>BH: emit PurchaseResult.Failure(error)
            end
        end
        BH-->>VM: purchaseResult Flow emits results
        VM->>VM: refresh puzzle count / UI on Success
    else non-cancel error
        BI-->>BH: emit PurchaseResult.Failure(error)
        BH-->>VM: purchaseResult Flow emits Failure
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas to focus:
    • Concurrency and lifecycle of the new CoroutineScope + SupervisorJob in BillingHelperImpl.
    • Channel buffering semantics, receiveAsFlow and multi-subscriber/backpressure implications.
    • Purchase verification logic and error mapping to PurchaseResult.
    • checkUnprocessedPurchases/queryPurchases interactions with BillingClient and idempotency.
    • DI scope change to Singleton and potential lifetime implications for billing resources.

Possibly related PRs

Suggested labels

기능 ⚒️

Suggested reviewers

  • kkh725
  • tgyuuAn

Poem

"I hopped in with a channel so bright,
I carried userIds through day and night,
I sniffed each receipt with a careful nose,
and emitted Success or Failure as the garden grows,
a little rabbit's verify-and-emit delight." 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective: validating and processing unhandled purchases in the billing module.
Description check ✅ Passed The PR description covers all major changes: Singleton scope for billing, unprocessed purchase checks, handling logic, and flow-based results.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/PC-1482

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

@comst19 네, PR 리뷰를 진행하겠습니다. 미처리 구매 내역 검증 및 재화 지급 처리 관련 변경사항들을 자세히 검토해보겠습니다.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (1)

64-69: Consider moving purchase collection to ViewModel.

Past review noted that the purchaseProduct collection could be moved to the ViewModel. While the current implementation works, handling purchase flows in the ViewModel would better separate UI and business logic concerns.

feature/store/src/main/java/com/puzzle/store/contract/StoreSideEffect.kt (1)

6-6: userId type inconsistency across store contracts.

This defines userId: String, but StoreState uses userId: Int. Refer to the comment on StoreState.kt for details on this type mismatch.

🧹 Nitpick comments (1)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1)

259-265: Consider adding the dispatcher for consistency.

Other coroutine launches in this ViewModel (e.g., initPuzzleCount, initMatchInfo) use viewModelScope.launch(dispatcher), but this function uses viewModelScope.launch without the dispatcher. For consistency, consider adding the dispatcher parameter.

🔎 Suggested fix
-    internal fun handlePurchaseEvent(product: PurchaseProduct) = viewModelScope.launch {
+    internal fun handlePurchaseEvent(product: PurchaseProduct) = viewModelScope.launch(dispatcher) {
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a76d053 and 2027f24.

📒 Files selected for processing (13)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (7 hunks)
  • core/billing/src/main/java/com/puzzle/billing/domain/BillingHelper.kt (1 hunks)
  • core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt (1 hunks)
  • core/data/src/main/java/com/puzzle/data/di/DataModule.kt (2 hunks)
  • feature/matching/build.gradle.kts (1 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt (5 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (5 hunks)
  • feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt (3 hunks)
  • feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (2 hunks)
  • feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (4 hunks)
  • feature/store/src/main/java/com/puzzle/store/contract/StoreSideEffect.kt (1 hunks)
  • feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt (1 hunks)
  • gradle/libs.versions.toml (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1)
core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
  • suspendRunCatching (5-13)
🪛 detekt (1.23.8)
core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt

[warning] 37-37: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)


[warning] 43-43: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)

🔇 Additional comments (19)
feature/matching/build.gradle.kts (1)

27-27: LGTM!

The billing module dependency addition correctly enables purchase verification and billing functionality in the matching feature.

feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (1)

39-39: LGTM!

The import enables coroutine usage for handling side effects.

feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt (1)

3-3: LGTM!

The test correctly integrates FakeBillingHelper to support the new billing dependency in MatchingViewModel.

Also applies to: 52-52

feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt (1)

78-84: LGTM!

The lifecycle-aware purchase event handling is correctly implemented. The author's explanation that screen-level collection avoids priority conflicts between matching and store screens is reasonable given that only these two screens display puzzle counts.

core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt (2)

18-26: LGTM!

The SharedFlow setup with extraBufferCapacity and BufferOverflow.DROP_OLDEST is appropriate for test scenarios. The emitPurchaseEvent helper enables deterministic testing of purchase flows.


33-43: Empty method bodies are intentional for this test fake.

The static analysis warnings about empty function blocks are false positives. Test doubles commonly use no-op implementations to provide minimal, predictable behavior for unit tests.

gradle/libs.versions.toml (1)

32-32: Version 8.2.1 of com.android.billingclient:billing-ktx is verified and has no known security advisories.

This is an officially supported version with no published vulnerabilities.

feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt (1)

11-11: Type mismatch: userId is Int in StoreState but String in StoreSideEffect.PurchaseProduct.

However, this is already handled—when creating the PurchaseProduct side effect in StoreViewModel.kt line 67, the userId is explicitly converted using .toString(). No action required.

Likely an incorrect or invalid review comment.

feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (2)

50-54: LGTM on dependency injection pattern.

The billingHelper follows the same internal val visibility pattern as eventHelper and navigationHelper, maintaining consistency for testability purposes.


111-112: LGTM on unprocessed purchase check placement.

Calling checkUnprocessedPurchases() after user info validation ensures purchases are only processed for authenticated, non-banned users.

core/data/src/main/java/com/puzzle/data/di/DataModule.kt (2)

85-89: LGTM on PaymentRepository binding.

The binding follows the established pattern for repository bindings in this module.


104-113: LGTM on BillingModule scope change to Singleton.

Moving BillingHelper to singleton scope is appropriate as it manages the BillingClient connection lifecycle and needs to be shared across ViewModels (Matching and Store) to handle unprocessed purchases consistently.

core/billing/src/main/java/com/puzzle/billing/domain/BillingHelper.kt (1)

11-17: Good API evolution for event-based purchase flow.

The changes are well-designed:

  • SharedFlow<PurchaseProduct> (non-nullable) is more appropriate than StateFlow<PurchaseProduct?> for discrete purchase events
  • Adding userId to purchaseProduct() enables purchase-to-user association via setObfuscatedAccountId
  • checkUnprocessedPurchases() addresses the PR objective of handling pending purchases

One consideration: the removal of release() relies on BillingClient's enableAutoServiceReconnection() for lifecycle management. Verify this is sufficient for your use case.

feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (3)

73-84: LGTM on purchase collection update.

The switch from collectLatest to collect is appropriate since the SharedFlow now emits non-null PurchaseProduct events. Error handling is properly maintained.


114-122: LGTM on getUserInfo implementation.

The function follows the established pattern in this ViewModel and properly handles errors via errorHelper.


66-67: Invalid userId handling for purchase operations.

The userId is a non-nullable Int with a default value of -1. If getUserInfo() fails, the purchase will proceed with -1 as the user ID, which is invalid and will cause billing issues. Ensure the purchase is blocked or deferred until a valid userId is available.

Likely an incorrect or invalid review comment.

core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (3)

46-51: LGTM on SharedFlow configuration.

The MutableSharedFlow with replay = 0, extraBufferCapacity = 1, and BufferOverflow.DROP_OLDEST is appropriate for discrete purchase events where only the latest emission matters if the collector is slow.


53-74: LGTM on PurchasesUpdatedListener implementation.

Good handling of purchase states with explicit branches for PURCHASED, PENDING, and other states. The TODO for pending state handling is noted.


117-137: LGTM on userId integration in purchase flow.

Using setObfuscatedAccountId(userId) properly associates the purchase with the user for tracking and unprocessed purchase recovery.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

66-68: Address TODO: Implement PENDING purchase state handling.

The PENDING state occurs when payment requires additional authentication (e.g., cash payments in some regions). Users should be notified that their payment is being processed.

Would you like me to generate an implementation for pending state handling, or should I open an issue to track this work?


142-154: Consider distinguishing between error and empty results.

queryPurchases() returns an empty list both when there are no purchases and when the query fails. This makes it impossible for callers to distinguish between these cases, potentially masking billing connection issues.

🔎 Option 1: Return Result wrapper
-    private suspend fun queryPurchases(): List<Purchase> = suspendCancellableCoroutine { continuation ->
+    private suspend fun queryPurchases(): Result<List<Purchase>> = suspendCancellableCoroutine { continuation ->
         val params = QueryPurchasesParams.newBuilder()
             .setProductType(BillingClient.ProductType.INAPP)
             .build()
 
         billingClient.queryPurchasesAsync(params) { result, purchases ->
             if (result.responseCode == BillingClient.BillingResponseCode.OK) {
-                continuation.resume(purchases)
+                continuation.resume(Result.success(purchases))
             } else {
-                continuation.resume(emptyList())
+                continuation.resume(Result.failure(Exception("Query failed: ${result.debugMessage}")))
             }
         }
     }

Then update checkUnprocessedPurchases() to handle the Result.

🔎 Option 2: Log errors before returning empty list
         billingClient.queryPurchasesAsync(params) { result, purchases ->
             if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                 continuation.resume(purchases)
             } else {
+                Log.e("Billing", "Query purchases failed: ${result.debugMessage}")
                 continuation.resume(emptyList())
             }
         }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2027f24 and 40fd2b3.

📒 Files selected for processing (1)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (7 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (1)

117-117: LGTM: Good practice to set obfuscated account ID.

Setting the obfuscated account ID helps Google Play associate purchases with user accounts for fraud detection and better purchase tracking.

Also applies to: 129-129

Comment on lines 53 to 74
private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
Log.d("purchasesUpdateListener", "$result $purchases")

if (result.responseCode == BillingClient.BillingResponseCode.OK && !purchases.isNullOrEmpty()) {
purchases.forEach { purchase ->
val token = purchase.purchaseToken
val uuid = purchase.products.firstOrNull() ?: return@forEach
_purchaseProduct.value = PurchaseProduct(uuid = uuid, credential = token)
if (result.responseCode != BillingClient.BillingResponseCode.OK || purchases.isNullOrEmpty()) {
return@PurchasesUpdatedListener
}

purchases.forEach { purchase ->
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
val productId = purchase.products.firstOrNull() ?: return@forEach
val token = purchase.purchaseToken
_purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
}
Purchase.PurchaseState.PENDING -> {
// TODO: 결제 대기 처리
}
else -> {
// 기타 상태 처리
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Purchases must be acknowledged after processing.

When a purchase reaches PURCHASED state, it must be acknowledged via billingClient.acknowledgePurchase() or consumed via billingClient.consumePurchase() within 3 days, or Google Play will automatically refund it. Currently, purchases are emitted but never acknowledged.

🔎 Recommended acknowledgment flow

After emitting the purchase, you should acknowledge it (or consume it if it's a consumable product):

Purchase.PurchaseState.PURCHASED -> {
    val productId = purchase.products.firstOrNull() ?: return@forEach
    val token = purchase.purchaseToken
    _purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
    
    // Acknowledge after successful emission
    if (!purchase.isAcknowledged) {
        val ackParams = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(token)
            .build()
        billingClient.acknowledgePurchase(ackParams) { result ->
            if (result.responseCode != BillingClient.BillingResponseCode.OK) {
                Log.e("Billing", "Acknowledgment failed: ${result.debugMessage}")
            }
        }
    }
}

Note: Acknowledgment typically happens after backend verification succeeds. Consider moving this to post-verification in your architecture.

🤖 Prompt for AI Agents
In core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt
around lines 53-74, purchased items are emitted but never acknowledged or
consumed which will cause automatic refunds; update the PURCHASED handling to
acknowledge (or consume for consumables) the purchase after successful
processing: check purchase.isAcknowledged, build AcknowledgePurchaseParams with
purchase.purchaseToken and call billingClient.acknowledgePurchase(...) and
handle the callback error logging; if the product is consumable use
billingClient.consumeAsync(...) similarly; ideally perform acknowledgment after
any backend verification succeeds — move or trigger the ack from the
verification completion path if applicable.

Copy link
Member

@tgyuuAn tgyuuAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1차 리뷰 완료!!

suspendRunCatching {
billingHelper.verifyPurchaseProduct(product)
}.onSuccess {
initPuzzleCount()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init을 하면 어떻게 되나요?
구매 성공시 재호출인 것 같은데 fetch라는 네이밍이 더 어올릴지도??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 유저의 퍼즐 개수를 가져오는 함수입니다.
여기가 아니더라도 매칭 화면에 들어올 때마다 쓰는 거라서 init 보다는 fetch가 좋겠네요! 수정하겠습니다.

Comment on lines 75 to 82
suspendRunCatching {
billingHelper.verifyPurchaseProduct(product)
}.onSuccess { verifyPurchaseProduct ->
getUserPuzzle()
setState { copy(purchasedPuzzleCount = verifyPurchaseProduct.rewardPuzzleCount) }
}.onFailure {
errorHelper.sendError(it)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collect 내부에서 runCatching을 하는 이유가 있나요?
Flow.catch 알아보시면 좋을 것 같아요!

Copy link
Collaborator Author

@comst19 comst19 Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 적용하니 더 깔끔하게 된 거 같아요!

=> 699082f

Comment on lines 78 to 79
getUserPuzzle()
setState { copy(purchasedPuzzleCount = verifyPurchaseProduct.rewardPuzzleCount) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUserPuzzle() 을 하는 이유는 뭔가요??
Ui재랜더링이라면 fetch가 더 적절할 것 같아요.

Copy link
Collaborator Author

@comst19 comst19 Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 위와 마찬가지로 fetch로 변경하겠습니다

=> ff0594a

Comment on lines 46 to 50
private val _purchaseProduct = MutableSharedFlow<PurchaseProduct>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문 :

스토어 화면에서 구매 진행된 뒤, collect 후 바로 다른 화면으로 전환되어 코루틴 Job이 취소되면 어떻게 되나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collect 했더라도 검증 API를 호출하기 전에 화면이 전환되어 Job이 취소되면, 해당 구매 건은 Consume되지 않은 미처리 상태로 남게 됩니다. => 검증 API를 쏘면 서버에서 확인 후 Consume 처리합니다.

이번 pr에서 매칭화면에 진입하여 유저 프로필을 받아올 수 있으면 미처리 상태의 재화를 조회하여 있다면 검증 api를 쏘는 로직을 추가했습니다.
BillingHelper의 queryPurchases(), checkUnprocessedPurchases() 입니다.

Comment on lines 55 to 57
if (result.responseCode != BillingClient.BillingResponseCode.OK || purchases.isNullOrEmpty()) {
return@PurchasesUpdatedListener
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 경우는 구매 실패인 것으로 보이는데, 구매 실패했을 때 사용자에게 UI적으로 무언갈 보여줄 필요가 없나요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI 상으로는 구매를 실패했거나 구매를 취소했을 때의 처리는 없습니다.

#183 pr에서 스피너 관련해서 추가 수정만 할 거 같습니다.

Comment on lines 69 to 71
else -> {
// 기타 상태 처리
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어떤 상태가 있는지 궁금해요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#183 pr에서 스피너를 넣으면서 사용할 거 같아서 분기처리를 했는데 사용하지 않을 거 같습니다.

public @interface PurchaseState {
    int UNSPECIFIED_STATE = 0;
    int PURCHASED = 1;
    int PENDING = 2;
}


val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.setObfuscatedAccountId(userId)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 메서드 기능 알려주세요~!

Copy link
Collaborator Author

@comst19 comst19 Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

결제 영수증에 인자 값(구매자 ID)를 포함시키는 메서드 입니다.

설정한 이유는 결제한 사람과 재화 받는 사람이 달라지는 사고를 막으려고 넣었습니다.
=> 아직 서버에서는 해당 로직을 하지 않은 것으로 알고 있습니다.

case:

  1. 계정 A로 결제 후 서버 검증 전 앱 종료
  2. 계정 B로 다시 로그인
  3. 앱 진입 시 미처리 재화 조회 로직 작동
  4. 서버에 검증 요청 시, 계정 B로 재화가 지급

공식 문서: https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder

Comment on lines 150 to 152
} else {
continuation.resume(emptyList())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 경우는 emptyList가 아니라 resumeWithException 으로 처리해서 스낵바 같은 UI를 뛰워줘야 할 것 같아요.

데이터를 불러오는 데 성공했는데 빈리스트와, 에러는 다르다고 생각해요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 그러네요, 미처리 상품이 없을 때라고 생각한 거 같네요 수정하겠습니다!
=> 1a15f87

val productId = purchase.products.firstOrNull() ?: return@forEach
val token = purchase.purchaseToken

_purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위와 마찬가지로 유실되면 어떻게 되나요?

Copy link
Collaborator Author

@comst19 comst19 Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번 pr에서 매칭화면에 진입하여 유저 프로필을 받아올 수 있으면 미처리 상태의 재화를 조회하여 있다면 검증 api를 쏘는 로직을 추가했습니다.
BillingHelper의 queryPurchases(), checkUnprocessedPurchases() 입니다.

<추가>
빌링 모듈에서 결제 검증까지 한 결과(Channel)을 내려주도록 수정했습니다.
=> b562926


interface BillingHelper {
val purchaseProduct: StateFlow<PurchaseProduct?>
val purchaseProduct: SharedFlow<PurchaseProduct>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SharedFlow인 이유가 궁금해요~
또한, 싱글톤 객체인데 발행될 때 collect 되지 않으면 유실될 것 같은데,
Channel 같은 것으로 버퍼를 주면 어떨까 싶어요.
이유가 있을까요?!

Copy link
Collaborator Author

@comst19 comst19 Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에는 매칭 화면과 스토어 화면 두 곳에서 이벤트를 관찰해야 해서, SharedFlow를 선택했습니다. 하지만 각각 화면이 보일 때 관찰하도록 할 예정이니 Channel로 바꾸면 될 거 같습니다. 또한 유실은 미처리 로직이 추가되어서 큰 문제가 없을 것 같았습니다.

다시 고민해보니, 검증 로직 자체를 BillingHelper 내부로 포함시키고 검증 결과만 flow(channel)로 내려주는 방향으로 바꾸면 좋을 거 같습니다.

=> b562926

추가
#183 에서 최종 billing 로직 봐주시면 감사하겠습니다.

Copy link
Member

@tgyuuAn tgyuuAn Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번엔 괜찮은데 다음 pr이랑 뭔가 겹치는 부분이 많은 것 같아요.
Cherrypick이랑 rebase 활용하시면 리뷰어가 더 편할 것 같아욥~~!
근형님 pr에서도 이전에 민수님 pr내용이랑 동일한 변경사항을 봤었던 것 같아서 근형님한테도 공유주시면 좋을 것 같네요.

이 부분은 다음 pr에서 이어나갈게요~

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (3)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (3)

46-51: Buffer capacity of 1 risks losing purchase events.

With extraBufferCapacity = 1 and DROP_OLDEST, only one purchase can be buffered. If multiple purchases occur rapidly before collection (e.g., user makes multiple purchases or checkUnprocessedPurchases finds multiple unprocessed items), earlier purchases will be dropped, leading to lost revenue and angry users.

🔎 Recommended fix
 private val _purchaseProduct = MutableSharedFlow<PurchaseProduct>(
     replay = 0,
-    extraBufferCapacity = 1,
-    onBufferOverflow = BufferOverflow.DROP_OLDEST
+    extraBufferCapacity = 64,
+    onBufferOverflow = BufferOverflow.SUSPEND
 )

This ensures purchases are queued rather than dropped.


53-74: Critical: Purchases must be acknowledged to prevent refunds and data loss.

This implementation has several critical issues:

  1. Missing acknowledgment: Purchases are never acknowledged via acknowledgePurchase() or consumed via consumeAsync(). Google Play will automatically refund un-acknowledged purchases after 3 days. Acknowledgment should occur after successful backend verification (in verifyPurchaseProduct success path).

  2. Silent emit failures: tryEmit() at line 64 returns false if the buffer is full, but the return value is not checked. Failed emits mean lost purchases with no error or retry mechanism.

  3. Duplicate emissions: Without filtering by isAcknowledged, the same purchase will be emitted repeatedly on every collection until acknowledged, causing duplicate verification requests and potential duplicate puzzle grants.

🔎 Recommended comprehensive fix
 purchases.forEach { purchase ->
+    if (purchase.isAcknowledged) return@forEach
+    
     when (purchase.purchaseState) {
         Purchase.PurchaseState.PURCHASED -> {
             val productId = purchase.products.firstOrNull() ?: return@forEach
             val token = purchase.purchaseToken
-            _purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
+            val emitted = _purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
+            if (!emitted) {
+                Log.e("Billing", "Failed to emit purchase: $productId")
+            }
         }
         Purchase.PurchaseState.PENDING -> {
-            // TODO: 결제 대기 처리
+            // Pending purchases will be delivered when completed
+            Log.d("Billing", "Purchase pending: ${purchase.products}")
         }
         else -> {
-            // 기타 상태 처리
+            Log.w("Billing", "Unexpected purchase state: ${purchase.purchaseState}")
         }
     }
 }

Then, add acknowledgment after successful verification in the verification success handler (likely in the caller or after paymentRepository.verifyPurchaseProduct succeeds):

// After verification succeeds
if (!purchase.isAcknowledged) {
    val ackParams = AcknowledgePurchaseParams.newBuilder()
        .setPurchaseToken(purchaseToken)
        .build()
    billingClient.acknowledgePurchase(ackParams) { result ->
        if (result.responseCode != BillingClient.BillingResponseCode.OK) {
            Log.e("Billing", "Acknowledgment failed: ${result.debugMessage}")
        }
    }
}

156-168: Same critical issues as purchasesUpdatedListener apply here.

This function has the same problems identified in lines 53-74:

  1. No isAcknowledged filter: Will re-emit the same purchases on every call until they're acknowledged, causing duplicates.
  2. Silent emit failures: tryEmit() at line 166 can fail silently if the buffer is full.
  3. No acknowledgment: These purchases must eventually be acknowledged after verification.
🔎 Recommended fix
 override suspend fun checkUnprocessedPurchases() {
     connectGooglePlayIfNeeded()
 
     val purchases = queryPurchases()
 
-    purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
+    purchases.filter { 
+        it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged 
+    }
         .forEach { purchase ->
             val productId = purchase.products.firstOrNull() ?: return@forEach
             val token = purchase.purchaseToken
 
-            _purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
+            val emitted = _purchaseProduct.tryEmit(PurchaseProduct(uuid = productId, credential = token))
+            if (!emitted) {
+                Log.e("Billing", "Failed to emit unprocessed purchase: $productId")
+            }
         }
 }
🧹 Nitpick comments (1)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1)

265-271: Consider adding error handling for purchase verification.

The function handles successful verification but doesn't handle failure cases. Since this processes user-initiated purchases, consider adding .onFailure to provide feedback if verification fails.

🔎 Suggested enhancement
 internal fun handlePurchaseEvent(product: PurchaseProduct) = viewModelScope.launch {
     suspendRunCatching {
         billingHelper.verifyPurchaseProduct(product)
     }.onSuccess {
         initPuzzleCount()
+    }.onFailure {
+        errorHelper.sendError(it)
     }
 }

Optional: Consider renaming initPuzzleCount to fetchPuzzleCount.

Based on a past review comment, since this function is used both for initialization and refresh (e.g., after purchases), fetch* might be a clearer name than init*.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 699082f and 1a15f87.

📒 Files selected for processing (2)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (7 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (5 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (2)

50-50: LGTM!

Clean dependency injection of BillingHelper with appropriate internal visibility for testing.


111-118: Background purchase check looks good.

The unprocessed purchase check runs independently after successful user info retrieval, with appropriate error handling for a background operation.

core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

117-117: LGTM! Good security enhancement.

Adding userId to the purchase flow and setting it via setObfuscatedAccountId helps prevent account mismatch scenarios where one user's purchase is credited to another user.

Also applies to: 129-129


142-154: Proper suspend implementation for querying purchases.

The use of suspendCancellableCoroutine correctly wraps the async callback, ensuring the caller suspends until results are available.

One minor concern: verify that purchases in the callback cannot be null when responseCode == OK. If it can, you'd need null handling:

billingClient.queryPurchasesAsync(params) { result, purchases ->
    if (result.responseCode == BillingClient.BillingResponseCode.OK) {
        continuation.resume(purchases ?: emptyList())
    } else {
        continuation.resumeWithException(RuntimeException("구매 조회 실패: ${result.responseCode}"))
    }
}

Based on learnings from past comments, the developer indicated this handles unprocessed purchases for cases where the app is closed before verification.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (1)

57-70: Fix scope lifecycle and add purchase acknowledgment.

Three issues require attention:

  1. Critical: Missing purchase acknowledgment. Google Play requires all purchases to be acknowledged within 3 days via billingClient.acknowledgePurchase() (for non-consumables) or billingClient.consumeAsync() (for consumables), or they will be automatically refunded. Currently, purchases are verified but never acknowledged.

  2. Major: Potential memory leak. The CoroutineScope with SupervisorJob is never cancelled. Consider implementing a cleanup method or tying the scope to a lifecycle.

  3. Major: Duplicate processing risk. Both purchasesUpdatedListener and checkUnprocessedPurchases() will process the same unacknowledged purchases repeatedly. Filter purchases by !purchase.isAcknowledged before processing.

🔎 Proposed fixes

1. Add acknowledgment after successful verification:

private suspend fun handlePurchaseVerification(purchase: Purchase) {
    val productIds = purchase.products
    if (productIds.isEmpty()) return

    try {
        val productId = purchase.products.firstOrNull() ?: return
        val token = purchase.purchaseToken

        val verifyResult = paymentRepository.verifyPurchaseProduct(
            PurchaseProduct(
                uuid = productId,
                credential = token
            )
        )
        _purchaseResult.send(PurchaseResult.Success(verifyResult.rewardPuzzleCount))
        
        // Acknowledge after successful verification
        if (!purchase.isAcknowledged) {
            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(token)
                .build()
            withContext(Dispatchers.IO) {
                billingClient.consumeAsync(consumeParams) { result, _ ->
                    if (result.responseCode != BillingClient.BillingResponseCode.OK) {
                        Log.e("Billing", "Consume failed: ${result.debugMessage}")
                    }
                }
            }
        }
    } catch (e: Exception) {
        _purchaseResult.send(PurchaseResult.Failure(e))
    }
}

2. Filter by acknowledgment status:

 private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
     if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
         purchases.forEach { purchase ->
-            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
+            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
                 scope.launch {
                     handlePurchaseVerification(purchase)
                 }
             }
         }
     } else if (result.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) {
         _purchaseResult.trySend(PurchaseResult.Failure(Exception("Billing Error: ${result.debugMessage}")))
     }
 }

3. Add cleanup method:

Add a public cleanup method and cancel the scope:

fun cleanup() {
    scope.cancel()
    billingClient.endConnection()
}
🧹 Nitpick comments (1)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (1)

113-133: Consider emitting failure when billing flow launch fails.

The userId parameter and setObfuscatedAccountId correctly prevent purchase-user mismatch scenarios. However, when launchBillingFlow fails, only a log is generated. Consider emitting PurchaseResult.Failure so the UI can notify the user.

🔎 Suggested improvement
 val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams)

 if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
     Log.e("Billing", "Purchase failed: ${billingResult.debugMessage}")
+    _purchaseResult.trySend(
+        PurchaseResult.Failure(Exception("Failed to launch billing flow: ${billingResult.debugMessage}"))
+    )
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ff0594a and ecf4468.

📒 Files selected for processing (1)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (6 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (2)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

53-55: LGTM: Channel-based result emission.

The migration from MutableSharedFlow to Channel<PurchaseResult> with Channel.BUFFERED (default capacity 64) properly addresses the previous buffer overflow concerns and provides clearer success/failure semantics.


159-171: LGTM: Proper suspend wrapper for async callback.

The suspendCancellableCoroutine correctly wraps the queryPurchasesAsync callback, ensuring the caller suspends until results are available. Error handling is appropriate.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (1)

277-287: Error suppression approach is reasonable.

Silently ignoring VERIFICATION_FAILED and RECEIPT_ALREADY_USED while forwarding other errors aligns with the background processing nature of this feature. This is a good refinement from completely removing failure handling.

🧹 Nitpick comments (1)
core/domain/src/main/java/com/puzzle/domain/model/error/HttpResponseException.kt (1)

153-153: Extra blank line.

There's an extra blank line here before the // Unknown comment. Consider removing it for consistency with the rest of the file formatting.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ecf4468 and 3577e80.

📒 Files selected for processing (2)
  • core/domain/src/main/java/com/puzzle/domain/model/error/HttpResponseException.kt (1 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (5 hunks)
🔇 Additional comments (5)
core/domain/src/main/java/com/puzzle/domain/model/error/HttpResponseException.kt (1)

146-151: New InAppPayment error codes added correctly.

The additions follow the existing pattern and are well-formatted. However, three of the four error codes (RECEIPT_ALREADY_USED, DUPLICATE_PAYMENT, PAYMENT_ALREADY_COMPLETED) appear semantically related and may overlap in usage scenarios. Consider documenting the specific scenarios where each should be used to prevent confusion and ensure consistent error handling throughout the billing flow.

Can you clarify the distinction between these three error codes? For example:

  • When should RECEIPT_ALREADY_USED be thrown vs. DUPLICATE_PAYMENT?
  • What differentiates DUPLICATE_PAYMENT from PAYMENT_ALREADY_COMPLETED?
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (4)

46-55: LGTM!

The billingHelper dependency is properly injected via constructor following the same pattern as other dependencies.


79-93: LGTM!

The method renames from init* to fetch* improve clarity as suggested in the past review. The naming now accurately reflects that these methods fetch data rather than initialize state.


112-118: LGTM!

The background check for unprocessed purchases is appropriately placed within the user info success flow. The fire-and-forget pattern with error forwarding to errorHelper is reasonable for this background task.


266-290: No action required — collectPurchaseResult() is already properly invoked from the Matching screen Composable.

The function is correctly called from MatchingScreen.kt within a LaunchedEffect with repeatOnLifecycle(Lifecycle.State.RESUMED), which provides proper lifecycle-aware coroutine scoping and automatic cancellation when the UI is not visible.

@comst19 comst19 requested review from kkh725 and tgyuuAn December 20, 2025 06:09
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

186-198: Fix suspend semantics: remove scope.launch and call handlePurchaseVerification directly.

This suspend function launches coroutines via scope.launch and returns immediately, violating the caller's expectation that a suspend function completes its work before returning. The launches at line 194 are fire-and-forget, meaning checkUnprocessedPurchases completes before verification finishes.

🔎 Recommended fix to await verification completion

Option 1 (Sequential processing):

     override suspend fun checkUnprocessedPurchases() {
         connectGooglePlayIfNeeded()

         val purchases = queryPurchases()

         purchases.filter {
             it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged
         }.forEach { purchase ->
-            scope.launch {
-                handlePurchaseVerification(purchase)
-            }
+            handlePurchaseVerification(purchase)
         }
     }

Option 2 (Parallel processing with proper suspension):

+    import kotlinx.coroutines.async
+    import kotlinx.coroutines.awaitAll
+    import kotlinx.coroutines.coroutineScope

     override suspend fun checkUnprocessedPurchases() {
         connectGooglePlayIfNeeded()

         val purchases = queryPurchases()

-        purchases.filter {
-            it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged
-        }.forEach { purchase ->
-            scope.launch {
-                handlePurchaseVerification(purchase)
-            }
-        }
+        coroutineScope {
+            purchases.filter {
+                it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged
+            }.map { purchase ->
+                async { handlePurchaseVerification(purchase) }
+            }.awaitAll()
+        }
     }

Option 1 is simpler and processes purchases sequentially. Option 2 processes in parallel but still suspends until all complete.


151-169: Critical: Purchases must be acknowledged after server verification succeeds.

After emitting PurchaseResult.Success, the purchase must be acknowledged via billingClient.acknowledgePurchase() (or consumed if it's a consumable). Google Play automatically refunds unacknowledged purchases after 3 days. Currently, no acknowledgment occurs anywhere in this file.

🔎 Recommended fix to acknowledge after verification
         try {
             val productId = purchase.products.firstOrNull() ?: return
             val token = purchase.purchaseToken

             val verifyResult = paymentRepository.verifyPurchaseProduct(
                 PurchaseProduct(
                     uuid = productId,
                     credential = token
                 )
             )
             _purchaseResult.send(PurchaseResult.Success(verifyResult.rewardPuzzleCount))
+            
+            // Acknowledge purchase after successful verification
+            if (!purchase.isAcknowledged) {
+                val ackParams = AcknowledgePurchaseParams.newBuilder()
+                    .setPurchaseToken(token)
+                    .build()
+                withContext(Dispatchers.IO) {
+                    billingClient.acknowledgePurchase(ackParams) { result ->
+                        if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+                            Log.e("Billing", "Acknowledgment failed: ${result.debugMessage}")
+                        }
+                    }
+                }
+            }
         } catch (e: Exception) {
             _purchaseResult.send(PurchaseResult.Failure(e))
         }

Note: Add import com.android.billingclient.api.AcknowledgePurchaseParams to imports.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3577e80 and 340c4f7.

📒 Files selected for processing (1)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (6 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (3)

53-55: LGTM: Channel-based purchase result signaling.

The use of Channel.BUFFERED (default capacity 64) with receiveAsFlow() provides robust buffering for purchase results and addresses the previous buffer capacity concern.


126-146: LGTM: User ID propagation prevents account-switching fraud.

Setting obfuscatedAccountId ensures the purchase is tied to the correct user, preventing scenarios where User A purchases but User B (after re-login) receives the rewards.


172-184: LGTM: Proper suspend semantics for queryPurchases.

The use of suspendCancellableCoroutine correctly wraps the async callback, ensuring the function suspends until the query completes and handles errors appropriately.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

157-172: Consider consolidating duplicate verification logic.

The verification logic in handlePurchaseVerification duplicates what's already in fetchPurchaseReward (lines 71-83). Both extract productId, call paymentRepository.verifyPurchaseProduct, and handle exceptions.

🔎 Proposed refactor to reuse fetchPurchaseReward
 private suspend fun handlePurchaseVerification(purchase: Purchase) {
-    val productId = purchase.products.firstOrNull() ?: return
     try {
-        val verifyResult = withContext(Dispatchers.IO) {
-            paymentRepository.verifyPurchaseProduct(
-                PurchaseProduct(
-                    uuid = productId,
-                    credential = purchase.purchaseToken
-                )
-            )
-        }
-        _purchaseResult.send(PurchaseResult.Success(verifyResult.rewardPuzzleCount))
+        val reward = fetchPurchaseReward(purchase)
+        _purchaseResult.send(PurchaseResult.Success(reward))
     } catch (e: Exception) {
         _purchaseResult.send(PurchaseResult.Failure(e))
     }
 }

This reduces duplication and ensures consistent verification behavior across both purchase flows.


188-208: Consider emitting an event when all verifications fail.

If all unprocessed purchases fail verification (each returns 0 via getOrDefault(0)), totalReward will be 0 and no PurchaseResult event will be emitted. This could silently hide verification failures from the UI.

💡 Alternative: emit Failure when all verifications fail
     val totalReward = supervisorScope {
-        purchases.map { purchase ->
+        val results = purchases.map { purchase ->
             async {
                 runCatching { fetchPurchaseReward(purchase) }.getOrDefault(0)
             }
-        }.awaitAll().sum()
+        }.awaitAll()
+        
+        if (results.all { it == 0 }) {
+            throw Exception("All purchase verifications failed")
+        }
+        results.sum()
     }

     if (totalReward > 0) {
         _purchaseResult.send(PurchaseResult.Success(totalReward))
     }

This ensures the user is notified when batch verification completely fails, though it may be intentional to silently skip failed purchases.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 86b9116 and f593c2e.

📒 Files selected for processing (4)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (6 hunks)
  • core/billing/src/main/java/com/puzzle/billing/domain/BillingHelper.kt (1 hunks)
  • core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt (1 hunks)
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (2)
core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
  • suspendRunCatching (5-13)
core/common-ui/src/main/java/com/puzzle/common/base/BaseViewModel.kt (1)
  • setState (39-41)
🪛 detekt (1.23.8)
core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt

[warning] 29-29: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)


[warning] 31-31: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (8)
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (3)

51-51: LGTM!

The BillingHelper injection enables unprocessed purchase checking and purchase result observation. The integration looks clean.


112-118: LGTM!

The concurrent launch of checkUnprocessedPurchases() is appropriate for background verification. Error handling via errorHelper ensures failures are surfaced without blocking the main user info flow.


266-280: Code properly handles infinite Flow collection with lifecycle awareness.

The function is correctly called from LaunchedEffect in MatchingScreen.kt wrapped with lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED), which prevents coroutine leaks and ensures proper lifecycle management.

core/billing/src/main/java/com/puzzle/billing/testing/FakeBillingHelper.kt (1)

14-32: LGTM!

The test fake correctly implements the BillingHelper interface with deterministic behavior suitable for testing. The Channel.BUFFERED capacity is appropriate for test scenarios.

Note: The empty function blocks flagged by static analysis are intentional for a test double and can be safely ignored.

core/billing/src/main/java/com/puzzle/billing/domain/BillingHelper.kt (1)

10-16: LGTM!

The interface changes provide a clean API surface:

  • Flow<PurchaseResult> is more appropriate than StateFlow for event signaling
  • userId parameter in purchaseProduct helps prevent wrong-user reward scenarios
  • checkUnprocessedPurchases() enables recovery from interrupted purchase flows
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (3)

49-51: LGTM!

The Channel.BUFFERED approach prevents event loss compared to the previous SharedFlow with limited capacity. The Flow exposure via receiveAsFlow() provides clean one-way event signaling.


55-69: LGTM!

The PurchasesUpdatedListener correctly handles purchase callbacks by launching coroutines for each purchase. Using scope.launch here is appropriate for fire-and-forget processing, and non-cancellation errors are properly emitted as PurchaseResult.Failure.


135-155: LGTM!

The addition of userId via setObfuscatedAccountId helps prevent wrong-user reward scenarios by embedding the user context in the purchase receipt.

Based on learnings, this addresses the case where a purchase is made on one account but rewards could be granted to a different account after app restart.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f593c2e and 7be5bc1.

📒 Files selected for processing (1)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (6 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (4)

49-51: LGTM! Channel-based signaling with adequate buffering.

The switch to Channel.BUFFERED (64 capacity) with suspend-on-overflow properly addresses the previous buffer capacity concern. Purchase events will now queue rather than drop.


53-69: LGTM! Proper coroutine setup and purchase listener.

The SupervisorJob with Dispatchers.Main.immediate correctly handles billing callbacks. The removal of nested scope.launch inside the listener (from past reviews) ensures proper error propagation. Using send() instead of trySend() guarantees delivery.


157-169: LGTM! Proper suspend wrapper for async callback.

The use of suspendCancellableCoroutine correctly wraps the callback-based queryPurchasesAsync, ensuring the function suspends until completion and properly propagates errors.


135-147: LGTM! User ID prevents account-switching exploits.

The addition of userId parameter and its use with setObfuscatedAccountId (line 147) prevents the scenario where User A makes a purchase, User B logs in before verification, and User B receives the rewards. Good security practice.

Based on past review comments, the backend should validate this obfuscated account ID during verification.

Comment on lines +85 to +92
private suspend fun handleSinglePurchase(purchase: Purchase) {
try {
val reward = fetchPurchaseReward(purchase)
_purchaseResult.send(PurchaseResult.Success(reward))
} catch (e: Exception) {
_purchaseResult.send(PurchaseResult.Failure(e))
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Purchases are never acknowledged.

After successful verification (line 87), the purchase must be acknowledged via billingClient.acknowledgePurchase() or consumed via billingClient.consumePurchase(). Without acknowledgment within 3 days, Google Play automatically refunds the purchase, resulting in revenue loss and users receiving both rewards and refunds.

The past review flagged this as critical and was marked "addressed," but no acknowledgment logic is visible in the current code.

🔎 Recommended fix to acknowledge after verification
 private suspend fun handleSinglePurchase(purchase: Purchase) {
     try {
         val reward = fetchPurchaseReward(purchase)
+        
+        // Acknowledge after successful verification
+        if (!purchase.isAcknowledged) {
+            val ackParams = AcknowledgePurchaseParams.newBuilder()
+                .setPurchaseToken(purchase.purchaseToken)
+                .build()
+            billingClient.acknowledgePurchase(ackParams) { result ->
+                if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+                    Log.e("Billing", "Acknowledgment failed: ${result.debugMessage}")
+                }
+            }
+        }
+        
         _purchaseResult.send(PurchaseResult.Success(reward))
     } catch (e: Exception) {
         _purchaseResult.send(PurchaseResult.Failure(e))
     }
 }

Add import:

import com.android.billingclient.api.AcknowledgePurchaseParams

Apply the same acknowledgment logic in checkUnprocessedPurchases after successful verification (around line 183).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt
around lines 85 to 92, the code verifies purchases but never acknowledges or
consumes them; add logic to call billingClient.acknowledgePurchase() (or
billingClient.consumePurchase() for consumables) immediately after successful
verification: build AcknowledgePurchaseParams with the purchase token, invoke
billingClient.acknowledgePurchase with a listener or suspend wrapper, check the
response code and handle errors, and only then send PurchaseResult.Success; also
apply the same acknowledgment flow in checkUnprocessedPurchases (around line
183) so previously verified-but-unacknowledged purchases are acknowledged too.

@tgyuuAn
Copy link
Member

tgyuuAn commented Dec 21, 2025

일단 pr이 무거워서 어프로브 눌러놓겠습니닷

@tgyuuAn tgyuuAn added 머지 해도될듯염🌟 현재 코드를 기존 코드에 합쳐도 될 것 같다라고 판단..! 🌟 and removed 리뷰 원해요🔥 피어의 리뷰를 기다리는 ing.. 🔥 labels Dec 21, 2025
* [PC-1487] store 진입 시 로딩전에 바로 스켈레톤 나오도록 수정

* [PC-1487] store 화면에서 결제 진행 시 spinner show/hide 로직

* [PC-1487] store 화면 리팩토링

* [PC-1487] 로그 제거

* [PC-1487] 스피너 표출 시 뒤로가기 이벤트 무시 로직

* [PC-1487] 결제 진행 상태 관리 로직 개선

* [PC-1487] 컨플릭트 수정

* [PC-1487] 컨플릭트 수정

* [PC-1487] 결제 로딩 상태(isPurchaseProcessing)를 제거하고 뷰모델 내부 로직으로 통합

* [PC-1487] purchaseProduct를 suspend로 변경

* [PC-1487] 미사용 import 정리

* [PC-1487] 스토어 로딩 처리 로직 위치 수정

* [PC-1487] 성공만 처리하도록 변경

* [PC-1487] when 분기처리 -> filterIsInstance

* [PC-1487] else -> PurchaseResult.Cancelled

* [PC-1487] isShowSpinner -> isPurchase

* [PC-1487] Loading을 compat, expanded 내부에서 분기 처리

* [PC-1487] 결제 상태 별 에러 메시지 추가 및 수정

* [PC-1487] purchasesUpdatedListener에서 각 상품 결제는 순차 처리 하도록 수정

* [PC-1487] DEVELOPER_ERROR 에러 메시지 수정
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (1)

227-244: Fix return type mismatch in early return.

Line 229 calls continuation.resume(Result.success(Unit)), but the function signature is suspend fun connectGooglePlayIfNeeded() which returns Unit, not Result<Unit>. This will cause a compilation error or type mismatch.

🔎 Proposed fix
     private suspend fun connectGooglePlayIfNeeded() = suspendCancellableCoroutine { continuation ->
         if (billingClient.isReady) {
-            continuation.resume(Result.success(Unit))
+            continuation.resume(Unit)
             return@suspendCancellableCoroutine
         }
♻️ Duplicate comments (2)
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

205-225: Major: Silent failure suppression loses error visibility.

Using runCatching { ... }.getOrDefault(0) at line 217 silently suppresses individual purchase verification failures. If a user has 3 unprocessed purchases and 2 fail verification, they receive only 1/3 of the expected reward with no indication of the failures.

Consider:

  • Logging failures at minimum
  • Optionally emitting individual results per purchase
  • Or including failure count/details in the aggregated result

Additionally, this method still lacks purchase acknowledgment logic (see separate critical comment on lines 116-123).

🔎 Recommended improvements
 override suspend fun checkUnprocessedPurchases() {
     connectGooglePlayIfNeeded()

     val purchases = queryPurchases().filter {
         it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged
     }

     if (purchases.isEmpty()) return

-    val totalReward = supervisorScope {
+    val results = supervisorScope {
         purchases.map { purchase ->
             async {
-                runCatching { fetchPurchaseReward(purchase) }.getOrDefault(0)
+                runCatching { 
+                    fetchPurchaseReward(purchase)
+                }.onFailure { 
+                    Log.e("Billing", "Purchase verification failed: ${purchase.orderId}", it)
+                }
             }
-        }.awaitAll().sum()
+        }.awaitAll()
     }
+    
+    val totalReward = results.mapNotNull { it.getOrNull() }.sum()
+    val failedCount = results.count { it.isFailure }
+    
+    if (failedCount > 0) {
+        Log.w("Billing", "$failedCount purchases failed verification")
+    }

     if (totalReward > 0) {
         _purchaseResult.send(PurchaseResult.Success(totalReward))
     }
 }

Add import:

import android.util.Log

116-123: Critical: Purchases must be acknowledged after successful verification.

After successful server verification (line 118), the purchase must be acknowledged via billingClient.acknowledgePurchase() or consumed. Without acknowledgment within 3 days, Google Play automatically refunds the purchase, causing revenue loss and allowing users to receive both rewards and refunds.

This issue was flagged as critical in multiple past reviews and marked "addressed," but no acknowledgment logic is present in the current code.

🔎 Recommended fix to acknowledge after verification
+import com.android.billingclient.api.AcknowledgePurchaseParams
+
 private suspend fun handleSinglePurchase(purchase: Purchase) {
     try {
         val reward = fetchPurchaseReward(purchase)
+        
+        // Acknowledge purchase after successful verification
+        if (!purchase.isAcknowledged) {
+            val ackParams = AcknowledgePurchaseParams.newBuilder()
+                .setPurchaseToken(purchase.purchaseToken)
+                .build()
+            
+            withContext(Dispatchers.IO) {
+                suspendCancellableCoroutine<Unit> { continuation ->
+                    billingClient.acknowledgePurchase(ackParams) { result ->
+                        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
+                            continuation.resume(Unit)
+                        } else {
+                            continuation.resumeWithException(
+                                Exception("Acknowledgment failed: ${result.debugMessage}")
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        
         _purchaseResult.send(PurchaseResult.Success(reward))
     } catch (e: Exception) {
         _purchaseResult.send(PurchaseResult.Failure(e))
     }
 }

Apply the same acknowledgment logic in checkUnprocessedPurchases (around line 217) after fetchPurchaseReward succeeds.

🧹 Nitpick comments (3)
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (1)

70-80: Validate userId before initiating purchase.

The purchase is initiated with currentState.userId.toString() (line 77), but there's no explicit check that userId != -1. If the user somehow triggers a purchase before getUserInfo completes, the purchase would proceed with "-1" as the userId.

Although the loading state should prevent interaction until initialization completes, adding a defensive check would improve robustness:

🔎 Proposed guard
 is StoreIntent.OnPurchaseClick -> {
+    if (currentState.userId == -1) {
+        errorHelper.sendError(IllegalStateException("User ID not loaded"))
+        return
+    }
 
     setState { copy(isPurchase = true) }
 
     _sideEffects.send(
         StoreSideEffect.PurchaseProduct(
             intent.purchaseProduct,
             currentState.userId.toString()
         )
     )
 }
core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (2)

52-52: Consider adding lifecycle management for the coroutine scope.

The CoroutineScope is never cancelled, which can lead to resource leaks if the BillingHelper is released. Although the module is singleton-scoped, consider either:

  • Adding a cleanup method (e.g., close() or release()) that cancels the scope
  • Using a lifecycle-aware scope if available in your DI framework
🔎 Proposed cleanup approach
+    fun release() {
+        scope.cancel()
+    }

Then ensure this is called when the billing module is no longer needed.


166-189: Minor style: Simplify early-return pattern.

The run block at lines 168-171 is unnecessary since you're returning immediately after sending the failure. Consider simplifying for clarity.

🔎 Optional simplification
-    val offerToken = purchaseProduct.detail.oneTimePurchaseOfferDetails?.offerToken
-        ?: run {
-            _purchaseResult.send(PurchaseResult.Failure(IllegalStateException("Product has no offer token")))
-            return
-        }
+    val offerToken = purchaseProduct.detail.oneTimePurchaseOfferDetails?.offerToken
+        ?: return _purchaseResult.send(PurchaseResult.Failure(IllegalStateException("Product has no offer token")))

Or even clearer:

+    val offerToken = purchaseProduct.detail.oneTimePurchaseOfferDetails?.offerToken
+    if (offerToken == null) {
+        _purchaseResult.send(PurchaseResult.Failure(IllegalStateException("Product has no offer token")))
+        return
+    }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7be5bc1 and 8599510.

📒 Files selected for processing (10)
  • core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt
  • core/billing/src/main/java/com/puzzle/billing/model/PurchaseResult.kt
  • feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt
  • feature/store/src/main/java/com/puzzle/store/StoreScreen.kt
  • feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt
  • feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt
  • feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreLoadingScreen.kt
  • feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreScreen.kt
  • feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreLoadingScreen.kt
  • feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreScreen.kt
🧰 Additional context used
🧬 Code graph analysis (4)
feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreScreen.kt (4)
feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreLoadingScreen.kt (1)
  • CompactStoreLoadingScreen (27-61)
feature/store/src/main/java/com/puzzle/store/ui/list/PromotionProductList.kt (1)
  • PromotionProductList (17-39)
feature/store/src/main/java/com/puzzle/store/ui/list/NormalProductList.kt (1)
  • NormalProductList (16-40)
feature/store/src/main/java/com/puzzle/store/ui/components/StoreDescription.kt (1)
  • StoreDescription (25-80)
feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreScreen.kt (5)
feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreLoadingScreen.kt (1)
  • ExpandedStoreLoadingScreen (31-80)
feature/store/src/main/java/com/puzzle/store/ui/components/StoreTopBar.kt (1)
  • StoreTopBar (26-49)
feature/store/src/main/java/com/puzzle/store/ui/list/PromotionProductList.kt (1)
  • PromotionProductList (17-39)
feature/store/src/main/java/com/puzzle/store/ui/list/NormalProductList.kt (1)
  • NormalProductList (16-40)
feature/store/src/main/java/com/puzzle/store/ui/components/StoreDescription.kt (1)
  • StoreDescription (25-80)
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (4)
feature/store/src/main/java/com/puzzle/store/ui/dialog/PurchaseSuccessDialog.kt (1)
  • PurchaseSuccessDialog (20-62)
feature/store/src/main/java/com/puzzle/store/ui/AdaptiveLayout.kt (1)
  • AdaptiveLayout (7-20)
feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreScreen.kt (1)
  • CompactStoreScreen (19-59)
feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreScreen.kt (1)
  • ExpandedStoreScreen (23-73)
feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (7)
core/network/src/main/java/com/puzzle/network/api/PieceApi.kt (1)
  • getUserInfo (88-89)
core/data/src/main/java/com/puzzle/data/repository/UserRepositoryImpl.kt (1)
  • getUserInfo (37-57)
core/domain/src/main/java/com/puzzle/domain/repository/UserRepository.kt (1)
  • getUserInfo (16-16)
core/network/src/main/java/com/puzzle/network/source/user/UserDataSource.kt (1)
  • getUserInfo (12-12)
core/network/src/main/java/com/puzzle/network/source/user/UserDataSourceImpl.kt (1)
  • getUserInfo (22-22)
core/common-ui/src/main/java/com/puzzle/common/base/BaseViewModel.kt (1)
  • setState (39-41)
core/common/src/main/java/com/puzzle/common/ResultUtil.kt (1)
  • suspendRunCatching (5-13)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (21)
feature/store/src/main/java/com/puzzle/store/StoreScreen.kt (5)

40-40: Good UX: Back navigation blocked during purchase.

Preventing navigation during the purchase flow ensures users don't accidentally interrupt the transaction.


46-50: Correctly placed in UI layer due to Activity requirement.

The side-effect collection and purchaseProduct call are properly located in the UI layer because:

  • The billingHelper.purchaseProduct() call requires an Activity parameter
  • ViewModels should not hold references to Android framework components like Activities

Adding userId to the purchase flow aligns with the PR's objective to verify and process purchases with proper user identification.

Regarding the past review comment: while moving logic to the ViewModel is generally preferred, this specific case requires the Activity context, so the current implementation is correct.


56-74: Well-structured layering of UI states.

The Box-based layout cleanly separates three concerns:

  1. Main store content (always rendered)
  2. Purchase progress overlay (isPurchase state)
  3. Success dialog (isShowPurchaseDialog state)

This approach maintains proper z-ordering and makes state-driven visibility clear.


77-101: Clean delegation to adaptive layouts.

The extracted StoreScreen composable properly delegates to compact/expanded layouts while managing shared concerns like blur effects and intent forwarding.


103-115: Effective interaction blocking during purchase.

The pointerInput(Unit) {} modifier correctly consumes all pointer events, preventing user interaction while the purchase is processing. Combined with the BackHandler, this provides solid UX to prevent purchase flow interruption.

Error handling is properly implemented: when purchase fails, isPurchase is reset to false via the collectPurchaseResult() flow, allowing users to retry or navigate away.

feature/store/src/main/java/com/puzzle/store/contract/StoreState.kt (1)

11-12: LGTM!

The new state fields isPurchase and userId are well-integrated into the purchase flow. The default values (false and -1) are appropriate sentinel values.

feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreLoadingScreen.kt (1)

3-3: LGTM! Animation timing improvement.

The reduced duration (5000ms → 1200ms) and switch to FastOutSlowInEasing create a more responsive and natural loading animation.

Also applies to: 71-71

feature/store/src/main/java/com/puzzle/store/ui/page/CompactStoreScreen.kt (1)

27-58: LGTM! Clean loading state handling.

The conditional rendering pattern appropriately delegates to CompactStoreLoadingScreen during the loading phase, with back navigation preserved.

feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreLoadingScreen.kt (2)

89-89: LGTM! Faster loading animations.

The reduced animation durations (1800ms → 1000ms for big card, 2400ms → 1200ms for list) improve perceived performance.

Also applies to: 104-104


111-111: Verify fixed delayStep is intentional.

The delayStep changed from computed 1f/itemCount to a fixed 0.15f. While this works well with the current itemCount = 4, if the item count changes in the future, the stagger timing will not adapt accordingly. Confirm this fixed value is intentional.

feature/store/src/main/java/com/puzzle/store/ui/page/ExpandedStoreScreen.kt (1)

31-72: LGTM! Consistent loading pattern.

The loading state handling mirrors CompactStoreScreen, maintaining consistency across screen variants.

feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt (2)

80-86: Good naming improvement: init → fetch.**

The renamed functions (fetchPuzzleCount, fetchFreeMatch, fetchMatchInfo) better convey that they retrieve and update data rather than just initialize.

Also applies to: 88-94, 96-96


113-119: LGTM! Non-blocking unprocessed purchase check.

The checkUnprocessedPurchases call is correctly launched in a separate coroutine with proper error handling. This ensures the main user info flow isn't blocked while background purchase verification occurs.

core/billing/src/main/java/com/puzzle/billing/model/PurchaseResult.kt (1)

1-8: LGTM! Well-designed domain model.

The PurchaseResult sealed class provides clear variants for all purchase outcomes. Including rewardPuzzle in Success enables immediate UI feedback without an additional query.

feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt (3)

44-56: LGTM! Efficient parallel initialization.

The concurrent fetching of user puzzle count, available products, and user info with joinAll optimizes startup time. The separate collectPurchaseResult launch ensures purchase results are observed throughout the ViewModel's lifecycle.


141-149: LGTM! User info fetch for purchase flow.

The new getUserInfo function correctly retrieves and stores the userId needed for the purchase flow.


85-111: Comprehensive purchase result handling.

The handling of all PurchaseResult variants is well-structured:

  • Success: Refreshes puzzle count and shows reward dialog
  • Failure: Delegates to error handler
  • Pending: Informs user via snackbar about async reward delivery
  • Cancelled: Silently ignored (appropriate UX)

The billingHelper.purchaseResult uses a Channel-based Flow (Channel.receiveAsFlow()), which correctly prevents replay of stale results. Each purchase result is consumed exactly once, making this pattern appropriate for one-time events.

core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt (4)

48-50: LGTM: Channel-based signaling properly configured.

Channel.BUFFERED (capacity 64 with SUSPEND overflow) addresses the previous concern about purchase event loss. The Flow API via receiveAsFlow() provides clean consumer-side semantics.


54-100: LGTM: Comprehensive purchase event handling.

The listener properly handles all purchase states and billing response codes with user-friendly error messages. The use of scope.launch allows async verification while maintaining proper error propagation through the channel.


191-203: LGTM: Proper suspend wrapper for async billing call.

The suspendCancellableCoroutine wrapper correctly handles the callback-based queryPurchasesAsync API, ensuring the caller awaits completion. Error handling is appropriate.


246-274: LGTM: Product conversion logic is clean.

The helper properly handles both product types and safely returns null for products without offers.

Comment on lines +267 to +280
internal suspend fun collectPurchaseResult() {
billingHelper.purchaseResult
.filterIsInstance<PurchaseResult.Success>()
.collect {

eventHelper.sendEvent(
PieceEvent.ShowSnackBar(
SnackBarState.Info("퍼즐이 추가로 지급되었습니다.")
)
)

fetchPuzzleCount()
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for collectPurchaseResult invocation
rg "collectPurchaseResult" --type=kt -A5 -B5

Repository: Piece-Puzzly/Piece-Android

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Search for collectPurchaseResult without specifying file type
rg "collectPurchaseResult" -A5 -B5

Repository: Piece-Puzzly/Piece-Android

Length of output: 4536


🏁 Script executed:

#!/bin/bash
# Get more context on StoreViewModel's collectPurchaseResult to see Failure handling
rg "collectPurchaseResult" --type=kt -A20 feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt

Repository: Piece-Puzzly/Piece-Android

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Get more context on StoreViewModel's collectPurchaseResult to see Failure handling
rg "collectPurchaseResult" -A20 feature/store/src/main/java/com/puzzle/store/StoreViewModel.kt

Repository: Piece-Puzzly/Piece-Android

Length of output: 1558


🏁 Script executed:

#!/bin/bash
# Find PurchaseResult definition to confirm it has Success, Failure, and Pending cases
rg "sealed.*PurchaseResult|class PurchaseResult|object PurchaseResult" -A10

Repository: Piece-Puzzly/Piece-Android

Length of output: 742


🏁 Script executed:

#!/bin/bash
# Verify the exact line numbers and confirm what the function does with the filtered result
sed -n '267,280p' feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt

Repository: Piece-Puzzly/Piece-Android

Length of output: 480


Handle all PurchaseResult states in MatchingViewModel.

Currently, collectPurchaseResult() filters only PurchaseResult.Success via filterIsInstance<>(), silently ignoring Failure, Pending, and Cancelled results. Compare with StoreViewModel.collectPurchaseResult(), which explicitly handles each case: Success updates UI, Failure reports the error via errorHelper, and Pending shows an in-progress message. Implement similar comprehensive handling for consistency and to ensure purchase failures are properly reported to the user.

🤖 Prompt for AI Agents
In
feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt
around lines 267-280, replace the current
filterIsInstance<PurchaseResult.Success>() collector with a full collector that
handles every PurchaseResult variant using a when expression: on Success send
the existing PieceEvent.ShowSnackBar(SnackBarState.Info("퍼즐이 추가로 지급되었습니다.")) and
call fetchPuzzleCount(); on Failure call errorHelper.reportError(error) (and
optionally show a user-facing SnackBar with the error message); on Pending send
a SnackBar indicating the purchase is in progress; on Cancelled send a SnackBar
indicating the purchase was cancelled. Ensure you stop using filterIsInstance
and directly collect purchaseResult to cover all states.

@comst19 comst19 merged commit 8d6ff08 into develop Dec 22, 2025
2 checks passed
@comst19 comst19 deleted the fix/PC-1482 branch December 22, 2025 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ㅁㅅ민수 머지 해도될듯염🌟 현재 코드를 기존 코드에 합쳐도 될 것 같다라고 판단..! 🌟

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants