From 0717fa2272d03af7e648a70ca14aeee53e6f3534 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 16 Oct 2026 01:00:47 -0400 Subject: [PATCH] Paywall offer for mixer packs, shared packs, and the launch price stage (#705) PackOffer.items decides what the paywall offers: the Armed X Air or X32's pack first with Pro beside it, Pro only for a generic input, only the upgrade for someone who bought a pack (a second pack is hidden), full Pro for someone whose only pack is Family Shared, nothing on Pro. The headline is "Pro for your XR18". PackAvailability offers packs in Debug builds only. PriceStage.current is .launch, labeled "Launch price". OwnedPurchase.isFamilyShared and ProPurchases.boughtPacks tell bought packs from shared ones (saved for cold start). An upgrade bought by this person needs a pack they bought; a shared upgrade rests on any owned pack, so a family keeps Pro and a refunded pack can't lean on a relative's. Claude-Session: https://claude.ai/code/session_01K4ihr1i6m1uuUswAENvJKJ Assisted-by: Claude Code:claude-opus-5-5 --- .../Sources/Recording/PackOffer.swift | 73 +++++++++++++++++++ .../Sources/Recording/ProPurchases.swift | 28 +++++-- .../Tests/RecordingTests/PackOfferTests.swift | 64 ++++++++++++++++ .../RecordingTests/ProPurchasesTests.swift | 21 ++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift create mode 100644 Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift diff --git a/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift b/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift new file mode 100644 index 00000000..e40b493e --- /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 04fec372..1057df96 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 00000000..1f0dbb7b --- /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 6801e124..7b1c10ea 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) + } }