From cfaceb5d151f49c1df93a383dfca94f642e5f2b2 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 1 Oct 2026 12:28:53 +0800 Subject: [PATCH] feat(commerce): integrate RevenueCat entitlements and paywalls --- .../ProOfferPresentationTests.swift | 168 ------- .../ProPaywallPresentationTests.swift | 98 ---- FrameworkTests/ProPaywallStateTests.swift | 61 +++ FrameworkTests/RevenueCatCommerceTests.swift | 239 ++++++++++ Frameworks/Resources/Localizable.xcstrings | 38 +- .../Commerce/ProAccessController.swift | 444 +++--------------- .../Commerce/ProPurchaseService.swift | 8 + .../Commerce/RevenueCatConfiguration.swift | 32 ++ .../RevenueCatEntitlementResolver.swift | 37 ++ .../RevenueCatProPurchaseService.swift | 80 ++++ .../ProOfferPresentation.swift | 68 --- .../ProPaywallContent.swift | 287 ----------- .../ProPaywallPresentation.swift | 137 ------ .../TermindPresentation/ProPaywallState.swift | 51 ++ .../TermindPresentation/Strings/Pro.swift | 2 +- Scripts/render-pro-paywall-icon.swift | 28 ++ TFI/Features/Commerce/MobileProView.swift | 104 ++-- TFI/Info.plist | 4 + TFITests/ProPaywallRenderingTests.swift | 85 ---- TFM/Features/Commerce/ProPaywallView.swift | 103 ++-- TFM/Info.plist | 4 + TFMTests/AppLanguageTests.swift | 21 - TFMTests/ArchitectureBoundaryTests.swift | 11 +- TFMTests/CommerceConfigurationTests.swift | 8 +- TFMTests/StoreKitTrialTests.swift | 254 +--------- Termind.xcodeproj/project.pbxproj | 41 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +- 27 files changed, 856 insertions(+), 1568 deletions(-) delete mode 100644 FrameworkTests/ProOfferPresentationTests.swift delete mode 100644 FrameworkTests/ProPaywallPresentationTests.swift create mode 100644 FrameworkTests/ProPaywallStateTests.swift create mode 100644 FrameworkTests/RevenueCatCommerceTests.swift create mode 100644 Frameworks/TermindInfrastructure/Commerce/ProPurchaseService.swift create mode 100644 Frameworks/TermindInfrastructure/Commerce/RevenueCatConfiguration.swift create mode 100644 Frameworks/TermindInfrastructure/Commerce/RevenueCatEntitlementResolver.swift create mode 100644 Frameworks/TermindInfrastructure/Commerce/RevenueCatProPurchaseService.swift delete mode 100644 Frameworks/TermindPresentation/ProOfferPresentation.swift delete mode 100644 Frameworks/TermindPresentation/ProPaywallContent.swift delete mode 100644 Frameworks/TermindPresentation/ProPaywallPresentation.swift create mode 100644 Frameworks/TermindPresentation/ProPaywallState.swift create mode 100644 Scripts/render-pro-paywall-icon.swift delete mode 100644 TFITests/ProPaywallRenderingTests.swift diff --git a/FrameworkTests/ProOfferPresentationTests.swift b/FrameworkTests/ProOfferPresentationTests.swift deleted file mode 100644 index b553a9bc..00000000 --- a/FrameworkTests/ProOfferPresentationTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Foundation -import TermindDomain -import TermindPresentation -import Testing - -struct ProOfferPresentationTests { - @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [1, 2, 4]) - func storeKitWeeklyTrialsDisplayTheirFullNumberOfDays(language: String, weeks: Int) throws { - var fixture = OfferFixture() - fixture.trialDuration = DateComponents(weekOfYear: weeks) - let weekly = fixture.presentation(locale: Locale(identifier: language)) - #expect(try #require(weekly.trialBadge).contains(String(weeks * 7))) - #expect(try #require(weekly.selectedPlanDetail).contains(String(weeks * 7))) - - fixture.trialDuration = DateComponents(day: weeks * 7) - let daily = fixture.presentation(locale: Locale(identifier: language)) - #expect(weekly.trialBadge == daily.trialBadge) - #expect(weekly.selectedPlanDetail == daily.selectedPlanDetail) - - fixture.canStartTrial = false - fixture.trialDuration = DateComponents(weekOfYear: weeks) - #expect(fixture.presentation().trialBadge == nil) - } - - @Test(arguments: [ProPurchasePlan.annual, .lifetime], [false, true]) - func purchaseTitleFollowsPlanAndTrialEligibility(plan: ProPurchasePlan, eligible: Bool) { - var fixture = OfferFixture() - fixture.plan = plan - fixture.canStartTrial = eligible - let expected: String.LocalizationValue = - switch plan { - case .annual: eligible ? "Start Free Trial" : "Subscribe Annually" - case .lifetime: "Buy Lifetime Access" - } - #expect(fixture.presentation().purchaseButtonTitle == String(localized: expected)) - } - - @Test(arguments: [false, true], [false, true]) - func annualDetailsRequireBothPriceAndBillingPeriod(hasPrice: Bool, hasPeriod: Bool) { - var fixture = OfferFixture() - fixture.annualPrice = hasPrice ? "$12.34" : nil - fixture.billingPeriod = hasPeriod ? DateComponents(year: 1) : nil - let offer = fixture.presentation() - #expect((offer.selectedPlanDetail != nil) == (hasPrice && hasPeriod)) - if let detail = offer.selectedPlanDetail { #expect(detail.contains("$12.34")) } - } - - @Test - func ineligibleTrialDataDoesNotAffectTheOffer() { - var fixture = OfferFixture() - fixture.canStartTrial = false - let withDuration = fixture.presentation() - fixture.trialDuration = nil - #expect(withDuration == fixture.presentation()) - #expect(withDuration.trialBadge == nil) - } - - @Test - func missingTrialDurationKeepsTheEligibleTitleWithoutInventingABadge() { - var fixture = OfferFixture() - let complete = fixture.presentation() - fixture.trialDuration = nil - let missingDuration = fixture.presentation() - fixture.canStartTrial = false - let paid = fixture.presentation() - #expect(missingDuration.purchaseButtonTitle == complete.purchaseButtonTitle) - #expect(missingDuration.purchaseButtonTitle != paid.purchaseButtonTitle) - #expect(missingDuration.trialBadge == nil) - #expect(missingDuration.selectedPlanDetail == paid.selectedPlanDetail) - } - - @Test(arguments: [Calendar.Component.year, .month, .weekOfYear, .day]) - func singularPeriodsUseAUnitNameAndPreserveUnitPriority(component: Calendar.Component) { - var fixture = OfferFixture() - var period = DateComponents() - period.setValue(1, for: component) - fixture.billingPeriod = period - let key: String.LocalizationValue = - switch component { - case .year: "year" - case .month: "month" - case .weekOfYear: "week" - default: "day" - } - #expect(fixture.presentation().annualBillingUnit == String(localized: key)) - if component == .year { - fixture.billingPeriod?.month = 1 - #expect(fixture.presentation().annualBillingUnit == String(localized: "year")) - } - } - - @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) - func compositePeriodsKeepOneFullUnitInTheEffectiveLocale(identifier: String) throws { - var fixture = OfferFixture() - let period = DateComponents(month: 3, day: 10) - fixture.billingPeriod = period - let locale = Locale(identifier: identifier) - let formatter = DateComponentsFormatter() - var calendar = Calendar(identifier: .gregorian) - calendar.locale = locale - formatter.calendar = calendar - formatter.unitsStyle = .full - formatter.maximumUnitCount = 1 - let expected = try #require(formatter.string(from: period)) - let offer = fixture.presentation(locale: locale) - #expect(offer.annualBillingUnit == expected) - #expect(offer.selectedPlanDetail?.contains(expected) == true) - #expect(offer.selectedPlanDetail?.contains("$12.34") == true) - } - - @Test - func nativeDefaultLookupRemainsIndependentOfTheDurationFormattingLocale() { - let fixture = OfferFixture() - let english = fixture.presentation(locale: Locale(identifier: "en")) - let chinese = fixture.presentation(locale: Locale(identifier: "zh-Hant")) - #expect(english.purchaseButtonTitle == chinese.purchaseButtonTitle) - #expect(english.annualBillingUnit == chinese.annualBillingUnit) - #expect(english.trialBadge != chinese.trialBadge) - } - - @Test - func callerLocalizationAppliesToEveryOfferLabel() throws { - var fixture = OfferFixture() - let offer = fixture.presentation { "[custom]" + String(localized: $0) } - let labels = [ - offer.purchaseButtonTitle, try #require(offer.annualBillingUnit), - try #require(offer.trialBadge), try #require(offer.selectedPlanDetail), - ] - #expect(labels.allSatisfy { $0.hasPrefix("[custom]") }) - fixture.plan = .lifetime - let lifetime = fixture.presentation { "[custom]" + String(localized: $0) } - #expect(lifetime.purchaseButtonTitle.hasPrefix("[custom]")) - #expect(lifetime.selectedPlanDetail?.hasPrefix("[custom]") == true) - } - - @Test - func lifetimeDetailsIgnoreAnnualProductAvailabilityWhileTheAnnualBadgeRemainsIndependent() { - var fixture = OfferFixture() - fixture.plan = .lifetime - let complete = fixture.presentation() - #expect(complete.trialBadge != nil) - fixture.annualPrice = nil - fixture.billingPeriod = nil - fixture.trialDuration = nil - let missing = fixture.presentation() - #expect(missing.purchaseButtonTitle == complete.purchaseButtonTitle) - #expect(missing.selectedPlanDetail == complete.selectedPlanDetail) - #expect(missing.selectedPlanDetail != nil) - #expect(missing.trialBadge == nil) - } -} - -private struct OfferFixture { - var plan: ProPurchasePlan = .annual - var annualPrice: String? = "$12.34" - var billingPeriod: DateComponents? = DateComponents(year: 1) - var trialDuration: DateComponents? = DateComponents(day: 7) - var canStartTrial = true - - func presentation( - locale: Locale = Locale(identifier: "en"), - localize: (String.LocalizationValue) -> String = { String(localized: $0) } - ) -> ProOfferPresentation { - ProOfferPresentation( - plan: plan, annualPrice: annualPrice, billingPeriod: billingPeriod, - trialDuration: trialDuration, canStartTrial: canStartTrial, locale: locale, localize: localize) - } -} diff --git a/FrameworkTests/ProPaywallPresentationTests.swift b/FrameworkTests/ProPaywallPresentationTests.swift deleted file mode 100644 index 728ce6ed..00000000 --- a/FrameworkTests/ProPaywallPresentationTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import TermindDomain -import TermindPresentation -import Testing - -struct ProPaywallPresentationTests { - @Test(arguments: [ - (ProSubscriptionPhase.introductoryTrial, "Free Trial", "Trial Ends"), - (.subscribed, "Annual Subscription", "Renews"), - (.gracePeriod, "Billing Grace Period", "Grace Period Ends"), - (.billingRetry, "Billing Issue", "Expired"), - (.expired, "Subscription Expired", "Expired"), - (.revoked, "Subscription Revoked", "Expired"), - ]) - func subscriptionKeepsItsStatusAndDateMeaning(phase: ProSubscriptionPhase, title: String, dateLabel: String) { - let status = subscription(phase: phase) - let access = ProAccessPresentation(status: status) - #expect(access.title.key == title) - #expect(access.dateLabel?.key == dateLabel) - #expect(access.date == .distantFuture) - #expect(ProPaywallPresentation(status: status).canManageSubscription) - } - - @Test(arguments: [ProOwnership.purchased, .familyShared]) - func canceledRenewalKeepsAccessAndShowsTheAccessEnd(ownership: ProOwnership) { - let status = subscription(phase: .subscribed, ownership: ownership, autoRenew: false) - let access = ProAccessPresentation(status: status) - #expect(access.dateLabel?.key == AppStrings.Settings.accessEnds.key) - #expect(status.hasProAccess) - #expect(!ProPaywallPresentation(status: status).showsPurchaseActions) - #expect(ProPaywallPresentation(status: status).canManageSubscription == (ownership == .purchased)) - } - - @Test(arguments: [ - ProSubscriptionPhase.introductoryTrial, .subscribed, .gracePeriod, .billingRetry, .expired, .revoked, - ]) - func sharedSubscriptionsIdentifyTheOwnerWithoutOfferingManagement(phase: ProSubscriptionPhase) { - let status = subscription(phase: phase, ownership: .familyShared) - #expect(ProAccessPresentation(status: status).isFamilyShared) - #expect(!ProPaywallPresentation(status: status).canManageSubscription) - } - - @Test(arguments: [ - (EntitlementStatus.loading, "Checking…"), - (.free(trialAvailable: true), "Free"), - (.purchased(.purchased), "Lifetime Access"), - (.purchased(.familyShared), "Lifetime Access · Family Shared"), - (.verificationFailed, "The App Store could not verify this purchase."), - (.unrestrictedForTesting, "Development Access"), - ]) - func nonSubscriptionStatesNeverShowSubscriptionDatesOrManagement(status: EntitlementStatus, title: String) { - let access = ProAccessPresentation(status: status) - #expect(access.title.key == title) - #expect(access.date == nil) - #expect(access.dateLabel == nil) - #expect(!ProPaywallPresentation(status: status).canManageSubscription) - } - - @Test - func missingPricesAndProcessingPreventPurchasesWithoutHidingRecovery() { - var presentation = ProPaywallPresentation( - status: .free(trialAvailable: false), lifetimePrice: "$49.99", - canPurchaseAnnual: true, canPurchaseLifetime: true) - #expect(presentation.needsProductReload) - #expect(!presentation.canPurchase(.annual)) - #expect(presentation.canPurchase(.lifetime)) - presentation.annualPrice = "$19.99" - #expect(!presentation.needsProductReload) - #expect(presentation.canPurchase(.annual)) - presentation.canPurchaseAnnual = false - #expect(!presentation.canPurchase(.annual)) - #expect(presentation.canPurchase(.lifetime)) - presentation.isProcessing = true - #expect(!presentation.canPurchase(.lifetime)) - #expect(presentation.showsPurchaseActions) - } - - @Test - func activeAccessStillShowsRestoreProgressAndErrors() { - var presentation = ProPaywallPresentation(status: .purchased(.purchased)) - #expect(!presentation.showsPurchaseActions) - presentation.isProcessing = true - #expect(presentation.showsPurchaseActions) - presentation.isProcessing = false - presentation.message = "Restore failed" - #expect(presentation.showsPurchaseActions) - #expect(!presentation.canPurchase(.annual)) - #expect(!presentation.canPurchase(.lifetime)) - } - - private func subscription( - phase: ProSubscriptionPhase, ownership: ProOwnership = .purchased, autoRenew: Bool = true - ) -> EntitlementStatus { - .subscription( - ProSubscriptionStatus( - phase: phase, expiresAt: .distantFuture, willAutoRenew: autoRenew, ownership: ownership)) - } -} diff --git a/FrameworkTests/ProPaywallStateTests.swift b/FrameworkTests/ProPaywallStateTests.swift new file mode 100644 index 00000000..61d244ae --- /dev/null +++ b/FrameworkTests/ProPaywallStateTests.swift @@ -0,0 +1,61 @@ +import Foundation +import TermindDomain +import TermindPresentation +import Testing + +struct ProPaywallStateTests { + @Test(arguments: [ProOwnership.purchased, .familyShared]) + func lifetimeAccessHasNoRenewalDateOrManagement(ownership: ProOwnership) { + let state = ProPaywallState(status: .purchased(ownership)) + #expect(state.hasProAccess) + #expect(state.state == "lifetime") + #expect(state.expiresAt == nil) + #expect(!state.canManageSubscription) + #expect(state.isFamilyShared == (ownership == .familyShared)) + } + + @Test(arguments: [true, false, nil] as [Bool?], [ProOwnership.purchased, .familyShared]) + func renewalStatePreservesAccessAndOwnership(autoRenew: Bool?, ownership: ProOwnership) { + let state = ProPaywallState( + status: .subscription( + .init( + phase: .subscribed, expiresAt: .distantFuture, willAutoRenew: autoRenew, ownership: ownership))) + #expect(state.hasProAccess) + #expect(state.expiresAt == .distantFuture) + #expect(state.state == (autoRenew == true ? "renewing" : autoRenew == false ? "ending" : "active")) + #expect(state.canManageSubscription == (ownership == .purchased)) + #expect(state.isFamilyShared == (ownership == .familyShared)) + } + + @Test(arguments: [ + (ProSubscriptionPhase.introductoryTrial, "trial", true), + (.gracePeriod, "grace", true), + (.billingRetry, "retry", false), + (.expired, "expired", false), + (.revoked, "revoked", false), + ]) + func subscriptionPhaseSelectsTheCorrectRemoteVariant(phase: ProSubscriptionPhase, code: String, active: Bool) { + let state = ProPaywallState( + status: .subscription( + .init( + phase: phase, expiresAt: .distantFuture, willAutoRenew: nil, ownership: .purchased))) + #expect(state.state == code) + #expect(state.hasProAccess == active) + #expect(state.expiresAt == .distantFuture) + #expect(state.canManageSubscription) + } + + @Test + func missingExpirationDoesNotInventADateAndUnverifiedAccessDoesNotUnlock() { + let trial = ProPaywallState( + status: .subscription( + .init( + phase: .introductoryTrial, expiresAt: nil, willAutoRenew: nil, ownership: .familyShared))) + #expect(trial.hasProAccess) + #expect(trial.expiresAt == nil) + #expect(!trial.canManageSubscription) + let unverified = ProPaywallState(status: .verificationFailed) + #expect(!unverified.hasProAccess) + #expect(unverified.state == "unverified") + } +} diff --git a/FrameworkTests/RevenueCatCommerceTests.swift b/FrameworkTests/RevenueCatCommerceTests.swift new file mode 100644 index 00000000..ca8c4c9a --- /dev/null +++ b/FrameworkTests/RevenueCatCommerceTests.swift @@ -0,0 +1,239 @@ +import Foundation +import RevenueCat +import TermindDomain +import Testing + +@testable import TermindInfrastructure + +@Suite @MainActor +struct RevenueCatCommerceTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test + func testStoreKeyCannotEnableProductionAndWhitespaceIsTrimmed() throws { + #expect( + RevenueCatConfiguration(apiKey: "test_example", entitlementID: "termind_pro", allowsTestStore: false) == nil + ) + let test = try #require( + RevenueCatConfiguration(apiKey: " test_example \n", entitlementID: "termind_pro", allowsTestStore: true)) + #expect(test.isTestStore) + #expect(test.apiKey == "test_example") + #expect( + RevenueCatConfiguration( + apiKey: "$(REVENUECAT_API_KEY)", entitlementID: "termind_pro", allowsTestStore: true) == nil) + #expect( + RevenueCatConfiguration(apiKey: "sk_private", entitlementID: "termind_pro", allowsTestStore: true) == nil) + #expect(RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "", allowsTestStore: false) == nil) + #expect( + RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "termind_pro", allowsTestStore: false) != nil + ) + } + + @Test + func onlyTheConfiguredEntitlementUnlocksPro() throws { + let info = try customerInfo(entitlementID: "another_entitlement", expiration: now.addingTimeInterval(60)) + #expect(resolve(info) == .free(trialAvailable: false)) + } + + @Test + func lifetimeAndTestStoreProductIDsAreResolvedThroughTheEntitlement() throws { + let info = try customerInfo(expiration: nil) + #expect(resolve(info) == .purchased(.purchased)) + } + + @Test + func cancelledSubscriptionRetainsAccessUntilExpiryAndFamilyOwnership() throws { + let info = try customerInfo( + expiration: now.addingTimeInterval(60), period: "trial", ownership: "FAMILY_SHARED", cancelled: true) + guard case .subscription(let status) = resolve(info) else { + Issue.record("Missing subscription") + return + } + #expect(status.phase == .introductoryTrial) + #expect(status.ownership == .familyShared) + #expect(status.willAutoRenew == false) + #expect(status.expiresAt == now.addingTimeInterval(60)) + } + + @Test + func cachedActiveFlagCannotExtendAnExpiredSubscription() throws { + let info = try customerInfo(expiration: now.addingTimeInterval(60)) + #expect(info.entitlements.all["termind_pro"]?.isActive == true) + #expect( + !RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now.addingTimeInterval(61)) + .hasProAccess) + } + + @Test + func graceUsesItsOwnDeadlineAndBillingRetryDoesNotUnlock() throws { + let graceEnd = now.addingTimeInterval(120) + let info = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true, grace: graceEnd) + guard case .subscription(let status) = resolve(info) else { + Issue.record("Missing grace") + return + } + #expect(status.phase == .gracePeriod) + #expect(status.expiresAt == graceEnd) + let retry = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true) + #expect(!resolve(retry).hasProAccess) + #expect(!RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: graceEnd).hasProAccess) + } + + @Test + func refundsAndFailedVerificationCannotUnlock() throws { + #expect(!resolve(try customerInfo(expiration: now.addingTimeInterval(60), refunded: true)).hasProAccess) + #expect(resolve(try customerInfo(expiration: nil, verification: 2)) == .verificationFailed) + } + + @Test + func restoreUsesRevenueCatAndKeepsTheAccountVariantOpen() async { + let service = FakeService() + let controller = ProAccessController(purchaseService: service, observeUpdates: false) + controller.isPaywallPresented = true + await controller.restorePurchases() + #expect(service.restores == 1) + #expect(controller.isPaywallPresented) + #expect(controller.error == .purchaseNotFound) + service.status = .purchased(.purchased) + await controller.restorePurchases() + #expect(controller.hasProAccess) + #expect(controller.isPaywallPresented) + #expect(controller.error == nil) + } + + @Test + func remotePaywallCompletionUpdatesAccessWithoutClosingItsAccountVariant() async { + let service = FakeService() + let controller = ProAccessController(purchaseService: service, observeUpdates: false) + controller.isPaywallPresented = true + controller.beginPaywallOperation() + service.status = .purchased(.purchased) + await controller.completePaywallOperation() + #expect(controller.hasProAccess) + #expect(controller.isPaywallPresented) + #expect(!controller.isProcessing) + + service.status = .free(trialAvailable: false) + await controller.refresh() + #expect(!controller.hasProAccess) + #expect(controller.isPaywallPresented) + } + + @Test + func customerInfoUpdatesGrantAndRevokeAccessWithoutAStoreKitFallback() async { + let service = FakeService() + let controller = ProAccessController(purchaseService: service) + await controller.refresh() + service.continuation.yield(.purchased(.purchased)) + for _ in 0..<100 where !controller.hasProAccess { await Task.yield() } + #expect(controller.hasProAccess) + service.continuation.yield(.free(trialAvailable: false)) + for _ in 0..<100 where controller.hasProAccess { await Task.yield() } + #expect(!controller.hasProAccess) + } + + @Test + func newerCustomerInfoWinsOverAnOlderRefreshResponse() async { + let service = FakeService() + let controller = ProAccessController(purchaseService: service) + await controller.refresh() + service.suspendsRefresh = true + let refresh = Task { await controller.refresh() } + for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() } + #expect(service.refreshContinuation != nil) + service.continuation.yield(.purchased(.purchased)) + for _ in 0..<100 where !controller.hasProAccess { await Task.yield() } + #expect(controller.hasProAccess) + service.refreshContinuation?.resume(returning: .free(trialAvailable: false)) + service.refreshContinuation = nil + await refresh.value + #expect(controller.hasProAccess) + } + + @Test + func anUnavailableServiceDoesNotStartAStoreKitFallback() async { + let controller = ProAccessController(purchaseService: nil) + await controller.refresh() + #expect(!controller.hasProAccess) + #expect(!controller.usesRevenueCat) + #expect(controller.status == .verificationFailed) + await controller.restorePurchases() + #expect(controller.error == .productUnavailable) + } + + @Test + func aFailedInitialFetchDoesNotLeaveThePaywallLoading() async { + let service = FakeService() + service.refreshFails = true + let controller = ProAccessController(purchaseService: service, observeUpdates: false) + await controller.refresh() + #expect(controller.status == .verificationFailed) + #expect(controller.error != nil) + service.refreshFails = false + service.status = .purchased(.purchased) + await controller.refresh() + #expect(controller.hasProAccess) + #expect(controller.error == nil) + } + + private func resolve(_ info: CustomerInfo) -> EntitlementStatus { + RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now) + } + + private func customerInfo( + entitlementID: String = "termind_pro", expiration: Date?, period: String = "normal", + ownership: String = "PURCHASED", cancelled: Bool = false, billingIssue: Bool = false, + grace: Date? = nil, refunded: Bool = false, verification: Int = 1 + ) throws -> CustomerInfo { + let formatter = ISO8601DateFormatter() + let purchase = formatter.string(from: now.addingTimeInterval(-3600)) + let productID = expiration == nil ? "test_lifetime" : "test_annual" + var product: [String: Any] = [ + "purchase_date": purchase, "original_purchase_date": purchase, "store": "app_store", + "is_sandbox": true, "period_type": period, "ownership_type": ownership, + ] + product["expires_date"] = expiration.map { formatter.string(from: $0) } + product["unsubscribe_detected_at"] = cancelled ? purchase : nil + product["billing_issues_detected_at"] = billingIssue ? purchase : nil + product["grace_period_expires_date"] = grace.map { formatter.string(from: $0) } + product["refunded_at"] = refunded ? purchase : nil + var entitlement: [String: Any] = ["product_identifier": productID, "purchase_date": purchase] + entitlement["expires_date"] = (grace ?? expiration).map { formatter.string(from: $0) } + let data: [String: Any] = [ + "request_date": formatter.string(from: now), "entitlementVerification": verification, + "subscriber": [ + "original_app_user_id": "$RCAnonymousID:test", "first_seen": purchase, + "subscriptions": expiration == nil ? [:] : [productID: product], + "non_subscriptions": expiration == nil ? [productID: [product]] : [:], + "entitlements": [entitlementID: entitlement], + ], + ] + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(CustomerInfo.self, from: JSONSerialization.data(withJSONObject: data)) + } + + private final class FakeService: ProPurchaseService { + let updates: AsyncStream + let continuation: AsyncStream.Continuation + var status: EntitlementStatus = .free(trialAvailable: false) + var restores = 0 + var refreshFails = false + var suspendsRefresh = false + var refreshContinuation: CheckedContinuation? + + init() { (updates, continuation) = AsyncStream.makeStream() } + func currentStatus() async throws -> EntitlementStatus { + if refreshFails { throw URLError(.notConnectedToInternet) } + if suspendsRefresh { + return await withCheckedContinuation { refreshContinuation = $0 } + } + return status + } + func restore() async throws -> EntitlementStatus { + restores += 1 + return status + } + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 27f4af09..35dc9325 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -10273,6 +10273,25 @@ } } }, + "Drag files to upload on terminal" : { + "comment" : "A feature of Termind Pro.", + "extractionState" : "manual", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖放文件到终端上传" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖放檔案到終端上傳" + } + } + } + }, "Drag to Import" : { "comment" : "Title of a drop target presentation for importing a private key or certificate.", "extractionState" : "manual", @@ -32668,25 +32687,6 @@ } } }, - "Upload files by dropping them on a terminal" : { - "comment" : "A feature of Termind Pro.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "将文件拖到终端以上传" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "將檔案拖到終端以上傳" - } - } - } - }, "Upload to %@" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift index 08bb9896..e844e81f 100644 --- a/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift +++ b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift @@ -1,114 +1,73 @@ import Foundation -import StoreKit import TermindDomain @MainActor @Observable public final class ProAccessController { - public static let lifetimeProductID = "com.akinokaede.termind.pro" - public static let annualProductID = "com.akinokaede.termind.pro.annual" - + private let purchaseService: (any ProPurchaseService)? private let now: () -> Date - private var transactionUpdates: Task? - private var refreshTask: Task? - private var accessTask: Task? - private var metadataTask: Task? - private var productsTask: Task<[Product], any Error>? + private var customerInfoUpdates: Task? + private var initialRefreshTask: Task? + private var accessTask: Task, Never>? private var entitlementExpirationTask: Task? private var refreshGeneration: UInt64 = 0 - private var metadataError: ProAccessControllerError? public private(set) var status: EntitlementStatus - /// The permanent, non-consumable product. Kept under the existing name for source compatibility. - public private(set) var product: Product? - public private(set) var annualProduct: Product? - public private(set) var isEligibleForAnnualIntroOffer: Bool? public private(set) var error: ProAccessControllerError? public private(set) var isProcessing = false - public private(set) var pendingProductID: String? public var isPaywallPresented = false public var requestedFeature: ProFeature? - public var displayPrice: String? { product?.displayPrice } - public var annualDisplayPrice: String? { annualProduct?.displayPrice } - public var annualBillingPeriod: DateComponents? { - annualProduct?.subscription.map { Self.dateComponents(for: $0.subscriptionPeriod, count: 1) } - } - public var annualIntroOfferDuration: DateComponents? { - guard let offer = Self.freeIntroductoryOffer(for: annualProduct) else { return nil } - return Self.dateComponents(for: offer.period, count: offer.periodCount) - } + public var usesRevenueCat: Bool { purchaseService != nil } public var hasProAccess: Bool { status.hasProAccess } - public var canPurchase: Bool { canPurchase(.lifetime) } - public var canPurchaseAnnual: Bool { canPurchase(.annual) } - public var canStartTrial: Bool { - canPurchase(.annual) && isEligibleForAnnualIntroOffer == true - && Self.freeIntroductoryOffer(for: annualProduct) != nil - } - public var message: String? { - if let error { return error.localizedDescription } - if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription } - if status.canPurchase, let metadataError { return metadataError.localizedDescription } - return nil - } - public static func live() -> ProAccessController { ProAccessController() } + public static func live() -> ProAccessController { + let service = RevenueCatConfiguration.bundled().map { RevenueCatProPurchaseService(configuration: $0) } + return ProAccessController(purchaseService: service) + } public static func unrestrictedForTesting() -> ProAccessController { testing(status: .unrestrictedForTesting) } public static func testing(status: EntitlementStatus) -> ProAccessController { - let controller = ProAccessController(observeStore: false) + let controller = ProAccessController(purchaseService: nil, observeUpdates: false) controller.status = status return controller } - public init( + init( + purchaseService: (any ProPurchaseService)?, now: @escaping () -> Date = { .now }, - observeStore: Bool = true + observeUpdates: Bool = true ) { + self.purchaseService = purchaseService self.now = now - status = .loading - guard observeStore else { return } - transactionUpdates = Task { [weak self] in - for await update in Transaction.updates { + status = purchaseService == nil ? .verificationFailed : .loading + guard observeUpdates, let purchaseService else { return } + let updates = purchaseService.updates + customerInfoUpdates = Task { [weak self] in + for await status in updates { guard !Task.isCancelled, let self else { return } - await self.handleTransactionUpdate(update) + self.acceptServiceStatus(status) } } - refreshTask = Task { [weak self] in await self?.refresh() } - metadataTask = Task { [weak self] in await self?.reloadProducts() } + initialRefreshTask = Task { [weak self] in await self?.refresh() } } isolated deinit { - transactionUpdates?.cancel() - refreshTask?.cancel() + customerInfoUpdates?.cancel() + initialRefreshTask?.cancel() accessTask?.cancel() - metadataTask?.cancel() - productsTask?.cancel() entitlementExpirationTask?.cancel() } - public func canUse(_: ProFeature) -> Bool { - status.hasProAccess - } - - public func canPurchase(_ plan: ProPurchasePlan) -> Bool { - guard status.canPurchase, !isProcessing else { return false } - switch plan { - case .annual: - return pendingProductID != Self.annualProductID && annualProduct != nil - case .lifetime: - return pendingProductID != Self.lifetimeProductID && product != nil - } - } + public func canUse(_: ProFeature) -> Bool { hasProAccess } @discardableResult public func require(_ feature: ProFeature) -> Bool { guard !canUse(feature) else { return true } - requestedFeature = feature - isPaywallPresented = true + presentPaywall(for: feature) return false } @@ -117,101 +76,54 @@ public final class ProAccessController { isPaywallPresented = true } - /// StoreKit's local transactions resolve access independently of network product metadata. - public func refresh() async { await refresh(including: nil) } - - private func refresh(including transaction: Transaction?) async { - if status == .unrestrictedForTesting { return } - if accessTask == nil || transaction != nil { - accessTask?.cancel() + /// RevenueCat owns purchases, receipt synchronization, cached access, and subscription updates. + public func refresh() async { + guard status != .unrestrictedForTesting else { return } + guard let purchaseService else { + error = .productUnavailable + apply(.verificationFailed) + return + } + if accessTask == nil { refreshGeneration &+= 1 - accessTask = Task { [weak self] in - await self?.currentStatus(including: transaction) ?? .loading + accessTask = Task { + do { + return .success(try await purchaseService.currentStatus()) + } catch { + return .failure(.operationFailed(error.localizedDescription)) + } } } let generation = refreshGeneration guard let task = accessTask else { return } - let resolved = await task.value - guard generation == refreshGeneration else { - if accessTask != nil { await refresh() } - return - } - guard accessTask != nil else { return } + let result = await task.value + // A newer CustomerInfo update takes precedence over an earlier request. + guard generation == refreshGeneration, accessTask != nil else { return } accessTask = nil - switch resolved { - case .purchased: - pendingProductID = nil - case .subscription(let subscription) where subscription.phase.hasProAccess: - if pendingProductID == Self.annualProductID { pendingProductID = nil } - default: - break - } - apply(resolved) - } - - /// Purchases the annual subscription. StoreKit applies its introductory offer for eligible accounts. - public func purchaseAnnual() async { await purchaseProduct(Self.annualProductID) } - public func purchaseLifetime() async { await purchaseProduct(Self.lifetimeProductID) } - - private func purchaseProduct(_ id: String) async { - guard !isProcessing, pendingProductID != id, status.canPurchase else { return } - isProcessing = true - error = nil - defer { isProcessing = false } - await refresh() - guard status.canPurchase else { return } - do { - try await fetchProducts() - let selected: Product - switch id { - case Self.annualProductID: - guard let annualProduct else { throw ProAccessControllerError.productUnavailable } - selected = annualProduct - case Self.lifetimeProductID: - guard let product else { throw ProAccessControllerError.productUnavailable } - selected = product - default: - throw ProAccessControllerError.productUnavailable - } - switch try await selected.purchase() { - case .success(let verification): - guard case .verified(let transaction) = verification, transaction.productID == id else { - error = .purchaseVerificationFailed - return - } - await refresh(including: transaction) - await transaction.finish() - if status.hasProAccess { isPaywallPresented = false } - case .pending: - pendingProductID = id - case .userCancelled: - break - @unknown default: - error = .purchaseVerificationFailed - } - } catch StoreKitError.userCancelled { - // Cancellation is not an entitlement change or a failure to display. - } catch let purchaseError { - error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription) + switch result { + case .success(let resolved): + error = nil + apply(resolved) + case .failure(let failure): + error = failure + if status == .loading { apply(.verificationFailed) } } } public func restorePurchases() async { guard !isProcessing else { return } - isProcessing = true - error = nil + guard let purchaseService else { + error = .productUnavailable + return + } + beginPaywallOperation() defer { isProcessing = false } do { - try await AppStore.sync() - await refresh() - switch status { - case .purchased: - isPaywallPresented = false - case .subscription(let subscription) where subscription.phase.hasProAccess: - isPaywallPresented = false - case .verificationFailed: + let restored = try await purchaseService.restore() + acceptServiceStatus(restored) + if restored == .verificationFailed { error = .purchaseVerificationFailed - default: + } else if !restored.hasProAccess { error = .purchaseNotFound } } catch { @@ -219,217 +131,33 @@ public final class ProAccessController { } } - public func reloadProducts() async { - do { - try await fetchProducts() - } catch { - product = nil - annualProduct = nil - isEligibleForAnnualIntroOffer = nil - metadataError = .productMetadataUnavailable - } + private func acceptServiceStatus(_ next: EntitlementStatus) { + refreshGeneration &+= 1 + accessTask?.cancel() + accessTask = nil + error = nil + if next.hasProAccess { isProcessing = false } + apply(next) } - private func fetchProducts() async throws { - let task = - productsTask - ?? Task { - try await Product.products(for: [Self.lifetimeProductID, Self.annualProductID]) - } - productsTask = task - defer { productsTask = nil } - let loaded = try await task.value - product = loaded.first { Self.isLifetimeProduct($0) } - annualProduct = loaded.first { Self.isAnnualSubscription($0) } - if let subscription = annualProduct?.subscription, - Self.freeIntroductoryOffer(for: annualProduct) != nil - { - isEligibleForAnnualIntroOffer = await subscription.isEligibleForIntroOffer - } else { - isEligibleForAnnualIntroOffer = false - } - if product == nil, annualProduct == nil { - metadataError = .productUnavailable - } else { - metadataError = nil - } - if case .free = status { - apply(.free(trialAvailable: isEligibleForAnnualIntroOffer == true)) - } + public func beginPaywallOperation() { + isProcessing = true + error = nil } - public static func isLifetimeProduct(_ product: Product) -> Bool { - product.id == lifetimeProductID && product.type == .nonConsumable && product.isFamilyShareable - } + public func cancelPaywallOperation() { isProcessing = false } - public static func isAnnualSubscription(_ product: Product) -> Bool { - guard product.id == annualProductID, product.type == .autoRenewable, - product.isFamilyShareable, let subscription = product.subscription - else { return false } - return subscription.subscriptionPeriod.unit == .year && subscription.subscriptionPeriod.value == 1 - } - - public static func freeIntroductoryOffer(for product: Product?) -> Product.SubscriptionOffer? { - guard let offer = product?.subscription?.introductoryOffer, - offer.type == .introductory, offer.paymentMode == .freeTrial, offer.price == .zero - else { return nil } - return offer - } - - private func currentStatus(including update: Transaction?) async -> EntitlementStatus { - let lifetimeResult = await currentLifetimeTransaction(including: update) - if let lifetime = lifetimeResult.transaction { - return .purchased(Self.ownership(of: lifetime)) - } - - var verificationFailed = lifetimeResult.verificationFailed - let annualResult: VerificationResult? - if let update, update.productID == Self.annualProductID { - annualResult = .verified(update) - } else { - annualResult = await Transaction.latest(for: Self.annualProductID) - } - if let annualResult { - switch annualResult { - case .verified(let transaction): - return .subscription(await subscriptionStatus(for: transaction)) - case .unverified: - verificationFailed = true - } - } - - return verificationFailed ? .verificationFailed : .free(trialAvailable: isEligibleForAnnualIntroOffer == true) - } - - private func currentLifetimeTransaction( - including update: Transaction? - ) async -> (transaction: Transaction?, verificationFailed: Bool) { - if let update, update.productID == Self.lifetimeProductID, update.productType == .nonConsumable, - update.revocationDate == nil - { - return (update, false) - } - var verificationFailed = false - for await result in Transaction.currentEntitlements { - switch result { - case .verified(let transaction) - where - transaction.productID == Self.lifetimeProductID && transaction.revocationDate == nil - && transaction.productType == .nonConsumable - && !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil - && update?.originalID == transaction.originalID): - return (transaction, false) - case .unverified(let transaction, _) where transaction.productID == Self.lifetimeProductID: - verificationFailed = true - default: - break - } - } - if let latest = await Transaction.latest(for: Self.lifetimeProductID) { - switch latest { - case .verified(let transaction) - where transaction.revocationDate == nil && transaction.productType == .nonConsumable - && !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil - && update?.originalID == transaction.originalID): - return (transaction, false) - case .unverified: - verificationFailed = true - default: - break - } - } - return (nil, verificationFailed) - } - - private func subscriptionStatus(for transaction: Transaction) async -> ProSubscriptionStatus { - let ownership = Self.ownership(of: transaction) - if transaction.revocationDate != nil || transaction.productType != .autoRenewable { - return ProSubscriptionStatus( - phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false, - ownership: ownership) - } - - let storeStatus = await transaction.subscriptionStatus - let renewalInfo: Product.SubscriptionInfo.RenewalInfo? - if let storeStatus, case .verified(let verified) = storeStatus.renewalInfo { - renewalInfo = verified - } else { - renewalInfo = nil - } - - if let expiration = transaction.expirationDate, expiration > now(), !transaction.isUpgraded { - let phase: ProSubscriptionPhase = Self.isIntroductoryTrial(transaction) ? .introductoryTrial : .subscribed - return ProSubscriptionStatus( - phase: phase, expiresAt: expiration, willAutoRenew: renewalInfo?.willAutoRenew, - ownership: ownership) - } - - switch storeStatus?.state { - case .inGracePeriod: - if let graceExpiration = renewalInfo?.gracePeriodExpirationDate, graceExpiration > now() { - return ProSubscriptionStatus( - phase: .gracePeriod, expiresAt: graceExpiration, willAutoRenew: renewalInfo?.willAutoRenew, - ownership: ownership) - } - return ProSubscriptionStatus( - phase: .billingRetry, expiresAt: transaction.expirationDate, - willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) - case .inBillingRetryPeriod: - return ProSubscriptionStatus( - phase: .billingRetry, expiresAt: transaction.expirationDate, - willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) - case .revoked: - return ProSubscriptionStatus( - phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false, - ownership: ownership) - case .subscribed: - return ProSubscriptionStatus( - phase: .subscribed, expiresAt: transaction.expirationDate, - willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) - case .expired, .none: - return ProSubscriptionStatus( - phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false, - ownership: ownership) - default: - return ProSubscriptionStatus( - phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false, - ownership: ownership) - } - } - - private static func isIntroductoryTrial(_ transaction: Transaction) -> Bool { - transaction.offer?.type == .introductory && transaction.offer?.paymentMode == .freeTrial - } - - private static func ownership(of transaction: Transaction) -> ProOwnership { - transaction.ownershipType == .familyShared ? .familyShared : .purchased - } - - public func handleTransactionUpdate(_ update: VerificationResult) async { - let knownProductIDs = [Self.lifetimeProductID, Self.annualProductID] - guard knownProductIDs.contains(update.unsafePayloadValue.productID) else { return } - if case .verified(let transaction) = update { - error = nil - if pendingProductID == transaction.productID { pendingProductID = nil } - await refresh(including: transaction) - await transaction.finish() - } else { - error = .purchaseVerificationFailed - await refresh() - } + public func completePaywallOperation() async { + isProcessing = false + await refresh() } private func apply(_ next: EntitlementStatus) { status = next entitlementExpirationTask?.cancel() - let expiresAt: Date? - switch next { - case .subscription(let subscription) where subscription.phase.hasProAccess: - expiresAt = subscription.expiresAt - default: - expiresAt = nil - } - if let expiresAt { + if case .subscription(let subscription) = next, + subscription.phase.hasProAccess, let expiresAt = subscription.expiresAt, expiresAt > now() + { entitlementExpirationTask = Task { [weak self] in let remaining = max(0, expiresAt.timeIntervalSince(self?.now() ?? .now)) try? await Task.sleep(for: .seconds(remaining)) @@ -438,27 +166,11 @@ public final class ProAccessController { } } } - - private static func dateComponents(for period: Product.SubscriptionPeriod, count: Int) -> DateComponents { - let value = period.value * count - var components = DateComponents() - switch period.unit { - case .day: components.day = value - case .week: components.weekOfYear = value - case .month: components.month = value - case .year: components.year = value - @unknown default: break - } - return components - } - } public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable { case productUnavailable - case productMetadataUnavailable case purchaseVerificationFailed - case purchasePending case operationFailed(String) case purchaseNotFound @@ -466,14 +178,10 @@ public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sen switch self { case .productUnavailable: String(localized: "Termind Pro is not available from the App Store right now.") - case .productMetadataUnavailable: - String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.") case .operationFailed(let description): description case .purchaseVerificationFailed: String(localized: "The App Store could not verify this purchase.") - case .purchasePending: - String(localized: "The purchase is waiting for App Store approval.") case .purchaseNotFound: String(localized: "No active Termind Pro purchase or subscription was found for this App Store account.") } diff --git a/Frameworks/TermindInfrastructure/Commerce/ProPurchaseService.swift b/Frameworks/TermindInfrastructure/Commerce/ProPurchaseService.swift new file mode 100644 index 00000000..aa4e02b5 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/ProPurchaseService.swift @@ -0,0 +1,8 @@ +import TermindDomain + +@MainActor +protocol ProPurchaseService: AnyObject { + var updates: AsyncStream { get } + func currentStatus() async throws -> EntitlementStatus + func restore() async throws -> EntitlementStatus +} diff --git a/Frameworks/TermindInfrastructure/Commerce/RevenueCatConfiguration.swift b/Frameworks/TermindInfrastructure/Commerce/RevenueCatConfiguration.swift new file mode 100644 index 00000000..d74afe22 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/RevenueCatConfiguration.swift @@ -0,0 +1,32 @@ +import Foundation + +nonisolated struct RevenueCatConfiguration: Equatable, Sendable { + let apiKey: String + let entitlementID: String + var isTestStore: Bool { apiKey.hasPrefix("test_") } + + init?(apiKey: String?, entitlementID: String?, allowsTestStore: Bool) { + let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let entitlement = entitlementID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !entitlement.isEmpty, !entitlement.contains("$("), + key.hasPrefix("appl_") || (allowsTestStore && key.hasPrefix("test_")), + let suffix = key.split(separator: "_", maxSplits: 1).last, + !suffix.isEmpty, key.count > 5, + key.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + else { return nil } + self.apiKey = key + self.entitlementID = entitlement + } + + static func bundled(in bundle: Bundle = .main) -> RevenueCatConfiguration? { + #if DEBUG + let allowsTestStore = true + #else + let allowsTestStore = false + #endif + return RevenueCatConfiguration( + apiKey: bundle.object(forInfoDictionaryKey: "TermindRevenueCatAPIKey") as? String, + entitlementID: bundle.object(forInfoDictionaryKey: "TermindRevenueCatEntitlementID") as? String, + allowsTestStore: allowsTestStore) + } +} diff --git a/Frameworks/TermindInfrastructure/Commerce/RevenueCatEntitlementResolver.swift b/Frameworks/TermindInfrastructure/Commerce/RevenueCatEntitlementResolver.swift new file mode 100644 index 00000000..d4ed11cc --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/RevenueCatEntitlementResolver.swift @@ -0,0 +1,37 @@ +import Foundation +import RevenueCat +import TermindDomain + +nonisolated enum RevenueCatEntitlementResolver { + static func resolve(_ info: CustomerInfo, entitlementID: String, now: Date) -> EntitlementStatus { + guard info.entitlements.verification != .failed else { return .verificationFailed } + guard let entitlement = info.entitlements.all[entitlementID] else { return .free(trialAvailable: false) } + guard entitlement.verification != .failed else { return .verificationFailed } + let ownership: ProOwnership = entitlement.ownershipType == .familyShared ? .familyShared : .purchased + if entitlement.expirationDate == nil { + return entitlement.isActive ? .purchased(ownership) : .free(trialAvailable: false) + } + let subscription = info.subscriptionsByProductIdentifier[entitlement.productIdentifier] + let expiration = entitlement.expirationDate + let phase: ProSubscriptionPhase + let deadline: Date? + if subscription?.refundedAt != nil { + phase = .revoked + deadline = expiration + } else if entitlement.isActive, let grace = subscription?.gracePeriodExpiresDate, + grace > now, subscription?.billingIssuesDetectedAt != nil + { + phase = .gracePeriod + deadline = grace + } else if entitlement.isActive, let expiration, expiration > now { + phase = entitlement.periodType == .trial ? .introductoryTrial : .subscribed + deadline = expiration + } else { + phase = entitlement.billingIssueDetectedAt == nil ? .expired : .billingRetry + deadline = expiration + } + return .subscription( + ProSubscriptionStatus( + phase: phase, expiresAt: deadline, willAutoRenew: entitlement.willRenew, ownership: ownership)) + } +} diff --git a/Frameworks/TermindInfrastructure/Commerce/RevenueCatProPurchaseService.swift b/Frameworks/TermindInfrastructure/Commerce/RevenueCatProPurchaseService.swift new file mode 100644 index 00000000..098c1822 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/RevenueCatProPurchaseService.swift @@ -0,0 +1,80 @@ +import Foundation +import RevenueCat +import TermindApplication +import TermindDomain + +@MainActor +final class RevenueCatProPurchaseService: ProPurchaseService { + private let purchases: Purchases + private let configuration: RevenueCatConfiguration + private let defaults: UserDefaults + private let now: () -> Date + private var updateTask: Task? + private var attemptedMigration = false + private let continuation: AsyncStream.Continuation + let updates: AsyncStream + + init( + configuration: RevenueCatConfiguration, defaults: UserDefaults = .standard, now: @escaping () -> Date = { .now } + ) { + self.configuration = configuration + self.defaults = defaults + self.now = now + // Both platforms use anonymous IDs. Restoring a universal Apple purchase aliases these IDs. + // Never use the device ID or iCloud account as a custom RevenueCat user ID. + if !Purchases.isConfigured { + Purchases.configure( + with: Configuration.builder(withAPIKey: configuration.apiKey) + .with(storeKitVersion: .storeKit2) + .with(entitlementVerificationMode: .informational) + .with(automaticDeviceIdentifierCollectionEnabled: false) + .with(preferredUILocaleOverride: AppLanguage.stored().locale.identifier) + ) + } + purchases = Purchases.shared + (updates, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let stream = purchases.customerInfoStream + updateTask = Task { [weak self] in + for await info in stream { + guard !Task.isCancelled, let self else { return } + self.continuation.yield(self.resolve(info)) + } + } + } + + isolated deinit { + updateTask?.cancel() + continuation.finish() + } + + func currentStatus() async throws -> EntitlementStatus { + do { + // Revalidate deadlines against RevenueCat instead of accepting a still-fresh SDK cache. + let info = try await purchases.customerInfo(fetchPolicy: .fetchCurrent) + let status = resolve(info) + let migrationKey = "commerce.revenuecat.migrated.\(purchases.appUserID)" + if !configuration.isTestStore, !status.hasProAccess, + status != .verificationFailed, !attemptedMigration, !defaults.bool(forKey: migrationKey) + { + attemptedMigration = true + // RevenueCat imports pre-integration Apple purchases without a second transaction observer. + let migrated = resolve(try await purchases.syncPurchases()) + if migrated != .verificationFailed { defaults.set(true, forKey: migrationKey) } + return migrated + } + return status + } catch { + if let cached = purchases.cachedCustomerInfo { return resolve(cached) } + throw error + } + } + + func restore() async throws -> EntitlementStatus { + resolve(try await purchases.restorePurchases()) + } + + private func resolve(_ info: CustomerInfo) -> EntitlementStatus { + RevenueCatEntitlementResolver.resolve(info, entitlementID: configuration.entitlementID, now: now()) + } + +} diff --git a/Frameworks/TermindPresentation/ProOfferPresentation.swift b/Frameworks/TermindPresentation/ProOfferPresentation.swift deleted file mode 100644 index 66f76a4c..00000000 --- a/Frameworks/TermindPresentation/ProOfferPresentation.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import TermindDomain - -public nonisolated struct ProOfferPresentation: Equatable, Sendable { - public let purchaseButtonTitle: String - public let selectedPlanDetail: String? - public let trialBadge: String? - public let annualBillingUnit: String? - - public init( - plan: ProPurchasePlan, annualPrice: String?, billingPeriod: DateComponents?, - trialDuration: DateComponents?, canStartTrial: Bool, locale: Locale, - localize: (String.LocalizationValue) -> String = { String(localized: $0) } - ) { - let formatter = DateComponentsFormatter() - var calendar = Calendar(identifier: .gregorian) - calendar.locale = locale - formatter.calendar = calendar - formatter.unitsStyle = .full - formatter.maximumUnitCount = 1 - annualBillingUnit = Self.billingUnit(billingPeriod, formatter: formatter, localize: localize) - let duration = - canStartTrial - ? trialDuration.flatMap { period -> String? in - var components = period - // StoreKit weeks arrive as weekOfYear, which DateComponentsFormatter does not format. - // Preserve the offer's full duration by expressing those weeks as days. - if let weeks = components.weekOfYear { - components.weekOfYear = nil - components.day = (components.day ?? 0) + weeks * 7 - } - return formatter.string(from: components) - } : nil - trialBadge = duration.map { localize("\($0) free") } - purchaseButtonTitle = - switch plan { - case .annual: localize(canStartTrial ? "Start Free Trial" : "Subscribe Annually") - case .lifetime: localize("Buy Lifetime Access") - } - switch plan { - case .annual: - guard let price = annualPrice, let billingUnit = annualBillingUnit else { - selectedPlanDetail = nil - return - } - if let duration { - selectedPlanDetail = localize( - "\(duration) free, then \(price) per \(billingUnit). Auto-renews until canceled.") - } else { - selectedPlanDetail = localize("\(price) per \(billingUnit). Auto-renews until canceled.") - } - case .lifetime: - selectedPlanDetail = localize("One payment. No subscription or automatic renewal.") - } - } - - private static func billingUnit( - _ components: DateComponents?, formatter: DateComponentsFormatter, - localize: (String.LocalizationValue) -> String - ) -> String? { - guard let components else { return nil } - if components.year == 1 { return localize("year") } - if components.month == 1 { return localize("month") } - if components.weekOfYear == 1 { return localize("week") } - if components.day == 1 { return localize("day") } - return formatter.string(from: components) - } -} diff --git a/Frameworks/TermindPresentation/ProPaywallContent.swift b/Frameworks/TermindPresentation/ProPaywallContent.swift deleted file mode 100644 index 4c0e7799..00000000 --- a/Frameworks/TermindPresentation/ProPaywallContent.swift +++ /dev/null @@ -1,287 +0,0 @@ -import SwiftUI -import TermindApplication -import TermindDomain - -public struct ProPaywallContent: View { - @Environment(\.locale) private var locale - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Binding private var selectedPlan: ProPurchasePlan - private let background: Color - private let cardBackground: Color - private let presentation: ProPaywallPresentation - private let purchase: (ProPurchasePlan) -> Void - private let restorePurchases: () -> Void - private let reloadProducts: () -> Void - private let manageSubscription: () -> Void - - public init( - presentation: ProPaywallPresentation, selectedPlan: Binding, - background: Color, cardBackground: Color, - purchase: @escaping (ProPurchasePlan) -> Void, restorePurchases: @escaping () -> Void, - reloadProducts: @escaping () -> Void, manageSubscription: @escaping () -> Void - ) { - self.background = background - self.cardBackground = cardBackground - self.presentation = presentation - _selectedPlan = selectedPlan - self.purchase = purchase - self.restorePurchases = restorePurchases - self.reloadProducts = reloadProducts - self.manageSubscription = manageSubscription - } - - public var body: some View { - ScrollView { - VStack(spacing: 24) { - hero - accessStatus - featureList - if presentation.requestedFeature == .unlimitedMetrics { - Text(AppStrings.Pro.freeMetricsLimit) - .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) - } - secondaryActions - if dynamicTypeSize.isAccessibilitySize { purchaseActions } - } - .padding(.horizontal, 20) - .padding(.top, 12) - .padding(.bottom, 20) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - if !dynamicTypeSize.isAccessibilitySize { purchaseActions } - } - .background(background) - } - - private var hero: some View { - VStack(spacing: 12) { - Image(systemName: "sparkles.rectangle.stack.fill") - .font(.system(size: 52, weight: .medium)) - .foregroundStyle(.tint) - .symbolRenderingMode(.hierarchical) - Text(AppStrings.Pro.termindPro).font(.largeTitle.bold()) - Text(AppStrings.Pro.workflowTagline) - .font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - - private var accessStatus: some View { - let status = ProAccessPresentation(status: presentation.status) - let tint: Color = - switch status.tone { - case .neutral: .secondary - case .active: .green - case .warning: .orange - case .revoked: .red - } - return HStack(spacing: 12) { - Image(systemName: status.symbol).font(.title3.weight(.semibold)).foregroundStyle(tint) - .frame(width: 32, height: 32).background(tint.opacity(0.14), in: .circle) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text(status.title).font(.headline) - if status.isFamilyShared, status.title.key != AppStrings.Settings.annualFamilyShared.key { - Text(AppStrings.Settings.annualFamilyShared).font(.caption).foregroundStyle(.secondary) - } - if let date = status.date, let label = status.dateLabel { - VStack(alignment: .leading, spacing: 2) { - Text(label) - Text(date, format: .dateTime.year().month().day().hour().minute()) - } - .font(.caption).foregroundStyle(.secondary) - } - } - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(cardBackground, in: .rect(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.22), lineWidth: 1) } - .accessibilityElement(children: .combine) - } - - private var featureList: some View { - VStack(spacing: 0) { - feature(AppStrings.Pro.assistantFeature, symbol: "sparkles") - Divider().padding(.leading, 52) - feature( - presentation.supportsMCPServer - ? AppStrings.Pro.connectMcpToolsAndRunTheBuiltInMcpServer : AppStrings.Pro.connectRemoteMcpTools, - symbol: "server.rack") - Divider().padding(.leading, 52) - feature(AppStrings.Pro.metricsFeature, symbol: "waveform.path.ecg") - Divider().padding(.leading, 52) - feature(AppStrings.Pro.terminalUploadFeature, symbol: "arrow.up.doc") - Divider().padding(.leading, 52) - feature(AppStrings.Pro.syncFeature, symbol: "icloud") - } - .background(cardBackground, in: .rect(cornerRadius: 16)) - .overlay { - RoundedRectangle(cornerRadius: 16).strokeBorder(.quaternary, lineWidth: 1) - } - } - - @ViewBuilder private var purchaseActions: some View { - if presentation.showsPurchaseActions { - VStack(spacing: 8) { - if presentation.isProcessing { ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey) } - if let message = presentation.message { - Text(message).font(.caption).foregroundStyle(.orange).multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - if presentation.status.canPurchase { - let offer = ProOfferPresentation( - plan: selectedPlan, annualPrice: presentation.annualPrice, - billingPeriod: presentation.billingPeriod, trialDuration: presentation.trialDuration, - canStartTrial: presentation.canStartTrial, locale: locale - ) { AppLocalization.string($0, locale: locale) } - planPicker(offer) - Button { - purchase(selectedPlan) - } label: { - Text(offer.purchaseButtonTitle).frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent).controlSize(.large) - .disabled(!presentation.canPurchase(selectedPlan)) - if let detail = offer.selectedPlanDetail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 6) - .background(background, ignoresSafeAreaEdges: .bottom) - .overlay(alignment: .top) { Divider() } - } - } - - private var secondaryActions: some View { - VStack(spacing: 10) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { accountButtons } - .fixedSize(horizontal: true, vertical: false) - VStack(spacing: 10) { accountButtons } - } - .buttonStyle(.plain).foregroundStyle(.tint) - if presentation.needsProductReload { - Button(AppStrings.Pro.reloadAppStorePrices.swiftUIKey, action: reloadProducts) - .buttonStyle(.plain).foregroundStyle(.tint).disabled(presentation.isProcessing) - } - ViewThatFits(in: .horizontal) { - HStack(spacing: 16) { legalLinks }.fixedSize(horizontal: true, vertical: false) - VStack(spacing: 8) { legalLinks } - } - .font(.caption2) - .buttonStyle(.plain) - .foregroundStyle(.tint.opacity(0.7)) - Text(AppStrings.Pro.familySharingDisclosure) - .font(.caption2).foregroundStyle(.tertiary).multilineTextAlignment(.center) - Text(AppStrings.Settings.aiProviderUsageAndChargesAreSeparateFromTermindPro) - .font(.caption2).foregroundStyle(.tertiary).multilineTextAlignment(.center) - } - } - - @ViewBuilder private var accountButtons: some View { - if presentation.canManageSubscription { - Button(AppStrings.Pro.manageSubscription.swiftUIKey, action: manageSubscription) - } - Button(AppStrings.Pro.restorePurchase.swiftUIKey, action: restorePurchases) - .disabled(presentation.isProcessing) - } - - @ViewBuilder private var legalLinks: some View { - Link( - AppStrings.Pro.privacyPolicy.swiftUIKey, - destination: URL(string: "https://termind.akinokaede.com/privacy/")!) - Link( - AppStrings.Pro.termsOfUse.swiftUIKey, - destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!) - } - - private func planPicker(_ offer: ProOfferPresentation) -> some View { - let layout = - dynamicTypeSize.isAccessibilitySize - ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 8)) - return layout { - planCard( - .annual, - title: AppStrings.Pro.annual, - price: presentation.annualPrice, - detail: offer.annualBillingUnit.map { - AppLocalization.resolve(AppStrings.Pro.billingPeriod($0), locale: locale) - }, - badge: offer.trialBadge) - planCard( - .lifetime, - title: AppStrings.Pro.lifetime, - price: presentation.lifetimePrice, - detail: AppLocalization.resolve(AppStrings.Pro.oneTimePurchase, locale: locale), - badge: nil) - } - } - - private func planCard( - _ plan: ProPurchasePlan, - title: LocalizedStringResource, - price: String?, - detail: String?, - badge: String? - ) -> some View { - let isSelected = selectedPlan == plan - let isAvailable = presentation.canPurchase(plan) - return Button { - selectedPlan = plan - } label: { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text(title).font(.subheadline.weight(.semibold)) - Spacer(minLength: 0) - if let badge { - Text(badge) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tint) - .fixedSize(horizontal: false, vertical: true) - } - } - Text(price ?? "—").font(.title3.bold()).foregroundStyle(.primary) - if let detail { Text(detail).font(.caption).foregroundStyle(.secondary) } - } - .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) - .padding(10) - .background(isSelected ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.06)) - .background(cardBackground) - .clipShape(.rect(cornerRadius: 12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .strokeBorder( - isSelected ? Color.accentColor : Color.secondary.opacity(0.2), - lineWidth: isSelected ? 2 : 1) - } - } - .buttonStyle(.plain) - .focusEffectDisabled() - .disabled(!isAvailable) - .opacity(isAvailable ? 1 : 0.55) - } - - private func feature(_ title: LocalizedStringResource, symbol: String) -> some View { - HStack(spacing: 12) { - Image(systemName: symbol).font(.body.weight(.medium)).foregroundStyle(.tint) - .frame(width: 32, height: 32).background(.tint.opacity(0.12), in: .rect(cornerRadius: 8)) - .accessibilityHidden(true) - Text(title).frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - } -} diff --git a/Frameworks/TermindPresentation/ProPaywallPresentation.swift b/Frameworks/TermindPresentation/ProPaywallPresentation.swift deleted file mode 100644 index e44f0b81..00000000 --- a/Frameworks/TermindPresentation/ProPaywallPresentation.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import TermindDomain - -/// Store-independent values supplied by the platform's commerce adapter. -public nonisolated struct ProPaywallPresentation { - public var status: EntitlementStatus - public var annualPrice: String? - public var lifetimePrice: String? - public var billingPeriod: DateComponents? - public var trialDuration: DateComponents? - public var canStartTrial: Bool - public var canPurchaseAnnual: Bool - public var canPurchaseLifetime: Bool - public var isProcessing: Bool - public var message: String? - public var requestedFeature: ProFeature? - public var supportsMCPServer: Bool - - public init( - status: EntitlementStatus, annualPrice: String? = nil, lifetimePrice: String? = nil, - billingPeriod: DateComponents? = nil, trialDuration: DateComponents? = nil, - canStartTrial: Bool = false, canPurchaseAnnual: Bool = false, canPurchaseLifetime: Bool = false, - isProcessing: Bool = false, message: String? = nil, requestedFeature: ProFeature? = nil, - supportsMCPServer: Bool = false - ) { - self.status = status - self.annualPrice = annualPrice - self.lifetimePrice = lifetimePrice - self.billingPeriod = billingPeriod - self.trialDuration = trialDuration - self.canStartTrial = canStartTrial - self.canPurchaseAnnual = canPurchaseAnnual - self.canPurchaseLifetime = canPurchaseLifetime - self.isProcessing = isProcessing - self.message = message - self.requestedFeature = requestedFeature - self.supportsMCPServer = supportsMCPServer - } - - public var canManageSubscription: Bool { - guard case .subscription(let subscription) = status else { return false } - return subscription.ownership == .purchased - } - - public var needsProductReload: Bool { annualPrice == nil || lifetimePrice == nil } - public var showsPurchaseActions: Bool { isProcessing || message != nil || status.canPurchase } - - public func canPurchase(_ plan: ProPurchasePlan) -> Bool { - guard status.canPurchase, !isProcessing else { return false } - switch plan { - case .annual: return annualPrice != nil && canPurchaseAnnual - case .lifetime: return lifetimePrice != nil && canPurchaseLifetime - } - } -} - -/// The same entitlement labels and date semantics are used on both platforms. -public nonisolated struct ProAccessPresentation { - public enum Tone: Sendable { case neutral, active, warning, revoked } - - public let title: LocalizedStringResource - public let symbol: String - public let tone: Tone - public let date: Date? - public let dateLabel: LocalizedStringResource? - public let isFamilyShared: Bool - - public init(status: EntitlementStatus) { - switch status { - case .loading: - title = AppStrings.Pro.checkingProgress - symbol = "arrow.triangle.2.circlepath" - tone = .neutral - case .free: - title = AppStrings.Pro.free - symbol = "person.crop.circle" - tone = .neutral - case .purchased(let ownership): - title = - ownership == .familyShared ? AppStrings.Pro.lifetimeAccessFamilyShared : AppStrings.Pro.lifetimeAccess - symbol = ownership == .familyShared ? "person.3.fill" : "checkmark.seal.fill" - tone = .active - case .verificationFailed: - title = AppStrings.Pro.theAppStoreCouldNotVerifyThisPurchase - symbol = "exclamationmark.triangle.fill" - tone = .warning - case .unrestrictedForTesting: - title = AppStrings.Settings.developmentAccess - symbol = "hammer" - tone = .active - case .subscription(let subscription): - self = Self(subscription: subscription) - return - } - date = nil - dateLabel = nil - isFamilyShared = false - } - - private init(subscription: ProSubscriptionStatus) { - date = subscription.expiresAt - isFamilyShared = subscription.ownership == .familyShared - switch subscription.phase { - case .introductoryTrial: - title = AppStrings.Pro.freeTrial - symbol = "clock.fill" - tone = .warning - dateLabel = AppStrings.Settings.trialEnds - case .subscribed: - title = isFamilyShared ? AppStrings.Settings.annualFamilyShared : AppStrings.Pro.annualSubscription - symbol = isFamilyShared ? "person.3.fill" : "checkmark.seal.fill" - tone = .active - dateLabel = - subscription.willAutoRenew == false ? AppStrings.Settings.accessEnds : AppStrings.Settings.renews - case .gracePeriod: - title = AppStrings.Pro.billingGracePeriod - symbol = "clock.badge.exclamationmark" - tone = .warning - dateLabel = AppStrings.Settings.gracePeriodEnds - case .billingRetry: - title = AppStrings.Pro.billingIssue - symbol = "creditcard.trianglebadge.exclamationmark" - tone = .warning - dateLabel = AppStrings.Settings.expired - case .expired: - title = AppStrings.Pro.subscriptionExpired - symbol = "calendar.badge.exclamationmark" - tone = .warning - dateLabel = AppStrings.Settings.expired - case .revoked: - title = AppStrings.Pro.subscriptionRevoked - symbol = "xmark.seal" - tone = .revoked - dateLabel = AppStrings.Settings.expired - } - } -} diff --git a/Frameworks/TermindPresentation/ProPaywallState.swift b/Frameworks/TermindPresentation/ProPaywallState.swift new file mode 100644 index 00000000..57bfc24e --- /dev/null +++ b/Frameworks/TermindPresentation/ProPaywallState.swift @@ -0,0 +1,51 @@ +import Foundation +import TermindDomain + +/// Data-only contract for RevenueCat's remote visibility rules and localized text. +public nonisolated struct ProPaywallState { + public let hasProAccess: Bool + public let canManageSubscription: Bool + public let isFamilyShared: Bool + public let expiresAt: Date? + /// Protocol values consumed by the dashboard, never displayed as app copy. + public let state: String + + public init(status: EntitlementStatus) { + hasProAccess = status.hasProAccess + switch status { + case .subscription(let subscription): + canManageSubscription = subscription.ownership == .purchased + isFamilyShared = subscription.ownership == .familyShared + expiresAt = subscription.expiresAt + switch subscription.phase { + case .introductoryTrial: state = "trial" + case .subscribed: + switch subscription.willAutoRenew { + case true: state = "renewing" + case false: state = "ending" + case nil: state = "active" + } + case .gracePeriod: state = "grace" + case .billingRetry: state = "retry" + case .expired: state = "expired" + case .revoked: state = "revoked" + } + default: + canManageSubscription = false + expiresAt = nil + if case .purchased(.familyShared) = status { + isFamilyShared = true + } else { + isFamilyShared = false + } + switch status { + case .loading: state = "loading" + case .free: state = "free" + case .purchased: state = "lifetime" + case .verificationFailed: state = "unverified" + case .unrestrictedForTesting: state = "development" + case .subscription: preconditionFailure("Handled above") + } + } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Pro.swift b/Frameworks/TermindPresentation/Strings/Pro.swift index 9a8148ad..791dd137 100644 --- a/Frameworks/TermindPresentation/Strings/Pro.swift +++ b/Frameworks/TermindPresentation/Strings/Pro.swift @@ -23,7 +23,7 @@ extension AppStrings { } public static var terminalUploadFeature: LocalizedStringResource { - "Upload files by dropping them on a terminal" + "Drag files to upload on terminal" } public static var syncFeature: LocalizedStringResource { diff --git a/Scripts/render-pro-paywall-icon.swift b/Scripts/render-pro-paywall-icon.swift new file mode 100644 index 00000000..794ee69c --- /dev/null +++ b/Scripts/render-pro-paywall-icon.swift @@ -0,0 +1,28 @@ +import AppKit +import SwiftUI + +// Preserve the former native Pro hero in RevenueCat's remote image component. +// Run from the repository root: swift Scripts/render-pro-paywall-icon.swift +@MainActor +func renderIcon(name: String, red: Double, green: Double, blue: Double) throws { + let content = Image(systemName: "sparkles.rectangle.stack.fill") + .font(.system(size: 52, weight: .medium)) + .foregroundStyle(.tint) + .tint(Color(red: red, green: green, blue: blue)) + .symbolRenderingMode(.hierarchical) + .frame(width: 80, height: 72) + let renderer = ImageRenderer(content: content) + renderer.scale = 4 + guard let cgImage = renderer.cgImage, + let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:]) + else { throw CocoaError(.fileWriteUnknown) } + let directory = URL(filePath: FileManager.default.currentDirectoryPath) + .appending(path: "Design/Brand/ProPaywall", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try png.write(to: directory.appending(path: name)) +} + +try MainActor.assumeIsolated { + try renderIcon(name: "pro-hero-light.png", red: 0, green: 122.0 / 255, blue: 1) + try renderIcon(name: "pro-hero-dark.png", red: 10.0 / 255, green: 132.0 / 255, blue: 1) +} diff --git a/TFI/Features/Commerce/MobileProView.swift b/TFI/Features/Commerce/MobileProView.swift index cc593383..40eb1d97 100644 --- a/TFI/Features/Commerce/MobileProView.swift +++ b/TFI/Features/Commerce/MobileProView.swift @@ -1,4 +1,4 @@ -import StoreKit +import RevenueCatUI import SwiftUI import TermindDomain import TermindInfrastructure @@ -7,50 +7,80 @@ import TermindPresentation struct MobileProView: View { @Bindable var pro: ProAccessController @Environment(\.dismiss) private var dismiss - @State private var isManagingSubscriptions = false - @State private var selectedPlan: ProPurchasePlan = .annual + @Environment(\.locale) private var locale + @State private var ignoresPurchaseDismissal = false var body: some View { - NavigationStack { - ProPaywallContent( - presentation: presentation, selectedPlan: $selectedPlan, - background: Color(uiColor: .systemGroupedBackground), - cardBackground: Color(uiColor: .secondarySystemGroupedBackground), purchase: purchase, - restorePurchases: { Task { await pro.restorePurchases() } }, - reloadProducts: { Task { await pro.reloadProducts() } }, - manageSubscription: { isManagingSubscriptions = true } - ) - .background(Color(uiColor: .systemGroupedBackground)) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") { dismiss() } - .labelStyle(.iconOnly) + VStack(spacing: 0) { + if pro.status == .loading { + ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { closeButton } + } else if pro.usesRevenueCat { + PaywallView(displayCloseButton: true) + .customPaywallVariables(paywallVariables) + .onPurchaseInitiated { [pro] _, resume in + Task { @MainActor in resume(shouldProceed: !pro.hasProAccess) } + } + .onPurchaseStarted { _ in pro.beginPaywallOperation() } + .onRestoreStarted { pro.beginPaywallOperation() } + .onPurchaseCompleted { _ in + ignoresPurchaseDismissal = true + Task { await pro.completePaywallOperation() } + } + .onRestoreCompleted { _ in Task { await pro.completePaywallOperation() } } + .onPurchaseCancelled { pro.cancelPaywallOperation() } + .onPurchaseFailure { _ in pro.cancelPaywallOperation() } + .onRestoreFailure { _ in pro.cancelPaywallOperation() } + .onRequestedDismissal { + // RevenueCat requests dismissal after purchase; keep the account variant visible. + if ignoresPurchaseDismissal { + ignoresPurchaseDismissal = false + } else { + dismiss() + } + } + } else { + ContentUnavailableView { + Label(AppStrings.Pro.termindPro, systemImage: "cart") + } description: { + Text(ProAccessControllerError.productUnavailable.localizedDescription) + } actions: { + Button(AppStrings.Pro.restorePurchase) { Task { await pro.restorePurchases() } } + Button(AppStrings.Common.close) { dismiss() } } } } .mobileSheetPresentation() - .task { - await pro.reloadProducts() - if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime } - } - .manageSubscriptionsSheet(isPresented: $isManagingSubscriptions) + .onChange(of: pro.isPaywallPresented) { if !pro.isPaywallPresented { dismiss() } } + .onDisappear { pro.cancelPaywallOperation() } + } + private var paywallVariables: [String: CustomVariableValue] { + let account = ProPaywallState(status: pro.status) + return [ + "pro": .bool(account.hasProAccess), + "manage": .bool(account.canManageSubscription), + "state": .string(account.state), + "family": .bool(account.isFamilyShared), + "dated": .bool(account.expiresAt != nil), + "date": .string( + account.expiresAt?.formatted( + .dateTime.year().month().day().hour().minute().locale(locale)) ?? ""), + ] } - private var presentation: ProPaywallPresentation { - ProPaywallPresentation( - status: pro.status, annualPrice: pro.annualDisplayPrice, lifetimePrice: pro.displayPrice, - billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration, - canStartTrial: pro.canStartTrial, canPurchaseAnnual: pro.canPurchase(.annual), - canPurchaseLifetime: pro.canPurchase(.lifetime), isProcessing: pro.isProcessing, - message: pro.message, requestedFeature: pro.requestedFeature, supportsMCPServer: false) + private var closeButton: some View { + Button { + dismiss() + } label: { + Label(AppStrings.Common.close, systemImage: "xmark") + .labelStyle(.iconOnly) + .font(.system(size: 20, weight: .medium)) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(16) } - private func purchase(_ plan: ProPurchasePlan) { - Task { - switch plan { - case .annual: await pro.purchaseAnnual() - case .lifetime: await pro.purchaseLifetime() - } - } - } } diff --git a/TFI/Info.plist b/TFI/Info.plist index d6d89fd2..72b84514 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -2,6 +2,10 @@ + TermindRevenueCatAPIKey + $(REVENUECAT_API_KEY) + TermindRevenueCatEntitlementID + $(REVENUECAT_ENTITLEMENT_ID) FirebaseCrashlyticsCollectionEnabled FirebaseDataCollectionDefaultEnabled diff --git a/TFITests/ProPaywallRenderingTests.swift b/TFITests/ProPaywallRenderingTests.swift deleted file mode 100644 index deb5384c..00000000 --- a/TFITests/ProPaywallRenderingTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI -import TermindDomain -import TermindPresentation -import Testing -import UIKit - -/// Native fixtures use the app's localized resources without contacting StoreKit. -@Suite( - .enabled( - if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", - "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), - .serialized) @MainActor -struct ProPaywallRenderingTests { - @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true]) - func sharedContentFitsThePlatformAndLargerText(language: String, largeText: Bool) async throws { - let sizes = [CGSize(width: 375, height: 760), CGSize(width: 700, height: 850)] - for size in sizes { - for subscribed in [false, true] { - let status: EntitlementStatus = - subscribed - ? .subscription( - ProSubscriptionStatus( - phase: .subscribed, expiresAt: Date(timeIntervalSince1970: 1_800_000_000), - willAutoRenew: false, ownership: .purchased)) - : .free(trialAvailable: true) - let presentation = ProPaywallPresentation( - status: status, annualPrice: "$19.99", lifetimePrice: "$49.99", - billingPeriod: DateComponents(year: 1), trialDuration: DateComponents(weekOfYear: 2), - canStartTrial: !subscribed, canPurchaseAnnual: !subscribed, canPurchaseLifetime: !subscribed) - let content = ProPaywallContent( - presentation: presentation, selectedPlan: .constant(.annual), - background: Color(uiColor: .systemGroupedBackground), - cardBackground: Color(uiColor: .secondarySystemGroupedBackground), purchase: { _ in }, - restorePurchases: {}, reloadProducts: {}, manageSubscription: {} - ) - .environment(\.locale, Locale(identifier: language)) - .environment(\.dynamicTypeSize, largeText ? .accessibility3 : .large) - .background(.background) - let name = "pro-\(language)-\(Int(size.width))-\(largeText ? "large" : "regular")-\(subscribed).png" - let host = UIHostingController(rootView: content) - host.loadViewIfNeeded() - let previousWindow = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first? - .keyWindow - let window = UIWindow(frame: CGRect(origin: .zero, size: size)) - window.rootViewController = host - window.makeKeyAndVisible() - defer { - window.isHidden = true - window.rootViewController = nil - previousWindow?.makeKey() - } - _ = host.sizeThatFits(in: size) - host.view.frame = CGRect(origin: .zero, size: size) - host.view.setNeedsLayout() - host.view.layoutIfNeeded() - try await Task.sleep(for: .milliseconds(150)) - host.view.layoutIfNeeded() - let scroll = try #require(descendants(host.view).compactMap { $0 as? UIScrollView }.first) - #expect(scroll.contentSize.width <= scroll.bounds.width + 1) - #expect(scroll.bounds.height > 100) - if largeText { - scroll.setContentOffset( - CGPoint( - x: 0, - y: max( - 0, scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom - )), - animated: false) - host.view.layoutIfNeeded() - } - let data = UIGraphicsImageRenderer(size: size).pngData { context in - host.view.layer.render(in: context.cgContext) - } - Attachment.record(data, named: name) - let directory = FileManager.default.temporaryDirectory.appending(path: "termind-pro-previews") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try data.write(to: directory.appending(path: name)) - } - } - } - - private func descendants(_ view: UIView) -> [UIView] { - [view] + view.subviews.flatMap(descendants) - } -} diff --git a/TFM/Features/Commerce/ProPaywallView.swift b/TFM/Features/Commerce/ProPaywallView.swift index 258ef089..2832aae0 100644 --- a/TFM/Features/Commerce/ProPaywallView.swift +++ b/TFM/Features/Commerce/ProPaywallView.swift @@ -1,5 +1,5 @@ +import RevenueCatUI import SwiftUI -import TermindApplication import TermindDomain import TermindInfrastructure import TermindPresentation @@ -7,27 +7,68 @@ import TermindPresentation struct ProPaywallView: View { @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - @State private var selectedPlan: ProPurchasePlan = .annual + @Environment(\.locale) private var locale + @State private var ignoresPurchaseDismissal = false private var pro: ProAccessController { app.proAccess } var body: some View { - ProPaywallContent( - presentation: presentation, selectedPlan: $selectedPlan, - background: TermindColor.windowSurface, cardBackground: TermindColor.contentSurface, purchase: purchase, - restorePurchases: { Task { await pro.restorePurchases() } }, - reloadProducts: { Task { await pro.reloadProducts() } }, - manageSubscription: { openURL(URL(string: "https://apps.apple.com/account/subscriptions")!) } - ) - .frame(width: 604, height: 640) - .overlay(alignment: .topLeading) { - closeButton.padding(16) - } - .task { - await pro.reloadProducts() - if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime } + VStack(spacing: 0) { + if pro.status == .loading { + ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { closeButton } + } else if pro.usesRevenueCat { + PaywallView(displayCloseButton: true) + .customPaywallVariables(paywallVariables) + .onPurchaseInitiated { [pro] _, resume in + Task { @MainActor in resume(shouldProceed: !pro.hasProAccess) } + } + .onPurchaseStarted { _ in pro.beginPaywallOperation() } + .onRestoreStarted { pro.beginPaywallOperation() } + .onPurchaseCompleted { _ in + ignoresPurchaseDismissal = true + Task { await pro.completePaywallOperation() } + } + .onRestoreCompleted { _ in Task { await pro.completePaywallOperation() } } + .onPurchaseCancelled { pro.cancelPaywallOperation() } + .onPurchaseFailure { _ in pro.cancelPaywallOperation() } + .onRestoreFailure { _ in pro.cancelPaywallOperation() } + .onRequestedDismissal { + // RevenueCat requests dismissal after purchase; keep the account variant visible. + if ignoresPurchaseDismissal { + ignoresPurchaseDismissal = false + } else { + dismiss() + } + } + } else { + ContentUnavailableView { + Label(AppStrings.Pro.termindPro, systemImage: "cart") + } description: { + Text(ProAccessControllerError.productUnavailable.localizedDescription) + } actions: { + Button(AppStrings.Pro.restorePurchase) { Task { await pro.restorePurchases() } } + Button(AppStrings.Common.close) { dismiss() } + } + } } + .frame(width: 604, height: 720) + .onChange(of: pro.isPaywallPresented) { if !pro.isPaywallPresented { dismiss() } } + .onDisappear { pro.cancelPaywallOperation() } + } + private var paywallVariables: [String: CustomVariableValue] { + let account = ProPaywallState(status: pro.status) + return [ + "pro": .bool(account.hasProAccess), + "manage": .bool(account.canManageSubscription), + "state": .string(account.state), + "family": .bool(account.isFamilyShared), + "dated": .bool(account.expiresAt != nil), + "date": .string( + account.expiresAt?.formatted( + .dateTime.year().month().day().hour().minute().locale(locale)) ?? ""), + ] } private var closeButton: some View { @@ -36,34 +77,14 @@ struct ProPaywallView: View { } label: { Label(AppStrings.Common.close, systemImage: "xmark") .labelStyle(.iconOnly) - .frame(width: 30, height: 30) - .contentShape(.circle) + .font(.system(size: 20, weight: .medium)) + .frame(width: 44, height: 44) + .contentShape(.rect) } .buttonStyle(.plain) - .termindGlassCircle(isInteractive: true) - .termindInputAwareFocusEffect() - .keyboardShortcut(.cancelAction) - .help(AppStrings.Common.close) - .accessibilityLabel(Text(AppStrings.Common.close)) + .padding(16) } - private var presentation: ProPaywallPresentation { - ProPaywallPresentation( - status: pro.status, annualPrice: pro.annualDisplayPrice, lifetimePrice: pro.displayPrice, - billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration, - canStartTrial: pro.canStartTrial, canPurchaseAnnual: pro.canPurchase(.annual), - canPurchaseLifetime: pro.canPurchase(.lifetime), isProcessing: pro.isProcessing, - message: pro.message, requestedFeature: pro.requestedFeature, supportsMCPServer: true) - } - - private func purchase(_ plan: ProPurchasePlan) { - Task { - switch plan { - case .annual: await pro.purchaseAnnual() - case .lifetime: await pro.purchaseLifetime() - } - } - } } struct ProPaywallPresenter: ViewModifier { diff --git a/TFM/Info.plist b/TFM/Info.plist index 639b24cf..7f797320 100644 --- a/TFM/Info.plist +++ b/TFM/Info.plist @@ -2,6 +2,10 @@ + TermindRevenueCatAPIKey + $(REVENUECAT_API_KEY) + TermindRevenueCatEntitlementID + $(REVENUECAT_ENTITLEMENT_ID) FirebaseCrashlyticsCollectionEnabled FirebaseDataCollectionDefaultEnabled diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index 91784eb2..09a5f2c0 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -99,27 +99,6 @@ struct AppLanguageTests { } } - @Test( - arguments: [AppLanguage.english, .simplifiedChinese, .traditionalChinese], - [ProPurchasePlan.annual, .lifetime]) - func proOfferUsesTheExplicitAppLanguage(language: AppLanguage, plan: ProPurchasePlan) throws { - let locale = language.locale - let price = "$12.34" - let offer = ProOfferPresentation( - plan: plan, annualPrice: price, billingPeriod: DateComponents(year: 1), - trialDuration: DateComponents(weekOfYear: 2), canStartTrial: true, locale: locale - ) { AppLocalization.string($0, locale: locale) } - let title: String.LocalizationValue = plan == .annual ? "Start Free Trial" : "Buy Lifetime Access" - #expect(offer.purchaseButtonTitle == AppLocalization.string(title, locale: locale)) - #expect(offer.annualBillingUnit == AppLocalization.string("year", locale: locale)) - #expect(try #require(offer.trialBadge).contains("14")) - #expect(!(try #require(offer.selectedPlanDetail)).isEmpty) - if plan == .annual { #expect(offer.selectedPlanDetail?.contains(price) == true) } - if language != .english { - #expect(offer.purchaseButtonTitle != AppLocalization.string(title, locale: AppLanguage.english.locale)) - } - } - @Test func followSystemRemovesAppleLanguageOverride() throws { let suiteName = "AppLanguageTests.\(UUID().uuidString)" diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 59adf95a..0c167eff 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -3,6 +3,11 @@ import Testing @Suite struct ArchitectureBoundaryTests { + @Test + func revenueCatOwnsCommerceTransactions() throws { + try assertNoImports(in: "../Frameworks/TermindInfrastructure/Commerce", forbidden: ["StoreKit"]) + } + @Test func diagnosisSDKStaysBehindInfrastructureAndStartsWithCollectionDisabled() throws { for module in ["TermindDomain", "TermindApplication", "TermindPresentation", "TermindPersistence"] { @@ -97,7 +102,7 @@ struct ArchitectureBoundaryTests { in: "../Frameworks/TermindDomain", forbidden: [ "SwiftUI", "AppKit", "UIKit", "SwiftData", "GRDB", "Security", "Network", "StoreKit", "CloudKit", - "CSSH2", "OpenSSL", "GhosttyTerminal", "GhosttyTheme", + "CSSH2", "OpenSSL", "GhosttyTerminal", "GhosttyTheme", "RevenueCat", "RevenueCatUI", ] ) } @@ -192,7 +197,7 @@ struct ArchitectureBoundaryTests { } try assertNoImports( in: "../Frameworks/TermindApplication", - forbidden: ["TermindInfrastructure", "TermindPersistence", "SwiftData"]) + forbidden: ["TermindInfrastructure", "TermindPersistence", "SwiftData", "RevenueCat", "RevenueCatUI"]) try assertNoImports( in: "../Frameworks/TermindPersistence", forbidden: ["TermindInfrastructure", "TermindRemoteInfrastructure"]) @@ -204,7 +209,7 @@ struct ArchitectureBoundaryTests { in: "../Frameworks/TermindPresentation", forbidden: [ "Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "GRDB", "TermindPersistence", - "TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit", + "TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit", "RevenueCat", "RevenueCatUI", ]) } diff --git a/TFMTests/CommerceConfigurationTests.swift b/TFMTests/CommerceConfigurationTests.swift index 2300620d..60404e0f 100644 --- a/TFMTests/CommerceConfigurationTests.swift +++ b/TFMTests/CommerceConfigurationTests.swift @@ -11,7 +11,7 @@ import Testing struct CommerceConfigurationTests { @Test - func localStoreKitProductMatchesPurchaseRequest() throws { + func localStoreKitFixtureContainsAnnualAndLifetimeProducts() throws { let configurationFile = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -23,9 +23,9 @@ struct CommerceConfigurationTests { let products = try #require(configuration["products"] as? [[String: Any]]) let productIDs = products.compactMap { $0["productID"] as? String } - #expect(Set(productIDs) == [ProAccessController.lifetimeProductID]) + #expect(Set(productIDs) == ["com.akinokaede.termind.pro"]) let pro = try #require( - products.first { $0["productID"] as? String == ProAccessController.lifetimeProductID }) + products.first { $0["productID"] as? String == "com.akinokaede.termind.pro" }) #expect(pro["displayPrice"] as? String == "9.99") #expect(pro["familyShareable"] as? Bool == true) @@ -33,7 +33,7 @@ struct CommerceConfigurationTests { let group = try #require(groups.first) let subscriptions = try #require(group["subscriptions"] as? [[String: Any]]) let annual = try #require( - subscriptions.first { $0["productID"] as? String == ProAccessController.annualProductID }) + subscriptions.first { $0["productID"] as? String == "com.akinokaede.termind.pro.annual" }) #expect(annual["displayPrice"] as? String == "3.99") #expect(annual["familyShareable"] as? Bool == true) #expect(annual["recurringSubscriptionPeriod"] as? String == "P1Y") diff --git a/TFMTests/StoreKitTrialTests.swift b/TFMTests/StoreKitTrialTests.swift index 70892e85..aaf5f7fa 100644 --- a/TFMTests/StoreKitTrialTests.swift +++ b/TFMTests/StoreKitTrialTests.swift @@ -1,16 +1,8 @@ import Foundation import StoreKit import StoreKitTest -import TermindApplication -import TermindDomain -import TermindInfrastructure -import TermindPersistence -import TermindPresentation -import TermindRemoteInfrastructure import Testing -@testable import Termind - @Suite( .enabled( if: ProcessInfo.processInfo.environment["TERMIND_STOREKIT_TESTS"] == "1", @@ -18,229 +10,31 @@ import Testing .serialized) @MainActor struct StoreKitTrialTests { + // This local fixture validates catalog metadata only. RevenueCatCommerceTests covers app access. @Test - func annualProductSuppliesPriceBillingPeriodTrialDurationAndEntitlement() async throws { - let fixture = try Fixture() - let access = fixture.controller() - await access.refresh() - await access.reloadProducts() + func fixtureHasTheUniversalAnnualAndLifetimeProductsWithAFreeIntroOffer() async throws { + let configuration = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .appending(path: "StoreKit/Termind.storekit") + let session = try SKTestSession(contentsOf: configuration) + session.resetToDefaultState() + session.disableDialogs = true + defer { session.resetToDefaultState() } - #expect(access.annualProduct?.displayPrice == "$3.99") - #expect(access.displayPrice == "$9.99") - #expect(access.annualBillingPeriod?.year == 1) - #expect(access.annualIntroOfferDuration?.weekOfYear == 2) - #expect(access.canStartTrial) - - access.isPaywallPresented = true - await access.purchaseAnnual() - guard case .subscription(let subscription) = access.status else { - Issue.record("Annual purchase did not produce subscription access: \(access.status)") - return - } - #expect(subscription.phase == .introductoryTrial) - #expect(subscription.expiresAt != nil) - #expect(access.hasProAccess) - #expect(!access.isPaywallPresented) - #expect(!access.canPurchaseAnnual) - #expect(!access.canPurchase) - - let relaunched = fixture.controller() - await relaunched.reloadProducts() - await relaunched.restorePurchases() - #expect(relaunched.status.hasProAccess) - #expect(relaunched.error == nil) - } - - @Test - func lifetimePurchaseTakesPrecedenceOverAnnualSubscription() async throws { - let fixture = try Fixture() - try await fixture.purchase(ProAccessController.annualProductID) - try await fixture.purchase(ProAccessController.lifetimeProductID) - let access = fixture.controller() - await access.refresh() - - #expect(access.status == .purchased(.purchased)) - #expect(access.hasProAccess) - #expect(!access.status.canPurchase) - } - - @Test - func cancellationAndNetworkFailuresDoNotRemoveAnnualAccess() async throws { - let fixture = try Fixture() - try await fixture.purchase(ProAccessController.annualProductID) - let access = fixture.controller() - await access.refresh() - let active = access.status - - try await fixture.session.setSimulatedError(.generic(.userCancelled), forAPI: .purchase) - await access.purchaseAnnual() - #expect(access.status == active) - #expect(access.error == nil) - try await fixture.session.setSimulatedError( - .generic(.networkError(URLError(.notConnectedToInternet))), forAPI: .loadProducts) - await access.reloadProducts() - #expect(access.status == active) - #expect(access.message == nil) - } - - @Test - func pendingAnnualPurchaseBlocksRepeatedRequestsAndVerifiedUpdateCompletesAccess() async throws { - let fixture = try Fixture() - fixture.session.askToBuyEnabled = true - let access = fixture.controller() - await access.refresh() - await access.reloadProducts() - - let first = Task { await access.purchaseAnnual() } - await Task.yield() - await access.purchaseAnnual() - await first.value - #expect(access.pendingProductID == ProAccessController.annualProductID) - #expect(!access.canPurchaseAnnual) - #expect(fixture.session.allTransactions().count == 1) - - let pending = try #require(fixture.session.allTransactions().first) - try fixture.session.approveAskToBuyTransaction(identifier: pending.identifier) - var approved: VerificationResult? - for _ in 0..<100 where approved == nil { - approved = await Transaction.latest(for: ProAccessController.annualProductID) - if approved == nil { try await Task.sleep(for: .milliseconds(50)) } - } - await access.handleTransactionUpdate(try #require(approved)) - #expect(access.hasProAccess) - #expect(access.pendingProductID == nil) - } - - @Test - func unverifiedAnnualTransactionNeverGrantsAccess() async throws { - let fixture = try Fixture() - try await fixture.session.setSimulatedError(.verification(.invalidSignature), forAPI: .verification) - let access = fixture.controller() - await access.refresh() - await access.reloadProducts() - await access.purchaseAnnual() - - #expect(access.error == .purchaseVerificationFailed) - await access.refresh() - #expect(!access.hasProAccess) - } - - @Test - func annualRemainsPurchasableWhenTheIntroductoryOfferIsRemoved() async throws { - let url = try Fixture.modifiedConfiguration("missingOffer") - defer { try? FileManager.default.removeItem(at: url) } - let fixture = try Fixture(configuration: url) - let access = fixture.controller() - await access.refresh() - await access.reloadProducts() - - #expect(access.canPurchaseAnnual) - #expect(!access.canStartTrial) - #expect(access.annualIntroOfferDuration == nil) - await access.purchaseAnnual() - guard case .subscription(let subscription) = access.status else { - Issue.record("Annual purchase without a trial did not produce subscription access") - return - } - #expect(subscription.phase == .subscribed) - #expect(subscription.expiresAt != nil) - } - - @Test(arguments: ["monthly", "unshared", "missingAnnual"]) - func invalidAnnualConfigurationCannotBePurchased(configuration: String) async throws { - let url = try Fixture.modifiedConfiguration(configuration) - defer { try? FileManager.default.removeItem(at: url) } - let fixture = try Fixture(configuration: url) - let access = fixture.controller() - await access.refresh() - await access.reloadProducts() - - #expect(!access.canPurchaseAnnual) - #expect(!access.canStartTrial) - #expect(access.canPurchase) - await access.purchaseAnnual() - #expect(!access.hasProAccess) - #expect(fixture.session.allTransactions().isEmpty) - } - - @Test - func restoringAnAccountWithoutPurchasesReportsNoActivePurchase() async throws { - let fixture = try Fixture() - let access = fixture.controller() - await access.reloadProducts() - await access.restorePurchases() - - #expect(access.status == .free(trialAvailable: true)) - #expect(access.error == .purchaseNotFound) - } - - private final class Fixture { - static var configurationURL: URL { - URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() - .appending(path: "StoreKit/Termind.storekit") - } - - let session: SKTestSession - - init(configuration: URL = configurationURL) throws { - session = try SKTestSession(contentsOf: configuration) - session.resetToDefaultState() - session.disableDialogs = true - session.clearTransactions() - } - - isolated deinit { - session.clearTransactions() - session.resetToDefaultState() - } - - func controller(now: @escaping () -> Date = { .now }) -> ProAccessController { - ProAccessController(now: now, observeStore: false) - } - - func purchase(_ identifier: Product.ID) async throws { - let updates = Task { - for await _ in Transaction.updates { - guard !Task.isCancelled else { return } - } - } - defer { updates.cancel() } - await Task.yield() - let products = try await Product.products(for: [identifier]) - let product = try #require(products.first { $0.id == identifier }) - let result = try await product.purchase() - guard case .success(let verification) = result else { - Issue.record("StoreKit did not complete the \(identifier) purchase: \(result)") - return - } - let transaction = try verification.payloadValue - await transaction.finish() - for _ in 0..<100 { - if await Transaction.latest(for: identifier) != nil { return } - try await Task.sleep(for: .milliseconds(50)) - } - Issue.record("StoreKit did not publish the \(identifier) transaction") - } - - static func modifiedConfiguration(_ name: String) throws -> URL { - var root = try #require( - JSONSerialization.jsonObject(with: Data(contentsOf: configurationURL)) as? [String: Any]) - var groups = try #require(root["subscriptionGroups"] as? [[String: Any]]) - var subscriptions = try #require(groups[0]["subscriptions"] as? [[String: Any]]) - let index = try #require( - subscriptions.firstIndex { $0["productID"] as? String == ProAccessController.annualProductID }) - switch name { - case "monthly": subscriptions[index]["recurringSubscriptionPeriod"] = "P1M" - case "unshared": subscriptions[index]["familyShareable"] = false - case "missingOffer": subscriptions[index]["introductoryOffer"] = NSNull() - case "missingAnnual": subscriptions.remove(at: index) - default: preconditionFailure("Unknown test configuration") - } - groups[0]["subscriptions"] = subscriptions - root["subscriptionGroups"] = groups - let url = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).storekit") - try JSONSerialization.data(withJSONObject: root).write(to: url) - return url - } + let products = try await Product.products( + for: ["com.akinokaede.termind.pro.annual", "com.akinokaede.termind.pro"]) + let annual = try #require(products.first { $0.id == "com.akinokaede.termind.pro.annual" }) + let lifetime = try #require(products.first { $0.id == "com.akinokaede.termind.pro" }) + #expect(annual.type == .autoRenewable) + #expect(annual.isFamilyShareable) + #expect(lifetime.type == .nonConsumable) + #expect(lifetime.isFamilyShareable) + let subscription = try #require(annual.subscription) + #expect(subscription.subscriptionPeriod.unit == .year) + #expect(subscription.subscriptionPeriod.value == 1) + let offer = try #require(subscription.introductoryOffer) + #expect(offer.paymentMode == .freeTrial) + #expect(offer.price == .zero) + #expect(offer.period.unit == .week) + #expect(offer.period.value * offer.periodCount == 2) } } diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index c152889c..b805393b 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -68,6 +68,12 @@ BENC00000000000000000002 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; BENC00000000000000000003 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; BENC00000000000000000004 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; + C0CA00000000000000000101 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; }; + C0CA00000000000000000102 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; }; + C0CA00000000000000000103 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; }; + C0CA00000000000000000104 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; }; + C0CA00000000000000000201 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000003 /* RevenueCatUI */; }; + C0CA00000000000000000202 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000003 /* RevenueCatUI */; }; C2D4B372BA5A040DDBCEFC85 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; C4AD00000000000000000001 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; }; C4AD00000000000000000002 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100017 /* MarkdownView */; }; @@ -651,6 +657,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C0CA00000000000000000202 /* RevenueCatUI in Frameworks */, + C0CA00000000000000000103 /* RevenueCat in Frameworks */, F1BA00000000000000000302 /* FirebaseCrashlytics in Frameworks */, F1BA00000000000000000301 /* FirebaseCore in Frameworks */, DEVK00000000000000000003 /* DeviceKit in Frameworks */, @@ -676,6 +684,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C0CA00000000000000000101 /* RevenueCat in Frameworks */, F1BA00000000000000000102 /* FirebaseCrashlytics in Frameworks */, F1BA00000000000000000101 /* FirebaseCore in Frameworks */, LOGG00000000000000000102 /* Logging in Frameworks */, @@ -727,6 +736,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C0CA00000000000000000104 /* RevenueCat in Frameworks */, F1BA00000000000000000402 /* FirebaseCrashlytics in Frameworks */, F1BA00000000000000000401 /* FirebaseCore in Frameworks */, LOGG00000000000000000106 /* Logging in Frameworks */, @@ -754,6 +764,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C0CA00000000000000000201 /* RevenueCatUI in Frameworks */, + C0CA00000000000000000102 /* RevenueCat in Frameworks */, F1BA00000000000000000202 /* FirebaseCrashlytics in Frameworks */, F1BA00000000000000000201 /* FirebaseCore in Frameworks */, DEVK00000000000000000004 /* DeviceKit in Frameworks */, @@ -915,6 +927,8 @@ ); name = TFI; packageProductDependencies = ( + C0CA00000000000000000003 /* RevenueCatUI */, + C0CA00000000000000000002 /* RevenueCat */, F1BA00000000000000000002 /* FirebaseCrashlytics */, F1BA00000000000000000001 /* FirebaseCore */, DEVK00000000000000000002 /* DeviceKit */, @@ -956,6 +970,7 @@ ); name = FrameworkTests; packageProductDependencies = ( + C0CA00000000000000000002 /* RevenueCat */, F1BA00000000000000000002 /* FirebaseCrashlytics */, F1BA00000000000000000001 /* FirebaseCore */, LOGG00000000000000000002 /* Logging */, @@ -1040,6 +1055,7 @@ ); name = TermindInfrastructure; packageProductDependencies = ( + C0CA00000000000000000002 /* RevenueCat */, F1BA00000000000000000002 /* FirebaseCrashlytics */, F1BA00000000000000000001 /* FirebaseCore */, LOGG00000000000000000002 /* Logging */, @@ -1233,6 +1249,8 @@ ); name = TFM; packageProductDependencies = ( + C0CA00000000000000000003 /* RevenueCatUI */, + C0CA00000000000000000002 /* RevenueCat */, F1BA00000000000000000002 /* FirebaseCrashlytics */, F1BA00000000000000000001 /* FirebaseCore */, DEVK00000000000000000002 /* DeviceKit */, @@ -1367,6 +1385,7 @@ mainGroup = TRMD00000000000000000030; minimizedProjectReferenceProxies = 1; packageReferences = ( + C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */, F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */, BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */, @@ -2480,6 +2499,8 @@ "$(inherited)", "-liconv", ); + REVENUECAT_API_KEY = REDACTED_REVENUECAT_API_KEY; + REVENUECAT_ENTITLEMENT_ID = termind_pro; STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK"; @@ -2544,6 +2565,8 @@ "$(inherited)", "-liconv", ); + REVENUECAT_API_KEY = REDACTED_REVENUECAT_API_KEY; + REVENUECAT_ENTITLEMENT_ID = termind_pro; STRING_CATALOG_GENERATE_SYMBOLS = NO; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK"; @@ -2875,6 +2898,14 @@ version = 7.11.1; }; }; + C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/RevenueCat/purchases-ios.git"; + requirement = { + kind = exactVersion; + version = 5.92.0; + }; + }; DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/devicekit/DeviceKit.git"; @@ -2971,6 +3002,16 @@ package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */; productName = GRDB; }; + C0CA00000000000000000002 /* RevenueCat */ = { + isa = XCSwiftPackageProductDependency; + package = C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */; + productName = RevenueCat; + }; + C0CA00000000000000000003 /* RevenueCatUI */ = { + isa = XCSwiftPackageProductDependency; + package = C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */; + productName = RevenueCatUI; + }; DEVK00000000000000000002 /* DeviceKit */ = { isa = XCSwiftPackageProductDependency; package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 92355744..c45b5b30 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "db5fde46a07b58b12a3db946618d16496080b8cba0501fc05694825a35f1a1b3", + "originHash" : "b4d0d2a0d4dd48bc17305a7633056c1a5aeddd04bed354f9616d8b1b31e4e888", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -244,6 +244,15 @@ "version" : "2.4.1" } }, + { + "identity" : "purchases-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RevenueCat/purchases-ios.git", + "state" : { + "revision" : "e45bb84b726885492ce4e7ee94ddb252d171a205", + "version" : "5.92.0" + } + }, { "identity" : "respring", "kind" : "remoteSourceControl",