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:
37
composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt
vendored
Normal file
37
composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt
vendored
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user