Implement freemium subscription system - Shared Kotlin models (Phase 4)

Shared Kotlin Features:
- Complete subscription data models with kotlinx.serialization
- SubscriptionApi client for all backend endpoints
- SubscriptionCache for storing subscription state
- SubscriptionHelper utility for checking user limits

Models (Subscription.kt):
- SubscriptionStatus (tier, usage, limits, master toggle flag)
- UsageStats (current usage counts)
- TierLimits (tier-specific limits, null = unlimited)
- UpgradeTriggerData (configurable prompts)
- FeatureBenefit (Free vs Pro comparison)
- Promotion (seasonal campaigns)
- ReceiptVerificationRequest/Response
- PurchaseVerificationRequest/Response

API Client (SubscriptionApi.kt):
- getSubscriptionStatus() - fetch user subscription
- getUpgradeTriggers() - fetch upgrade prompts
- getFeatureBenefits() - fetch tier comparison
- getActivePromotions() - fetch active promotions
- verifyIOSReceipt() - verify Apple purchase
- verifyAndroidPurchase() - verify Google purchase

Cache (SubscriptionCache.kt):
- Stores currentSubscription in mutableState
- Stores upgradeTriggers, featureBenefits, promotions
- Reactive state for UI observation
- clear() method for logout

Helper (SubscriptionHelper.kt):
- canAddProperty() - check property limit
- canAddTask() - check task limit
- shouldShowUpgradePromptForContractors() - check if should show upgrade screen
- shouldShowUpgradePromptForDocuments() - check if should show upgrade screen
- Respects master toggle (limitationsEnabled)
- Returns UsageCheck(allowed, triggerKey)

Next: iOS StoreKit implementation (Phase 5)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-24 13:30:53 -06:00
parent b2c3fac3f9
commit 65476e2d66
4 changed files with 255 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package com.example.mycrib.cache
import androidx.compose.runtime.mutableStateOf
import com.example.mycrib.models.FeatureBenefit
import com.example.mycrib.models.Promotion
import com.example.mycrib.models.SubscriptionStatus
import com.example.mycrib.models.UpgradeTriggerData
object SubscriptionCache {
val currentSubscription = mutableStateOf<SubscriptionStatus?>(null)
val upgradeTriggers = mutableStateOf<Map<String, UpgradeTriggerData>>(emptyMap())
val featureBenefits = mutableStateOf<List<FeatureBenefit>>(emptyList())
val promotions = mutableStateOf<List<Promotion>>(emptyList())
fun updateSubscriptionStatus(subscription: SubscriptionStatus) {
currentSubscription.value = subscription
}
fun updateUpgradeTriggers(triggers: Map<String, UpgradeTriggerData>) {
upgradeTriggers.value = triggers
}
fun updateFeatureBenefits(benefits: List<FeatureBenefit>) {
featureBenefits.value = benefits
}
fun updatePromotions(promos: List<Promotion>) {
promotions.value = promos
}
fun clear() {
currentSubscription.value = null
upgradeTriggers.value = emptyMap()
featureBenefits.value = emptyList()
promotions.value = emptyList()
}
}

View File

@@ -0,0 +1,71 @@
package com.example.mycrib.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubscriptionStatus(
val tier: String, // "free" or "pro"
@SerialName("subscribed_at") val subscribedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("auto_renew") val autoRenew: Boolean = true,
val usage: UsageStats,
val limits: TierLimits,
@SerialName("limitations_enabled") val limitationsEnabled: Boolean = false // Master toggle
)
@Serializable
data class UsageStats(
@SerialName("properties_count") val propertiesCount: Int,
@SerialName("tasks_count") val tasksCount: Int,
@SerialName("contractors_count") val contractorsCount: Int,
@SerialName("documents_count") val documentsCount: Int
)
@Serializable
data class TierLimits(
val properties: Int? = null, // null = unlimited
val tasks: Int? = null,
val contractors: Int? = null,
val documents: Int? = null
)
@Serializable
data class UpgradeTriggerData(
val title: String,
val message: String,
@SerialName("button_text") val buttonText: String
)
@Serializable
data class FeatureBenefit(
@SerialName("feature_name") val featureName: String,
@SerialName("free_tier") val freeTier: String,
@SerialName("pro_tier") val proTier: String
)
@Serializable
data class Promotion(
@SerialName("promotion_id") val promotionId: String,
val title: String,
val message: String,
val link: String? = null
)
@Serializable
data class ReceiptVerificationRequest(
@SerialName("receipt_data") val receiptData: String
)
@Serializable
data class PurchaseVerificationRequest(
@SerialName("purchase_token") val purchaseToken: String,
@SerialName("product_id") val productId: String
)
@Serializable
data class VerificationResponse(
val success: Boolean,
val tier: String? = null,
val error: String? = null
)

View File

@@ -0,0 +1,64 @@
package com.example.mycrib.network
import com.example.mycrib.models.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class SubscriptionApi {
private val baseUrl = ApiConfig.baseUrl
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
return apiCall {
httpClient.get("$baseUrl/subscription/status/") {
header("Authorization", "Token $token")
}.body()
}
}
suspend fun getUpgradeTriggers(token: String): ApiResult<Map<String, UpgradeTriggerData>> {
return apiCall {
httpClient.get("$baseUrl/subscription/upgrade-triggers/") {
header("Authorization", "Token $token")
}.body()
}
}
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
return apiCall {
httpClient.get("$baseUrl/subscription/feature-benefits/").body()
}
}
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
return apiCall {
httpClient.get("$baseUrl/subscription/promotions/") {
header("Authorization", "Token $token")
}.body()
}
}
suspend fun verifyIOSReceipt(token: String, receiptData: String): ApiResult<VerificationResponse> {
return apiCall {
httpClient.post("$baseUrl/subscription/verify-ios/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(ReceiptVerificationRequest(receiptData))
}.body()
}
}
suspend fun verifyAndroidPurchase(
token: String,
purchaseToken: String,
productId: String
): ApiResult<VerificationResponse> {
return apiCall {
httpClient.post("$baseUrl/subscription/verify-android/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(PurchaseVerificationRequest(purchaseToken, productId))
}.body()
}
}
}

View File

@@ -0,0 +1,83 @@
package com.example.mycrib.utils
import com.example.mycrib.cache.SubscriptionCache
object SubscriptionHelper {
data class UsageCheck(val allowed: Boolean, val triggerKey: String?)
fun canAddProperty(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) // Allow if no subscription data
// If limitations are disabled globally, allow everything
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null)
}
if (subscription.tier == "pro") {
return UsageCheck(true, null)
}
if (subscription.usage.propertiesCount >= (subscription.limits.properties ?: 1)) {
return UsageCheck(false, "add_second_property")
}
return UsageCheck(true, null)
}
fun canAddTask(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null)
// If limitations are disabled globally, allow everything
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null)
}
if (subscription.tier == "pro") {
return UsageCheck(true, null)
}
if (subscription.usage.tasksCount >= (subscription.limits.tasks ?: 10)) {
return UsageCheck(false, "add_11th_task")
}
return UsageCheck(true, null)
}
fun shouldShowUpgradePromptForContractors(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(false, null)
// If limitations are disabled globally, don't show prompt
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null)
}
// Pro users don't see the prompt
if (subscription.tier == "pro") {
return UsageCheck(false, null)
}
// Free users see the upgrade prompt
return UsageCheck(true, "view_contractors")
}
fun shouldShowUpgradePromptForDocuments(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(false, null)
// If limitations are disabled globally, don't show prompt
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null)
}
// Pro users don't see the prompt
if (subscription.tier == "pro") {
return UsageCheck(false, null)
}
// Free users see the upgrade prompt
return UsageCheck(true, "view_documents")
}
}