diff --git a/App/PaywallSheet.swift b/App/PaywallSheet.swift index c87be0c3..fec3045b 100644 --- a/App/PaywallSheet.swift +++ b/App/PaywallSheet.swift @@ -70,7 +70,7 @@ struct PaywallSheet: View { .disabled(product == nil || store.busy) } // The price stage under the offers, in every build (Eric, #705). - if !items.isEmpty, let label = PriceStage.current.label { + if !items.isEmpty, let label = PriceStage.label(proPrice: store.proProduct?.price, currencyCode: store.proProduct?.priceFormatStyle.currencyCode) { Text(label).font(.footnote).secondaryText() } Button("Restore Purchases") { Task { await store.restore() } } diff --git a/App/ProSection.swift b/App/ProSection.swift index 6449ad7e..27d30da2 100644 --- a/App/ProSection.swift +++ b/App/ProSection.swift @@ -55,7 +55,7 @@ struct ProSection: View { .disabled(store.upgradeProduct == nil || store.busy) } // The price stage under the offers, in every build (Eric, #705). - if !items.isEmpty, let label = PriceStage.current.label { + if !items.isEmpty, let label = PriceStage.label(proPrice: store.proProduct?.price, currencyCode: store.proProduct?.priceFormatStyle.currencyCode) { Text(label).font(.footnote).secondaryText() } Button("Restore Purchases") { Task { await store.restore() } } diff --git a/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift b/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift index 40151d81..a6ca16ac 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift @@ -116,14 +116,38 @@ extension MixerFamily { } /// 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 +/// ($9.99, $4.99, $4.99), then Full ($19.99, $7.99, $11.99). The stage follows Pro's live StoreKit price (Eric, #908), so +/// a price step in App Store Connect hides "Launch price" in installed copies without an update. +public enum PriceStage { + /// What the paywall says beside the price: "Launch price" while Pro's live price is its launch price in that + /// currency, never a countdown. Nil while the price is loading, after a price step, or in a currency not listed. + public static func label(proPrice: Decimal?, currencyCode: String?) -> String? { + guard let proPrice, let currencyCode, + launchProPrices[currencyCode]?.contains(where: { Decimal(string: $0) == cents(proPrice) }) == true else { return nil } + return "Launch price" + } - /// The stage the App Store prices are at now. - public static let current = PriceStage.launch + /// The price to the cent: StoreKit's Decimal can carry float residue (4.9900000000000002). Every listed price is whole cents. + private static func cents(_ price: Decimal) -> Decimal { + var price = price, rounded = Decimal() + NSDecimalRound(&rounded, &price, 2, .plain) + return rounded + } - /// 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 } + /// Pro's launch price in each App Store currency, as App Store Connect equalized the $4.99 US price + /// (read 2026-10-21; some territories in one currency differ, so a currency can list two). StoreKit gives no price + /// tier, so a price outside this list, from a price step or Apple re-equalizing a territory, hides the label. + static let launchProPrices: [String: [String]] = [ + "AED": ["19.99"], "AUD": ["7.99"], "BRL": ["29.9"], "CAD": ["6.99"], + "CHF": ["4"], "CLP": ["5990"], "CNY": ["38"], "COP": ["24900"], + "CZK": ["129"], "DKK": ["39"], "EGP": ["249.99"], "EUR": ["4.99", "5.99"], + "GBP": ["4.99"], "HKD": ["38"], "HUF": ["1990"], "IDR": ["99000"], + "ILS": ["14.9"], "INR": ["499"], "JPY": ["800"], "KRW": ["7700"], + "KZT": ["2990"], "MXN": ["99"], "MYR": ["22.9"], "NGN": ["7900"], + "NOK": ["59"], "NZD": ["9.99"], "PEN": ["22.9"], "PHP": ["299"], + "PKR": ["1300"], "PLN": ["24.99"], "QAR": ["19.99"], "RON": ["29.99"], + "RUB": ["449"], "SAR": ["19.99"], "SEK": ["69"], "SGD": ["6.98"], + "THB": ["199"], "TRY": ["249.99"], "TWD": ["150"], "TZS": ["14900"], + "USD": ["4.99", "5.99"], "VND": ["149000"], "ZAR": ["99.99"], + ] } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift index 9d4d6c1a..59da68f6 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift @@ -36,12 +36,27 @@ struct PackOfferTests { #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)") + @Test("\"Launch price\" shows only while Pro's live price is its launch price in that currency (Eric, #908)") func launchPrice() { - #expect(PriceStage.current == .launch) - #expect(PriceStage.launch.label == "Launch price") - #expect(PriceStage.step2.label == nil) - #expect(PriceStage.full.label == nil) + #expect(PriceStage.label(proPrice: Decimal(string: "4.99"), currencyCode: "USD") == "Launch price") + #expect(PriceStage.label(proPrice: Decimal(string: "5.99"), currencyCode: "EUR") == "Launch price") + #expect(PriceStage.label(proPrice: Decimal(string: "800"), currencyCode: "JPY") == "Launch price") + #expect(PriceStage.label(proPrice: Decimal(string: "9.99"), currencyCode: "USD") == nil) + #expect(PriceStage.label(proPrice: Decimal(string: "19.99"), currencyCode: "USD") == nil) + } + + @Test("A StoreKit price with float residue or trailing zeros still matches its launch price (#908)") + func launchPriceRounding() { + #expect(PriceStage.label(proPrice: Decimal(4.99), currencyCode: "USD") == "Launch price") + #expect(PriceStage.label(proPrice: Decimal(string: "29.90"), currencyCode: "BRL") == "Launch price") + #expect(PriceStage.label(proPrice: Decimal(4), currencyCode: "CHF") == "Launch price") + } + + @Test("No label while Pro's price is loading, or in a currency without a known launch price (#908)") + func launchPriceUnknown() { + #expect(PriceStage.label(proPrice: nil, currencyCode: "USD") == nil) + #expect(PriceStage.label(proPrice: Decimal(string: "4.99"), currencyCode: nil) == nil) + #expect(PriceStage.label(proPrice: Decimal(string: "4.99"), currencyCode: "XXX") == nil) } @Test("The paywall headline: a running Trial's line wins, with the pack or upgrade offered below; else the pack, the upgrade, then the moment (Eric, #705)") diff --git a/docs/adr/0004-one-time-pro-unlock-and-show-safe-limits.md b/docs/adr/0004-one-time-pro-unlock-and-show-safe-limits.md index c81ba0a5..53782755 100644 --- a/docs/adr/0004-one-time-pro-unlock-and-show-safe-limits.md +++ b/docs/adr/0004-one-time-pro-unlock-and-show-safe-limits.md @@ -43,7 +43,7 @@ This replaces "Free = 2 USB Channels" above and in the mixer packs section. A fr - **Pro or a pack** adds: names and colors from the Mixer, typed names, the Drive (second Copy, Repair, Drive only), unlimited Markers, auto-gain, clip protection, the Show report, Reaper export, 96 kHz and Templates. - Pack prices change to X Air $7.99, X32/M32 $7.99; DJM stays $6.99. Pro stays $19.99. - Still judged only when record is pressed and frozen into the Take (`TakeAllowance`); auto-gain, which runs between Takes, is judged when it runs. -- **Launch price ladder (Eric, 2026-10-15/16):** Launch Pro $4.99, packs $2.99, upgrade to Pro $1.99; Step 2 $9.99 / $4.99 / $4.99; Full $19.99 / $7.99 / $11.99. Moved by Eric at about 25 and 50 buyers. Pro went to $4.99 in App Store Connect on 2026-10-16. X Air and X32 packs first (DJM waits for #364); one upgrade product repriced per stage; a second pack is hidden; only a pack the person bought earns the upgrade offer; packs and the upgrade share with family like Pro. +- **Launch price ladder (Eric, 2026-10-15/16):** Launch Pro $4.99, packs $2.99, upgrade to Pro $1.99; Step 2 $9.99 / $4.99 / $4.99; Full $19.99 / $7.99 / $11.99. Moved by Eric at about 25 and 50 buyers. Pro went to $4.99 in App Store Connect on 2026-10-16. The "Launch price" line follows Pro's live StoreKit price against a per-currency table of launch prices, so a price step hides it without an app update (Eric, #908). X Air and X32 packs first (DJM waits for #364); one upgrade product repriced per stage; a second pack is hidden; only a pack the person bought earns the upgrade offer; packs and the upgrade share with family like Pro. ## Consequences diff --git a/docs/app-store/app-store-connect-steps.md b/docs/app-store/app-store-connect-steps.md index b033fde3..d5f45b3a 100644 --- a/docs/app-store/app-store-connect-steps.md +++ b/docs/app-store/app-store-connect-steps.md @@ -34,7 +34,7 @@ An agent can create these two through the API once step 1 is Active: say "create - [ ] Type **Non-Consumable**. Reference Name `Pro`. Product ID `dev.ericdahl.ShowRecorder.pro` (exactly; the app looks for this). - [x] **Price:** $4.99, the launch price (ADR 0004; set 2026-10-16). The app says "Launch price" under Buy Pro. Then two steps, each only after Eric says go (Claude watches sales through the App Store Connect API and asks): - [ ] At about 25 buyers: **$9.99**. - - [ ] At about 50 buyers: **$19.99**, full price. The "Launch price" line goes when `PriceStage.current` moves past `.launch`. + - [ ] At about 50 buyers: **$19.99**, full price. The "Launch price" line goes by itself: it shows only while Pro's live price is the launch price (#908). - [ ] **Family Sharing: On.** This can't be turned off later (ADR 0004 wants it on). - [ ] **Localization (English U.S.):** Display Name `ShowRecorder Pro` (30 characters max). Description `Mixer names, Drive copy, report, Reaper, 96 kHz, more` (55 characters max; Free records every USB Channel now). - [ ] **Review screenshot:** the paywall (taken for #324).