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