feat(commerce): integrate RevenueCat entitlements and paywalls
This commit is contained in:
1 parent
22f09eaeee
commit
cfaceb5d15
27 files changed
+856
-1568
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<EntitlementStatus>
|
||||
let continuation: AsyncStream<EntitlementStatus>.Continuation
|
||||
var status: EntitlementStatus = .free(trialAvailable: false)
|
||||
var restores = 0
|
||||
var refreshFails = false
|
||||
var suspendsRefresh = false
|
||||
var refreshContinuation: CheckedContinuation<EntitlementStatus, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var accessTask: Task<EntitlementStatus, Never>?
|
||||
private var metadataTask: Task<Void, Never>?
|
||||
private var productsTask: Task<[Product], any Error>?
|
||||
private var customerInfoUpdates: Task<Void, Never>?
|
||||
private var initialRefreshTask: Task<Void, Never>?
|
||||
private var accessTask: Task<Result<EntitlementStatus, ProAccessControllerError>, Never>?
|
||||
private var entitlementExpirationTask: Task<Void, Never>?
|
||||
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<Transaction>?
|
||||
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<Transaction>) 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.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import TermindDomain
|
||||
|
||||
@MainActor
|
||||
protocol ProPurchaseService: AnyObject {
|
||||
var updates: AsyncStream<EntitlementStatus> { get }
|
||||
func currentStatus() async throws -> EntitlementStatus
|
||||
func restore() async throws -> EntitlementStatus
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
private var attemptedMigration = false
|
||||
private let continuation: AsyncStream<EntitlementStatus>.Continuation
|
||||
let updates: AsyncStream<EntitlementStatus>
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ProPurchasePlan>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>TermindRevenueCatAPIKey</key>
|
||||
<string>$(REVENUECAT_API_KEY)</string>
|
||||
<key>TermindRevenueCatEntitlementID</key>
|
||||
<string>$(REVENUECAT_ENTITLEMENT_ID)</string>
|
||||
<key>FirebaseCrashlyticsCollectionEnabled</key>
|
||||
<false/>
|
||||
<key>FirebaseDataCollectionDefaultEnabled</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>TermindRevenueCatAPIKey</key>
|
||||
<string>$(REVENUECAT_API_KEY)</string>
|
||||
<key>TermindRevenueCatEntitlementID</key>
|
||||
<string>$(REVENUECAT_ENTITLEMENT_ID)</string>
|
||||
<key>FirebaseCrashlyticsCollectionEnabled</key>
|
||||
<false/>
|
||||
<key>FirebaseDataCollectionDefaultEnabled</key>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<Transaction>?
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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" */;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user