Skip to content
Merged
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
18 changes: 16 additions & 2 deletions TablePro/Models/Settings/License.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,34 @@ enum LicenseStatus: String, Codable {

/// The `data` portion of the signed license payload from the server
struct LicensePayloadData: Codable, Equatable {
let billingCycle: String?
let licenseKey: String
let email: String
let status: String
let expiresAt: String?
let issuedAt: String
let tier: String

private enum CodingKeys: String, CodingKey {
case billingCycle = "billing_cycle"
case licenseKey = "license_key"
case email
case status
case expiresAt = "expires_at"
case issuedAt = "issued_at"
case tier
}

/// Custom encode to explicitly write null for nil optionals.
/// The auto-synthesized Codable uses encodeIfPresent which omits nil keys,
/// but PHP's json_encode includes "expires_at":null — the signed JSON must match exactly.
/// but PHP's json_encode includes null values — the signed JSON must match exactly.
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
if let billingCycle {
try container.encode(billingCycle, forKey: .billingCycle)
} else {
try container.encodeNil(forKey: .billingCycle)
}
try container.encode(licenseKey, forKey: .licenseKey)
try container.encode(email, forKey: .email)
try container.encode(status, forKey: .status)
Expand All @@ -66,6 +75,7 @@ struct LicensePayloadData: Codable, Equatable {
try container.encodeNil(forKey: .expiresAt)
}
try container.encode(issuedAt, forKey: .issuedAt)
try container.encode(tier, forKey: .tier)
}
}

Expand Down Expand Up @@ -132,6 +142,8 @@ struct License: Codable, Equatable {
var lastValidatedAt: Date
var machineId: String
var signedPayload: SignedLicensePayload
var tier: String
var billingCycle: String?

/// Whether the license has expired based on expiration date
var isExpired: Bool {
Expand Down Expand Up @@ -171,7 +183,9 @@ struct License: Codable, Equatable {
expiresAt: expiresAt,
lastValidatedAt: Date(),
machineId: machineId,
signedPayload: signedPayload
signedPayload: signedPayload,
tier: payload.tier,
billingCycle: payload.billingCycle
)
}
}
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Components/ProFeatureGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ struct ProFeatureGateModifier: ViewModifier {
openLicenseSettings()
}
.buttonStyle(.borderedProminent)
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app")!)
.font(.subheadline)
case .unlicensed:
Text("\(feature.displayName) requires a Pro license")
.font(.headline)
Expand All @@ -61,6 +63,8 @@ struct ProFeatureGateModifier: ViewModifier {
openLicenseSettings()
}
.buttonStyle(.borderedProminent)
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!)
.font(.subheadline)
}
}
.padding()
Expand Down
23 changes: 23 additions & 0 deletions TablePro/Views/Settings/LicenseSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ struct LicenseSettingsView: View {
Text(maskedKey(license.key))
.textSelection(.enabled)
}

LabeledContent("Status:") {
Text(license.status.displayName)
.foregroundStyle(license.status.isValid ? .green : .red)
}

if let expiresAt = license.expiresAt {
LabeledContent("Expires:", value: expiresAt.formatted(date: .abbreviated, time: .omitted))
} else {
LabeledContent("Expires:", value: String(localized: "Lifetime"))
}

LabeledContent("Tier:", value: license.tier.capitalized)

if let billingCycle = license.billingCycle {
LabeledContent("Billing:", value: billingCycle.capitalized)
}
}

Section("Maintenance") {
Expand Down Expand Up @@ -83,6 +100,12 @@ struct LicenseSettingsView: View {
.disabled(licenseKeyInput.trimmingCharacters(in: .whitespaces).isEmpty)
}
}

HStack {
Spacer()
Link("Purchase License", destination: URL(string: "https://tablepro.app")!)
.font(.subheadline)
}
}
}

Expand Down
111 changes: 97 additions & 14 deletions TableProTests/Models/LicenseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,17 @@ struct LicenseTests {
machineId: "machine1",
signedPayload: SignedLicensePayload(
data: LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "active",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
),
signature: "sig"
)
),
tier: "starter"
)
#expect(license.isExpired == false)
}
Expand All @@ -68,14 +71,17 @@ struct LicenseTests {
machineId: "machine1",
signedPayload: SignedLicensePayload(
data: LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "active",
expiresAt: "2025-01-01T00:00:00Z",
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
),
signature: "sig"
)
),
tier: "starter"
)
#expect(license.isExpired == false)
}
Expand All @@ -92,14 +98,17 @@ struct LicenseTests {
machineId: "machine1",
signedPayload: SignedLicensePayload(
data: LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "expired",
expiresAt: "2024-01-01T00:00:00Z",
issuedAt: "2023-01-01T00:00:00Z"
issuedAt: "2023-01-01T00:00:00Z",
tier: "starter"
),
signature: "sig"
)
),
tier: "starter"
)
#expect(license.isExpired == true)
}
Expand All @@ -117,14 +126,17 @@ struct LicenseTests {
machineId: "machine1",
signedPayload: SignedLicensePayload(
data: LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "active",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
),
signature: "sig"
)
),
tier: "starter"
)
#expect(license.daysSinceLastValidation == 0)
}
Expand All @@ -144,14 +156,17 @@ struct LicenseTests {
machineId: "machine1",
signedPayload: SignedLicensePayload(
data: LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "active",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
),
signature: "sig"
)
),
tier: "starter"
)
#expect(license.daysSinceLastValidation == 5)
}
Expand All @@ -161,11 +176,13 @@ struct LicenseTests {
@Test("License.from maps active status correctly")
func licenseFromMapsActiveStatus() {
let payloadData = LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "active",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
)
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
let license = License.from(
Expand All @@ -179,11 +196,13 @@ struct LicenseTests {
@Test("License.from maps expired status correctly")
func licenseFromMapsExpiredStatus() {
let payloadData = LicensePayloadData(
billingCycle: "monthly",
licenseKey: "test-key",
email: "test@test.com",
status: "expired",
expiresAt: "2024-01-01T00:00:00Z",
issuedAt: "2023-01-01T00:00:00Z"
issuedAt: "2023-01-01T00:00:00Z",
tier: "starter"
)
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
let license = License.from(
Expand All @@ -197,11 +216,13 @@ struct LicenseTests {
@Test("License.from maps suspended status correctly")
func licenseFromMapsSuspendedStatus() {
let payloadData = LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "suspended",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
)
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
let license = License.from(
Expand All @@ -215,11 +236,13 @@ struct LicenseTests {
@Test("License.from maps unknown status to validationFailed")
func licenseFromMapsUnknownStatusToValidationFailed() {
let payloadData = LicensePayloadData(
billingCycle: nil,
licenseKey: "test-key",
email: "test@test.com",
status: "unknown",
expiresAt: nil,
issuedAt: "2024-01-01T00:00:00Z"
issuedAt: "2024-01-01T00:00:00Z",
tier: "starter"
)
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
let license = License.from(
Expand All @@ -229,4 +252,64 @@ struct LicenseTests {
)
#expect(license.status == .validationFailed)
}

// MARK: - LicensePayloadData Encoding Tests

@Test("LicensePayloadData encodes all 7 fields in alphabetical order matching server format")
func payloadDataEncodesAllFieldsAlphabetically() throws {
let payloadData = LicensePayloadData(
billingCycle: "monthly",
licenseKey: "ABC-123",
email: "user@example.com",
status: "active",
expiresAt: "2025-12-31T23:59:59Z",
issuedAt: "2025-01-01T00:00:00Z",
tier: "pro"
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(payloadData)
let json = String(data: data, encoding: .utf8)

guard let json else {
Issue.record("Failed to encode payload data to UTF-8 string")
return
}

guard let keys = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
Issue.record("Failed to deserialize JSON as dictionary")
return
}

let expectedKeys = ["billing_cycle", "email", "expires_at", "issued_at", "license_key", "status", "tier"]
#expect(keys.keys.sorted() == expectedKeys)

let billingCycleRange = json.range(of: "billing_cycle")
let tierRange = json.range(of: "tier")
guard let billingCycleRange, let tierRange else {
Issue.record("Expected keys not found in JSON string")
return
}
#expect(billingCycleRange.lowerBound < tierRange.lowerBound)
}

@Test("LicensePayloadData encodes nil billingCycle as null")
func payloadDataEncodesNilBillingCycleAsNull() throws {
let payloadData = LicensePayloadData(
billingCycle: nil,
licenseKey: "ABC-123",
email: "user@example.com",
status: "active",
expiresAt: nil,
issuedAt: "2025-01-01T00:00:00Z",
tier: "starter"
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(payloadData)
let json = String(data: data, encoding: .utf8)

#expect(json?.contains("\"billing_cycle\":null") == true)
#expect(json?.contains("\"expires_at\":null") == true)
}
}
Loading