Skip to content
Open
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: 5 additions & 1 deletion Sources/AmoreLicensing/Models/License.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public struct License: Identifiable, Hashable, Codable, Sendable {
public var expiresAt: Date?
/// The set of entitlement keys granted by this license.
public var entitlements: Set<Entitlement>
/// Subscription state if this license is subscription-backed,
/// or `nil` for perpetual / one-time-purchase licenses.
public var subscriptionState: SubscriptionState?
}

extension License {
Expand All @@ -19,7 +22,8 @@ extension License {
id: payload.licenseId,
name: payload.product,
expiresAt: payload.exp.value,
entitlements: payload.entitlements
entitlements: payload.entitlements,
subscriptionState: payload.subscriptionState
)
}

Expand Down
95 changes: 95 additions & 0 deletions Sources/AmoreLicensing/Models/SubscriptionState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation

/// State of a subscription that backs a ``License``.
///
/// Returned inside ``License/subscriptionState``. `nil` indicates a perpetual
/// or one-time-purchase license with no subscription.
public enum SubscriptionState: Sendable, Hashable {
/// Auto-renews on `renewsAt`. Happy path.
case renewing(renewsAt: Date)

/// Cancellation scheduled. Access works until `endsAt`.
case canceling(endsAt: Date, canceledAt: Date)

/// In trial. Converts to paid on `trialEndsAt` unless `canceledAt != nil`,
/// in which case access stops at `trialEndsAt` with no charge.
case trialing(trialEndsAt: Date, canceledAt: Date?)

/// Payment failed, Stripe retrying. Access typically allowed
/// until `gracePeriodEndsAt`.
case pastDue(gracePeriodEndsAt: Date)

/// Subscription paused.
case paused

/// Terminal: canceled, unpaid, expired, or any unrecoverable state.
case lapsed

}

extension SubscriptionState: Codable {

private enum CodingKeys: String, CodingKey {
case state
case renewsAt = "renews_at"
case endsAt = "ends_at"
case canceledAt = "canceled_at"
case trialEndsAt = "trial_ends_at"
case gracePeriodEndsAt = "grace_period_ends_at"
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .renewing(let renewsAt):
try container.encode("renewing", forKey: .state)
try container.encode(renewsAt, forKey: .renewsAt)
case .canceling(let endsAt, let canceledAt):
try container.encode("canceling", forKey: .state)
try container.encode(endsAt, forKey: .endsAt)
try container.encode(canceledAt, forKey: .canceledAt)
case .trialing(let trialEndsAt, let canceledAt):
try container.encode("trialing", forKey: .state)
try container.encode(trialEndsAt, forKey: .trialEndsAt)
try container.encodeIfPresent(canceledAt, forKey: .canceledAt)
case .pastDue(let gracePeriodEndsAt):
try container.encode("past_due", forKey: .state)
try container.encode(gracePeriodEndsAt, forKey: .gracePeriodEndsAt)
case .paused:
try container.encode("paused", forKey: .state)
case .lapsed:
try container.encode("lapsed", forKey: .state)
}
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let state = try container.decode(String.self, forKey: .state)
switch state {
case "renewing":
self = .renewing(renewsAt: try container.decode(Date.self, forKey: .renewsAt))
case "canceling":
self = .canceling(
endsAt: try container.decode(Date.self, forKey: .endsAt),
canceledAt: try container.decode(Date.self, forKey: .canceledAt)
)
case "trialing":
self = .trialing(
trialEndsAt: try container.decode(Date.self, forKey: .trialEndsAt),
canceledAt: try container.decodeIfPresent(Date.self, forKey: .canceledAt)
)
case "past_due":
self = .pastDue(gracePeriodEndsAt: try container.decode(Date.self, forKey: .gracePeriodEndsAt))
case "paused":
self = .paused
case "lapsed":
self = .lapsed
default:
throw DecodingError.dataCorruptedError(
forKey: .state, in: container,
debugDescription: "Unknown subscription state: \(state)"
)
}
}

}
5 changes: 5 additions & 0 deletions Sources/AmoreLicensing/Payload/LicensePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ protocol LicensePayloadProtocol {
var nonce: String { get }
var product: String { get }
var entitlements: Set<License.Entitlement> { get }
var subscriptionState: SubscriptionState? { get }
}

struct LicensePayload: JWTPayload, LicensePayloadProtocol {
Expand All @@ -19,6 +20,7 @@ struct LicensePayload: JWTPayload, LicensePayloadProtocol {
var nonce: String
var product: String = "Amore"
var entitlements: Set<License.Entitlement> = []
var subscriptionState: SubscriptionState?

enum CodingKeys: String, CodingKey {
case exp
Expand All @@ -28,6 +30,7 @@ struct LicensePayload: JWTPayload, LicensePayloadProtocol {
case nonce
case product
case entitlements
case subscriptionState = "subscription_state"
}

func verify(using algorithm: some JWTAlgorithm) throws {
Expand All @@ -43,6 +46,7 @@ struct GracePeriodPayload: JWTPayload, LicensePayloadProtocol {
var nonce: String
var product: String = "Amore"
var entitlements: Set<License.Entitlement> = []
var subscriptionState: SubscriptionState?

enum CodingKeys: String, CodingKey {
case exp
Expand All @@ -52,6 +56,7 @@ struct GracePeriodPayload: JWTPayload, LicensePayloadProtocol {
case nonce
case product
case entitlements
case subscriptionState = "subscription_state"
}

func verify(using algorithm: some JWTAlgorithm) throws {
Expand Down
92 changes: 92 additions & 0 deletions Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
@testable import AmoreLicensing
import Foundation
import JWTKit
import Testing

@Suite("LicensePayload decode")
struct LicensePayloadDecodeTests {

// Mirror JWTKit: payloads are decoded with .secondsSince1970, so every
// embedded Date (iat/exp and subscription_state) reads as epoch seconds.
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return decoder
}()

private func decodePayload(_ json: String) throws -> LicensePayload {
try decoder.decode(LicensePayload.self, from: Data(json.utf8))
}

@Test func decodesPayloadWithRenewingSubscriptionState() throws {
let json = """
{
"exp": 1800000000,
"iat": 1779000000,
"hardware_id": "hw-1",
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
"nonce": "n-1",
"product": "Pro",
"entitlements": [],
"subscription_state": { "state": "renewing", "renews_at": 1780272000 }
}
"""
let payload = try decodePayload(json)
guard case .renewing(let renewsAt) = payload.subscriptionState else {
Issue.record("Expected .renewing, got \(String(describing: payload.subscriptionState))")
return
}
#expect(renewsAt == Date(timeIntervalSince1970: 1_780_272_000))
}

@Test func decodesPayloadWithoutSubscriptionState() throws {
let json = """
{
"exp": 1800000000,
"iat": 1779000000,
"hardware_id": "hw-1",
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
"nonce": "n-1",
"product": "Pro",
"entitlements": []
}
"""
let payload = try decodePayload(json)
#expect(payload.subscriptionState == nil)
}

@Test func decodesPayloadWithPausedSubscriptionState() throws {
let json = """
{
"exp": 1800000000,
"iat": 1779000000,
"hardware_id": "hw-1",
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
"nonce": "n-1",
"product": "Pro",
"entitlements": [],
"subscription_state": { "state": "paused" }
}
"""
let payload = try decodePayload(json)
#expect(payload.subscriptionState == .paused)
}

@Test func licenseFromPayloadCarriesSubscriptionState() throws {
let json = """
{
"exp": 1800000000,
"iat": 1779000000,
"hardware_id": "hw-1",
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
"nonce": "n-1",
"product": "Pro",
"entitlements": [],
"subscription_state": { "state": "lapsed" }
}
"""
let payload = try decodePayload(json)
let license = License(from: payload)
#expect(license.subscriptionState == .lapsed)
}
}
122 changes: 122 additions & 0 deletions Tests/AmoreLicensingTests/SubscriptionStateCodableTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
@testable import AmoreLicensing
import Foundation
import Testing

@Suite("SubscriptionState Codable (SDK contract)")
struct SubscriptionStateCodableTests {

// Mirror JWTKit's coders: subscription_state only ever travels inside a JWT,
// whose encoder/decoder use .secondsSince1970 for every Date.
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
return encoder
}()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return decoder
}()

private func roundTrip(_ value: SubscriptionState) throws -> SubscriptionState {
let data = try encoder.encode(value)
return try decoder.decode(SubscriptionState.self, from: data)
}

private func jsonObject(_ value: SubscriptionState) throws -> [String: Any] {
let data = try encoder.encode(value)
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
}

@Test func renewingRoundTrips() throws {
let date = Date(timeIntervalSince1970: 1_780_000_000)
let value: SubscriptionState = .renewing(renewsAt: date)
let json = try jsonObject(value)
#expect(json["state"] as? String == "renewing")
#expect(json["renews_at"] as? Int == 1_780_000_000)
#expect(try roundTrip(value) == value)
}

@Test func cancelingRoundTrips() throws {
let ends = Date(timeIntervalSince1970: 1_780_000_000)
let canceled = Date(timeIntervalSince1970: 1_779_000_000)
let value: SubscriptionState = .canceling(endsAt: ends, canceledAt: canceled)
let json = try jsonObject(value)
#expect(json["state"] as? String == "canceling")
#expect(json["ends_at"] as? Int == 1_780_000_000)
#expect(json["canceled_at"] as? Int == 1_779_000_000)
#expect(try roundTrip(value) == value)
}

@Test func trialingWithoutCancellationOmitsCanceledAt() throws {
let trialEnd = Date(timeIntervalSince1970: 1_780_000_000)
let value: SubscriptionState = .trialing(trialEndsAt: trialEnd, canceledAt: nil)
let json = try jsonObject(value)
#expect(json["state"] as? String == "trialing")
#expect(json["trial_ends_at"] as? Int == 1_780_000_000)
#expect(json["canceled_at"] == nil, "canceled_at must be omitted when nil")
#expect(try roundTrip(value) == value)
}

@Test func trialingWithCancellationEncodesCanceledAt() throws {
let trialEnd = Date(timeIntervalSince1970: 1_780_000_000)
let canceled = Date(timeIntervalSince1970: 1_779_000_000)
let value: SubscriptionState = .trialing(trialEndsAt: trialEnd, canceledAt: canceled)
let json = try jsonObject(value)
#expect(json["canceled_at"] as? Int == 1_779_000_000)
#expect(try roundTrip(value) == value)
}

@Test func pastDueRoundTrips() throws {
let grace = Date(timeIntervalSince1970: 1_780_000_000)
let value: SubscriptionState = .pastDue(gracePeriodEndsAt: grace)
let json = try jsonObject(value)
#expect(json["state"] as? String == "past_due")
#expect(json["grace_period_ends_at"] as? Int == 1_780_000_000)
#expect(try roundTrip(value) == value)
}

@Test func pausedRoundTripsWithNoExtraKeys() throws {
let value: SubscriptionState = .paused
let json = try jsonObject(value)
#expect(json["state"] as? String == "paused")
#expect(json.count == 1)
#expect(try roundTrip(value) == value)
}

@Test func lapsedRoundTripsWithNoExtraKeys() throws {
let value: SubscriptionState = .lapsed
let json = try jsonObject(value)
#expect(json["state"] as? String == "lapsed")
#expect(json.count == 1)
#expect(try roundTrip(value) == value)
}

@Test func unknownStateRejected() {
let json = Data(#"{"state":"flapping"}"#.utf8)
#expect(throws: DecodingError.self) {
_ = try self.decoder.decode(SubscriptionState.self, from: json)
}
}

@Test func decodesServerEncodedRenewingFixture() throws {
// Byte-for-byte fixture that the server emits; must decode here.
let json = Data(#"{"state":"renewing","renews_at":1780272000}"#.utf8)
let decoded = try decoder.decode(SubscriptionState.self, from: json)
guard case .renewing(let date) = decoded else {
Issue.record("Expected .renewing, got \(decoded)")
return
}
#expect(date == Date(timeIntervalSince1970: 1_780_272_000))
}

@Test func decodesServerEncodedTrialingNullCanceledAtFixture() throws {
let json = Data(#"{"state":"trialing","trial_ends_at":1780272000,"canceled_at":null}"#.utf8)
let decoded = try decoder.decode(SubscriptionState.self, from: json)
guard case .trialing(_, let canceledAt) = decoded else {
Issue.record("Expected .trialing, got \(decoded)")
return
}
#expect(canceledAt == nil)
}
}
Loading