diff --git a/CHANGELOG.md b/CHANGELOG.md index fb31628a4..9a8aa4c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.3 + +### Fixes + +- Fixes computed period prices (`weeklyPrice`, `dailyPrice`) being off by a small amount for products whose subscription period is expressed in days. + ## 4.15.2 ### Enhancements diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 9e400afba..2326830fd 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.2 +4.15.3 """ diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift index 1fd904b3d..e8a719cc0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift @@ -194,7 +194,7 @@ struct APIStoreProduct: StoreProductType { let days: Decimal switch unit { case .day: days = Decimal(subscriptionValue) - case .week: days = Decimal(365) / Decimal(52) * Decimal(subscriptionValue) + case .week: days = Decimal(7) * Decimal(subscriptionValue) case .month: days = Decimal(365) / Decimal(12) * Decimal(subscriptionValue) case .year: days = Decimal(365 * subscriptionValue) @unknown default: days = 1 @@ -209,7 +209,7 @@ struct APIStoreProduct: StoreProductType { } let weeks: Decimal switch unit { - case .day: weeks = Decimal(subscriptionValue) * Decimal(52) / Decimal(365) + case .day: weeks = Decimal(subscriptionValue) / Decimal(7) case .week: weeks = Decimal(subscriptionValue) case .month: weeks = Decimal(52) / Decimal(12) * Decimal(subscriptionValue) case .year: weeks = Decimal(52 * subscriptionValue) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 35d12a5db..f87c2bbc0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -263,106 +263,34 @@ struct SK2StoreProduct: StoreProductType { } var dailyPrice: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { - return "n/a" - } - - let numberOfUnits = subscriptionPeriod.value - var periods: Decimal = 1.0 - let inputPrice = underlyingSK2Product.price - - switch subscriptionPeriod.unit { - case .year: - periods = Decimal(365 * numberOfUnits) - case .month: - periods = Decimal(365) / Decimal(12) * Decimal(numberOfUnits) - case .week: - periods = Decimal(365) / Decimal(52) * Decimal(numberOfUnits) - case .day: - periods = Decimal(numberOfUnits) - @unknown default: - periods = Decimal(numberOfUnits) - } - - let result = (inputPrice / periods).roundedPrice() - return priceFormatter.string(from: NSDecimalNumber(decimal: result)) ?? "n/a" + return formattedComputedPrice { $0.pricePerDay(withTotalPrice: $1) } } var weeklyPrice: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { - return "n/a" - } - - let numberOfUnits = subscriptionPeriod.value - var periods: Decimal = 1.0 - let inputPrice = underlyingSK2Product.price - - switch subscriptionPeriod.unit { - case .year: - periods = Decimal(52 * numberOfUnits) - case .month: - periods = Decimal(52) / Decimal(12) * Decimal(numberOfUnits) - case .week: - periods = Decimal(numberOfUnits) - case .day: - periods = Decimal(numberOfUnits) * Decimal(52) / Decimal(365) - @unknown default: - periods = Decimal(numberOfUnits) - } - - let result = (inputPrice / periods).roundedPrice() - return priceFormatter.string(from: NSDecimalNumber(decimal: result)) ?? "n/a" + return formattedComputedPrice { $0.pricePerWeek(withTotalPrice: $1) } } var monthlyPrice: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { - return "n/a" - } - - let numberOfUnits = subscriptionPeriod.value - var periods: Decimal = 1.0 - let inputPrice = underlyingSK2Product.price - - switch subscriptionPeriod.unit { - case .year: - periods = Decimal(12 * numberOfUnits) - case .month: - periods = Decimal(1 * numberOfUnits) - case .week: - periods = Decimal(numberOfUnits) * Decimal(12) / Decimal(52) - case .day: - periods = Decimal(numberOfUnits) * Decimal(12) / Decimal(365) - @unknown default: - periods = Decimal(numberOfUnits) - } - - let result = (inputPrice / periods).roundedPrice() - return priceFormatter.string(from: NSDecimalNumber(decimal: result)) ?? "n/a" + return formattedComputedPrice { $0.pricePerMonth(withTotalPrice: $1) } } var yearlyPrice: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + return formattedComputedPrice { $0.pricePerYear(withTotalPrice: $1) } + } + + /// Formats a per-period price derived from the *normalized* subscription + /// period. `subscriptionPeriod` is built via `SubscriptionPeriod.from(...)`, + /// which applies `normalized()` — collapsing StoreKit's occasional `.day × 7` + /// into `.week × 1`. Without that, a weekly product reported by StoreKit in + /// days would divide by an approximate day↔week factor and report a + /// `weeklyPrice` a penny off (e.g. £6.99 → £7.00). + private func formattedComputedPrice( + _ perPeriod: (SubscriptionPeriod, Decimal) -> Decimal + ) -> String { + guard let subscriptionPeriod = subscriptionPeriod else { return "n/a" } - - let numberOfUnits = subscriptionPeriod.value - var periods: Decimal = 1.0 - let inputPrice = underlyingSK2Product.price - - switch subscriptionPeriod.unit { - case .year: - periods = Decimal(numberOfUnits) - case .month: - periods = Decimal(numberOfUnits) / Decimal(12) - case .week: - periods = Decimal(numberOfUnits) / Decimal(52) - case .day: - periods = Decimal(numberOfUnits) / Decimal(365) - @unknown default: - periods = Decimal(numberOfUnits) - } - - let result = (inputPrice / periods).roundedPrice() + let result = perPeriod(subscriptionPeriod, underlyingSK2Product.price) return priceFormatter.string(from: NSDecimalNumber(decimal: result)) ?? "n/a" } diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StripeProductType.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StripeProductType.swift index 7016d6af3..709ac3380 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StripeProductType.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StripeProductType.swift @@ -299,7 +299,8 @@ struct StripeProductType: StoreProductType { } if subscriptionPeriod.unit == .week { - periods = Decimal(365) / Decimal(52) * Decimal(numberOfUnits) + // 7 days per week exactly — not 365/52. + periods = Decimal(7) * Decimal(numberOfUnits) } if subscriptionPeriod.unit == .day { @@ -338,7 +339,8 @@ struct StripeProductType: StoreProductType { } if subscriptionPeriod.unit == .day { - periods = Decimal(numberOfUnits) * Decimal(52) / Decimal(365) + // 7 days per week exactly — a 7-day product is 1 week. + periods = Decimal(numberOfUnits) / Decimal(7) } let rounded = (price / periods).roundedPrice() diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift index 6503e84be..21c54aa63 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift @@ -107,7 +107,7 @@ public final class SubscriptionPeriod: NSObject, Sendable { /// Occasionally, StoreKit seems to send back a value 7 days for a 7day trial /// instead of a value of 1 week for a trial of 7 days in length. /// Source: https://github.com/RevenueCat/react-native-purchases/issues/348 - private func normalized() -> SubscriptionPeriod { + func normalized() -> SubscriptionPeriod { switch unit { case .day: if value.isMultiple(of: 7) { @@ -131,7 +131,10 @@ extension SubscriptionPeriod { let periodsPerDay: Decimal = { switch self.unit { case .day: return 1 - case .week: return Decimal(365) / Decimal(52) + // A week is exactly 7 days. Don't route through 52/365 — 52 weeks is + // only 364 days, so that approximation makes a 1-week product's daily + // price disagree with an equivalent 7-day product's. + case .week: return 7 case .month: return Decimal(365) / Decimal(12) case .year: return 365 } @@ -145,7 +148,9 @@ extension SubscriptionPeriod { func pricePerWeek(withTotalPrice price: Decimal) -> Decimal { let periodsPerWeek: Decimal = { switch self.unit { - case .day: return Decimal(52) / Decimal(365) + // A day is exactly 1/7 of a week. The old 52/365 made a 7-day product + // resolve to 0.997 weeks, inflating its weekly price (e.g. 6.99 → 7.00). + case .day: return Decimal(1) / Decimal(7) case .week: return 1 case .month: return Decimal(52) / Decimal(12) case .year: return 52 diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index cdf48a52d..d7ef7686e 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.2" + s.version = "4.15.3" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift index 6dfadf128..d4bd7e9e3 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift @@ -57,6 +57,29 @@ struct SubscriptionPeriodPriceTests { #expect(dailyPrice == Decimal(string: "0.99")) } + @Test("Daily price for a 1-week subscription divides by exactly 7") + func testDailyPriceForWeeklySubscriptionDividesBySeven() { + // A week is exactly 7 days. Before the day↔week fix this divided by + // 365/52 ≈ 7.019, giving 7.00 / 7.019 ≈ 0.9973 → "0.99" — a penny too low. + let period = SubscriptionPeriod(value: 1, unit: .week) + let dailyPrice = period.pricePerDay(withTotalPrice: Decimal(string: "7.00")!) + + #expect(dailyPrice == Decimal(string: "1.00")) + } + + @Test("7-day and 1-week subscriptions produce the same daily price") + func testSevenDayAndOneWeekDailyPriceAreConsistent() { + // The two are the same duration — their derived daily price must match. + let price = Decimal(string: "7.00")! + let sevenDay = SubscriptionPeriod(value: 7, unit: .day) + let oneWeek = SubscriptionPeriod(value: 1, unit: .week) + + #expect( + sevenDay.pricePerDay(withTotalPrice: price) + == oneWeek.pricePerDay(withTotalPrice: price) + ) + } + @Test("Daily price for multi-month subscription") func testDailyPriceForThreeMonthSubscription() { // $24.99/3 months should be ~$0.27/day (24.99 / 90) @@ -119,6 +142,30 @@ struct SubscriptionPeriodPriceTests { #expect(weeklyPrice == Decimal(string: "4.99")) } + @Test("Weekly price for a 7-day subscription equals the price") + func testWeeklyPriceForSevenDaySubscription() { + // A 7-day period IS one week, so the weekly price must equal the price. + // Before the day↔week conversion fix this divided by (7 × 52/365) ≈ 0.9973, + // giving 6.99 / 0.9973 ≈ 7.009, which truncated to "7.00" — a penny too high. + let period = SubscriptionPeriod(value: 7, unit: .day) + let weeklyPrice = period.pricePerWeek(withTotalPrice: Decimal(string: "6.99")!) + + #expect(weeklyPrice == Decimal(string: "6.99")) + } + + @Test("7-day and 1-week subscriptions produce the same weekly price") + func testSevenDayAndOneWeekWeeklyPriceAreConsistent() { + // The two are the same duration — their derived weekly price must match. + let price = Decimal(string: "6.99")! + let sevenDay = SubscriptionPeriod(value: 7, unit: .day) + let oneWeek = SubscriptionPeriod(value: 1, unit: .week) + + #expect( + sevenDay.pricePerWeek(withTotalPrice: price) + == oneWeek.pricePerWeek(withTotalPrice: price) + ) + } + // MARK: - Monthly Price Tests @Test("Monthly price for yearly subscription") @@ -222,4 +269,46 @@ struct SubscriptionPeriodPriceTests { // 0.99 / 365 = 0.00271... rounds down to 0.00 #expect(dailyPrice == Decimal(string: "0.00")) } + + // MARK: - Normalization + + // StoreKit sometimes reports a weekly subscription as `.day × 7` rather than + // `.week × 1`. `normalized()` collapses day/month multiples so the computed + // price logic (and `SK2StoreProduct`, which routes through it) treats a + // 7-day product as exactly one week — otherwise its weekly price came out a + // penny high (e.g. £6.99 → £7.00). + + @Test("A 7-day period normalizes to 1 week") + func testSevenDayPeriodNormalizesToOneWeek() { + let normalized = SubscriptionPeriod(value: 7, unit: .day).normalized() + + #expect(normalized.value == 1) + #expect(normalized.unit == .week) + } + + @Test("A 14-day period normalizes to 2 weeks") + func testFourteenDayPeriodNormalizesToTwoWeeks() { + let normalized = SubscriptionPeriod(value: 14, unit: .day).normalized() + + #expect(normalized.value == 2) + #expect(normalized.unit == .week) + } + + @Test("A non-7-multiple day period stays in days") + func testThreeDayPeriodStaysInDays() { + let normalized = SubscriptionPeriod(value: 3, unit: .day).normalized() + + #expect(normalized.value == 3) + #expect(normalized.unit == .day) + } + + @Test("Normalized 7-day period yields a weekly price equal to the price") + func testNormalizedSevenDayWeeklyPriceEqualsPrice() { + // The full chain: a 7-day period normalizes to 1 week, and the weekly + // price of a 1-week product is the price itself. + let normalized = SubscriptionPeriod(value: 7, unit: .day).normalized() + let weeklyPrice = normalized.pricePerWeek(withTotalPrice: Decimal(string: "6.99")!) + + #expect(weeklyPrice == Decimal(string: "6.99")) + } }