Skip to content

Commit a877bf9

Browse files
authored
Merge pull request #3 from AmoreComputer/feat/license-subscription-state
Expose SubscriptionState on License via JWT
2 parents 398ae12 + ebf6a8a commit a877bf9

5 files changed

Lines changed: 319 additions & 1 deletion

File tree

‎Sources/AmoreLicensing/Models/License.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ public struct License: Identifiable, Hashable, Codable, Sendable {
1010
public var expiresAt: Date?
1111
/// The set of entitlement keys granted by this license.
1212
public var entitlements: Set<Entitlement>
13+
/// Subscription state if this license is subscription-backed,
14+
/// or `nil` for perpetual / one-time-purchase licenses.
15+
public var subscriptionState: SubscriptionState?
1316
}
1417

1518
extension License {
@@ -19,7 +22,8 @@ extension License {
1922
id: payload.licenseId,
2023
name: payload.product,
2124
expiresAt: payload.exp.value,
22-
entitlements: payload.entitlements
25+
entitlements: payload.entitlements,
26+
subscriptionState: payload.subscriptionState
2327
)
2428
}
2529

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import Foundation
2+
3+
/// State of a subscription that backs a ``License``.
4+
///
5+
/// Returned inside ``License/subscriptionState``. `nil` indicates a perpetual
6+
/// or one-time-purchase license with no subscription.
7+
public enum SubscriptionState: Sendable, Hashable {
8+
/// Auto-renews on `renewsAt`. Happy path.
9+
case renewing(renewsAt: Date)
10+
11+
/// Cancellation scheduled. Access works until `endsAt`.
12+
case canceling(endsAt: Date, canceledAt: Date)
13+
14+
/// In trial. Converts to paid on `trialEndsAt` unless `canceledAt != nil`,
15+
/// in which case access stops at `trialEndsAt` with no charge.
16+
case trialing(trialEndsAt: Date, canceledAt: Date?)
17+
18+
/// Payment failed, Stripe retrying. Access typically allowed
19+
/// until `gracePeriodEndsAt`.
20+
case pastDue(gracePeriodEndsAt: Date)
21+
22+
/// Subscription paused.
23+
case paused
24+
25+
/// Terminal: canceled, unpaid, expired, or any unrecoverable state.
26+
case lapsed
27+
28+
}
29+
30+
extension SubscriptionState: Codable {
31+
32+
private enum CodingKeys: String, CodingKey {
33+
case state
34+
case renewsAt = "renews_at"
35+
case endsAt = "ends_at"
36+
case canceledAt = "canceled_at"
37+
case trialEndsAt = "trial_ends_at"
38+
case gracePeriodEndsAt = "grace_period_ends_at"
39+
}
40+
41+
public func encode(to encoder: Encoder) throws {
42+
var container = encoder.container(keyedBy: CodingKeys.self)
43+
switch self {
44+
case .renewing(let renewsAt):
45+
try container.encode("renewing", forKey: .state)
46+
try container.encode(renewsAt, forKey: .renewsAt)
47+
case .canceling(let endsAt, let canceledAt):
48+
try container.encode("canceling", forKey: .state)
49+
try container.encode(endsAt, forKey: .endsAt)
50+
try container.encode(canceledAt, forKey: .canceledAt)
51+
case .trialing(let trialEndsAt, let canceledAt):
52+
try container.encode("trialing", forKey: .state)
53+
try container.encode(trialEndsAt, forKey: .trialEndsAt)
54+
try container.encodeIfPresent(canceledAt, forKey: .canceledAt)
55+
case .pastDue(let gracePeriodEndsAt):
56+
try container.encode("past_due", forKey: .state)
57+
try container.encode(gracePeriodEndsAt, forKey: .gracePeriodEndsAt)
58+
case .paused:
59+
try container.encode("paused", forKey: .state)
60+
case .lapsed:
61+
try container.encode("lapsed", forKey: .state)
62+
}
63+
}
64+
65+
public init(from decoder: Decoder) throws {
66+
let container = try decoder.container(keyedBy: CodingKeys.self)
67+
let state = try container.decode(String.self, forKey: .state)
68+
switch state {
69+
case "renewing":
70+
self = .renewing(renewsAt: try container.decode(Date.self, forKey: .renewsAt))
71+
case "canceling":
72+
self = .canceling(
73+
endsAt: try container.decode(Date.self, forKey: .endsAt),
74+
canceledAt: try container.decode(Date.self, forKey: .canceledAt)
75+
)
76+
case "trialing":
77+
self = .trialing(
78+
trialEndsAt: try container.decode(Date.self, forKey: .trialEndsAt),
79+
canceledAt: try container.decodeIfPresent(Date.self, forKey: .canceledAt)
80+
)
81+
case "past_due":
82+
self = .pastDue(gracePeriodEndsAt: try container.decode(Date.self, forKey: .gracePeriodEndsAt))
83+
case "paused":
84+
self = .paused
85+
case "lapsed":
86+
self = .lapsed
87+
default:
88+
throw DecodingError.dataCorruptedError(
89+
forKey: .state, in: container,
90+
debugDescription: "Unknown subscription state: \(state)"
91+
)
92+
}
93+
}
94+
95+
}

‎Sources/AmoreLicensing/Payload/LicensePayload.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ protocol LicensePayloadProtocol {
99
var nonce: String { get }
1010
var product: String { get }
1111
var entitlements: Set<License.Entitlement> { get }
12+
var subscriptionState: SubscriptionState? { get }
1213
}
1314

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

2325
enum CodingKeys: String, CodingKey {
2426
case exp
@@ -28,6 +30,7 @@ struct LicensePayload: JWTPayload, LicensePayloadProtocol {
2830
case nonce
2931
case product
3032
case entitlements
33+
case subscriptionState = "subscription_state"
3134
}
3235

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

4751
enum CodingKeys: String, CodingKey {
4852
case exp
@@ -52,6 +56,7 @@ struct GracePeriodPayload: JWTPayload, LicensePayloadProtocol {
5256
case nonce
5357
case product
5458
case entitlements
59+
case subscriptionState = "subscription_state"
5560
}
5661

5762
func verify(using algorithm: some JWTAlgorithm) throws {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
@testable import AmoreLicensing
2+
import Foundation
3+
import JWTKit
4+
import Testing
5+
6+
@Suite("LicensePayload decode")
7+
struct LicensePayloadDecodeTests {
8+
9+
// Mirror JWTKit: payloads are decoded with .secondsSince1970, so every
10+
// embedded Date (iat/exp and subscription_state) reads as epoch seconds.
11+
private let decoder: JSONDecoder = {
12+
let decoder = JSONDecoder()
13+
decoder.dateDecodingStrategy = .secondsSince1970
14+
return decoder
15+
}()
16+
17+
private func decodePayload(_ json: String) throws -> LicensePayload {
18+
try decoder.decode(LicensePayload.self, from: Data(json.utf8))
19+
}
20+
21+
@Test func decodesPayloadWithRenewingSubscriptionState() throws {
22+
let json = """
23+
{
24+
"exp": 1800000000,
25+
"iat": 1779000000,
26+
"hardware_id": "hw-1",
27+
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
28+
"nonce": "n-1",
29+
"product": "Pro",
30+
"entitlements": [],
31+
"subscription_state": { "state": "renewing", "renews_at": 1780272000 }
32+
}
33+
"""
34+
let payload = try decodePayload(json)
35+
guard case .renewing(let renewsAt) = payload.subscriptionState else {
36+
Issue.record("Expected .renewing, got \(String(describing: payload.subscriptionState))")
37+
return
38+
}
39+
#expect(renewsAt == Date(timeIntervalSince1970: 1_780_272_000))
40+
}
41+
42+
@Test func decodesPayloadWithoutSubscriptionState() throws {
43+
let json = """
44+
{
45+
"exp": 1800000000,
46+
"iat": 1779000000,
47+
"hardware_id": "hw-1",
48+
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
49+
"nonce": "n-1",
50+
"product": "Pro",
51+
"entitlements": []
52+
}
53+
"""
54+
let payload = try decodePayload(json)
55+
#expect(payload.subscriptionState == nil)
56+
}
57+
58+
@Test func decodesPayloadWithPausedSubscriptionState() throws {
59+
let json = """
60+
{
61+
"exp": 1800000000,
62+
"iat": 1779000000,
63+
"hardware_id": "hw-1",
64+
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
65+
"nonce": "n-1",
66+
"product": "Pro",
67+
"entitlements": [],
68+
"subscription_state": { "state": "paused" }
69+
}
70+
"""
71+
let payload = try decodePayload(json)
72+
#expect(payload.subscriptionState == .paused)
73+
}
74+
75+
@Test func licenseFromPayloadCarriesSubscriptionState() throws {
76+
let json = """
77+
{
78+
"exp": 1800000000,
79+
"iat": 1779000000,
80+
"hardware_id": "hw-1",
81+
"license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E",
82+
"nonce": "n-1",
83+
"product": "Pro",
84+
"entitlements": [],
85+
"subscription_state": { "state": "lapsed" }
86+
}
87+
"""
88+
let payload = try decodePayload(json)
89+
let license = License(from: payload)
90+
#expect(license.subscriptionState == .lapsed)
91+
}
92+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
@testable import AmoreLicensing
2+
import Foundation
3+
import Testing
4+
5+
@Suite("SubscriptionState Codable (SDK contract)")
6+
struct SubscriptionStateCodableTests {
7+
8+
// Mirror JWTKit's coders: subscription_state only ever travels inside a JWT,
9+
// whose encoder/decoder use .secondsSince1970 for every Date.
10+
private let encoder: JSONEncoder = {
11+
let encoder = JSONEncoder()
12+
encoder.dateEncodingStrategy = .secondsSince1970
13+
return encoder
14+
}()
15+
private let decoder: JSONDecoder = {
16+
let decoder = JSONDecoder()
17+
decoder.dateDecodingStrategy = .secondsSince1970
18+
return decoder
19+
}()
20+
21+
private func roundTrip(_ value: SubscriptionState) throws -> SubscriptionState {
22+
let data = try encoder.encode(value)
23+
return try decoder.decode(SubscriptionState.self, from: data)
24+
}
25+
26+
private func jsonObject(_ value: SubscriptionState) throws -> [String: Any] {
27+
let data = try encoder.encode(value)
28+
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
29+
}
30+
31+
@Test func renewingRoundTrips() throws {
32+
let date = Date(timeIntervalSince1970: 1_780_000_000)
33+
let value: SubscriptionState = .renewing(renewsAt: date)
34+
let json = try jsonObject(value)
35+
#expect(json["state"] as? String == "renewing")
36+
#expect(json["renews_at"] as? Int == 1_780_000_000)
37+
#expect(try roundTrip(value) == value)
38+
}
39+
40+
@Test func cancelingRoundTrips() throws {
41+
let ends = Date(timeIntervalSince1970: 1_780_000_000)
42+
let canceled = Date(timeIntervalSince1970: 1_779_000_000)
43+
let value: SubscriptionState = .canceling(endsAt: ends, canceledAt: canceled)
44+
let json = try jsonObject(value)
45+
#expect(json["state"] as? String == "canceling")
46+
#expect(json["ends_at"] as? Int == 1_780_000_000)
47+
#expect(json["canceled_at"] as? Int == 1_779_000_000)
48+
#expect(try roundTrip(value) == value)
49+
}
50+
51+
@Test func trialingWithoutCancellationOmitsCanceledAt() throws {
52+
let trialEnd = Date(timeIntervalSince1970: 1_780_000_000)
53+
let value: SubscriptionState = .trialing(trialEndsAt: trialEnd, canceledAt: nil)
54+
let json = try jsonObject(value)
55+
#expect(json["state"] as? String == "trialing")
56+
#expect(json["trial_ends_at"] as? Int == 1_780_000_000)
57+
#expect(json["canceled_at"] == nil, "canceled_at must be omitted when nil")
58+
#expect(try roundTrip(value) == value)
59+
}
60+
61+
@Test func trialingWithCancellationEncodesCanceledAt() throws {
62+
let trialEnd = Date(timeIntervalSince1970: 1_780_000_000)
63+
let canceled = Date(timeIntervalSince1970: 1_779_000_000)
64+
let value: SubscriptionState = .trialing(trialEndsAt: trialEnd, canceledAt: canceled)
65+
let json = try jsonObject(value)
66+
#expect(json["canceled_at"] as? Int == 1_779_000_000)
67+
#expect(try roundTrip(value) == value)
68+
}
69+
70+
@Test func pastDueRoundTrips() throws {
71+
let grace = Date(timeIntervalSince1970: 1_780_000_000)
72+
let value: SubscriptionState = .pastDue(gracePeriodEndsAt: grace)
73+
let json = try jsonObject(value)
74+
#expect(json["state"] as? String == "past_due")
75+
#expect(json["grace_period_ends_at"] as? Int == 1_780_000_000)
76+
#expect(try roundTrip(value) == value)
77+
}
78+
79+
@Test func pausedRoundTripsWithNoExtraKeys() throws {
80+
let value: SubscriptionState = .paused
81+
let json = try jsonObject(value)
82+
#expect(json["state"] as? String == "paused")
83+
#expect(json.count == 1)
84+
#expect(try roundTrip(value) == value)
85+
}
86+
87+
@Test func lapsedRoundTripsWithNoExtraKeys() throws {
88+
let value: SubscriptionState = .lapsed
89+
let json = try jsonObject(value)
90+
#expect(json["state"] as? String == "lapsed")
91+
#expect(json.count == 1)
92+
#expect(try roundTrip(value) == value)
93+
}
94+
95+
@Test func unknownStateRejected() {
96+
let json = Data(#"{"state":"flapping"}"#.utf8)
97+
#expect(throws: DecodingError.self) {
98+
_ = try self.decoder.decode(SubscriptionState.self, from: json)
99+
}
100+
}
101+
102+
@Test func decodesServerEncodedRenewingFixture() throws {
103+
// Byte-for-byte fixture that the server emits; must decode here.
104+
let json = Data(#"{"state":"renewing","renews_at":1780272000}"#.utf8)
105+
let decoded = try decoder.decode(SubscriptionState.self, from: json)
106+
guard case .renewing(let date) = decoded else {
107+
Issue.record("Expected .renewing, got \(decoded)")
108+
return
109+
}
110+
#expect(date == Date(timeIntervalSince1970: 1_780_272_000))
111+
}
112+
113+
@Test func decodesServerEncodedTrialingNullCanceledAtFixture() throws {
114+
let json = Data(#"{"state":"trialing","trial_ends_at":1780272000,"canceled_at":null}"#.utf8)
115+
let decoded = try decoder.decode(SubscriptionState.self, from: json)
116+
guard case .trialing(_, let canceledAt) = decoded else {
117+
Issue.record("Expected .trialing, got \(decoded)")
118+
return
119+
}
120+
#expect(canceledAt == nil)
121+
}
122+
}

0 commit comments

Comments
 (0)