diff --git a/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift b/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift index 1c19100a55..8d15f2855f 100644 --- a/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift +++ b/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift @@ -132,6 +132,7 @@ internal protocol StoreProductType: Sendable { /// /// #### Related Symbols /// - ``pricePerMonth`` + /// - ``pricePerYear`` var price: Decimal { get } /// The price of this product using ``priceFormatter``. @@ -202,6 +203,7 @@ public extension StoreProduct { /// /// #### Related Symbols /// - ``pricePerMonth`` + /// - ``pricePerYear`` @objc(price) var priceDecimalNumber: NSDecimalNumber { return self.price as NSDecimalNumber } @@ -213,6 +215,13 @@ public extension StoreProduct { return self.subscriptionPeriod?.pricePerMonth(withTotalPrice: self.price) as NSDecimalNumber? } + /// Calculates the price of this subscription product per year. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerYear: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerYear(withTotalPrice: self.price) as NSDecimalNumber? + } + /// The price of the `introductoryPrice` formatted using ``priceFormatter``. /// - Returns: `nil` if there is no `introductoryPrice`. @objc var localizedIntroductoryPriceString: String? { diff --git a/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift b/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift index ef4f60b0a9..3f1dff3d93 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift @@ -102,18 +102,38 @@ extension SubscriptionPeriod.Unit: Sendable {} extension SubscriptionPeriod: Sendable {} extension SubscriptionPeriod { + func pricePerMonth(withTotalPrice price: Decimal) -> Decimal { - let periodsPerMonth: Decimal = { - switch self.unit { - case .day: return 1 / 30 - case .week: return 1 / 4 - case .month: return 1 - case .year: return 12 - } - }() * Decimal(self.value) - - return (price as NSDecimalNumber) - .dividing(by: periodsPerMonth as NSDecimalNumber, + return self.pricePerPeriod(for: self.unitsPerMonth, totalPrice: price) + } + + func pricePerYear(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerYear, totalPrice: price) + } + + private var unitsPerMonth: Decimal { + switch self.unit { + case .day: return 1 / 30 + case .week: return 1 / 4 + case .month: return 1 + case .year: return 12 + } + } + + private var unitsPerYear: Decimal { + switch self.unit { + case .day: return 1 / 365 + case .week: return 1 / 52.14 // Number of weeks in a year + case .month: return 1 / 12 + case .year: return 1 + } + } + + private func pricePerPeriod(for units: Decimal, totalPrice: Decimal) -> Decimal { + let periods: Decimal = units * Decimal(self.value) + + return (totalPrice as NSDecimalNumber) + .dividing(by: periods as NSDecimalNumber, withBehavior: Self.roundingBehavior) as Decimal } @@ -125,6 +145,7 @@ extension SubscriptionPeriod { raiseOnUnderflow: false, raiseOnDivideByZero: false ) + } extension SubscriptionPeriod.Unit: CustomDebugStringConvertible { diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCStoreProductAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCStoreProductAPI.m index e877d4b62b..33587cb2ea 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCStoreProductAPI.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCStoreProductAPI.m @@ -27,6 +27,7 @@ + (void)checkAPI { RCStoreProductDiscount *introductoryPrice = product.introductoryDiscount; NSArray *discounts = product.discounts; NSDecimalNumber *pricePerMonth = product.pricePerMonth; + NSDecimalNumber *pricePerYear = product.pricePerYear; NSString *localizedIntroductoryPriceString = product.localizedIntroductoryPriceString; SKProduct *sk1 = product.sk1Product; @@ -46,6 +47,7 @@ + (void)checkAPI { introductoryPrice, discounts, pricePerMonth, + pricePerYear, localizedIntroductoryPriceString, sk1 ); diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/StoreProductAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/StoreProductAPI.swift index 3151dbdd64..7d52d1dde1 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/StoreProductAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/StoreProductAPI.swift @@ -28,6 +28,7 @@ func checkStoreProductAPI() { let discounts: [StoreProductDiscount] = product.discounts let pricePerMonth: NSDecimalNumber? = product.pricePerMonth + let pricePerYear: NSDecimalNumber? = product.pricePerYear let localizedIntroductoryPriceString: String? = product.localizedIntroductoryPriceString let sk1Product: SK1Product? = product.sk1Product let sk2Product: SK2Product? = product.sk2Product @@ -56,6 +57,7 @@ func checkStoreProductAPI() { introductoryPrice!, discounts, pricePerMonth!, + pricePerYear!, localizedIntroductoryPriceString!, sk1Product!, sk2Product! diff --git a/Tests/UnitTests/Purchasing/StoreKitAbstractions/SubscriptionPeriodTests.swift b/Tests/UnitTests/Purchasing/StoreKitAbstractions/SubscriptionPeriodTests.swift index 2bb7fc4632..0732e0e804 100644 --- a/Tests/UnitTests/Purchasing/StoreKitAbstractions/SubscriptionPeriodTests.swift +++ b/Tests/UnitTests/Purchasing/StoreKitAbstractions/SubscriptionPeriodTests.swift @@ -70,6 +70,33 @@ class SubscriptionPeriodTests: TestCase { } } + func testPricePerYear() { + let expectations: [(period: SubscriptionPeriod, price: Decimal, expected: Decimal)] = [ + (p(1, .day), 1, 365), + (p(1, .day), 2, 730), + (p(15, .day), 5, 121.66), + (p(1, .week), 10, 521.4), + (p(2, .week), 10, 260.7), + (p(1, .month), 14.99, 179.88), + (p(1, .month), 5, 60), + (p(2, .month), 30, 180), + (p(3, .month), 40, 160), + (p(1, .year), 120, 120), + (p(1, .year), 29.99, 29.99), + (p(2, .year), 50, 25), + (p(3, .year), 720, 240) + ] + + for expectation in expectations { + let pricePerYear = expectation.period.pricePerYear(withTotalPrice: expectation.price) as NSDecimalNumber + let result = Double(truncating: pricePerYear) + let expected = Double(truncating: expectation.expected as NSDecimalNumber) + + expect(result).to(beCloseTo(expected), + description: "\(expectation.price) / \(expectation.period.debugDescription)") + } + } + private func p(_ value: Int, _ unit: SubscriptionPeriod.Unit) -> SubscriptionPeriod { return .init(value: value, unit: unit) }