diff --git a/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift b/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift new file mode 100644 index 0000000..e40b493 --- /dev/null +++ b/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift @@ -0,0 +1,73 @@ +import Foundation +import MixerLink + +/// Where mixer packs (#705) are offered: Debug builds only, never TestFlight or the App Store until Eric tries packs +/// on a device. Moving it up is a one-line PR, like `DriveOnlyAvailability`. +public enum PackAvailability { + public static func isAvailable(isDebugBuild: Bool) -> Bool { + isDebugBuild + } + + /// The answer for the running app. + public static var isAvailableInThisBuild: Bool { + #if DEBUG + let isDebugBuild = true + #else + let isDebugBuild = false + #endif + return isAvailable(isDebugBuild: isDebugBuild) + } +} + +/// What the paywall offers with mixer packs (#705): the Armed family's pack first with Pro beside it, Pro alone for +/// a generic input, and only the upgrade for someone who already has a pack (a second pack is hidden, Eric 2026-10-16). +public enum PackOffer { + public enum Item: Equatable, Sendable { + case pack(MixerFamily) + case pro + case upgrade + } + + /// - Parameter boughtPacks: the owned packs this person bought themselves; a pack only shared with them through + /// Family Sharing doesn't earn the upgrade, so they're offered full Pro (Eric, #705). + public static func items( + ownsPro: Bool, armedFamily: MixerFamily?, ownedPacks: Set, boughtPacks: Set, packsOffered: Bool + ) -> [Item] { + if ownsPro { return [] } + if !packsOffered { return [.pro] } + if !boughtPacks.isEmpty { return [.upgrade] } + // A second pack is hidden (Eric): with only a shared pack, the offer is full Pro. + if !ownedPacks.isEmpty { return [.pro] } + if let armedFamily, ProPurchases.packProductIDs[armedFamily] != nil { return [.pack(armedFamily), .pro] } + return [.pro] + } + + /// The pack headline (#705): the linked Mixer's model, else the family's name. + public static func headline(family: MixerFamily, mixerModel: String?) -> String { + let model = mixerModel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return "Pro for your \(model.isEmpty ? family.name : model)" + } +} + +extension MixerFamily { + /// The family's name in the paywall (#705). + var name: String { + switch self { + case .xAir: "X Air" + case .x32: "X32" + } + } +} + +/// Where the launch price ladder stands (Eric, 2026-10-15, #705): Launch (Pro $4.99, packs $2.99, upgrade $1.99), Step 2 +/// ($9.99, $4.99, $4.99), then Full ($19.99, $7.99, $11.99). StoreKit gives only today's price, so the stage is set here +/// and moved by a one-line PR when App Store Connect's prices move. +public enum PriceStage: Equatable, Sendable { + case launch, step2, full + + /// The stage the App Store prices are at now. + public static let current = PriceStage.launch + + /// What the paywall says beside the price: "Launch price" at the Launch stage only, never a countdown. + public var label: String? { self == .launch ? "Launch price" : nil } +} diff --git a/Packages/ShowRecorderKit/Sources/Recording/ProPurchases.swift b/Packages/ShowRecorderKit/Sources/Recording/ProPurchases.swift index 04fec37..1057df9 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/ProPurchases.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/ProPurchases.swift @@ -8,11 +8,14 @@ public struct OwnedPurchase: Equatable, Sendable { public var purchaseDate: Date /// Set when the purchase was refunded or revoked (such as leaving the family that shared it). public var revocationDate: Date? + /// Shared by a family member through Family Sharing, not bought by this person (#705). + public var isFamilyShared: Bool - public init(productID: String, purchaseDate: Date, revocationDate: Date? = nil) { + public init(productID: String, purchaseDate: Date, revocationDate: Date? = nil, isFamilyShared: Bool = false) { self.productID = productID self.purchaseDate = purchaseDate self.revocationDate = revocationDate + self.isFamilyShared = isFamilyShared } } @@ -33,14 +36,20 @@ public struct ProPurchases: Equatable, Sendable { public private(set) var ownsPro = false public private(set) var trialStartedAt: Date? public private(set) var ownedPacks: Set = [] + /// The owned packs this person bought themselves, not shared through Family Sharing: only these earn the offer of + /// the upgrade to Pro (Eric, #705), so nobody is sold an upgrade resting on a pack someone else can stop sharing. + public private(set) var boughtPacks: Set = [] public init(owned: [OwnedPurchase]) { let owned = owned.filter { $0.revocationDate == nil } ownsPro = owned.contains { $0.productID == Self.proProductID } trialStartedAt = owned.first { $0.productID == Self.trialProductID }?.purchaseDate ownedPacks = Set(Self.packProductIDs.filter { pack in owned.contains { $0.productID == pack.value } }.keys) - // The upgrade is Pro only alongside a pack still owned (#705). - if !ownedPacks.isEmpty, owned.contains(where: { $0.productID == Self.upgradeProductID }) { ownsPro = true } + boughtPacks = Set(Self.packProductIDs.filter { pack in owned.contains { $0.productID == pack.value && !$0.isFamilyShared } }.keys) + // The upgrade is Pro only alongside a pack still owned (#705). One this person bought needs a pack they bought, + // so refunding it can't lean on a family member's; one shared with them rests on any pack shared with them too. + let ownsBought = !boughtPacks.isEmpty, ownsAny = !ownedPacks.isEmpty + if owned.contains(where: { $0.productID == Self.upgradeProductID && ($0.isFamilyShared ? ownsAny : ownsBought) }) { ownsPro = true } } private static let lastKnownProKey = "proPurchases.ownsPro" @@ -48,27 +57,34 @@ public struct ProPurchases: Equatable, Sendable { /// The owned packs as their product IDs, which stay the same when the code changes (#705). A device that saved /// before this key existed restores no packs. private static let lastKnownPacksKey = "proPurchases.ownedPacks" + /// The packs among them bought by this person, not shared (#705), as product IDs too. + private static let lastKnownBoughtPacksKey = "proPurchases.boughtPacks" - private init(ownsPro: Bool, trialStartedAt: Date?, ownedPacks: Set) { + private init(ownsPro: Bool, trialStartedAt: Date?, ownedPacks: Set, boughtPacks: Set) { self.ownsPro = ownsPro self.trialStartedAt = trialStartedAt self.ownedPacks = ownedPacks + self.boughtPacks = boughtPacks } /// What was owned the last time the App Store was asked, kept on the device so a launch knows the tier at once /// (#316). Nil before the first answer (a fresh install or a new device). public static func lastKnown(from defaults: UserDefaults = .standard) -> ProPurchases? { guard defaults.object(forKey: lastKnownProKey) != nil else { return nil } - let packIDs = defaults.stringArray(forKey: lastKnownPacksKey) ?? [] + func packs(_ key: String) -> Set { + let ids = defaults.stringArray(forKey: key) ?? [] + return Set(packProductIDs.filter { ids.contains($0.value) }.keys) + } return ProPurchases( ownsPro: defaults.bool(forKey: lastKnownProKey), trialStartedAt: defaults.object(forKey: lastKnownTrialKey) as? Date, - ownedPacks: Set(packProductIDs.filter { packIDs.contains($0.value) }.keys)) + ownedPacks: packs(lastKnownPacksKey), boughtPacks: packs(lastKnownBoughtPacksKey)) } public func saveAsLastKnown(to defaults: UserDefaults = .standard) { defaults.set(ownsPro, forKey: Self.lastKnownProKey) defaults.set(trialStartedAt, forKey: Self.lastKnownTrialKey) defaults.set(ownedPacks.compactMap { Self.packProductIDs[$0] }.sorted(), forKey: Self.lastKnownPacksKey) + defaults.set(boughtPacks.compactMap { Self.packProductIDs[$0] }.sorted(), forKey: Self.lastKnownBoughtPacksKey) } /// Free, Trial or Pro at `now`. Read when record is pressed, never awaited. diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift new file mode 100644 index 0000000..1f0dbb7 --- /dev/null +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift @@ -0,0 +1,64 @@ +import Foundation +import MixerLink +@testable import Recording +import Testing + +/// What the paywall offers with mixer packs (#705, ADR 0004): the Armed family's pack first with Pro beside it, Pro +/// alone for a generic input, and only the upgrade for someone who already has a pack (a second pack is hidden, Eric). +@Suite("Pack offer") +struct PackOfferTests { + @Test("An X Air or X32 as the Armed input: its pack first, then Pro (#705)") + func packFirst() { + #expect(PackOffer.items(ownsPro: false, armedFamily: .xAir, ownedPacks: [], boughtPacks: [], packsOffered: true) == [.pack(.xAir), .pro]) + #expect(PackOffer.items(ownsPro: false, armedFamily: .x32, ownedPacks: [], boughtPacks: [], packsOffered: true) == [.pack(.x32), .pro]) + } + + @Test("A generic input (no mixer family) is offered Pro only: there is no pack for it (#705)") + func genericGetsPro() { + #expect(PackOffer.items(ownsPro: false, armedFamily: nil, ownedPacks: [], boughtPacks: [], packsOffered: true) == [.pro]) + } + + @Test("Someone with a pack is offered only the upgrade to Pro, whatever is Armed: a second pack is hidden (Eric, #705)") + func packOwnerGetsUpgrade() { + #expect(PackOffer.items(ownsPro: false, armedFamily: .xAir, ownedPacks: [.x32], boughtPacks: [.x32], packsOffered: true) == [.upgrade]) + #expect(PackOffer.items(ownsPro: false, armedFamily: .x32, ownedPacks: [.x32], boughtPacks: [.x32], packsOffered: true) == [.upgrade]) + #expect(PackOffer.items(ownsPro: false, armedFamily: nil, ownedPacks: [.xAir], boughtPacks: [.xAir], packsOffered: true) == [.upgrade]) + } + + @Test("A pack shared through Family Sharing doesn't earn the upgrade: that person is offered full Pro (Eric, #705)") + func sharedPackGetsPro() { + #expect(PackOffer.items(ownsPro: false, armedFamily: .x32, ownedPacks: [.xAir], boughtPacks: [], packsOffered: true) == [.pro]) + } + + @Test("At the Launch stage the paywall says \"Launch price\"; at full price it says nothing (Eric, #705)") + func launchPrice() { + #expect(PriceStage.current == .launch) + #expect(PriceStage.launch.label == "Launch price") + #expect(PriceStage.step2.label == nil) + #expect(PriceStage.full.label == nil) + } + + @Test("Pro owned: nothing to offer (#705)") + func proOwned() { + #expect(PackOffer.items(ownsPro: true, armedFamily: .xAir, ownedPacks: [.xAir], boughtPacks: [.xAir], packsOffered: true).isEmpty) + } + + @Test("Where packs aren't offered yet, the paywall is Pro only, as before (#705)") + func packsNotOffered() { + #expect(PackOffer.items(ownsPro: false, armedFamily: .xAir, ownedPacks: [], boughtPacks: [], packsOffered: false) == [.pro]) + } + + @Test("The pack headline names the Mixer: its model when linked, else the family (#705)") + func headline() { + #expect(PackOffer.headline(family: .xAir, mixerModel: "XR18") == "Pro for your XR18") + #expect(PackOffer.headline(family: .x32, mixerModel: nil) == "Pro for your X32") + #expect(PackOffer.headline(family: .xAir, mixerModel: " ") == "Pro for your X Air") + #expect(PackOffer.headline(family: .xAir, mixerModel: "XR18\n") == "Pro for your XR18") + } + + @Test("Packs are offered in Debug builds only for now: never TestFlight or the App Store (#705)") + func availability() { + #expect(PackAvailability.isAvailable(isDebugBuild: true)) + #expect(!PackAvailability.isAvailable(isDebugBuild: false)) + } +} diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/ProPurchasesTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/ProPurchasesTests.swift index 6801e12..7b1c10e 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/ProPurchasesTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/ProPurchasesTests.swift @@ -102,4 +102,25 @@ struct ProPurchasesTests { #expect(!refunded.ownsPro) #expect(refunded.tier(now: bought, armedFamily: .x32) == .free) } + + @Test("A pack bought by this person is in boughtPacks; one shared through Family Sharing is owned but not bought (#705)") + func boughtVersusShared() { + let shared = OwnedPurchase(productID: ProPurchases.packProductIDs[.x32]!, purchaseDate: bought, isFamilyShared: true) + let owned = ProPurchases(owned: [pack(.xAir), shared]) + #expect(owned.ownedPacks == [.xAir, .x32]) + #expect(owned.boughtPacks == [.xAir]) + } + + @Test("An upgrade bought by this person needs a pack they bought: refunding it can't lean on a family member's pack (#705)") + func boughtUpgradeNeedsBoughtPack() { + let sharedPack = OwnedPurchase(productID: ProPurchases.packProductIDs[.x32]!, purchaseDate: bought, isFamilyShared: true) + #expect(!ProPurchases(owned: [pack(.xAir, revoked: true), sharedPack, upgrade()]).ownsPro) + } + + @Test("An upgrade shared through Family Sharing rests on the shared pack: the family keeps Pro (#705)") + func sharedUpgradeWithSharedPack() { + let sharedPack = OwnedPurchase(productID: ProPurchases.packProductIDs[.xAir]!, purchaseDate: bought, isFamilyShared: true) + let sharedUpgrade = OwnedPurchase(productID: ProPurchases.upgradeProductID, purchaseDate: bought, isFamilyShared: true) + #expect(ProPurchases(owned: [sharedPack, sharedUpgrade]).ownsPro) + } }