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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.15.2
4.15.3
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SuperwallKit.podspec
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Comment thread
yusuftor marked this conversation as resolved.
Expand Down Expand Up @@ -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"))
}
}
Loading