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
Original file line number Diff line number Diff line change
Expand Up @@ -1637,13 +1637,16 @@ class LicenseExecutor(
s"Expired ($days days ago, $remaining days until Community fallback)"
}
val degradedNote = if (mgr.wasDegraded) " (degraded)" else ""
val trialNote = if (key.isTrial) " (trial)" else ""
val row = ListMap[String, Any](
"license_type" -> s"${mgr.licenseType}$degradedNote",
"license_type" -> s"${mgr.licenseType}$trialNote$degradedNote",
"trial" -> key.isTrial,
"max_materialized_views" -> formatQuota(mgr.quotas.maxMaterializedViews),
"max_clusters" -> formatQuota(mgr.quotas.maxClusters),
"max_result_rows" -> formatQuota(mgr.quotas.maxQueryResults),
"max_concurrent_queries" -> formatQuota(mgr.quotas.maxConcurrentQueries),
"expires_at" -> formatExpiry(key.expiresAt),
"days_remaining" -> key.daysRemaining.getOrElse(-1L),
"status" -> graceStatus
)
ElasticSuccess(QueryRows(Seq(row)))
Expand All @@ -1656,16 +1659,19 @@ class LicenseExecutor(
val row = ListMap[String, Any](
"previous_tier" -> previousTier.toString,
"new_tier" -> key.licenseType.toString,
"trial" -> key.isTrial,
"expires_at" -> formatExpiry(key.expiresAt),
"status" -> "Refreshed",
"message" -> ""
)
ElasticSuccess(QueryRows(Seq(row)))
case Left(err) =>
val currentKey = strategy.licenseManager.currentLicenseKey
val row = ListMap[String, Any](
"previous_tier" -> previousTier.toString,
"new_tier" -> previousTier.toString,
"expires_at" -> formatExpiry(strategy.licenseManager.currentLicenseKey.expiresAt),
"trial" -> currentKey.isTrial,
"expires_at" -> formatExpiry(currentKey.expiresAt),
"status" -> "Failed",
"message" -> err.message
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {

row should contain key "license_type"
row("license_type") shouldBe "Community"
row("trial") shouldBe false
row should contain key "max_materialized_views"
row("max_materialized_views") shouldBe "3"
row should contain key "max_clusters"
Expand All @@ -85,6 +86,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
row("max_concurrent_queries") shouldBe "5"
row should contain key "expires_at"
row("expires_at") shouldBe "never"
row("days_remaining") shouldBe -1L
row should contain key "status"
row("status") shouldBe "Active"
}
Expand All @@ -107,11 +109,13 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
val row = execute(executor, ShowLicense)

row("license_type") shouldBe "Pro"
row("trial") shouldBe false
row("max_materialized_views") shouldBe "50"
row("max_clusters") shouldBe "5"
row("max_result_rows") shouldBe "1000000"
row("max_concurrent_queries") shouldBe "50"
row("expires_at") shouldBe "2026-12-31T23:59:59Z"
row("days_remaining").asInstanceOf[Long] should be > 0L
row("status") shouldBe "Active"
}

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

it should "show trial indicator for Pro trial license" in {
val trialManager = new LicenseManager {
override def validate(key: String): Either[LicenseError, LicenseKey] =
Left(InvalidLicense("test"))
override def hasFeature(feature: Feature): Boolean = true
override def quotas: Quota = Quota.Pro
override def licenseType: LicenseType = LicenseType.Pro
override def currentLicenseKey: LicenseKey = LicenseKey(
id = "test-trial",
licenseType = LicenseType.Pro,
features = Feature.values.toSet,
expiresAt = Some(Instant.now().plusSeconds(15 * 86400)),
metadata = Map("trial" -> "true")
)
}
val executor = new LicenseExecutor(strategy = mkStrategy(trialManager))
val row = execute(executor, ShowLicense)

row("license_type") shouldBe "Pro (trial)"
row("trial") shouldBe true
row("days_remaining").asInstanceOf[Long] should (be >= 14L and be <= 15L)
row("status") shouldBe "Active"
}

// -------------------------------------------------------------------------
// REFRESH LICENSE
// -------------------------------------------------------------------------
Expand All @@ -175,6 +203,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
row("previous_tier") shouldBe "Community"
row should contain key "new_tier"
row("new_tier") shouldBe "Community"
row("trial") shouldBe false
row should contain key "expires_at"
row("expires_at") shouldBe "never"
row should contain key "status"
Expand Down Expand Up @@ -204,6 +233,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {

row("previous_tier") shouldBe "Pro"
row("new_tier") shouldBe "Pro"
row("trial") shouldBe false
row("expires_at") shouldBe "2027-06-30T23:59:59Z"
row("status") shouldBe "Refreshed"
row("message") shouldBe ""
Expand Down Expand Up @@ -255,6 +285,32 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
row("message").toString should include("Network error")
row("previous_tier") shouldBe "Pro"
row("new_tier") shouldBe "Pro"
row("trial") shouldBe false
row("expires_at") shouldBe "2026-12-31T23:59:59Z"
}

it should "include trial flag on successful refresh to trial key" in {
val proManager = new LicenseManager {
override def validate(key: String): Either[LicenseError, LicenseKey] =
Left(InvalidLicense("test"))
override def hasFeature(feature: Feature): Boolean = true
override def quotas: Quota = Quota.Pro
override def licenseType: LicenseType = LicenseType.Pro
}
val trialKey = LicenseKey(
id = "trial-pro",
licenseType = LicenseType.Pro,
features = Feature.values.toSet,
expiresAt = Some(Instant.parse("2026-07-08T23:59:59Z")),
metadata = Map("trial" -> "true")
)
val executor = new LicenseExecutor(
strategy = mkStrategy(proManager, refreshResult = Right(trialKey))
)
val row = executeRefresh(executor)

row("new_tier") shouldBe "Pro"
row("trial") shouldBe true
row("status") shouldBe "Refreshed"
}
}
17 changes: 10 additions & 7 deletions documentation/sql/dql_statements.md
Original file line number Diff line number Diff line change
Expand Up @@ -1318,12 +1318,14 @@ Returns the current license type, quota values, expiration date, and grace statu

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

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

| license_type | max_materialized_views | max_clusters | max_result_rows | max_concurrent_queries | expires_at | status |
|---|---|---|---|---|---|---|
| Community | 3 | 2 | 10000 | 5 | never | Active |
| license_type | trial | max_materialized_views | max_clusters | max_result_rows | max_concurrent_queries | expires_at | days_remaining | status |
|---|---|---|---|---|---|---|---|---|
| Community | false | 3 | 2 | 10000 | 5 | never | -1 | Active |
📊 1 row(s) (1ms)

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

| previous_tier | new_tier | expires_at | status | message |
|---|---|---|---|---|
| Community | Community | never | Failed | License refresh is not supported in Community mode |
| previous_tier | new_tier | trial | expires_at | status | message |
|---|---|---|---|---|---|
| Community | Community | false | never | Failed | License refresh is not supported in Community mode |
📊 1 row(s) (1ms)

> **Note:** Requires API key configuration. Without an API key, returns an informational failure message.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,16 @@ package object licensing {
features: Set[Feature],
expiresAt: Option[java.time.Instant],
metadata: Map[String, String] = Map.empty
)
) {

/** Whether this is a trial license (Pro trial via API key). */
def isTrial: Boolean = metadata.get("trial").contains("true")

/** Days remaining until expiration, or None if no expiry. */
def daysRemaining: Option[Long] = expiresAt.map { exp =>
java.time.Duration.between(java.time.Instant.now(), exp).toDays
}
}

object LicenseKey {
val Community: LicenseKey = LicenseKey(
Expand Down Expand Up @@ -175,6 +184,9 @@ package object licensing {
* Community.
*/
def currentLicenseKey: LicenseKey = LicenseKey.Community

/** Whether the current license is a trial. */
def isTrial: Boolean = currentLicenseKey.isTrial
}

sealed trait LicenseError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package app.softnetwork.elastic.licensing

import java.time.{Duration, Instant}

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

Expand Down Expand Up @@ -84,4 +86,56 @@ class LicenseKeySpec extends AnyFlatSpec with Matchers {
)
key.metadata shouldBe empty
}

"isTrial" should "return true when trial metadata is set" in {
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.now().plus(Duration.ofDays(30))),
metadata = Map("trial" -> "true")
)
key.isTrial shouldBe true
}

it should "return false for paid Pro" in {
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.now().plus(Duration.ofDays(365))),
metadata = Map("trial" -> "false")
)
key.isTrial shouldBe false
}

it should "return false when trial metadata is absent" in {
LicenseKey.Community.isTrial shouldBe false
}

"daysRemaining" should "compute days until expiry" in {
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.now().plus(Duration.ofDays(15))),
metadata = Map.empty
)
key.daysRemaining.get should (be >= 14L and be <= 15L)
}

it should "return None for Community (no expiry)" in {
LicenseKey.Community.daysRemaining shouldBe None
}

it should "return negative for expired keys" in {
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.now().minus(Duration.ofDays(5))),
metadata = Map.empty
)
key.daysRemaining.get should be < 0L
}
}
Loading