Billing & Subscriptions
Visla GPS uses Google Play Billing Library 8 for in-app subscription purchases on Android. The system follows a per-device licensing model: users choose how many GPS trackers to monitor and a billing period, then subscribe through Google Play. A parallel Stripe/web path exists for users who subscribe outside the app.
Data-layer details β API endpoints, DTOs, and repository implementations are documented in
data-layer/billing.md. This page covers architecture, UX flows, and integration design.
Architecture Overviewβ
There are two parallel subsystems:
| Subsystem | Purpose | Key classes |
|---|---|---|
| Play Billing | Purchase flow, product catalog, acknowledgement | IBillingManager, BillingManager, FakeBillingManager |
| Backend Billing | Subscription status, licenses, Stripe checkout/portal | BillingRepository, SubscriptionRepository, BillingApi, SubscriptionApi |
Per-Device Licensing Modelβ
Visla GPS sells subscriptions on a per-device basis. Each subscription includes a number of device licenses (1β10) that determine how many GPS trackers the user can monitor.
Domain Entitiesβ
// domain/repositories/BillingRepository.kt
data class License(
val allowed: Int, // total licenses in the subscription
val active: Int, // devices currently using a license
val available: Int, // remaining slots (allowed - active)
val suspended: Int // devices in suspended state
)
// 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
}
The DeviceLicenseInteractor combines license checking with purchase verification:
// domain/usecases/billing/DeviceLicenseInteractor.kt
class DeviceLicenseInteractor @Inject constructor(
private val getLicenseStatusUseCase: GetLicenseStatusUseCase,
private val verifyAndroidPurchaseUseCase: VerifyAndroidPurchaseUseCase
) {
suspend fun canAddDevice(currentDeviceCount: Int): Boolean {
val license = getLicenseStatusUseCase()
return currentDeviceCount < license.allowed
}
suspend fun verifyPurchase(purchaseToken: String, productId: String) {
verifyAndroidPurchaseUseCase(purchaseToken, productId)
}
}
When a user tries to add a new device, the app checks canAddDevice(). If licenses are
exhausted, the subscription screen is presented inline so the user can upgrade.
Plan Types & Product IDsβ
Product ID Formatβ
All product IDs follow the pattern sub_{period}_{devices}:
// data/billing/BillingManager.kt
companion object {
private val PRODUCT_IDS: List<String> = buildList {
val periods = listOf("monthly", "semiannual", "annual")
for (period in periods) {
for (devices in 1..10) {
add("sub_${period}_$devices")
}
}
}
}
This generates 30 products (3 periods Γ 10 device tiers).
Subscription Periodsβ
// data/billing/BillingManager.kt
enum class SubscriptionPeriod(val id: String, val displayName: String, val discount: Int) {
MONTHLY("monthly", "Mensile", 0),
SEMIANNUAL("semiannual", "Semestrale", 20),
ANNUAL("annual", "Annuale", 40)
}
| Period | ID | Display | Discount | Example 1-device price |
|---|---|---|---|---|
| Monthly | monthly | Mensile | 0% | β¬4.99/mo |
| Semiannual | semiannual | Semestrale | 20% | β¬23.99/6mo |
| Annual | annual | Annuale | 40% | β¬35.99/yr |
Prices scale linearly by device count (e.g., 3 devices monthly = β¬4.99 Γ 3 = β¬14.97). The annual plan is highlighted as "Best Value" in the UI.
IBillingManager Interfaceβ
IBillingManager abstracts all Play Billing operations, enabling swappable implementations
for debug and release builds.
// 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?>
get() = MutableStateFlow(null)
val isFake: Boolean
get() = false
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"
}
}
Key design points:
purchaseById()allows purchase by product ID string (needed forFakeBillingManagerwhereProductDetailscannot be constructed).getPriceById()returns formatted prices regardless of implementation.isFakeflag lets the UI adapt behavior for testing.- The static
getProductId()helper builds product IDs consistently.
Subscription Purchase Flowβ
1. User Selects Planβ
SubscriptionScreen presents device count (1β10) and period selectors. The product ID is
computed dynamically and the price is resolved from the billing manager:
// ui/subscription/SubscriptionScreen.kt
val currentProductId = IBillingManager.getProductId(selectedDevices, selectedPeriod)
val currentPrice = currentProduct?.let { billingManager.getFormattedPrice(it) }
?: billingManager.getPriceById(currentProductId)
?: "--"
2. Purchase Initiatedβ
The PurchaseButton component calls billingManager.purchaseById():
// ui/components/subscription/SubscriptionActions.kt
scope.launch {
billingManager.purchaseById(
activity = context,
productId = currentProductId,
userId = userId
)
}
3. BillingManager Launches Play Flowβ
In release builds, BillingManager constructs BillingFlowParams with the user's
obfuscated account ID and launches the Google Play purchase sheet:
// data/billing/BillingManager.kt
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setObfuscatedAccountId(userId.toString())
.build()
val result = billingClient?.launchBillingFlow(activity, billingFlowParams)
4. Purchase Callbackβ
When Play completes, onPurchasesUpdated fires. Successful purchases are handled by
handlePurchase():
// data/billing/BillingManager.kt
private suspend fun handlePurchase(purchase: Purchase) {
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
// 1. Verify with backend
val productId = purchase.products.firstOrNull() ?: ""
onPurchaseVerified(purchase.purchaseToken, productId)
// 2. Acknowledge the purchase
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient?.acknowledgePurchase(acknowledgePurchaseParams)
}
// 3. Refresh purchase state
queryPurchases()
}
}
5. Backend Verificationβ
The onPurchaseVerified callback is wired by the DI module to call the backend:
// di/ReleaseBillingModule.kt
BillingManager(context) { purchaseToken, productId ->
billingRepository.verifyAndroidPurchase(purchaseToken, productId)
}
This hits POST /api/billing/purchases/android/verify on the server, which validates the
purchase token with Google's servers and activates the subscription.
Complete Flow Diagramβ
Debug vs Release Billing Modulesβ
The app uses Hilt build-type modules to swap billing implementations.
Release: Real Google Playβ
// di/ReleaseBillingModule.kt
@Module
@InstallIn(SingletonComponent::class)
object ReleaseBillingModule {
@Provides @Singleton
fun provideBillingManager(
@ApplicationContext context: Context,
billingRepository: BillingRepository
): IBillingManager =
BillingManager(context) { purchaseToken, productId ->
billingRepository.verifyAndroidPurchase(purchaseToken, productId)
}
@Provides @Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl
}
Debug: FakeBillingManagerβ
// 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 = purchaseToken, productId = productId)
)
}
@Provides @Singleton
fun provideBillingRepository(impl: BillingRepositoryImpl): BillingRepository = impl
}
Key differences:
| Aspect | Release | Debug |
|---|---|---|
| Google Play connection | Real BillingClient | None β simulated |
| Product prices | From Play Console | Hardcoded (β¬4.99 base) |
| Purchase flow | Play purchase sheet | Instant simulated purchase |
| Backend verification | β Real | β Real (still calls backend) |
isFake flag | false | true |
successMessage | Not used | Shows debug confirmation dialog |
Both modules provide the real BillingRepositoryImpl β fake billing only bypasses
Google Play, never the backend. This means debug purchases create real subscription records
on the server.
FakeBillingManager Detailsβ
FakeBillingManager simulates Play Store behavior for development:
- Generates fake purchase tokens:
fake_purchase_token_{productId}_{timestamp} - Provides configurable test scenarios via
simulateLoadError,simulatePurchaseError, andsimulatePurchaseDelay - Pre-populates a price map mirroring real pricing for UI testing
- Still calls
onPurchaseVerifiedto exercise the full backend flow
Qualifier Annotationsβ
// di/BillingModule.kt
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class RealBilling
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class FakeBilling
Subscription State Machineβ
// domain/entities/Subscription.kt
enum class SubscriptionStatus {
ACTIVE, // subscription is current and paid
TRIALING, // in free trial period
PAST_DUE, // payment failed, in grace period
CANCELED, // user cancelled, may still have access until period end
INCOMPLETE, // initial payment not completed
INACTIVE; // no subscription
companion object {
fun fromString(value: String?): SubscriptionStatus = when (value?.lowercase()) {
"active" -> ACTIVE
"trialing" -> TRIALING
"past_due" -> PAST_DUE
"canceled" -> CANCELED
"incomplete" -> INCOMPLETE
else -> INACTIVE
}
}
}
The isActive computed property treats both ACTIVE and TRIALING as active:
val isActive: Boolean
get() = status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.TRIALING
The UI uses hasActiveSubscription from both SubscriptionInfo (backend) and
SubscriptionManagementUiState to decide whether to show management or purchase screens.
License Managementβ
Checking License Availabilityβ
When adding a device, the app checks license capacity before proceeding:
// domain/usecases/billing/DeviceLicenseInteractor.kt
suspend fun canAddDevice(currentDeviceCount: Int): Boolean {
val license = getLicenseStatusUseCase()
return currentDeviceCount < license.allowed
}
License UIβ
LicenseUsageCard displays a visual breakdown of license utilization:
- Active: devices currently using a license
- Total: total allowed licenses
- Available: remaining slots
- A progress bar showing utilization ratio
- Suspended device count (if any)
When licenses are exhausted, the AddDeviceScreen shows a warning and presents the
SubscriptionScreen inline so the user can upgrade without leaving the device-add flow.
Plan Modificationβ
The SubscriptionManagementScreen extracts the current plan details from the plan ID:
// ui/subscription/SubscriptionManagementViewModel.kt
private fun extractPeriodFromPlanId(planId: String?): SubscriptionPeriod? {
if (planId == null) return null
val parts = planId.split("_").toMutableList()
if (parts.firstOrNull() == "sub" && parts.size > 1) {
parts.removeAt(0)
}
if (parts.isEmpty()) return null
return when (parts[0]) {
"monthly" -> SubscriptionPeriod.MONTHLY
"semiannual" -> SubscriptionPeriod.SEMIANNUAL
"annual" -> SubscriptionPeriod.ANNUAL
else -> null
}
}
When modifying, the current device count and period are passed to SubscriptionScreen, which
highlights them and adjusts the purchase button text accordingly (e.g., "Increase Licenses",
"Change Duration").
Stripe / Web Billing Fallbackβ
Users who subscribe through the web (Stripe) are handled via SubscriptionRepository:
// domain/repositories/SubscriptionRepository.kt
interface SubscriptionRepository {
suspend fun getStatus(): Subscription
suspend fun createCheckoutSession(planKey: String): CheckoutSession
suspend fun getPortalUrl(): BillingPortal
}
The corresponding use cases:
// 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()
}
The UI detects the billing provider and displays it in StatusCard:
// ui/components/subscription/SubscriptionStatus.kt
Icon(
imageVector = when (provider) {
"google" -> Icons.Default.PlayArrow
"apple" -> Icons.Default.Phone
else -> Icons.Default.CreditCard // Stripe/web
},
...
)
Cancellation for Google subscriptions redirects to Play Store subscriptions page; Stripe subscriptions would use the billing portal URL.
UI Screen Structureβ
Navigation Routesβ
| Route | Screen | Purpose |
|---|---|---|
subscription_management | SubscriptionManagementScreen | View current plan, license usage, modify/cancel |
subscription_plans?current={n}&active={n}&period={p} | SubscriptionScreen | Select and purchase a plan |
SubscriptionManagementScreenβ
Loads subscription status and license info in parallel:
// ui/subscription/SubscriptionManagementViewModel.kt
coroutineScope {
val subscriptionInfoDeferred = async { billingRepository.getSubscriptionStatus() }
val licenseStatusDeferred = async { billingRepository.getLicenseStatus() }
// ...
}
Branches on state:
- Loading β spinner
- Active subscription β
ActiveSubscriptionContent(status card, license usage, actions) - No subscription β
NoSubscriptionContent(empty state with CTA)
SubscriptionScreenβ
Plan selection screen with composable cards:
| Component | Purpose |
|---|---|
DeviceSelectorCard | Grid of 1β10 device buttons |
PeriodSelectorCard | Monthly/Semiannual/Annual radio list with prices |
PriceSummaryCard | Total price display |
PurchaseButton | Context-aware CTA (Subscribe / Modify / Upgrade) |
RestorePurchasesButton | Restore previous purchases |
FeaturesCard | Feature checklist (GPS tracking, history, notifications, geofences, support) |
Design Decisionsβ
Why two billing repositories?β
BillingRepository handles Android-specific concerns (Play purchase verification, device
licenses) while SubscriptionRepository handles provider-agnostic subscription management
(status, checkout sessions, billing portal). This separation allows the app to support
multiple billing providers (Google, Stripe, Apple) with clean boundaries.
Why does FakeBillingManager still call the backend?β
Debug builds bypass Google Play but still call POST /api/billing/purchases/android/verify.
This ensures the full subscription lifecycle is exercised during development β the backend
creates real subscription records, license allocations happen, and status queries return
meaningful data. Only the Play purchase UI is faked.
Why build-type Hilt modules instead of a runtime toggle?β
Using src/debug/ and src/release/ source sets ensures the real BillingClient is never
instantiated in debug builds (avoiding Play Store connection errors on emulators) and the
FakeBillingManager is never shipped in production. This is enforced at compile time.
Why 30 separate product IDs?β
Google Play requires distinct product IDs for each SKU. Rather than using base plans with
offer tokens for device tiers, each combination of {period, devices} maps to a unique
product ID (sub_monthly_1 through sub_annual_10). This gives full pricing control
per-tier in Play Console and simplifies the purchase verification on the backend.
Why obfuscatedAccountId?β
BillingFlowParams.setObfuscatedAccountId(userId.toString()) links the Google Play purchase
to the Visla user account. This enables server-side purchase verification to associate the
subscription with the correct user without exposing internal IDs to Google.
Why inline subscription in AddDeviceScreen?β
When a user runs out of licenses while adding a device, the SubscriptionScreen is presented
as a bottom-sheet-style overlay rather than navigating away. This reduces friction β the
user can upgrade and immediately continue adding their device without losing context.