From 3d5d81b4a322fa9f7bdc3e4628de22aa06eb5535 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Wed, 21 Oct 2026 14:36:11 -0400 Subject: [PATCH] "Launch price" follows Pro's live price, so a price step hides it without an update (#908) The label came from a fixed PriceStage.current = .launch, so after a price step every installed copy would still say "Launch price" beside $9.99. StoreKit gives no price tier, so the app now compares Pro's live price, rounded to the cent, with Pro's launch price in that currency, read from App Store Connect on 2026-10-21 (43 currencies). Anything else hides it. Assisted-by: Claude Code:claude-opus-5-5 --- App/PaywallSheet.swift | 2 +- App/ProSection.swift | 2 +- .../Sources/Recording/PackOffer.swift | 40 +++++++++++++++---- .../Tests/RecordingTests/PackOfferTests.swift | 25 +++++++++--- ...ne-time-pro-unlock-and-show-safe-limits.md | 2 +- docs/app-store/app-store-connect-steps.md | 2 +- 6 files changed, 56 insertions(+), 17 deletions(-) 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).