Skip to content

Commit 1aa2ebf

Browse files
authored
fix: align license payload with server signature, improve license UI (#386)
1 parent a7d9a69 commit 1aa2ebf

4 files changed

Lines changed: 140 additions & 16 deletions

File tree

TablePro/Models/Settings/License.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,34 @@ enum LicenseStatus: String, Codable {
3838

3939
/// The `data` portion of the signed license payload from the server
4040
struct LicensePayloadData: Codable, Equatable {
41+
let billingCycle: String?
4142
let licenseKey: String
4243
let email: String
4344
let status: String
4445
let expiresAt: String?
4546
let issuedAt: String
47+
let tier: String
4648

4749
private enum CodingKeys: String, CodingKey {
50+
case billingCycle = "billing_cycle"
4851
case licenseKey = "license_key"
4952
case email
5053
case status
5154
case expiresAt = "expires_at"
5255
case issuedAt = "issued_at"
56+
case tier
5357
}
5458

5559
/// Custom encode to explicitly write null for nil optionals.
5660
/// The auto-synthesized Codable uses encodeIfPresent which omits nil keys,
57-
/// but PHP's json_encode includes "expires_at":null — the signed JSON must match exactly.
61+
/// but PHP's json_encode includes null values — the signed JSON must match exactly.
5862
func encode(to encoder: Encoder) throws {
5963
var container = encoder.container(keyedBy: CodingKeys.self)
64+
if let billingCycle {
65+
try container.encode(billingCycle, forKey: .billingCycle)
66+
} else {
67+
try container.encodeNil(forKey: .billingCycle)
68+
}
6069
try container.encode(licenseKey, forKey: .licenseKey)
6170
try container.encode(email, forKey: .email)
6271
try container.encode(status, forKey: .status)
@@ -66,6 +75,7 @@ struct LicensePayloadData: Codable, Equatable {
6675
try container.encodeNil(forKey: .expiresAt)
6776
}
6877
try container.encode(issuedAt, forKey: .issuedAt)
78+
try container.encode(tier, forKey: .tier)
6979
}
7080
}
7181

@@ -132,6 +142,8 @@ struct License: Codable, Equatable {
132142
var lastValidatedAt: Date
133143
var machineId: String
134144
var signedPayload: SignedLicensePayload
145+
var tier: String
146+
var billingCycle: String?
135147

136148
/// Whether the license has expired based on expiration date
137149
var isExpired: Bool {
@@ -171,7 +183,9 @@ struct License: Codable, Equatable {
171183
expiresAt: expiresAt,
172184
lastValidatedAt: Date(),
173185
machineId: machineId,
174-
signedPayload: signedPayload
186+
signedPayload: signedPayload,
187+
tier: payload.tier,
188+
billingCycle: payload.billingCycle
175189
)
176190
}
177191
}

TablePro/Views/Components/ProFeatureGate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ struct ProFeatureGateModifier: ViewModifier {
5151
openLicenseSettings()
5252
}
5353
.buttonStyle(.borderedProminent)
54+
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app")!)
55+
.font(.subheadline)
5456
case .unlicensed:
5557
Text("\(feature.displayName) requires a Pro license")
5658
.font(.headline)
@@ -61,6 +63,8 @@ struct ProFeatureGateModifier: ViewModifier {
6163
openLicenseSettings()
6264
}
6365
.buttonStyle(.borderedProminent)
66+
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!)
67+
.font(.subheadline)
6468
}
6569
}
6670
.padding()

TablePro/Views/Settings/LicenseSettingsView.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ struct LicenseSettingsView: View {
3737
Text(maskedKey(license.key))
3838
.textSelection(.enabled)
3939
}
40+
41+
LabeledContent("Status:") {
42+
Text(license.status.displayName)
43+
.foregroundStyle(license.status.isValid ? .green : .red)
44+
}
45+
46+
if let expiresAt = license.expiresAt {
47+
LabeledContent("Expires:", value: expiresAt.formatted(date: .abbreviated, time: .omitted))
48+
} else {
49+
LabeledContent("Expires:", value: String(localized: "Lifetime"))
50+
}
51+
52+
LabeledContent("Tier:", value: license.tier.capitalized)
53+
54+
if let billingCycle = license.billingCycle {
55+
LabeledContent("Billing:", value: billingCycle.capitalized)
56+
}
4057
}
4158

4259
Section("Maintenance") {
@@ -83,6 +100,12 @@ struct LicenseSettingsView: View {
83100
.disabled(licenseKeyInput.trimmingCharacters(in: .whitespaces).isEmpty)
84101
}
85102
}
103+
104+
HStack {
105+
Spacer()
106+
Link("Purchase License", destination: URL(string: "https://tablepro.app")!)
107+
.font(.subheadline)
108+
}
86109
}
87110
}
88111

TableProTests/Models/LicenseTests.swift

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,17 @@ struct LicenseTests {
4444
machineId: "machine1",
4545
signedPayload: SignedLicensePayload(
4646
data: LicensePayloadData(
47+
billingCycle: nil,
4748
licenseKey: "test-key",
4849
email: "test@test.com",
4950
status: "active",
5051
expiresAt: nil,
51-
issuedAt: "2024-01-01T00:00:00Z"
52+
issuedAt: "2024-01-01T00:00:00Z",
53+
tier: "starter"
5254
),
5355
signature: "sig"
54-
)
56+
),
57+
tier: "starter"
5558
)
5659
#expect(license.isExpired == false)
5760
}
@@ -68,14 +71,17 @@ struct LicenseTests {
6871
machineId: "machine1",
6972
signedPayload: SignedLicensePayload(
7073
data: LicensePayloadData(
74+
billingCycle: nil,
7175
licenseKey: "test-key",
7276
email: "test@test.com",
7377
status: "active",
7478
expiresAt: "2025-01-01T00:00:00Z",
75-
issuedAt: "2024-01-01T00:00:00Z"
79+
issuedAt: "2024-01-01T00:00:00Z",
80+
tier: "starter"
7681
),
7782
signature: "sig"
78-
)
83+
),
84+
tier: "starter"
7985
)
8086
#expect(license.isExpired == false)
8187
}
@@ -92,14 +98,17 @@ struct LicenseTests {
9298
machineId: "machine1",
9399
signedPayload: SignedLicensePayload(
94100
data: LicensePayloadData(
101+
billingCycle: nil,
95102
licenseKey: "test-key",
96103
email: "test@test.com",
97104
status: "expired",
98105
expiresAt: "2024-01-01T00:00:00Z",
99-
issuedAt: "2023-01-01T00:00:00Z"
106+
issuedAt: "2023-01-01T00:00:00Z",
107+
tier: "starter"
100108
),
101109
signature: "sig"
102-
)
110+
),
111+
tier: "starter"
103112
)
104113
#expect(license.isExpired == true)
105114
}
@@ -117,14 +126,17 @@ struct LicenseTests {
117126
machineId: "machine1",
118127
signedPayload: SignedLicensePayload(
119128
data: LicensePayloadData(
129+
billingCycle: nil,
120130
licenseKey: "test-key",
121131
email: "test@test.com",
122132
status: "active",
123133
expiresAt: nil,
124-
issuedAt: "2024-01-01T00:00:00Z"
134+
issuedAt: "2024-01-01T00:00:00Z",
135+
tier: "starter"
125136
),
126137
signature: "sig"
127-
)
138+
),
139+
tier: "starter"
128140
)
129141
#expect(license.daysSinceLastValidation == 0)
130142
}
@@ -144,14 +156,17 @@ struct LicenseTests {
144156
machineId: "machine1",
145157
signedPayload: SignedLicensePayload(
146158
data: LicensePayloadData(
159+
billingCycle: nil,
147160
licenseKey: "test-key",
148161
email: "test@test.com",
149162
status: "active",
150163
expiresAt: nil,
151-
issuedAt: "2024-01-01T00:00:00Z"
164+
issuedAt: "2024-01-01T00:00:00Z",
165+
tier: "starter"
152166
),
153167
signature: "sig"
154-
)
168+
),
169+
tier: "starter"
155170
)
156171
#expect(license.daysSinceLastValidation == 5)
157172
}
@@ -161,11 +176,13 @@ struct LicenseTests {
161176
@Test("License.from maps active status correctly")
162177
func licenseFromMapsActiveStatus() {
163178
let payloadData = LicensePayloadData(
179+
billingCycle: nil,
164180
licenseKey: "test-key",
165181
email: "test@test.com",
166182
status: "active",
167183
expiresAt: nil,
168-
issuedAt: "2024-01-01T00:00:00Z"
184+
issuedAt: "2024-01-01T00:00:00Z",
185+
tier: "starter"
169186
)
170187
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
171188
let license = License.from(
@@ -179,11 +196,13 @@ struct LicenseTests {
179196
@Test("License.from maps expired status correctly")
180197
func licenseFromMapsExpiredStatus() {
181198
let payloadData = LicensePayloadData(
199+
billingCycle: "monthly",
182200
licenseKey: "test-key",
183201
email: "test@test.com",
184202
status: "expired",
185203
expiresAt: "2024-01-01T00:00:00Z",
186-
issuedAt: "2023-01-01T00:00:00Z"
204+
issuedAt: "2023-01-01T00:00:00Z",
205+
tier: "starter"
187206
)
188207
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
189208
let license = License.from(
@@ -197,11 +216,13 @@ struct LicenseTests {
197216
@Test("License.from maps suspended status correctly")
198217
func licenseFromMapsSuspendedStatus() {
199218
let payloadData = LicensePayloadData(
219+
billingCycle: nil,
200220
licenseKey: "test-key",
201221
email: "test@test.com",
202222
status: "suspended",
203223
expiresAt: nil,
204-
issuedAt: "2024-01-01T00:00:00Z"
224+
issuedAt: "2024-01-01T00:00:00Z",
225+
tier: "starter"
205226
)
206227
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
207228
let license = License.from(
@@ -215,11 +236,13 @@ struct LicenseTests {
215236
@Test("License.from maps unknown status to validationFailed")
216237
func licenseFromMapsUnknownStatusToValidationFailed() {
217238
let payloadData = LicensePayloadData(
239+
billingCycle: nil,
218240
licenseKey: "test-key",
219241
email: "test@test.com",
220242
status: "unknown",
221243
expiresAt: nil,
222-
issuedAt: "2024-01-01T00:00:00Z"
244+
issuedAt: "2024-01-01T00:00:00Z",
245+
tier: "starter"
223246
)
224247
let signedPayload = SignedLicensePayload(data: payloadData, signature: "sig")
225248
let license = License.from(
@@ -229,4 +252,64 @@ struct LicenseTests {
229252
)
230253
#expect(license.status == .validationFailed)
231254
}
255+
256+
// MARK: - LicensePayloadData Encoding Tests
257+
258+
@Test("LicensePayloadData encodes all 7 fields in alphabetical order matching server format")
259+
func payloadDataEncodesAllFieldsAlphabetically() throws {
260+
let payloadData = LicensePayloadData(
261+
billingCycle: "monthly",
262+
licenseKey: "ABC-123",
263+
email: "user@example.com",
264+
status: "active",
265+
expiresAt: "2025-12-31T23:59:59Z",
266+
issuedAt: "2025-01-01T00:00:00Z",
267+
tier: "pro"
268+
)
269+
let encoder = JSONEncoder()
270+
encoder.outputFormatting = [.sortedKeys]
271+
let data = try encoder.encode(payloadData)
272+
let json = String(data: data, encoding: .utf8)
273+
274+
guard let json else {
275+
Issue.record("Failed to encode payload data to UTF-8 string")
276+
return
277+
}
278+
279+
guard let keys = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
280+
Issue.record("Failed to deserialize JSON as dictionary")
281+
return
282+
}
283+
284+
let expectedKeys = ["billing_cycle", "email", "expires_at", "issued_at", "license_key", "status", "tier"]
285+
#expect(keys.keys.sorted() == expectedKeys)
286+
287+
let billingCycleRange = json.range(of: "billing_cycle")
288+
let tierRange = json.range(of: "tier")
289+
guard let billingCycleRange, let tierRange else {
290+
Issue.record("Expected keys not found in JSON string")
291+
return
292+
}
293+
#expect(billingCycleRange.lowerBound < tierRange.lowerBound)
294+
}
295+
296+
@Test("LicensePayloadData encodes nil billingCycle as null")
297+
func payloadDataEncodesNilBillingCycleAsNull() throws {
298+
let payloadData = LicensePayloadData(
299+
billingCycle: nil,
300+
licenseKey: "ABC-123",
301+
email: "user@example.com",
302+
status: "active",
303+
expiresAt: nil,
304+
issuedAt: "2025-01-01T00:00:00Z",
305+
tier: "starter"
306+
)
307+
let encoder = JSONEncoder()
308+
encoder.outputFormatting = [.sortedKeys]
309+
let data = try encoder.encode(payloadData)
310+
let json = String(data: data, encoding: .utf8)
311+
312+
#expect(json?.contains("\"billing_cycle\":null") == true)
313+
#expect(json?.contains("\"expires_at\":null") == true)
314+
}
232315
}

0 commit comments

Comments
 (0)