From ebf6a8a869ca46bfe8825a08304ee564c2b2cdb8 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Thu, 28 May 2026 14:30:47 +0800 Subject: [PATCH] feat(licensing): expose Stripe SubscriptionState on License via JWT --- Sources/AmoreLicensing/Models/License.swift | 6 +- .../Models/SubscriptionState.swift | 95 ++++++++++++++ .../Payload/LicensePayload.swift | 5 + .../LicensePayloadDecodeTests.swift | 92 +++++++++++++ .../SubscriptionStateCodableTests.swift | 122 ++++++++++++++++++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 Sources/AmoreLicensing/Models/SubscriptionState.swift create mode 100644 Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift create mode 100644 Tests/AmoreLicensingTests/SubscriptionStateCodableTests.swift diff --git a/Sources/AmoreLicensing/Models/License.swift b/Sources/AmoreLicensing/Models/License.swift index e7ba8e4..ac08a3c 100644 --- a/Sources/AmoreLicensing/Models/License.swift +++ b/Sources/AmoreLicensing/Models/License.swift @@ -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 + /// Subscription state if this license is subscription-backed, + /// or `nil` for perpetual / one-time-purchase licenses. + public var subscriptionState: SubscriptionState? } extension License { @@ -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 ) } diff --git a/Sources/AmoreLicensing/Models/SubscriptionState.swift b/Sources/AmoreLicensing/Models/SubscriptionState.swift new file mode 100644 index 0000000..57ed2f7 --- /dev/null +++ b/Sources/AmoreLicensing/Models/SubscriptionState.swift @@ -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)" + ) + } + } + +} diff --git a/Sources/AmoreLicensing/Payload/LicensePayload.swift b/Sources/AmoreLicensing/Payload/LicensePayload.swift index 36df642..ecadc12 100644 --- a/Sources/AmoreLicensing/Payload/LicensePayload.swift +++ b/Sources/AmoreLicensing/Payload/LicensePayload.swift @@ -9,6 +9,7 @@ protocol LicensePayloadProtocol { var nonce: String { get } var product: String { get } var entitlements: Set { get } + var subscriptionState: SubscriptionState? { get } } struct LicensePayload: JWTPayload, LicensePayloadProtocol { @@ -19,6 +20,7 @@ struct LicensePayload: JWTPayload, LicensePayloadProtocol { var nonce: String var product: String = "Amore" var entitlements: Set = [] + var subscriptionState: SubscriptionState? enum CodingKeys: String, CodingKey { case exp @@ -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 { @@ -43,6 +46,7 @@ struct GracePeriodPayload: JWTPayload, LicensePayloadProtocol { var nonce: String var product: String = "Amore" var entitlements: Set = [] + var subscriptionState: SubscriptionState? enum CodingKeys: String, CodingKey { case exp @@ -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 { diff --git a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift new file mode 100644 index 0000000..f69d246 --- /dev/null +++ b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift @@ -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) + } +} diff --git a/Tests/AmoreLicensingTests/SubscriptionStateCodableTests.swift b/Tests/AmoreLicensingTests/SubscriptionStateCodableTests.swift new file mode 100644 index 0000000..a3f574e --- /dev/null +++ b/Tests/AmoreLicensingTests/SubscriptionStateCodableTests.swift @@ -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) + } +}