From ad0b2ef94988d93d026a716136397fe77efa949b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 18:18:10 +0200 Subject: [PATCH] feat(store): set trial_ends_at on tenant create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateTenant now defaults trial_ends_at to NOW() + 14 days when the new tenant lands in status='trial'. Demo-kind tenants get status='demo' (per PLATFORM_ARCHITECTURE.md §5d) and trial_ends_at stays NULL — those flow through the M13.2 demo-provisioning path. Both store implementations (Memory + Postgres) updated; tests assert the 14-day window for customers and the absent end for demo kind. Unblocks M12.1 (portal trial banner can render a real countdown). Refs: M4.1 + M12.1 --- CHANGELOG.md | 1 + internal/server/tenants_test.go | 40 +++++++++++++++++++++++++++++++++ internal/store/memory.go | 28 +++++++++++++++-------- internal/store/postgres.go | 15 +++++++++++-- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d133a3..e1e2800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl ## [Unreleased] ### Added +- feat(store): CreateTenant defaults trial_ends_at to NOW()+14d for customer kind; demo kind gets status='demo' and no trial end - feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant. - feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test. - feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests diff --git a/internal/server/tenants_test.go b/internal/server/tenants_test.go index 0e5e246..80afe86 100644 --- a/internal/server/tenants_test.go +++ b/internal/server/tenants_test.go @@ -3,6 +3,7 @@ package server_test import ( "net/http" "testing" + "time" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) @@ -119,3 +120,42 @@ func TestCancelTenant(t *testing.T) { } }) } + +func TestCreateTenant_setsTrialEndsAt(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + _, body := h.do("POST", "/v1/tenants", map[string]any{ + "slug": "trial-ends-co", "name": "Trial Ends Co.", + }) + out := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + if out.Tenant.Status != "trial" { + t.Fatalf("status = %q, want trial", out.Tenant.Status) + } + if out.Tenant.TrialEndsAt == nil { + t.Fatal("trial_ends_at is nil; should be ~14 days from now") + } + // Sanity-check: ends_at is in the future, within 13.5-14.5 days. + delta := time.Until(*out.Tenant.TrialEndsAt) + if delta < 13*24*time.Hour || delta > 15*24*time.Hour { + t.Errorf("trial_ends_at offset = %v, want ~14d", delta) + } + }) +} + +func TestCreateTenant_demoKindHasNoTrialEnd(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + _, body := h.do("POST", "/v1/tenants", map[string]any{ + "slug": "demo-co", "name": "Demo", "kind": "demo", + }) + out := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + if out.Tenant.Status != "demo" { + t.Errorf("status = %q, want demo", out.Tenant.Status) + } + if out.Tenant.TrialEndsAt != nil { + t.Errorf("trial_ends_at = %v, want nil for demo kind", out.Tenant.TrialEndsAt) + } + }) +} diff --git a/internal/store/memory.go b/internal/store/memory.go index b98b9d1..3621162 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -69,16 +69,26 @@ func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, erro return nil, ErrConflict } now := time.Now().UTC() + kind := firstNonEmpty(in.Kind, "customer") + status := "trial" + var trialEnds *time.Time + if kind == "demo" { + status = "demo" + } else { + end := now.Add(14 * 24 * time.Hour) + trialEnds = &end + } t := &Tenant{ - ID: uuid.NewString(), - Slug: in.Slug, - Name: in.Name, - Status: "trial", - Kind: firstNonEmpty(in.Kind, "customer"), - Plan: firstNonEmpty(in.Plan, "starter"), - SalesOwner: in.SalesOwner, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + Slug: in.Slug, + Name: in.Name, + Status: status, + Kind: kind, + Plan: firstNonEmpty(in.Plan, "starter"), + SalesOwner: in.SalesOwner, + TrialEndsAt: trialEnds, + CreatedAt: now, + UpdatedAt: now, } m.tenants[t.ID] = t m.bySlug[t.Slug] = t.ID diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 35f5ad1..e235d9d 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -90,9 +90,20 @@ func scanTenant(row pgx.Row) (*Tenant, error) { func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) { kind := firstNonEmpty(in.Kind, "customer") plan := firstNonEmpty(in.Plan, "starter") + // Default status = 'trial'; set trial_ends_at = NOW() + 14 days so the + // portal's trial banner has a real countdown to render. Demo tenants + // (kind=demo) get status='demo' and no trial_ends_at — that's set by + // the M13.2 demo provisioning path. row := p.pool.QueryRow(ctx, - `INSERT INTO tenants (slug, name, kind, plan, sales_owner) - VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, '')) + `INSERT INTO tenants (slug, name, kind, plan, status, sales_owner, trial_ends_at) + VALUES ( + $1, $2, $3::tenant_kind, $4, + CASE WHEN $3::tenant_kind = 'demo' THEN 'demo'::tenant_status + ELSE 'trial'::tenant_status END, + NULLIF($5, ''), + CASE WHEN $3::tenant_kind = 'demo' THEN NULL + ELSE NOW() + INTERVAL '14 days' END + ) RETURNING id::text, slug, name, status::text, kind::text, plan, COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''), trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),