Skip to content

Commit c19dca8

Browse files
authored
Merge pull request #76 from SOFTNETWORK-APP/feature/licensingInfrastructure
Closed Issue #75
2 parents 1e58fdc + a6aaa72 commit c19dca8

5 files changed

Lines changed: 141 additions & 10 deletions

File tree

core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,13 +1637,16 @@ class LicenseExecutor(
16371637
s"Expired ($days days ago, $remaining days until Community fallback)"
16381638
}
16391639
val degradedNote = if (mgr.wasDegraded) " (degraded)" else ""
1640+
val trialNote = if (key.isTrial) " (trial)" else ""
16401641
val row = ListMap[String, Any](
1641-
"license_type" -> s"${mgr.licenseType}$degradedNote",
1642+
"license_type" -> s"${mgr.licenseType}$trialNote$degradedNote",
1643+
"trial" -> key.isTrial,
16421644
"max_materialized_views" -> formatQuota(mgr.quotas.maxMaterializedViews),
16431645
"max_clusters" -> formatQuota(mgr.quotas.maxClusters),
16441646
"max_result_rows" -> formatQuota(mgr.quotas.maxQueryResults),
16451647
"max_concurrent_queries" -> formatQuota(mgr.quotas.maxConcurrentQueries),
16461648
"expires_at" -> formatExpiry(key.expiresAt),
1649+
"days_remaining" -> key.daysRemaining.getOrElse(-1L),
16471650
"status" -> graceStatus
16481651
)
16491652
ElasticSuccess(QueryRows(Seq(row)))
@@ -1656,16 +1659,19 @@ class LicenseExecutor(
16561659
val row = ListMap[String, Any](
16571660
"previous_tier" -> previousTier.toString,
16581661
"new_tier" -> key.licenseType.toString,
1662+
"trial" -> key.isTrial,
16591663
"expires_at" -> formatExpiry(key.expiresAt),
16601664
"status" -> "Refreshed",
16611665
"message" -> ""
16621666
)
16631667
ElasticSuccess(QueryRows(Seq(row)))
16641668
case Left(err) =>
1669+
val currentKey = strategy.licenseManager.currentLicenseKey
16651670
val row = ListMap[String, Any](
16661671
"previous_tier" -> previousTier.toString,
16671672
"new_tier" -> previousTier.toString,
1668-
"expires_at" -> formatExpiry(strategy.licenseManager.currentLicenseKey.expiresAt),
1673+
"trial" -> currentKey.isTrial,
1674+
"expires_at" -> formatExpiry(currentKey.expiresAt),
16691675
"status" -> "Failed",
16701676
"message" -> err.message
16711677
)

core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
7575

7676
row should contain key "license_type"
7777
row("license_type") shouldBe "Community"
78+
row("trial") shouldBe false
7879
row should contain key "max_materialized_views"
7980
row("max_materialized_views") shouldBe "3"
8081
row should contain key "max_clusters"
@@ -85,6 +86,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
8586
row("max_concurrent_queries") shouldBe "5"
8687
row should contain key "expires_at"
8788
row("expires_at") shouldBe "never"
89+
row("days_remaining") shouldBe -1L
8890
row should contain key "status"
8991
row("status") shouldBe "Active"
9092
}
@@ -107,11 +109,13 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
107109
val row = execute(executor, ShowLicense)
108110

109111
row("license_type") shouldBe "Pro"
112+
row("trial") shouldBe false
110113
row("max_materialized_views") shouldBe "50"
111114
row("max_clusters") shouldBe "5"
112115
row("max_result_rows") shouldBe "1000000"
113116
row("max_concurrent_queries") shouldBe "50"
114117
row("expires_at") shouldBe "2026-12-31T23:59:59Z"
118+
row("days_remaining").asInstanceOf[Long] should be > 0L
115119
row("status") shouldBe "Active"
116120
}
117121

@@ -161,6 +165,30 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
161165
row("status") shouldBe "Expired (10 days ago, 4 days until Community fallback)"
162166
}
163167

168+
it should "show trial indicator for Pro trial license" in {
169+
val trialManager = new LicenseManager {
170+
override def validate(key: String): Either[LicenseError, LicenseKey] =
171+
Left(InvalidLicense("test"))
172+
override def hasFeature(feature: Feature): Boolean = true
173+
override def quotas: Quota = Quota.Pro
174+
override def licenseType: LicenseType = LicenseType.Pro
175+
override def currentLicenseKey: LicenseKey = LicenseKey(
176+
id = "test-trial",
177+
licenseType = LicenseType.Pro,
178+
features = Feature.values.toSet,
179+
expiresAt = Some(Instant.now().plusSeconds(15 * 86400)),
180+
metadata = Map("trial" -> "true")
181+
)
182+
}
183+
val executor = new LicenseExecutor(strategy = mkStrategy(trialManager))
184+
val row = execute(executor, ShowLicense)
185+
186+
row("license_type") shouldBe "Pro (trial)"
187+
row("trial") shouldBe true
188+
row("days_remaining").asInstanceOf[Long] should (be >= 14L and be <= 15L)
189+
row("status") shouldBe "Active"
190+
}
191+
164192
// -------------------------------------------------------------------------
165193
// REFRESH LICENSE
166194
// -------------------------------------------------------------------------
@@ -175,6 +203,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
175203
row("previous_tier") shouldBe "Community"
176204
row should contain key "new_tier"
177205
row("new_tier") shouldBe "Community"
206+
row("trial") shouldBe false
178207
row should contain key "expires_at"
179208
row("expires_at") shouldBe "never"
180209
row should contain key "status"
@@ -204,6 +233,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
204233

205234
row("previous_tier") shouldBe "Pro"
206235
row("new_tier") shouldBe "Pro"
236+
row("trial") shouldBe false
207237
row("expires_at") shouldBe "2027-06-30T23:59:59Z"
208238
row("status") shouldBe "Refreshed"
209239
row("message") shouldBe ""
@@ -255,6 +285,32 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
255285
row("message").toString should include("Network error")
256286
row("previous_tier") shouldBe "Pro"
257287
row("new_tier") shouldBe "Pro"
288+
row("trial") shouldBe false
258289
row("expires_at") shouldBe "2026-12-31T23:59:59Z"
259290
}
291+
292+
it should "include trial flag on successful refresh to trial key" in {
293+
val proManager = new LicenseManager {
294+
override def validate(key: String): Either[LicenseError, LicenseKey] =
295+
Left(InvalidLicense("test"))
296+
override def hasFeature(feature: Feature): Boolean = true
297+
override def quotas: Quota = Quota.Pro
298+
override def licenseType: LicenseType = LicenseType.Pro
299+
}
300+
val trialKey = LicenseKey(
301+
id = "trial-pro",
302+
licenseType = LicenseType.Pro,
303+
features = Feature.values.toSet,
304+
expiresAt = Some(Instant.parse("2026-07-08T23:59:59Z")),
305+
metadata = Map("trial" -> "true")
306+
)
307+
val executor = new LicenseExecutor(
308+
strategy = mkStrategy(proManager, refreshResult = Right(trialKey))
309+
)
310+
val row = executeRefresh(executor)
311+
312+
row("new_tier") shouldBe "Pro"
313+
row("trial") shouldBe true
314+
row("status") shouldBe "Refreshed"
315+
}
260316
}

documentation/sql/dql_statements.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,12 +1318,14 @@ Returns the current license type, quota values, expiration date, and grace statu
13181318

13191319
| Column | Description |
13201320
|--------|-------------|
1321-
| `license_type` | Current license tier (Community, Pro, Enterprise). Shows "(degraded)" suffix if the license was degraded from a higher tier. |
1321+
| `license_type` | Current license tier (Community, Pro, Enterprise). Shows "(trial)" suffix for trial licenses, "(degraded)" suffix if degraded from a higher tier. |
1322+
| `trial` | `true` if the license is a Pro trial, `false` otherwise |
13221323
| `max_materialized_views` | Maximum number of materialized views allowed, or "unlimited" |
13231324
| `max_clusters` | Maximum number of federated clusters allowed, or "unlimited" |
13241325
| `max_result_rows` | Maximum rows returned per query, or "unlimited" |
13251326
| `max_concurrent_queries` | Maximum concurrent queries allowed, or "unlimited" |
13261327
| `expires_at` | License expiration timestamp, or "never" for Community |
1328+
| `days_remaining` | Days until expiration, or -1 for Community (no expiry) |
13271329
| `status` | "Active", or grace period details if expired |
13281330

13291331
**Example:**
@@ -1332,9 +1334,9 @@ Returns the current license type, quota values, expiration date, and grace statu
13321334
SHOW LICENSE;
13331335
```
13341336

1335-
| license_type | max_materialized_views | max_clusters | max_result_rows | max_concurrent_queries | expires_at | status |
1336-
|---|---|---|---|---|---|---|
1337-
| Community | 3 | 2 | 10000 | 5 | never | Active |
1337+
| license_type | trial | max_materialized_views | max_clusters | max_result_rows | max_concurrent_queries | expires_at | days_remaining | status |
1338+
|---|---|---|---|---|---|---|---|---|
1339+
| Community | false | 3 | 2 | 10000 | 5 | never | -1 | Active |
13381340
📊 1 row(s) (1ms)
13391341

13401342
---
@@ -1353,6 +1355,7 @@ Forces an immediate license refresh from the backend (API key fetch). Returns th
13531355
|--------|-------------|
13541356
| `previous_tier` | License tier before refresh |
13551357
| `new_tier` | License tier after refresh |
1358+
| `trial` | `true` if the new license is a Pro trial, `false` otherwise |
13561359
| `expires_at` | New expiration timestamp |
13571360
| `status` | "Refreshed" on success, "Failed" on error |
13581361
| `message` | Error details (empty on success) |
@@ -1363,9 +1366,9 @@ Forces an immediate license refresh from the backend (API key fetch). Returns th
13631366
REFRESH LICENSE;
13641367
```
13651368

1366-
| previous_tier | new_tier | expires_at | status | message |
1367-
|---|---|---|---|---|
1368-
| Community | Community | never | Failed | License refresh is not supported in Community mode |
1369+
| previous_tier | new_tier | trial | expires_at | status | message |
1370+
|---|---|---|---|---|---|
1371+
| Community | Community | false | never | Failed | License refresh is not supported in Community mode |
13691372
📊 1 row(s) (1ms)
13701373

13711374
> **Note:** Requires API key configuration. Without an API key, returns an informational failure message.

licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,16 @@ package object licensing {
9494
features: Set[Feature],
9595
expiresAt: Option[java.time.Instant],
9696
metadata: Map[String, String] = Map.empty
97-
)
97+
) {
98+
99+
/** Whether this is a trial license (Pro trial via API key). */
100+
def isTrial: Boolean = metadata.get("trial").contains("true")
101+
102+
/** Days remaining until expiration, or None if no expiry. */
103+
def daysRemaining: Option[Long] = expiresAt.map { exp =>
104+
java.time.Duration.between(java.time.Instant.now(), exp).toDays
105+
}
106+
}
98107

99108
object LicenseKey {
100109
val Community: LicenseKey = LicenseKey(
@@ -175,6 +184,9 @@ package object licensing {
175184
* Community.
176185
*/
177186
def currentLicenseKey: LicenseKey = LicenseKey.Community
187+
188+
/** Whether the current license is a trial. */
189+
def isTrial: Boolean = currentLicenseKey.isTrial
178190
}
179191

180192
sealed trait LicenseError {

licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package app.softnetwork.elastic.licensing
1818

19+
import java.time.{Duration, Instant}
20+
1921
import org.scalatest.flatspec.AnyFlatSpec
2022
import org.scalatest.matchers.should.Matchers
2123

@@ -84,4 +86,56 @@ class LicenseKeySpec extends AnyFlatSpec with Matchers {
8486
)
8587
key.metadata shouldBe empty
8688
}
89+
90+
"isTrial" should "return true when trial metadata is set" in {
91+
val key = LicenseKey(
92+
id = "org-123",
93+
licenseType = LicenseType.Pro,
94+
features = Set(Feature.MaterializedViews),
95+
expiresAt = Some(Instant.now().plus(Duration.ofDays(30))),
96+
metadata = Map("trial" -> "true")
97+
)
98+
key.isTrial shouldBe true
99+
}
100+
101+
it should "return false for paid Pro" in {
102+
val key = LicenseKey(
103+
id = "org-123",
104+
licenseType = LicenseType.Pro,
105+
features = Set(Feature.MaterializedViews),
106+
expiresAt = Some(Instant.now().plus(Duration.ofDays(365))),
107+
metadata = Map("trial" -> "false")
108+
)
109+
key.isTrial shouldBe false
110+
}
111+
112+
it should "return false when trial metadata is absent" in {
113+
LicenseKey.Community.isTrial shouldBe false
114+
}
115+
116+
"daysRemaining" should "compute days until expiry" in {
117+
val key = LicenseKey(
118+
id = "org-123",
119+
licenseType = LicenseType.Pro,
120+
features = Set(Feature.MaterializedViews),
121+
expiresAt = Some(Instant.now().plus(Duration.ofDays(15))),
122+
metadata = Map.empty
123+
)
124+
key.daysRemaining.get should (be >= 14L and be <= 15L)
125+
}
126+
127+
it should "return None for Community (no expiry)" in {
128+
LicenseKey.Community.daysRemaining shouldBe None
129+
}
130+
131+
it should "return negative for expired keys" in {
132+
val key = LicenseKey(
133+
id = "org-123",
134+
licenseType = LicenseType.Pro,
135+
features = Set(Feature.MaterializedViews),
136+
expiresAt = Some(Instant.now().minus(Duration.ofDays(5))),
137+
metadata = Map.empty
138+
)
139+
key.daysRemaining.get should be < 0L
140+
}
87141
}

0 commit comments

Comments
 (0)