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
2 changes: 1 addition & 1 deletion App/PaywallSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand Down
2 changes: 1 addition & 1 deletion App/ProSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand Down
40 changes: 32 additions & 8 deletions Packages/ShowRecorderKit/Sources/Recording/PackOffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
]
}
25 changes: 20 additions & 5 deletions Packages/ShowRecorderKit/Tests/RecordingTests/PackOfferTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0004-one-time-pro-unlock-and-show-safe-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/app-store/app-store-connect-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading