Billing & Subscription Data Layer
Two parallel subsystems handle billing:
| Subsystem | Concern | API | Repository |
|---|---|---|---|
| Billing | Google Play purchases, license counts, purchase verification | BillingApi | BillingRepository |
| Subscription | Server-side subscription status, checkout, portal | SubscriptionApi | SubscriptionRepository |
BillingApiβ
Retrofit interface for device-license and Android purchase verification endpoints.
File: data/remote/api/BillingApi.kt
interface BillingApi {
@GET("api/billing/subscription/status")
suspend fun getSubscriptionStatus(): SubscriptionStatusDto
@GET("api/devices/license/status")
suspend fun getLicenseStatus(): LicenseStatusDto
@POST("api/billing/purchases/android/verify")
suspend fun verifyAndroidPurchase(@Body request: VerifyPurchaseRequest): Unit
}
BillingApi DTOsβ
Defined in the same file (data/remote/api/BillingApi.kt):
data class SubscriptionStatusDto(
@SerializedName("is_subscribed") val isSubscribed: Boolean,
val status: String? = null,
@SerializedName("plan_id") val planId: String? = null,
val devices: Int? = null,
@SerializedName("expires_at") val expiresAt: String? = null,
val provider: String? = null,
@SerializedName("cancel_at_period_end") val cancelAtPeriodEnd: Boolean? = null
) {
val hasActiveSubscription: Boolean get() = isSubscribed
val deviceLimit: Int get() = devices ?: 0
}
data class LicenseStatusDto(
val allowed: Int,
val active: Int,
val suspended: Int,
val total: Int,
val needsSelection: Boolean,
val canAddMore: Boolean
) {
val available: Int get() = (allowed - active).coerceAtLeast(0)
}
data class VerifyPurchaseRequest(
@SerializedName("purchase_token") val purchaseToken: String,
@SerializedName("product_id") val productId: String
)
SubscriptionApiβ
Retrofit interface for server-managed subscription lifecycle.
File: data/remote/api/SubscriptionApi.kt
interface SubscriptionApi {
@GET("api/billing/status")
suspend fun getStatus(): SubscriptionDto
@POST("api/billing/checkout")
suspend fun createCheckoutSession(@Body request: CheckoutRequest): CheckoutSessionDto
@GET("api/billing/portal")
suspend fun getPortalUrl(): BillingPortalDto
}
Subscription DTOsβ
File: data/remote/dto/SubscriptionDtos.kt
data class CheckoutRequest(val planKey: String)
data class SubscriptionDto(
val status: String,
val planKey: String? = null,
@SerializedName("current_period_end") val currentPeriodEnd: String? = null,
@SerializedName("cancel_at_period_end") val cancelAtPeriodEnd: Boolean = false,
@SerializedName("trial_end") val trialEnd: String? = null,
@SerializedName("device_limit") val deviceLimit: Int = 1,
@SerializedName("devices_used") val devicesUsed: Int = 0
)
data class CheckoutSessionDto(val sessionId: String, val url: String)
data class BillingPortalDto(val url: String)
Domain Entitiesβ
File: domain/entities/Subscription.kt
data class Subscription(
val status: SubscriptionStatus,
val planKey: String?,
val currentPeriodEnd: Instant?,
val cancelAtPeriodEnd: Boolean,
val trialEnd: Instant?,
val deviceLimit: Int,
val devicesUsed: Int
) {
val isActive: Boolean
get() = status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.TRIALING
val canAddDevice: Boolean
get() = devicesUsed < deviceLimit
}
enum class SubscriptionStatus {
ACTIVE, TRIALING, PAST_DUE, CANCELED, INCOMPLETE, INACTIVE;
companion object {
fun fromString(value: String?): SubscriptionStatus = when (value?.lowercase()) {
"active" -> ACTIVE
"trialing" -> TRIALING
"past_due" -> PAST_DUE
"canceled" -> CANCELED
"incomplete" -> INCOMPLETE
else -> INACTIVE
}
}
}
data class CheckoutSession(val sessionId: String, val url: String)
data class BillingPortal(val url: String)
Domain value objects returned by BillingRepository:
File: domain/repositories/BillingRepository.kt (co-located with interface)
data class SubscriptionInfo(
val hasActiveSubscription: Boolean,
val planId: String?,
val expiresAt: String?,
val provider: String?
)
data class License(val allowed: Int, val active: Int, val available: Int, val suspended: Int)
BillingRepositoryβ
File: domain/repositories/BillingRepository.kt
interface BillingRepository {
suspend fun getSubscriptionStatus(): SubscriptionInfo
suspend fun getLicenseStatus(): License
suspend fun verifyAndroidPurchase(purchaseToken: String, productId: String)
}
BillingRepositoryImpl (release)β
File: data/repositories/BillingRepositoryImpl.kt
Production implementation β calls BillingApi and maps DTOs to domain models. Wraps HttpException / IOException into NetworkException.
@Singleton
class BillingRepositoryImpl @Inject constructor(
private val billingApi: BillingApi
) : BillingRepository {
override suspend fun getSubscriptionStatus(): SubscriptionInfo = try {
val response = billingApi.getSubscriptionStatus()
SubscriptionInfo(
hasActiveSubscription = response.hasActiveSubscription,
planId = response.planId,
expiresAt = response.expiresAt,
provider = response.provider
)
} catch (e: HttpException) {
throw NetworkException("HTTP error: ${e.code()}", e)
} catch (e: IOException) {
throw NetworkException("Network error fetching subscription status", e)
}
override suspend fun getLicenseStatus(): License = try {
val response = billingApi.getLicenseStatus()
License(
allowed = response.allowed,
active = response.active,
available = response.available,
suspended = response.suspended
)
} catch (e: HttpException) {
throw NetworkException("HTTP error: ${e.code()}", e)
} catch (e: IOException) {
throw NetworkException("Network error fetching license status", e)
}
override suspend fun verifyAndroidPurchase(purchaseToken: String, productId: String) {
try {
billingApi.verifyAndroidPurchase(
VerifyPurchaseRequest(purchaseToken = purchaseToken, productId = productId)
)
} catch (e: HttpException) {
throw NetworkException("HTTP error verifying purchase: ${e.code()}", e)
} catch (e: IOException) {
throw NetworkException("Network error verifying purchase", e)
}
}
}
FakeBillingRepositoryImpl (debug)β
File: debug/.../data/repositories/FakeBillingRepositoryImpl.kt
In-memory implementation that reads subscription state from FakeBillingManager. Used in debug builds to bypass Google Play while still testing the subscription flow.
@Singleton
class FakeBillingRepositoryImpl @Inject constructor(
private val billingManager: IBillingManager
) : BillingRepository {
override suspend fun getSubscriptionStatus(): SubscriptionInfo
override suspend fun getLicenseStatus(): License
override suspend fun verifyAndroidPurchase(purchaseToken: String, productId: String)
fun reset()
}
Key behaviours:
| Method | Behaviour |
|---|---|
getSubscriptionStatus() | Casts billingManager to FakeBillingManager; if hasActiveSubscription is true, returns SubscriptionInfo with provider = "fake" and the first purchased product ID as planId. |
getLicenseStatus() | Parses device count from the product ID format sub_{period}_{devices} (e.g. sub_monthly_5 β 5). Returns License with allowed = devices, active = 0. |
verifyAndroidPurchase() | Only processes tokens starting with "fake_purchase_token"; updates in-memory fakeSubscription and fakeLicense state. |
reset() | Resets both fakeSubscription and fakeLicense to inactive/zero defaults. |
Note: Despite
FakeBillingRepositoryImplexisting in the debug source set, theDebugBillingModuleactually binds the realBillingRepositoryImplforBillingRepository. The fake repository is available for manual/test use but is not wired via DI by default.
SubscriptionRepositoryβ
File: domain/repositories/SubscriptionRepository.kt
interface SubscriptionRepository {
suspend fun getStatus(): Subscription
suspend fun createCheckoutSession(planKey: String): CheckoutSession
suspend fun getPortalUrl(): BillingPortal
}
SubscriptionRepositoryImplβ
File: data/repositories/SubscriptionRepositoryImpl.kt
@Singleton
class SubscriptionRepositoryImpl @Inject constructor(
private val subscriptionApi: SubscriptionApi
) : SubscriptionRepository {
override suspend fun getStatus(): Subscription = try {
val response = subscriptionApi.getStatus()
Subscription(
status = SubscriptionStatus.fromString(response.status),
planKey = response.planKey,
currentPeriodEnd = response.currentPeriodEnd?.let { parseInstant(it) },
cancelAtPeriodEnd = response.cancelAtPeriodEnd,
trialEnd = response.trialEnd?.let { parseInstant(it) },
deviceLimit = response.deviceLimit,
devicesUsed = response.devicesUsed
)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error fetching subscription status", e)
}
override suspend fun createCheckoutSession(planKey: String): CheckoutSession = try {
val response = subscriptionApi.createCheckoutSession(CheckoutRequest(planKey))
CheckoutSession(sessionId = response.sessionId, url = response.url)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error creating checkout session", e)
}
override suspend fun getPortalUrl(): BillingPortal = try {
val response = subscriptionApi.getPortalUrl()
BillingPortal(url = response.url)
} catch (e: HttpException) {
throw mapHttpException(e)
} catch (e: IOException) {
throw NetworkException("Network error getting billing portal", e)
}
private fun parseInstant(dateString: String): Instant = try {
Instant.parse(dateString)
} catch (e: Exception) {
Logger.warn("SubscriptionRepository: Failed to parse instant: $dateString", ...)
Instant.now()
}
private fun mapHttpException(e: HttpException): Exception =
NetworkException("HTTP error: ${e.code()}", e)
}
SubscriptionMapperβ
File: data/mappers/SubscriptionMapper.kt
Standalone mapper (not currently used by SubscriptionRepositoryImpl, which does its own mapping inline). Provided for alternative wiring or testing.
@Singleton
class SubscriptionMapper @Inject constructor() {
fun toDomain(dto: SubscriptionDto): Subscription
fun toCheckoutSession(dto: CheckoutSessionDto): CheckoutSession
fun toBillingPortal(dto: BillingPortalDto): BillingPortal
}
| Method | Input β Output |
|---|---|
toDomain(SubscriptionDto) | SubscriptionDto β Subscription β parses status via SubscriptionStatus.fromString(), parses ISO-8601 date strings to Instant? (returns null on failure, unlike the repo which falls back to Instant.now()). |
toCheckoutSession(CheckoutSessionDto) | CheckoutSessionDto β CheckoutSession |
toBillingPortal(BillingPortalDto) | BillingPortalDto β BillingPortal |
IBillingManagerβ
In-app purchase abstraction over Google Play Billing Library. Shared interface between BillingManager (release) and FakeBillingManager (debug).
File: data/billing/IBillingManager.kt
interface IBillingManager {
val products: StateFlow<List<ProductDetails>>
val isLoading: StateFlow<Boolean>
val errorMessage: StateFlow<String?>
val purchasedSubscriptions: StateFlow<List<Purchase>>
val hasActiveSubscription: Boolean
val successMessage: StateFlow<String?> // default: MutableStateFlow(null)
val isFake: Boolean // default: false
fun clearSuccessMessage()
suspend fun loadProducts()
suspend fun purchase(activity: Activity, productDetails: ProductDetails, userId: Int)
suspend fun purchaseById(activity: Activity, productId: String, userId: Int)
suspend fun queryPurchases()
suspend fun restorePurchases()
fun getProduct(productId: String): ProductDetails?
fun getPriceById(productId: String): String?
fun isProductAvailable(productId: String): Boolean
fun getFormattedPrice(productDetails: ProductDetails): String
fun destroy()
companion object {
fun getProductId(devices: Int, period: SubscriptionPeriod): String =
"sub_${period.id}_$devices"
}
}
BillingManager (release)β
File: data/billing/BillingManager.kt
Real Google Play Billing integration. Implements IBillingManager and PurchasesUpdatedListener.
class BillingManager(
private val context: Context,
private val onPurchaseVerified: suspend (String, String) -> Unit
) : IBillingManager, PurchasesUpdatedListener
Product IDs: Generated as sub_{period}_{devices} for periods monthly, semiannual, annual and device counts 1β10 (30 products total).
| Method | Behaviour |
|---|---|
loadProducts() | Queries Google Play for all 30 subscription product IDs. Populates products state flow. |
purchase(activity, productDetails, userId) | Launches the Google Play billing flow. Sets obfuscatedAccountId to userId. |
purchaseById(activity, productId, userId) | Looks up ProductDetails via getProduct(), delegates to purchase(). |
onPurchasesUpdated(billingResult, purchases) | Callback from Google Play. On success, calls handlePurchase() for each purchase. |
handlePurchase(purchase) (private) | Calls onPurchaseVerified(token, productId) β acknowledges purchase β refreshes queryPurchases(). |
queryPurchases() | Queries active subscriptions from Google Play. Updates purchasedSubscriptions. |
restorePurchases() | Delegates to queryPurchases() with loading state. |
getProduct(productId) | Finds ProductDetails by productId in the loaded products list. |
getFormattedPrice(productDetails) | Extracts formatted price from the first pricing phase of the first offer. |
getPriceById(productId) | Combines getProduct() + getFormattedPrice(). |
isProductAvailable(productId) | Returns true if getProduct() is non-null. |
destroy() | Ends the billing client connection. |
SubscriptionPeriod enum (defined in same file):
enum class SubscriptionPeriod(val id: String, val displayName: String, val discount: Int) {
MONTHLY("monthly", "Mensile", 0),
SEMIANNUAL("semiannual", "Semestrale", 20),
ANNUAL("annual", "Annuale", 40)
}
FakeBillingManager (debug)β
File: debug/.../data/billing/FakeBillingManager.kt
Simulates billing without Google Play. Generates fake prices for all 30 product IDs based on base prices (monthly β¬4.99, semiannual β¬23.99, annual β¬35.99) multiplied by device count.
class FakeBillingManager(
private val onPurchaseVerified: suspend (purchaseToken: String, productId: String) -> Unit = { _, _ -> }
) : IBillingManager
| Property / Method | Behaviour |
|---|---|
isFake | Always true. |
fakePrices: StateFlow<Map<String, String>> | Pre-computed price map for all products. |
fakePurchasedProducts: StateFlow<Set<String>> | Tracks purchased product IDs in-memory. |
hasActiveSubscription | true if purchasedSubscriptions or fakePurchasedProducts is non-empty. |
simulateLoadError / simulatePurchaseError / simulatePurchaseDelay | Test configuration flags. |
loadProducts() | 500 ms delay; respects simulateLoadError. Keeps products empty (real ProductDetails can't be mocked). |
purchase(activity, productDetails, userId) | Delegates to purchaseById(). |
purchaseById(activity, productId, userId) | Simulates delay β calls onPurchaseVerified(fakeToken, productId) β adds to fakePurchasedProducts β sets successMessage. |
simulatePurchase(productId, userId) | Same as purchaseById but without Activity parameter. |
getProduct(productId) | Always returns null (cannot construct real ProductDetails). |
getPriceById(productId) | Returns price from fakePrices map. |
getFakePrice(productId) | Returns price from fakePrices map, defaults to "--". |
isProductAvailable(productId) | Checks fakePrices map. |
getFormattedPrice(productDetails) | Delegates to getFakePrice(). |
setError(message) / clearError() / clearSuccessMessage() | Test helpers to manipulate error/success state. |
destroy() | No-op. |
Hilt Wiringβ
BillingModule (shared qualifiers)β
File: di/BillingModule.kt
@Qualifier annotation class RealBilling
@Qualifier annotation class FakeBilling
DebugBillingModuleβ
File: debug/.../di/DebugBillingModule.kt
@Module @InstallIn(SingletonComponent::class)
object DebugBillingModule {
@Provides @Singleton
fun provideBillingManager(@ApplicationContext context: Context, billingApi: BillingApi): IBillingManager =
FakeBillingManager { purchaseToken, productId ->
billingApi.verifyAndroidPurchase(
VerifyPurchaseRequest(purchaseToken, productId)
)
}
@Provides @Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl
}
Debug builds use FakeBillingManager (bypasses Google Play) but bind the real BillingRepositoryImpl so purchase verification still goes to the backend.
ReleaseBillingModuleβ
File: release/.../di/ReleaseBillingModule.kt
@Module @InstallIn(SingletonComponent::class)
object ReleaseBillingModule {
@Provides @Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl
@Provides @Singleton
fun provideBillingManager(@ApplicationContext context: Context, billingRepository: BillingRepository): IBillingManager =
BillingManager(context) { purchaseToken, productId ->
billingRepository.verifyAndroidPurchase(purchaseToken, productId)
}
}
Release builds use the real BillingManager (Google Play) and real BillingRepositoryImpl.
Use Casesβ
File: domain/usecases/billing/BillingUseCases.kt
class GetSubscriptionStatusUseCase @Inject constructor(
private val subscriptionRepository: SubscriptionRepository
) {
suspend operator fun invoke(): Subscription = subscriptionRepository.getStatus()
}
class CreateCheckoutSessionUseCase @Inject constructor(
private val subscriptionRepository: SubscriptionRepository
) {
suspend operator fun invoke(planKey: String): CheckoutSession {
if (planKey.isBlank()) throw ValidationException("Plan key is required", "planKey")
return subscriptionRepository.createCheckoutSession(planKey.trim())
}
}
class GetBillingPortalUseCase @Inject constructor(
private val subscriptionRepository: SubscriptionRepository
) {
suspend operator fun invoke(): BillingPortal = subscriptionRepository.getPortalUrl()
}
Data Flow Summaryβ
- Read path: ViewModel β
GetSubscriptionStatusUseCaseβSubscriptionRepository.getStatus()βSubscriptionApiβ backend - Purchase path (release): ViewModel β
BillingManager.purchase()β Google Play βonPurchasesUpdatedβBillingRepository.verifyAndroidPurchase()βBillingApiβ backend - Purchase path (debug): ViewModel β
FakeBillingManager.purchaseById()βonPurchaseVerifiedcallback βBillingApi.verifyAndroidPurchase()β backend - License check: ViewModel β
BillingRepository.getLicenseStatus()βBillingApiβ backend