Skip to content
This repository has been archived by the owner on Feb 24, 2025. It is now read-only.

Commit

Permalink
Privacy Pro Free Trials - Add Offer Model and ActiveOffers Property (#…
Browse files Browse the repository at this point in the history
…1218)

Please review the release process for BrowserServicesKit
[here](https://app.asana.com/0/1200194497630846/1200837094583426).

**Required**:

Task/Issue URL:
https://app.asana.com/0/1206488453854252/1209235370800483
iOS PR: duckduckgo/iOS#3936
macOS PR: duckduckgo/macos-browser#3838
What kind of version bump will this require?: Patch

**Description**: To support Free Trials in the iOS app, adding `Offer`
Model and `activeOffers` Property.

**Steps to test this PR**:
1. See client PRs

---
###### Internal references:
[Software Engineering
Expectations](https://app.asana.com/0/59792373528535/199064865822552)
[Technical Design
Template](https://app.asana.com/0/59792373528535/184709971311943)
  • Loading branch information
aataraxiaa authored Feb 10, 2025
1 parent eafeda3 commit f0db997
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 45 deletions.
31 changes: 31 additions & 0 deletions Sources/Subscription/API/Model/PrivacyProSubscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct PrivacyProSubscription: Codable, Equatable, CustomDebugStringConve
public let expiresOrRenewsAt: Date
public let platform: Platform
public let status: Status
public let activeOffers: [Offer]

/// Not parsed from
public var features: [SubscriptionEntitlement]?
Expand Down Expand Up @@ -63,10 +64,40 @@ public struct PrivacyProSubscription: Codable, Equatable, CustomDebugStringConve
}
}

/// Represents a subscription offer.
///
/// The `Offer` struct encapsulates information about a specific subscription offer,
/// including its type.
public struct Offer: Codable, Equatable {
/// The type of the offer.
public let type: OfferType
}

/// Represents different types of subscription offers.
///
/// - `trial`: A trial offer.
/// - `unknown`: A fallback case for any unrecognized offer types, ensuring forward compatibility.
public enum OfferType: String, Codable {
case trial = "Trial"
case unknown

/// Decodes an `OfferType` from a JSON value.
///
/// If the decoded value does not match any known case, it defaults to `.unknown`.
public init(from decoder: Decoder) throws {
self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
}
}

public var isActive: Bool {
status != .expired && status != .inactive
}

/// Returns `true` is the Subscription has an active `Offer` with a type of `trial`. False otherwise.
public var hasActiveTrialOffer: Bool {
activeOffers.contains(where: { $0.type == .trial })
}

public var debugDescription: String {
return """
Subscription:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public struct SubscriptionAPIMockResponseFactory {
additionalParams: nil)!
if success {
let jsonString = """
{"email":"","entitlements":[{"product":"Data Broker Protection","name":"subscriber"},{"product":"Identity Theft Restoration","name":"subscriber"},{"product":"Network Protection","name":"subscriber"}],"subscription":{"productId":"ios.subscription.1month","name":"Monthly Subscription","billingPeriod":"Monthly","startedAt":1730991734000,"expiresOrRenewsAt":1730992034000,"platform":"apple","status":"Auto-Renewable"}}
{"email":"","entitlements":[{"product":"Data Broker Protection","name":"subscriber"},{"product":"Identity Theft Restoration","name":"subscriber"},{"product":"Network Protection","name":"subscriber"}],"subscription":{"productId":"ios.subscription.1month","name":"Monthly Subscription","billingPeriod":"Monthly","startedAt":1730991734000,"expiresOrRenewsAt":1730992034000,"platform":"apple","status":"Auto-Renewable", "activeOffers": [] }}
"""
let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!,
statusCode: HTTPStatusCode.ok.rawValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,25 @@ public struct SubscriptionMockFactory {
startedAt: Date(),
expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)),
platform: .apple,
status: .autoRenewable)
status: .autoRenewable,
activeOffers: [])
public static let expiredSubscription = PrivacyProSubscription(productId: UUID().uuidString,
name: "Subscription test #2",
billingPeriod: .monthly,
startedAt: Date().addingTimeInterval(TimeInterval.days(-31)),
expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(-1)),
platform: .apple,
status: .expired)
status: .expired,
activeOffers: [])

public static let expiredStripeSubscription = PrivacyProSubscription(productId: UUID().uuidString,
name: "Subscription test #2",
billingPeriod: .monthly,
startedAt: Date().addingTimeInterval(TimeInterval.days(-31)),
expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(-1)),
platform: .stripe,
status: .expired)
status: .expired,
activeOffers: [])

public static let productsItems: [GetProductsItem] = [GetProductsItem(productId: appleSubscription.productId,
productLabel: appleSubscription.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,13 @@ class DefaultRemoteMessagingSurveyURLBuilderTests: XCTestCase {
)

let subscription = PrivacyProSubscription(productId: "product-id",
name: "product-name",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable)
name: "product-name",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable,
activeOffers: [])

return DefaultRemoteMessagingSurveyURLBuilder(
statisticsStore: mockStatisticsStore,
Expand Down
189 changes: 163 additions & 26 deletions Tests/SubscriptionTests/API/Models/SubscriptionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,29 @@ final class SubscriptionTests: XCTestCase {

func testEquality() throws {
let a = PrivacyProSubscription(productId: "1",
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable)
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable,
activeOffers: [PrivacyProSubscription.Offer(type: .trial)])
let b = PrivacyProSubscription(productId: "1",
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable)
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable,
activeOffers: [PrivacyProSubscription.Offer(type: .trial)])
let c = PrivacyProSubscription(productId: "2",
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable)
name: "a",
billingPeriod: .monthly,
startedAt: Date(timeIntervalSince1970: 1000),
expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
platform: .apple,
status: .autoRenewable,
activeOffers: [])
XCTAssertEqual(a, b)
XCTAssertNotEqual(a, c)
}
Expand All @@ -69,7 +72,18 @@ final class SubscriptionTests: XCTestCase {
}

func testDecoding() throws {
let rawSubscription = "{\"productId\":\"ddg-privacy-pro-sandbox-monthly-renews-us\",\"name\":\"Monthly Subscription\",\"billingPeriod\":\"Monthly\",\"startedAt\":1718104783000,\"expiresOrRenewsAt\":1723375183000,\"platform\":\"stripe\",\"status\":\"Auto-Renewable\"}"
let rawSubscription = """
{
\"productId\": \"ddg-privacy-pro-sandbox-monthly-renews-us\",
\"name\": \"Monthly Subscription\",
\"billingPeriod\": \"Monthly\",
\"startedAt\": 1718104783000,
\"expiresOrRenewsAt\": 1723375183000,
\"platform\": \"stripe\",
\"status\": \"Auto-Renewable\",
\"activeOffers\": []
}
"""

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
Expand Down Expand Up @@ -128,17 +142,140 @@ final class SubscriptionTests: XCTestCase {
let unknown = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"something unexpected\"".utf8))
XCTAssertEqual(unknown, PrivacyProSubscription.Status.unknown)
}

func testOfferTypeDecoding() throws {
let trial = try JSONDecoder().decode(PrivacyProSubscription.OfferType.self, from: Data("\"Trial\"".utf8))
XCTAssertEqual(trial, PrivacyProSubscription.OfferType.trial)

let unknown = try JSONDecoder().decode(PrivacyProSubscription.OfferType.self, from: Data("\"something unexpected\"".utf8))
XCTAssertEqual(unknown, PrivacyProSubscription.OfferType.unknown)
}

func testDecodingWithActiveOffers() throws {
let rawSubscriptionWithOffers = """
{
\"productId\": \"ddg-privacy-pro-sandbox-monthly-renews-us\",
\"name\": \"Monthly Subscription\",
\"billingPeriod\": \"Monthly\",
\"startedAt\": 1718104783000,
\"expiresOrRenewsAt\": 1723375183000,
\"platform\": \"stripe\",
\"status\": \"Auto-Renewable\",
\"activeOffers\": [{ \"type\": \"Trial\"}]
}
"""

let rawSubscriptionWithoutOffers = """
{
\"productId\": \"ddg-privacy-pro-sandbox-monthly-renews-us\",
\"name\": \"Monthly Subscription\",
\"billingPeriod\": \"Monthly\",
\"startedAt\": 1718104783000,
\"expiresOrRenewsAt\": 1723375183000,
\"platform\": \"stripe\",
\"status\": \"Auto-Renewable\",
\"activeOffers\": []
}
"""

let rawSubscriptionWithUnknownOffers = """
{
\"productId\": \"ddg-privacy-pro-sandbox-monthly-renews-us\",
\"name\": \"Monthly Subscription\",
\"billingPeriod\": \"Monthly\",
\"startedAt\": 1718104783000,
\"expiresOrRenewsAt\": 1723375183000,
\"platform\": \"stripe\",
\"status\": \"Auto-Renewable\",
\"activeOffers\": [{ \"type\": \"SpecialOffer\"}]
}
"""

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .millisecondsSince1970

let subscriptionWithOffers = try decoder.decode(PrivacyProSubscription.self, from: Data(rawSubscriptionWithOffers.utf8))
XCTAssertEqual(subscriptionWithOffers.activeOffers, [PrivacyProSubscription.Offer(type: .trial)])

let subscriptionWithoutOffers = try decoder.decode(PrivacyProSubscription.self, from: Data(rawSubscriptionWithoutOffers.utf8))
XCTAssertEqual(subscriptionWithoutOffers.activeOffers, [])

let subscriptionWithUnknownOffers = try decoder.decode(PrivacyProSubscription.self, from: Data(rawSubscriptionWithUnknownOffers.utf8))
XCTAssertEqual(subscriptionWithUnknownOffers.activeOffers, [PrivacyProSubscription.Offer(type: .unknown)])
}

func testHasActiveTrialOffer_WithTrialOffer_ReturnsTrue() {
// Given
let subscription = PrivacyProSubscription.make(
withStatus: .autoRenewable,
activeOffers: [PrivacyProSubscription.Offer(type: .trial)]
)

// When
let hasActiveTrialOffer = subscription.hasActiveTrialOffer

// Then
XCTAssertTrue(hasActiveTrialOffer)
}

func testHasActiveTrialOffer_WithNoOffers_ReturnsFalse() {
// Given
let subscription = PrivacyProSubscription.make(
withStatus: .autoRenewable,
activeOffers: []
)

// When
let hasActiveTrialOffer = subscription.hasActiveTrialOffer

// Then
XCTAssertFalse(hasActiveTrialOffer)
}

func testHasActiveTrialOffer_WithNonTrialOffer_ReturnsFalse() {
// Given
let subscription = PrivacyProSubscription.make(
withStatus: .autoRenewable,
activeOffers: [PrivacyProSubscription.Offer(type: .unknown)]
)

// When
let hasActiveTrialOffer = subscription.hasActiveTrialOffer

// Then
XCTAssertFalse(hasActiveTrialOffer)
}

func testHasActiveTrialOffer_WithMultipleOffersIncludingTrial_ReturnsTrue() {
// Given
let subscription = PrivacyProSubscription.make(
withStatus: .autoRenewable,
activeOffers: [
PrivacyProSubscription.Offer(type: .unknown),
PrivacyProSubscription.Offer(type: .trial),
PrivacyProSubscription.Offer(type: .unknown)
]
)

// When
let hasActiveTrialOffer = subscription.hasActiveTrialOffer

// Then
XCTAssertTrue(hasActiveTrialOffer)
}
}

extension PrivacyProSubscription {

static func make(withStatus status: PrivacyProSubscription.Status) -> PrivacyProSubscription {
static func make(withStatus status: PrivacyProSubscription.Status, activeOffers: [PrivacyProSubscription.Offer] = []) -> PrivacyProSubscription {
PrivacyProSubscription(productId: UUID().uuidString,
name: "Subscription test #1",
billingPeriod: .monthly,
startedAt: Date(),
expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)),
platform: .apple,
status: status)
name: "Subscription test #1",
billingPeriod: .monthly,
startedAt: Date(),
expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)),
platform: .apple,
status: status,
activeOffers: activeOffers)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ final class SubscriptionEndpointServiceTests: XCTestCase {
"startedAt": \(Constants.subscription.startedAt.timeIntervalSince1970*1000),
"expiresOrRenewsAt": \(Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970*1000),
"platform": "\(Constants.subscription.platform.rawValue)",
"status": "\(Constants.subscription.status.rawValue)"
"status": "\(Constants.subscription.status.rawValue)",
"activeOffers": []
}
""".data(using: .utf8)!

Expand Down Expand Up @@ -323,7 +324,8 @@ final class SubscriptionEndpointServiceTests: XCTestCase {
"startedAt": \(Constants.subscription.startedAt.timeIntervalSince1970*1000),
"expiresOrRenewsAt": \(Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970*1000),
"platform": "\(Constants.subscription.platform.rawValue)",
"status": "\(Constants.subscription.status.rawValue)"
"status": "\(Constants.subscription.status.rawValue)",
"activeOffers": []
}
}
""".data(using: .utf8)!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ final class SubscriptionEndpointServiceV2Tests: XCTestCase {
startedAt: date,
expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60),
platform: .apple,
status: .autoRenewable
status: .autoRenewable,
activeOffers: []
)
return try! encoder.encode(subscription)
}
Expand All @@ -88,7 +89,8 @@ final class SubscriptionEndpointServiceV2Tests: XCTestCase {
startedAt: date,
expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60),
platform: .google,
status: .autoRenewable
status: .autoRenewable,
activeOffers: []
)
endpointService.updateCache(with: cachedSubscription)

Expand Down Expand Up @@ -196,7 +198,8 @@ final class SubscriptionEndpointServiceV2Tests: XCTestCase {
startedAt: date,
expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60),
platform: .stripe,
status: .gracePeriod
status: .gracePeriod,
activeOffers: []
)
)
let confirmData = try encoder.encode(confirmResponse)
Expand All @@ -221,7 +224,8 @@ final class SubscriptionEndpointServiceV2Tests: XCTestCase {
startedAt: date,
expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60),
platform: .google,
status: .autoRenewable
status: .autoRenewable,
activeOffers: []
)
endpointService.updateCache(with: subscription)

Expand All @@ -238,7 +242,8 @@ final class SubscriptionEndpointServiceV2Tests: XCTestCase {
startedAt: date,
expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60),
platform: .apple,
status: .autoRenewable
status: .autoRenewable,
activeOffers: []
)
endpointService.updateCache(with: subscription)

Expand Down
Loading

0 comments on commit f0db997

Please sign in to comment.