diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 3a02cc09c..bb750dba3 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -436,8 +436,9 @@ func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.C rt.registry, rt.provisioner, account.WorkspaceLimitPolicy{ - ProviderHostedMSP: true, - ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg), + ProviderHostedMSP: true, + ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg), + ProviderMSPLicenseLapsed: func() bool { return rt.cfg.ProviderMSPLicenseLapsed(time.Now()) }, }, ) mux := http.NewServeMux() diff --git a/cmd/pulse-control-plane/provider_msp_status.go b/cmd/pulse-control-plane/provider_msp_status.go index 0dfe73f80..3dc8421b2 100644 --- a/cmd/pulse-control-plane/provider_msp_status.go +++ b/cmd/pulse-control-plane/provider_msp_status.go @@ -23,15 +23,18 @@ type providerMSPStatusOptions struct { } type providerMSPStatusReport struct { - OK bool - Environment string - ControlMode string - BaseURL string - PlanVersion string - PlanSource string - LicenseID string - LicenseEmail string - WorkspaceLimit int + OK bool + Environment string + ControlMode string + BaseURL string + PlanVersion string + PlanSource string + LicenseID string + LicenseEmail string + WorkspaceLimit int + // LicenseLapsed is informational, not a failure: a lapsed install must + // still upgrade and keep its portal up so the provider can renew there. + LicenseLapsed bool RegistryReady bool TotalTenants int HealthyTenants int @@ -117,6 +120,7 @@ func runProviderMSPStatusWithDependencies(ctx context.Context, cfg *cloudcp.CPCo LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID), LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)), WorkspaceLimit: workspaceLimit, + LicenseLapsed: cfg.ProviderMSPLicenseLapsed(deps.Now()), CountsByState: map[registry.TenantState]int{}, } addFailure := func(format string, args ...any) { @@ -399,6 +403,7 @@ func printProviderMSPStatusReport(report *providerMSPStatusReport) { fmt.Printf("license_id=%s\n", report.LicenseID) fmt.Printf("license_email=%s\n", report.LicenseEmail) fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit) + fmt.Printf("license_lapsed=%t\n", report.LicenseLapsed) fmt.Printf("registry_ready=%t\n", report.RegistryReady) fmt.Printf("total_tenants=%d\n", report.TotalTenants) fmt.Printf("healthy_tenants=%d\n", report.HealthyTenants) diff --git a/cmd/pulse-control-plane/provider_msp_status_test.go b/cmd/pulse-control-plane/provider_msp_status_test.go index e6ea84fa7..ef01aada9 100644 --- a/cmd/pulse-control-plane/provider_msp_status_test.go +++ b/cmd/pulse-control-plane/provider_msp_status_test.go @@ -73,6 +73,30 @@ func TestProviderMSPStatusReportsHealthyOperatorSurface(t *testing.T) { } } +// A lapsed licence is reported, not failed: upgrade.sh gates on status, and a +// lapsed install must still upgrade and keep its portal up to renew. +func TestProviderMSPStatusReportsALapsedLicenceWithoutFailing(t *testing.T) { + cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile) + now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + cfg.ProviderMSPLicenseExpiresAt = now.Add(-30 * 24 * time.Hour) + report, err := runProviderMSPStatusWithDependencies(context.Background(), cfg, providerMSPStatusOptions{}, providerMSPStatusDependencies{ + RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) { + return healthyProviderMSPStatusPreflightReport(), nil + }, + NewDocker: healthyProviderMSPStatusDocker(map[string]bool{}), + CheckBackup: func(context.Context, *cloudcp.CPConfig) (*providerMSPBackupStatus, error) { + return healthyProviderMSPBackupStatus(now), nil + }, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("runProviderMSPStatusWithDependencies: %v", err) + } + if !report.OK || !report.LicenseLapsed { + t.Fatalf("report OK=%v LicenseLapsed=%v failures=%v, want OK and lapsed", report.OK, report.LicenseLapsed, report.Failures) + } +} + func TestProviderMSPStatusFailsOnFailedUnhealthyAndStuckWorkspaces(t *testing.T) { cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile) now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 1a582b52b..65b8b37ac 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3455,6 +3455,40 @@ Regression coverage: `TestEnsureTenantNetworkRejectsUnownedExistingNetwork` in `internal/cloudcp/docker/manager_test.go`. +### A provider control plane keeps its portal up on a lapsed licence + +A provider-hosted control plane used to refuse to start once its licence was +past expiry and the 7-day grace: `ValidateLicense` rejected it, so `LoadConfig` +failed and the portal went down at the next restart. That is exactly when the +provider needs the portal, because Plan is the only place to buy or renew; +the same happened to a paying provider whose subscription lapsed. Startup now +resolves an authentic, key-bound licence with +`pkglicensing.ValidateLicenseAllowingLapse`, preferring a current licence +(renewed first) and otherwise the lapsed one that expired later, and logs the +lapse. Nothing is unlocked by this: client runtimes still verify the provider +licence carried in each lease with `ValidateLicense` and drop MSP capabilities +once it lapses, workspace creation refuses with `provider_msp_license_lapsed`, +and the refresher still refuses to adopt a lapsed licence from the licence +server. The Plan panel reads `lapsed` from the plan state, says the evaluation +or plan ended and what that means for clients, and offers the plans, including +the same plan again after a paid plan ends; `provider-msp status` prints +`license_lapsed` without failing, so a lapsed install can still upgrade. The +portal also shows a control plane error's `message` in preference to its +machine `error` code, which had been surfacing codes such as +`provider_msp_license_lapsed` and `already_subscribed` as the whole message. +Verified on 2026-09-24 against the walkthrough lab: the control plane started +on an evaluation that lapsed 20 days earlier, the Plan panel offered Solo and +Starter, adding a client showed the lapse sentence, and with a live +subscription the refresher restored the paid licence on its own. Regression +coverage: `TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence` +in `pkg/licensing/service_lapsed_test.go`, +`TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence` in +`internal/cloudcp/config_test.go`, +`TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence` in +`internal/cloudcp/account/tenant_handlers_lapsed_test.go`, and +`TestProviderMSPLicenseRefreshRefusesALapsedLicence` in +`internal/cloudcp/provider_msp_license_refresh_test.go`. + ### Provider-hosted MSP platforms buy and renew their own licence A provider-hosted control plane now buys and renews its licence through the diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 51d91a867..efcb09cec 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2483,6 +2483,20 @@ unchanged. Regression coverage: `TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof` in `cmd/pulse-control-plane/provider_msp_install_proof_test.go`. +### A lapsed provider MSP install still starts and upgrades + +A provider-hosted install whose licence is past expiry and grace (an +evaluation that ran out, or a paid plan that was not renewed) now starts its +control plane instead of crash-looping on licence validation, so the portal +and its Plan tab stay reachable to buy or renew. `provider-msp status`, which +`upgrade.sh` gates on, prints `license_lapsed=true` as information rather than +a failure, so such an install can still be upgraded. Client runtimes keep +enforcing the lapse themselves and new clients are refused until a current +licence is in place. Verified on 2026-09-24 on the walkthrough lab with an +evaluation that lapsed 20 days earlier. Regression coverage: +`TestProviderMSPStatusReportsALapsedLicenceWithoutFailing` in +`cmd/pulse-control-plane/provider_msp_status_test.go`. + ### Provider MSP setup points buyers at the portal `deploy/provider-msp/setup.sh` no longer tells an evaluating provider to diff --git a/internal/cloudcp/account/tenant_handlers.go b/internal/cloudcp/account/tenant_handlers.go index 3d121917b..c29a355cf 100644 --- a/internal/cloudcp/account/tenant_handlers.go +++ b/internal/cloudcp/account/tenant_handlers.go @@ -36,6 +36,10 @@ type ownerAwareWorkspaceProvisioner interface { type WorkspaceLimitPolicy struct { ProviderHostedMSP bool ProviderMSPPlanVersion string + // ProviderMSPLicenseLapsed reports whether the provider's licence is past + // its expiry and grace period. The portal keeps running then, so the + // provider can buy or renew, but no new client workspace may be created. + ProviderMSPLicenseLapsed func() bool } // HandleListTenants lists all tenants for an account. @@ -271,6 +275,13 @@ func enforceWorkspaceLimit(reg *registry.TenantRegistry, account *registry.Accou usingProviderHostedPlan := false if sa == nil { if policy.ProviderHostedMSP && account != nil && account.Kind == registry.AccountKindMSP { + if policy.ProviderMSPLicenseLapsed != nil && policy.ProviderMSPLicenseLapsed() { + return &workspaceLimitError{ + reason: "provider_msp_license_lapsed", + message: "Your Pulse MSP plan has ended, so no new clients can be added. Buy a plan from Plan in the portal to add clients again.", + statusCode: http.StatusForbidden, + } + } planVersion = pkglicensing.CanonicalizePlanVersion(policy.ProviderMSPPlanVersion) usingProviderHostedPlan = true if strings.TrimSpace(planVersion) == "" { diff --git a/internal/cloudcp/account/tenant_handlers_test.go b/internal/cloudcp/account/tenant_handlers_test.go index 420e1f8a5..b352717e8 100644 --- a/internal/cloudcp/account/tenant_handlers_test.go +++ b/internal/cloudcp/account/tenant_handlers_test.go @@ -698,3 +698,42 @@ func TestCreateWorkspace_BlockedWhenSubscriptionCanceled(t *testing.T) { t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String()) } } + +// A provider control plane keeps running on a lapsed licence so its portal can +// sell the renewal, but it must not add clients until the provider buys. +func TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence(t *testing.T) { + reg := newTestRegistry(t) + lapsed := true + mux, _ := newTestTenantMuxWithWorkspaceLimitPolicy( + t, + reg, + t.TempDir(), + WorkspaceLimitPolicy{ProviderHostedMSP: true, ProviderMSPPlanVersion: "msp_eval", ProviderMSPLicenseLapsed: func() bool { return lapsed }}, + cpstripe.WithDefaultMSPPlanVersion("msp_eval"), + ) + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatal(err) + } + if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Provider MSP"}); err != nil { + t.Fatal(err) + } + create := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/accounts/"+accountID+"/tenants", bytes.NewBufferString(`{"display_name":"Acme Dental"}`)) + return doRequest(t, mux, req) + } + + rec := create() + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Buy a plan from Plan") { + t.Fatalf("create on a lapsed licence = %d %q, want 403 pointing to Plan", rec.Code, rec.Body.String()) + } + if count, _ := reg.CountActiveByAccountID(accountID); count != 0 { + t.Fatalf("lapsed licence created %d workspaces", count) + } + + // Once the provider buys and the licence is current again, clients can be added. + lapsed = false + if rec := create(); rec.Code != http.StatusCreated { + t.Fatalf("create after renewal = %d %q, want 201", rec.Code, rec.Body.String()) + } +} diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 19be82762..0fce5e538 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -234,6 +234,13 @@ func LoadConfig() (*CPConfig, error) { providerMSPLicenseEmail = resolved.LicenseEmail providerMSPLicenseKey = resolved.LicenseKey providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey + if resolved.lapsed(time.Now()) { + log.Warn(). + Str("license_id", resolved.LicenseID). + Str("plan_version", resolved.PlanVersion). + Time("expired_at", resolved.ExpiresAt). + Msg("Provider MSP license has lapsed: the portal stays up to sell the renewal, no new clients can be added, and client workspaces drop MSP capabilities") + } } cfg := &CPConfig{ @@ -626,7 +633,14 @@ type providerMSPLicenseResolution struct { ExpiresAt time.Time } +// resolveProviderMSPPlanFromLicenseFile validates a licence the control plane +// is about to adopt, such as one the licence server just returned. It refuses +// a lapsed licence. func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) { + return resolveProviderMSPLicenseFile(path, false) +} + +func resolveProviderMSPLicenseFile(path string, allowLapsed bool) (*providerMSPLicenseResolution, error) { path = strings.TrimSpace(path) if path == "" { return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required") @@ -636,7 +650,11 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso return nil, err } pkglicensing.InitEmbeddedPublicKey() - license, err := pkglicensing.ValidateLicense(licenseKey) + validate := pkglicensing.ValidateLicense + if allowLapsed { + validate = pkglicensing.ValidateLicenseAllowingLapse + } + license, err := validate(licenseKey) if err != nil { return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err) } @@ -689,26 +707,62 @@ func ProviderMSPRenewedLicensePath(dataDir string) string { return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt") } -// resolveProviderMSPLicense prefers a renewed licence that still validates -// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is -// tried first because the host file is usually the self-issued evaluation, -// which expires; a paying provider must keep starting after it does. The -// lease signing key check in validate still ties either licence to this -// control plane's private key. +// lapsed reports whether the licence is past its expiry and grace period, the +// point at which client runtimes stop accepting leases chained to it. +func (r *providerMSPLicenseResolution) lapsed(now time.Time) bool { + return providerMSPLicenseLapsed(r.ExpiresAt, now) +} + +func providerMSPLicenseLapsed(expiresAt, now time.Time) bool { + return !expiresAt.IsZero() && now.After(expiresAt.Add(pkglicensing.DefaultGracePeriod)) +} + +// ProviderMSPLicenseLapsed reports whether this platform's licence is past its +// expiry and grace period. The control plane keeps running so its portal can +// sell the renewal, but it adds no clients, and client runtimes drop MSP +// capabilities on their own when they verify the lapsed licence. +func (c *CPConfig) ProviderMSPLicenseLapsed(now time.Time) bool { + if c == nil { + return false + } + return providerMSPLicenseLapsed(c.ProviderMSPLicenseExpiresAt, now) +} + +// resolveProviderMSPLicense picks the licence a provider-hosted control plane +// starts on: a current renewed licence first, then a current +// CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence wins because the host file +// is usually the self-issued evaluation, which expires; a paying provider must +// keep starting after it does. When neither is current it still starts, on +// whichever lapsed licence expires later, because refusing to start would take +// down the portal, which is the only place a provider can buy or renew. Both +// candidates must be authentic Pulse licences, and validate still ties the +// chosen one to this control plane's lease signing key. func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) { + now := time.Now() + var renewed *providerMSPLicenseResolution renewedPath := ProviderMSPRenewedLicensePath(dataDir) if _, err := os.Stat(renewedPath); err == nil { - renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath) - if err == nil { + candidate, err := resolveProviderMSPLicenseFile(renewedPath, true) + if err != nil { + log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE") + } else { + renewed = candidate + } + } + if renewed != nil && !renewed.lapsed(now) { + return renewed, ProviderMSPPlanSourceRenewedLicense, nil + } + host, err := resolveProviderMSPLicenseFile(hostFile, true) + if err != nil { + if renewed != nil { return renewed, ProviderMSPPlanSourceRenewedLicense, nil } - log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE") - } - resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile) - if err != nil { return nil, "", err } - return resolved, ProviderMSPPlanSourceLicenseFile, nil + if !host.lapsed(now) || renewed == nil || !renewed.ExpiresAt.After(host.ExpiresAt) { + return host, ProviderMSPPlanSourceLicenseFile, nil + } + return renewed, ProviderMSPPlanSourceRenewedLicense, nil } func readProviderMSPLicenseFile(path string) (string, error) { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index d8100019a..a68f8a5ef 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -991,6 +991,51 @@ func TestLoadConfig_SessionTTLProviderHostedDefault(t *testing.T) { } } +// Refusing to start on a lapsed licence took the provider's portal down at the +// moment the provider needed it to buy. The control plane now starts, reports +// the licence as lapsed, and when both licences have lapsed it starts on the +// one that expired later, so a lapsed paid plan reads as that plan. +func TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence(t *testing.T) { + setProviderHostedMSPEnv(t) + t.Setenv("CP_ENV", "production") + dataDir := t.TempDir() + t.Setenv("CP_DATA_DIR", dataDir) + issuer := newProviderMSPTestIssuer(t) + key := trialSigningEnvPublicKey(t) + hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"), + issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key)) + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with only a lapsed evaluation: %v", err) + } + if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile { + t.Fatalf("lapsed evaluation resolved plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource) + } + if !cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatal("ProviderMSPLicenseLapsed = false for an evaluation that ended 60 days ago") + } + + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), + issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-20*24*time.Hour), key)) + cfg, err = LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with both licences lapsed: %v", err) + } + if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPPlanVersion != "msp_solo" || !cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatalf("both lapsed resolved plan=%q source=%q lapsed=%v, want the later-expiring paid licence", + cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseLapsed(time.Now())) + } + + // Inside the 7-day grace the licence is expired but not yet lapsed. + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), + issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-2*24*time.Hour), key)) + if cfg, err = LoadConfig(); err != nil || cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatalf("licence 2 days past expiry: err=%v lapsed=%v, want running and not lapsed", err, cfg != nil && cfg.ProviderMSPLicenseLapsed(time.Now())) + } +} + func TestLoadConfig_SessionTTLOverride(t *testing.T) { setProviderHostedMSPEnv(t) t.Setenv("CP_SESSION_TTL", "36h") diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index e6265c00f..2b6d3935c 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621", + "source_hash": "5307f9f4c72602a934c10d38c5a20b212bddae474198b278648fc751df932267", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 805c55a7b..f6cede0de 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1034,14 +1034,14 @@ } function messageFromPayload(payload, fallback) { if (payload && typeof payload === "object") { - var errorMessage = payload.error; - if (typeof errorMessage === "string" && errorMessage.trim()) { - return errorMessage; - } var message = payload.message; if (typeof message === "string" && message.trim()) { return message; } + var errorMessage = payload.error; + if (typeof errorMessage === "string" && errorMessage.trim()) { + return errorMessage; + } } if (typeof payload === "string" && payload.trim()) { return payload; @@ -1517,7 +1517,7 @@ if (body.error !== "workspace_limit_reached") return ""; var entity = clientLanguage ? "client workspaces" : "workspaces"; var counts = typeof body.current === "number" && typeof body.limit === "number" && body.limit > 0 ? " (" + body.current + " of " + body.limit + " in use)" : ""; - return "Your license limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or upgrade your license to add more."; + return "Your plan limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or move to a bigger plan to add more."; } var createWorkspace = async function(accountID) { var nameEl = getElement("ws-name-" + accountID); @@ -2728,15 +2728,30 @@ var expiresAt = new Date(plan.expires_at).getTime(); return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS; } + function planEnded(plan, now) { + if (plan.lapsed) return true; + if (!plan.expires_at) return false; + var expiresAt = new Date(plan.expires_at).getTime(); + return !isNaN(expiresAt) && now >= expiresAt; + } function renderCurrentPlan(plan, canManage, busy, inUse, now) { var expires = formatDate(plan.expires_at); var title = providerPlanName(plan.plan_version); var description; var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)]; var cta = ""; - if (plan.evaluation) { + var ended = planEnded(plan, now); + var lostFeatures = plan.lapsed ? " Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added." : ""; + if (plan.evaluation && ended) { + description = "Your evaluation ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Buy a plan to keep your clients monitored and to add more."; + } else if (plan.evaluation) { description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more."; if (expires) meta.push("Expires " + expires); + } else if (ended) { + description = "Your " + title + " plan ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Renew from Manage billing or buy a plan below."; + if (canManage) { + cta = '"; + } } else { description = paidLicenceInGrace(plan, now) ? "Your subscription has not renewed. Your clients keep this plan until " + expires + ". Open Manage billing to renew or update your payment method." : "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active."; if (canManage) { @@ -2774,7 +2789,8 @@ var plan = view.plan; if (!plan) return parts.join(""); parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now)); - if (plan.evaluation) { + var ended = planEnded(plan, now); + if (plan.evaluation || ended) { if (plan.plans_error) { parts.push('
"); } else if (plan.purchase_available) { @@ -2783,7 +2799,7 @@ }); var cycle = hasAnnual ? view.cycle : "monthly"; var offers = plan.plans.filter(function(option) { - return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit; + return option.billing_cycle === cycle && (ended && !plan.evaluation ? option.workspace_limit >= plan.workspace_limit : option.workspace_limit > plan.workspace_limit); }); parts.push('You pay per client workspace, never per monitored system. Every client workspace is full Pulse.
You pay per client workspace, never per monitored system. Every client workspace is full Pulse.