From 65476e2d66689bc5da7fe079c6222ee55eb34ff2 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 24 Nov 2025 13:30:53 -0600 Subject: [PATCH] Implement freemium subscription system - Shared Kotlin models (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../example/mycrib/cache/SubscriptionCache.kt | 37 +++++++++ .../com/example/mycrib/models/Subscription.kt | 71 ++++++++++++++++ .../example/mycrib/network/SubscriptionApi.kt | 64 ++++++++++++++ .../mycrib/utils/SubscriptionHelper.kt | 83 +++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/network/SubscriptionApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt new file mode 100644 index 0000000..751cfca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/SubscriptionCache.kt @@ -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(null) + val upgradeTriggers = mutableStateOf>(emptyMap()) + val featureBenefits = mutableStateOf>(emptyList()) + val promotions = mutableStateOf>(emptyList()) + + fun updateSubscriptionStatus(subscription: SubscriptionStatus) { + currentSubscription.value = subscription + } + + fun updateUpgradeTriggers(triggers: Map) { + upgradeTriggers.value = triggers + } + + fun updateFeatureBenefits(benefits: List) { + featureBenefits.value = benefits + } + + fun updatePromotions(promos: List) { + promotions.value = promos + } + + fun clear() { + currentSubscription.value = null + upgradeTriggers.value = emptyMap() + featureBenefits.value = emptyList() + promotions.value = emptyList() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt new file mode 100644 index 0000000..e1ec4ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt @@ -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 +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/SubscriptionApi.kt new file mode 100644 index 0000000..b47e3a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/SubscriptionApi.kt @@ -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 { + return apiCall { + httpClient.get("$baseUrl/subscription/status/") { + header("Authorization", "Token $token") + }.body() + } + } + + suspend fun getUpgradeTriggers(token: String): ApiResult> { + return apiCall { + httpClient.get("$baseUrl/subscription/upgrade-triggers/") { + header("Authorization", "Token $token") + }.body() + } + } + + suspend fun getFeatureBenefits(): ApiResult> { + return apiCall { + httpClient.get("$baseUrl/subscription/feature-benefits/").body() + } + } + + suspend fun getActivePromotions(token: String): ApiResult> { + return apiCall { + httpClient.get("$baseUrl/subscription/promotions/") { + header("Authorization", "Token $token") + }.body() + } + } + + suspend fun verifyIOSReceipt(token: String, receiptData: String): ApiResult { + 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 { + return apiCall { + httpClient.post("$baseUrl/subscription/verify-android/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(PurchaseVerificationRequest(purchaseToken, productId)) + }.body() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt new file mode 100644 index 0000000..99631af --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt @@ -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") + } +}