From a6aaa726453659eaba8af7ad2d3b7ed5b3dcf809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 9 Apr 2026 16:25:45 +0200 Subject: [PATCH] feat: add 30-day Pro trial support to licensing module (story 5.9) Add isTrial and daysRemaining to LicenseKey, surface trial flag and days_remaining in SHOW LICENSE / REFRESH LICENSE output, update docs. Closed Issue #75 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../elastic/client/GatewayApi.scala | 10 +++- .../elastic/client/LicenseExecutorSpec.scala | 56 +++++++++++++++++++ documentation/sql/dql_statements.md | 17 +++--- .../elastic/licensing/package.scala | 14 ++++- .../elastic/licensing/LicenseKeySpec.scala | 54 ++++++++++++++++++ 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala b/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala index ce52b5cb..de04f7d3 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala @@ -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))) @@ -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 ) diff --git a/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala index 4ff1c88c..0a2f8e98 100644 --- a/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala +++ b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala @@ -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" @@ -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" } @@ -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" } @@ -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 // ------------------------------------------------------------------------- @@ -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" @@ -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 "" @@ -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" + } } diff --git a/documentation/sql/dql_statements.md b/documentation/sql/dql_statements.md index ac92eca6..30512ea6 100644 --- a/documentation/sql/dql_statements.md +++ b/documentation/sql/dql_statements.md @@ -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:** @@ -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) --- @@ -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) | @@ -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. diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index d79c977e..c1272310 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -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( @@ -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 { diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala index e5527319..8999d693 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala @@ -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 @@ -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 + } }