Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions core/billing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ android {
}

dependencies {
implementation(projects.core.common)
implementation(projects.core.domain)
implementation(projects.core.network)
implementation(projects.core.analytics)

implementation(libs.android.billingclient)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,16 @@ import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.queryProductDetails
import com.puzzle.analytics.AnalyticsHelper
import com.puzzle.billing.domain.BillingHelper
import com.puzzle.billing.domain.PaymentRepository
import com.puzzle.billing.model.NormalProduct
import com.puzzle.billing.model.PieceProduct
import com.puzzle.billing.model.PromotionProduct
import com.puzzle.billing.model.PurchaseResult
import com.puzzle.domain.model.payment.CashProduct
import com.puzzle.domain.model.payment.Product
import com.puzzle.domain.model.payment.PurchaseProduct
import com.puzzle.billing.model.CashProduct
import com.puzzle.billing.model.Product
import com.puzzle.billing.model.PurchaseProduct
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand All @@ -44,7 +41,6 @@ import kotlin.coroutines.resumeWithException

class BillingHelperImpl @Inject constructor(
private val paymentRepository: PaymentRepository,
private val analyticsHelper: AnalyticsHelper,
@ApplicationContext context: Context,
) : BillingHelper {
private val _purchaseResult = Channel<PurchaseResult>(Channel.BUFFERED)
Expand Down Expand Up @@ -104,7 +100,7 @@ class BillingHelperImpl @Inject constructor(
private suspend fun fetchPurchaseReward(purchase: Purchase): Int {
val productId = purchase.products.firstOrNull() ?: throw Exception("상품 ID 없습니다.")

val verifyResult = withContext(Dispatchers.IO) {
val rewardPuzzleCount = withContext(Dispatchers.IO) {
paymentRepository.verifyPurchaseProduct(
PurchaseProduct(
uuid = productId,
Expand All @@ -113,14 +109,7 @@ class BillingHelperImpl @Inject constructor(
)
}

if (verifyResult.rewardPuzzleCount > 0) {
analyticsHelper.trackRevenueEvent(
Copy link
Collaborator

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.

IOS 는 추가를 일단안한거같기도하고, 정확한 price 가 아니고 퍼즐값이라 일단 제거했어요

productId = productId,
price = verifyResult.rewardPuzzleCount.toDouble(),
)
}

return verifyResult.rewardPuzzleCount
return rewardPuzzleCount
}

private suspend fun handleSinglePurchase(purchase: Purchase) {
Expand Down Expand Up @@ -160,14 +149,15 @@ class BillingHelperImpl @Inject constructor(

val result = withContext(Dispatchers.IO) { billingClient.queryProductDetails(params) }
val products = result.productDetailsList ?: emptyList()
val playProductMap = products.associateBy { it.productId }

// 3. 서버 상품과 Google Play 상품 매칭 후 변환
return products.mapNotNull { product ->
val serverProduct = cashProducts.products.find { it.uuid == product.productId }
serverProduct?.let {
return cashProducts.products.mapNotNull { serverProduct ->
val playProduct = playProductMap[serverProduct.uuid]
playProduct?.let {
convertToPieceProduct(
productDetail = product,
serverProduct = it
productDetail = it,
serverProduct = serverProduct
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.puzzle.billing.data

import com.puzzle.billing.domain.PaymentRepository
import com.puzzle.domain.model.payment.CashProduct
import com.puzzle.domain.model.payment.PurchaseProduct
import com.puzzle.network.source.payment.PaymentDataSource
import com.puzzle.billing.mapper.payment.toDomain
import com.puzzle.billing.model.CashProduct
import com.puzzle.billing.model.PurchaseProduct
import com.puzzle.billing.network.source.payment.PaymentDataSource
import javax.inject.Inject

class PaymentRepositoryImpl @Inject constructor(
Expand Down
35 changes: 35 additions & 0 deletions core/billing/src/main/java/com/puzzle/billing/di/BillingModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.puzzle.billing.di

import com.puzzle.billing.data.BillingHelperImpl
import com.puzzle.billing.data.PaymentRepositoryImpl
import com.puzzle.billing.domain.BillingHelper
import com.puzzle.billing.domain.PaymentRepository
import com.puzzle.billing.network.source.payment.PaymentDataSource
import com.puzzle.billing.network.source.payment.PaymentDataSourceImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class BillingModule {
@Binds
@Singleton
abstract fun bindPaymentDataSource(
paymentDataSourceImpl: PaymentDataSourceImpl
): PaymentDataSource

@Binds
@Singleton
abstract fun bindPaymentRepository(
paymentRepositoryImpl: PaymentRepositoryImpl
): PaymentRepository

@Binds
@Singleton
abstract fun bindBillingHelper(
billingHelperImpl: BillingHelperImpl
): BillingHelper
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.puzzle.billing.domain
import android.app.Activity
import com.puzzle.billing.model.PieceProduct
import com.puzzle.billing.model.PurchaseResult
import com.puzzle.domain.model.payment.CashProduct
import com.puzzle.billing.model.CashProduct
import kotlinx.coroutines.flow.Flow

interface BillingHelper {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package com.puzzle.billing.domain

import com.puzzle.domain.model.payment.CashProduct
import com.puzzle.domain.model.payment.PurchaseProduct
import com.puzzle.domain.model.payment.VerifyPurchaseProduct
import com.puzzle.billing.model.CashProduct
import com.puzzle.billing.model.PurchaseProduct

interface PaymentRepository {
suspend fun getAvailableProduct(): CashProduct
suspend fun verifyPurchaseProduct(purchaseProduct: PurchaseProduct): VerifyPurchaseProduct
suspend fun verifyPurchaseProduct(purchaseProduct: PurchaseProduct): Int
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if VerifyPurchaseProduct exists in the billing module
echo "=== Searching for VerifyPurchaseProduct in billing module ==="
find core/billing -type f -name "*.kt" | xargs grep -l "VerifyPurchaseProduct" 2>/dev/null || echo "Not found in files"

# Search for definition of VerifyPurchaseProduct
echo -e "\n=== Searching for VerifyPurchaseProduct definition ==="
rg "class|data class|enum|sealed class|object|typealias.*VerifyPurchaseProduct" --type=kotlin

# Check the PaymentRepository file
echo -e "\n=== Content of PaymentRepository.kt ==="
find core/billing -name "PaymentRepository.kt" -type f -exec cat {} \;

# Search for usages of verifyPurchaseProduct method
echo -e "\n=== Finding callers of verifyPurchaseProduct ==="
rg -n "verifyPurchaseProduct" --type=kotlin -B2 -A2

Repository: Piece-Puzzly/Piece-Android

Length of output: 50382


🏁 Script executed:

#!/bin/bash
# Get the PaymentRepository.kt file content
echo "=== PaymentRepository.kt content ==="
cat core/billing/src/main/java/com/puzzle/billing/domain/PaymentRepository.kt

echo -e "\n=== VerifyPurchaseProduct.kt content ==="
cat core/billing/src/main/java/com/puzzle/billing/model/payment/VerifyPurchaseProduct.kt

echo -e "\n=== Usages of verifyPurchaseProduct method ==="
rg "verifyPurchaseProduct" --type=kotlin -B3 -A3

Repository: Piece-Puzzly/Piece-Android

Length of output: 4815


🏁 Script executed:

#!/bin/bash
# Get PaymentRepositoryImpl to see what toDomain() actually does
echo "=== PaymentRepositoryImpl.kt content ==="
cat core/billing/src/main/java/com/puzzle/billing/data/PaymentRepositoryImpl.kt

# Check for toDomain extension functions
echo -e "\n=== Searching for toDomain extension on VerifyPurchaseProductResponse ==="
rg "fun.*VerifyPurchaseProductResponse.*toDomain" --type=kotlin -A5

# Check ProductMapper which likely has the mapping logic
echo -e "\n=== ProductMapper.kt content ==="
cat core/billing/src/main/java/com/puzzle/billing/mapper/payment/ProductMapper.kt

Repository: Piece-Puzzly/Piece-Android

Length of output: 2542


Return type should be VerifyPurchaseProduct instead of Int—this is an architectural inconsistency.

The domain model VerifyPurchaseProduct exists but is not being used. The repository currently returns a raw Int value, which:

  • Loses type safety: Primitive Int lacks semantic meaning; callers cannot distinguish this from other integer values.
  • Violates clean architecture: Domain repositories should return domain models, not primitives.
  • Breaks consistency: Other repository methods (e.g., getAvailableProduct()) correctly return domain models (CashProduct).

The current mapper in ProductMapper.kt explicitly converts VerifyPurchaseProductResponse to Int. Update it to return VerifyPurchaseProduct instead, and change the repository interface return type accordingly.

🤖 Prompt for AI Agents
In @core/billing/src/main/java/com/puzzle/billing/domain/PaymentRepository.kt at
line 8, Change the repository method signature and mapper to use the domain
model VerifyPurchaseProduct instead of Int: update the suspend fun
verifyPurchaseProduct(purchaseProduct: PurchaseProduct) declaration in
PaymentRepository (and any implementing classes) to return
VerifyPurchaseProduct, then modify the mapping in ProductMapper (the conversion
from VerifyPurchaseProductResponse) to produce a VerifyPurchaseProduct instance
rather than an Int, and adjust callers to consume the domain object; ensure all
compile-time types and imports are updated accordingly.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.puzzle.billing.mapper.payment

import com.puzzle.billing.model.CashProduct
import com.puzzle.billing.model.Product
import com.puzzle.network.model.UNKNOWN_INT
import com.puzzle.network.model.payment.BasicCashProductResponse
import com.puzzle.network.model.payment.GetAvailableProductResponse
import com.puzzle.network.model.payment.PromotionProductResponse
import com.puzzle.network.model.payment.VerifyPurchaseProductResponse
import java.math.BigDecimal

internal fun GetAvailableProductResponse.toDomain(): CashProduct =
CashProduct(
products = this.promotionCashProducts.map(PromotionProductResponse::toDomain) +
this.basicCashProducts.map(BasicCashProductResponse::toDomain),
)

internal fun BasicCashProductResponse.toDomain(): Product.BasicCashProduct =
Product.BasicCashProduct(
uuid = this.uuid,
name = this.name,
rewardPuzzleCount = this.rewardPuzzleCount,
originalAmount = BigDecimal(this.originalAmount),
discountRate = this.discountRate,
discountedAmount = BigDecimal(this.discountedAmount),
)

internal fun PromotionProductResponse.toDomain(): Product.PromotionProduct =
Product.PromotionProduct(
uuid = this.uuid,
imageUrl = this.cardImageUrl,
)

internal fun VerifyPurchaseProductResponse.toDomain(): Int = this.rewardPuzzleCount ?: UNKNOWN_INT
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.puzzle.domain.model.payment
package com.puzzle.billing.model

import java.math.BigDecimal

// api
data class CashProduct(
val products: List<Product>,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.puzzle.billing.model

import com.android.billingclient.api.ProductDetails

// for ui
sealed interface PieceProduct {
val id: String
val detail: ProductDetails
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.puzzle.billing.model

import com.puzzle.network.model.payment.VerifyPurchaseProductRequest
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: Network layer dependency contradicts module independence goal.

The import of com.puzzle.network.model.payment.VerifyPurchaseProductRequest creates a direct dependency from the billing module to the network layer. This contradicts the PR's stated objective of making the Billing module independent by removing external dependencies.

Impact:

  • Billing module now depends on network module
  • Cannot use/test PurchaseProduct without network module present
  • Violates separation of concerns between domain models and network DTOs

Recommendation:
Move the toDto() conversion logic to a dedicated mapper (e.g., PurchaseProductMapper) within the billing module's mapper layer, which can handle the dependency on network types. The domain model should remain dependency-free.

♻️ Suggested refactor to remove network dependency

Remove the network import and toDto() method from this file:

 package com.puzzle.billing.model
 
-import com.puzzle.network.model.payment.VerifyPurchaseProductRequest
-
 // api
 data class PurchaseProduct(
     val uuid: String,
     val credential: String,
     val store: Store = Store.PLAY_STORE,
-) {
-    fun toDto() = VerifyPurchaseProductRequest(
-        productUUID = uuid,
-        purchaseCredential = credential,
-        store = store.name,
-    )
-}
+)
 
 enum class Store {
     PLAY_STORE, APP_STORE
 }

Then create a mapper in core/billing/mapper/payment/PurchaseProductMapper.kt:

package com.puzzle.billing.mapper.payment

import com.puzzle.billing.model.PurchaseProduct
import com.puzzle.network.model.payment.VerifyPurchaseProductRequest

fun PurchaseProduct.toDto() = VerifyPurchaseProductRequest(
    productUUID = uuid,
    purchaseCredential = credential,
    store = store.name,
)
🤖 Prompt for AI Agents
In @core/billing/src/main/java/com/puzzle/billing/model/PurchaseProduct.kt at
line 3, The PurchaseProduct data class currently imports
com.puzzle.network.model.payment.VerifyPurchaseProductRequest and exposes a
toDto() conversion, creating an unwanted network-module dependency; remove that
import and any toDto() method from the PurchaseProduct class so the domain model
is dependency-free, and instead add a new mapper file (e.g.,
PurchaseProductMapper in core.billing.mapper.payment) that defines an extension
function fun PurchaseProduct.toDto(): VerifyPurchaseProductRequest mapping uuid
-> productUUID, credential -> purchaseCredential and store.name -> store,
keeping the network import only inside the mapper.


// api
data class PurchaseProduct(
val uuid: String,
val credential: String,
val store: Store = Store.PLAY_STORE,
) {
fun toDto() = VerifyPurchaseProductRequest(
productUUID = uuid,
purchaseCredential = credential,
store = store.name,
)
}

enum class Store {
PLAY_STORE, APP_STORE
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.puzzle.network.source.payment
package com.puzzle.billing.network.source.payment

import com.puzzle.domain.model.payment.PurchaseProduct
import com.puzzle.billing.model.PurchaseProduct
import com.puzzle.network.model.payment.GetAvailableProductResponse
import com.puzzle.network.model.payment.VerifyPurchaseProductResponse

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.puzzle.network.source.payment
package com.puzzle.billing.network.source.payment

import com.puzzle.domain.model.payment.PurchaseProduct
import com.puzzle.billing.model.PurchaseProduct
import com.puzzle.network.api.PieceApi
import com.puzzle.network.model.payment.GetAvailableProductResponse
import com.puzzle.network.model.payment.toDto
import com.puzzle.network.model.unwrapData
import javax.inject.Inject

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import android.app.Activity
import com.puzzle.billing.domain.BillingHelper
import com.puzzle.billing.model.PieceProduct
import com.puzzle.billing.model.PurchaseResult
import com.puzzle.domain.model.payment.CashProduct
import com.puzzle.billing.model.CashProduct
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.channels.Channel
Expand Down
25 changes: 19 additions & 6 deletions core/common/src/main/java/com/puzzle/common/TimeUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,12 @@ fun LocalDateTime.toBlockSyncFormattedTime(): String {
return this.format(formatter)
}

/**
* YYYY-MM-DD형식의 문자열을 "YYYYMMDD" 형식의 문자열로 변환합니다.
*/
/** YYYY-MM-DD형식의 문자열을 "YYYYMMDD" 형식의 문자열로 변환합니다 */
fun String.toCompactDateString(): String {
return this.replace("-", "")
}

/**
* 가장 가까운 이후의 오후 10시까지 남은 초를 구합니다.
*/
/** 가장 가까운 이후의 오후 10시까지 남은 초를 구합니다 */
fun getRemainingTimeInSec(): Long {
val zoneId = ZoneId.of(SEOUL_TIME_ZONE)
val now = LocalDateTime.now(zoneId)
Expand Down Expand Up @@ -99,19 +95,22 @@ fun formatIsoToDate(isoString: String): String {
val inputFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val outputFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss")
val dateTime = LocalDateTime.parse(isoString, inputFormatter)

return dateTime.format(outputFormatter)
}

fun formatTimeToHourMinuteSecond(totalSeconds: Long): String {
val hours = totalSeconds / HOUR_IN_SECOND
val minutes = (totalSeconds % HOUR_IN_SECOND) / MINUTE_IN_SECOND
val seconds = totalSeconds % MINUTE_IN_SECOND

return String.format(Locale.KOREA, " %02d:%02d:%02d ", hours, minutes, seconds)
}

fun formatTimeToMinuteSecond(totalSeconds: Long): String {
val minutes = totalSeconds / MINUTE_IN_SECOND
val seconds = totalSeconds % MINUTE_IN_SECOND

return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}

Expand All @@ -127,6 +126,20 @@ fun getResetDateString(
return standardDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
}

/** "yyyy.MM.dd.HH.mm.ss" 형식의 날짜 문자열로부터 24시간이 지났는지 확인 */
fun String.isOver24Hours(): Boolean {
return runCatching {
val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss")
val parsedDateTime = LocalDateTime.parse(this, formatter)
val now = LocalDateTime.now(ZoneId.of(SEOUL_TIME_ZONE))

val hoursSince = Duration.between(parsedDateTime, now).toHours()
hoursSince >= 24
}.getOrElse {
false // 파싱 실패, 타임존 문제, 포맷 불일치 등
}
}

const val DAILY_RESET_HOUR = 22

const val SECOND_IN_MILLIS: Long = 1000L
Expand Down
Loading