Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift
Original file line number Diff line number Diff line change
@@ -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<MixerFamily>, boughtPacks: Set<MixerFamily>, 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 }
}
28 changes: 22 additions & 6 deletions Packages/ShowRecorderKit/Sources/Recording/ProPurchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -33,42 +36,55 @@ public struct ProPurchases: Equatable, Sendable {
public private(set) var ownsPro = false
public private(set) var trialStartedAt: Date?
public private(set) var ownedPacks: Set<MixerFamily> = []
/// 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<MixerFamily> = []

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"
private static let lastKnownTrialKey = "proPurchases.trialStartedAt"
/// 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<MixerFamily>) {
private init(ownsPro: Bool, trialStartedAt: Date?, ownedPacks: Set<MixerFamily>, boughtPacks: Set<MixerFamily>) {
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<MixerFamily> {
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.
Expand Down
64 changes: 64 additions & 0 deletions Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading