Skip to main content

Billing & Subscription Data Layer

Two parallel subsystems handle billing:

SubsystemConcernAPIRepository
BillingGoogle Play purchases, license counts, purchase verificationBillingApiBillingRepository
SubscriptionServer-side subscription status, checkout, portalSubscriptionApiSubscriptionRepository

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:

MethodBehaviour
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 FakeBillingRepositoryImpl existing in the debug source set, the DebugBillingModule actually binds the real BillingRepositoryImpl for BillingRepository. 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
}
MethodInput β†’ 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).

MethodBehaviour
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 / MethodBehaviour
isFakeAlways true.
fakePrices: StateFlow<Map<String, String>>Pre-computed price map for all products.
fakePurchasedProducts: StateFlow<Set<String>>Tracks purchased product IDs in-memory.
hasActiveSubscriptiontrue if purchasedSubscriptions or fakePurchasedProducts is non-empty.
simulateLoadError / simulatePurchaseError / simulatePurchaseDelayTest 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() β†’ onPurchaseVerified callback β†’ BillingApi.verifyAndroidPurchase() β†’ backend
  • License check: ViewModel β†’ BillingRepository.getLicenseStatus() β†’ BillingApi β†’ backend