[2026-04-28] Rename apple-browsers rules .mdc → .md for Obsidian compatibility
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
---
|
||||
source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/subscription-architecture.mdc
|
||||
confidence: 0.9
|
||||
namespace: work
|
||||
last_synced: 2026-04-28
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Subscription Architecture & Implementation
|
||||
|
||||
## Overview
|
||||
DuckDuckGo's subscription system provides access to premium features including VPN (Network Protection), Personal Information Removal (PIR), Identity Theft Restoration (ITR), and AI Chat. The system supports multiple purchase platforms and cross-platform activation.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Shared Foundation: BrowserServicesKit
|
||||
All subscription logic is centralized in `BrowserServicesKit/Sources/Subscription/`:
|
||||
|
||||
```swift
|
||||
// ✅ CORRECT - Use BrowserServicesKit for core subscription logic
|
||||
import BrowserServicesKit
|
||||
|
||||
final class SubscriptionViewModel: ObservableObject {
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
|
||||
init(subscriptionManager: SubscriptionManager = SubscriptionManager.shared) {
|
||||
self.subscriptionManager = subscriptionManager
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Don't duplicate subscription logic in platform code
|
||||
final class SubscriptionViewModel: ObservableObject {
|
||||
func checkSubscriptionStatus() {
|
||||
// Don't reimplement subscription logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Purchase Methods
|
||||
|
||||
#### iOS
|
||||
- **Purchase Method**: App Store only (StoreKit)
|
||||
- **Geographic Coverage**: Global
|
||||
- **Cross-Platform**: Can activate Stripe subscriptions from other platforms
|
||||
|
||||
#### macOS App Store Build
|
||||
- **Purchase Method**: App Store only (StoreKit)
|
||||
- **Geographic Coverage**: Global
|
||||
- **Cross-Platform**: Can activate Stripe subscriptions
|
||||
|
||||
#### macOS Direct Download Build
|
||||
- **US Users**: Stripe web purchases
|
||||
- **Non-US Users**: Redirected to iOS App Store
|
||||
- **Cross-Platform**: Primary platform for Stripe purchases
|
||||
|
||||
### Version Management
|
||||
ALWAYS use V2 implementations for new code:
|
||||
|
||||
```swift
|
||||
// ✅ CORRECT - Use V2 implementations
|
||||
let subscriptionManager = SubscriptionManagerV2()
|
||||
let purchaseManager = StorePurchaseManagerV2()
|
||||
let purchaseFlow = AppStorePurchaseFlowV2()
|
||||
|
||||
// ❌ INCORRECT - Don't use V1 implementations
|
||||
let subscriptionManager = SubscriptionManager() // Legacy
|
||||
let purchaseFlow = AppStorePurchaseFlow() // Legacy
|
||||
```
|
||||
|
||||
## Premium Features Implementation
|
||||
|
||||
### Feature Entitlements
|
||||
```swift
|
||||
// ✅ CORRECT - Check entitlements through SubscriptionManager
|
||||
final class FeatureViewModel: ObservableObject {
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
|
||||
var isFeatureEnabled: Bool {
|
||||
subscriptionManager.hasEntitlement(for: .networkProtection)
|
||||
}
|
||||
|
||||
var availableFeatures: [SubscriptionFeature] {
|
||||
subscriptionManager.entitlements.compactMap { entitlement in
|
||||
switch entitlement {
|
||||
case .networkProtection:
|
||||
return .vpn
|
||||
case .dataBrokerProtection:
|
||||
return .personalInformationRemoval
|
||||
case .identityTheftRestoration:
|
||||
return .identityTheftRestoration
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VPN Integration
|
||||
```swift
|
||||
// ✅ CORRECT - VPN entitlement integration
|
||||
final class VPNManager: ObservableObject {
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
|
||||
func enableVPN() async {
|
||||
guard subscriptionManager.hasEntitlement(for: .networkProtection) else {
|
||||
await showSubscriptionPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Enable VPN functionality
|
||||
await startVPNConnection()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Personal Information Removal (PIR)
|
||||
```swift
|
||||
// ✅ CORRECT - PIR implementation with freemium support
|
||||
final class PIRManager: ObservableObject {
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
|
||||
var isFreemiumEligible: Bool {
|
||||
// Check feature flag and eligibility
|
||||
FeatureFlags.shared.isEnabled(.freemiumPIR) &&
|
||||
!subscriptionManager.isUserSubscribed &&
|
||||
isUSUser
|
||||
}
|
||||
|
||||
func performScan() async {
|
||||
if subscriptionManager.hasEntitlement(for: .dataBrokerProtection) {
|
||||
await performFullScan()
|
||||
} else if isFreemiumEligible {
|
||||
await performLimitedScan()
|
||||
} else {
|
||||
await showSubscriptionPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Purchase Flow Implementation
|
||||
|
||||
### Free Trial Support
|
||||
```swift
|
||||
// ✅ CORRECT - Free trial implementation
|
||||
final class SubscriptionPurchaseViewModel: ObservableObject {
|
||||
@Published var isTrialEligible = false
|
||||
@Published var trialPeriod: String = ""
|
||||
|
||||
func checkTrialEligibility() async {
|
||||
guard FeatureFlags.shared.isEnabled(.privacyProFreeTrial) else {
|
||||
isTrialEligible = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check server-side eligibility
|
||||
let eligible = await subscriptionManager.checkFreshFreeTrialEligibility()
|
||||
await MainActor.run {
|
||||
isTrialEligible = eligible
|
||||
if let product = subscriptionManager.currentProduct,
|
||||
let offer = product.introductoryOffer {
|
||||
trialPeriod = offer.localizedPeriod
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Purchase
|
||||
```swift
|
||||
// ✅ CORRECT - Platform-aware purchase flow
|
||||
final class PurchaseFlowCoordinator {
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
|
||||
func initiatePurchase() async {
|
||||
#if os(iOS)
|
||||
// iOS always uses App Store
|
||||
await purchaseViaAppStore()
|
||||
#elseif os(macOS)
|
||||
if Bundle.main.isMacAppStore {
|
||||
await purchaseViaAppStore()
|
||||
} else {
|
||||
// Direct download build
|
||||
if isUSUser {
|
||||
await purchaseViaStripe()
|
||||
} else {
|
||||
await redirectToiOSApp()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Platform Activation
|
||||
|
||||
### URL Handling
|
||||
```swift
|
||||
// ✅ CORRECT - Subscription URL handling
|
||||
final class SubscriptionURLHandler {
|
||||
func handleSubscriptionURL(_ url: URL) {
|
||||
guard url.scheme == "duckduckgo",
|
||||
url.host == "subscription" else { return }
|
||||
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value {
|
||||
Task {
|
||||
await subscriptionManager.activateSubscription(with: token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Bridge
|
||||
```swift
|
||||
// ✅ CORRECT - V1 to V2 authentication migration
|
||||
final class AuthenticationManager {
|
||||
func migrateToV2() async {
|
||||
let bridge = SubscriptionAuthV1toV2Bridge()
|
||||
|
||||
if let v1Token = await bridge.extractV1Token() {
|
||||
await subscriptionManager.migrateFromV1(token: v1Token)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Mock Subscription Manager
|
||||
```swift
|
||||
// ✅ CORRECT - Mock for testing
|
||||
final class MockSubscriptionManager: SubscriptionManager {
|
||||
var mockEntitlements: [SubscriptionEntitlement] = []
|
||||
var mockSubscriptionStatus: Bool = false
|
||||
|
||||
override var isUserSubscribed: Bool {
|
||||
mockSubscriptionStatus
|
||||
}
|
||||
|
||||
override var entitlements: [SubscriptionEntitlement] {
|
||||
mockEntitlements
|
||||
}
|
||||
|
||||
override func hasEntitlement(for feature: SubscriptionFeature) -> Bool {
|
||||
mockEntitlements.contains(where: { $0.feature == feature })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Subscription States
|
||||
```swift
|
||||
// ✅ CORRECT - Test different subscription states
|
||||
final class SubscriptionViewModelTests: XCTestCase {
|
||||
private var viewModel: SubscriptionViewModel!
|
||||
private var mockManager: MockSubscriptionManager!
|
||||
|
||||
func testSubscribedUser() {
|
||||
// Given
|
||||
mockManager.mockSubscriptionStatus = true
|
||||
mockManager.mockEntitlements = [.networkProtection, .dataBrokerProtection]
|
||||
|
||||
// When
|
||||
viewModel.checkSubscriptionStatus()
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(viewModel.isSubscribed)
|
||||
XCTAssertTrue(viewModel.hasVPNAccess)
|
||||
XCTAssertTrue(viewModel.hasPIRAccess)
|
||||
}
|
||||
|
||||
func testFreeTrialEligibility() async {
|
||||
// Given
|
||||
FeatureFlags.shared.enable(.privacyProFreeTrial)
|
||||
mockManager.mockTrialEligibility = true
|
||||
|
||||
// When
|
||||
await viewModel.checkTrialEligibility()
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(viewModel.isTrialEligible)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Flags and Configuration
|
||||
|
||||
### Subscription Feature Flags
|
||||
```swift
|
||||
// ✅ CORRECT - Feature flag usage
|
||||
enum SubscriptionFeatureFlag: String, CaseIterable {
|
||||
case privacyProFreeTrial = "privacyProFreeTrial"
|
||||
case iosStripeSubscriptions = "iosStripeSubscriptions"
|
||||
case freemiumPIR = "DBPSubfeature.freemium"
|
||||
|
||||
var isEnabled: Bool {
|
||||
FeatureFlags.shared.isEnabled(self)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```swift
|
||||
// ✅ CORRECT - Environment-based configuration
|
||||
extension SubscriptionEnvironment {
|
||||
static var `default`: SubscriptionEnvironment {
|
||||
#if os(iOS)
|
||||
// iOS always uses App Store
|
||||
return .appStore
|
||||
#elseif os(macOS)
|
||||
if Bundle.main.isMacAppStore {
|
||||
return .appStore
|
||||
} else {
|
||||
return FeatureFlags.shared.isEnabled(.iosStripeSubscriptions) ? .stripe : .appStore
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Analytics and Tracking
|
||||
|
||||
### Subscription Pixels
|
||||
```swift
|
||||
// ✅ CORRECT - Analytics implementation
|
||||
final class SubscriptionAnalytics {
|
||||
func trackPurchaseFlow(origin: SubscriptionFunnelOrigin) {
|
||||
PixelKit.fire(
|
||||
pixel: .subscriptionPurchaseFlowStarted,
|
||||
parameters: [
|
||||
"origin": origin.rawValue,
|
||||
"platform": currentPlatform.rawValue
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func trackTrialEligibility(eligible: Bool) {
|
||||
PixelKit.fire(
|
||||
pixel: .subscriptionTrialEligibilityCheck,
|
||||
parameters: [
|
||||
"eligible": eligible.description
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
### Security Considerations
|
||||
- Store authentication tokens in Keychain only
|
||||
- Use HTTPS for all subscription API calls
|
||||
- Validate receipts server-side
|
||||
- Implement proper token refresh logic
|
||||
|
||||
### Performance Optimization
|
||||
- Cache subscription status locally
|
||||
- Use background queues for API calls
|
||||
- Implement offline capability for cached states
|
||||
- Minimize UI blocking operations
|
||||
|
||||
### User Experience
|
||||
- Provide clear trial information
|
||||
- Handle purchase failures gracefully
|
||||
- Support subscription restoration
|
||||
- Maintain consistent UI across platforms
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
- Don't hardcode subscription URLs
|
||||
- Don't bypass entitlement checks
|
||||
- Don't duplicate subscription logic across platforms
|
||||
- Don't ignore V1 to V2 migration paths
|
||||
- Don't forget to handle cross-platform activation
|
||||
Reference in New Issue
Block a user