Skip to main content

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:

SubsystemPurposeKey classes
Play BillingPurchase flow, product catalog, acknowledgementIBillingManager, BillingManager, FakeBillingManager
Backend BillingSubscription status, licenses, Stripe checkout/portalBillingRepository, 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)
}
PeriodIDDisplayDiscountExample 1-device price
MonthlymonthlyMensile0%€4.99/mo
SemiannualsemiannualSemestrale20%€23.99/6mo
AnnualannualAnnuale40%€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 for FakeBillingManager where ProductDetails cannot be constructed).
  • getPriceById() returns formatted prices regardless of implementation.
  • isFake flag 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:

AspectReleaseDebug
Google Play connectionReal BillingClientNone β€” simulated
Product pricesFrom Play ConsoleHardcoded (€4.99 base)
Purchase flowPlay purchase sheetInstant simulated purchase
Backend verificationβœ… Realβœ… Real (still calls backend)
isFake flagfalsetrue
successMessageNot usedShows 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, and simulatePurchaseDelay
  • Pre-populates a price map mirroring real pricing for UI testing
  • Still calls onPurchaseVerified to 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​

RouteScreenPurpose
subscription_managementSubscriptionManagementScreenView current plan, license usage, modify/cancel
subscription_plans?current={n}&active={n}&period={p}SubscriptionScreenSelect 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:

ComponentPurpose
DeviceSelectorCardGrid of 1–10 device buttons
PeriodSelectorCardMonthly/Semiannual/Annual radio list with prices
PriceSummaryCardTotal price display
PurchaseButtonContext-aware CTA (Subscribe / Modify / Upgrade)
RestorePurchasesButtonRestore previous purchases
FeaturesCardFeature 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.