From b27811a5817632bb32d3e55b0e16aa6490fdd8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 13:52:51 +0700 Subject: [PATCH] fix: align license payload with server signature, improve license UI --- TablePro/Models/Settings/License.swift | 18 ++- .../Views/Components/ProFeatureGate.swift | 4 + .../Views/Settings/LicenseSettingsView.swift | 23 ++++ TableProTests/Models/LicenseTests.swift | 111 +++++++++++++++--- 4 files changed, 140 insertions(+), 16 deletions(-) diff --git a/TablePro/Models/Settings/License.swift b/TablePro/Models/Settings/License.swift index 2f45ce1c5..7ecbec95b 100644 --- a/TablePro/Models/Settings/License.swift +++ b/TablePro/Models/Settings/License.swift @@ -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) @@ -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) } } @@ -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 { @@ -171,7 +183,9 @@ struct License: Codable, Equatable { expiresAt: expiresAt, lastValidatedAt: Date(), machineId: machineId, - signedPayload: signedPayload + signedPayload: signedPayload, + tier: payload.tier, + billingCycle: payload.billingCycle ) } } diff --git a/TablePro/Views/Components/ProFeatureGate.swift b/TablePro/Views/Components/ProFeatureGate.swift index feb925411..1e74bebed 100644 --- a/TablePro/Views/Components/ProFeatureGate.swift +++ b/TablePro/Views/Components/ProFeatureGate.swift @@ -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) @@ -61,6 +63,8 @@ struct ProFeatureGateModifier: ViewModifier { openLicenseSettings() } .buttonStyle(.borderedProminent) + Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!) + .font(.subheadline) } } .padding() diff --git a/TablePro/Views/Settings/LicenseSettingsView.swift b/TablePro/Views/Settings/LicenseSettingsView.swift index a708b75c1..492c1b8f7 100644 --- a/TablePro/Views/Settings/LicenseSettingsView.swift +++ b/TablePro/Views/Settings/LicenseSettingsView.swift @@ -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") { @@ -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) + } } } diff --git a/TableProTests/Models/LicenseTests.swift b/TableProTests/Models/LicenseTests.swift index ad135b8c5..175005695 100644 --- a/TableProTests/Models/LicenseTests.swift +++ b/TableProTests/Models/LicenseTests.swift @@ -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) } @@ -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) } @@ -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) } @@ -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) } @@ -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) } @@ -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( @@ -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( @@ -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( @@ -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( @@ -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) + } }