feat(store): set trial_ends_at on tenant create
ci / image (pull_request) Has been skipped
ci / shared (pull_request) Successful in 7s
ci / test (pull_request) Successful in 1m55s

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
This commit is contained in:
2026-05-19 18:18:10 +02:00
parent a37ae1d121
commit ad0b2ef949
4 changed files with 73 additions and 11 deletions
+1
View File
@@ -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
+40
View File
@@ -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)
}
})
}
+19 -9
View File
@@ -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
+13 -2
View File
@@ -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,''),