Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
package aiprompts
import "errors"
var (
ErrInvalidKey = errors.New("invalid prompt key")
ErrInvalidInput = errors.New("invalid prompt input")
)
// ClientError maps known client errors to safe API messages.
func ClientError(err error) (msg string, ok bool) {
switch {
case errors.Is(err, ErrInvalidKey):
return "invalid prompt key", true
case errors.Is(err, ErrInvalidInput):
return "invalid prompt templates", true
default:
// Wrapped ErrInvalidKey / ErrInvalidInput from fmt.Errorf("%w: …")
if errors.Is(err, ErrInvalidKey) {
return err.Error(), true
}
if errors.Is(err, ErrInvalidInput) {
return err.Error(), true
}
return "", false
}
}
+129
View File
@@ -0,0 +1,129 @@
package aiprompts
// Prompt keys stored in ai_prompt_templates.prompt_key.
const (
KeyProductEnhance = "product_enhance"
KeySEOMeta = "seo_meta"
KeyCampaignEmail = "campaign_email"
)
// MaxSystemRunes / MaxUserRunes bound stored templates (prompt-injection surface).
const (
MaxSystemRunes = 6000
MaxUserRunes = 4000
)
// Variable describes a placeholder tenants can insert into templates.
type Variable struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
Keys []string `json:"keys"` // prompt_key values that support this variable
}
// Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal).
var VariableCatalog = []Variable{
{Name: "name", Label: "Product name", Description: "Current product title", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "description", Label: "Description", Description: "Current product description", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "category", Label: "Category", Description: "Resolved category name", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "attrs", Label: "Attributes", Description: "Compact JSON of product attributes", Keys: []string{KeyProductEnhance}},
{Name: "gtin", Label: "GTIN", Description: "Product GTIN / barcode when present", Keys: []string{KeyProductEnhance}},
{Name: "brand", Label: "Brand name", Description: "Company or product brand label", Keys: []string{KeySEOMeta, KeyCampaignEmail}},
{Name: "brand_voice", Label: "Brand voice", Description: "Brand kit tone / dos / don'ts block", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
{Name: "language", Label: "Content language", Description: "Company content language (English display name)", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
{Name: "campaign_prompt", Label: "Campaign brief", Description: "Per-campaign user brief or template default", Keys: []string{KeyCampaignEmail}},
{Name: "products", Label: "Product list", Description: "Plain-text product snippets for the campaign", Keys: []string{KeyCampaignEmail}},
{Name: "template_key", Label: "Template key", Description: "Campaign template id (christmas, custom, …)", Keys: []string{KeyCampaignEmail}},
}
// DefaultTemplate is the built-in prompt when the company has no custom row or disabled it.
type DefaultTemplate struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
}
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
var BuiltInDefaults = []DefaultTemplate{
{
Key: KeyProductEnhance,
Label: "Product title & description",
Description: "Used when processing products (AI enhance step).",
SystemTemplate: `Retail product copywriter.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"}
- name: short retail title
- description: 1-2 factual sentences
- Write name and description in {{language}}
Example:
{"name":"Acme Widget Pro","description":"Durable widget for everyday use. Clear specs, ready to ship."}
{{brand_voice}}`,
UserTemplate: `Category: {{category}}
Name: {{name}}
Desc: {{description}}
Attrs: {{attrs}}`,
},
{
Key: KeySEOMeta,
Label: "SEO meta title & description",
Description: "Used when applying AI SEO meta to a product.",
SystemTemplate: `SEO meta writer for ecommerce.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"meta_title":"string","meta_description":"string"}
- meta_title: 50-60 chars, product + benefit
- meta_description: 120-155 chars, factual
- Write meta_title and meta_description in {{language}}
Example:
{"meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."}
{{brand_voice}}`,
UserTemplate: `Name: {{name}}
Category: {{category}}
Desc: {{description}}`,
},
{
Key: KeyCampaignEmail,
Label: "Campaign email",
Description: "Used when generating marketing emails with AI.",
SystemTemplate: `Marketing email writer.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"subject":"string","html_body":"string","plain_body":"string"}
- subject: short
- html_body: simple HTML (<p>, <ul>, <a> only)
- plain_body: plain text mirror
- Write subject, html_body, and plain_body in {{language}}
Example:
{"subject":"Holiday picks from Acme","html_body":"<p>Season's greetings.</p><ul><li>Widget Pro</li></ul><p><a href=\"#\">Shop now</a></p>","plain_body":"Season's greetings.\n- Widget Pro\nShop now"}
{{brand_voice}}`,
UserTemplate: `{{campaign_prompt}}
Products:
{{products}}
Brand: {{brand}}
Template: {{template_key}}`,
},
}
// ValidPromptKey reports whether key is a known prompt_key.
func ValidPromptKey(key string) bool {
switch key {
case KeyProductEnhance, KeySEOMeta, KeyCampaignEmail:
return true
default:
return false
}
}
// DefaultFor returns the built-in default for key, or empty if unknown.
func DefaultFor(key string) (DefaultTemplate, bool) {
for _, d := range BuiltInDefaults {
if d.Key == key {
return d, true
}
}
return DefaultTemplate{}, false
}
+61
View File
@@ -0,0 +1,61 @@
package aiprompts
import (
"regexp"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
// varToken matches {{name}} with optional whitespace. Only [a-z0-9_]+ names.
var varToken = regexp.MustCompile(`\{\{\s*([a-z][a-z0-9_]*)\s*\}\}`)
// Vars is the substitution map for Render (keys without braces).
type Vars map[string]string
// Render replaces {{var}} tokens. Unknown variables become empty string (safe, deterministic).
// Templates are sanitized before render; values should already be sanitized by callers.
func Render(template string, vars Vars) string {
template = strings.TrimSpace(template)
if template == "" {
return ""
}
return varToken.ReplaceAllStringFunc(template, func(match string) string {
sub := varToken.FindStringSubmatch(match)
if len(sub) < 2 {
return ""
}
name := sub[1]
if vars == nil {
return ""
}
return vars[name]
})
}
// SanitizeTemplate cleans and bounds a stored prompt template.
func SanitizeTemplate(s string, maxRunes int) string {
return security.SanitizePrompt(s, maxRunes)
}
// ExtractVariables returns unique variable names found in template (sorted order of first appearance).
func ExtractVariables(template string) []string {
matches := varToken.FindAllStringSubmatch(template, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) < 2 {
continue
}
name := m[1]
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
@@ -0,0 +1,49 @@
package aiprompts
import "testing"
func TestRender_replacesKnownVars(t *testing.T) {
t.Parallel()
got := Render("Hello {{name}} in {{category}}", Vars{
"name": "Widget",
"category": "Tools",
})
want := "Hello Widget in Tools"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestRender_unknownVarEmpty(t *testing.T) {
t.Parallel()
got := Render("X={{missing}}Y", Vars{"name": "a"})
if got != "X=Y" {
t.Fatalf("got %q", got)
}
}
func TestRender_whitespaceInBraces(t *testing.T) {
t.Parallel()
got := Render("{{ name }}", Vars{"name": "ok"})
if got != "ok" {
t.Fatalf("got %q", got)
}
}
func TestExtractVariables(t *testing.T) {
t.Parallel()
got := ExtractVariables("{{name}} and {{name}} then {{brand_voice}}")
if len(got) != 2 || got[0] != "name" || got[1] != "brand_voice" {
t.Fatalf("got %#v", got)
}
}
func TestValidPromptKey(t *testing.T) {
t.Parallel()
if !ValidPromptKey(KeyProductEnhance) {
t.Fatal("expected product_enhance valid")
}
if ValidPromptKey("nope") {
t.Fatal("expected nope invalid")
}
}
+238
View File
@@ -0,0 +1,238 @@
package aiprompts
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service loads and stores per-company AI prompt templates.
type Service struct {
Pool *pgxpool.Pool
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{Pool: pool}
}
type stored struct {
key string
language string
systemTemplate string
userTemplate string
isEnabled bool
updatedAt time.Time
}
// GetBundle returns all prompt keys with effective templates for language + variable catalog.
func (s *Service) GetBundle(ctx context.Context, companyID uuid.UUID, language string) (Bundle, error) {
lang, err := company.ParseLanguage(language, true)
if err != nil {
lang = company.LoadLanguage(ctx, s.Pool, companyID)
}
contentLangs := company.LoadContentLanguages(ctx, s.Pool, companyID)
storedRows, err := s.loadAll(ctx, companyID)
if err != nil {
return Bundle{}, err
}
byKeyLang := map[string]stored{}
customLangs := map[string][]string{}
for _, st := range storedRows {
byKeyLang[st.key+"\x00"+st.language] = st
customLangs[st.key] = appendUnique(customLangs[st.key], st.language)
}
out := make([]Template, 0, len(BuiltInDefaults))
for _, def := range BuiltInDefaults {
t := Template{
Key: def.Key,
Language: lang,
Label: def.Label,
Description: def.Description,
IsDefault: true,
IsCustom: false,
IsEnabled: true,
}
if st, ok := byKeyLang[def.Key+"\x00"+lang]; ok {
t.IsCustom = true
t.IsDefault = false
t.IsEnabled = st.isEnabled
t.UpdatedAt = st.updatedAt
if st.isEnabled {
t.SystemTemplate = st.systemTemplate
t.UserTemplate = st.userTemplate
} else {
t.SystemTemplate = def.SystemTemplate
t.UserTemplate = def.UserTemplate
t.IsDefault = true
}
} else {
t.SystemTemplate = def.SystemTemplate
t.UserTemplate = def.UserTemplate
}
out = append(out, t)
}
return Bundle{
Language: lang,
Prompts: out,
Variables: VariableCatalog,
CustomLanguages: customLangs,
ContentLanguages: contentLangs,
}, nil
}
// Resolve returns the effective templates for one key + language
// (custom if enabled for lang, else built-in). No cross-language company fallback.
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID, key, language string) (Resolved, error) {
if !ValidPromptKey(key) {
return Resolved{}, ErrInvalidKey
}
def, ok := DefaultFor(key)
if !ok {
return Resolved{}, ErrInvalidKey
}
lang, err := company.ParseLanguage(language, true)
if err != nil {
lang = company.DefaultLanguage
}
st, err := s.loadOne(ctx, companyID, key, lang)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Resolved{}, err
}
if err == nil && st.isEnabled {
sys := strings.TrimSpace(st.systemTemplate)
user := strings.TrimSpace(st.userTemplate)
if sys == "" {
sys = def.SystemTemplate
}
if user == "" {
user = def.UserTemplate
}
return Resolved{
Key: key,
Language: lang,
SystemTemplate: sys,
UserTemplate: user,
IsCustom: true,
}, nil
}
return Resolved{
Key: key,
Language: lang,
SystemTemplate: def.SystemTemplate,
UserTemplate: def.UserTemplate,
IsCustom: false,
}, nil
}
// Update applies prompt updates (upsert or reset). Empty prompts list is a no-op.
func (s *Service) Update(ctx context.Context, companyID uuid.UUID, in UpdateInput) (Bundle, error) {
defaultLang := strings.TrimSpace(in.Language)
if defaultLang == "" {
defaultLang = company.LoadLanguage(ctx, s.Pool, companyID)
}
if len(in.Prompts) == 0 {
return s.GetBundle(ctx, companyID, defaultLang)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return Bundle{}, err
}
defer tx.Rollback(ctx)
lastLang := defaultLang
for _, item := range in.Prompts {
key := strings.TrimSpace(strings.ToLower(item.Key))
if !ValidPromptKey(key) {
return Bundle{}, fmt.Errorf("%w: %s", ErrInvalidKey, item.Key)
}
langRaw := strings.TrimSpace(item.Language)
if langRaw == "" {
langRaw = defaultLang
}
lang, err := company.ParseLanguage(langRaw, false)
if err != nil {
return Bundle{}, fmt.Errorf("%w: language %q", ErrInvalidInput, langRaw)
}
lastLang = lang
if item.Reset {
_, err := tx.Exec(ctx, `
DELETE FROM ai_prompt_templates
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
companyID, key, lang)
if err != nil {
return Bundle{}, err
}
continue
}
sys := SanitizeTemplate(item.SystemTemplate, MaxSystemRunes)
user := SanitizeTemplate(item.UserTemplate, MaxUserRunes)
if sys == "" && user == "" {
return Bundle{}, fmt.Errorf("%w: empty templates for %s", ErrInvalidInput, key)
}
enabled := true
if item.IsEnabled != nil {
enabled = *item.IsEnabled
}
_, err = tx.Exec(ctx, `
INSERT INTO ai_prompt_templates (
company_id, prompt_key, language, system_template, user_template, is_enabled, updated_at
) VALUES ($1,$2,$3,$4,$5,$6, now())
ON CONFLICT (company_id, prompt_key, language) DO UPDATE SET
system_template = EXCLUDED.system_template,
user_template = EXCLUDED.user_template,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()`,
companyID, key, lang, sys, user, enabled)
if err != nil {
return Bundle{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return Bundle{}, err
}
return s.GetBundle(ctx, companyID, lastLang)
}
func (s *Service) loadAll(ctx context.Context, companyID uuid.UUID) ([]stored, error) {
rows, err := s.Pool.Query(ctx, `
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
FROM ai_prompt_templates WHERE company_id = $1`, companyID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []stored
for rows.Next() {
var st stored
if err := rows.Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
func (s *Service) loadOne(ctx context.Context, companyID uuid.UUID, key, language string) (stored, error) {
var st stored
err := s.Pool.QueryRow(ctx, `
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
FROM ai_prompt_templates
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
companyID, key, language).Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt)
return st, err
}
func appendUnique(list []string, v string) []string {
for _, x := range list {
if x == v {
return list
}
}
return append(list, v)
}
+51
View File
@@ -0,0 +1,51 @@
package aiprompts
import "time"
// Template is one company's prompt for a feature key (+ language) (API / storage shape).
type Template struct {
Key string `json:"key"`
Language string `json:"language,omitempty"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
IsEnabled bool `json:"is_enabled"`
IsCustom bool `json:"is_custom"`
IsDefault bool `json:"is_default"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// UpdateItem is one prompt in a PUT body.
type UpdateItem struct {
Key string `json:"key"`
Language string `json:"language,omitempty"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
IsEnabled *bool `json:"is_enabled,omitempty"`
Reset bool `json:"reset,omitempty"` // delete custom row → fall back to built-in
}
// UpdateInput is the PUT /integrations/ai/prompts body.
type UpdateInput struct {
Language string `json:"language,omitempty"` // default language for items missing Language
Prompts []UpdateItem `json:"prompts"`
}
// Resolved is the effective system+user templates after defaults / custom merge.
type Resolved struct {
Key string
Language string
SystemTemplate string
UserTemplate string
IsCustom bool
}
// Bundle is the GET response for the prompts UI.
type Bundle struct {
Language string `json:"language"`
Prompts []Template `json:"prompts"`
Variables []Variable `json:"variables"`
CustomLanguages map[string][]string `json:"custom_languages"` // key → langs with overrides
ContentLanguages []string `json:"content_languages,omitempty"`
}
+148
View File
@@ -0,0 +1,148 @@
package aiprovider
import "strings"
// Mode values stored on ai_providers.mode (UI / API).
const (
ModeInternal = "internal"
ModePopular = "popular"
ModeCustom = "custom"
)
// ModeInternalLabel is the analytics / job recording value when using platform OpenAI (admin settings or env fallback).
const ModeInternalLabel = "internal"
// ModeCustomLabel is the analytics value for custom OpenAI-compatible endpoints.
const ModeCustomLabel = "custom"
// PopularProvider is a curated OpenAI-compatible catalog entry.
type PopularProvider struct {
Name string `json:"name"`
Label string `json:"label"`
BaseURL string `json:"base_url"`
DefaultModel string `json:"default_model"`
Models []string `json:"models"`
}
// PopularCatalog lists OpenAI-compatible providers tenants can pick by API key only.
var PopularCatalog = []PopularProvider{
{
Name: "openai",
Label: "OpenAI",
BaseURL: "https://api.openai.com/v1",
DefaultModel: "gpt-4o-mini",
Models: []string{"gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"},
},
{
Name: "google",
Label: "Google (Gemini OpenAI compat)",
BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
DefaultModel: "gemini-2.0-flash",
Models: []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.0-flash-lite"},
},
{
Name: "groq",
Label: "Groq",
BaseURL: "https://api.groq.com/openai/v1",
DefaultModel: "llama-3.3-70b-versatile",
Models: []string{"llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"},
},
{
Name: "mistral",
Label: "Mistral",
BaseURL: "https://api.mistral.ai/v1",
DefaultModel: "mistral-small-latest",
Models: []string{"mistral-small-latest", "mistral-medium-latest", "mistral-large-latest"},
},
{
Name: "deepseek",
Label: "DeepSeek",
BaseURL: "https://api.deepseek.com/v1",
DefaultModel: "deepseek-chat",
Models: []string{"deepseek-chat", "deepseek-reasoner"},
},
{
Name: "openrouter",
Label: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4o-mini",
Models: []string{"openai/gpt-4o-mini", "anthropic/claude-sonnet-4", "google/gemini-2.0-flash-001"},
},
}
func FindPopular(name string) (PopularProvider, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, p := range PopularCatalog {
if p.Name == name {
return p, true
}
}
return PopularProvider{}, false
}
// AnalyticsMode returns the recorded provider mode for jobs/products.
// Contract for analytics: "internal" | "popular:<name>" | "custom"
func AnalyticsMode(mode, popularName string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case ModePopular:
name := strings.ToLower(strings.TrimSpace(popularName))
if name == "" {
name = "unknown"
}
return "popular:" + name
case ModeCustom:
return ModeCustomLabel
default:
return ModeInternalLabel
}
}
// AnalyticsClass maps a stored ai_provider_mode value to a rollup class.
// Returns: internal | popular | custom | unknown
func AnalyticsClass(modeLabel string) string {
m := strings.ToLower(strings.TrimSpace(modeLabel))
switch {
case m == "" || m == "unknown":
return "unknown"
case m == ModeInternalLabel || m == ModeInternal:
return ModeInternal
case m == ModeCustomLabel || m == ModeCustom:
return ModeCustom
case strings.HasPrefix(m, "popular:"):
return ModePopular
default:
return "unknown"
}
}
// NormalizeAnalyticsMode coerces free-form labels into the analytics contract.
func NormalizeAnalyticsMode(modeLabel string) string {
m := strings.ToLower(strings.TrimSpace(modeLabel))
switch {
case m == "" || m == "unknown":
return "unknown"
case m == ModeInternalLabel || m == ModeInternal:
return ModeInternalLabel
case m == ModeCustomLabel || m == ModeCustom:
return ModeCustomLabel
case strings.HasPrefix(m, "popular:"):
name := strings.TrimSpace(strings.TrimPrefix(m, "popular:"))
if name == "" {
name = "unknown"
}
return "popular:" + name
default:
return "unknown"
}
}
func normalizeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case ModePopular:
return ModePopular
case ModeCustom:
return ModeCustom
default:
return ModeInternal
}
}
@@ -0,0 +1,34 @@
package aiprovider
import "testing"
func TestAnalyticsClass(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"", "unknown"},
{"unknown", "unknown"},
{"internal", "internal"},
{"custom", "custom"},
{"popular:openai", "popular"},
{"popular:groq", "popular"},
{"POPULAR:openai", "popular"},
{"weird", "unknown"},
}
for _, c := range cases {
if got := AnalyticsClass(c.in); got != c.want {
t.Fatalf("AnalyticsClass(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestNormalizeAnalyticsMode(t *testing.T) {
t.Parallel()
if got := NormalizeAnalyticsMode("popular:"); got != "popular:unknown" {
t.Fatalf("got %q", got)
}
if got := NormalizeAnalyticsMode("CUSTOM"); got != "custom" {
t.Fatalf("got %q", got)
}
}
+120
View File
@@ -0,0 +1,120 @@
package aiprovider
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
const encPrefix = "enc:v1:"
// DeriveKey builds a 32-byte AES key. Prefer APP_ENCRYPTION_KEY /
// CREDENTIALS_ENCRYPTION_KEY; falls back to DATABASE_URL material (local/dev).
// In production, explicitKey is required; empty returns nil (fail closed).
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
if b, err := decodeKeyMaterial(explicitKey); err == nil {
return b
}
sum := sha256.Sum256([]byte(explicitKey))
return sum[:]
}
if config.IsProductionEnv() {
return nil
}
sum := sha256.Sum256([]byte("descrybe-ai-v1|" + fallbackMaterial))
return sum[:]
}
func decodeKeyMaterial(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
return nil, errors.New("invalid key material")
}
func EncryptSecret(key []byte, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
}
func DecryptSecret(key []byte, stored string) (string, error) {
if stored == "" {
return "", nil
}
if !strings.HasPrefix(stored, encPrefix) {
if config.IsProductionEnv() {
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
}
return stored, nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func last4(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return ""
}
runes := []rune(secret)
if len(runes) <= 4 {
return string(runes)
}
return string(runes[len(runes)-4:])
}
+45
View File
@@ -0,0 +1,45 @@
package aiprovider
import (
"errors"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
// clientError is a validation message safe to return to API clients.
type clientError struct {
msg string
}
func (e *clientError) Error() string { return e.msg }
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
func ClientMsg(msg string) error {
return &clientError{msg: msg}
}
// ClientError reports whether err is a known client-facing AI provider error.
func ClientError(err error) (msg string, ok bool) {
if err == nil {
return "", false
}
var ce *clientError
if errors.As(err, &ce) {
return ce.msg, true
}
switch {
case errors.Is(err, ErrNotConfigured),
errors.Is(err, ErrInvalidMode),
errors.Is(err, ErrInvalidPopular),
errors.Is(err, ErrMissingAPIKey),
errors.Is(err, ErrMissingModel),
errors.Is(err, ErrMissingURL):
return err.Error(), true
case errors.Is(err, security.ErrInvalidURL),
errors.Is(err, security.ErrBlockedURL),
errors.Is(err, security.ErrBlockedHost):
return "invalid base_url", true
default:
return "", false
}
}
@@ -0,0 +1,110 @@
package aiprovider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
)
func TestTestPlatformRole_unknown(t *testing.T) {
t.Parallel()
svc := NewService(nil, EnvConfig{})
res, err := svc.TestPlatformRole(context.Background(), "nope")
if err == nil {
t.Fatal("expected error")
}
if res["status"] != "failed" {
t.Fatalf("status=%v", res["status"])
}
}
func TestTestPlatformRole_skippedWhenUnset(t *testing.T) {
t.Parallel()
svc := NewService(nil, EnvConfig{})
svc.Platform = platformsettings.NewService(nil, platformsettings.EnvConfig{})
res, err := svc.TestPlatformRole(context.Background(), RoleSupport)
if err != nil {
t.Fatal(err)
}
if res["status"] != "skipped" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
}
func TestTestPlatformRole_chatProbeOK(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
http.NotFound(w, r)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer sk-test-") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"content": "ok"}},
},
"usage": map[string]any{"total_tokens": 1},
})
}))
t.Cleanup(srv.Close)
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-test-platform",
OpenAIBaseURL: srv.URL + "/v1",
OpenAIModel: "test-model",
})
svc := NewService(nil, EnvConfig{})
svc.Platform = plat
res, err := svc.TestPlatformRole(context.Background(), RoleProcessing)
if err != nil {
t.Fatalf("err=%v res=%v", err, res)
}
if res["status"] != "ok" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
if msg, _ := res["message"].(string); strings.Contains(strings.ToLower(msg), "sk-") {
t.Fatalf("message must not leak key fragments: %q", msg)
}
}
func TestTestPlatformRole_embedProbeOK(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/embeddings" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{
{"embedding": []float32{0.1, 0.2}, "index": 0},
},
})
}))
t.Cleanup(srv.Close)
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIEmbeddingAPIKey: "sk-test-embed",
OpenAIEmbeddingBaseURL: srv.URL + "/v1",
OpenAIEmbeddingModel: "text-embedding-3-small",
})
svc := NewService(nil, EnvConfig{})
svc.Platform = plat
res, err := svc.TestPlatformRole(context.Background(), RoleVectorization)
if err != nil {
t.Fatalf("err=%v res=%v", err, res)
}
if res["status"] != "ok" {
t.Fatalf("status=%v message=%v", res["status"], res["message"])
}
}
@@ -0,0 +1,63 @@
package aiprovider
import (
"context"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
)
func TestResolvePlatformOpenAI_envOnly(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-env-fallback",
OpenAIBaseURL: "https://api.openai.com/v1",
OpenAIModel: "gpt-4o-mini",
},
}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "sk-env-fallback" || oi.Source != platformsettings.SourceEnv {
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
}
ok, err := svc.platformConfigured(context.Background())
if err != nil || !ok {
t.Fatalf("configured=%v err=%v", ok, err)
}
}
func TestResolvePlatformOpenAI_unset(t *testing.T) {
svc := &Service{Env: EnvConfig{}}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "" || oi.Source != platformsettings.SourceNone {
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
}
ok, err := svc.platformConfigured(context.Background())
if err != nil || ok {
t.Fatalf("configured=%v err=%v", ok, err)
}
}
func TestResolvePlatformOpenAI_viaPlatformService(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-from-plat-env",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIModel: "local-model",
})
svc := &Service{Platform: plat, Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win"}}
oi, err := svc.resolvePlatformOpenAI(context.Background())
if err != nil {
t.Fatal(err)
}
if oi.APIKey != "sk-from-plat-env" {
t.Fatalf("key=%q", oi.APIKey)
}
if oi.Source != platformsettings.SourceEnv {
t.Fatalf("source=%q", oi.Source)
}
}
+180
View File
@@ -0,0 +1,180 @@
package aiprovider
import (
"context"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
// Role identifiers — keep in sync with platformsettings.AIRole* / processing.AIRole*.
//
// RoleSupport is a FUTURE config slot (platformsettings.ai_roles["support"]).
// Resolving a Completer when the slot is configured is allowed for future
// draft-assist UIs, but support.TryAutoReplyLLM must remain the only gate for
// ticket auto-replies — and that stub currently refuses. Guided /docs Ask is
// rule-based and must never use RoleSupport or RoleDocsAPI.
const (
RoleProcessing = processing.AIRoleProcessing
RoleVectorization = processing.AIRoleVectorization
RoleDocsAPI = processing.AIRoleDocsAPI
RoleSupport = processing.AIRoleSupport
)
// RoleEndpoint is a resolved OpenAI-compatible chat endpoint for one role.
// Secrets are plaintext only in-process — never log or return to clients.
type RoleEndpoint struct {
APIKey string
BaseURL string
Model string
UsingBYOK bool
ModeLabel string
}
// RoleEndpointSource looks up admin-configured role bindings (platform / company).
// ok=false means the role is unset — callers must fall back.
type RoleEndpointSource interface {
LookupRole(ctx context.Context, companyID uuid.UUID, role string) (ep RoleEndpoint, ok bool, err error)
}
// ResolveCompleterForRole prefers an injected RoleEndpointSource binding when set;
// otherwise uses company BYOK then platformsettings.ResolveAIConfig for the role
// (processing falls back to legacy openai JSON + OPENAI_* env when unset).
func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) {
role = strings.TrimSpace(role)
if role == "" {
role = RoleProcessing
}
if s != nil && s.Roles != nil {
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
if err != nil {
return nil, ModeInternalLabel, false, err
}
if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" {
return s.completerFromEndpoint(ep)
}
}
switch role {
case RoleProcessing:
// Full Resolve needs Pool for company BYOK; without Pool use platform/env only.
if s != nil && s.Pool != nil {
return s.ResolveCompleter(ctx, companyID)
}
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
case RoleDocsAPI, RoleSupport:
return s.resolvePlatformRoleCompleter(ctx, role)
default:
// Vectorization uses embeddings clients — not chat Completer.
return nil, ModeInternalLabel, false, nil
}
}
// ResolveEmbedderForRole returns an OpenAI-compatible Embedder for the
// vectorization role (platformsettings.AIRoleVectorization) with env fallback.
// Non-vectorization roles return (nil, nil). Unset config returns (nil, nil).
func (s *Service) ResolveEmbedderForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Embedder, error) {
role = strings.TrimSpace(role)
if role == "" {
role = RoleVectorization
}
if role != RoleVectorization {
return nil, nil
}
if s != nil && s.Roles != nil {
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
if err != nil {
return nil, err
}
if ok && strings.TrimSpace(ep.APIKey) != "" {
model := strings.TrimSpace(ep.Model)
if model == "" {
model = "text-embedding-3-small"
}
rpm, retries := 0, 3
if s != nil {
rpm = s.Env.ProcessingRPM
retries = s.Env.ProcessingMaxRetries
}
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, model, rpm, retries)
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return client, nil
}
}
if s != nil && s.Platform != nil {
return s.Platform.ResolveEmbedder(ctx)
}
return nil, nil
}
func (s *Service) resolvePlatformRoleCompleter(ctx context.Context, role string) (processing.Completer, string, bool, error) {
if s == nil {
return nil, ModeInternalLabel, false, nil
}
if s.Platform != nil {
cfg, err := s.Platform.ResolveAIConfig(ctx, role)
if err != nil {
return nil, ModeInternalLabel, false, err
}
if strings.TrimSpace(cfg.APIKey) != "" {
if role != RoleProcessing && !cfg.Enabled {
return nil, ModeInternalLabel, false, nil
}
model := strings.TrimSpace(cfg.Model)
if model == "" && role == RoleProcessing {
model = strings.TrimSpace(s.Env.OpenAIModel)
}
if model != "" {
return s.completerFromEndpoint(RoleEndpoint{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: model,
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
})
}
}
return nil, ModeInternalLabel, false, nil
}
if role == RoleProcessing {
key := strings.TrimSpace(s.Env.OpenAIAPIKey)
model := strings.TrimSpace(s.Env.OpenAIModel)
if key != "" && model != "" {
return s.completerFromEndpoint(RoleEndpoint{
APIKey: key,
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
Model: model,
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
})
}
}
return nil, ModeInternalLabel, false, nil
}
func (s *Service) completerFromEndpoint(ep RoleEndpoint) (processing.Completer, string, bool, error) {
rpm := 0
retries := 0
if s != nil {
rpm = s.Env.ProcessingRPM
retries = s.Env.ProcessingMaxRetries
}
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, ep.Model, rpm, retries)
label := strings.TrimSpace(ep.ModeLabel)
if label == "" {
if ep.UsingBYOK {
label = ModeCustom
} else {
label = ModeInternalLabel
}
}
client.ModeLabel = label
if s != nil && s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return client, label, ep.UsingBYOK, nil
}
+149
View File
@@ -0,0 +1,149 @@
package aiprovider
import (
"context"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
type stubRoleSource struct {
ep RoleEndpoint
ok bool
err error
}
func (s stubRoleSource) LookupRole(_ context.Context, _ uuid.UUID, _ string) (RoleEndpoint, bool, error) {
return s.ep, s.ok, s.err
}
func TestResolveCompleterForRole_unsetFallsBackToEnv(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-env-fallback",
OpenAIBaseURL: "https://api.openai.com/v1",
OpenAIModel: "gpt-4o-mini",
},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil || !oc.Enabled() {
t.Fatalf("expected enabled OpenAIClient, got %T", c)
}
if label != ModeInternalLabel {
t.Fatalf("label=%q", label)
}
if byok {
t.Fatal("env fallback must not be BYOK")
}
}
func TestResolveCompleterForRole_usesRoleBindingWhenSet(t *testing.T) {
svc := &Service{
Env: EnvConfig{
OpenAIAPIKey: "sk-should-not-win",
OpenAIModel: "env-model",
},
Roles: stubRoleSource{
ok: true,
ep: RoleEndpoint{
APIKey: "sk-role-processing",
BaseURL: "https://role.example/v1",
Model: "role-model",
UsingBYOK: false,
ModeLabel: ModeInternalLabel,
},
},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.New(), RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil {
t.Fatalf("type=%T", c)
}
if oc.APIKey != "sk-role-processing" || oc.Model != "role-model" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
if label != ModeInternalLabel || byok {
t.Fatalf("label=%q byok=%v", label, byok)
}
}
func TestResolveCompleterForRole_platformProcessingRole(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-plat-processing",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIModel: "plat-model",
})
svc := &Service{
Platform: plat,
Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win", OpenAIModel: "env-model"},
}
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
if err != nil {
t.Fatal(err)
}
oc, ok := c.(*processing.OpenAIClient)
if !ok || oc == nil {
t.Fatalf("type=%T", c)
}
if oc.APIKey != "sk-plat-processing" || oc.Model != "plat-model" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
if label != ModeInternalLabel || byok {
t.Fatalf("label=%q byok=%v", label, byok)
}
}
func TestResolveCompleterForRole_vectorizationUnsetNoChatFallback(t *testing.T) {
svc := &Service{
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
}
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleVectorization)
if err != nil {
t.Fatal(err)
}
if c != nil {
t.Fatal("vectorization must not fall back to chat completer")
}
}
func TestResolveEmbedderForRole_usesPlatformVectorization(t *testing.T) {
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
OpenAIAPIKey: "sk-embed-env",
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
OpenAIEmbeddingModel: "text-embedding-3-small",
})
svc := &Service{Platform: plat}
emb, err := svc.ResolveEmbedderForRole(context.Background(), uuid.Nil, RoleVectorization)
if err != nil {
t.Fatal(err)
}
oc, ok := emb.(*processing.OpenAIClient)
if !ok || oc == nil || !oc.Enabled() {
t.Fatalf("expected OpenAIClient embedder, got %T", emb)
}
if oc.APIKey != "sk-embed-env" || oc.Model != "text-embedding-3-small" {
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
}
}
func TestResolveCompleterForRole_supportUnsetNoEnvFallback(t *testing.T) {
svc := &Service{
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
}
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleSupport)
if err != nil {
t.Fatal(err)
}
if c != nil {
t.Fatal("unset support must not fall back to processing/env completer")
}
}
+443
View File
@@ -0,0 +1,443 @@
package aiprovider
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotConfigured = errors.New("ai provider not configured")
ErrInvalidMode = errors.New("mode must be internal, popular, or custom")
ErrInvalidPopular = errors.New("unknown popular provider")
ErrMissingAPIKey = errors.New("api key required")
ErrMissingModel = errors.New("model required")
ErrMissingURL = errors.New("base url required for custom provider")
)
// aiProbeTimeout bounds admin/company connection tests so a hung provider cannot
// hold the HTTP request for multi-retry OpenAI client durations.
const aiProbeTimeout = 45 * time.Second
type Service struct {
Pool *pgxpool.Pool
Key []byte
Env EnvConfig
// Platform is optional; when set, platform OpenAI is loaded from admin
// settings (DB) with EnvConfig as bootstrap fallback.
Platform *platformsettings.Service
// Roles is optional admin role-binding lookup (processing / embeddings / …).
// When nil or a role is unset, ResolveCompleterForRole falls back to Resolve.
Roles RoleEndpointSource
HTTPClient *http.Client
}
func NewService(pool *pgxpool.Pool, env EnvConfig) *Service {
keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret)
return &Service{
Pool: pool,
Key: DeriveKey(keyMaterial, env.DatabaseURL),
Env: env,
// HTTPClient is optional (tests). Production uses NewOpenAIClient's
// SafeHTTPClient so dial-time SSRF applies; leave nil here so platform
// OPENAI_BASE_URL loopback (local models) is not overwritten.
}
}
type stored struct {
mode, popularName, baseURL, model, keyEnc, last4 string
enabled bool
lastTest *time.Time
lastStatus *string
}
func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (stored, error) {
var st stored
err := s.Pool.QueryRow(ctx, `
SELECT mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled,
last_test_at, last_test_status
FROM ai_providers WHERE company_id = $1`, companyID).Scan(
&st.mode, &st.popularName, &st.baseURL, &st.model, &st.keyEnc, &st.last4, &st.enabled,
&st.lastTest, &st.lastStatus,
)
return st, err
}
func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (PublicConfig, error) {
platformOK, err := s.platformConfigured(ctx)
if err != nil {
return PublicConfig{}, err
}
st, err := s.loadStored(ctx, companyID)
if errors.Is(err, pgx.ErrNoRows) {
return PublicConfig{
Mode: ModeInternal,
Configured: false,
IsEnabled: false,
ActiveModeLabel: ModeInternalLabel,
PlatformFallback: platformOK,
PopularProviders: PopularCatalog,
}, nil
}
if err != nil {
return PublicConfig{}, err
}
hasKey := st.keyEnc != ""
masked := ""
if hasKey && st.last4 != "" {
masked = "••••" + st.last4
}
active := ModeInternalLabel
if st.enabled && hasKey && (st.mode == ModePopular || st.mode == ModeCustom) {
active = AnalyticsMode(st.mode, st.popularName)
}
return PublicConfig{
Mode: normalizeMode(st.mode),
PopularName: st.popularName,
BaseURL: st.baseURL,
Model: st.model,
IsEnabled: st.enabled,
Configured: true,
HasAPIKey: hasKey,
APIKeyLast4: st.last4,
APIKeyMasked: masked,
LastTestAt: st.lastTest,
LastTestStatus: st.lastStatus,
ActiveModeLabel: active,
PlatformFallback: platformOK,
PopularProviders: PopularCatalog,
}, nil
}
func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (PublicConfig, error) {
mode := normalizeMode(in.Mode)
if mode != ModeInternal && mode != ModePopular && mode != ModeCustom {
return PublicConfig{}, ErrInvalidMode
}
var existing stored
existing, err := s.loadStored(ctx, companyID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return PublicConfig{}, err
}
hasExisting := err == nil
keyEnc := ""
last4v := ""
if hasExisting {
keyEnc = existing.keyEnc
last4v = existing.last4
}
if in.ClearAPIKey {
keyEnc = ""
last4v = ""
} else if strings.TrimSpace(in.APIKey) != "" {
plain := strings.TrimSpace(in.APIKey)
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return PublicConfig{}, err
}
keyEnc = enc
last4v = last4(plain)
}
popularName := ""
baseURL := ""
model := strings.TrimSpace(in.Model)
switch mode {
case ModeInternal:
// Platform fallback; company key optional/cleared when switching away from BYOK.
if !in.IsEnabled {
keyEnc = ""
last4v = ""
}
case ModePopular:
pop, ok := FindPopular(in.PopularName)
if !ok {
return PublicConfig{}, ErrInvalidPopular
}
popularName = pop.Name
baseURL = pop.BaseURL
if model == "" {
model = pop.DefaultModel
}
if !modelAllowed(pop, model) {
return PublicConfig{}, ClientMsg(fmt.Sprintf("model %q is not in the %s catalog (or leave blank for default)", model, pop.Name))
}
if in.IsEnabled && keyEnc == "" {
return PublicConfig{}, ErrMissingAPIKey
}
case ModeCustom:
normalized, err := validateProviderBaseURL(in.BaseURL)
if err != nil {
return PublicConfig{}, err
}
baseURL = normalized
if model == "" {
return PublicConfig{}, ErrMissingModel
}
if in.IsEnabled && keyEnc == "" {
return PublicConfig{}, ErrMissingAPIKey
}
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO ai_providers (
company_id, mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
ON CONFLICT (company_id) DO UPDATE SET
mode = EXCLUDED.mode,
popular_name = EXCLUDED.popular_name,
base_url = EXCLUDED.base_url,
model = EXCLUDED.model,
api_key_enc = EXCLUDED.api_key_enc,
api_key_last4 = EXCLUDED.api_key_last4,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()`,
companyID, mode, popularName, baseURL, model, keyEnc, last4v, in.IsEnabled && mode != ModeInternal)
if err != nil {
return PublicConfig{}, err
}
return s.GetConfig(ctx, companyID)
}
func modelAllowed(pop PopularProvider, model string) bool {
model = strings.TrimSpace(model)
if model == "" || model == pop.DefaultModel {
return true
}
for _, m := range pop.Models {
if m == model {
return true
}
}
// Allow unknown model strings for popular providers (API may add models faster than catalog).
return true
}
func validateProviderBaseURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ErrMissingURL
}
normalized, err := security.ValidatePublicHTTPSURL(raw)
if err != nil {
return "", err
}
if normalized == "" {
return "", ErrMissingURL
}
return strings.TrimRight(normalized, "/"), nil
}
// Resolve picks company BYOK completer when enabled+keyed, else platform AI
// from admin settings (DB), with optional env fallback via Platform / Env.
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID) (Resolved, error) {
rpm := s.Env.ProcessingRPM
retries := s.Env.ProcessingMaxRetries
st, err := s.loadStored(ctx, companyID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Resolved{}, err
}
if err == nil && st.enabled && (st.mode == ModePopular || st.mode == ModeCustom) {
key, derr := DecryptSecret(s.Key, st.keyEnc)
if derr != nil {
return Resolved{}, derr
}
if strings.TrimSpace(key) != "" && strings.TrimSpace(st.baseURL) != "" && strings.TrimSpace(st.model) != "" {
client := processing.NewOpenAIClient(key, st.baseURL, st.model, rpm, retries)
client.ModeLabel = AnalyticsMode(st.mode, st.popularName)
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return Resolved{
Completer: client,
ModeLabel: client.ModeLabel,
UsingBYOK: true,
}, nil
}
}
platform, err := s.resolvePlatformOpenAI(ctx)
if err != nil {
return Resolved{}, err
}
if strings.TrimSpace(platform.APIKey) == "" {
return Resolved{ModeLabel: ModeInternalLabel, UsingBYOK: false}, nil
}
client := processing.NewOpenAIClient(
platform.APIKey,
platform.BaseURL,
platform.Model,
rpm,
retries,
)
client.ModeLabel = ModeInternalLabel
if s.HTTPClient != nil {
client.HTTPClient = s.HTTPClient
}
return Resolved{
Completer: client,
ModeLabel: ModeInternalLabel,
UsingBYOK: false,
}, nil
}
func (s *Service) platformConfigured(ctx context.Context) (bool, error) {
oi, err := s.resolvePlatformOpenAI(ctx)
if err != nil {
return false, err
}
return strings.TrimSpace(oi.APIKey) != "", nil
}
func (s *Service) resolvePlatformOpenAI(ctx context.Context) (platformsettings.ResolvedOpenAI, error) {
if s.Platform != nil {
return s.Platform.ResolveOpenAI(ctx)
}
out := platformsettings.ResolvedOpenAI{
APIKey: strings.TrimSpace(s.Env.OpenAIAPIKey),
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
Model: strings.TrimSpace(s.Env.OpenAIModel),
Source: platformsettings.SourceNone,
}
if out.APIKey != "" {
out.Source = platformsettings.SourceEnv
}
return out, nil
}
// ResolveCompleter implements processing.CompanyCompleterResolver (legacy callers).
// Prefer ResolveCompleterForRole for new call sites.
func (s *Service) ResolveCompleter(ctx context.Context, companyID uuid.UUID) (processing.Completer, string, bool, error) {
r, err := s.Resolve(ctx, companyID)
if err != nil {
return nil, ModeInternalLabel, false, err
}
return r.Completer, r.ModeLabel, r.UsingBYOK, nil
}
// TestPlatformRole probes admin platform AI role credentials (not company BYOK).
// Chat roles send a minimal completion; vectorization sends a one-token embed.
// Never returns upstream error bodies (may contain key fragments).
func (s *Service) TestPlatformRole(ctx context.Context, role string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
defer cancel()
role = strings.TrimSpace(role)
out := map[string]any{"role": role}
if role == "" || !platformsettings.ValidAIRole(role) {
out["status"] = "failed"
out["message"] = "unknown ai role"
return out, fmt.Errorf("unknown ai role %q", role)
}
if role == RoleVectorization {
emb, err := s.ResolveEmbedderForRole(ctx, uuid.Nil, role)
if err != nil {
out["status"] = "failed"
out["message"] = "provider resolve failed"
return out, err
}
if emb == nil {
out["status"] = "skipped"
out["message"] = "Vectorization AI is not configured in admin platform settings"
return out, nil
}
if _, err := emb.Embed(ctx, []string{"ping"}); err != nil {
out["status"] = "failed"
out["message"] = "connection failed — check vectorization provider, key, and model"
return out, err
}
out["status"] = "ok"
out["message"] = "Embeddings probe succeeded"
return out, nil
}
completer, _, _, err := s.resolvePlatformRoleCompleter(ctx, role)
if err != nil {
out["status"] = "failed"
out["message"] = "provider resolve failed"
return out, err
}
if completer == nil {
out["status"] = "skipped"
out["message"] = "AI role is not configured (or disabled) in admin platform settings"
return out, nil
}
if _, err := completer.Complete(ctx, "Reply with exactly: ok", "ping"); err != nil {
out["status"] = "failed"
out["message"] = "connection failed — check provider, key, base URL, and model"
return out, err
}
out["status"] = "ok"
out["message"] = "Connection probe succeeded"
return out, nil
}
// TestConnection sends a minimal chat completion and records last_test_*.
func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
defer cancel()
resolved, err := s.Resolve(ctx, companyID)
status := "ok"
message := "connection successful"
if err != nil {
status = "failed"
message = "provider resolve failed"
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": ModeInternalLabel}, err
}
if resolved.Completer == nil {
status = "failed"
message = "no api key configured (company BYOK or admin platform settings)"
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, ErrNotConfigured
}
_, err = resolved.Completer.Complete(ctx, "Reply with exactly: ok", "ping")
if err != nil {
status = "failed"
// TruncateError classifies transport/auth failures without leaking secrets.
message = processing.TruncateError(err)
if message == "" || message == "provider error (details redacted)" {
message = "connection failed — check provider, key, base URL, and model"
}
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
WHERE company_id = $1`, companyID, status)
return map[string]any{
"status": status,
"message": message,
"mode": resolved.ModeLabel,
"byok": resolved.UsingBYOK,
}, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
@@ -0,0 +1,93 @@
package aiprovider
import "testing"
func TestEncryptDecryptRoundTrip(t *testing.T) {
t.Setenv("APP_ENV", "development")
key := DeriveKey("test-ai-key-material", "fallback")
enc, err := EncryptSecret(key, "sk-test-secret-value")
if err != nil {
t.Fatal(err)
}
if enc == "" || enc == "sk-test-secret-value" {
t.Fatalf("expected ciphertext, got %q", enc)
}
plain, err := DecryptSecret(key, enc)
if err != nil {
t.Fatal(err)
}
if plain != "sk-test-secret-value" {
t.Fatalf("got %q", plain)
}
}
func TestDecryptSecret_plaintextPassthrough(t *testing.T) {
// Legacy/migrated rows may store unprefixed plaintext in local/dev only.
t.Setenv("APP_ENV", "development")
key := DeriveKey("test-ai-key-material", "fallback")
got, err := DecryptSecret(key, "sk-legacy-plain")
if err != nil {
t.Fatal(err)
}
if got != "sk-legacy-plain" {
t.Fatalf("got %q", got)
}
}
func TestDecryptSecret_plaintextRejectedInProduction(t *testing.T) {
t.Setenv("APP_ENV", "production")
key := DeriveKey("test-ai-key-material", "fallback")
if _, err := DecryptSecret(key, "sk-legacy-plain"); err == nil {
t.Fatal("expected plaintext decrypt rejected in production")
}
}
func TestAnalyticsMode(t *testing.T) {
cases := []struct {
mode, name, want string
}{
{ModeInternal, "", "internal"},
{ModePopular, "openai", "popular:openai"},
{ModePopular, "Google", "popular:google"},
{ModeCustom, "", "custom"},
{"", "", "internal"},
}
for _, c := range cases {
got := AnalyticsMode(c.mode, c.name)
if got != c.want {
t.Fatalf("AnalyticsMode(%q,%q)=%q want %q", c.mode, c.name, got, c.want)
}
}
}
func TestLast4(t *testing.T) {
if got := last4("sk-abcdefgh"); got != "efgh" {
t.Fatalf("got %q", got)
}
if got := last4("ab"); got != "ab" {
t.Fatalf("got %q", got)
}
}
func TestFindPopular(t *testing.T) {
p, ok := FindPopular("openai")
if !ok || p.BaseURL == "" {
t.Fatal("expected openai")
}
if _, ok := FindPopular("nope"); ok {
t.Fatal("expected miss")
}
}
func TestValidateProviderBaseURL(t *testing.T) {
ok, err := validateProviderBaseURL("https://api.openai.com/v1")
if err != nil || ok == "" {
t.Fatalf("want ok, got %q err=%v", ok, err)
}
if _, err := validateProviderBaseURL("http://169.254.169.254/"); err == nil {
t.Fatal("expected metadata URL blocked")
}
if _, err := validateProviderBaseURL("http://192.168.1.1/v1"); err == nil {
t.Fatal("expected private IP blocked")
}
}
+55
View File
@@ -0,0 +1,55 @@
package aiprovider
import (
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
)
// PublicConfig is the tenant-safe view (no raw secrets).
type PublicConfig struct {
Mode string `json:"mode"`
PopularName string `json:"popular_name,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
IsEnabled bool `json:"is_enabled"`
Configured bool `json:"configured"`
HasAPIKey bool `json:"has_api_key"`
APIKeyLast4 string `json:"api_key_last4,omitempty"`
APIKeyMasked string `json:"api_key_masked,omitempty"`
LastTestAt *time.Time `json:"last_test_at,omitempty"`
LastTestStatus *string `json:"last_test_status,omitempty"`
ActiveModeLabel string `json:"active_mode_label"`
PlatformFallback bool `json:"platform_fallback_available"`
PopularProviders []PopularProvider `json:"popular_providers,omitempty"`
}
// UpdateInput is the PUT body. Empty api_key keeps the existing encrypted key.
type UpdateInput struct {
Mode string `json:"mode"`
PopularName string `json:"popular_name"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
APIKey string `json:"api_key"`
IsEnabled bool `json:"is_enabled"`
ClearAPIKey bool `json:"clear_api_key"`
}
// Resolved is the runtime completer + analytics mode for one company job.
type Resolved struct {
Completer processing.Completer
ModeLabel string // internal | popular:<name> | custom
UsingBYOK bool // true when company key is used (skip managed token credits)
}
type EnvConfig struct {
AppEncryptionKey string
CredentialsEncryptionKey string
TokenSigningSecret string
DatabaseURL string
OpenAIAPIKey string // optional env bootstrap; prefer admin platform settings
OpenAIBaseURL string
OpenAIModel string
ProcessingRPM int
ProcessingMaxRetries int
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var ErrInvalidAPIKey = errors.New("invalid api key")
// APIKeyIdentity is the tenant binding resolved from a valid API key.
type APIKeyIdentity struct {
KeyID uuid.UUID
CompanyID uuid.UUID
UserID uuid.UUID
MembershipRole string // active membership role for the key owner (admin|member)
}
// HashAPIKey returns a SHA-256 hex digest for O(1) api_keys.key_hash lookup.
// Matches hashes written by dashboard key creation. Also used for invite tokens at rest.
func HashAPIKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// AuthenticateAPIKey looks up a non-revoked key by hash and updates last_used_at.
// Keys owned by inactive users or without an active company membership are rejected.
// MembershipRole is returned so HTTP middleware can withhold company-admin powers
// when the owner is no longer an admin (keys are admin-created; scopes/expiry columns
// do not exist yet — empty/full privilege remains the default for admin-owned keys).
func (s *Service) AuthenticateAPIKey(ctx context.Context, raw string) (APIKeyIdentity, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return APIKeyIdentity{}, ErrInvalidAPIKey
}
hash := HashAPIKey(raw)
var id APIKeyIdentity
var role string
err := s.Pool.QueryRow(ctx, `
SELECT k.id, k.company_id, k.user_id, m.role
FROM api_keys k
INNER JOIN users u ON u.id = k.user_id AND u.is_active = true
INNER JOIN memberships m ON m.user_id = k.user_id
AND m.company_id = k.company_id
AND m.status = 'active'
WHERE k.key_hash = $1 AND k.revoked_at IS NULL`, hash).
Scan(&id.KeyID, &id.CompanyID, &id.UserID, &role)
if errors.Is(err, pgx.ErrNoRows) {
return APIKeyIdentity{}, ErrInvalidAPIKey
}
if err != nil {
return APIKeyIdentity{}, err
}
id.MembershipRole = NormalizeMembershipRole(role)
_, _ = s.Pool.Exec(ctx, `
UPDATE api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1`, id.KeyID)
return id, nil
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import (
"testing"
)
func TestHashAPIKeyDeterministic(t *testing.T) {
t.Parallel()
a := HashAPIKey("dk_test_secret_value")
b := HashAPIKey("dk_test_secret_value")
if a != b {
t.Fatalf("hash not deterministic")
}
if len(a) != 64 {
t.Fatalf("expected sha256 hex length 64, got %d", len(a))
}
if HashAPIKey("other") == a {
t.Fatal("different keys must not collide")
}
}
func TestHashAPIKeyEmpty(t *testing.T) {
t.Parallel()
got := HashAPIKey("")
if len(got) != 64 {
t.Fatalf("empty input still hashes: len=%d", len(got))
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import "errors"
var (
ErrRegisterFieldsRequired = errors.New("email, password, and company name are required")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrUserNotFound = errors.New("user not found")
ErrNotCompanyMember = errors.New("not a member of company")
ErrInviteNotFound = errors.New("invite not found")
ErrEmailRequired = errors.New("email is required")
ErrSyntheticEmail = errors.New("synthetic migration email cannot receive invites")
ErrNotEligibleSetPassword = errors.New("user not eligible for set-password invite")
ErrEmailMismatch = errors.New("signed-in email does not match invite email")
)
// ClientError reports whether err is a known client-facing auth error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrRegisterFieldsRequired),
errors.Is(err, ErrPasswordTooShort),
errors.Is(err, ErrPasswordAlreadySet),
errors.Is(err, ErrUserExists),
errors.Is(err, ErrInviteInvalid),
errors.Is(err, ErrInvalidCredentials),
errors.Is(err, ErrMustSetPassword),
errors.Is(err, ErrUserNotFound),
errors.Is(err, ErrNotCompanyMember),
errors.Is(err, ErrInviteNotFound),
errors.Is(err, ErrTokenInvalid),
errors.Is(err, ErrEmailRequired),
errors.Is(err, ErrSyntheticEmail),
errors.Is(err, ErrNotEligibleSetPassword),
errors.Is(err, ErrEmailMismatch):
return err.Error(), true
default:
return "", false
}
}
+327
View File
@@ -0,0 +1,327 @@
package auth
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Invite is a pending company invite row (plaintext token returned once at creation; hashed at rest).
type Invite struct {
ID uuid.UUID `json:"id"`
CompanyID uuid.UUID `json:"company_id"`
Email string `json:"email"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
}
func normalizeInviteRole(role string) string {
role = strings.TrimSpace(strings.ToLower(role))
if role == "admin" {
return "admin"
}
return "member"
}
// NormalizeMembershipRole maps invite/membership role strings to admin|member.
// Unknown values collapse to member (safe default for invites).
func NormalizeMembershipRole(role string) string {
return normalizeInviteRole(role)
}
// ErrInvalidMembershipRole is returned when a role is not exactly admin|member.
var ErrInvalidMembershipRole = errors.New("invalid membership role")
// ParseMembershipRole accepts only admin|member (case-insensitive). Unlike
// NormalizeMembershipRole it does not coerce unknown values to member — use for
// PATCH/role updates where coercion would silently demote admins.
func ParseMembershipRole(role string) (string, error) {
role = strings.TrimSpace(strings.ToLower(role))
switch role {
case "admin", "member":
return role, nil
default:
return "", ErrInvalidMembershipRole
}
}
// preferMembershipRole keeps admin on invite accept conflict (never demote admin→member).
// Mirrors AcceptInvite ON CONFLICT role CASE.
func preferMembershipRole(existing, invited string) string {
if existing == "admin" {
return "admin"
}
return normalizeInviteRole(invited)
}
// HashInviteToken returns the SHA-256 hex digest stored in invites.token (same construction as API keys).
func HashInviteToken(raw string) string {
return HashAPIKey(raw)
}
// IsSyntheticLegacyEmail reports Clerk-missing synthetic addresses that must not receive invites.
func IsSyntheticLegacyEmail(email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
return strings.HasSuffix(email, "@legacy.local")
}
// EmailsEqual compares emails case-insensitively after trim.
func EmailsEqual(a, b string) bool {
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
}
// ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
return "", ErrInviteInvalid
}
var (
email string
expiresAt time.Time
acceptedAt *time.Time
)
tokenHash := HashInviteToken(token)
err := s.Pool.QueryRow(ctx, `
SELECT email, expires_at, accepted_at
FROM invites
WHERE token = $1 OR token = $2
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
LIMIT 1`, tokenHash, token).Scan(&email, &expiresAt, &acceptedAt)
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
return "", ErrInviteInvalid
}
if err != nil {
return "", err
}
return strings.ToLower(strings.TrimSpace(email)), nil
}
// SetPasswordInvite is a one-time accept-invite token for a migrated user (plaintext returned once).
type SetPasswordInvite struct {
InviteID uuid.UUID
UserID uuid.UUID
Email string
CompanyID uuid.UUID
Role string
Token string
ExpiresAt time.Time
}
// CreateInvite inserts a pending invite (token hashed at rest) and returns the plaintext token once.
func (s *Service) CreateInvite(ctx context.Context, companyID, invitedBy uuid.UUID, email, role string) (Invite, string, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return Invite{}, "", ErrEmailRequired
}
role = normalizeInviteRole(role)
token, err := RandomToken(24)
if err != nil {
return Invite{}, "", err
}
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
var inv Invite
err = s.Pool.QueryRow(ctx, `
INSERT INTO invites (company_id, email, role, token, invited_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, company_id, email, role, expires_at`,
companyID, email, role, HashInviteToken(token), invitedBy, expires,
).Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt)
if err != nil {
return Invite{}, "", err
}
return inv, token, nil
}
// ListPendingInvites returns unaccepted, unexpired invites for a company.
func (s *Service) ListPendingInvites(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Invite, int64, error) {
const where = `company_id = $1 AND accepted_at IS NULL AND expires_at > now()`
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM invites WHERE `+where, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, email, role, expires_at
FROM invites
WHERE `+where+`
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Invite
for rows.Next() {
var inv Invite
if err := rows.Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt); err != nil {
return nil, 0, err
}
out = append(out, inv)
}
return out, total, rows.Err()
}
// RevokeInvite deletes a pending invite owned by the company.
func (s *Service) RevokeInvite(ctx context.Context, companyID, inviteID uuid.UUID) error {
ct, err := s.Pool.Exec(ctx, `
DELETE FROM invites
WHERE id = $1 AND company_id = $2 AND accepted_at IS NULL`, inviteID, companyID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrInviteNotFound
}
return nil
}
// CompanyName returns the display name for a company.
func (s *Service) CompanyName(ctx context.Context, companyID uuid.UUID) (string, error) {
var name string
err := s.Pool.QueryRow(ctx, `SELECT name FROM companies WHERE id = $1`, companyID).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
return "", errors.New("company not found")
}
return name, err
}
// UpdateMembershipRole sets an active membership role to admin or member.
func (s *Service) UpdateMembershipRole(ctx context.Context, companyID, userID uuid.UUID, role string) (Membership, error) {
role = normalizeInviteRole(role)
var m Membership
err := s.Pool.QueryRow(ctx, `
UPDATE memberships
SET role = $3, updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'
RETURNING id, company_id, user_id, role, status`,
companyID, userID, role,
).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
if errors.Is(err, pgx.ErrNoRows) {
return Membership{}, ErrNotCompanyMember
}
return m, err
}
// IsPlatformAdmin reports whether the user has full platform admin privileges
// (admin/developer or legacy is_platform_admin). support_staff is excluded.
func (s *Service) IsPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
access, err := s.GetStaffAccess(ctx, userID)
if err != nil {
return false, err
}
return access.FullAdmin, nil
}
// UpdateProfile updates the user's display name.
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, name string) (User, error) {
name = strings.TrimSpace(name)
var n *string
if name != "" {
n = &name
}
_, err := s.Pool.Exec(ctx, `
UPDATE users SET name = $2, updated_at = now() WHERE id = $1`, userID, n)
if err != nil {
return User{}, err
}
return s.GetUser(ctx, userID)
}
// ListUsersNeedingPassword returns active users with must_set_password (migration cutover).
func (s *Service) ListUsersNeedingPassword(ctx context.Context, limit int) ([]User, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.Pool.Query(ctx, `
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
FROM users
WHERE must_set_password = true AND is_active = true
ORDER BY email
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// ReissueSetPasswordInvite expires prior pending invites and creates a durable invite for a
// must_set_password user with an active membership. Token plaintext is returned once.
func (s *Service) ReissueSetPasswordInvite(ctx context.Context, userID uuid.UUID, ttl time.Duration) (SetPasswordInvite, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
var (
out SetPasswordInvite
mustSetPassword bool
isActive bool
)
err := s.Pool.QueryRow(ctx, `
SELECT id, email, must_set_password, is_active
FROM users WHERE id = $1`, userID).Scan(&out.UserID, &out.Email, &mustSetPassword, &isActive)
if errors.Is(err, pgx.ErrNoRows) {
return SetPasswordInvite{}, ErrUserNotFound
}
if err != nil {
return SetPasswordInvite{}, err
}
if !isActive || !mustSetPassword {
return SetPasswordInvite{}, ErrNotEligibleSetPassword
}
if IsSyntheticLegacyEmail(out.Email) {
return SetPasswordInvite{}, ErrSyntheticEmail
}
out.Email = strings.ToLower(strings.TrimSpace(out.Email))
err = s.Pool.QueryRow(ctx, `
SELECT company_id, role
FROM memberships
WHERE user_id = $1 AND status = 'active'
ORDER BY created_at
LIMIT 1`, userID).Scan(&out.CompanyID, &out.Role)
if errors.Is(err, pgx.ErrNoRows) {
return SetPasswordInvite{}, ErrNotEligibleSetPassword
}
if err != nil {
return SetPasswordInvite{}, err
}
out.Role = normalizeInviteRole(out.Role)
token, err := RandomToken(24)
if err != nil {
return SetPasswordInvite{}, err
}
out.Token = token
out.ExpiresAt = time.Now().UTC().Add(ttl)
// Expire prior unaccepted invites for this email+company so re-issue is safe.
if _, err := s.Pool.Exec(ctx, `
UPDATE invites
SET expires_at = least(expires_at, now())
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
out.CompanyID, out.Email); err != nil {
return SetPasswordInvite{}, err
}
err = s.Pool.QueryRow(ctx, `
INSERT INTO invites (company_id, email, role, token, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
out.CompanyID, out.Email, out.Role, HashInviteToken(token), out.ExpiresAt,
).Scan(&out.InviteID)
if err != nil {
return SetPasswordInvite{}, err
}
return out, nil
}
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"errors"
"testing"
)
func TestPreferMembershipRoleNeverDemotesAdmin(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing string
invited string
want string
}{
{name: "admin stays admin on member invite", existing: "admin", invited: "member", want: "admin"},
{name: "admin stays admin on admin invite", existing: "admin", invited: "admin", want: "admin"},
{name: "member promotes to admin", existing: "member", invited: "admin", want: "admin"},
{name: "member stays member", existing: "member", invited: "member", want: "member"},
{name: "unknown invited normalizes to member", existing: "member", invited: "owner", want: "member"},
{name: "empty existing yields invited role", existing: "", invited: "admin", want: "admin"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := preferMembershipRole(tc.existing, tc.invited)
if got != tc.want {
t.Fatalf("preferMembershipRole(%q, %q)=%q want %q", tc.existing, tc.invited, got, tc.want)
}
})
}
}
func TestHashInviteTokenMatchesAPIKeyHash(t *testing.T) {
t.Parallel()
const raw = "invite-plaintext-secret"
got := HashInviteToken(raw)
if got != HashAPIKey(raw) {
t.Fatalf("HashInviteToken must match HashAPIKey construction")
}
if len(got) != 64 {
t.Fatalf("expected sha256 hex length 64, got %d", len(got))
}
if got == raw {
t.Fatal("invite token must not be stored as plaintext")
}
}
func TestNormalizeInviteRole(t *testing.T) {
t.Parallel()
if normalizeInviteRole("Admin") != "admin" {
t.Fatal("expected admin")
}
if normalizeInviteRole(" MEMBER ") != "member" {
t.Fatal("expected member")
}
if normalizeInviteRole("owner") != "member" {
t.Fatal("unknown roles collapse to member")
}
if NormalizeMembershipRole("Admin") != "admin" {
t.Fatal("NormalizeMembershipRole should accept Admin")
}
}
func TestParseMembershipRole(t *testing.T) {
t.Parallel()
got, err := ParseMembershipRole(" Admin ")
if err != nil || got != "admin" {
t.Fatalf("admin: got %q err=%v", got, err)
}
got, err = ParseMembershipRole("MEMBER")
if err != nil || got != "member" {
t.Fatalf("member: got %q err=%v", got, err)
}
if _, err := ParseMembershipRole("owner"); !errors.Is(err, ErrInvalidMembershipRole) {
t.Fatalf("owner: err=%v want ErrInvalidMembershipRole", err)
}
if _, err := ParseMembershipRole(""); !errors.Is(err, ErrInvalidMembershipRole) {
t.Fatalf("empty: err=%v want ErrInvalidMembershipRole", err)
}
}
func TestIsSyntheticLegacyEmail(t *testing.T) {
t.Parallel()
if !IsSyntheticLegacyEmail("user_abc@legacy.local") {
t.Fatal("expected synthetic")
}
if !IsSyntheticLegacyEmail(" User@Legacy.Local ") {
t.Fatal("expected case-insensitive synthetic")
}
if IsSyntheticLegacyEmail("real@example.com") {
t.Fatal("real email must not be treated as synthetic")
}
if IsSyntheticLegacyEmail("legacy.local@example.com") {
t.Fatal("suffix-only match; local-part must not trigger")
}
if IsSyntheticLegacyEmail("") {
t.Fatal("empty must not be synthetic")
}
}
func TestEmailsEqual(t *testing.T) {
t.Parallel()
if !EmailsEqual("A@Example.COM", " a@example.com ") {
t.Fatal("expected equal after normalize")
}
if EmailsEqual("a@example.com", "b@example.com") {
t.Fatal("expected mismatch")
}
if !EmailsEqual("", "") {
t.Fatal("empty emails should compare equal")
}
}
+61
View File
@@ -0,0 +1,61 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 1
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
argonSaltLen = 16
)
func HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", ErrPasswordTooShort
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
}
func VerifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("invalid password hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, err
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(want, got) == 1, nil
}
+173
View File
@@ -0,0 +1,173 @@
package auth
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// DefaultPasswordResetTTL is the self-serve reset link lifetime.
const DefaultPasswordResetTTL = time.Hour
// PasswordResetIssue is returned once when a reset token is created (plaintext token for email only).
type PasswordResetIssue struct {
UserID uuid.UUID
Email string
Token string
}
// IssuePasswordReset creates a durable hashed reset token for an active user with a deliverable email.
// Unknown, inactive, and synthetic @legacy.local addresses return ErrUserNotFound / ErrSyntheticEmail
// so callers can respond opaquely without enumeration.
func (s *Service) IssuePasswordReset(ctx context.Context, email string, ttl time.Duration) (PasswordResetIssue, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return PasswordResetIssue{}, ErrEmailRequired
}
if IsSyntheticLegacyEmail(email) {
return PasswordResetIssue{}, ErrSyntheticEmail
}
if ttl <= 0 {
ttl = DefaultPasswordResetTTL
}
var (
userID uuid.UUID
isActive bool
)
err := s.Pool.QueryRow(ctx, `
SELECT id, is_active
FROM users
WHERE lower(email) = $1`, email).Scan(&userID, &isActive)
if errors.Is(err, pgx.ErrNoRows) {
return PasswordResetIssue{}, ErrUserNotFound
}
if err != nil {
return PasswordResetIssue{}, err
}
if !isActive {
return PasswordResetIssue{}, ErrUserNotFound
}
token, err := RandomToken(24)
if err != nil {
return PasswordResetIssue{}, err
}
expiresAt := time.Now().UTC().Add(ttl)
tx, err := s.Pool.Begin(ctx)
if err != nil {
return PasswordResetIssue{}, err
}
defer tx.Rollback(ctx)
// Invalidate prior unused tokens so only the latest link works.
if _, err := tx.Exec(ctx, `
UPDATE password_reset_tokens
SET expires_at = least(expires_at, now())
WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > now()`, userID); err != nil {
return PasswordResetIssue{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`, userID, HashInviteToken(token), expiresAt); err != nil {
return PasswordResetIssue{}, err
}
if err := tx.Commit(ctx); err != nil {
return PasswordResetIssue{}, err
}
return PasswordResetIssue{UserID: userID, Email: email, Token: token}, nil
}
// ResetPasswordWithToken consumes a one-time reset token and sets a new password
// regardless of must_set_password (dedicated reset path — not SetPassword / ForceSetPassword).
func (s *Service) ResetPasswordWithToken(ctx context.Context, rawToken, password string) error {
rawToken = strings.TrimSpace(rawToken)
if rawToken == "" {
return ErrTokenInvalid
}
passwordHash, err := HashPassword(password)
if err != nil {
return err
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var (
tokenID uuid.UUID
userID uuid.UUID
expiresAt time.Time
consumedAt *time.Time
)
err = tx.QueryRow(ctx, `
SELECT id, user_id, expires_at, consumed_at
FROM password_reset_tokens
WHERE token_hash = $1
FOR UPDATE`, HashInviteToken(rawToken)).Scan(&tokenID, &userID, &expiresAt, &consumedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrTokenInvalid
}
if err != nil {
return err
}
if consumedAt != nil || !expiresAt.After(time.Now().UTC()) {
return ErrTokenInvalid
}
var isActive bool
err = tx.QueryRow(ctx, `SELECT is_active FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&isActive)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !isActive) {
return ErrTokenInvalid
}
if err != nil {
return err
}
ct, err := tx.Exec(ctx, `
UPDATE users
SET password_hash = $2,
must_set_password = false,
session_version = session_version + 1,
updated_at = now()
WHERE id = $1 AND is_active = true`, userID, passwordHash)
if isUndefinedColumn(err) {
// Pre-042 DBs: still reset password; session revoke requires session_version migration.
ct, err = tx.Exec(ctx, `
UPDATE users
SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1 AND is_active = true`, userID, passwordHash)
}
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrTokenInvalid
}
if _, err := tx.Exec(ctx, `
UPDATE password_reset_tokens
SET consumed_at = now()
WHERE id = $1`, tokenID); err != nil {
return err
}
// Expire any sibling unused tokens for this user.
if _, err := tx.Exec(ctx, `
UPDATE password_reset_tokens
SET expires_at = least(expires_at, now())
WHERE user_id = $1 AND id <> $2 AND consumed_at IS NULL AND expires_at > now()`,
userID, tokenID); err != nil {
return err
}
return tx.Commit(ctx)
}
@@ -0,0 +1,28 @@
package auth
import (
"errors"
"testing"
"time"
)
func TestDefaultPasswordResetTTL(t *testing.T) {
t.Parallel()
if DefaultPasswordResetTTL != time.Hour {
t.Fatalf("DefaultPasswordResetTTL=%v want 1h", DefaultPasswordResetTTL)
}
}
func TestIssuePasswordResetRejectsSyntheticEmail(t *testing.T) {
t.Parallel()
s := &Service{} // no Pool — synthetic check must return before any DB use
for _, email := range []string{
"user_abc@legacy.local",
" User_ABC@Legacy.Local ",
} {
_, err := s.IssuePasswordReset(t.Context(), email, 0)
if !errors.Is(err, ErrSyntheticEmail) {
t.Fatalf("email=%q err=%v want ErrSyntheticEmail", email, err)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"strings"
"testing"
)
func TestHashPasswordRejectsShort(t *testing.T) {
t.Parallel()
_, err := HashPassword("short")
if err == nil {
t.Fatal("expected error for password shorter than 8 characters")
}
}
func TestHashPasswordAndVerifyRoundTrip(t *testing.T) {
t.Parallel()
const password = "correct-horse-battery"
encoded, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(encoded, "$argon2id$") {
t.Fatalf("unexpected encoding prefix: %q", encoded)
}
ok, err := VerifyPassword(encoded, password)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Fatal("expected password to verify")
}
ok, err = VerifyPassword(encoded, "wrong-password")
if err != nil {
t.Fatalf("VerifyPassword wrong: %v", err)
}
if ok {
t.Fatal("expected wrong password to fail verification")
}
}
func TestVerifyPasswordInvalidFormat(t *testing.T) {
t.Parallel()
_, err := VerifyPassword("not-a-hash", "anything12")
if err == nil {
t.Fatal("expected invalid format error")
}
}
func TestRandomTokenLength(t *testing.T) {
t.Parallel()
tok, err := RandomToken(24)
if err != nil {
t.Fatalf("RandomToken: %v", err)
}
if len(tok) != 48 {
t.Fatalf("expected hex length 48, got %d (%q)", len(tok), tok)
}
}
+491
View File
@@ -0,0 +1,491 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrMustSetPassword = errors.New("password_not_set")
ErrInviteInvalid = errors.New("invite invalid or expired")
ErrPasswordAlreadySet = errors.New("password already set")
ErrUserExists = errors.New("user already exists")
)
type Service struct {
Pool *pgxpool.Pool
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name,omitempty"`
MustSetPassword bool `json:"must_set_password"`
IsPlatformAdmin bool `json:"is_platform_admin"`
StaffRole *string `json:"staff_role,omitempty"`
IsActive bool `json:"is_active"`
}
type Membership struct {
ID uuid.UUID `json:"id"`
CompanyID uuid.UUID `json:"company_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
}
type Company struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type RegisterInput struct {
Email string
Password string
Name string
CompanyName string
}
type LoginResult struct {
User User `json:"user"`
CompanyID uuid.UUID `json:"company_id"`
Companies []Company `json:"companies"`
}
func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult, error) {
email := strings.ToLower(strings.TrimSpace(in.Email))
if email == "" || in.Password == "" || strings.TrimSpace(in.CompanyName) == "" {
return LoginResult{}, ErrRegisterFieldsRequired
}
hash, err := HashPassword(in.Password)
if err != nil {
return LoginResult{}, err
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return LoginResult{}, err
}
defer tx.Rollback(ctx)
var existing uuid.UUID
err = tx.QueryRow(ctx, `SELECT id FROM users WHERE email = $1`, email).Scan(&existing)
if err == nil {
return LoginResult{}, ErrUserExists
}
if !errors.Is(err, pgx.ErrNoRows) {
return LoginResult{}, err
}
var userID uuid.UUID
var name *string
if strings.TrimSpace(in.Name) != "" {
n := strings.TrimSpace(in.Name)
name = &n
}
err = tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, must_set_password)
VALUES ($1, $2, $3, false)
RETURNING id`, email, name, hash).Scan(&userID)
if err != nil {
return LoginResult{}, err
}
var companyID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO companies (name) VALUES ($1) RETURNING id`, strings.TrimSpace(in.CompanyName)).Scan(&companyID)
if err != nil {
return LoginResult{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
if err != nil {
return LoginResult{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO company_settings (company_id) VALUES ($1)
ON CONFLICT DO NOTHING`, companyID)
if err != nil {
return LoginResult{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO credit_balances (company_id) VALUES ($1)
ON CONFLICT DO NOTHING`, companyID)
if err != nil {
return LoginResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return LoginResult{}, err
}
user, err := s.GetUser(ctx, userID)
if err != nil {
return LoginResult{}, err
}
return LoginResult{
User: user,
CompanyID: companyID,
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
}, nil
}
func (s *Service) Login(ctx context.Context, email, password string) (LoginResult, error) {
email = strings.ToLower(strings.TrimSpace(email))
var (
user User
hash *string
)
err := s.Pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, staff_role, is_active
FROM users WHERE email = $1`, email).Scan(
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.StaffRole, &user.IsActive,
)
if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, is_active
FROM users WHERE email = $1`, email).Scan(
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.IsActive,
)
}
if errors.Is(err, pgx.ErrNoRows) {
return LoginResult{}, ErrInvalidCredentials
}
if err != nil {
return LoginResult{}, err
}
if !user.IsActive {
return LoginResult{}, ErrInvalidCredentials
}
// Migrated / invite-pending accounts have no usable password until accept-invite or set-password.
if user.MustSetPassword || hash == nil || *hash == "" {
if user.MustSetPassword {
return LoginResult{}, ErrMustSetPassword
}
return LoginResult{}, ErrInvalidCredentials
}
ok, err := VerifyPassword(*hash, password)
if err != nil || !ok {
return LoginResult{}, ErrInvalidCredentials
}
companies, err := s.ListUserCompanies(ctx, user.ID)
if err != nil {
return LoginResult{}, err
}
var companyID uuid.UUID
if len(companies) > 0 {
companyID = companies[0].ID
}
_, _ = s.Pool.Exec(ctx, `UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1`, user.ID)
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
}
func (s *Service) AcceptInvite(ctx context.Context, token, password, name string) (LoginResult, error) {
token = strings.TrimSpace(token)
if token == "" {
return LoginResult{}, ErrInviteInvalid
}
var (
inviteID, companyID uuid.UUID
email, role string
expiresAt time.Time
acceptedAt *time.Time
)
// Prefer hashed lookup (at-rest); also accept legacy plaintext rows until they expire.
tokenHash := HashInviteToken(token)
err := s.Pool.QueryRow(ctx, `
SELECT id, company_id, email, role, expires_at, accepted_at
FROM invites
WHERE token = $1 OR token = $2
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
LIMIT 1`, tokenHash, token).Scan(
&inviteID, &companyID, &email, &role, &expiresAt, &acceptedAt,
)
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
return LoginResult{}, ErrInviteInvalid
}
if err != nil {
return LoginResult{}, err
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return LoginResult{}, err
}
defer tx.Rollback(ctx)
var userID uuid.UUID
var existingHash string
var mustSet bool
err = tx.QueryRow(ctx, `
SELECT id, password_hash, must_set_password FROM users WHERE email = $1`,
strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet)
if errors.Is(err, pgx.ErrNoRows) {
hash, herr := HashPassword(password)
if herr != nil {
return LoginResult{}, herr
}
var n *string
if strings.TrimSpace(name) != "" {
nn := strings.TrimSpace(name)
n = &nn
}
err = tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, must_set_password)
VALUES ($1, $2, $3, false) RETURNING id`, strings.ToLower(email), n, hash).Scan(&userID)
if err != nil {
return LoginResult{}, err
}
} else if err != nil {
return LoginResult{}, err
} else if mustSet {
// Migration / first-password invites may set a password once.
hash, herr := HashPassword(password)
if herr != nil {
return LoginResult{}, herr
}
_, err = tx.Exec(ctx, `
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1 AND must_set_password = true`, userID, hash)
if err != nil {
return LoginResult{}, err
}
} else {
// Existing accounts keep their password; invitee must prove ownership.
ok, verr := VerifyPassword(existingHash, password)
if verr != nil || !ok {
return LoginResult{}, ErrInvalidCredentials
}
}
_, err = tx.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, $3, 'active')
ON CONFLICT (company_id, user_id) DO UPDATE
SET role = CASE
WHEN memberships.role = 'admin' THEN memberships.role
ELSE EXCLUDED.role
END,
status = 'active', updated_at = now()`,
companyID, userID, role)
if err != nil {
return LoginResult{}, err
}
ct, err := tx.Exec(ctx, `
UPDATE invites SET accepted_at = now()
WHERE id = $1 AND accepted_at IS NULL`, inviteID)
if err != nil {
return LoginResult{}, err
}
if ct.RowsAffected() == 0 {
return LoginResult{}, ErrInviteInvalid
}
if err := tx.Commit(ctx); err != nil {
return LoginResult{}, err
}
user, err := s.GetUser(ctx, userID)
if err != nil {
return LoginResult{}, err
}
companies, err := s.ListUserCompanies(ctx, userID)
if err != nil {
return LoginResult{}, err
}
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
}
func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
// Only users flagged must_set_password may set via token/session bootstrap.
// This also makes HMAC set-password tokens single-use after success.
ct, err := s.Pool.Exec(ctx, `
UPDATE users
SET password_hash = $2,
must_set_password = false,
session_version = session_version + 1,
updated_at = now()
WHERE id = $1 AND must_set_password = true`, userID, hash)
if isUndefinedColumn(err) {
ct, err = s.Pool.Exec(ctx, `
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1 AND must_set_password = true`, userID, hash)
}
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
var exists bool
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
if exists {
return ErrPasswordAlreadySet
}
return ErrUserNotFound
}
return nil
}
// ChangePassword verifies the current password then sets a new one (in-app Settings).
// Bumps session_version so other sessions are revoked; callers must re-stamp the cookie.
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
var (
hash string
mustSetPassword bool
isActive bool
)
err := s.Pool.QueryRow(ctx, `
SELECT password_hash, must_set_password, is_active
FROM users WHERE id = $1`, userID).Scan(&hash, &mustSetPassword, &isActive)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUserNotFound
}
if err != nil {
return err
}
if !isActive {
return ErrUserNotFound
}
if mustSetPassword {
return ErrMustSetPassword
}
ok, err := VerifyPassword(hash, currentPassword)
if err != nil {
return err
}
if !ok {
return ErrInvalidCredentials
}
newHash, err := HashPassword(newPassword)
if err != nil {
return err
}
ct, err := s.Pool.Exec(ctx, `
UPDATE users
SET password_hash = $2,
must_set_password = false,
session_version = session_version + 1,
updated_at = now()
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
if isUndefinedColumn(err) {
ct, err = s.Pool.Exec(ctx, `
UPDATE users
SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
}
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// ForceSetPassword sets a password regardless of must_set_password (local/admin bootstrap).
func (s *Service) ForceSetPassword(ctx context.Context, userID uuid.UUID, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
ct, err := s.Pool.Exec(ctx, `
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE id = $1 AND is_active = true`, userID, hash)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
FROM users WHERE id = $1`, id).Scan(
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive,
)
if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT id, email, name, must_set_password, is_platform_admin, is_active
FROM users WHERE id = $1`, id).Scan(
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.IsActive,
)
}
return u, err
}
func (s *Service) ListUserCompanies(ctx context.Context, userID uuid.UUID) ([]Company, error) {
// Prefer Platform Demo sandbox when present, then richest tenant (products/feeds).
// A1 Slovenija wins remaining ties; accept old Local Demo Co rename as A1 alias.
const a1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
rows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name
FROM memberships m
JOIN companies c ON c.id = m.company_id
WHERE m.user_id = $1 AND m.status = 'active'
ORDER BY
CASE
WHEN lower(c.name) IN ('platform demo', 'demo') THEN 0
ELSE 1
END,
(SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC,
(SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) DESC,
CASE
WHEN lower(c.name) = 'a1 slovenija' THEN 0
WHEN lower(c.name) = 'local demo co' THEN 0
WHEN lower(COALESCE(c.legacy_company_id, '')) = lower($2) THEN 0
ELSE 1
END,
c.name`, userID, a1LegacyCompanyID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Company
for rows.Next() {
var c Company
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Service) EnsureMembership(ctx context.Context, userID, companyID uuid.UUID) (Membership, error) {
var m Membership
err := s.Pool.QueryRow(ctx, `
SELECT id, company_id, user_id, role, status
FROM memberships
WHERE user_id = $1 AND company_id = $2 AND status = 'active'`,
userID, companyID).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
if errors.Is(err, pgx.ErrNoRows) {
return Membership{}, ErrNotCompanyMember
}
return m, err
}
func RandomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+33
View File
@@ -0,0 +1,33 @@
package auth
import (
"net/http"
"time"
"github.com/alexedwards/scs/pgxstore"
"github.com/alexedwards/scs/v2"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleHours int) *scs.SessionManager {
sm := scs.New()
sm.Store = pgxstore.New(pool)
sm.Lifetime = 7 * 24 * time.Hour
if idleHours <= 0 {
idleHours = 24
}
sm.IdleTimeout = time.Duration(idleHours) * time.Hour
sm.Cookie.Name = cookieName
sm.Cookie.HttpOnly = true
sm.Cookie.Secure = secure
sm.Cookie.SameSite = http.SameSiteLaxMode
sm.Cookie.Path = "/"
return sm
}
const (
SessionUserIDKey = "user_id"
SessionCompanyIDKey = "company_id"
SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo
SessionVersionKey = "session_version" // must match users.session_version
)
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"net/http"
"testing"
"time"
)
func TestNewSessionManagerCookieFlags(t *testing.T) {
t.Parallel()
sm := NewSessionManager(nil, "descrybe_session", true, 12)
if sm.Cookie.Name != "descrybe_session" {
t.Fatalf("Name = %q", sm.Cookie.Name)
}
if !sm.Cookie.HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
if !sm.Cookie.Secure {
t.Fatal("secure=true must set Secure")
}
if sm.Cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", sm.Cookie.SameSite)
}
if sm.Cookie.Path != "/" {
t.Fatalf("Path = %q, want /", sm.Cookie.Path)
}
if sm.IdleTimeout != 12*time.Hour {
t.Fatalf("IdleTimeout = %v, want 12h", sm.IdleTimeout)
}
if sm.Lifetime != 7*24*time.Hour {
t.Fatalf("Lifetime = %v, want 7d", sm.Lifetime)
}
insecure := NewSessionManager(nil, "descrybe_session", false, 0)
if insecure.Cookie.Secure {
t.Fatal("secure=false must not set Secure")
}
if !insecure.Cookie.HttpOnly {
t.Fatal("session cookie must remain HttpOnly")
}
if insecure.IdleTimeout != 24*time.Hour {
t.Fatalf("default IdleTimeout = %v, want 24h", insecure.IdleTimeout)
}
}
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// UserSessionState is the cookie-session gate (active flag + version for revoke-on-reset).
type UserSessionState struct {
Active bool
Version int
}
// UserSessionState loads is_active and session_version for RequireSession.
// When session_version is not migrated yet, Version defaults to 0 (pre-hardening sessions keep working).
func (s *Service) UserSessionState(ctx context.Context, userID uuid.UUID) (UserSessionState, error) {
var st UserSessionState
err := s.Pool.QueryRow(ctx, `
SELECT is_active, session_version
FROM users
WHERE id = $1`, userID).Scan(&st.Active, &st.Version)
if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT is_active
FROM users
WHERE id = $1`, userID).Scan(&st.Active)
st.Version = 0
}
if errors.Is(err, pgx.ErrNoRows) {
return UserSessionState{}, ErrUserNotFound
}
if err != nil {
return UserSessionState{}, err
}
return st, nil
}
@@ -0,0 +1,192 @@
package auth
import (
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestResetPasswordWithTokenBumpsSessionVersion(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
var ready bool
if err := pg.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
)`).Scan(&ready); err != nil || !ready {
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
}
if err := pg.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
)`).Scan(&ready); err != nil || !ready {
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
}
svc := &Service{Pool: pg}
userID := uuid.New()
email := "session-ver-" + userID.String()[:8] + "@example.test"
hash, err := HashPassword("OldPassword123!")
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
VALUES ($1, $2, $3, $4, false, false, true, 3)`,
userID, email, "Session Ver", hash)
if err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
cleanupCtx := t.Context()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
})
issue, err := svc.IssuePasswordReset(ctx, email, time.Hour)
if err != nil {
t.Fatalf("IssuePasswordReset: %v", err)
}
if err := svc.ResetPasswordWithToken(ctx, issue.Token, "NewPassword456!"); err != nil {
t.Fatalf("ResetPasswordWithToken: %v", err)
}
st, err := svc.UserSessionState(ctx, userID)
if err != nil {
t.Fatalf("UserSessionState: %v", err)
}
if !st.Active {
t.Fatal("expected active user")
}
if st.Version != 4 {
t.Fatalf("session_version=%d want 4 (bumped from 3)", st.Version)
}
}
func TestChangePasswordBumpsSessionVersion(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
var ready bool
if err := pg.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
)`).Scan(&ready); err != nil || !ready {
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
}
svc := &Service{Pool: pg}
userID := uuid.New()
email := "change-pw-" + userID.String()[:8] + "@example.test"
const oldPassword = "OldPassword123!"
hash, err := HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
VALUES ($1, $2, $3, $4, false, false, true, 2)`,
userID, email, "Change PW", hash)
if err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
})
if err := svc.ChangePassword(ctx, userID, "wrong-password", "NewPassword456!"); err != ErrInvalidCredentials {
t.Fatalf("wrong current: err=%v want ErrInvalidCredentials", err)
}
if err := svc.ChangePassword(ctx, userID, oldPassword, "short"); err != ErrPasswordTooShort {
t.Fatalf("short password: err=%v want ErrPasswordTooShort", err)
}
if err := svc.ChangePassword(ctx, userID, oldPassword, "NewPassword456!"); err != nil {
t.Fatalf("ChangePassword: %v", err)
}
st, err := svc.UserSessionState(ctx, userID)
if err != nil {
t.Fatalf("UserSessionState: %v", err)
}
if st.Version != 3 {
t.Fatalf("session_version=%d want 3 (bumped from 2)", st.Version)
}
var stored string
if err := pg.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&stored); err != nil {
t.Fatalf("load hash: %v", err)
}
ok, err := VerifyPassword(stored, "NewPassword456!")
if err != nil || !ok {
t.Fatalf("new password verify ok=%v err=%v", ok, err)
}
ok, err = VerifyPassword(stored, oldPassword)
if err != nil || ok {
t.Fatal("old password should no longer verify")
}
}
func TestSetPasswordRejectsWhenAlreadySet(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
svc := &Service{Pool: pg}
userID := uuid.New()
email := "set-pw-" + userID.String()[:8] + "@example.test"
hash, err := HashPassword("AlreadySet123!")
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
VALUES ($1, $2, $3, $4, false, false, true)`,
userID, email, "Set PW", hash)
if err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
})
if err := svc.SetPassword(ctx, userID, "AnotherPass123!"); err != ErrPasswordAlreadySet {
t.Fatalf("SetPassword: err=%v want ErrPasswordAlreadySet", err)
}
if err := svc.ChangePassword(ctx, userID, "AlreadySet123!", "short"); err != ErrPasswordTooShort {
// ensure ChangePassword path still works for eligible users after SetPassword rejection
t.Fatalf("ChangePassword short: err=%v", err)
}
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Platform staff roles (users.staff_role). Orthogonal to company membership roles.
const (
StaffRoleAdmin = "admin"
StaffRoleDeveloper = "developer"
StaffRoleSupportStaff = "support_staff"
)
var (
ErrInvalidStaffRole = errors.New("invalid staff_role")
ErrStaffUserNotFound = errors.New("user not found")
)
// StaffAccess is the resolved capability set for a platform staff user.
type StaffAccess struct {
Role string `json:"staff_role,omitempty"`
FullAdmin bool `json:"full_admin"`
SupportDesk bool `json:"support_desk"`
IsSupportOnly bool `json:"is_support_only"`
}
// NormalizeStaffRole returns a known staff role or empty string.
func NormalizeStaffRole(raw string) (string, error) {
role := strings.ToLower(strings.TrimSpace(raw))
switch role {
case "", StaffRoleAdmin, StaffRoleDeveloper, StaffRoleSupportStaff:
return role, nil
default:
return "", ErrInvalidStaffRole
}
}
// ResolveStaffRole returns the effective staff role per contract 04:
// staff_role if set; else admin when is_platform_admin; else empty.
func ResolveStaffRole(isPlatformAdmin bool, staffRole string) string {
role, _ := NormalizeStaffRole(staffRole)
if role != "" {
return role
}
if isPlatformAdmin {
return StaffRoleAdmin
}
return ""
}
// ResolveStaffAccess maps DB flags to capabilities.
//
// Rules (fail closed):
// - staff_role=support_staff → support desk only (never full admin), even if is_platform_admin.
// - staff_role=admin|developer → full admin + support desk.
// - staff_role empty + is_platform_admin → legacy full admin (backward compatible).
// - otherwise → no staff access.
func ResolveStaffAccess(isPlatformAdmin bool, staffRole string) StaffAccess {
role := ResolveStaffRole(isPlatformAdmin, staffRole)
switch role {
case StaffRoleSupportStaff:
return StaffAccess{
Role: StaffRoleSupportStaff,
FullAdmin: false,
SupportDesk: true,
IsSupportOnly: true,
}
case StaffRoleAdmin, StaffRoleDeveloper:
return StaffAccess{
Role: role,
FullAdmin: true,
SupportDesk: true,
}
default:
return StaffAccess{}
}
}
// StaffCapabilities lists platform capability keys for a resolved staff role.
func StaffCapabilities(role string) []string {
switch role {
case StaffRoleAdmin, StaffRoleDeveloper:
return []string{
"staff.admin_shell",
"staff.support.queue",
"staff.support.reply",
"staff.support.assign",
"staff.users.read",
"staff.users.write",
"staff.analytics",
"staff.billing",
"staff.plans_features",
"staff.feature_gates",
"staff.settings",
"staff.jobs_stuck",
"staff.impersonate",
"staff.dev_password",
}
case StaffRoleSupportStaff:
return []string{
"staff.admin_shell",
"staff.support.queue",
"staff.support.reply",
"staff.support.assign",
}
default:
return nil
}
}
// GetStaffAccess loads is_platform_admin + staff_role for an active user.
// Missing staff_role column (pre-migration) falls back to boolean-only admin.
func (s *Service) GetStaffAccess(ctx context.Context, userID uuid.UUID) (StaffAccess, error) {
if s == nil || s.Pool == nil {
return StaffAccess{}, errors.New("auth service unavailable")
}
var isAdmin bool
var staffRole *string
err := s.Pool.QueryRow(ctx, `
SELECT is_platform_admin, staff_role
FROM users
WHERE id = $1 AND is_active = true`, userID,
).Scan(&isAdmin, &staffRole)
if errors.Is(err, pgx.ErrNoRows) {
return StaffAccess{}, nil
}
if err != nil {
if isUndefinedColumn(err) {
ok, err2 := s.platformAdminFlag(ctx, userID)
if err2 != nil {
return StaffAccess{}, err2
}
return ResolveStaffAccess(ok, ""), nil
}
return StaffAccess{}, err
}
role := ""
if staffRole != nil {
role = *staffRole
}
return ResolveStaffAccess(isAdmin, role), nil
}
// platformAdminFlag reads users.is_platform_admin without staff_role resolution.
func (s *Service) platformAdminFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
var ok bool
err := s.Pool.QueryRow(ctx, `
SELECT is_platform_admin FROM users WHERE id = $1 AND is_active = true`, userID).Scan(&ok)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return ok, err
}
// StaffUser is a platform staff directory row.
type StaffUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name,omitempty"`
IsPlatformAdmin bool `json:"is_platform_admin"`
StaffRole *string `json:"staff_role,omitempty"`
ResolvedRole string `json:"resolved_role"`
IsActive bool `json:"is_active"`
}
// ListStaffUsers returns active users with any platform staff access.
func (s *Service) ListStaffUsers(ctx context.Context, limit, offset int) ([]StaffUser, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.Pool.Query(ctx, `
SELECT id, email, name, is_platform_admin, staff_role, is_active
FROM users
WHERE is_active = true
AND (is_platform_admin = true OR staff_role IS NOT NULL)
ORDER BY coalesce(staff_role, ''), email
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]StaffUser, 0)
for rows.Next() {
var u StaffUser
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
return nil, err
}
stored := ""
if u.StaffRole != nil {
stored = *u.StaffRole
}
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, stored)
out = append(out, u)
}
return out, rows.Err()
}
// SetStaffRole assigns or clears a platform staff role.
// Non-empty role sets is_platform_admin=true (contract invariant).
// Empty role clears staff_role and is_platform_admin.
func (s *Service) SetStaffRole(ctx context.Context, userID uuid.UUID, staffRole string) (StaffUser, error) {
role, err := NormalizeStaffRole(staffRole)
if err != nil {
return StaffUser{}, err
}
var (
u StaffUser
stored *string
)
if role == "" {
err = s.Pool.QueryRow(ctx, `
UPDATE users
SET staff_role = NULL, is_platform_admin = false, updated_at = now()
WHERE id = $1 AND is_active = true
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID,
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
} else {
err = s.Pool.QueryRow(ctx, `
UPDATE users
SET staff_role = $2, is_platform_admin = true, updated_at = now()
WHERE id = $1 AND is_active = true
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID, role,
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
}
if errors.Is(err, pgx.ErrNoRows) {
return StaffUser{}, ErrStaffUserNotFound
}
if err != nil {
return StaffUser{}, err
}
u.StaffRole = stored
storedRole := ""
if stored != nil {
storedRole = *stored
}
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, storedRole)
return u, nil
}
// IsAssignableSupportStaff reports whether userID may be set as a ticket assignee.
func (s *Service) IsAssignableSupportStaff(ctx context.Context, userID uuid.UUID) (bool, error) {
access, err := s.GetStaffAccess(ctx, userID)
if err != nil {
return false, err
}
return access.SupportDesk, nil
}
func isUndefinedColumn(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "42703"
}
return false
}
@@ -0,0 +1,92 @@
package auth
import (
"strings"
)
// StaffRoleFromPlatformAdmin maps the legacy boolean gate onto staff roles.
// Until a dedicated staff_role column exists: platform admin → admin.
func StaffRoleFromPlatformAdmin(isPlatformAdmin bool) string {
if isPlatformAdmin {
return StaffRoleAdmin
}
return ""
}
// supportStaffFeatureOff keys denied for support_staff (03-roles-matrix.json).
var supportStaffFeatureOff = map[string]struct{}{
"billing.checkout": {},
"billing.customer_portal": {},
"billing.quick_upgrade": {},
"capability.api_access": {},
"capability.brand_ai_apply": {},
"capability.byok": {},
"capability.campaign_ai": {},
"capability.email_live_send": {},
"capability.seo_ai_rewrite": {},
"catalog.structured_descriptions": {},
"catalog.vector_categories": {},
"dashboard.store_reconnect": {},
"integrations.ai": {},
"integrations.ai.byok": {},
"integrations.email": {},
"integrations.email.blast": {},
"integrations.email.test": {},
"marketing.brand_ai_apply": {},
"marketing.brand_kit": {},
"marketing.campaigns": {},
"marketing.campaigns.create": {},
"marketing.campaigns.generate_ai": {},
"marketing.campaigns.send": {},
"marketing.content_calendar": {},
"marketing.reviews": {},
"marketing.seo": {},
"marketing.seo.ai_rewrite": {},
"marketing.seo.template_fill": {},
"settings.api_keys": {},
"settings.team_invite": {},
"stores.hub": {},
"stores.shopify": {},
"stores.shopify.connection": {},
"stores.shopify.orders": {},
"stores.shopify.settings": {},
"stores.woocommerce": {},
"stores.woocommerce.attributes": {},
"stores.woocommerce.categories": {},
"stores.woocommerce.connection": {},
"stores.woocommerce.orders": {},
"stores.woocommerce.reviews": {},
"stores.woocommerce.settings": {},
}
// DefaultStaffRoleAllows reports the dashboard feature ceiling for a staff role
// when acting in a tenant context (compose with plan_allows at resolve time).
// admin / developer → all keys ON; support_staff → limited set; unknown → false.
func DefaultStaffRoleAllows(role string, featureKey string) bool {
featureKey = strings.TrimSpace(featureKey)
normalized, _ := NormalizeStaffRole(role)
switch normalized {
case StaffRoleAdmin, StaffRoleDeveloper:
return true
case StaffRoleSupportStaff:
_, denied := supportStaffFeatureOff[featureKey]
return !denied
default:
return false
}
}
// StaffRoleAllowsAdminRoute is the platform console ceiling (not feature keys).
// Per contract 04: support_staff → /admin/support only; admin|developer → all.
func StaffRoleAllowsAdminRoute(role string, route string) bool {
route = strings.ToLower(strings.TrimSpace(route))
normalized, _ := NormalizeStaffRole(role)
switch normalized {
case StaffRoleAdmin, StaffRoleDeveloper:
return true
case StaffRoleSupportStaff:
return strings.HasPrefix(route, "/admin/support")
default:
return false
}
}
@@ -0,0 +1,41 @@
package auth
import "testing"
func TestDefaultStaffRoleAllows(t *testing.T) {
t.Parallel()
if !DefaultStaffRoleAllows(StaffRoleAdmin, "billing.checkout") {
t.Fatal("admin allows all")
}
if !DefaultStaffRoleAllows(StaffRoleDeveloper, "catalog.vector_categories") {
t.Fatal("developer allows debug catalog")
}
if DefaultStaffRoleAllows(StaffRoleSupportStaff, "billing.checkout") {
t.Fatal("support_staff denies billing checkout")
}
if !DefaultStaffRoleAllows(StaffRoleSupportStaff, "support.center") {
t.Fatal("support_staff allows support.center")
}
if DefaultStaffRoleAllows("", "dashboard.overview") {
t.Fatal("unknown role denies")
}
}
func TestStaffRoleAllowsAdminRoute(t *testing.T) {
t.Parallel()
if !StaffRoleAllowsAdminRoute(StaffRoleAdmin, "/admin/billing") {
t.Fatal("admin billing")
}
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
t.Fatal("support_staff no billing")
}
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
t.Fatal("support_staff support queue")
}
if StaffRoleFromPlatformAdmin(true) != StaffRoleAdmin {
t.Fatal("platform admin maps to admin")
}
if StaffRoleFromPlatformAdmin(false) != "" {
t.Fatal("non-admin maps empty")
}
}
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"strings"
"testing"
)
func TestResolveStaffAccess(t *testing.T) {
t.Parallel()
cases := []struct {
name string
admin bool
role string
wantRole string
wantFull bool
wantDesk bool
wantOnly bool
}{
{name: "legacy_platform_admin", admin: true, role: "", wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
{name: "plain_user", admin: false, role: "", wantFull: false, wantDesk: false},
{name: "support_staff", admin: false, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
{name: "support_staff_with_admin_flag", admin: true, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
{name: "admin_role", admin: true, role: StaffRoleAdmin, wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
{name: "developer_role", admin: false, role: StaffRoleDeveloper, wantRole: StaffRoleDeveloper, wantFull: true, wantDesk: true},
{name: "unknown_role_ignored", admin: false, role: "superuser", wantFull: false, wantDesk: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ResolveStaffAccess(tc.admin, tc.role)
if got.Role != tc.wantRole {
t.Fatalf("role = %q, want %q", got.Role, tc.wantRole)
}
if got.FullAdmin != tc.wantFull || got.SupportDesk != tc.wantDesk || got.IsSupportOnly != tc.wantOnly {
t.Fatalf("got full=%v desk=%v only=%v want full=%v desk=%v only=%v",
got.FullAdmin, got.SupportDesk, got.IsSupportOnly, tc.wantFull, tc.wantDesk, tc.wantOnly)
}
})
}
}
func TestStaffCapabilities(t *testing.T) {
t.Parallel()
adminCaps := StaffCapabilities(StaffRoleAdmin)
if len(adminCaps) < 10 {
t.Fatalf("admin caps too small: %v", adminCaps)
}
supportCaps := StaffCapabilities(StaffRoleSupportStaff)
if len(supportCaps) != 4 {
t.Fatalf("support caps = %v", supportCaps)
}
for _, c := range supportCaps {
if strings.Contains(c, "billing") || strings.Contains(c, "settings") {
t.Fatalf("support must not get %s", c)
}
}
}
func TestStaffRoleAllowsAdminRouteContract(t *testing.T) {
t.Parallel()
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
t.Fatal("support_staff should access /admin/support")
}
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
t.Fatal("support_staff must not access billing")
}
if !StaffRoleAllowsAdminRoute(StaffRoleDeveloper, "/admin/settings") {
t.Fatal("developer should access settings")
}
}
func TestNormalizeStaffRole(t *testing.T) {
t.Parallel()
if _, err := NormalizeStaffRole("nope"); err == nil {
t.Fatal("expected error for invalid role")
}
got, err := NormalizeStaffRole(" Support_Staff ")
if err != nil || got != StaffRoleSupportStaff {
t.Fatalf("got %q err=%v", got, err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
var ErrTokenInvalid = errors.New("token invalid or expired")
// IssueSetPasswordToken creates a signed, time-limited token (no DB row).
// secret must come from env (TOKEN_SIGNING_SECRET); never commit secrets.
func IssueSetPasswordToken(secret string, userID uuid.UUID, ttl time.Duration) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", errors.New("token signing secret not configured")
}
if ttl <= 0 {
ttl = 72 * time.Hour
}
exp := time.Now().Add(ttl).Unix()
nonce, err := RandomToken(8)
if err != nil {
return "", err
}
payload := fmt.Sprintf("%s.%d.%s", userID.String(), exp, nonce)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
raw := payload + "." + sig
return base64.RawURLEncoding.EncodeToString([]byte(raw)), nil
}
func ParseSetPasswordToken(secret, token string) (uuid.UUID, error) {
if strings.TrimSpace(secret) == "" || strings.TrimSpace(token) == "" {
return uuid.Nil, ErrTokenInvalid
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return uuid.Nil, ErrTokenInvalid
}
parts := strings.Split(string(raw), ".")
if len(parts) != 4 {
return uuid.Nil, ErrTokenInvalid
}
userID, err := uuid.Parse(parts[0])
if err != nil {
return uuid.Nil, ErrTokenInvalid
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() > exp {
return uuid.Nil, ErrTokenInvalid
}
payload := parts[0] + "." + parts[1] + "." + parts[2]
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[3])) {
return uuid.Nil, ErrTokenInvalid
}
return userID, nil
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strconv"
"testing"
"time"
"github.com/google/uuid"
)
func TestIssueAndParseSetPasswordToken(t *testing.T) {
t.Parallel()
const secret = "test-signing-secret-not-for-prod"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
token, err := IssueSetPasswordToken(secret, uid, time.Hour)
if err != nil {
t.Fatalf("IssueSetPasswordToken: %v", err)
}
got, err := ParseSetPasswordToken(secret, token)
if err != nil {
t.Fatalf("ParseSetPasswordToken: %v", err)
}
if got != uid {
t.Fatalf("user id = %s, want %s", got, uid)
}
}
func TestParseSetPasswordTokenRejectsWrongSecret(t *testing.T) {
t.Parallel()
uid := uuid.New()
token, err := IssueSetPasswordToken("secret-a", uid, time.Hour)
if err != nil {
t.Fatalf("IssueSetPasswordToken: %v", err)
}
if _, err := ParseSetPasswordToken("secret-b", token); err == nil {
t.Fatal("expected invalid token for wrong secret")
}
}
func TestParseSetPasswordTokenRejectsExpired(t *testing.T) {
t.Parallel()
uid := uuid.New()
const secret = "secret"
// Build an already-expired signed token (IssueSetPasswordToken coerces ttl<=0 to 72h).
exp := time.Now().Add(-time.Hour).Unix()
nonce := "deadbeefdeadbeef"
payload := uid.String() + "." + strconv.FormatInt(exp, 10) + "." + nonce
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
token := base64.RawURLEncoding.EncodeToString([]byte(payload + "." + sig))
if _, err := ParseSetPasswordToken(secret, token); err == nil {
t.Fatal("expected expired token to fail")
}
}
func TestIssueSetPasswordTokenRequiresSecret(t *testing.T) {
t.Parallel()
if _, err := IssueSetPasswordToken("", uuid.New(), time.Hour); err == nil {
t.Fatal("expected error when secret is empty")
}
}
@@ -0,0 +1,43 @@
package billing
import "testing"
func TestCapabilitiesResponseETagStableAndSensitive(t *testing.T) {
t.Parallel()
base := Capabilities{
PlanID: 3,
PlanName: "Growth",
HasActivePlan: true,
FeatureETag: featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": false}),
Entitlements: Entitlements{RemainingCredits: 100},
}
a := CapabilitiesResponseETag(base)
b := CapabilitiesResponseETag(base)
if a == "" || a[0] != '"' || a[len(a)-1] != '"' {
t.Fatalf("etag must be quoted strong form, got %q", a)
}
if a != b {
t.Fatalf("etag unstable: %q vs %q", a, b)
}
creditChanged := base
creditChanged.Entitlements.RemainingCredits = 99
if CapabilitiesResponseETag(creditChanged) == a {
t.Fatal("etag must change when remaining credits change")
}
featChanged := base
featChanged.FeatureETag = featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": true})
if CapabilitiesResponseETag(featChanged) == a {
t.Fatal("etag must change when feature map changes")
}
}
func TestFeatureETagIgnoresDisabledKeys(t *testing.T) {
t.Parallel()
a := featureETag(map[string]bool{"a": true, "b": false})
b := featureETag(map[string]bool{"a": true})
if a != b {
t.Fatalf("disabled keys should not affect feature etag: %q vs %q", a, b)
}
}
@@ -0,0 +1,36 @@
package billing
import "errors"
var (
ErrPlanNameRequired = errors.New("name required")
ErrPlanNotFound = errors.New("plan not found")
ErrAmountRequired = errors.New("amount required")
ErrStripeNoCustomer = errors.New("no stripe customer for this company — complete a checkout first")
)
// ClientError reports whether err is a known client-facing billing/Stripe error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrPlanNameRequired),
errors.Is(err, ErrPlanNotFound),
errors.Is(err, ErrAmountRequired),
errors.Is(err, ErrStripeNotConfigured),
errors.Is(err, ErrStripePlanUnsupported),
errors.Is(err, ErrStripePriceMissing),
errors.Is(err, ErrStripeNoCustomer),
errors.Is(err, ErrInsufficientCredits),
errors.Is(err, ErrProductLimitExceeded),
errors.Is(err, ErrAIRequiresUpgrade),
errors.Is(err, ErrEPRELRequiresUpgrade),
errors.Is(err, ErrUnknownFeatureKey),
errors.Is(err, ErrUnknownFeatureSection),
errors.Is(err, ErrInvalidFeatureGates),
errors.Is(err, ErrFeatureDisabled):
return err.Error(), true
default:
return "", false
}
}
@@ -0,0 +1,200 @@
package billing
import (
"context"
"errors"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Concurrent ConsumeCredits on one company must serialize on credit_balances and
// never overspend the wallet.
func TestConsumeCreditsConcurrentNoOverspend(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-contention")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
// Paid plan keeps CanUseAI true at empty wallet so flat (0-token) debits still
// hit the atomic UPDATE and return ErrInsufficientCredits (not a Free no-op).
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "consume-contention-"+companyID.String()[:8], "integration", 100).Scan(&planID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
})
_, err = pg.Exec(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, now() - interval '1 day', now() + interval '30 days')`, companyID, planID)
if err != nil {
t.Fatal(err)
}
const wallet = 20
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, 0, now())`, companyID, wallet)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
_ = svc.EnsureDefaultCosts(ctx)
const workers = 40
var wg sync.WaitGroup
var okCount atomic.Int64
var insuff atomic.Int64
startGate := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startGate
err := svc.ConsumeCredits(ctx, companyID, 0, "product_processing")
if err == nil {
okCount.Add(1)
return
}
if errors.Is(err, ErrInsufficientCredits) {
insuff.Add(1)
return
}
t.Errorf("unexpected: %v", err)
}()
}
close(startGate)
wg.Wait()
if okCount.Load() != wallet {
t.Fatalf("ok=%d want %d (insuff=%d)", okCount.Load(), wallet, insuff.Load())
}
if okCount.Load()+insuff.Load() != workers {
t.Fatalf("ok+insuff=%d want %d", okCount.Load()+insuff.Load(), workers)
}
var used, total int
err = pg.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if err != nil {
t.Fatal(err)
}
if used != wallet || total != wallet {
t.Fatalf("wallet total=%d used=%d want total=%d used=%d", total, used, wallet, wallet)
}
var cycleUsed, products int
err = pg.QueryRow(ctx, `
SELECT credits_used, products_processed FROM billing_cycles
WHERE company_id = $1 AND end_date > now()
ORDER BY start_date DESC LIMIT 1`, companyID).Scan(&cycleUsed, &products)
if err != nil {
t.Fatal(err)
}
if cycleUsed != wallet || products != wallet {
t.Fatalf("cycle used=%d products=%d want %d", cycleUsed, products, wallet)
}
}
func TestConsumeCreditsBatchMatchesSummedBase(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-batch")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 100, 0, now())`, companyID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
_ = svc.EnsureDefaultCosts(ctx)
// 5 products, 0 tokens → DebitAmountN = 5 base credits, products_processed += 5.
if err := svc.ConsumeCreditsBatch(ctx, companyID, 0, 5, "product_processing"); err != nil {
t.Fatal(err)
}
var used, products int
err = pg.QueryRow(ctx, `
SELECT cb.used_credits, bc.products_processed
FROM credit_balances cb
JOIN billing_cycles bc ON bc.company_id = cb.company_id AND bc.end_date > now()
WHERE cb.company_id = $1
ORDER BY bc.start_date DESC LIMIT 1`, companyID).Scan(&used, &products)
if err != nil {
t.Fatal(err)
}
if used != 5 || products != 5 {
t.Fatalf("used=%d products=%d want 5/5", used, products)
}
}
+47
View File
@@ -0,0 +1,47 @@
package billing
import "testing"
func TestTokenPackMath(t *testing.T) {
// Mirrors DebitAmount pack math: ceil(tokens/1000).
cases := []struct {
tokens int
packs int
}{
{0, 0},
{1, 1},
{1000, 1},
{1001, 2},
{2500, 3},
}
for _, c := range cases {
packs := 0
if c.tokens > 0 {
packs = (c.tokens + 999) / 1000
}
got := DebitAmount(1, 1, c.tokens)
want := 1 + packs
if got != want {
t.Fatalf("tokens=%d DebitAmount=%d want %d (packs=%d)", c.tokens, got, want, packs)
}
}
}
func TestEstimateDebitMath(t *testing.T) {
// Pure pack math aligned with EstimateDebit / ConsumeCredits (costs=1).
cases := []struct {
featureTokens int
want int
}{
{0, 1}, // base feature cost only
{1, 2},
{1000, 2},
{1001, 3},
}
for _, c := range cases {
got := DebitAmount(1, 1, c.featureTokens)
if got != c.want {
t.Fatalf("tokens=%d debit=%d want %d", c.featureTokens, got, c.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package billing
import "strings"
// CreditsPerAIProduct is the typical wallet debit for one AI enhance
// (product_processing base + one openai_token_k pack when tokens ≤ 1000).
// Each content-language pass burns another ~CreditsPerAIProduct per product.
// Included monthly grants assume AssumedPrimaryContentLanguages only.
const CreditsPerAIProduct = 2
// AssumedPrimaryContentLanguages is how many content languages the included
// monthly grant is sized for. Extra languages → credit packs or BYOK.
const AssumedPrimaryContentLanguages = 1
// ScaleMaxProducts is the top self-serve SKU ceiling. Huge catalogs (1M+)
// belong on Enterprise (sales-led credits / BYOK), not public Scale.
const ScaleMaxProducts = 12_000
// PlanAICoverPercent is included monthly AI as a share of CreditSKUBase
// (primary language only). Paid public plans use ~50% cover so Starter stays
// lean (100 credits ≈ 50 AI products on a 100-SKU plan) and higher tiers
// scale with CreditSKUBase ≤ PlanMaxProducts — not a full-catalog AI bundle.
// Formula: credits = (CreditSKUBase × cover% / 100) × CreditsPerAIProduct.
func PlanAICoverPercent(planName string) int {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "starter":
return 50 // 100 × 50% × 2 = 100
case "plus":
return 50 // 400 × 50% × 2 = 400
case "growth":
return 50 // 1,200 × 50% × 2 = 1,200
case "business":
return 50 // 4,000 × 50% × 2 = 4,000
case "scale":
return 50 // 12,000 × 50% × 2 = 12,000
case "enterprise":
return 50 // display ladder only; grant is EnterpriseUnlimitedCredits
default:
return 0
}
}
// PlanMaxProducts is the hard SKU ceiling for a public plan name.
// Slow retail ladder for small→mid shops; Scale reaches ScaleMaxProducts;
// Enterprise is unlimited (nil). Credits sized via CreditSKUBase (≤ MaxProducts).
func PlanMaxProducts(planName string) *int {
mp := func(n int) *int { return &n }
switch strings.ToLower(strings.TrimSpace(planName)) {
case "free":
return mp(50)
case "starter":
return mp(100)
case "plus":
return mp(400)
case "growth":
return mp(1_200)
case "business":
return mp(4_000)
case "scale":
return mp(ScaleMaxProducts)
default:
// Enterprise and unknown custom plans — unlimited SKU cap.
return nil
}
}
// CreditSKUBase is the catalog size used ONLY to size included monthly AI credits.
// May be smaller than PlanMaxProducts so large catalogs still get a bounded AI starter grant.
// Public paid ladder: base equals PlanMaxProducts (50% cover → half-catalog primary-lang AI).
func CreditSKUBase(planName string) int {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "starter":
return 100
case "plus":
return 400
case "growth":
return 1_200
case "business":
return 4_000
case "scale":
return 12_000
default:
return 0
}
}
// MonthlyCreditsForSKUCover returns credits for coverPct% of skuBase at CreditsPerAIProduct each.
func MonthlyCreditsForSKUCover(skuBase, coverPct int) int {
if skuBase <= 0 || coverPct <= 0 {
return 0
}
if coverPct > 100 {
coverPct = 100
}
products := (skuBase * coverPct) / 100
return products * CreditsPerAIProduct * AssumedPrimaryContentLanguages
}
// MonthlyCreditsForPlan sizes the monthly grant from CreditSKUBase × PlanAICoverPercent.
// The maxProducts argument is ignored when CreditSKUBase is set (paid public ladder).
func MonthlyCreditsForPlan(planName string, maxProducts int) int {
base := CreditSKUBase(planName)
if base <= 0 {
base = maxProducts
}
return MonthlyCreditsForSKUCover(base, PlanAICoverPercent(planName))
}
// CreditPack is a one-time AI credit top-up sold via Stripe Checkout (mode=payment).
// These are additional Stripe Products with one-time Prices — not subscription add-ons.
type CreditPack struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Credits int `json:"credits"`
PriceUSD int `json:"price_usd"` // whole dollars for marketing UI
// Approx product×language passes at CreditsPerAIProduct.
AIProducts int `json:"ai_products"`
}
func packFromCredits(id, name, desc string, credits, priceUSD int) CreditPack {
return CreditPack{
ID: id,
Name: name,
Description: desc,
Credits: credits,
PriceUSD: priceUSD,
AIProducts: credits / CreditsPerAIProduct,
}
}
// DefaultCreditPacks is the public top-up ladder (credits-first sizing).
// Prices are balanced so small packs are not punitive $/credit vs larger ones,
// while staying expensive enough that packs cannot undercut plan upgrades or A1 (~€300).
func DefaultCreditPacks() []CreditPack {
return []CreditPack{
packFromCredits("tiny", "Nano pack", "Smoke tests / tiny fixes (25 credits ≈ 12 AI products)", 25, 29),
packFromCredits("small", "Starter pack", "Small top-up (65 credits ≈ 32 AI products)", 65, 59),
packFromCredits("medium", "Plus pack", "Burst top-up (200 credits ≈ 100 AI products)", 200, 149),
packFromCredits("large", "Growth pack", "Mid buffer (500 credits ≈ 250 AI products)", 500, 299),
packFromCredits("xl", "Catalog pack", "Catalog / re-run buffer (1,200 credits ≈ 600 AI products)", 1200, 599),
packFromCredits("xxl", "Business pack", "Large multi-language buffer (3,000 credits ≈ 1,500 AI products)", 3000, 1299),
packFromCredits("mega", "Scale pack", "Distributor / agency burst (8,000 credits ≈ 4,000 AI products)", 8000, 2999),
}
}
// CreditPackByID returns a pack from DefaultCreditPacks.
func CreditPackByID(id string) (CreditPack, bool) {
want := strings.ToLower(strings.TrimSpace(id))
for _, p := range DefaultCreditPacks() {
if p.ID == want {
return p, true
}
}
return CreditPack{}, false
}
// CreditPackPriceKey is the Stripe PriceIDs map key for a one-time pack (pack:<id>).
func CreditPackPriceKey(packID string) string {
return "pack:" + strings.ToLower(strings.TrimSpace(packID))
}
// CreditPackSettingsKey is the platformsettings Values key (stripe.price.pack.<id>).
func CreditPackSettingsKey(packID string) string {
return "stripe.price.pack." + strings.ToLower(strings.TrimSpace(packID))
}
// CreditPackEnvVar is the optional process-env fallback (STRIPE_PRICE_PACK_<ID>).
func CreditPackEnvVar(packID string) string {
return "STRIPE_PRICE_PACK_" + strings.ToUpper(strings.TrimSpace(packID))
}
@@ -0,0 +1,122 @@
package billing
import (
"errors"
"testing"
)
func TestRemainingCreditsClamped(t *testing.T) {
cases := []struct {
total, used, want int
}{
{100, 40, 60},
{100, 100, 0},
{100, 150, 0}, // used > total must not report negative
{0, 0, 0},
{0, 5, 0},
}
for _, c := range cases {
got := RemainingCreditsClamped(c.total, c.used)
if got != c.want {
t.Fatalf("total=%d used=%d got=%d want=%d", c.total, c.used, got, c.want)
}
}
}
func TestApplyCreditDeltaPreventsNegativeRemaining(t *testing.T) {
cases := []struct {
total, used, amount, want int
}{
{100, 20, 50, 150},
{100, 80, -50, 80}, // clawback stops at used
{100, 80, -200, 80},
{10, 0, -5, 5},
{10, 0, -20, 0},
{0, 0, 25, 25},
}
for _, c := range cases {
got := ApplyCreditDelta(c.total, c.used, c.amount)
if got != c.want {
t.Fatalf("total=%d used=%d amount=%d got=%d want=%d", c.total, c.used, c.amount, got, c.want)
}
if got < c.used {
t.Fatalf("total after delta below used: got=%d used=%d", got, c.used)
}
}
}
func TestComputeEntitlementsClampsNegativeRemaining(t *testing.T) {
ent := ComputeEntitlements("Free", 0, -10, false)
if ent.RemainingCredits != 0 {
t.Fatalf("remaining=%d want 0", ent.RemainingCredits)
}
if ent.CanUseAI {
t.Fatal("negative remaining on Free must not unlock AI")
}
}
func TestConsumeCreditsContractErrors(t *testing.T) {
// Document sentinel used by processOne / ProcessJob abort path.
if !errors.Is(ErrInsufficientCredits, ErrInsufficientCredits) {
t.Fatal("sentinel self-match")
}
wrapped := errors.New("x")
if errors.Is(wrapped, ErrInsufficientCredits) {
t.Fatal("unrelated error must not match")
}
}
func TestDebitFloorAndNegativeTokens(t *testing.T) {
if got := DebitAmount(1, 1, -5); got != 1 {
t.Fatalf("DebitAmount negative tokens=%d want 1", got)
}
}
func TestDebitAmount(t *testing.T) {
cases := []struct {
feature, tokenK, tokens, want int
}{
{1, 1, 0, 1},
{1, 1, 1, 2},
{1, 1, 1000, 2},
{1, 1, 1001, 3},
{2, 3, 2500, 2 + 3*3}, // base 2 + 3 packs * 3
{0, 0, 0, 1}, // floors
}
for _, c := range cases {
got := DebitAmount(c.feature, c.tokenK, c.tokens)
if got != c.want {
t.Fatalf("DebitAmount(%d,%d,%d)=%d want %d", c.feature, c.tokenK, c.tokens, got, c.want)
}
}
}
func TestDebitAmountNVsPerProduct(t *testing.T) {
// Exact parity when each item's tokens don't leave partial packs that merge.
sum := DebitAmount(1, 1, 1000) + DebitAmount(1, 1, 1000)
batchedExact := DebitAmountN(1, 1, 2000, 2)
if sum != batchedExact {
t.Fatalf("aligned packs: sum=%d batch=%d", sum, batchedExact)
}
// Combined packs can undercharge vs per-item ceil.
perItem := DebitAmount(1, 1, 500) + DebitAmount(1, 1, 500) // 2+2=4
batched := DebitAmountN(1, 1, 1000, 2) // 2*1 + 1 = 3
if perItem <= batched {
t.Fatalf("expected batch undercharge: perItem=%d batched=%d", perItem, batched)
}
}
func TestConsumeCreditsSkipsEntitlementsOnAITokens(t *testing.T) {
// Document hot-path contract: tokenCount > 0 skips EntitlementsForCompany.
// Flat (0-token) Free-plan burn still gates via !CanUseAI.
tokenCount := 1200
needEntitlements := tokenCount == 0
if needEntitlements {
t.Fatal("AI token debit must not require entitlements preflight")
}
tokenCount = 0
if !(tokenCount == 0) {
t.Fatal("flat debit still gates entitlements")
}
}
@@ -0,0 +1,130 @@
package billing
import (
"context"
"strings"
"github.com/google/uuid"
)
// IsCustomPackage reports whether a plan should get the "custom deal" feature
// treatment (all dashboard features ON by default for non-A1 deals).
//
// Product semantics (see IsPublicProductPlan + plans.is_custom):
// - Client / admin deals with is_custom=true → custom (including A1 PAYG)
// - Public Enterprise (and any row with is_custom=true) → custom
// - Exact "Legacy" plan name → never enable-all (restricted migrated matrix)
// - Free / Starter / Growth / Business with is_custom=false → not custom
//
// is_custom wins over A1* name patterns for PAYG billing / PlanProfileCustom,
// but A1* custom deals use A1PaygPlanFeatures (not literal enable-all) so
// Stores, Marketing, and Integrations stay off.
func IsCustomPackage(name string, isCustom bool) bool {
if strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
return false
}
if isCustom {
return true
}
// Legacy-named plans without is_custom stay on the limited matrix.
if IsLegacyPlanName(name) {
return false
}
return !IsPublicProductPlan(name)
}
// A1PaygFeatureDenied reports keys that stay OFF on A1 PAYG custom deals.
// Nav: Stores → stores.*; Marketing → marketing.*; Integrations → integrations.*.
// Cutover honesty chrome (ETL gaps / reconnect / migrated checklist) stays OFF — A1 is not a
// hypercare merchant surface (see MigratedEtlGapsPanel + IsA1CohortCompany product rules).
func A1PaygFeatureDenied(key string) bool {
switch key {
case "dashboard.etl_gaps", "dashboard.store_reconnect", "dashboard.migrated_checklist":
return true
}
return strings.HasPrefix(key, "stores.") ||
strings.HasPrefix(key, "marketing.") ||
strings.HasPrefix(key, "integrations.")
}
// A1PaygPlanFeatures is the dump-faithful A1 PAYG matrix: custom enable-all
// minus Stores, Marketing, and Integrations sections.
func A1PaygPlanFeatures() map[string]bool {
out := AllRegistryFeatures(true)
for k := range out {
if A1PaygFeatureDenied(k) {
out[k] = false
}
}
return out
}
// SparseA1PaygOverrides returns explicit false overrides for A1 PAYG denied keys.
func SparseA1PaygOverrides() map[string]bool {
out := make(map[string]bool)
for _, k := range FeatureCatalogKeys {
if A1PaygFeatureDenied(k) {
out[k] = false
}
}
return out
}
// AllRegistryFeatures returns every FeatureCatalogKeys entry set to enabled.
func AllRegistryFeatures(enabled bool) map[string]bool {
out := make(map[string]bool, len(FeatureCatalogKeys))
for _, k := range FeatureCatalogKeys {
out[k] = enabled
}
return out
}
// EnableSectionForAllPlans turns a section master switch ON for every plan
// (global gate; missing rows already default ON).
func (s *Service) EnableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
return s.SetSectionGate(ctx, section, true, updatedBy)
}
// DisableSectionForAllPlans turns a section master switch OFF for every plan.
func (s *Service) DisableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
return s.SetSectionGate(ctx, section, false, updatedBy)
}
// prepareCustomPackageCreateFeatures applies create-time defaults for custom packages:
// when the caller omitted features, materialize enable-all overrides so admin UIs
// show an explicit all-ON matrix (resolve already treats empty+is_custom as all ON).
func prepareCustomPackageCreateFeatures(p *Plan, creating, featuresProvided bool) {
if !creating {
return
}
if IsLegacyPlan(p.Name, p.IsLegacy) && !IsCustomPackage(p.Name, p.IsCustom) {
p.IsLegacy = true
if strings.EqualFold(strings.TrimSpace(p.Name), LegacyPlanName) {
p.IsCustom = false
} else if !IsPublicProductPlan(p.Name) {
p.IsCustom = true
}
if !featuresProvided {
p.Features = SparseLegacyOverrides()
}
return
}
// Non-public ladder names are client deals — keep is_custom aligned.
if !IsPublicProductPlan(p.Name) {
p.IsCustom = true
}
// A1 PAYG / custom deals are never the restricted Legacy matrix.
if IsCustomPackage(p.Name, p.IsCustom) {
p.IsLegacy = false
}
if featuresProvided {
return
}
if IsCustomPackage(p.Name, p.IsCustom) {
if IsLegacyPlanName(p.Name) {
p.Features = A1PaygPlanFeatures()
return
}
p.Features = AllRegistryFeatures(true)
}
}
@@ -0,0 +1,188 @@
package billing
import "testing"
func TestIsCustomPackage(t *testing.T) {
cases := []struct {
name string
isCustom bool
want bool
}{
{"Free", false, false},
{"Starter", false, false},
{"Growth", false, false},
{"Business", false, false},
{"Enterprise", true, true},
{"Enterprise", false, false}, // public ladder without is_custom flag
{"A1", false, false}, // legacy name without is_custom → limited matrix
{"A1", true, true}, // dump-faithful A1 PAYG is_custom → custom profile (Stores/AI still gated)
{"Legacy", true, false}, // exact Legacy package never enable-all
{"Merkur trial", false, true},
{" growth ", false, false},
{"", false, true}, // empty name is not a public plan name
}
for _, tc := range cases {
got := IsCustomPackage(tc.name, tc.isCustom)
if got != tc.want {
t.Fatalf("IsCustomPackage(%q, %v)=%v want %v", tc.name, tc.isCustom, got, tc.want)
}
}
}
func TestAllRegistryFeatures(t *testing.T) {
on := AllRegistryFeatures(true)
off := AllRegistryFeatures(false)
if len(on) != len(FeatureCatalogKeys) || len(off) != len(FeatureCatalogKeys) {
t.Fatalf("len on=%d off=%d catalog=%d", len(on), len(off), len(FeatureCatalogKeys))
}
for _, k := range FeatureCatalogKeys {
if !on[k] {
t.Fatalf("expected %s enabled", k)
}
if off[k] {
t.Fatalf("expected %s disabled", k)
}
}
}
func TestPrepareCustomPackageCreateFeatures(t *testing.T) {
t.Run("custom create without features enables all", func(t *testing.T) {
p := Plan{Name: "ClientCo Deal", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.Features == nil || len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected full enable-all features, got %#v", p.Features)
}
for _, k := range FeatureCatalogKeys {
if !p.Features[k] {
t.Fatalf("key %s not enabled", k)
}
}
})
t.Run("non-public name forces is_custom", func(t *testing.T) {
p := Plan{Name: "Merkur", IsCustom: false}
prepareCustomPackageCreateFeatures(&p, true, false)
if !p.IsCustom {
t.Fatal("expected is_custom forced true for client deal")
}
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected enable-all after force custom, got %d keys", len(p.Features))
}
})
t.Run("public free create leaves features nil", func(t *testing.T) {
p := Plan{Name: "Free", IsCustom: false}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.Features != nil {
t.Fatalf("standard Free must not materialize features: %#v", p.Features)
}
})
t.Run("explicit features respected", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true, Features: map[string]bool{"catalog.products": false}}
prepareCustomPackageCreateFeatures(&p, true, true)
if p.Features["catalog.products"] != false || len(p.Features) != 1 {
t.Fatalf("explicit features overwritten: %#v", p.Features)
}
})
t.Run("update does not rewrite", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true, ID: 9}
prepareCustomPackageCreateFeatures(&p, false, false)
if p.Features != nil {
t.Fatalf("update must not inject features: %#v", p.Features)
}
})
t.Run("enterprise is_custom create enables all", func(t *testing.T) {
p := Plan{Name: "Enterprise", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("enterprise custom create should enable all, got %d", len(p.Features))
}
})
t.Run("A1 custom create uses PAYG matrix without Stores/Marketing/Integrations", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.IsLegacy {
t.Fatal("A1 PAYG create must clear is_legacy")
}
if !p.IsCustom {
t.Fatal("A1 remains a client deal (is_custom)")
}
if !p.Features["processing.monitor"] {
t.Fatal("A1 PAYG must enable processing.monitor")
}
if !p.Features["capability.eprel"] {
t.Fatal("A1 PAYG must enable capability.eprel")
}
if p.Features["stores.hub"] || p.Features["marketing.campaigns"] || p.Features["integrations.ai"] || p.Features["integrations.email"] {
t.Fatal("A1 PAYG must deny stores, marketing, and integrations")
}
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected full tailored matrix, got %d keys", len(p.Features))
}
})
t.Run("Legacy create seeds legacy sparse", func(t *testing.T) {
p := Plan{Name: "Legacy", IsCustom: false, IsLegacy: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if !p.IsLegacy {
t.Fatal("Legacy create must set is_legacy")
}
if p.Features["processing.monitor"] {
t.Fatal("Legacy must not enable processing.monitor")
}
})
}
func TestDefaultPlanFeaturesCustomUsesIsCustomPackage(t *testing.T) {
// Non-legacy client deal without is_custom flag still all-ON via name.
m := DefaultPlanFeatures("ClientCo Deal", false)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("client deal default missing %s", k)
}
}
// A1 without is_custom stays legacy — limited matrix.
a1 := DefaultPlanFeatures("A1", false)
if a1["processing.monitor"] {
t.Fatal("legacy A1 (is_custom=false) must keep processing.monitor off")
}
a1Payg := DefaultPlanFeatures("A1", true)
if !a1Payg["processing.monitor"] || !a1Payg["capability.eprel"] {
t.Fatal("A1 PAYG is_custom must enable core PAYG features")
}
if a1Payg["stores.hub"] || a1Payg["stores.shopify"] || a1Payg["marketing.campaigns"] || a1Payg["integrations.ai"] || a1Payg["integrations.ai.byok"] || a1Payg["integrations.email"] {
t.Fatal("A1 PAYG must keep Stores, Marketing, and Integrations off")
}
free := DefaultPlanFeatures("Free", false)
if free["capability.ai_processing"] {
t.Fatal("Free should keep AI processing off by default")
}
}
func TestPlanAllowsFeatureCustomByName(t *testing.T) {
if !PlanAllowsFeature("Merkur trial", false, nil, "capability.byok") {
t.Fatal("non-public package should allow all keys when overrides empty")
}
if PlanAllowsFeature("Free", false, nil, "capability.byok") {
t.Fatal("Free should deny byok by default")
}
}
func TestResolveEffectiveFeaturesSectionGate(t *testing.T) {
gates := emptyGatesView()
gates.Sections["marketing"] = false
features, sections, disabled := ResolveEffectiveFeatures("Merkur", true, nil, gates)
if sections["marketing"] {
t.Fatal("marketing section should be off")
}
if features["marketing.campaigns"] {
t.Fatal("marketing.campaigns should be effective-false when section off")
}
found := false
for _, d := range disabled {
if d == "marketing.campaigns" {
found = true
break
}
}
if !found {
t.Fatal("marketing.campaigns should appear in disabled list")
}
}
@@ -0,0 +1,245 @@
package billing
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Concurrent claimAndRollDueCompanyPlan must roll a due company_plan exactly once.
func TestClaimAndRollDueCompanyPlanConcurrent(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-claim-test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "claim-test-plan-"+companyID.String()[:8], "integration", 100).Scan(&planID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
})
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
nextBill := time.Now().UTC().Add(-time.Hour)
var rowID int64
err = pg.QueryRow(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, $3, $4)
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 100, 17, now())`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
const workers = 8
var wg sync.WaitGroup
errs := make(chan error, workers)
oks := make(chan bool, workers)
startGate := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startGate
ok, runErr := svc.claimAndRollDueCompanyPlan(ctx, rowID)
if runErr != nil {
errs <- runErr
return
}
oks <- ok
}()
}
close(startGate)
wg.Wait()
close(errs)
close(oks)
for err := range errs {
t.Fatalf("claimAndRollDueCompanyPlan: %v", err)
}
successes := 0
for ok := range oks {
if ok {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly 1 successful claim, got %d", successes)
}
var cycleCount int
err = pg.QueryRow(ctx, `SELECT COUNT(*) FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&cycleCount)
if err != nil {
t.Fatal(err)
}
if cycleCount != 1 {
t.Fatalf("billing_cycles rows=%d want 1", cycleCount)
}
var creditsUsed int
err = pg.QueryRow(ctx, `
SELECT credits_used FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&creditsUsed)
if err != nil {
t.Fatal(err)
}
if creditsUsed != 17 {
t.Fatalf("credits_used=%d want 17", creditsUsed)
}
var stillDue bool
err = pg.QueryRow(ctx, `
SELECT next_billing_date <= now()
FROM company_plans WHERE id = $1`, rowID).Scan(&stillDue)
if err != nil {
t.Fatal(err)
}
if stillDue {
t.Fatal("company_plans still due after roll")
}
var total, used int
err = pg.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total, &used)
if err != nil {
t.Fatal(err)
}
if total != 100 || used != 0 {
t.Fatalf("credit_balances total=%d used=%d want 100/0", total, used)
}
}
func TestRunDueBillingCyclesBestEffortMultiCompany(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
type fixture struct {
companyID uuid.UUID
planID int64
rowID int64
}
var fixtures []fixture
for i := 0; i < 2; i++ {
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-multi-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
cid := companyID
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, cid)
})
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "multi-plan-"+companyID.String()[:8], "integration", 80).Scan(&planID)
if err != nil {
t.Fatal(err)
}
pid := planID
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, pid)
})
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
nextBill := time.Now().UTC().Add(-time.Hour)
var rowID int64
err = pg.QueryRow(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, $3, $4)
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 80, 3, now())`, companyID)
if err != nil {
t.Fatal(err)
}
fixtures = append(fixtures, fixture{companyID: companyID, planID: planID, rowID: rowID})
}
svc := &Service{Pool: pg}
res, runErr := svc.RunDueBillingCycles(ctx)
if runErr != nil {
t.Fatalf("RunDueBillingCycles: %v", runErr)
}
if res.Processed < 2 {
t.Fatalf("processed=%d want >=2 (got failed=%d)", res.Processed, res.Failed)
}
if res.Failed != 0 {
t.Fatalf("failed=%d want 0", res.Failed)
}
for _, f := range fixtures {
var stillDue bool
err = pg.QueryRow(ctx, `
SELECT next_billing_date <= now()
FROM company_plans WHERE id = $1`, f.rowID).Scan(&stillDue)
if err != nil {
t.Fatal(err)
}
if stillDue {
t.Fatalf("company_plan %d still due after multi-company run", f.rowID)
}
}
}
@@ -0,0 +1,99 @@
package billing
import (
"errors"
"strings"
"testing"
)
func TestRecordDueCycleAttempt(t *testing.T) {
permanent := errors.New("insert failed")
cases := []struct {
name string
ok bool
err error
wantProcessed int
wantFailed int
wantErrSubstr string
wantWrapped error
}{
{
name: "success",
ok: true,
wantProcessed: 1,
},
{
name: "skipped claim is neither processed nor failed",
ok: false,
},
{
name: "permanent failure increments failed and wraps",
err: permanent,
wantFailed: 1,
wantErrSubstr: "company_plan 42:",
wantWrapped: permanent,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var res DueBillingCyclesResult
gotErr := recordDueCycleAttempt(&res, 42, tc.ok, tc.err)
if res.Processed != tc.wantProcessed || res.Failed != tc.wantFailed {
t.Fatalf("processed=%d failed=%d want processed=%d failed=%d",
res.Processed, res.Failed, tc.wantProcessed, tc.wantFailed)
}
if tc.wantErrSubstr == "" {
if gotErr != nil {
t.Fatalf("unexpected err: %v", gotErr)
}
return
}
if gotErr == nil {
t.Fatal("expected error")
}
if !strings.Contains(gotErr.Error(), tc.wantErrSubstr) {
t.Fatalf("err=%q missing %q", gotErr.Error(), tc.wantErrSubstr)
}
if tc.wantWrapped != nil && !errors.Is(gotErr, tc.wantWrapped) {
t.Fatalf("errors.Is(%v, %v)=false", gotErr, tc.wantWrapped)
}
})
}
}
func TestRecordDueCycleAttemptBestEffortAggregation(t *testing.T) {
var res DueBillingCyclesResult
var errs []error
for _, attempt := range []struct {
rowID int64
ok bool
err error
}{
{1, true, nil},
{2, false, errors.New("update failed")},
{3, false, nil},
{4, true, nil},
{5, false, errors.New("commit failed")},
} {
if attemptErr := recordDueCycleAttempt(&res, attempt.rowID, attempt.ok, attempt.err); attemptErr != nil {
errs = append(errs, attemptErr)
}
}
if res.Processed != 2 || res.Failed != 2 {
t.Fatalf("processed=%d failed=%d want 2/2", res.Processed, res.Failed)
}
joined := errors.Join(errs...)
if joined == nil {
t.Fatal("expected aggregated error")
}
msg := joined.Error()
for _, want := range []string{"company_plan 2:", "company_plan 5:", "update failed", "commit failed"} {
if !strings.Contains(msg, want) {
t.Fatalf("aggregated err %q missing %q", msg, want)
}
}
}
@@ -0,0 +1,221 @@
package billing
import (
"context"
"strings"
)
// EnsureDefaultFeatureSeeds idempotently seeds global section master switches
// (marketing + integrations forced OFF; other sections default ON). Plan feature
// overrides stay sparse: empty '{}' means unset and DefaultPlanFeatures /
// is_custom apply at resolve time.
//
// ASSUMPTION: There is no plans.features_customized flag. A non-empty
// plans.features JSON object means an admin customized the package - this
// seeder never overwrites it (except legacy-flagged plans — see
// EnsureLegacyPlanFeatureSeeds). Empty '{}' means unset.
// Custom / Enterprise (is_custom=true, non-legacy-name) resolve to all features ON.
// A1* with is_custom resolve to A1PaygPlanFeatures (Stores/Marketing/Integrations off).
// Legacy (A1 without is_custom / is_legacy) resolve to the image-nav matrix; empty rows are backfilled.
func (s *Service) EnsureDefaultFeatureSeeds(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
if err := s.seedGlobalSectionGates(ctx); err != nil {
return err
}
return s.EnsureLegacyDefaults(ctx)
}
// EnsureLegacyPlanFeatureSeeds idempotently applies the legacy sparse matrix to
// plans that are legacy by name or is_legacy flag.
//
// Rules:
// - empty features → write SparseLegacyOverrides + mark is_legacy when column exists
// - is_legacy=true → re-apply SparseLegacyOverrides (flagged cohort)
// - non-empty customized (not enable-all) and not flagged → leave alone
func (s *Service) EnsureLegacyPlanFeatureSeeds(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) {
return s.ensureLegacyPlanFeatureSeedsWithoutFlag(ctx)
}
return err
}
defer rows.Close()
type row struct {
id int64
name string
isCustom bool
isLegacy bool
raw []byte
}
var list []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name, &r.isCustom, &r.isLegacy, &r.raw); err != nil {
return err
}
list = append(list, r)
}
if err := rows.Err(); err != nil {
return err
}
sparse := SparseLegacyOverrides()
for _, r := range list {
// Custom / PAYG deals (incl. A1 with is_custom) keep their own matrix —
// never overwrite with legacy-sparse. A1 PAYG hygiene lives in ensureA1PaygPlanSemantics.
if IsCustomPackage(r.name, r.isCustom) {
continue
}
if !IsLegacyPlan(r.name, r.isLegacy) {
continue
}
overrides, derr := decodeFeaturesJSON(r.raw)
if derr != nil {
return derr
}
shouldWrite := r.isLegacy || featuresMapEmpty(overrides)
if !shouldWrite {
continue
}
if _, err := s.SetPlanFeatures(ctx, r.id, sparse); err != nil {
return err
}
if _, err := s.Pool.Exec(ctx, `
UPDATE plans SET is_legacy = true, updated_at = now() WHERE id = $1 AND is_legacy = false`, r.id); err != nil {
if isUndefinedColumn(err) {
continue
}
return err
}
}
return nil
}
func (s *Service) ensureLegacyPlanFeatureSeedsWithoutFlag(ctx context.Context) error {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, is_custom, COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) || isUndefinedRelation(err) {
return nil
}
return err
}
defer rows.Close()
sparse := SparseLegacyOverrides()
for rows.Next() {
var id int64
var name string
var isCustom bool
var raw []byte
if err := rows.Scan(&id, &name, &isCustom, &raw); err != nil {
return err
}
if IsCustomPackage(name, isCustom) {
continue
}
if !IsLegacyPlanName(name) {
continue
}
overrides, derr := decodeFeaturesJSON(raw)
if derr != nil {
return derr
}
if !featuresMapEmpty(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, sparse); err != nil {
return err
}
}
return rows.Err()
}
func (s *Service) seedGlobalSectionGates(ctx context.Context) error {
for _, section := range FeatureSections {
_, err := s.Pool.Exec(ctx, `
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
VALUES ($1, 'section', true, now())
ON CONFLICT (gate_key) DO NOTHING`, section)
if err != nil {
if isUndefinedRelation(err) {
return nil
}
return err
}
}
// Work-mode defaults: keep Marketing + Integrations off platform-wide.
// Upsert so restarts re-assert OFF even if an older seed left them ON.
for _, section := range []string{"marketing", "integrations"} {
_, err := s.Pool.Exec(ctx, `
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
VALUES ($1, 'section', false, now())
ON CONFLICT (gate_key) DO UPDATE
SET enabled = false,
updated_at = now()
WHERE platform_feature_gates.enabled IS DISTINCT FROM false`, section)
if err != nil {
if isUndefinedRelation(err) {
return nil
}
return err
}
}
s.invalidateFeatureGatesCache()
return nil
}
// SparseDefaultOverrides returns only the false keys from DefaultPlanFeatures
// (empty map for custom / all-on packages). Legacy plans return SparseLegacyOverrides.
// Useful for "reset to defaults" admin helpers without storing the full expanded matrix.
func SparseDefaultOverrides(planName string, isCustom bool) map[string]bool {
return SparseDefaultOverridesEx(planName, isCustom, IsLegacyPlanName(planName))
}
// SparseDefaultOverridesEx includes an explicit is_legacy flag.
func SparseDefaultOverridesEx(planName string, isCustom, isLegacy bool) map[string]bool {
if IsCustomPackage(planName, isCustom) {
if IsLegacyPlanName(planName) {
return SparseA1PaygOverrides()
}
return map[string]bool{}
}
if IsLegacyPlan(planName, isLegacy) {
return SparseLegacyOverrides()
}
full := DefaultPlanFeaturesEx(planName, false, false)
out := make(map[string]bool)
for k, v := range full {
if !v {
out[k] = false
}
}
return out
}
// NormalizePublicPlanName maps a plan name to the public ladder key used by
// DefaultPlanFeatures (free|starter|plus|growth|business|scale|enterprise|other).
func NormalizePublicPlanName(planName string) string {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "", "free":
return "free"
case "starter", "plus":
// Plus uses the Starter feature matrix (AI on, BYOK off).
return "starter"
case "growth":
return "growth"
case "business", "scale":
return "business"
case "enterprise":
return "enterprise"
default:
return "other"
}
}
@@ -0,0 +1,141 @@
package billing
import (
"testing"
)
func TestDefaultPlanFeaturesMatrix(t *testing.T) {
t.Parallel()
free := DefaultPlanFeatures("Free", false)
if len(free) != len(FeatureCatalogKeys) {
t.Fatalf("free matrix size=%d want %d", len(free), len(FeatureCatalogKeys))
}
for _, k := range []string{
"catalog.products",
"catalog.products.process_categories",
"capability.normalize_specs_fill",
"capability.eprel",
"feeds.list",
"billing.overview",
} {
if !free[k] {
t.Fatalf("free should allow %s", k)
}
}
for _, k := range []string{
"catalog.products.process_ai_titles",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"settings.api_keys",
"capability.ai_processing",
"capability.byok",
"integrations.ai.byok",
} {
if free[k] {
t.Fatalf("free should deny %s", k)
}
}
starter := DefaultPlanFeatures("Starter", false)
if !starter["catalog.products.process_ai_titles"] {
t.Fatal("starter should allow AI titles")
}
if starter["integrations.ai.byok"] || starter["capability.byok"] {
t.Fatal("starter should deny BYOK")
}
growth := DefaultPlanFeatures("Growth", false)
for _, k := range FeatureCatalogKeys {
if !growth[k] {
t.Fatalf("growth should allow all keys; %s is off", k)
}
}
business := DefaultPlanFeatures("Business", false)
for _, k := range FeatureCatalogKeys {
if !business[k] {
t.Fatalf("business should allow all keys; %s is off", k)
}
}
enterprise := DefaultPlanFeatures("Enterprise", true)
for _, k := range FeatureCatalogKeys {
if !enterprise[k] {
t.Fatalf("enterprise custom should allow all keys; %s is off", k)
}
}
custom := DefaultPlanFeatures("ClientCo Deal", true)
for _, k := range FeatureCatalogKeys {
if !custom[k] {
t.Fatalf("custom should allow all keys; %s is off", k)
}
}
}
func TestSparseDefaultOverrides(t *testing.T) {
t.Parallel()
free := SparseDefaultOverrides("Free", false)
if len(free) == 0 {
t.Fatal("free sparse overrides should list denied keys")
}
for k, v := range free {
if v {
t.Fatalf("sparse override for %s should be false", k)
}
}
if SparseDefaultOverrides("Growth", false) == nil {
t.Fatal("expected empty map not nil")
}
if len(SparseDefaultOverrides("Growth", false)) != 0 {
t.Fatal("growth sparse should be empty")
}
if len(SparseDefaultOverrides("Anything", true)) != 0 {
t.Fatal("custom sparse should be empty")
}
if len(SparseDefaultOverrides("A1", false)) == 0 {
t.Fatal("legacy A1 sparse should list denied keys")
}
a1PaygSparse := SparseDefaultOverrides("A1", true)
if len(a1PaygSparse) == 0 {
t.Fatal("A1 PAYG custom sparse should list Stores/AI denied keys")
}
if a1PaygSparse["stores.hub"] != false || a1PaygSparse["integrations.ai"] != false {
t.Fatalf("A1 PAYG sparse must deny stores/AI: %#v", a1PaygSparse)
}
if _, ok := a1PaygSparse["processing.monitor"]; ok {
t.Fatal("A1 PAYG sparse must not list allowed keys")
}
}
func TestNormalizePublicPlanName(t *testing.T) {
t.Parallel()
cases := map[string]string{
"": "free",
"Free": "free",
"STARTER": "starter",
"Growth": "growth",
"Business": "business",
"Enterprise": "enterprise",
"A1": "other",
}
for in, want := range cases {
if got := NormalizePublicPlanName(in); got != want {
t.Fatalf("NormalizePublicPlanName(%q)=%q want %q", in, got, want)
}
}
}
func TestPlanAllowsFeatureUsesOverrides(t *testing.T) {
t.Parallel()
if PlanAllowsFeature("Free", false, map[string]bool{"settings.api_keys": true}, "settings.api_keys") != true {
t.Fatal("override true should win on free")
}
if PlanAllowsFeature("Free", false, nil, "settings.api_keys") != false {
t.Fatal("free default denies api keys")
}
if PlanAllowsFeature("Deal", true, nil, "settings.api_keys") != true {
t.Fatal("custom allows all")
}
}
+190
View File
@@ -0,0 +1,190 @@
package billing
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Entitlements describes plan-gated capabilities for a company.
type Entitlements struct {
PlanName string `json:"plan_name"`
IsFreePlan bool `json:"is_free_plan"`
IsPaidPlan bool `json:"is_paid_plan"`
IsTrial bool `json:"is_trial"`
MonthlyCredits int `json:"monthly_credits"`
RemainingCredits int `json:"remaining_credits"`
// CanUseAI is true when remaining credits > 0 OR the company is on a paid plan (not Free).
CanUseAI bool `json:"can_use_ai"`
// CanUseEPREL is always true: EU EPREL is public free data (no credits). Platform may still disable the enricher via eprel.enabled / EPREL_ENABLED.
CanUseEPREL bool `json:"can_use_eprel"`
}
// ErrAIRequiresUpgrade is returned when a job is AI-only and the company cannot use AI.
var ErrAIRequiresUpgrade = errors.New("ai features require a paid plan or AI credits")
// ErrEPRELRequiresUpgrade is retained for API error shaping only; CanUseEPREL is always true
// (public EU data, no credits). Do not surface "upgrade for EPREL" in product copy.
var ErrEPRELRequiresUpgrade = errors.New("EPREL enrichment unavailable")
// IsFreePlanName reports whether the plan name is the forever-free tier.
func IsFreePlanName(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "free")
}
// RemainingCreditsClamped returns max(0, total-used) so corrupted wallets never report negative spendable credits.
func RemainingCreditsClamped(total, used int) int {
r := total - used
if r < 0 {
return 0
}
return r
}
// ApplyCreditDelta returns the next total_credits after amount (grants or clawbacks).
// Never below used_credits or below 0 — prevents negative remaining balances.
func ApplyCreditDelta(total, used, amount int) int {
next := total + amount
if next < used {
next = used
}
if next < 0 {
next = 0
}
return next
}
// DebitAmount is the per-product credit burn: feature base + ceil(tokens/1000)*tokenK.
// Negative tokenCount is treated as 0. Costs below 1 fall back to 1.
func DebitAmount(featureCost, tokenKCost, tokenCount int) int {
if featureCost < 1 {
featureCost = 1
}
if tokenCount < 0 {
tokenCount = 0
}
debit := featureCost
if tokenCount > 0 {
if tokenKCost < 1 {
tokenKCost = 1
}
packs := (tokenCount + 999) / 1000
debit += packs * tokenKCost
}
if debit < 1 {
debit = 1
}
return debit
}
// DebitAmountN scales the feature base by productCount and adds token packs on the
// combined tokenCount. packs(sum) can be less than sum(packs) — for exact parity with
// N×ConsumeCredits, sum DebitAmount per item instead of using this helper.
func DebitAmountN(featureCost, tokenKCost, tokenCount, productCount int) int {
if productCount < 1 {
productCount = 1
}
if featureCost < 1 {
featureCost = 1
}
if tokenCount < 0 {
tokenCount = 0
}
debit := featureCost * productCount
if tokenCount > 0 {
if tokenKCost < 1 {
tokenKCost = 1
}
packs := (tokenCount + 999) / 1000
debit += packs * tokenKCost
}
if debit < 1 {
debit = 1
}
return debit
}
// ComputeEntitlements builds entitlements from plan + wallet state (pure; testable).
func ComputeEntitlements(planName string, monthlyCredits, remaining int, isTrial bool) Entitlements {
if remaining < 0 {
remaining = 0
}
free := IsFreePlanName(planName) || planName == ""
paid := !free
canAI := remaining > 0 || paid
return Entitlements{
PlanName: planName,
IsFreePlan: free,
IsPaidPlan: paid,
IsTrial: isTrial,
MonthlyCredits: monthlyCredits,
RemainingCredits: remaining,
CanUseAI: canAI,
CanUseEPREL: true,
}
}
// EntitlementsForCompany loads active plan + credit wallet entitlements.
func (s *Service) EntitlementsForCompany(ctx context.Context, companyID uuid.UUID) (Entitlements, error) {
var total, used int
err := s.Pool.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if errors.Is(err, pgx.ErrNoRows) {
total, used = 0, 0
} else if err != nil {
return Entitlements{}, err
}
remaining := RemainingCreditsClamped(total, used)
var planName string
var monthly int
var isTrial bool
err = s.Pool.QueryRow(ctx, `
SELECT COALESCE(p.name, ''), COALESCE(p.monthly_credits, 0), cp.is_trial
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&planName, &monthly, &isTrial)
if errors.Is(err, pgx.ErrNoRows) {
return ComputeEntitlements("Free", 0, remaining, false), nil
}
if err != nil {
return Entitlements{}, err
}
return ComputeEntitlements(planName, monthly, remaining, isTrial), nil
}
// ProcessingTypeRequiresAI reports whether the request is an AI-only intent
// (cannot be silently downgraded to normalize/specs/fill).
func ProcessingTypeRequiresAI(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "enhance", "enhance_only", "enhance-only", "title", "description", "seo", "seo_ai":
return true
default:
return false
}
}
// ProcessingTypeRequiresEPREL reports whether the request is EPREL-only.
func ProcessingTypeRequiresEPREL(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "eprel", "eprel_only":
return true
default:
return false
}
}
// ProcessingTypeIsEmailCampaignAI is reserved for future email-campaign AI endpoints.
func ProcessingTypeIsEmailCampaignAI(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "email_campaign", "email_campaign_ai", "campaign_ai":
return true
default:
return false
}
}
@@ -0,0 +1,318 @@
package billing
// Code generated from docs/plan-permissions/01-feature-keys.json — do not hand-edit keys.
// FeatureCatalogKeys is the admin-validated registry of dashboard feature keys.
var FeatureCatalogKeys = []string{
"shell.navigation",
"shell.command_palette",
"shell.company_switcher",
"shell.support_notifications",
"shell.tutorial",
"shell.account_menu",
"shell.billing_recovery_banner",
"dashboard.overview",
"dashboard.stats",
"dashboard.quick_links",
"dashboard.recent_jobs",
"dashboard.news_feed",
"dashboard.activation_checklist",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
"dashboard.store_reconnect",
"dashboard.upgrade_banners",
"catalog.products",
"catalog.products.tab_processed",
"catalog.products.tab_needs_review",
"catalog.products.tab_error",
"catalog.products.tab_processing",
"catalog.products.tab_unprocessed",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"catalog.products.enrichment_review",
"catalog.products.export_selection",
"catalog.products.upgrade_prompt",
"catalog.categories",
"catalog.categories.title_formula",
"catalog.categories.description_formula",
"catalog.attributes",
"catalog.attributes.bulk_import",
"catalog.standard_fields",
"catalog.standard_fields.groups",
"catalog.structured_descriptions",
"catalog.vector_categories",
"feeds.list",
"feeds.add_url",
"feeds.add_csv",
"feeds.sync",
"feeds.mapping",
"feeds.mapping.select_item",
"feeds.mapping.map_fields",
"feeds.export_feeds",
"feeds.export_feeds.create",
"feeds.export_feeds.generate",
"feeds.uploads",
"stores.hub",
"stores.woocommerce",
"stores.woocommerce.connection",
"stores.woocommerce.categories",
"stores.woocommerce.attributes",
"stores.woocommerce.orders",
"stores.woocommerce.reviews",
"stores.woocommerce.settings",
"stores.shopify",
"stores.shopify.connection",
"stores.shopify.orders",
"stores.shopify.settings",
"processing.monitor",
"marketing.campaigns",
"marketing.campaigns.create",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"marketing.content_calendar",
"marketing.brand_kit",
"marketing.brand_ai_apply",
"marketing.seo",
"marketing.seo.template_fill",
"marketing.seo.ai_rewrite",
"marketing.reviews",
"integrations.ai",
"integrations.ai.byok",
"integrations.email",
"integrations.email.test",
"integrations.email.blast",
"billing.overview",
"billing.customer_portal",
"billing.quick_upgrade",
"billing.plans_compare",
"billing.checkout",
"settings.profile",
"settings.company",
"settings.alerts",
"settings.api_keys",
"settings.team",
"settings.team_invite",
"support.center",
"support.ticket_create",
"support.ticket_thread",
"capability.sku_cap",
"capability.ai_credits",
"capability.ai_processing",
"capability.eprel",
"capability.normalize_specs_fill",
"capability.campaign_ai",
"capability.email_live_send",
"capability.brand_ai_apply",
"capability.seo_ai_rewrite",
"capability.feed_source_limit",
"capability.export_feed_limit",
"capability.storage_limit",
"capability.api_access",
"capability.byok",
}
// FeatureSections are global section master-switch keys.
var FeatureSections = []string{
"shell",
"dashboard",
"catalog",
"feeds",
"stores",
"processing",
"marketing",
"integrations",
"billing",
"settings",
"support",
"capabilities",
}
var featureKeySection = map[string]string{
"shell.navigation": "shell",
"shell.command_palette": "shell",
"shell.company_switcher": "shell",
"shell.support_notifications": "shell",
"shell.tutorial": "shell",
"shell.account_menu": "shell",
"shell.billing_recovery_banner": "shell",
"dashboard.overview": "dashboard",
"dashboard.stats": "dashboard",
"dashboard.quick_links": "dashboard",
"dashboard.recent_jobs": "dashboard",
"dashboard.news_feed": "dashboard",
"dashboard.activation_checklist": "dashboard",
"dashboard.migrated_checklist": "dashboard",
"dashboard.etl_gaps": "dashboard",
"dashboard.store_reconnect": "dashboard",
"dashboard.upgrade_banners": "dashboard",
"catalog.products": "catalog",
"catalog.products.tab_processed": "catalog",
"catalog.products.tab_needs_review": "catalog",
"catalog.products.tab_error": "catalog",
"catalog.products.tab_processing": "catalog",
"catalog.products.tab_unprocessed": "catalog",
"catalog.products.process_categories": "catalog",
"catalog.products.process_attributes": "catalog",
"catalog.products.process_ai_titles": "catalog",
"catalog.products.process_ai_descriptions": "catalog",
"catalog.products.enrichment_review": "catalog",
"catalog.products.export_selection": "catalog",
"catalog.products.upgrade_prompt": "catalog",
"catalog.categories": "catalog",
"catalog.categories.title_formula": "catalog",
"catalog.categories.description_formula": "catalog",
"catalog.attributes": "catalog",
"catalog.attributes.bulk_import": "catalog",
"catalog.standard_fields": "catalog",
"catalog.standard_fields.groups": "catalog",
"catalog.structured_descriptions": "catalog",
"catalog.vector_categories": "catalog",
"feeds.list": "feeds",
"feeds.add_url": "feeds",
"feeds.add_csv": "feeds",
"feeds.sync": "feeds",
"feeds.mapping": "feeds",
"feeds.mapping.select_item": "feeds",
"feeds.mapping.map_fields": "feeds",
"feeds.export_feeds": "feeds",
"feeds.export_feeds.create": "feeds",
"feeds.export_feeds.generate": "feeds",
"feeds.uploads": "feeds",
"stores.hub": "stores",
"stores.woocommerce": "stores",
"stores.woocommerce.connection": "stores",
"stores.woocommerce.categories": "stores",
"stores.woocommerce.attributes": "stores",
"stores.woocommerce.orders": "stores",
"stores.woocommerce.reviews": "stores",
"stores.woocommerce.settings": "stores",
"stores.shopify": "stores",
"stores.shopify.connection": "stores",
"stores.shopify.orders": "stores",
"stores.shopify.settings": "stores",
"processing.monitor": "processing",
"marketing.campaigns": "marketing",
"marketing.campaigns.create": "marketing",
"marketing.campaigns.generate_ai": "marketing",
"marketing.campaigns.send": "marketing",
"marketing.content_calendar": "marketing",
"marketing.brand_kit": "marketing",
"marketing.brand_ai_apply": "marketing",
"marketing.seo": "marketing",
"marketing.seo.template_fill": "marketing",
"marketing.seo.ai_rewrite": "marketing",
"marketing.reviews": "marketing",
"integrations.ai": "integrations",
"integrations.ai.byok": "integrations",
"integrations.email": "integrations",
"integrations.email.test": "integrations",
"integrations.email.blast": "integrations",
"billing.overview": "billing",
"billing.customer_portal": "billing",
"billing.quick_upgrade": "billing",
"billing.plans_compare": "billing",
"billing.checkout": "billing",
"settings.profile": "settings",
"settings.company": "settings",
"settings.alerts": "settings",
"settings.api_keys": "settings",
"settings.team": "settings",
"settings.team_invite": "settings",
"support.center": "support",
"support.ticket_create": "support",
"support.ticket_thread": "support",
"capability.sku_cap": "capabilities",
"capability.ai_credits": "capabilities",
"capability.ai_processing": "capabilities",
"capability.eprel": "capabilities",
"capability.normalize_specs_fill": "capabilities",
"capability.campaign_ai": "capabilities",
"capability.email_live_send": "capabilities",
"capability.brand_ai_apply": "capabilities",
"capability.seo_ai_rewrite": "capabilities",
"capability.feed_source_limit": "capabilities",
"capability.export_feed_limit": "capabilities",
"capability.storage_limit": "capabilities",
"capability.api_access": "capabilities",
"capability.byok": "capabilities",
}
var featureCatalogSet = map[string]struct{}{}
func init() {
for _, k := range FeatureCatalogKeys {
featureCatalogSet[k] = struct{}{}
}
}
// SectionOfFeature returns the section for a registry feature key.
func SectionOfFeature(key string) (string, bool) {
s, ok := featureKeySection[key]
return s, ok
}
// IsKnownFeatureKey reports whether key is in the dashboard feature registry.
func IsKnownFeatureKey(key string) bool {
_, ok := featureCatalogSet[key]
return ok
}
// IsKnownFeatureSection reports whether section is a valid master-switch section.
func IsKnownFeatureSection(section string) bool {
for _, s := range FeatureSections {
if s == section {
return true
}
}
return false
}
func freePlanFeatureOff(key string) bool {
switch key {
case "capability.ai_processing":
return true
case "capability.api_access":
return true
case "capability.brand_ai_apply":
return true
case "capability.byok":
return true
case "capability.campaign_ai":
return true
case "capability.email_live_send":
return true
case "capability.seo_ai_rewrite":
return true
case "catalog.products.process_ai_descriptions":
return true
case "catalog.products.process_ai_titles":
return true
case "integrations.ai.byok":
return true
case "marketing.brand_ai_apply":
return true
case "marketing.campaigns.generate_ai":
return true
case "marketing.campaigns.send":
return true
case "marketing.seo.ai_rewrite":
return true
case "settings.api_keys":
return true
default:
return false
}
}
func starterPlanFeatureOff(key string) bool {
switch key {
case "capability.byok":
return true
case "integrations.ai.byok":
return true
default:
return false
}
}
@@ -0,0 +1,72 @@
package billing
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
type featureKeyDoc struct {
Key string `json:"key"`
}
// TestFeatureCatalogKeysMatchDocsJSON keeps Go FeatureCatalogKeys aligned with
// docs/plan-permissions/01-feature-keys.json (shared with the web catalog).
func TestFeatureCatalogKeysMatchDocsJSON(t *testing.T) {
t.Parallel()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", ".."))
path := filepath.Join(root, "docs", "plan-permissions", "01-feature-keys.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var docs []featureKeyDoc
if err := json.Unmarshal(raw, &docs); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if len(docs) == 0 {
t.Fatal("docs feature keys empty")
}
want := make(map[string]struct{}, len(docs))
for _, row := range docs {
if row.Key == "" {
t.Fatal("empty key in docs JSON")
}
want[row.Key] = struct{}{}
}
got := make(map[string]struct{}, len(FeatureCatalogKeys))
for _, k := range FeatureCatalogKeys {
got[k] = struct{}{}
}
for k := range want {
if _, ok := got[k]; !ok {
t.Errorf("FeatureCatalogKeys missing docs key %q", k)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("FeatureCatalogKeys has extra key %q not in docs", k)
}
}
if len(got) != len(want) {
t.Fatalf("FeatureCatalogKeys len=%d docs len=%d", len(got), len(want))
}
}
func TestLegacyAllowlistIncludesStorageLimit(t *testing.T) {
t.Parallel()
// roles-matrix legacy_user / plan_profiles.legacy list storage_limit ON.
// Must not enable stores/marketing — only the marketing meter capability key.
if !LegacyFeatureAllowed("capability.storage_limit") {
t.Fatal("legacy allowlist must include capability.storage_limit (roles-matrix)")
}
if LegacyFeatureAllowed("stores.hub") || LegacyFeatureAllowed("marketing.campaigns") {
t.Fatal("legacy must still deny stores/marketing (no A1 pollution)")
}
}
@@ -0,0 +1,76 @@
package billing
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// FeatureKeyFromError extracts the feature key from an ErrFeatureDisabled wrap
// ("feature_disabled: marketing.campaigns.generate_ai").
func FeatureKeyFromError(err error) string {
if err == nil || !errors.Is(err, ErrFeatureDisabled) {
return ""
}
msg := err.Error()
const prefix = "feature_disabled:"
idx := strings.Index(strings.ToLower(msg), prefix)
if idx < 0 {
return ""
}
return strings.TrimSpace(msg[idx+len(prefix):])
}
// FeatureKeysForProcessingType maps a processing job type to registry keys that
// must be effective before StartJob may proceed.
func FeatureKeysForProcessingType(processingType string) []string {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "title":
return []string{"capability.ai_processing", "catalog.products.process_ai_titles"}
case "description":
return []string{"capability.ai_processing", "catalog.products.process_ai_descriptions"}
case "enhance", "enhance_only", "enhance-only", "seo", "seo_ai":
return []string{"capability.ai_processing"}
case "eprel", "eprel_only":
return []string{"capability.eprel"}
case "email_campaign", "email_campaign_ai", "campaign_ai":
return []string{"capability.campaign_ai", "marketing.campaigns.generate_ai"}
case "normalize", "specs", "fill", "categories", "attributes", "full", "":
return []string{"capability.normalize_specs_fill"}
default:
return []string{"capability.normalize_specs_fill"}
}
}
// AssertFeatures fails closed on the first disabled key.
// Loads Capabilities once for the whole key set (avoids N×CapabilitiesForCompany).
func (s *Service) AssertFeatures(ctx context.Context, companyID uuid.UUID, keys ...string) error {
if len(keys) == 0 {
return nil
}
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return err
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if caps.Features == nil || !caps.Features[key] {
return fmt.Errorf("%w: %s", ErrFeatureDisabled, key)
}
}
return nil
}
// AssertProcessingFeatures enforces plan ∩ global feature keys for a job type.
func (s *Service) AssertProcessingFeatures(ctx context.Context, companyID uuid.UUID, processingType string) error {
if s == nil {
return nil
}
return s.AssertFeatures(ctx, companyID, FeatureKeysForProcessingType(processingType)...)
}
@@ -0,0 +1,129 @@
package billing
import (
"errors"
"fmt"
"testing"
)
func TestDefaultPlanFeaturesFreeDeniesAI(t *testing.T) {
m := DefaultPlanFeatures("Free", false)
for _, key := range []string{
"capability.ai_processing",
"catalog.products.process_ai_titles",
"marketing.campaigns.generate_ai",
"settings.api_keys",
"capability.api_access",
"capability.email_live_send",
} {
if m[key] {
t.Fatalf("Free should deny %s", key)
}
}
if !m["catalog.products"] || !m["capability.normalize_specs_fill"] {
t.Fatal("Free should allow catalog + normalize")
}
}
func TestDefaultPlanFeaturesCustomEnableAll(t *testing.T) {
m := DefaultPlanFeatures("Acme Deal", true)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("custom should enable all; missing %s", k)
}
}
m2 := DefaultPlanFeatures("Enterprise", true)
for _, k := range FeatureCatalogKeys {
if !m2[k] {
t.Fatalf("Enterprise (is_custom) should enable all; missing %s", k)
}
}
}
func TestResolveEffectiveFeaturesGlobalSectionDisableAll(t *testing.T) {
gates := emptyGatesView()
gates.Sections["marketing"] = false
features, sections, disabled := ResolveEffectiveFeatures("Growth", false, nil, gates)
if sections["marketing"] {
t.Fatal("marketing section should be off")
}
if features["marketing.campaigns"] || features["marketing.campaigns.generate_ai"] {
t.Fatal("marketing keys must be false when section disabled")
}
found := false
for _, d := range disabled {
if d == "marketing.campaigns.generate_ai" {
found = true
break
}
}
if !found {
t.Fatal("disabled_features should list marketing.campaigns.generate_ai")
}
if !features["catalog.products"] {
t.Fatal("catalog should remain on")
}
}
func TestResolveEffectiveFeaturesCustomOverrideFalse(t *testing.T) {
gates := emptyGatesView()
overrides := map[string]bool{"settings.api_keys": false}
features, _, _ := ResolveEffectiveFeatures("Client Deal", true, overrides, gates)
if features["settings.api_keys"] {
t.Fatal("override false must win on custom")
}
if !features["capability.ai_processing"] {
t.Fatal("other keys stay on for custom")
}
}
func TestPlanAllowsFeatureCapabilityResolution(t *testing.T) {
if PlanAllowsFeature("Free", false, nil, "capability.ai_processing") {
t.Fatal("Free deny AI capability")
}
if !PlanAllowsFeature("Starter", false, nil, "capability.ai_processing") {
t.Fatal("Starter allow AI capability")
}
if PlanAllowsFeature("Starter", false, nil, "capability.byok") {
t.Fatal("Starter deny BYOK")
}
if !PlanAllowsFeature("Growth", false, nil, "capability.byok") {
t.Fatal("Growth allow BYOK")
}
}
func TestFeatureKeyFromError(t *testing.T) {
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
if got := FeatureKeyFromError(err); got != "marketing.campaigns.generate_ai" {
t.Fatalf("got %q", got)
}
if FeatureKeyFromError(errors.New("other")) != "" {
t.Fatal("non-feature error should yield empty")
}
}
func TestFeatureKeysForProcessingType(t *testing.T) {
keys := FeatureKeysForProcessingType("title")
if len(keys) != 2 || keys[0] != "capability.ai_processing" {
t.Fatalf("title keys: %v", keys)
}
keys = FeatureKeysForProcessingType("normalize")
if len(keys) != 1 || keys[0] != "capability.normalize_specs_fill" {
t.Fatalf("normalize keys: %v", keys)
}
}
func TestCloneGatesViewIndependent(t *testing.T) {
src := emptyGatesView()
src.Sections["marketing"] = false
src.Features["capability.ai_processing"] = false
dst := cloneGatesView(src)
dst.Sections["marketing"] = true
dst.Features["capability.ai_processing"] = true
if src.Sections["marketing"] {
t.Fatal("clone must not share sections map")
}
if src.Features["capability.ai_processing"] {
t.Fatal("clone must not share features map")
}
}
+135
View File
@@ -0,0 +1,135 @@
package billing
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// FeatureDef is one catalog entry for listFeatures / admin editors.
type FeatureDef struct {
Key string `json:"key"`
Section string `json:"section"`
Label string `json:"label,omitempty"`
}
// ListFeatures returns the canonical feature registry (listFeatures).
func (s *Service) ListFeatures(_ context.Context) ([]FeatureDef, error) {
out := make([]FeatureDef, 0, len(FeatureCatalogKeys))
for _, key := range FeatureCatalogKeys {
section, _ := SectionOfFeature(key)
out = append(out, FeatureDef{
Key: key,
Section: section,
Label: key,
})
}
return out, nil
}
// IsAllowed reports effective(feature) for a company's active plan (isAllowed).
func (s *Service) IsAllowed(ctx context.Context, companyID uuid.UUID, key string) (bool, error) {
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return false, err
}
key = strings.TrimSpace(key)
if caps.Features == nil {
return false, nil
}
return caps.Features[key], nil
}
// IsAllowedForPlan reports effective(feature) for a plan id (globals still apply).
func (s *Service) IsAllowedForPlan(ctx context.Context, planID int64, key string) (bool, error) {
name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return false, err
}
gates, err := s.GetFeatureGates(ctx)
if err != nil {
return false, err
}
features, _, _ := ResolveEffectiveFeaturesEx(name, isCustom, isLegacy, overrides, gates)
return features[strings.TrimSpace(key)], nil
}
// SetPlanFeature merges one override into plans.features (setPlanFeature).
func (s *Service) SetPlanFeature(ctx context.Context, planID int64, key string, enabled bool) error {
key = strings.TrimSpace(key)
if !IsKnownFeatureKey(key) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
}
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
if overrides == nil {
overrides = map[string]bool{}
}
overrides[key] = enabled
_, err = s.SetPlanFeatures(ctx, planID, overrides)
return err
}
// SetGlobalFeature upserts one platform master switch (setGlobalFeature).
// Section ids use kind=section; feature keys use kind=feature.
func (s *Service) SetGlobalFeature(ctx context.Context, gateKey string, enabled bool, updatedBy *uuid.UUID) error {
gateKey = strings.TrimSpace(gateKey)
if gateKey == "" {
return fmt.Errorf("%w: empty gate key", ErrUnknownFeatureKey)
}
if IsKnownFeatureSection(gateKey) {
_, err := s.SetSectionGate(ctx, gateKey, enabled, updatedBy)
return err
}
if !IsKnownFeatureKey(gateKey) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, gateKey)
}
_, err := s.SetFeatureGates(ctx, nil, map[string]bool{gateKey: enabled}, updatedBy)
return err
}
// EnableAllForPlan writes all registry keys as true overrides (enableAllForPlan).
func (s *Service) EnableAllForPlan(ctx context.Context, planID int64) error {
_, err := s.EnableAllPlanFeatures(ctx, planID)
return err
}
// ApplyDefaultMatrix replaces plans.features with sparse defaults for that plan (applyDefaultMatrix).
// Custom packages get an empty override map (is_custom => all ON at resolve).
// Legacy packages get SparseLegacyOverrides (processing.monitor OFF; image-nav ON).
func (s *Service) ApplyDefaultMatrix(ctx context.Context, planID int64) error {
name, isCustom, isLegacy, _, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
_, err = s.SetPlanFeatures(ctx, planID, SparseDefaultOverridesEx(name, isCustom, isLegacy))
return err
}
// AssertFeature fails closed when a feature is not effective for the company.
func (s *Service) AssertFeature(ctx context.Context, companyID uuid.UUID, key string) error {
ok, err := s.IsAllowed(ctx, companyID, key)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%w: %s", ErrFeatureDisabled, strings.TrimSpace(key))
}
return nil
}
// ResolveFeatures is the contract name for ResolveEffectiveFeatures (plan ∧ globals).
func ResolveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
features, _, _ := ResolveEffectiveFeatures(planName, isCustom, overrides, gates)
return features
}
// ResolveFeaturesEx includes an explicit is_legacy flag.
func ResolveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
features, _, _ := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates)
return features
}
@@ -0,0 +1,90 @@
package billing
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func TestDefaultPlanFeaturesStarterBYOK(t *testing.T) {
m := DefaultPlanFeatures("Starter", false)
if !m["catalog.products.process_ai_titles"] {
t.Fatal("Starter should allow AI titles")
}
if m["integrations.ai.byok"] || m["capability.byok"] {
t.Fatal("Starter should deny BYOK")
}
}
func TestDefaultPlanFeaturesGrowthAllOn(t *testing.T) {
m := DefaultPlanFeatures("Growth", false)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("Growth should allow %s", k)
}
}
}
func TestPlanAllowsOverrideFalseWins(t *testing.T) {
overrides := map[string]bool{"settings.api_keys": false}
if PlanAllowsFeature("Growth", false, overrides, "settings.api_keys") {
t.Fatal("override false should win on Growth")
}
}
func TestResolveFeaturesGlobalFeatureOff(t *testing.T) {
gates := FeatureGatesView{
Sections: map[string]bool{},
Features: map[string]bool{"capability.byok": false},
}
features := ResolveFeatures("Business", false, nil, gates)
if features["capability.byok"] {
t.Fatal("global feature kill-switch should win")
}
}
func TestSparseDefaultOverridesFree(t *testing.T) {
sparse := SparseDefaultOverrides("Free", false)
if sparse["catalog.products.process_ai_titles"] != false {
t.Fatal("expected sparse false for AI titles")
}
if _, ok := sparse["catalog.products"]; ok {
t.Fatal("ON keys should not appear in sparse overrides")
}
if len(SparseDefaultOverrides("Acme", true)) != 0 {
t.Fatal("custom sparse should be empty")
}
}
func TestValidateFeatureOverridesRejectsUnknown(t *testing.T) {
err := validateFeatureOverrides(map[string]bool{"not.a.real.key": true})
if !errors.Is(err, ErrUnknownFeatureKey) {
t.Fatalf("want ErrUnknownFeatureKey, got %v", err)
}
}
func TestListFeaturesCatalogComplete(t *testing.T) {
s := &Service{}
list, err := s.ListFeatures(context.Background())
if err != nil {
t.Fatal(err)
}
if len(list) != len(FeatureCatalogKeys) {
t.Fatalf("got %d want %d", len(list), len(FeatureCatalogKeys))
}
if list[0].Section == "" {
t.Fatal("section required")
}
}
func TestAssertFeatureErrorWraps(t *testing.T) {
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
if !errors.Is(err, ErrFeatureDisabled) {
t.Fatal(err)
}
if !strings.Contains(err.Error(), "marketing.campaigns.generate_ai") {
t.Fatal(err)
}
}
+172
View File
@@ -0,0 +1,172 @@
package billing
import (
"errors"
"fmt"
"strings"
"testing"
)
func TestGateErrorWrapping(t *testing.T) {
creditErr := fmt.Errorf("%w: need at least %d credits (have %d)", ErrInsufficientCredits, 5, 2)
if !errors.Is(creditErr, ErrInsufficientCredits) {
t.Fatal("expected ErrInsufficientCredits")
}
if !strings.Contains(creditErr.Error(), "need at least 5") {
t.Fatalf("unexpected message: %v", creditErr)
}
limitErr := fmt.Errorf("%w: plan allows up to %d products", ErrProductLimitExceeded, 100)
if !errors.Is(limitErr, ErrProductLimitExceeded) {
t.Fatal("expected ErrProductLimitExceeded")
}
if errors.Is(limitErr, ErrInsufficientCredits) {
t.Fatal("should not match credits error")
}
aiErr := fmt.Errorf("%w — upgrade", ErrAIRequiresUpgrade)
if !errors.Is(aiErr, ErrAIRequiresUpgrade) {
t.Fatal("expected ErrAIRequiresUpgrade")
}
}
func TestComputeEntitlements(t *testing.T) {
free := ComputeEntitlements("Free", 0, 0, false)
if free.CanUseAI || !free.CanUseEPREL || !free.IsFreePlan {
t.Fatalf("free: %+v", free)
}
freeWithLeftover := ComputeEntitlements("Free", 0, 10, false)
if !freeWithLeftover.CanUseAI {
t.Fatal("leftover credits on Free should unlock AI")
}
growth := ComputeEntitlements("Growth", 2000, 0, false)
if !growth.CanUseAI || !growth.CanUseEPREL || !growth.IsPaidPlan {
t.Fatalf("growth: %+v", growth)
}
enterprise := ComputeEntitlements("Enterprise", EnterpriseUnlimitedCredits, EnterpriseUnlimitedCredits, false)
if !enterprise.CanUseAI || !enterprise.CanUseEPREL || !enterprise.IsPaidPlan || enterprise.IsFreePlan {
t.Fatalf("enterprise: %+v", enterprise)
}
if enterprise.MonthlyCredits != EnterpriseUnlimitedCredits || enterprise.RemainingCredits != EnterpriseUnlimitedCredits {
t.Fatalf("enterprise credits: %+v", enterprise)
}
if ProcessingTypeRequiresAI("title") != true {
t.Fatal("title requires AI")
}
if ProcessingTypeRequiresAI("full") {
t.Fatal("full should auto-skip AI on Free, not hard-require")
}
if !ProcessingTypeRequiresEPREL("eprel_only") {
t.Fatal("eprel_only requires EPREL")
}
}
func TestDefaultPublicPlansEnterpriseUnlimited(t *testing.T) {
plans := defaultPublicPlans()
var ent *Plan
for i := range plans {
if strings.EqualFold(plans[i].Name, "Enterprise") {
ent = &plans[i]
break
}
}
if ent == nil {
t.Fatal("Enterprise missing from defaultPublicPlans")
}
if ent.MonthlyCredits != EnterpriseUnlimitedCredits {
t.Fatalf("monthly_credits=%d want %d", ent.MonthlyCredits, EnterpriseUnlimitedCredits)
}
if ent.MaxProducts != nil {
t.Fatalf("max_products should be nil (unlimited), got %v", *ent.MaxProducts)
}
if !ent.IsCustom {
t.Fatal("Enterprise should be is_custom")
}
}
func TestDefaultPublicPlansFreeZeroCredits(t *testing.T) {
plans := defaultPublicPlans()
var free *Plan
for i := range plans {
if strings.EqualFold(plans[i].Name, "Free") {
free = &plans[i]
break
}
}
if free == nil {
t.Fatal("Free missing from defaultPublicPlans")
}
if free.MonthlyCredits != 0 {
t.Fatalf("Free monthly_credits=%d want 0 (Enterprise seed must not change this)", free.MonthlyCredits)
}
if free.MaxProducts == nil || *free.MaxProducts != 50 {
t.Fatalf("Free max_products=%v want 50", free.MaxProducts)
}
if free.IsCustom {
t.Fatal("Free must not be is_custom")
}
// Enterprise packaging must not leak into Free.
if free.MonthlyCredits == EnterpriseUnlimitedCredits {
t.Fatal("Free must not share Enterprise credit pack")
}
}
func TestDefaultPublicPlansFiftyPercentCover(t *testing.T) {
want := map[string]int{
"Starter": 100,
"Plus": 400,
"Growth": 1_200,
"Business": 4_000,
"Scale": 12_000,
}
pctWant := map[string]int{
"Starter": 50, "Plus": 50, "Growth": 50, "Business": 50, "Scale": 50, "Enterprise": 50,
}
maxWant := map[string]int{
"Free": 50, "Starter": 100, "Plus": 400, "Growth": 1_200, "Business": 4_000, "Scale": ScaleMaxProducts,
}
for name, pct := range pctWant {
if got := PlanAICoverPercent(name); got != pct {
t.Fatalf("PlanAICoverPercent(%s)=%d want %d", name, got, pct)
}
}
for name, exp := range want {
if got := MonthlyCreditsForPlan(name, 0); got != exp {
t.Fatalf("MonthlyCreditsForPlan(%s)=%d want %d", name, got, exp)
}
}
for _, p := range defaultPublicPlans() {
if exp, ok := want[p.Name]; ok && p.MonthlyCredits != exp {
t.Fatalf("%s monthly_credits=%d want %d", p.Name, p.MonthlyCredits, exp)
}
if p.Name == "Enterprise" {
if p.MaxProducts != nil {
t.Fatalf("Enterprise max_products should be nil, got %v", p.MaxProducts)
}
continue
}
wantMax, ok := maxWant[p.Name]
if !ok {
t.Fatalf("%s missing from maxWant", p.Name)
}
if p.MaxProducts == nil || *p.MaxProducts != wantMax {
t.Fatalf("%s max_products=%v want %d", p.Name, p.MaxProducts, wantMax)
}
if p.Name != "Free" {
base := CreditSKUBase(p.Name)
if base <= 0 || base > wantMax {
t.Fatalf("%s CreditSKUBase=%d must be in (0, MaxProducts=%d]", p.Name, base, wantMax)
}
}
}
// Starter included AI must stay tiny vs A1 (~€300) economics.
if want["Starter"] > 150 {
t.Fatalf("Starter monthly credits=%d too high vs A1 positioning", want["Starter"])
}
if CreditSKUBase("Starter") != 100 || want["Starter"] != 100 {
t.Fatalf("Starter base/credits: base=%d credits=%d", CreditSKUBase("Starter"), want["Starter"])
}
if got := PlanMaxProducts("Scale"); got == nil || *got != ScaleMaxProducts || ScaleMaxProducts >= 1_000_000 {
t.Fatalf("Scale max_products=%v ScaleMaxProducts=%d want %d (<1M)", got, ScaleMaxProducts, ScaleMaxProducts)
}
}
+168
View File
@@ -0,0 +1,168 @@
package billing
import (
"strings"
)
// A1LegacyCompanyID is the MySQL company_id for A1 Slovenija (migrated dump name kept in PG).
// Cohort remains legacy even if an older local rename used "Local Demo Co".
const A1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
// LegacyPlanName is the seeded package name for migrated / limited-nav tenants.
const LegacyPlanName = "Legacy"
// PlanProfile is the packaging bucket used for default feature matrices.
type PlanProfile string
const (
PlanProfileFree PlanProfile = "free"
PlanProfileStarter PlanProfile = "starter"
PlanProfileGrowth PlanProfile = "growth"
PlanProfileBusiness PlanProfile = "business"
PlanProfileEnterprise PlanProfile = "enterprise"
PlanProfileLegacy PlanProfile = "legacy"
PlanProfileCustom PlanProfile = "custom"
)
// legacyFeatureAllowlist is the ON set for the legacy (A1) matrix.
// Source: docs/admin-roles-support/03-roles-matrix.md / .json (legacy_user).
var legacyFeatureAllowlist = map[string]struct{}{
"shell.navigation": {},
"shell.command_palette": {},
"shell.company_switcher": {},
"shell.tutorial": {},
"shell.account_menu": {},
"shell.billing_recovery_banner": {},
"dashboard.overview": {},
"dashboard.stats": {},
"dashboard.quick_links": {},
"dashboard.recent_jobs": {},
"dashboard.news_feed": {},
"dashboard.activation_checklist": {},
"dashboard.migrated_checklist": {},
"dashboard.etl_gaps": {},
"dashboard.upgrade_banners": {},
"catalog.products": {},
"catalog.products.tab_processed": {},
"catalog.products.tab_needs_review": {},
"catalog.products.tab_error": {},
"catalog.products.tab_processing": {},
"catalog.products.tab_unprocessed": {},
"catalog.products.process_categories": {},
"catalog.products.process_attributes": {},
"catalog.products.process_ai_titles": {},
"catalog.products.process_ai_descriptions": {},
"catalog.products.enrichment_review": {},
"catalog.products.export_selection": {},
"catalog.products.upgrade_prompt": {},
"catalog.categories": {},
"catalog.categories.title_formula": {},
"catalog.categories.description_formula": {},
"catalog.attributes": {},
"catalog.attributes.bulk_import": {},
"catalog.standard_fields": {},
"catalog.standard_fields.groups": {},
"feeds.list": {},
"feeds.add_url": {},
"feeds.add_csv": {},
"feeds.sync": {},
"feeds.mapping": {},
"feeds.mapping.select_item": {},
"feeds.mapping.map_fields": {},
"feeds.export_feeds": {},
"feeds.export_feeds.create": {},
"feeds.export_feeds.generate": {},
"feeds.uploads": {},
"billing.overview": {},
"billing.customer_portal": {},
"billing.quick_upgrade": {},
"billing.plans_compare": {},
"billing.checkout": {},
"settings.profile": {},
"settings.company": {},
"settings.alerts": {},
"settings.api_keys": {},
"settings.team": {},
"settings.team_invite": {},
"capability.sku_cap": {},
"capability.ai_credits": {},
"capability.ai_processing": {},
"capability.eprel": {},
"capability.normalize_specs_fill": {},
"capability.feed_source_limit": {},
"capability.export_feed_limit": {},
"capability.storage_limit": {},
"capability.api_access": {},
}
// IsLegacyPlanName reports whether a plan name matches the legacy cohort patterns
// (exact "legacy", A1*, or "a1 slovenija"). See docs/admin-roles-support/03-roles-matrix.md.
func IsLegacyPlanName(planName string) bool {
n := strings.ToLower(strings.TrimSpace(planName))
if n == "" {
return false
}
if n == "legacy" {
return true
}
if strings.Contains(n, "a1 slovenija") {
return true
}
if n == "a1" || strings.HasPrefix(n, "a1 ") || strings.HasPrefix(n, "a1-") || strings.HasPrefix(n, "a1_") {
return true
}
return false
}
// IsLegacyPlan reports legacy packaging from an explicit flag and/or name patterns.
func IsLegacyPlan(planName string, isLegacyFlag bool) bool {
return isLegacyFlag || IsLegacyPlanName(planName)
}
// IsLegacyCompanyID reports whether a remapped legacy MySQL company id is the A1 cohort.
func IsLegacyCompanyID(legacyCompanyID string) bool {
return strings.EqualFold(strings.TrimSpace(legacyCompanyID), A1LegacyCompanyID)
}
// IsA1CohortCompany reports whether a company is the migrated A1 tenant.
// Match only immutable legacy_company_id — never mutable display names
// (register/rename to "A1" must not grant Legacy plan privileges).
// companyName is retained for call-site compatibility; it is ignored.
func IsA1CohortCompany(legacyCompanyID, companyName string) bool {
_ = companyName
return IsLegacyCompanyID(legacyCompanyID)
}
// LegacyFeatureAllowed reports whether key is ON in the legacy matrix.
func LegacyFeatureAllowed(key string) bool {
_, ok := legacyFeatureAllowlist[key]
return ok
}
// ResolvePlanProfile maps name + flags to the default matrix bucket.
func ResolvePlanProfile(planName string, isCustom, isLegacyFlag bool) PlanProfile {
if IsCustomPackage(planName, isCustom) {
norm := strings.ToLower(strings.TrimSpace(planName))
if norm == "enterprise" {
return PlanProfileEnterprise
}
return PlanProfileCustom
}
if IsLegacyPlan(planName, isLegacyFlag) {
return PlanProfileLegacy
}
norm := strings.ToLower(strings.TrimSpace(planName))
switch norm {
case "", "free":
return PlanProfileFree
case "starter", "plus":
return PlanProfileStarter
case "growth":
return PlanProfileGrowth
case "business", "scale":
return PlanProfileBusiness
case "enterprise":
return PlanProfileEnterprise
}
return PlanProfileFree
}
@@ -0,0 +1,18 @@
package billing
// SparseLegacyOverrides returns false overrides for every registry key not on the legacy allow-list.
// Storing these makes admin UIs show an explicit legacy matrix; resolve also applies DefaultPlanFeatures.
func SparseLegacyOverrides() map[string]bool {
out := make(map[string]bool)
for _, k := range FeatureCatalogKeys {
if !LegacyFeatureAllowed(k) {
out[k] = false
}
}
return out
}
// featuresMapEmpty reports whether the sparse override map is unset (nil or no keys).
func featuresMapEmpty(features map[string]bool) bool {
return len(features) == 0
}
@@ -0,0 +1,357 @@
package billing
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureLegacyDefaults idempotently:
// 1. Upserts the Legacy plan row (meters aligned with Enterprise for migrated catalogs)
// 2. Repairs prior enable-all feature maps on legacy-named plans
// 3. Delegates empty/flagged sparse backfill to EnsureLegacyPlanFeatureSeeds
// 4. Assigns the Legacy plan to A1 cohort companies when missing or on a non-legacy profile
// 5. Repairs dump-faithful A1 PAYG plan rows (is_custom, clear mistaken is_legacy)
func (s *Service) EnsureLegacyDefaults(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
if err := s.ensureLegacyPlanRow(ctx); err != nil {
return err
}
if err := s.repairLegacyEnableAllFeatures(ctx); err != nil {
return err
}
if err := s.EnsureLegacyPlanFeatureSeeds(ctx); err != nil {
return err
}
if err := s.assignLegacyPlanToA1Companies(ctx); err != nil {
return err
}
return s.ensureA1PaygPlanSemantics(ctx)
}
// repairLegacyEnableAllFeatures rewrites full all-true maps on legacy-named plans
// (left over from prior custom enable-all create) to SparseLegacyOverrides.
func (s *Service) repairLegacyEnableAllFeatures(ctx context.Context) error {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) {
rows, err = s.Pool.Query(ctx, `
SELECT id, name, false, COALESCE(features, '{}'::jsonb) FROM plans`)
}
if err != nil {
if isUndefinedRelation(err) || isUndefinedColumn(err) {
return nil
}
return err
}
}
defer rows.Close()
for rows.Next() {
var id int64
var name string
var isLegacy bool
var raw []byte
if err := rows.Scan(&id, &name, &isLegacy, &raw); err != nil {
return err
}
// Only the explicit Legacy package is rewritten; A1 PAYG / other A1* deals keep features.
if !strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
continue
}
if !IsLegacyPlan(name, isLegacy) {
continue
}
overrides, err := decodeFeaturesJSON(raw)
if err != nil {
return err
}
if featuresMapEmpty(overrides) || !isEnableAllOverrides(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, SparseLegacyOverrides()); err != nil {
return err
}
}
return rows.Err()
}
func (s *Service) ensureLegacyPlanRow(ctx context.Context) error {
desc := "Migrated legacy package — catalog, feeds, billing & settings (no Background Tasks / stores / marketing)"
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, LegacyPlanName).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
_, err = s.Pool.Exec(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, is_legacy, term)
VALUES ($1, $2, $3, NULL, NULL, false, true, 'monthly')`,
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1, $2, $3, NULL, NULL, false, 'monthly')`,
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
}
if err != nil {
return err
}
}
return s.seedLegacyFeaturesIfEmpty(ctx, 0, LegacyPlanName)
}
if err != nil {
return err
}
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
is_custom = false, is_legacy = true, term = 'monthly', updated_at = now()
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
is_custom = false, term = 'monthly', updated_at = now()
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
}
if err != nil {
return err
}
}
return s.seedLegacyFeaturesIfEmpty(ctx, id, LegacyPlanName)
}
func isEnableAllOverrides(overrides map[string]bool) bool {
if len(overrides) < len(FeatureCatalogKeys) {
return false
}
for _, k := range FeatureCatalogKeys {
v, ok := overrides[k]
if !ok || !v {
return false
}
}
return true
}
func shouldWriteLegacySparse(overrides map[string]bool) bool {
return featuresMapEmpty(overrides) || isEnableAllOverrides(overrides)
}
func (s *Service) seedLegacyFeaturesIfEmpty(ctx context.Context, planID int64, name string) error {
if planID == 0 {
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
if err != nil {
return err
}
planID = id
}
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
if !shouldWriteLegacySparse(overrides) {
return nil
}
_, err = s.SetPlanFeatures(ctx, planID, SparseLegacyOverrides())
return err
}
func (s *Service) assignLegacyPlanToA1Companies(ctx context.Context) error {
legacyID, err := s.PlanIDByName(ctx, LegacyPlanName)
if err != nil {
return err
}
// One row per company using the active plan only — joining all company_plans rows
// previously re-AssignPlan'd when an inactive Enterprise row appeared and wiped
// migrated credit_balances (A1 dump 2500/216 → fake pack).
// Privilege-sensitive: match ONLY immutable legacy_company_id. Mutable names
// ("A1", "Local Demo Co", …) must never auto-AssignPlan (register/rename IDOR).
rows, err := s.Pool.Query(ctx, `
SELECT c.id::text, COALESCE(c.legacy_company_id, ''), COALESCE(c.name, ''),
COALESCE(p.name, ''), COALESCE(p.is_legacy, false),
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0)
FROM companies c
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
LEFT JOIN plans p ON p.id = cp.plan_id
LEFT JOIN credit_balances cb ON cb.company_id = c.id
WHERE lower(COALESCE(c.legacy_company_id, '')) = lower($1)`,
A1LegacyCompanyID)
if err != nil {
// Fail closed when legacy_company_id is unavailable — never fall back to name match.
if isUndefinedColumn(err) {
return nil
}
return err
}
defer rows.Close()
for rows.Next() {
var (
cid, legacyCID, cname, planName string
planLegacy bool
total, used int
)
if err := rows.Scan(&cid, &legacyCID, &cname, &planName, &planLegacy, &total, &used); err != nil {
return err
}
if !IsA1CohortCompany(legacyCID, cname) {
continue
}
if IsLegacyPlan(planName, planLegacy) {
continue
}
// Dump-faithful A1 PAYG / other custom deals keep their plan + wallet.
if strings.EqualFold(strings.TrimSpace(planName), "A1") || strings.Contains(strings.ToLower(planName), "a1") {
continue
}
// Preserve migrated wallets — AssignPlan resets used_credits and total from plan monthly.
if total > 0 || used > 0 {
continue
}
companyUUID, err := uuid.Parse(cid)
if err != nil {
continue
}
if err := s.AssignPlan(ctx, companyUUID, legacyID, false, 0); err != nil {
return err
}
}
return rows.Err()
}
// ensureA1PaygPlanSemantics repairs dump-faithful A1 plans:
// is_custom=true, is_legacy=false, PAYG description, A1PaygPlanFeatures
// (Stores + Marketing + Integrations OFF), and documents open-ended contract dates on
// company_plans.notes when dates are null.
func (s *Service) ensureA1PaygPlanSemantics(ctx context.Context) error {
desc := "A1 pay-as-you-go — credits wallet, unlimited SKUs, catalog/feeds/processing/billing (Stores, Marketing, Integrations off). EPREL included on all plans."
_, err := s.Pool.Exec(ctx, `
UPDATE plans SET
is_custom = true,
is_legacy = false,
description = $1,
updated_at = now()
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`, desc)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET is_custom = true, description = $1, updated_at = now()
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`, desc)
}
if err != nil {
return err
}
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, COALESCE(features, '{}'::jsonb)
FROM plans
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`)
if err != nil {
if isUndefinedRelation(err) || isUndefinedColumn(err) {
return nil
}
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var name string
var raw []byte
if err := rows.Scan(&id, &name, &raw); err != nil {
return err
}
overrides, err := decodeFeaturesJSON(raw)
if err != nil {
return err
}
if !shouldWriteA1PaygFeatures(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, A1PaygPlanFeatures()); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
const paygNote = "PAYG: open-ended contract (no end date). Credits are consumed as used; yearly packaging is advisory."
// Privilege-sensitive notes: match A1* plan names and/or immutable legacy_company_id only.
// Never match mutable company display names (same isolation as assignLegacyPlanToA1Companies).
_, err = s.Pool.Exec(ctx, `
UPDATE company_plans cp
SET notes = CASE
WHEN COALESCE(cp.notes, '') = '' THEN $1
WHEN cp.notes LIKE '%' || $1 || '%' THEN cp.notes
ELSE cp.notes || E'\n' || $1
END,
updated_at = now()
FROM plans p, companies c
WHERE cp.plan_id = p.id
AND cp.company_id = c.id
AND cp.is_active = true
AND cp.contract_end_date IS NULL
AND (
lower(p.name) = 'a1'
OR lower(p.name) LIKE 'a1 %'
OR lower(p.name) LIKE 'a1-%'
OR lower(p.name) LIKE 'a1_%'
OR lower(p.name) LIKE '%a1 slovenija%'
OR lower(COALESCE(c.legacy_company_id, '')) = lower($2)
)`, paygNote, A1LegacyCompanyID)
if err != nil && !isUndefinedColumn(err) && !isUndefinedRelation(err) {
return err
}
return nil
}
func looksLikeLegacySparse(overrides map[string]bool) bool {
if len(overrides) == 0 {
return false
}
for _, v := range overrides {
if v {
return false
}
}
return true
}
// shouldWriteA1PaygFeatures reports whether A1 plan features need hygiene to the
// tailored PAYG matrix (Stores + Marketing + Integrations explicitly OFF).
func shouldWriteA1PaygFeatures(overrides map[string]bool) bool {
if featuresMapEmpty(overrides) || looksLikeLegacySparse(overrides) || isEnableAllOverrides(overrides) {
return true
}
for _, k := range FeatureCatalogKeys {
if !A1PaygFeatureDenied(k) {
continue
}
v, ok := overrides[k]
if !ok || v {
return true
}
}
return false
}
@@ -0,0 +1,146 @@
package billing
import "testing"
func TestIsLegacyPlanName(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"Legacy": true,
"legacy": true,
"A1": true,
"A1 Slovenija": true,
"a1-deal": true,
"A1 Deal": true,
"My Legacy Co": false, // exact "legacy" only — substring must not match
"Free": false,
"Enterprise": false,
"Merkur": false,
"": false,
}
for in, want := range cases {
if got := IsLegacyPlanName(in); got != want {
t.Fatalf("IsLegacyPlanName(%q)=%v want %v", in, got, want)
}
}
}
func TestDefaultPlanFeaturesLegacyMatrix(t *testing.T) {
t.Parallel()
for _, name := range []string{"A1", "Legacy", "A1 Slovenija"} {
m := DefaultPlanFeatures(name, false)
if len(m) != len(FeatureCatalogKeys) {
t.Fatalf("%s size=%d want %d", name, len(m), len(FeatureCatalogKeys))
}
for _, k := range []string{
"dashboard.overview",
"catalog.products",
"feeds.list",
"feeds.export_feeds",
"catalog.categories",
"catalog.attributes",
"catalog.standard_fields",
"billing.overview",
"settings.profile",
"capability.ai_processing",
"catalog.products.process_ai_titles",
"capability.storage_limit",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
} {
if !m[k] {
t.Fatalf("%s should allow %s", name, k)
}
}
for _, k := range []string{
"processing.monitor",
"stores.hub",
"marketing.campaigns",
"integrations.ai",
"support.center",
"shell.support_notifications",
"capability.byok",
} {
if m[k] {
t.Fatalf("%s should deny %s", name, k)
}
}
}
// Explicit Legacy package (or is_legacy on non-custom) forces legacy matrix.
flagged := DefaultPlanFeaturesEx("Legacy", false, true)
if flagged["processing.monitor"] {
t.Fatal("is_legacy flag must apply legacy matrix when not custom")
}
// is_custom wins over is_legacy for PAYG core, but Stores/Marketing/Integrations stay denied.
customWins := DefaultPlanFeaturesEx("A1", true, true)
if !customWins["processing.monitor"] {
t.Fatal("is_custom must win over is_legacy for PAYG core features")
}
if customWins["stores.hub"] || customWins["marketing.campaigns"] || customWins["integrations.ai"] || customWins["integrations.email"] {
t.Fatal("A1 PAYG must still deny Stores, Marketing, and Integrations")
}
if customWins["dashboard.etl_gaps"] || customWins["dashboard.store_reconnect"] || customWins["dashboard.migrated_checklist"] {
t.Fatal("A1 PAYG must deny cutover honesty chrome (ETL gaps / reconnect / migrated checklist)")
}
}
func TestResolvePlanProfile(t *testing.T) {
t.Parallel()
if ResolvePlanProfile("A1", false, false) != PlanProfileLegacy {
t.Fatal("A1 without is_custom → legacy")
}
if ResolvePlanProfile("A1", true, false) != PlanProfileCustom {
t.Fatal("A1 is_custom PAYG → custom")
}
if ResolvePlanProfile("A1", true, true) != PlanProfileCustom {
t.Fatal("A1 is_custom wins over is_legacy flag")
}
if ResolvePlanProfile("Free", false, false) != PlanProfileFree {
t.Fatal("Free → free")
}
if ResolvePlanProfile("Growth", false, false) != PlanProfileGrowth {
t.Fatal("Growth → growth")
}
if ResolvePlanProfile("Enterprise", true, false) != PlanProfileEnterprise {
t.Fatal("Enterprise → enterprise")
}
if ResolvePlanProfile("Merkur", false, false) != PlanProfileCustom {
t.Fatal("Merkur → custom")
}
}
func TestIsLegacyCompanyID(t *testing.T) {
t.Parallel()
if !IsLegacyCompanyID(A1LegacyCompanyID) {
t.Fatal("A1 id should match")
}
if IsLegacyCompanyID("other") {
t.Fatal("other id should not match")
}
}
func TestIsA1CohortCompany(t *testing.T) {
t.Parallel()
if !IsA1CohortCompany(A1LegacyCompanyID, "Anything") {
t.Fatal("legacy id must match")
}
// Mutable display names must never grant cohort privileges (register/rename).
for _, name := range []string{"A1 Slovenija", "Local Demo Co", "A1", "a1", "Retail A1", "Baikal"} {
if IsA1CohortCompany("", name) {
t.Fatalf("name-only %q must not match", name)
}
}
}
func TestShouldWriteLegacySparse(t *testing.T) {
t.Parallel()
if !shouldWriteLegacySparse(nil) || !shouldWriteLegacySparse(map[string]bool{}) {
t.Fatal("empty should write")
}
if !shouldWriteLegacySparse(AllRegistryFeatures(true)) {
t.Fatal("enable-all should repair")
}
partial := map[string]bool{"catalog.products": false}
if shouldWriteLegacySparse(partial) {
t.Fatal("admin partial customization must not be wiped")
}
}
+126
View File
@@ -0,0 +1,126 @@
package billing
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// CompanyWithoutActivePlan is a tenant with no is_active company_plans row.
type CompanyWithoutActivePlan struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
LegacyCompanyID string `json:"legacy_company_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PlanIDByName resolves a plan by case-insensitive name (lowest id wins).
func (s *Service) PlanIDByName(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, ErrPlanNameRequired
}
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPlanNotFound
}
if err != nil {
return 0, err
}
return id, nil
}
// HasActivePlan reports whether the company has an is_active company_plans row.
func (s *Service) HasActivePlan(ctx context.Context, companyID uuid.UUID) (bool, error) {
var has bool
err := s.Pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM company_plans WHERE company_id = $1 AND is_active = true
)`, companyID).Scan(&has)
return has, err
}
// ListCompaniesWithoutActivePlan returns companies with no active plan assignment.
// Safe read-only operator / cutover helper (never mutates).
// Excludes the A1 cohort (legacy_company_id) — A1 plans are managed separately.
func (s *Service) ListCompaniesWithoutActivePlan(ctx context.Context, limit, offset int) ([]CompanyWithoutActivePlan, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
rows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name, c.language, COALESCE(c.legacy_company_id, ''), c.created_at
FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($3)
ORDER BY c.created_at DESC
LIMIT $1 OFFSET $2`, limit, offset, A1LegacyCompanyID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]CompanyWithoutActivePlan, 0)
for rows.Next() {
var c CompanyWithoutActivePlan
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.LegacyCompanyID, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CountCompaniesWithoutActivePlan returns how many companies lack an active plan.
// Excludes the A1 cohort (same filter as ListCompaniesWithoutActivePlan).
func (s *Service) CountCompaniesWithoutActivePlan(ctx context.Context) (int64, error) {
var n int64
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($1)`, A1LegacyCompanyID).Scan(&n)
return n, err
}
// AssignPlanIfMissing assigns planID only when the company has no active plan.
// Does not deactivate or replace an existing active plan (safe cutover repair).
// Returns assigned=false when the company already has an active plan.
func (s *Service) AssignPlanIfMissing(ctx context.Context, companyID uuid.UUID, planID int64) (assigned bool, err error) {
has, err := s.HasActivePlan(ctx, companyID)
if err != nil {
return false, err
}
if has {
return false, nil
}
if err := s.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
return false, err
}
return true, nil
}
// AssignPlanByNameIfMissing resolves planName then AssignPlanIfMissing.
func (s *Service) AssignPlanByNameIfMissing(ctx context.Context, companyID uuid.UUID, planName string) (assigned bool, err error) {
planID, err := s.PlanIDByName(ctx, planName)
if err != nil {
return false, err
}
return s.AssignPlanIfMissing(ctx, companyID, planID)
}
@@ -0,0 +1,142 @@
package billing
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestPlanIDByNameRequiresName(t *testing.T) {
t.Parallel()
svc := &Service{}
_, err := svc.PlanIDByName(context.Background(), " ")
if !errors.Is(err, ErrPlanNameRequired) {
t.Fatalf("got %v, want ErrPlanNameRequired", err)
}
}
func TestAssignPlanIfMissingSkipsExisting(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
svc := &Service{Pool: pg}
if err := svc.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
freeID, err := svc.PlanIDByName(ctx, "Free")
if err != nil {
t.Fatal(err)
}
starterID, err := svc.PlanIDByName(ctx, "Starter")
if err != nil {
t.Fatal(err)
}
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "missing-plans-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
assigned, err := svc.AssignPlanIfMissing(ctx, companyID, freeID)
if err != nil {
t.Fatal(err)
}
if !assigned {
t.Fatal("expected first assign to succeed")
}
assigned, err = svc.AssignPlanIfMissing(ctx, companyID, starterID)
if err != nil {
t.Fatal(err)
}
if assigned {
t.Fatal("must not overwrite an existing active plan")
}
has, err := svc.HasActivePlan(ctx, companyID)
if err != nil || !has {
t.Fatalf("has active plan: has=%v err=%v", has, err)
}
var planID int64
err = pg.QueryRow(ctx, `SELECT plan_id FROM company_plans WHERE company_id = $1 AND is_active = true`, companyID).Scan(&planID)
if err != nil {
t.Fatal(err)
}
if planID != freeID {
t.Fatalf("active plan_id=%d, want Free id=%d", planID, freeID)
}
missing, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
if err != nil {
t.Fatal(err)
}
for _, c := range missing {
if c.ID == companyID {
t.Fatal("company with active plan must not appear in without-plan list")
}
}
}
func TestListCompaniesWithoutActivePlanIncludesBareCompany(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
svc := &Service{Pool: pg}
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "no-plan-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
found := false
rows, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
if err != nil {
t.Fatal(err)
}
for _, c := range rows {
if c.ID == companyID {
found = true
break
}
}
if !found {
t.Fatal("bare company must appear in without-plan list")
}
}
@@ -0,0 +1,71 @@
package billing
import (
"context"
"strings"
)
// IsEphemeralTestPlanName reports integration-test plan rows that should stay
// out of the admin "catalog" filter (consume-contention-*, claim-test-plan-*, multi-plan-*).
func IsEphemeralTestPlanName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return false
}
return strings.HasPrefix(n, "consume-contention-") ||
strings.HasPrefix(n, "claim-test-plan-") ||
strings.HasPrefix(n, "multi-plan-")
}
// IsObsoleteLadderPlanName reports pre-v2 public ladder leftovers that must never
// appear on Choose your plan (Basic / Professional / Merkur / Mini).
func IsObsoleteLadderPlanName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "basic", "professional", "mini", "merkur", "meur", "merkur trial":
return true
default:
return false
}
}
// EnsurePlanCatalogHygiene soft-hides obsolete ladder leftovers and forces EPREL
// on for every plan (public EU data — never credit-gated).
//
// Soft-hide: mark Basic/Professional/… as is_custom with an archived description
// so they never look like self-serve product rows. Rows are not deleted (may be
// referenced by history). Ephemeral test plans are left in DB but filtered in admin UI.
func (s *Service) EnsurePlanCatalogHygiene(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
_, err := s.Pool.Exec(ctx, `
UPDATE plans SET
is_custom = true,
description = CASE
WHEN description IS NULL OR btrim(description) = '' THEN
'Archived pre-v2 plan (hidden from Choose your plan)'
WHEN description LIKE 'Archived pre-v2%' THEN description
ELSE 'Archived pre-v2 plan (hidden from Choose your plan). ' || description
END,
updated_at = now()
WHERE lower(name) IN ('basic', 'professional', 'mini', 'merkur', 'meur', 'merkur trial')
AND is_custom = false`)
if err != nil {
return err
}
// Never leave an explicit capability.eprel=false override — EPREL is free on all plans.
_, err = s.Pool.Exec(ctx, `
UPDATE plans
SET features = features || '{"capability.eprel": true}'::jsonb,
updated_at = now()
WHERE features ? 'capability.eprel'
AND (features->>'capability.eprel') = 'false'`)
if err != nil {
if isUndefinedColumn(err) {
return nil
}
return err
}
return nil
}
@@ -0,0 +1,52 @@
package billing
import "testing"
func TestIsEphemeralTestPlanName(t *testing.T) {
t.Parallel()
for _, name := range []string{
"consume-contention-abc",
"claim-test-plan-14c023e2",
"multi-plan-a6b5d552",
} {
if !IsEphemeralTestPlanName(name) {
t.Fatalf("expected ephemeral: %q", name)
}
}
for _, name := range []string{"Free", "A1", "Platform Demo", "Legacy", ""} {
if IsEphemeralTestPlanName(name) {
t.Fatalf("expected non-ephemeral: %q", name)
}
}
}
func TestIsObsoleteLadderPlanName(t *testing.T) {
t.Parallel()
for _, name := range []string{"Basic", "Professional", "Merkur trial", "Mini", "Meur"} {
if !IsObsoleteLadderPlanName(name) {
t.Fatalf("expected obsolete: %q", name)
}
if IsPublicProductPlan(name) {
t.Fatalf("obsolete must not be public: %q", name)
}
}
for _, name := range []string{"Free", "Starter", "A1", "Platform Demo"} {
if IsObsoleteLadderPlanName(name) {
t.Fatalf("expected retained: %q", name)
}
}
}
func TestPlanAllowsEPRELOnFree(t *testing.T) {
t.Parallel()
if !PlanAllowsFeature("Free", false, nil, "capability.eprel") {
t.Fatal("Free must include capability.eprel")
}
if !PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": true}, "capability.eprel") {
t.Fatal("explicit true override must allow EPREL")
}
// Explicit false is still honored at plan_allows level; hygiene clears it in DB.
if PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": false}, "capability.eprel") {
t.Fatal("explicit false override still wins until hygiene clears it")
}
}
+650
View File
@@ -0,0 +1,650 @@
package billing
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
ErrUnknownFeatureKey = errors.New("unknown feature key")
ErrUnknownFeatureSection = errors.New("unknown feature section")
ErrInvalidFeatureGates = errors.New("invalid feature gates payload")
ErrFeatureDisabled = errors.New("feature_disabled")
)
// FeatureGatesView is the admin global master-switch snapshot.
type FeatureGatesView struct {
Sections map[string]bool `json:"sections"`
Features map[string]bool `json:"features"`
}
// PlanFeaturesView is the admin per-plan feature editor payload.
type PlanFeaturesView struct {
PlanID int64 `json:"plan_id"`
PlanName string `json:"plan_name"`
IsCustom bool `json:"is_custom"`
IsLegacy bool `json:"is_legacy"`
Features map[string]bool `json:"features"`
ResolvedFeatures map[string]bool `json:"resolved_features"`
}
// Capabilities is the tenant-resolved plan ∩ global feature matrix.
type Capabilities struct {
PlanID int64 `json:"plan_id,omitempty"`
PlanName string `json:"plan_name"`
IsCustom bool `json:"is_custom"`
IsLegacy bool `json:"is_legacy"`
HasActivePlan bool `json:"has_active_plan"`
Features map[string]bool `json:"features"`
Sections map[string]bool `json:"sections"`
DisabledFeatures []string `json:"disabled_features"`
FeatureETag string `json:"feature_etag"`
Entitlements Entitlements `json:"entitlements"`
}
// FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
type FeatureGatesUpdate struct {
Sections map[string]bool `json:"sections"`
Features map[string]bool `json:"features"`
}
// PlanFeaturesUpdate is the PUT /api/admin/plans/{id}/features body.
// Features replaces the stored overrides object (sparse map).
type PlanFeaturesUpdate struct {
Features map[string]bool `json:"features"`
}
// SectionGateUpdate is the PUT /api/admin/feature-gates/sections/{section} body.
// Enabled is required (*bool) so omitting the field cannot silently disable a section.
type SectionGateUpdate struct {
Enabled *bool `json:"enabled"`
}
// DefaultPlanFeatures returns the expanded default matrix for a plan name.
// Legacy (A1 / is_legacy patterns) uses the image-nav allow-list — not custom all-ON.
// Custom packages (isCustom, non-legacy) default all registry keys ON.
func DefaultPlanFeatures(planName string, isCustom bool) map[string]bool {
return DefaultPlanFeaturesEx(planName, isCustom, IsLegacyPlanName(planName))
}
// DefaultPlanFeaturesEx is DefaultPlanFeatures with an explicit is_legacy flag.
func DefaultPlanFeaturesEx(planName string, isCustom, isLegacy bool) map[string]bool {
out := make(map[string]bool, len(FeatureCatalogKeys))
// Custom deals get enable-all, except A1* PAYG which keeps Stores + AI off.
if IsCustomPackage(planName, isCustom) {
if IsLegacyPlanName(planName) {
return A1PaygPlanFeatures()
}
for _, k := range FeatureCatalogKeys {
out[k] = true
}
return out
}
if IsLegacyPlan(planName, isLegacy) {
for _, k := range FeatureCatalogKeys {
out[k] = LegacyFeatureAllowed(k)
}
return out
}
norm := strings.ToLower(strings.TrimSpace(planName))
for _, k := range FeatureCatalogKeys {
allowed := true
switch norm {
case "", "free":
allowed = !freePlanFeatureOff(k)
case "starter", "plus":
allowed = !starterPlanFeatureOff(k)
default:
// Growth / Business / Scale / named public ladder: all ON except unknown.
allowed = true
}
out[k] = allowed
}
return out
}
// PlanAllowsFeature resolves plan_allows(key) without global gates.
func PlanAllowsFeature(planName string, isCustom bool, overrides map[string]bool, key string) bool {
return PlanAllowsFeatureEx(planName, isCustom, IsLegacyPlanName(planName), overrides, key)
}
// PlanAllowsFeatureEx is PlanAllowsFeature with an explicit is_legacy flag.
func PlanAllowsFeatureEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, key string) bool {
// A1 PAYG deny list always wins — stale stored matrices must not re-enable
// Stores / Marketing / Integrations after seed hygiene expands the deny set.
if IsCustomPackage(planName, isCustom) && IsLegacyPlanName(planName) && A1PaygFeatureDenied(key) {
return false
}
if overrides != nil {
if v, ok := overrides[key]; ok {
return v
}
}
if IsCustomPackage(planName, isCustom) {
if IsLegacyPlanName(planName) {
return !A1PaygFeatureDenied(key)
}
return true
}
if IsLegacyPlan(planName, isLegacy) {
defaults := DefaultPlanFeaturesEx(planName, isCustom, true)
if v, ok := defaults[key]; ok {
return v
}
return false
}
defaults := DefaultPlanFeaturesEx(planName, false, false)
if v, ok := defaults[key]; ok {
return v
}
return false
}
// ResolveEffectiveFeatures applies plan ∩ global section ∩ global feature.
func ResolveEffectiveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) {
return ResolveEffectiveFeaturesEx(planName, isCustom, IsLegacyPlanName(planName), overrides, gates)
}
// ResolveEffectiveFeaturesEx is ResolveEffectiveFeatures with an explicit is_legacy flag.
func ResolveEffectiveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) {
sections = make(map[string]bool, len(FeatureSections))
for _, s := range FeatureSections {
enabled := true
if gates.Sections != nil {
if v, ok := gates.Sections[s]; ok {
enabled = v
}
}
sections[s] = enabled
}
features = make(map[string]bool, len(FeatureCatalogKeys))
disabled = make([]string, 0)
for _, key := range FeatureCatalogKeys {
allowed := PlanAllowsFeatureEx(planName, isCustom, isLegacy, overrides, key)
sec, _ := SectionOfFeature(key)
if !sections[sec] {
allowed = false
}
if gates.Features != nil {
if v, ok := gates.Features[key]; ok && !v {
allowed = false
}
}
features[key] = allowed
if !allowed {
disabled = append(disabled, key)
}
}
sort.Strings(disabled)
return features, sections, disabled
}
func featureETag(features map[string]bool) string {
keys := make([]string, 0, len(features))
for k, v := range features {
if v {
keys = append(keys, k)
}
}
sort.Strings(keys)
sum := sha256.Sum256([]byte(strings.Join(keys, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
// CapabilitiesResponseETag is a strong HTTP ETag for GET /api/billing/capabilities.
// It covers the feature map plus plan identity and remaining credits so conditional
// GETs do not skip wallet updates when only credits change.
func CapabilitiesResponseETag(c Capabilities) string {
raw := fmt.Sprintf("%s|p%d|r%d|%t|%s", c.FeatureETag, c.PlanID, c.Entitlements.RemainingCredits, c.HasActivePlan, c.PlanName)
sum := sha256.Sum256([]byte(raw))
return `"` + "sha256:" + hex.EncodeToString(sum[:8]) + `"`
}
func validateFeatureOverrides(features map[string]bool) error {
if features == nil {
return nil
}
for k := range features {
if !IsKnownFeatureKey(k) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k)
}
}
return nil
}
func validateGatesUpdate(sections, features map[string]bool) error {
for s := range sections {
if !IsKnownFeatureSection(s) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureSection, s)
}
}
for k := range features {
if !IsKnownFeatureKey(k) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k)
}
}
return nil
}
func decodeFeaturesJSON(raw []byte) (map[string]bool, error) {
if len(raw) == 0 {
return map[string]bool{}, nil
}
var m map[string]bool
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
if m == nil {
m = map[string]bool{}
}
return m, nil
}
func encodeFeaturesJSON(m map[string]bool) ([]byte, error) {
if m == nil {
m = map[string]bool{}
}
return json.Marshal(m)
}
func emptyGatesView() FeatureGatesView {
sections := make(map[string]bool, len(FeatureSections))
for _, s := range FeatureSections {
sections[s] = true
}
return FeatureGatesView{
Sections: sections,
Features: map[string]bool{},
}
}
func cloneGatesView(v FeatureGatesView) FeatureGatesView {
out := FeatureGatesView{
Sections: make(map[string]bool, len(v.Sections)),
Features: make(map[string]bool, len(v.Features)),
}
for k, enabled := range v.Sections {
out.Sections[k] = enabled
}
for k, enabled := range v.Features {
out.Features[k] = enabled
}
return out
}
func (s *Service) invalidateFeatureGatesCache() {
if s == nil {
return
}
s.gatesMu.Lock()
s.gatesCache = nil
s.gatesCachedAt = time.Time{}
s.gatesMu.Unlock()
}
func (s *Service) storeFeatureGatesCache(view FeatureGatesView) {
if s == nil {
return
}
copied := cloneGatesView(view)
s.gatesMu.Lock()
s.gatesCache = &copied
s.gatesCachedAt = time.Now()
s.gatesMu.Unlock()
}
// GetFeatureGates returns global section/feature master switches (missing => enabled).
func (s *Service) GetFeatureGates(ctx context.Context) (FeatureGatesView, error) {
if s == nil || s.Pool == nil {
return emptyGatesView(), nil
}
s.gatesMu.RLock()
if s.gatesCache != nil && time.Since(s.gatesCachedAt) < featureGatesCacheTTL {
cached := cloneGatesView(*s.gatesCache)
s.gatesMu.RUnlock()
return cached, nil
}
s.gatesMu.RUnlock()
view, err := s.loadFeatureGates(ctx)
if err != nil {
return FeatureGatesView{}, err
}
s.storeFeatureGatesCache(view)
return cloneGatesView(view), nil
}
func (s *Service) loadFeatureGates(ctx context.Context) (FeatureGatesView, error) {
view := emptyGatesView()
rows, err := s.Pool.Query(ctx, `
SELECT gate_key, kind, enabled FROM platform_feature_gates`)
if err != nil {
// Table may not exist yet (migration pending).
if isUndefinedRelation(err) {
return view, nil
}
return FeatureGatesView{}, err
}
defer rows.Close()
for rows.Next() {
var key, kind string
var enabled bool
if err := rows.Scan(&key, &kind, &enabled); err != nil {
return FeatureGatesView{}, err
}
switch kind {
case "section":
view.Sections[key] = enabled
case "feature":
view.Features[key] = enabled
}
}
if err := rows.Err(); err != nil {
return FeatureGatesView{}, err
}
return view, nil
}
// SetFeatureGates upserts provided section/feature gates (partial). Omitted maps are left unchanged.
func (s *Service) SetFeatureGates(ctx context.Context, sections, features map[string]bool, updatedBy *uuid.UUID) (FeatureGatesView, error) {
if err := validateGatesUpdate(sections, features); err != nil {
return FeatureGatesView{}, err
}
if s == nil || s.Pool == nil {
return FeatureGatesView{}, errors.New("billing service unavailable")
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return FeatureGatesView{}, err
}
defer tx.Rollback(ctx)
upsert := func(key, kind string, enabled bool) error {
_, err := tx.Exec(ctx, `
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at, updated_by)
VALUES ($1, $2, $3, now(), $4)
ON CONFLICT (gate_key) DO UPDATE SET
kind = EXCLUDED.kind,
enabled = EXCLUDED.enabled,
updated_at = now(),
updated_by = EXCLUDED.updated_by`, key, kind, enabled, updatedBy)
return err
}
for k, v := range sections {
if err := upsert(k, "section", v); err != nil {
return FeatureGatesView{}, err
}
}
for k, v := range features {
if err := upsert(k, "feature", v); err != nil {
return FeatureGatesView{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return FeatureGatesView{}, err
}
s.invalidateFeatureGatesCache()
return s.GetFeatureGates(ctx)
}
// SetSectionGate enables/disables one section for ALL plans (global master switch).
func (s *Service) SetSectionGate(ctx context.Context, section string, enabled bool, updatedBy *uuid.UUID) (FeatureGatesView, error) {
section = strings.TrimSpace(section)
if !IsKnownFeatureSection(section) {
return FeatureGatesView{}, fmt.Errorf("%w: %s", ErrUnknownFeatureSection, section)
}
return s.SetFeatureGates(ctx, map[string]bool{section: enabled}, nil, updatedBy)
}
func (s *Service) loadPlanFeaturesRow(ctx context.Context, planID int64) (name string, isCustom bool, isLegacy bool, overrides map[string]bool, err error) {
if s == nil || s.Pool == nil {
return "", false, false, nil, errors.New("billing service unavailable")
}
var raw []byte
err = s.Pool.QueryRow(ctx, `
SELECT name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &isLegacy, &raw)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, false, nil, ErrPlanNotFound
}
if err != nil {
if isUndefinedColumn(err) {
// Pre-migration: fall back without is_legacy and/or features.
err = s.Pool.QueryRow(ctx, `
SELECT name, is_custom, COALESCE(features, '{}'::jsonb)
FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &raw)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, false, nil, ErrPlanNotFound
}
if err != nil {
if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `SELECT name, is_custom FROM plans WHERE id = $1`, planID).
Scan(&name, &isCustom)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, false, nil, ErrPlanNotFound
}
if err != nil {
return "", false, false, nil, err
}
return name, isCustom, IsLegacyPlanName(name), map[string]bool{}, nil
}
return "", false, false, nil, err
}
overrides, err = decodeFeaturesJSON(raw)
if err != nil {
return "", false, false, nil, err
}
return name, isCustom, IsLegacyPlanName(name), overrides, nil
}
return "", false, false, nil, err
}
overrides, err = decodeFeaturesJSON(raw)
if err != nil {
return "", false, false, nil, err
}
if !isLegacy {
isLegacy = IsLegacyPlanName(name)
}
return name, isCustom, isLegacy, overrides, nil
}
func planFeaturesView(planID int64, name string, isCustom, isLegacy bool, overrides map[string]bool) PlanFeaturesView {
if overrides == nil {
overrides = map[string]bool{}
}
resolved := make(map[string]bool, len(FeatureCatalogKeys))
for _, k := range FeatureCatalogKeys {
resolved[k] = PlanAllowsFeatureEx(name, isCustom, isLegacy, overrides, k)
}
return PlanFeaturesView{
PlanID: planID,
PlanName: name,
IsCustom: isCustom,
IsLegacy: IsLegacyPlan(name, isLegacy),
Features: overrides,
ResolvedFeatures: resolved,
}
}
// GetPlanFeatures returns stored overrides + plan_allows resolved matrix (globals ignored).
func (s *Service) GetPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return PlanFeaturesView{}, err
}
return planFeaturesView(planID, name, isCustom, isLegacy, overrides), nil
}
// SetPlanFeatures replaces the plan's features override object.
func (s *Service) SetPlanFeatures(ctx context.Context, planID int64, features map[string]bool) (PlanFeaturesView, error) {
if s == nil || s.Pool == nil {
return PlanFeaturesView{}, errors.New("billing service unavailable")
}
if features == nil {
features = map[string]bool{}
}
if err := validateFeatureOverrides(features); err != nil {
return PlanFeaturesView{}, err
}
raw, err := encodeFeaturesJSON(features)
if err != nil {
return PlanFeaturesView{}, err
}
tag, err := s.Pool.Exec(ctx, `
UPDATE plans SET features = $2::jsonb, updated_at = now() WHERE id = $1`, planID, raw)
if err != nil {
if isUndefinedColumn(err) {
return PlanFeaturesView{}, errors.New("plans.features column missing — run migration 026_plan_features")
}
return PlanFeaturesView{}, err
}
if tag.RowsAffected() == 0 {
return PlanFeaturesView{}, ErrPlanNotFound
}
return s.GetPlanFeatures(ctx, planID)
}
// EnableAllPlanFeatures sets every registry key to true on the plan (custom packages helper).
func (s *Service) EnableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(true))
}
// DisableAllPlanFeatures sets every registry key to false on the plan.
func (s *Service) DisableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(false))
}
// CapabilitiesForCompany returns effective features for the company's active plan ∩ globals.
func (s *Service) CapabilitiesForCompany(ctx context.Context, companyID uuid.UUID) (Capabilities, error) {
if s == nil || s.Pool == nil {
return Capabilities{}, errors.New("billing service unavailable")
}
gates, err := s.GetFeatureGates(ctx)
if err != nil {
return Capabilities{}, err
}
var planID int64
var planName string
var isCustom bool
var isLegacy bool
var monthly *int
var isTrial bool
var raw []byte
hasPlan := false
err = s.Pool.QueryRow(ctx, `
SELECT p.id, p.name, p.is_custom, COALESCE(p.is_legacy, false), p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb)
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planID, &planName, &isCustom, &isLegacy, &monthly, &isTrial, &raw)
if err == nil {
hasPlan = true
} else if errors.Is(err, pgx.ErrNoRows) {
planName = "Free"
raw = []byte("{}")
} else if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb)
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planID, &planName, &isCustom, &monthly, &isTrial, &raw)
if err == nil {
hasPlan = true
isLegacy = IsLegacyPlanName(planName)
} else if errors.Is(err, pgx.ErrNoRows) {
planName = "Free"
raw = []byte("{}")
} else if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planID, &planName, &isCustom, &monthly, &isTrial)
if err == nil {
hasPlan = true
raw = []byte("{}")
isLegacy = IsLegacyPlanName(planName)
} else if errors.Is(err, pgx.ErrNoRows) {
planName = "Free"
raw = []byte("{}")
} else {
return Capabilities{}, err
}
} else {
return Capabilities{}, err
}
} else {
return Capabilities{}, err
}
overrides, err := decodeFeaturesJSON(raw)
if err != nil {
return Capabilities{}, err
}
var total, used int
_ = s.Pool.QueryRow(ctx, `SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
remaining := RemainingCreditsClamped(total, used)
monthlyVal := 0
if monthly != nil {
monthlyVal = *monthly
}
ent := ComputeEntitlements(planName, monthlyVal, remaining, isTrial)
isLegacy = IsLegacyPlan(planName, isLegacy)
features, sections, disabled := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates)
out := Capabilities{
PlanName: planName,
IsCustom: isCustom,
IsLegacy: isLegacy,
HasActivePlan: hasPlan,
Features: features,
Sections: sections,
DisabledFeatures: disabled,
FeatureETag: featureETag(features),
Entitlements: ent,
}
if hasPlan {
out.PlanID = planID
}
return out, nil
}
func isUndefinedRelation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "does not exist") && strings.Contains(msg, "platform_feature_gates")
}
func isUndefinedColumn(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
missing := strings.Contains(msg, "does not exist") || strings.Contains(msg, "undefined column") || strings.Contains(msg, "undefined_column")
if !missing {
return false
}
// Postgres: column "x" of relation "y" does not exist — features or is_legacy pre-migration.
return strings.Contains(msg, "column") || strings.Contains(msg, "features") || strings.Contains(msg, "is_legacy")
}
@@ -0,0 +1,52 @@
package billing
import (
"strings"
"testing"
)
func TestIsPublicProductPlan(t *testing.T) {
public := []string{"Free", "Starter", "Growth", "Business", "Enterprise", " free ", "GROWTH"}
for _, name := range public {
if !IsPublicProductPlan(name) {
t.Fatalf("expected public: %q", name)
}
}
hidden := []string{"A1", "Merkur trial", "Merkur", "Meur", "Basic", "Professional", "Mini", ""}
for _, name := range hidden {
if IsPublicProductPlan(name) {
t.Fatalf("expected hidden from public pricing: %q", name)
}
}
}
func TestFilterPublicPlans(t *testing.T) {
all := []Plan{
{Name: "Basic"},
{Name: "A1", IsCustom: true},
{Name: "Merkur trial", IsCustom: true},
{Name: "Free"},
{Name: "Starter"},
{Name: "Growth"},
{Name: "Business"},
{Name: "Enterprise", IsCustom: true},
{Name: "Professional"},
}
out := make([]Plan, 0, 5)
for _, p := range all {
if IsPublicProductPlan(p.Name) {
out = append(out, p)
}
}
if len(out) != 5 {
t.Fatalf("got %d public plans, want 5: %+v", len(out), out)
}
for _, p := range out {
key := strings.ToLower(p.Name)
switch key {
case "free", "starter", "growth", "business", "enterprise":
default:
t.Fatalf("unexpected public plan %q", p.Name)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) {
t.Helper()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() { pg.Close() })
return pg, ctx, cancel
}
func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID {
t.Helper()
companyID := uuid.New()
_, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
return companyID
}
func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int {
t.Helper()
var total int
err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total)
if err != nil {
return 0
}
return total
}
func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string {
t.Helper()
var name string
err := pg.QueryRow(ctx, `
SELECT lower(p.name) FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&name)
if err != nil {
return ""
}
return name
}
func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
if err := billing.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
}
res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"})
if err != nil {
t.Fatal(err)
}
if !res.Mock || !res.Applied {
t.Fatalf("expected mock applied checkout, got %#v", res)
}
if activePlanName(t, pg, ctx, companyID) != "starter" {
t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID))
}
want := MonthlyCreditsForPlan("Starter", 0)
if got := creditTotal(t, pg, ctx, companyID); got != want {
t.Fatalf("credits=%d want %d", got, want)
}
var subID *string
_ = pg.QueryRow(ctx, `
SELECT stripe_subscription_id FROM company_plans
WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID)
if subID == nil || *subID == "" {
t.Fatal("mock checkout must set stripe_subscription_id")
}
}
func TestMockCreditPackCheckoutGrants(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
}
before := creditTotal(t, pg, ctx, companyID)
res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small")
if err != nil {
t.Fatal(err)
}
if !res.Mock || !res.Applied {
t.Fatalf("expected mock applied pack, got %#v", res)
}
pack, _ := CreditPackByID("small")
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
t.Fatalf("credits=%d want %d", got, before+pack.Credits)
}
}
func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret
}
eventID := "evt_mock_credit_" + companyID.String()[:8]
payload, err := json.Marshal(map[string]any{
"id": eventID,
"type": "checkout.session.completed",
"data": map[string]any{
"object": map[string]any{
"id": "cs_mock_1",
"client_reference_id": companyID.String(),
"metadata": map[string]string{
"kind": "credit_pack",
"pack": "tiny",
"credits": "9999", // must be ignored for catalog pack
},
},
},
})
if err != nil {
t.Fatal(err)
}
before := creditTotal(t, pg, ctx, companyID)
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
pack, _ := CreditPackByID("tiny")
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits)
}
mid := creditTotal(t, pg, ctx, companyID)
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
if got := creditTotal(t, pg, ctx, companyID); got != mid {
t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid)
}
}
func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
if err := billing.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true},
}
if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil {
t.Fatal(err)
}
if activePlanName(t, pg, ctx, companyID) != "plus" {
t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID))
}
eventID := "evt_mock_del_" + companyID.String()[:8]
payload, err := json.Marshal(map[string]any{
"id": eventID,
"type": "customer.subscription.deleted",
"data": map[string]any{
"object": map[string]any{
"id": "sub_mock_del",
"customer": "cus_mock",
"status": "canceled",
"metadata": map[string]string{"company_id": companyID.String()},
},
},
})
if err != nil {
t.Fatal(err)
}
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
if got := activePlanName(t, pg, ctx, companyID); got != "free" {
t.Fatalf("after delete plan=%q want free", got)
}
}
func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
secret := "whsec_mock_local"
s := &StripeService{
Pool: pg,
Billing: &Service{Pool: pg},
Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret},
}
payload, err := json.Marshal(map[string]any{
"id": "evt_signed_" + companyID.String()[:8],
"type": "ping",
"data": map[string]any{"object": map[string]any{}},
})
if err != nil {
t.Fatal(err)
}
if err := s.HandleWebhook(ctx, payload, ""); err == nil {
t.Fatal("unsigned must fail when webhook secret set")
}
sig := signStripePayload(t, secret, payload)
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
t.Fatalf("valid signature under ForceMock: %v", err)
}
// Second delivery is an idempotent no-op.
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
t.Fatal(err)
}
var n int
if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`,
fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("claim rows=%d want 1", n)
}
}
@@ -0,0 +1,304 @@
package billing
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// SalesQuoteCheckoutInput drives Checkout for an admin-prepared custom deal.
type SalesQuoteCheckoutInput struct {
QuoteID uuid.UUID
CompanyID uuid.UUID
PlanID int64
PlanName string
Email string
CompanyName string
Currency string
TotalAmountCents int
InstallmentCount int
InstallmentInterval string // month | quarter | year
InstallmentAmountCents int
}
// SalesQuoteCheckoutResult is returned to admin after preparing Checkout for a quote.
type SalesQuoteCheckoutResult struct {
URL string `json:"url"`
Mock bool `json:"mock"`
Applied bool `json:"applied,omitempty"`
Message string `json:"message,omitempty"`
ProductID string `json:"product_id,omitempty"`
PriceID string `json:"price_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// CreateSalesQuoteCheckout creates a Stripe Price + Checkout Session for a sales quote.
//
// ASSUMPTION (installments):
// - installment_count == 1 → Checkout mode=payment (one-time Price).
// - installment_count > 1 → Checkout mode=subscription with a recurring Price equal to
// installment_amount_cents; subscription_data.cancel_at ends billing after N intervals
// (month / quarter=3 months / year). Stripe collects the first installment at Checkout;
// later invoices are charged automatically on the subscription.
func (s *StripeService) CreateSalesQuoteCheckout(ctx context.Context, in SalesQuoteCheckoutInput) (SalesQuoteCheckoutResult, error) {
if in.QuoteID == uuid.Nil || in.CompanyID == uuid.Nil || in.PlanID <= 0 {
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: quote identifiers", ErrStripePlanUnsupported)
}
if in.InstallmentAmountCents <= 0 || in.TotalAmountCents <= 0 {
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: amounts", ErrStripePlanUnsupported)
}
count := in.InstallmentCount
if count <= 0 {
count = 1
}
interval := strings.ToLower(strings.TrimSpace(in.InstallmentInterval))
if interval == "" {
interval = "month"
}
currency := strings.ToLower(strings.TrimSpace(in.Currency))
if currency == "" {
currency = "usd"
}
ctx, cfg, err := s.bindCfg(ctx)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
web := strings.TrimRight(cfg.WebOrigin, "/")
if web == "" {
web = "http://localhost:5174"
}
if cfg.AllowMockPurchase() {
if err := s.applySalesQuotePurchase(ctx, in.CompanyID, in.PlanID, in.QuoteID, "cus_mock_"+in.CompanyID.String()[:8], "sub_mock_"+uuid.NewString()[:8], "price_mock_quote"); err != nil {
return SalesQuoteCheckoutResult{}, err
}
return SalesQuoteCheckoutResult{
URL: web + "/billing?checkout=success&mock=1&sales_quote=" + url.QueryEscape(in.QuoteID.String()),
Mock: true,
Applied: true,
Message: "Mock mode: custom sales quote plan assigned without Stripe.",
}, nil
}
if cfg.MockMode() {
return SalesQuoteCheckoutResult{}, ErrStripeNotConfigured
}
productID, err := s.ensureSalesQuoteProduct(ctx, in)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
priceID, err := s.createSalesQuotePrice(ctx, productID, in, count == 1)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
customerID, err := s.ensureCustomer(ctx, in.CompanyID, in.Email, in.CompanyName)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
form := url.Values{}
form.Set("success_url", web+"/billing?checkout=success&sales_quote="+url.QueryEscape(in.QuoteID.String()))
form.Set("cancel_url", web+"/billing?checkout=cancel&sales_quote="+url.QueryEscape(in.QuoteID.String()))
form.Set("client_reference_id", in.CompanyID.String())
form.Set("metadata[company_id]", in.CompanyID.String())
form.Set("metadata[kind]", "sales_quote")
form.Set("metadata[quote_id]", in.QuoteID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
form.Set("metadata[plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
form.Set("metadata[installment_count]", strconv.Itoa(count))
form.Set("metadata[installment_interval]", interval)
form.Set("line_items[0][price]", priceID)
form.Set("line_items[0][quantity]", "1")
form.Set("allow_promotion_codes", "true")
if customerID != "" {
form.Set("customer", customerID)
} else if in.Email != "" {
form.Set("customer_email", in.Email)
}
if count == 1 {
form.Set("mode", "payment")
form.Set("payment_intent_data[metadata][company_id]", in.CompanyID.String())
form.Set("payment_intent_data[metadata][kind]", "sales_quote")
form.Set("payment_intent_data[metadata][quote_id]", in.QuoteID.String())
form.Set("payment_intent_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
} else {
form.Set("mode", "subscription")
form.Set("subscription_data[metadata][company_id]", in.CompanyID.String())
form.Set("subscription_data[metadata][kind]", "sales_quote")
form.Set("subscription_data[metadata][quote_id]", in.QuoteID.String())
form.Set("subscription_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
form.Set("subscription_data[metadata][plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
cancelAt, err := salesQuoteCancelAt(time.Now().UTC(), count, interval)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
form.Set("subscription_data[cancel_at]", strconv.FormatInt(cancelAt.Unix(), 10))
}
var sess struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil {
return SalesQuoteCheckoutResult{}, err
}
if sess.URL == "" {
return SalesQuoteCheckoutResult{}, errors.New("stripe checkout session missing url")
}
return SalesQuoteCheckoutResult{
URL: sess.URL,
Mock: false,
ProductID: productID,
PriceID: priceID,
SessionID: sess.ID,
}, nil
}
func (s *StripeService) ensureSalesQuoteProduct(ctx context.Context, in SalesQuoteCheckoutInput) (string, error) {
metaKey := in.QuoteID.String()
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_sales_quote']:'%s'", metaKey))
var search struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
return "", err
}
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
return search.Data[0].ID, nil
}
form := url.Values{}
name := strings.TrimSpace(in.PlanName)
if name == "" {
name = "Custom Descrybe plan"
}
form.Set("name", "Descrybe — "+name)
form.Set("description", fmt.Sprintf("Sales quote %s (%d installments)", in.QuoteID.String(), in.InstallmentCount))
form.Set("metadata[descrybe_sales_quote]", metaKey)
form.Set("metadata[company_id]", in.CompanyID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
var product struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
return "", err
}
if strings.TrimSpace(product.ID) == "" {
return "", fmt.Errorf("stripe product missing id")
}
return product.ID, nil
}
func (s *StripeService) createSalesQuotePrice(ctx context.Context, productID string, in SalesQuoteCheckoutInput, oneTime bool) (string, error) {
form := url.Values{}
form.Set("product", productID)
form.Set("currency", strings.ToLower(strings.TrimSpace(in.Currency)))
if form.Get("currency") == "" {
form.Set("currency", "usd")
}
form.Set("unit_amount", strconv.Itoa(in.InstallmentAmountCents))
form.Set("metadata[descrybe_sales_quote]", in.QuoteID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
if oneTime {
// default type one_time
} else {
stripeInterval, intervalCount, err := stripeRecurringFromInstallment(in.InstallmentInterval)
if err != nil {
return "", err
}
form.Set("recurring[interval]", stripeInterval)
if intervalCount > 1 {
form.Set("recurring[interval_count]", strconv.Itoa(intervalCount))
}
}
var price struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
return "", err
}
if strings.TrimSpace(price.ID) == "" {
return "", fmt.Errorf("stripe price missing id")
}
return price.ID, nil
}
func stripeRecurringFromInstallment(interval string) (stripeInterval string, intervalCount int, err error) {
switch strings.ToLower(strings.TrimSpace(interval)) {
case "month", "":
return "month", 1, nil
case "quarter":
return "month", 3, nil
case "year":
return "year", 1, nil
default:
return "", 0, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
}
}
func salesQuoteCancelAt(now time.Time, count int, interval string) (time.Time, error) {
if count < 1 {
return time.Time{}, fmt.Errorf("%w: installment count", ErrStripePlanUnsupported)
}
switch strings.ToLower(strings.TrimSpace(interval)) {
case "month", "":
return now.AddDate(0, count, 0), nil
case "quarter":
return now.AddDate(0, count*3, 0), nil
case "year":
return now.AddDate(count, 0, 0), nil
default:
return time.Time{}, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
}
}
func (s *StripeService) applySalesQuotePurchase(ctx context.Context, companyID uuid.UUID, planID int64, quoteID uuid.UUID, customerID, subscriptionID, priceID string) error {
if s.Billing == nil {
return errors.New("billing service not configured")
}
if planID <= 0 {
return fmt.Errorf("%w: plan_id", ErrStripePlanUnsupported)
}
if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
return err
}
if customerID != "" {
_, _ = s.Pool.Exec(ctx, `
UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`,
companyID, customerID)
}
_, _ = s.Pool.Exec(ctx, `
UPDATE company_plans
SET stripe_subscription_id = NULLIF($2, ''), stripe_price_id = NULLIF($3, ''), updated_at = now()
WHERE company_id = $1 AND is_active = true`,
companyID, subscriptionID, priceID)
_ = s.setSubscriptionStatusNote(ctx, companyID, "active")
if quoteID != uuid.Nil {
_, err := s.Pool.Exec(ctx, `
UPDATE sales_quotes
SET status = 'paid', paid_at = COALESCE(paid_at, now()), updated_at = now()
WHERE id = $1 AND status <> 'canceled'`, quoteID)
if err != nil {
return err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE sales_leads
SET status = 'won', updated_at = now()
WHERE id = (SELECT lead_id FROM sales_quotes WHERE id = $1)
AND status <> 'closed'`, quoteID)
}
return nil
}
@@ -0,0 +1,46 @@
package billing
import (
"testing"
"time"
)
func TestSalesQuoteCancelAt(t *testing.T) {
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
got, err := salesQuoteCancelAt(now, 4, "month")
if err != nil {
t.Fatal(err)
}
want := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("month cancel_at = %v, want %v", got, want)
}
got, err = salesQuoteCancelAt(now, 2, "quarter")
if err != nil {
t.Fatal(err)
}
want = time.Date(2027, 2, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("quarter cancel_at = %v, want %v", got, want)
}
got, err = salesQuoteCancelAt(now, 1, "year")
if err != nil {
t.Fatal(err)
}
want = time.Date(2027, 8, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("year cancel_at = %v, want %v", got, want)
}
}
func TestStripeRecurringFromInstallment(t *testing.T) {
iv, n, err := stripeRecurringFromInstallment("quarter")
if err != nil || iv != "month" || n != 3 {
t.Fatalf("quarter => %s/%d err=%v", iv, n, err)
}
iv, n, err = stripeRecurringFromInstallment("month")
if err != nil || iv != "month" || n != 1 {
t.Fatalf("month => %s/%d err=%v", iv, n, err)
}
}
@@ -0,0 +1,127 @@
package billing
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
// SyncCreditPackResult is one pack after Stripe Product/Price ensure.
type SyncCreditPackResult struct {
PackID string `json:"pack_id"`
ProductID string `json:"product_id"`
PriceID string `json:"price_id"`
Created bool `json:"created"`
Credits int `json:"credits"`
PriceUSD int `json:"price_usd"`
}
// SyncCreditPackProducts creates/updates Stripe Products + one-time Prices for
// DefaultCreditPacks. Packs are additional one-time products (Checkout mode=payment),
// not subscription add-ons. Metadata descrybe_pack=<id> identifies each product.
func (s *StripeService) SyncCreditPackProducts(ctx context.Context) ([]SyncCreditPackResult, error) {
ctx, cfg, err := s.bindCfg(ctx)
if err != nil {
return nil, err
}
if cfg.MockMode() || strings.TrimSpace(cfg.SecretKey) == "" {
return nil, ErrStripeNotConfigured
}
out := make([]SyncCreditPackResult, 0, len(DefaultCreditPacks()))
for _, pack := range DefaultCreditPacks() {
productID, createdProduct, err := s.ensureCreditPackProduct(ctx, pack)
if err != nil {
return out, fmt.Errorf("pack %s product: %w", pack.ID, err)
}
priceID, createdPrice, err := s.ensureCreditPackPrice(ctx, productID, pack)
if err != nil {
return out, fmt.Errorf("pack %s price: %w", pack.ID, err)
}
out = append(out, SyncCreditPackResult{
PackID: pack.ID,
ProductID: productID,
PriceID: priceID,
Created: createdProduct || createdPrice,
Credits: pack.Credits,
PriceUSD: pack.PriceUSD,
})
}
return out, nil
}
func (s *StripeService) ensureCreditPackProduct(ctx context.Context, pack CreditPack) (productID string, created bool, err error) {
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_pack']:'%s'", pack.ID))
var search struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
return "", false, err
}
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
return search.Data[0].ID, false, nil
}
form := url.Values{}
form.Set("name", "Descrybe AI credits — "+pack.Name)
form.Set("description", pack.Description)
form.Set("metadata[descrybe_pack]", pack.ID)
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
form.Set("metadata[ai_products]", strconv.Itoa(pack.AIProducts))
form.Set("metadata[price_usd]", strconv.Itoa(pack.PriceUSD))
var product struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
return "", false, err
}
if strings.TrimSpace(product.ID) == "" {
return "", false, fmt.Errorf("stripe product missing id")
}
return product.ID, true, nil
}
func (s *StripeService) ensureCreditPackPrice(ctx context.Context, productID string, pack CreditPack) (priceID string, created bool, err error) {
// Reuse an active one-time price on this product that matches unit amount.
wantCents := pack.PriceUSD * 100
var list struct {
Data []struct {
ID string `json:"id"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Type string `json:"type"`
Active bool `json:"active"`
} `json:"data"`
}
endpoint := "https://api.stripe.com/v1/prices?product=" + url.QueryEscape(productID) + "&active=true&limit=20"
if err := s.stripeGET(ctx, endpoint, &list); err != nil {
return "", false, err
}
for _, p := range list.Data {
if p.Active && p.Type == "one_time" && strings.EqualFold(p.Currency, "usd") && int(p.UnitAmount) == wantCents {
return p.ID, false, nil
}
}
form := url.Values{}
form.Set("product", productID)
form.Set("currency", "usd")
form.Set("unit_amount", strconv.Itoa(wantCents))
form.Set("metadata[descrybe_pack]", pack.ID)
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
var price struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
return "", false, err
}
if strings.TrimSpace(price.ID) == "" {
return "", false, fmt.Errorf("stripe price missing id")
}
return price.ID, true, nil
}
+350
View File
@@ -0,0 +1,350 @@
package billing
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"testing"
"time"
"github.com/google/uuid"
)
func TestNormalizePlanTerm(t *testing.T) {
plan, term, err := normalizePlanTerm("Starter", "YEARLY")
if err != nil || plan != "starter" || term != "yearly" {
t.Fatalf("got %s %s err=%v", plan, term, err)
}
plan, term, err = normalizePlanTerm("Plus", "monthly")
if err != nil || plan != "plus" || term != "monthly" {
t.Fatalf("plus: got %s %s err=%v", plan, term, err)
}
plan, term, err = normalizePlanTerm("scale", "yearly")
if err != nil || plan != "scale" || term != "yearly" {
t.Fatalf("scale: got %s %s err=%v", plan, term, err)
}
_, _, err = normalizePlanTerm("enterprise", "monthly")
if err == nil {
t.Fatal("enterprise must be rejected")
}
_, _, err = normalizePlanTerm("free", "monthly")
if err == nil {
t.Fatal("free must be rejected")
}
}
func TestCheckoutReturnURL(t *testing.T) {
success := checkoutReturnURL("https://app.example", "success", "starter", "monthly", "", true)
if success != "https://app.example/billing?checkout=success&plan=starter&term=monthly&session_id={CHECKOUT_SESSION_ID}" {
t.Fatalf("success url: %s", success)
}
cancel := checkoutReturnURL("https://app.example/", "cancel", "starter", "yearly", "", false)
if cancel != "https://app.example/billing?checkout=cancel&plan=starter&term=yearly" {
t.Fatalf("cancel url: %s", cancel)
}
pack := checkoutReturnURL("https://app.example", "success", "", "", "tiny", true)
if pack != "https://app.example/billing?checkout=success&pack=tiny&session_id={CHECKOUT_SESSION_ID}" {
t.Fatalf("pack url: %s", pack)
}
packCancel := checkoutReturnURL("https://app.example", "cancel", "", "", "tiny", false)
if packCancel != "https://app.example/billing?checkout=cancel&pack=tiny" {
t.Fatalf("pack cancel url: %s", packCancel)
}
}
func TestVerifyStripeSignature(t *testing.T) {
secret := "whsec_test_secret"
payload := []byte(`{"id":"evt_1","type":"checkout.session.completed"}`)
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "%d.", ts)
_, _ = mac.Write(payload)
sig := hex.EncodeToString(mac.Sum(nil))
header := fmt.Sprintf("t=%d,v1=%s", ts, sig)
if err := verifyStripeSignature(payload, header, secret, 5*time.Minute); err != nil {
t.Fatal(err)
}
if err := verifyStripeSignature(payload, "t="+fmt.Sprint(ts)+",v1=deadbeef", secret, 5*time.Minute); err == nil {
t.Fatal("expected bad signature")
}
}
func TestStripeConfigMockMode(t *testing.T) {
if !(StripeConfig{}).MockMode() {
t.Fatal("empty secret should be mock")
}
if (StripeConfig{SecretKey: "sk_test_x"}).MockMode() {
t.Fatal("secret set should not mock")
}
if !(StripeConfig{SecretKey: "sk_test_x", ForceMock: true}).MockMode() {
t.Fatal("ForceMock should override")
}
if (StripeConfig{}).AllowMockPurchase() {
t.Fatal("empty secret alone must not allow mock purchase")
}
if (StripeConfig{SecretKey: "sk_test_x"}).AllowMockPurchase() {
t.Fatal("live secret must not allow mock purchase")
}
if !(StripeConfig{ForceMock: true}).AllowMockPurchase() {
t.Fatal("ForceMock should allow mock purchase")
}
}
func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
if !errors.Is(err, ErrStripeNotConfigured) {
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
}
}
func TestHandleWebhookRejectsUnsignedWithoutForceMock(t *testing.T) {
// Empty secret (MockMode) without ForceMock must still reject unsigned webhooks.
s := &StripeService{Cfg: StripeConfig{}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_x","type":"ping"}`), "")
if err != ErrStripeNotConfigured {
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
}
s2 := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}}
err = s2.HandleWebhook(context.TODO(), []byte(`{"id":"evt_y","type":"ping"}`), "")
if err != ErrStripeNotConfigured {
t.Fatalf("live without webhook secret: want ErrStripeNotConfigured, got %v", err)
}
}
func TestHandleWebhookVerifiesEvenWhenForceMock(t *testing.T) {
secret := "whsec_test_secret"
payload := []byte(`{"id":"evt_1","type":"ping"}`)
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret}}
err := s.HandleWebhook(context.TODO(), payload, "t=1,v1=deadbeef")
if !errors.Is(err, ErrStripeBadSignature) {
t.Fatalf("ForceMock must still verify when WebhookSecret set, got %v", err)
}
}
func TestHandleWebhookRejectsUnsignedInProductionEvenWithForceMock(t *testing.T) {
t.Setenv("APP_ENV", "production")
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_prod","type":"ping"}`), "")
if !errors.Is(err, ErrStripeNotConfigured) {
t.Fatalf("production must reject unsigned ForceMock webhooks, got %v", err)
}
}
func TestLoadStripePriceIDs(t *testing.T) {
m := LoadStripePriceIDs(func(k string) string {
switch k {
case "STRIPE_PRICE_STARTER_MONTHLY":
return "price_starter_m"
case "STRIPE_PRICE_PACK_SMALL":
return "price_pack_s"
default:
return ""
}
})
if m["starter:monthly"] != "price_starter_m" {
t.Fatalf("got %#v", m)
}
if m["pack:small"] != "price_pack_s" {
t.Fatalf("pack missing: %#v", m)
}
}
func TestPlanFromPriceIDIgnoresPacks(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
"growth:monthly": "price_g_m",
"pack:small": "price_pack_s",
}}}
if got := s.planFromPriceID("price_g_m"); got != "growth" {
t.Fatalf("got %q", got)
}
if got := s.planFromPriceID("price_pack_s"); got != "" {
t.Fatalf("pack price must not map to a plan, got %q", got)
}
}
func TestCreditPackCatalog(t *testing.T) {
if got := MonthlyCreditsForPlan("Starter", 0); got != 100 {
t.Fatalf("starter cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Plus", 0); got != 400 {
t.Fatalf("plus cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Growth", 0); got != 1200 {
t.Fatalf("growth cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Business", 0); got != 4000 {
t.Fatalf("business cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Scale", 0); got != 12000 {
t.Fatalf("scale cover: got %d", got)
}
packs := DefaultCreditPacks()
if len(packs) < 7 {
t.Fatalf("want at least 7 packs, got %d", len(packs))
}
tiny, ok := CreditPackByID("tiny")
if !ok || tiny.Credits != 25 || tiny.PriceUSD != 29 {
t.Fatalf("tiny pack: %#v ok=%v", tiny, ok)
}
small, ok := CreditPackByID("small")
if !ok || small.Credits != 65 || small.PriceUSD != 59 {
t.Fatalf("small pack: %#v ok=%v", small, ok)
}
med, ok := CreditPackByID("medium")
if !ok || med.Credits != 200 || med.PriceUSD != 149 {
t.Fatalf("medium pack: %#v ok=%v", med, ok)
}
mega, ok := CreditPackByID("mega")
if !ok || mega.Credits != 8000 || mega.PriceUSD != 2999 {
t.Fatalf("mega pack: %#v ok=%v", mega, ok)
}
if CreditPackSettingsKey("small") != "stripe.price.pack.small" {
t.Fatalf("settings key")
}
if CreditPackEnvVar("xxl") != "STRIPE_PRICE_PACK_XXL" {
t.Fatalf("env var")
}
if _, ok := CreditPackByID("nope"); ok {
t.Fatal("unknown pack must miss")
}
}
func TestPlanFromPriceID(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
"growth:monthly": "price_g_m",
}}}
if got := s.planFromPriceID("price_g_m"); got != "growth" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeSubscriptionStatus(t *testing.T) {
if got := NormalizeSubscriptionStatus(" Past_Due "); got != "past_due" {
t.Fatalf("got %q", got)
}
}
func TestIsPastDueSubscriptionStatus(t *testing.T) {
if !IsPastDueSubscriptionStatus("past_due") {
t.Fatal("expected past_due")
}
if !IsPastDueSubscriptionStatus(" Past_Due ") {
t.Fatal("expected normalized past_due")
}
if IsPastDueSubscriptionStatus("active") {
t.Fatal("active must not be past_due")
}
if IsPastDueSubscriptionStatus("") {
t.Fatal("empty must not be past_due")
}
}
func TestParseStripeStatusNote(t *testing.T) {
note := FormatStripeStatusNote("Past_Due")
if note != "stripe_status:past_due" {
t.Fatalf("format got %q", note)
}
if got := ParseStripeStatusNote(&note); got != "past_due" {
t.Fatalf("parse got %q", got)
}
ops := "ops: keep forever"
if got := ParseStripeStatusNote(&ops); got != "" {
t.Fatalf("ops notes must be ignored, got %q", got)
}
if got := ParseStripeStatusNote(nil); got != "" {
t.Fatalf("nil got %q", got)
}
}
func TestCreatePortalSessionMock(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
res, err := s.CreatePortalSession(context.TODO(), uuid.New())
if err != nil {
t.Fatal(err)
}
if !res.Mock || res.URL != "http://localhost:5174/billing?portal=mock" {
t.Fatalf("got %#v", res)
}
// Empty secret alone (MockMode without ForceMock) also returns mock portal deep-link.
s2 := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
res, err = s2.CreatePortalSession(context.TODO(), uuid.New())
if err != nil {
t.Fatal(err)
}
if !res.Mock {
t.Fatalf("mock mode portal expected, got %#v", res)
}
}
func TestCreateCheckoutSessionMockRequiresBilling(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCheckoutSession(context.TODO(), uuid.New(), "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
if err == nil || err.Error() != "billing service not configured" {
t.Fatalf("want billing not configured, got %v", err)
}
}
func TestCreateCreditPackCheckoutMockRequiresBilling(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "small")
if err == nil || err.Error() != "billing service not configured" {
t.Fatalf("want billing not configured, got %v", err)
}
_, err = s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "nope")
if !errors.Is(err, ErrStripePlanUnsupported) {
t.Fatalf("unknown pack: %v", err)
}
}
func TestCreditsFromPackMetadata(t *testing.T) {
got, err := creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "999999"})
if err != nil {
t.Fatal(err)
}
if got != 65 {
t.Fatalf("catalog must win over inflated credits, got %d", got)
}
got, err = creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "not-a-number"})
if err != nil {
t.Fatal(err)
}
if got != 65 {
t.Fatalf("catalog must win over garbage credits, got %d", got)
}
_, err = creditsFromPackMetadata(map[string]string{"pack": "unknown", "credits": "abc"})
if err == nil {
t.Fatal("unknown pack with garbage credits must fail")
}
got, err = creditsFromPackMetadata(map[string]string{"pack": "custom", "credits": "42"})
if err != nil {
t.Fatal(err)
}
if got != 42 {
t.Fatalf("unknown pack may use positive credits metadata, got %d", got)
}
_, err = creditsFromPackMetadata(map[string]string{"pack": "nope"})
if !errors.Is(err, ErrStripePlanUnsupported) {
t.Fatalf("empty credits unknown pack: %v", err)
}
}
func TestHandleWebhookForceMockUnsignedNeedsStore(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_local","type":"ping"}`), "")
if err == nil || err.Error() != "stripe store not configured" {
t.Fatalf("want store not configured, got %v", err)
}
}
func signStripePayload(t *testing.T, secret string, payload []byte) string {
t.Helper()
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "%d.", ts)
_, _ = mac.Write(payload)
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
}
+23
View File
@@ -0,0 +1,23 @@
package billing
import "testing"
func TestParseUsageRange(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"", "30d"},
{"7d", "7d"},
{"30D", "30d"},
{" cycle ", "cycle"},
{"all", "all"},
{"week", "30d"},
{"90d", "30d"},
}
for _, c := range cases {
if got := ParseUsageRange(c.in); got != c.want {
t.Fatalf("ParseUsageRange(%q)=%q want %q", c.in, got, c.want)
}
}
}
+246
View File
@@ -0,0 +1,246 @@
package campaigns
import (
"context"
"encoding/json"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/google/uuid"
)
// AudienceFilter is the structured form of email_campaigns.audience_filter.
// UI shape uses type + category_ids; API/docs also accept bought_category directly.
type AudienceFilter struct {
Type string `json:"type,omitempty"`
CategoryIDs []string `json:"category_ids,omitempty"`
BoughtCategory string `json:"bought_category,omitempty"`
NotBoughtCategory string `json:"not_bought_category,omitempty"`
BoughtCategories []string `json:"bought_categories,omitempty"`
Emails []string `json:"emails,omitempty"`
}
// ResolveAudience returns campaign recipients from explicit emails and/or Woo order history.
// Bought/not-bought category matching is best-effort over synced woo_orders / order_items.
func (s *Service) ResolveAudience(ctx context.Context, companyID uuid.UUID, filter AudienceFilter, limit int) (woocommerce.AudienceResult, error) {
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
boughtList, notBought, err := s.resolveBoughtCategories(ctx, companyID, filter)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if len(boughtList) > 0 {
woo := &woocommerce.Service{Pool: s.Pool}
if len(boughtList) == 1 && boughtList[0] == "__any_order__" {
res, err := woo.AudienceAnyOrdersExcept(ctx, companyID, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
seen := map[string]struct{}{}
for _, c := range res.Customers {
seen[strings.ToLower(c.Email)] = struct{}{}
}
for _, raw := range filter.Emails {
if len(res.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
res.Customers = append(res.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
res.Total = len(res.Customers)
return res, nil
}
merged := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "best-effort from synced Woo orders (campaign audience_filter)",
}
seen := map[string]struct{}{}
for _, bought := range boughtList {
if len(merged.Customers) >= limit {
break
}
res, err := woo.AudienceBoughtCategories(ctx, companyID, bought, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if res.Note != "" {
merged.Note = res.Note
}
for _, c := range res.Customers {
email := strings.ToLower(strings.TrimSpace(c.Email))
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
merged.Customers = append(merged.Customers, c)
if len(merged.Customers) >= limit {
break
}
}
}
for _, raw := range filter.Emails {
if len(merged.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
merged.Customers = append(merged.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
merged.Total = len(merged.Customers)
return merged, nil
}
out := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "explicit email list (no bought_category filter)",
}
seen := map[string]struct{}{}
for _, raw := range filter.Emails {
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
out.Customers = append(out.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
if len(out.Customers) >= limit {
break
}
}
out.Total = len(out.Customers)
return out, nil
}
func (s *Service) resolveBoughtCategories(ctx context.Context, companyID uuid.UUID, filter AudienceFilter) ([]string, string, error) {
notBought := strings.TrimSpace(filter.NotBoughtCategory)
bought := make([]string, 0)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
for _, existing := range bought {
if strings.EqualFold(existing, v) {
return
}
}
bought = append(bought, v)
}
add(filter.BoughtCategory)
for _, v := range filter.BoughtCategories {
add(v)
}
typ := strings.ToLower(strings.TrimSpace(filter.Type))
names, err := s.categoryNamesByIDs(ctx, companyID, filter.CategoryIDs)
if err != nil {
return nil, "", err
}
switch typ {
case "purchased", "by_category":
for _, name := range names {
add(name)
}
case "not_purchased":
if notBought == "" && len(names) > 0 {
notBought = names[0]
}
if len(bought) == 0 {
return []string{"__any_order__"}, notBought, nil
}
default:
// Keep explicit bought_category / bought_categories when type is empty/all.
if len(bought) == 0 {
for _, name := range names {
add(name)
}
}
}
return bought, notBought, nil
}
func (s *Service) categoryNamesByIDs(ctx context.Context, companyID uuid.UUID, rawIDs []string) ([]string, error) {
ids := make([]uuid.UUID, 0, len(rawIDs))
for _, raw := range rawIDs {
id, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, nil
}
rows, err := s.Pool.Query(ctx, `
SELECT name FROM categories
WHERE company_id = $1 AND id = ANY($2::uuid[]) AND is_active = true`, companyID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, len(ids))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name != "" {
out = append(out, name)
}
}
return out, rows.Err()
}
// ResolveAudienceMap accepts the loose map[string]any shape used by campaign Create/Update inputs.
func (s *Service) ResolveAudienceMap(ctx context.Context, companyID uuid.UUID, raw map[string]any, limit int) (woocommerce.AudienceResult, error) {
return s.ResolveAudience(ctx, companyID, AudienceFilterFromMap(raw), limit)
}
// AudienceFilterFromMap converts a JSON-object audience_filter into AudienceFilter.
func AudienceFilterFromMap(raw map[string]any) AudienceFilter {
if raw == nil {
return AudienceFilter{}
}
b, err := json.Marshal(raw)
if err != nil {
return AudienceFilter{}
}
return ParseAudienceFilter(b)
}
// ParseAudienceFilter decodes audience_filter JSONB.
func ParseAudienceFilter(raw []byte) AudienceFilter {
var f AudienceFilter
if len(raw) == 0 {
return f
}
_ = json.Unmarshal(raw, &f)
return f
}
@@ -0,0 +1,31 @@
package campaigns
import "testing"
func TestParseAudienceFilterUIShape(t *testing.T) {
raw := []byte(`{"type":"purchased","category_ids":["11111111-1111-1111-1111-111111111111"],"bought_category":"Demo Electronics"}`)
f := ParseAudienceFilter(raw)
if f.Type != "purchased" {
t.Fatalf("type=%q", f.Type)
}
if f.BoughtCategory != "Demo Electronics" {
t.Fatalf("bought=%q", f.BoughtCategory)
}
if len(f.CategoryIDs) != 1 {
t.Fatalf("category_ids=%v", f.CategoryIDs)
}
}
func TestAudienceFilterFromMap(t *testing.T) {
f := AudienceFilterFromMap(map[string]any{
"type": "not_purchased",
"category_ids": []any{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
"bought_category": "",
})
if f.Type != "not_purchased" {
t.Fatalf("type=%q", f.Type)
}
if len(f.CategoryIDs) != 1 {
t.Fatalf("ids=%v", f.CategoryIDs)
}
}
@@ -0,0 +1,50 @@
package campaigns
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestResolveAudienceSeededWooDemo(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
var companyID uuid.UUID
var af []byte
err = pg.QueryRow(ctx, `
SELECT company_id, audience_filter
FROM email_campaigns
WHERE name LIKE 'Woo demo%'
ORDER BY updated_at DESC LIMIT 1`).Scan(&companyID, &af)
if err != nil {
t.Skip("no seeded woo demo campaign:", err)
}
var m map[string]any
if err := json.Unmarshal(af, &m); err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
res, err := svc.ResolveAudienceMap(ctx, companyID, m, 100)
if err != nil {
t.Fatal(err)
}
if res.Total < 3 {
t.Fatalf("expected >=3 audience customers, got %d (%v)", res.Total, res.Customers)
}
t.Logf("resolved %d customers via campaign filter: %+v", res.Total, res.Customers)
}
+392
View File
@@ -0,0 +1,392 @@
package campaigns
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
maxCampaignProductIDs = 100
maxCampaignCategoryIDs = 50
)
// Campaign is the API representation of email_campaigns (+ latest version fields).
type Campaign struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TemplateKey string `json:"template_key"`
Season string `json:"season,omitempty"`
Status string `json:"status"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt string `json:"prompt"`
UseDefaultPrompt bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
SentAt *time.Time `json:"sent_at,omitempty"`
Subject string `json:"subject,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
PlainBody string `json:"plain_body,omitempty"`
LatestVersion *Version `json:"latest_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Version struct {
ID uuid.UUID `json:"id"`
Version int `json:"version"`
Subject string `json:"subject"`
HTMLBody string `json:"html_body"`
PlainBody string `json:"plain_body"`
GenerationMode string `json:"generation_mode"`
GeneratedAt time.Time `json:"generated_at"`
}
type CreateInput struct {
Name string `json:"name"`
TemplateKey string `json:"template_key"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt string `json:"prompt"`
UseDefaultPrompt *bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
}
type UpdateInput struct {
Name *string `json:"name"`
TemplateKey *string `json:"template_key"`
Status *string `json:"status"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt *string `json:"prompt"`
UseDefaultPrompt *bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
ScheduledAt *time.Time `json:"scheduled_at"`
}
type GenerateInput struct {
Mode string `json:"mode"` // template | ai
Force bool `json:"force"`
UseAI *bool `json:"use_ai"`
}
type SendTestInput struct {
To string `json:"to"`
Email string `json:"email"`
}
type ScheduleInput struct {
ScheduledAt time.Time `json:"scheduled_at"`
}
type SendInput struct {
Confirm bool `json:"confirm"`
Recipients []string `json:"recipients"`
DryRun bool `json:"dry_run"`
}
func (s *Service) List(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Campaign, int64, error) {
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM email_campaigns WHERE company_id = $1`, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
audience_filter, scheduled_at, sent_at, created_at, updated_at
FROM email_campaigns WHERE company_id = $1
ORDER BY updated_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]Campaign, 0)
for rows.Next() {
c, err := scanCampaign(rows)
if err != nil {
return nil, 0, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
// One round-trip for the page (was N+1 via attachLatestVersion per row).
_ = s.attachLatestVersions(ctx, out)
return out, total, nil
}
func (s *Service) Get(ctx context.Context, companyID, id uuid.UUID) (Campaign, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
audience_filter, scheduled_at, sent_at, created_at, updated_at
FROM email_campaigns WHERE company_id = $1 AND id = $2`, companyID, id)
c, err := scanCampaign(row)
if errors.Is(err, pgx.ErrNoRows) {
return Campaign{}, ErrNotFound
}
if err != nil {
return Campaign{}, err
}
_ = s.attachLatestVersion(ctx, &c)
return c, nil
}
func (s *Service) Create(ctx context.Context, companyID uuid.UUID, createdBy *uuid.UUID, in CreateInput) (Campaign, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return Campaign{}, ErrNameRequired
}
tpl, err := GetTemplate(in.TemplateKey)
if err != nil {
return Campaign{}, err
}
useDefault := true
if in.UseDefaultPrompt != nil {
useDefault = *in.UseDefaultPrompt
}
prompt := SanitizePrompt(in.Prompt)
if useDefault && prompt == "" {
prompt = tpl.DefaultPrompt
}
if err := ValidatePrompt(prompt); err != nil {
return Campaign{}, err
}
af := in.AudienceFilter
if af == nil {
af = map[string]any{}
}
afBytes, err := json.Marshal(af)
if err != nil {
return Campaign{}, err
}
cats := in.CategoryIDs
if cats == nil {
cats = []uuid.UUID{}
}
prods := in.ProductIDs
if prods == nil {
prods = []uuid.UUID{}
}
if err := validateCampaignRefs(cats, prods); err != nil {
return Campaign{}, err
}
var id uuid.UUID
err = s.Pool.QueryRow(ctx, `
INSERT INTO email_campaigns (
company_id, name, template_key, category_ids, product_ids, prompt, use_default_prompt, audience_filter, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
RETURNING id`,
companyID, name, tpl.Key, cats, prods, prompt, useDefault, string(afBytes), createdBy,
).Scan(&id)
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Update(ctx context.Context, companyID, id uuid.UUID, in UpdateInput) (Campaign, error) {
cur, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
name := cur.Name
if in.Name != nil {
name = strings.TrimSpace(*in.Name)
if name == "" {
return Campaign{}, ErrNameRequired
}
}
tplKey := cur.TemplateKey
if in.TemplateKey != nil {
tpl, err := GetTemplate(*in.TemplateKey)
if err != nil {
return Campaign{}, err
}
tplKey = tpl.Key
}
status := cur.Status
if in.Status != nil {
st := strings.TrimSpace(strings.ToLower(*in.Status))
switch st {
case "draft", "ready", "scheduled", "sent", "cancelled":
status = st
default:
return Campaign{}, ErrInvalidStatus
}
}
prompt := cur.Prompt
if in.Prompt != nil {
prompt = SanitizePrompt(*in.Prompt)
if err := ValidatePrompt(prompt); err != nil {
return Campaign{}, err
}
}
useDefault := cur.UseDefaultPrompt
if in.UseDefaultPrompt != nil {
useDefault = *in.UseDefaultPrompt
}
cats := cur.CategoryIDs
if in.CategoryIDs != nil {
cats = in.CategoryIDs
}
prods := cur.ProductIDs
if in.ProductIDs != nil {
prods = in.ProductIDs
}
if err := validateCampaignRefs(cats, prods); err != nil {
return Campaign{}, err
}
af := cur.AudienceFilter
if in.AudienceFilter != nil {
af = in.AudienceFilter
}
afBytes, err := json.Marshal(af)
if err != nil {
return Campaign{}, err
}
scheduledAt := cur.ScheduledAt
if in.ScheduledAt != nil {
scheduledAt = in.ScheduledAt
}
_, err = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET
name=$3, template_key=$4, status=$5, category_ids=$6, product_ids=$7,
prompt=$8, use_default_prompt=$9, audience_filter=$10::jsonb, scheduled_at=$11, updated_at=now()
WHERE company_id=$1 AND id=$2`,
companyID, id, name, tplKey, status, cats, prods, prompt, useDefault, string(afBytes), scheduledAt,
)
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Delete(ctx context.Context, companyID, id uuid.UUID) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM email_campaigns WHERE company_id=$1 AND id=$2`, companyID, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type scannable interface {
Scan(dest ...any) error
}
func scanCampaign(row scannable) (Campaign, error) {
var c Campaign
var af []byte
err := row.Scan(
&c.ID, &c.Name, &c.TemplateKey, &c.Status, &c.CategoryIDs, &c.ProductIDs, &c.Prompt, &c.UseDefaultPrompt,
&af, &c.ScheduledAt, &c.SentAt, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return Campaign{}, err
}
if c.CategoryIDs == nil {
c.CategoryIDs = []uuid.UUID{}
}
if c.ProductIDs == nil {
c.ProductIDs = []uuid.UUID{}
}
c.AudienceFilter = map[string]any{}
if len(af) > 0 {
_ = json.Unmarshal(af, &c.AudienceFilter)
}
if tpl, err := GetTemplate(c.TemplateKey); err == nil {
c.Season = tpl.Season
}
return c, nil
}
func applyVersionFields(c *Campaign, v Version) {
c.LatestVersion = &v
c.Subject = v.Subject
c.HTMLBody = v.HTMLBody
c.PlainBody = v.PlainBody
}
func (s *Service) attachLatestVersion(ctx context.Context, c *Campaign) error {
var v Version
err := s.Pool.QueryRow(ctx, `
SELECT id, version, subject, html_body, plain_body, generation_mode, generated_at
FROM email_campaign_versions
WHERE campaign_id=$1
ORDER BY version DESC LIMIT 1`, c.ID).Scan(
&v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
log.Printf("campaigns: attach version: %v", err)
return err
}
applyVersionFields(c, v)
return nil
}
// latestVersionsByCampaignIDsSQL loads one latest version per campaign (batch for List).
const latestVersionsByCampaignIDsSQL = `
SELECT DISTINCT ON (campaign_id)
campaign_id, id, version, subject, html_body, plain_body, generation_mode, generated_at
FROM email_campaign_versions
WHERE campaign_id = ANY($1)
ORDER BY campaign_id, version DESC`
// attachLatestVersions fills LatestVersion/subject/body fields for a page of campaigns in one query.
func (s *Service) attachLatestVersions(ctx context.Context, campaigns []Campaign) error {
if len(campaigns) == 0 {
return nil
}
ids := make([]uuid.UUID, len(campaigns))
for i := range campaigns {
ids[i] = campaigns[i].ID
}
rows, err := s.Pool.Query(ctx, latestVersionsByCampaignIDsSQL, ids)
if err != nil {
log.Printf("campaigns: attach versions batch: %v", err)
return err
}
defer rows.Close()
byID := make(map[uuid.UUID]Version, len(campaigns))
for rows.Next() {
var campaignID uuid.UUID
var v Version
if err := rows.Scan(
&campaignID, &v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
); err != nil {
return err
}
byID[campaignID] = v
}
if err := rows.Err(); err != nil {
return err
}
for i := range campaigns {
if v, ok := byID[campaigns[i].ID]; ok {
applyVersionFields(&campaigns[i], v)
}
}
return nil
}
func validateCampaignRefs(cats, prods []uuid.UUID) error {
if len(cats) > maxCampaignCategoryIDs {
return ErrTooManyCategoryIDs
}
if len(prods) > maxCampaignProductIDs {
return ErrTooManyProductIDs
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package campaigns
import "errors"
var (
ErrNotFound = errors.New("campaign not found")
ErrProviderNotFound = errors.New("email provider not configured")
ErrProviderUnverified = errors.New("email provider not verified")
ErrInvalidEmail = errors.New("invalid email address")
ErrInvalidTemplate = errors.New("invalid template_key")
ErrInvalidStatus = errors.New("invalid status")
ErrMissingContent = errors.New("campaign has no generated content")
ErrAIRequiresUpgrade = errors.New("campaign AI generate requires a paid plan or AI credits")
ErrInsufficientCredits = errors.New("insufficient credits for campaign AI generate")
ErrAIUnavailable = errors.New("AI generation is not configured")
ErrRateLimited = errors.New("rate limit exceeded")
ErrUnsubscribed = errors.New("recipient is unsubscribed")
ErrMissingUnsubscribe = errors.New("generated HTML missing unsubscribe footer")
ErrPromptTooLong = errors.New("prompt exceeds maximum length")
ErrNameRequired = errors.New("name required")
ErrNoRecipients = errors.New("no recipients")
ErrConfirmRequired = errors.New("confirmation required to send campaign")
ErrTooManyProductIDs = errors.New("too many product_ids")
ErrTooManyCategoryIDs = errors.New("too many category_ids")
)
// ClientError reports whether err is a known client-facing campaign validation error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrInvalidTemplate),
errors.Is(err, ErrInvalidStatus),
errors.Is(err, ErrInvalidEmail),
errors.Is(err, ErrMissingContent),
errors.Is(err, ErrMissingUnsubscribe),
errors.Is(err, ErrPromptTooLong),
errors.Is(err, ErrNameRequired),
errors.Is(err, ErrNoRecipients),
errors.Is(err, ErrAIUnavailable),
errors.Is(err, ErrConfirmRequired),
errors.Is(err, ErrTooManyProductIDs),
errors.Is(err, ErrTooManyCategoryIDs),
errors.Is(err, ErrUnsubscribed),
errors.Is(err, ErrRateLimited),
errors.Is(err, ErrProviderNotFound),
errors.Is(err, ErrProviderUnverified):
return err.Error(), true
default:
return "", false
}
}
@@ -0,0 +1,529 @@
package campaigns
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
)
func (s *Service) Generate(ctx context.Context, companyID, id uuid.UUID, in GenerateInput) (Campaign, error) {
if !s.allowGenerate(companyID.String()) {
return Campaign{}, ErrRateLimited
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
mode := strings.ToLower(strings.TrimSpace(in.Mode))
if mode == "" {
if in.UseAI != nil && *in.UseAI {
mode = "ai"
} else {
mode = "template"
}
}
if mode != "template" && mode != "ai" {
return Campaign{}, fmt.Errorf("mode must be template or ai")
}
tpl, err := GetTemplate(c.TemplateKey)
if err != nil {
return Campaign{}, err
}
brandName := s.companyName(ctx, companyID)
brand, _ := company.LoadBrand(ctx, s.Pool, companyID)
subject := renderSubject(tpl, brandName)
products := s.loadProductSnippets(ctx, companyID, c.ProductIDs, c.CategoryIDs)
logoAbs := company.AbsoluteLogoForEmbed(s.PublicAPIURL, s.TokenSigningSecret, companyID, brand.LogoURL)
html := templateHTML(subject, defaultIntro(tpl, brandName), buildProductHTML(products), s.WebOrigin, logoAbs)
plain := subject + "\n\n" + defaultIntro(tpl, brandName) + "\n\n" + productPlainList(products)
if mode == "ai" {
if s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.campaign_ai", "marketing.campaigns.generate_ai"); err != nil {
return Campaign{}, err
}
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
if err != nil {
return Campaign{}, err
}
// Free tier: CanUseAI is false when no credits / free plan.
// Paid with CanUseAI but empty wallet must not run AI (no silent free generate).
if !ent.CanUseAI || ent.IsFreePlan {
return Campaign{}, ErrAIRequiresUpgrade
}
if ent.RemainingCredits < 1 {
return Campaign{}, ErrInsufficientCredits
}
}
var completer processing.Completer
if s.AI != nil {
cplt, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
if rerr != nil {
return Campaign{}, ErrAIUnavailable
}
completer = cplt
} else {
completer = s.Completer
}
if completer == nil {
return Campaign{}, ErrAIUnavailable
}
if en, ok := completer.(processing.EnableChecker); ok && !en.Enabled() {
return Campaign{}, ErrAIUnavailable
}
sysTpl := ""
userTpl := ""
lang := company.LoadLanguage(ctx, s.Pool, companyID)
if s.Prompts != nil {
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeyCampaignEmail, lang); perr == nil {
sysTpl = resolved.SystemTemplate
userTpl = resolved.UserTemplate
}
}
if def, ok := aiprompts.DefaultFor(aiprompts.KeyCampaignEmail); ok {
if strings.TrimSpace(sysTpl) == "" {
sysTpl = def.SystemTemplate
}
if strings.TrimSpace(userTpl) == "" {
userTpl = def.UserTemplate
}
}
userPrompt := c.Prompt
if c.UseDefaultPrompt || strings.TrimSpace(userPrompt) == "" {
userPrompt = tpl.DefaultPrompt
}
userPrompt = SanitizePrompt(userPrompt)
userPrompt = security.TruncateRunes(userPrompt, 600)
products = limitProductSnippets(products, processing.MaxCampaignProducts)
vars := aiprompts.Vars{
"campaign_prompt": userPrompt,
"products": productPlainList(products),
"brand": brandName,
"brand_voice": processing.CompactBrandPrompt(brand.PromptBlock()),
"language": company.LanguageLabel(company.LoadLanguage(ctx, s.Pool, companyID)),
"template_key": c.TemplateKey,
}
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
if user == "" {
user = userPrompt + "\n\nProducts:\n" + productPlainList(products) + "\nBrand: " + brandName
}
comp, obj, err := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{
MaxTokens: processing.MaxTokensCampaign,
Temperature: processing.DefaultStructuredTemp,
})
if err != nil && obj == nil && comp.Text == "" {
log.Printf("campaigns: ai generate failed company=%s", companyID)
return Campaign{}, fmt.Errorf("ai generation failed")
}
parsed := parseAIContent(comp.Text, subject, html, plain)
if obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
}
}
subject, html, plain = parsed.Subject, parsed.HTML, parsed.Plain
if s.Billing != nil {
// Always debit base feature cost (even if provider reported 0 tokens).
if err := s.Billing.ConsumeCredits(ctx, companyID, comp.TotalTokens, "campaign_copy"); err != nil {
return Campaign{}, err
}
}
}
unsubURL := s.unsubscribePlaceholderURL(companyID)
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
if !HasUnsubscribeFooter(html) {
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
}
if !HasUnsubscribeFooter(html) {
return Campaign{}, ErrMissingUnsubscribe
}
subject = security.TruncateRunes(subject, MaxSubjectLen)
var nextVer int
err = s.Pool.QueryRow(ctx, `
SELECT COALESCE(MAX(version), 0) + 1 FROM email_campaign_versions
WHERE company_id=$1 AND campaign_id=$2`, companyID, id).Scan(&nextVer)
if err != nil {
return Campaign{}, err
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO email_campaign_versions (
campaign_id, company_id, version, subject, html_body, plain_body, generation_mode
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, companyID, nextVer, subject, html, plain, mode,
)
if err != nil {
return Campaign{}, err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='ready', updated_at=now() WHERE company_id=$1 AND id=$2`, companyID, id)
return s.Get(ctx, companyID, id)
}
func (s *Service) SendTest(ctx context.Context, companyID, id uuid.UUID, in SendTestInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":test") {
return Campaign{}, ErrRateLimited
}
to := strings.TrimSpace(in.To)
if to == "" {
to = strings.TrimSpace(in.Email)
}
addr, err := NormalizeEmail(to)
if err != nil {
return Campaign{}, ErrInvalidEmail
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || (c.Subject == "" && c.HTMLBody == "") {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified {
return Campaign{}, ErrProviderUnverified
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: []string{addr},
Subject: "[TEST] " + c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "test",
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Schedule(ctx context.Context, companyID, id uuid.UUID, in ScheduleInput) (Campaign, error) {
if in.ScheduledAt.IsZero() || in.ScheduledAt.Before(time.Now().UTC().Add(-time.Minute)) {
return Campaign{}, fmt.Errorf("scheduled_at must be in the future")
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified || !cfg.CanSendReal {
return Campaign{}, ErrProviderUnverified
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil {
return Campaign{}, ErrMissingContent
}
_, err = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='scheduled', scheduled_at=$3, updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id, in.ScheduledAt.UTC())
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Send(ctx context.Context, companyID, id uuid.UUID, in SendInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":send") {
return Campaign{}, ErrRateLimited
}
if !in.Confirm {
return Campaign{}, ErrConfirmRequired
}
if !in.DryRun && s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.email_live_send", "marketing.campaigns.send"); err != nil {
return Campaign{}, err
}
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || c.HTMLBody == "" {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !in.DryRun && (!cfg.Verified || !cfg.CanSendReal) {
return Campaign{}, ErrProviderUnverified
}
recipients := in.Recipients
if len(recipients) == 0 {
res, err := s.ResolveAudienceMap(ctx, companyID, c.AudienceFilter, 100)
if err != nil {
return Campaign{}, err
}
for _, cust := range res.Customers {
recipients = append(recipients, cust.Email)
}
}
cleaned := make([]string, 0, len(recipients))
seen := map[string]struct{}{}
for _, raw := range recipients {
addr, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
cleaned = append(cleaned, addr)
}
if len(cleaned) == 0 {
return Campaign{}, ErrNoRecipients
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: cleaned,
Subject: c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "blast",
ConfirmUnderstood: email.ConfirmUnderstoodPhrase,
ForceDryRun: in.DryRun,
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
if !in.DryRun {
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='sent', sent_at=now(), updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id)
}
return s.Get(ctx, companyID, id)
}
func mapEmailErr(err error) error {
switch {
case errors.Is(err, email.ErrNotConfigured):
return ErrProviderNotFound
case errors.Is(err, email.ErrNotVerified), errors.Is(err, email.ErrNotEnabled):
return ErrProviderUnverified
case errors.Is(err, email.ErrRateLimited):
return ErrRateLimited
case errors.Is(err, email.ErrMissingConfirm):
return ErrConfirmRequired
case errors.Is(err, email.ErrInvalidRecipient), errors.Is(err, email.ErrInvalidFrom):
return ErrInvalidEmail
default:
return err
}
}
func (s *Service) companyName(ctx context.Context, companyID uuid.UUID) string {
var name string
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM companies WHERE id=$1`, companyID).Scan(&name)
name = strings.TrimSpace(name)
if name == "" {
return "our store"
}
return name
}
type productSnippet struct {
Name string
}
func (s *Service) loadProductSnippets(ctx context.Context, companyID uuid.UUID, productIDs, categoryIDs []uuid.UUID) []productSnippet {
out := make([]productSnippet, 0, 8)
if len(productIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1 AND id = ANY($2)
LIMIT 12`, companyID, productIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 && len(categoryIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), 'Product')
FROM processed_products p
JOIN categories c ON c.company_id = p.company_id
AND (c.name = p.category OR c.unique_id = p.category OR c.id::text = p.category)
WHERE p.company_id=$1 AND c.id = ANY($2::uuid[])
ORDER BY p.updated_at DESC NULLS LAST
LIMIT 12`, companyID, categoryIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1
ORDER BY updated_at DESC NULLS LAST
LIMIT 6`, companyID)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
return out
}
func (s *Service) unsubscribePlaceholderURL(companyID uuid.UUID) string {
base := strings.TrimRight(s.WebOrigin, "/")
if base == "" {
base = strings.TrimRight(s.PublicAPIURL, "/")
}
if base == "" {
return "/unsubscribe"
}
return base + "/unsubscribe?company=" + companyID.String()
}
type aiParsed struct {
Subject string
HTML string
Plain string
}
func parseAIContent(text, fallbackSubject, fallbackHTML, fallbackPlain string) aiParsed {
text = strings.TrimSpace(text)
out := aiParsed{Subject: fallbackSubject, HTML: fallbackHTML, Plain: fallbackPlain}
obj, err := processing.ParseJSONObject(text)
if err == nil && obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
out.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
}
return out
}
if strings.Contains(text, "<") {
out.HTML = text
out.Plain = stripTags(text)
}
return out
}
func limitProductSnippets(products []productSnippet, max int) []productSnippet {
if max > 0 && len(products) > max {
products = products[:max]
}
out := make([]productSnippet, len(products))
copy(out, products)
for i := range out {
out[i].Name = security.TruncateRunes(out[i].Name, processing.MaxCampaignNameRunes)
}
return out
}
func defaultIntro(tpl Template, brand string) string {
switch TemplateKey(tpl.Key) {
case TemplateChristmas:
return fmt.Sprintf("Season's greetings from %s — here are a few holiday favorites we think you'll love.", brand)
case TemplateBlackFriday:
return fmt.Sprintf("Black Friday is here. %s picked standout products worth a look before they go.", brand)
case TemplateSpring:
return fmt.Sprintf("Spring refresh from %s — new energy for the season ahead.", brand)
default:
return fmt.Sprintf("A few highlights from %s, curated for you.", brand)
}
}
func buildProductHTML(products []productSnippet) string {
if len(products) == 0 {
return `<p><em>Your selected products will appear here.</em></p>`
}
var b strings.Builder
b.WriteString(`<ul style="padding-left:18px">`)
for _, p := range products {
b.WriteString("<li>" + escapeHTML(p.Name) + "</li>")
}
b.WriteString("</ul>")
return b.String()
}
func productPlainList(products []productSnippet) string {
if len(products) == 0 {
return "(no products selected)"
}
names := make([]string, 0, len(products))
for _, p := range products {
names = append(names, "- "+p.Name)
}
return strings.Join(names, "\n")
}
@@ -0,0 +1,49 @@
package campaigns
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
)
func TestLatestVersionsByCampaignIDsSQL_batchesDistinctOn(t *testing.T) {
if !strings.Contains(latestVersionsByCampaignIDsSQL, "DISTINCT ON (campaign_id)") {
t.Fatal("expected DISTINCT ON so each campaign gets one latest version")
}
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ANY($1)") {
t.Fatal("expected ANY($1) batch filter over campaign IDs")
}
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ORDER BY campaign_id, version DESC") {
t.Fatal("expected ORDER BY campaign_id, version DESC for DISTINCT ON")
}
}
func TestApplyVersionFields(t *testing.T) {
c := Campaign{ID: uuid.New()}
v := Version{
ID: uuid.New(),
Version: 3,
Subject: "Hello",
HTMLBody: "<p>Hi</p>",
PlainBody: "Hi",
}
applyVersionFields(&c, v)
if c.Subject != "Hello" || c.HTMLBody != "<p>Hi</p>" || c.PlainBody != "Hi" {
t.Fatalf("subject/body not applied: %+v", c)
}
if c.LatestVersion == nil || c.LatestVersion.Version != 3 {
t.Fatalf("LatestVersion not applied: %+v", c.LatestVersion)
}
}
func TestAttachLatestVersionsEmpty(t *testing.T) {
s := &Service{}
if err := s.attachLatestVersions(context.TODO(), nil); err != nil {
t.Fatalf("empty page should no-op: %v", err)
}
if err := s.attachLatestVersions(context.TODO(), []Campaign{}); err != nil {
t.Fatalf("empty slice should no-op: %v", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package campaigns
import (
"testing"
"github.com/google/uuid"
)
func TestValidateCampaignRefs(t *testing.T) {
okCats := make([]uuid.UUID, maxCampaignCategoryIDs)
okProds := make([]uuid.UUID, maxCampaignProductIDs)
for i := range okCats {
okCats[i] = uuid.New()
}
for i := range okProds {
okProds[i] = uuid.New()
}
if err := validateCampaignRefs(okCats, okProds); err != nil {
t.Fatalf("expected ok, got %v", err)
}
tooManyCats := append(append([]uuid.UUID{}, okCats...), uuid.New())
if err := validateCampaignRefs(tooManyCats, nil); err != ErrTooManyCategoryIDs {
t.Fatalf("got %v want ErrTooManyCategoryIDs", err)
}
tooManyProds := append(append([]uuid.UUID{}, okProds...), uuid.New())
if err := validateCampaignRefs(nil, tooManyProds); err != ErrTooManyProductIDs {
t.Fatalf("got %v want ErrTooManyProductIDs", err)
}
}
func TestClientErrorTooManyRefs(t *testing.T) {
for _, err := range []error{ErrTooManyProductIDs, ErrTooManyCategoryIDs} {
msg, ok := ClientError(err)
if !ok || msg == "" {
t.Fatalf("ClientError(%v) ok=%v msg=%q", err, ok, msg)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package campaigns
import (
"net/http"
"sync"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the email campaigns API surface (CRUD + generate + schedule/send).
// Tenant sending goes through email.Service (verified provider, rate limits, unsub).
type Service struct {
Pool *pgxpool.Pool
Billing *billing.Service
Email *email.Service
Completer processing.Completer
AI *aiprovider.Service
Prompts *aiprompts.Service
WebOrigin string
PublicAPIURL string
// TokenSigningSecret signs public brand-logo URLs for email embeds.
TokenSigningSecret string
HTTP *http.Client
genMu sync.Mutex
genHit map[string][]time.Time
sendMu sync.Mutex
sendHit map[string][]time.Time
}
func NewService(pool *pgxpool.Pool, billingSvc *billing.Service, emailSvc *email.Service) *Service {
return &Service{
Pool: pool,
Billing: billingSvc,
Email: emailSvc,
HTTP: &http.Client{Timeout: 30 * time.Second},
genHit: make(map[string][]time.Time),
sendHit: make(map[string][]time.Time),
}
}
const (
generateRPM = 10
sendRPM = 20
)
func (s *Service) allowGenerate(companyID string) bool {
return allowWindow(&s.genMu, s.genHit, companyID, generateRPM, time.Minute)
}
func (s *Service) allowSend(companyID string) bool {
return allowWindow(&s.sendMu, s.sendHit, companyID, sendRPM, time.Minute)
}
func allowWindow(mu *sync.Mutex, hits map[string][]time.Time, key string, limit int, window time.Duration) bool {
now := time.Now()
cutoff := now.Add(-window)
mu.Lock()
defer mu.Unlock()
ts := hits[key]
kept := ts[:0]
for _, t := range ts {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= limit {
hits[key] = kept
return false
}
hits[key] = append(kept, now)
return true
}
+119
View File
@@ -0,0 +1,119 @@
package campaigns
import (
"fmt"
"strings"
)
// TemplateKey is a seasonal or custom campaign template identifier.
type TemplateKey string
const (
TemplateChristmas TemplateKey = "christmas"
TemplateBlackFriday TemplateKey = "black_friday"
TemplateSpring TemplateKey = "spring"
TemplateCustom TemplateKey = "custom"
)
type Template struct {
Key string `json:"key"`
Name string `json:"name"`
Season string `json:"season"`
DefaultSubject string `json:"default_subject"`
DefaultPrompt string `json:"default_prompt"`
Description string `json:"description"`
}
var builtInTemplates = []Template{
{
Key: string(TemplateChristmas),
Name: "Christmas",
Season: "christmas",
DefaultSubject: "Holiday picks from {{brand}}",
DefaultPrompt: "Warm Christmas email for these products. Festive, concise, clear CTA. JSON only.",
Description: "Festive seasonal campaign for holiday shoppers.",
},
{
Key: string(TemplateBlackFriday),
Name: "Black Friday",
Season: "black_friday",
DefaultSubject: "Black Friday deals from {{brand}}",
DefaultPrompt: "Urgent Black Friday email for these products. Limited-time value, no false claims, strong CTA. JSON only.",
Description: "Deal-focused Black Friday / Cyber Week campaign.",
},
{
Key: string(TemplateSpring),
Name: "Spring",
Season: "spring",
DefaultSubject: "Fresh for spring — {{brand}}",
DefaultPrompt: "Light spring email for these products. Renewal + practical benefits, clear CTA. JSON only.",
Description: "Seasonal spring refresh campaign.",
},
{
Key: string(TemplateCustom),
Name: "Custom",
Season: "custom",
DefaultSubject: "News from {{brand}}",
DefaultPrompt: "Clear marketing email for these products. Short subject, scannable body, CTA. JSON only.",
Description: "Blank slate with sensible defaults.",
},
}
func ListTemplates() []Template {
out := make([]Template, len(builtInTemplates))
copy(out, builtInTemplates)
return out
}
func GetTemplate(key string) (Template, error) {
key = strings.TrimSpace(strings.ToLower(key))
if key == "" {
key = string(TemplateCustom)
}
for _, t := range builtInTemplates {
if t.Key == key {
return t, nil
}
}
return Template{}, ErrInvalidTemplate
}
func ValidTemplateKey(key string) bool {
_, err := GetTemplate(key)
return err == nil
}
func renderSubject(tpl Template, brand string) string {
if brand == "" {
brand = "our store"
}
return strings.ReplaceAll(tpl.DefaultSubject, "{{brand}}", brand)
}
func templateHTML(subject, intro, productBlock, ctaURL, logoURL string) string {
if ctaURL == "" {
ctaURL = "#"
}
logoBlock := ""
if strings.TrimSpace(logoURL) != "" {
logoBlock = fmt.Sprintf(
`<p style="margin:0 0 16px"><img src="%s" alt="" width="120" style="max-width:160px;height:auto;border:0" /></p>`,
escapeAttr(logoURL),
)
}
return fmt.Sprintf(`<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#222;line-height:1.5">
%s<h1 style="font-size:22px;margin:0 0 12px">%s</h1>
<p>%s</p>
%s
<p style="margin:24px 0"><a href="%s" style="background:#2d1b4e;color:#fff;padding:10px 16px;text-decoration:none;border-radius:4px">Shop now</a></p>
</body></html>`, logoBlock, escapeHTML(subject), escapeHTML(intro), productBlock, escapeAttr(ctaURL))
}
func escapeHTML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
func escapeAttr(s string) string {
return escapeHTML(s)
}
@@ -0,0 +1,35 @@
package campaigns
import "testing"
func TestListTemplates(t *testing.T) {
tpls := ListTemplates()
if len(tpls) != 4 {
t.Fatalf("expected 4 templates, got %d", len(tpls))
}
for _, key := range []string{"christmas", "black_friday", "spring", "custom"} {
if !ValidTemplateKey(key) {
t.Fatalf("expected valid key %s", key)
}
}
}
func TestNormalizeEmail(t *testing.T) {
e, err := NormalizeEmail(" User@Example.COM ")
if err != nil || e != "user@example.com" {
t.Fatalf("got %q err=%v", e, err)
}
if _, err := NormalizeEmail("not-an-email"); err == nil {
t.Fatal("expected error")
}
}
func TestUnsubscribeFooter(t *testing.T) {
html, plain := EnsureUnsubscribeFooter("<p>Hi</p>", "Hi", "https://example.com/unsubscribe?token=abc")
if !HasUnsubscribeFooter(html) {
t.Fatalf("missing footer in %s", html)
}
if plain == "" {
t.Fatal("plain empty")
}
}
+126
View File
@@ -0,0 +1,126 @@
package campaigns
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/mail"
"regexp"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
const (
MaxPromptLen = security.MaxCampaignPromptRunes
MaxSubjectLen = 200
MaxHTMLBodyLen = security.MaxEmailHTMLRunes
unsubscribeMark = "data-descrybe-unsubscribe"
)
var emailLoose = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
// NormalizeEmail lowercases and trims; returns ErrInvalidEmail when invalid.
func NormalizeEmail(raw string) (string, error) {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" || len(raw) > 254 {
return "", ErrInvalidEmail
}
addr, err := mail.ParseAddress(raw)
if err != nil {
return "", ErrInvalidEmail
}
e := strings.TrimSpace(strings.ToLower(addr.Address))
if !emailLoose.MatchString(e) {
return "", ErrInvalidEmail
}
return e, nil
}
func EmailHash(email string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(sum[:])
}
func NewToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func ValidatePrompt(prompt string) error {
if security.CapPromptLength(prompt, MaxPromptLen) {
return ErrPromptTooLong
}
return nil
}
// SanitizePrompt bounds and soft-filters campaign prompts before AI / storage.
func SanitizePrompt(prompt string) string {
return security.SanitizePrompt(prompt, MaxPromptLen)
}
// SanitizeHTMLBody strips dangerous markup from generated/stored campaign HTML.
func SanitizeHTMLBody(html string) string {
return security.SanitizeEmailHTML(html)
}
func HasUnsubscribeFooter(html string) bool {
lower := strings.ToLower(html)
if strings.Contains(lower, unsubscribeMark) {
return true
}
if strings.Contains(lower, "unsubscribe") && (strings.Contains(lower, "href=") || strings.Contains(lower, "/unsubscribe")) {
return true
}
return false
}
func EnsureUnsubscribeFooter(html, plain, unsubscribeURL string) (string, string) {
if HasUnsubscribeFooter(html) {
if plain == "" {
plain = stripTags(html)
}
return html, plain
}
footerHTML := `<hr style="border:none;border-top:1px solid #ddd;margin:24px 0"/>` +
`<p style="font-size:12px;color:#666" ` + unsubscribeMark + `="1">` +
`You are receiving this because you opted in to marketing emails. ` +
`<a href="` + unsubscribeURL + `">Unsubscribe</a>.</p>`
footerPlain := "\n\n---\nUnsubscribe: " + unsubscribeURL + "\n"
if strings.TrimSpace(html) == "" {
html = "<div></div>"
}
html = html + footerHTML
if plain == "" {
plain = stripTags(html)
} else {
plain = plain + footerPlain
}
return html, plain
}
func stripTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
if unicode.IsSpace(r) {
if b.Len() > 0 && b.String()[b.Len()-1] != ' ' {
b.WriteByte(' ')
}
} else {
b.WriteRune(r)
}
}
}
return strings.TrimSpace(b.String())
}
+370
View File
@@ -0,0 +1,370 @@
package catalog
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const productCursorVersion = 1
// productCursor is an opaque keyset bookmark for product list pages.
// Encoded as URL-safe base64 JSON in the `cursor` query param.
type productCursor struct {
V int `json:"v"`
ID string `json:"id"`
SB string `json:"sb"`
SO string `json:"so"`
K string `json:"k"`
Blank bool `json:"b,omitempty"`
}
// HasProductCursor reports whether the filter requests keyset pagination.
func HasProductCursor(f ListFilter) bool {
return strings.TrimSpace(f.Cursor) != "" || strings.TrimSpace(f.AfterID) != ""
}
// EncodeProductCursor builds an opaque cursor from a product list row.
func EncodeProductCursor(f ListFilter, item map[string]any) (string, error) {
f = NormalizeListFilter(f)
id := stringifyID(item["id"])
if id == "" {
return "", fmt.Errorf("missing id")
}
cur := productCursor{
V: productCursorVersion,
ID: id,
SB: f.SortBy,
SO: f.SortOrder,
}
switch f.SortBy {
case "name":
name := productSortName(item)
cur.Blank = name == ""
cur.K = strings.ToLower(name)
case "createdAt":
ts, ok := asTime(item["created_at"])
if !ok {
return "", fmt.Errorf("missing created_at")
}
cur.K = ts.UTC().Format(time.RFC3339Nano)
default: // updatedAt
ts, ok := asTime(item["updated_at"])
if !ok {
return "", fmt.Errorf("missing updated_at")
}
cur.K = ts.UTC().Format(time.RFC3339Nano)
}
raw, err := json.Marshal(cur)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// DecodeProductCursor parses an opaque product list cursor.
func DecodeProductCursor(raw string) (productCursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return productCursor{}, fmt.Errorf("empty cursor")
}
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return productCursor{}, fmt.Errorf("invalid cursor encoding")
}
var cur productCursor
if err := json.Unmarshal(b, &cur); err != nil {
return productCursor{}, fmt.Errorf("invalid cursor payload")
}
if cur.V != productCursorVersion {
return productCursor{}, fmt.Errorf("unsupported cursor version")
}
if _, err := uuid.Parse(cur.ID); err != nil {
return productCursor{}, fmt.Errorf("invalid cursor id")
}
switch cur.SB {
case "name", "updatedAt", "createdAt":
default:
return productCursor{}, fmt.Errorf("invalid cursor sort")
}
if cur.SO != "asc" && cur.SO != "desc" {
return productCursor{}, fmt.Errorf("invalid cursor order")
}
return cur, nil
}
// NextProductCursor returns next_cursor / next_after_id when the page is full.
// When the page length equals limit there may still be no further rows (rare);
// clients should treat an empty follow-up page as the end.
func NextProductCursor(f ListFilter, items []map[string]any, limit int) (nextCursor, nextAfterID string) {
f = NormalizeListFilter(f)
if limit <= 0 || len(items) < limit {
return "", ""
}
last := items[len(items)-1]
nextAfterID = stringifyID(last["id"])
enc, err := EncodeProductCursor(f, last)
if err != nil {
return "", nextAfterID
}
return enc, nextAfterID
}
func (c productCursor) matchesFilter(f ListFilter) bool {
f = NormalizeListFilter(f)
return c.SB == f.SortBy && c.SO == f.SortOrder
}
// appendRawKeyset adds a keyset predicate for raw_products (alias rp).
func appendRawKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
id, err := uuid.Parse(cur.ID)
if err != nil {
return args, where, fmt.Errorf("invalid cursor id")
}
dirAfter := keysetOp(f.SortOrder)
switch f.SortBy {
case "name":
nameExpr := `COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '')`
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
args = append(args, boolToInt(cur.Blank), cur.K, id)
b, k, i := len(args)-2, len(args)-1, len(args)
where = append(where, fmt.Sprintf(`(
%s > $%d
OR (%s = $%d AND LOWER(%s) %s $%d)
OR (%s = $%d AND LOWER(%s) = $%d AND rp.id %s $%d)
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
return args, where, nil
case "createdAt":
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(rp.created_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
default: // updatedAt
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(rp.updated_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
}
}
// appendProcessedKeyset adds a keyset predicate for processed_products (alias p).
func appendProcessedKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
id, err := uuid.Parse(cur.ID)
if err != nil {
return args, where, fmt.Errorf("invalid cursor id")
}
dirAfter := keysetOp(f.SortOrder)
switch f.SortBy {
case "name":
nameExpr := `COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), '')`
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
args = append(args, boolToInt(cur.Blank), cur.K, id)
b, k, i := len(args)-2, len(args)-1, len(args)
where = append(where, fmt.Sprintf(`(
%s > $%d
OR (%s = $%d AND LOWER(%s) %s $%d)
OR (%s = $%d AND LOWER(%s) = $%d AND p.id %s $%d)
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
return args, where, nil
case "createdAt":
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(p.created_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
default: // updatedAt
ts, err := time.Parse(time.RFC3339Nano, cur.K)
if err != nil {
ts, err = time.Parse(time.RFC3339, cur.K)
}
if err != nil {
return args, where, fmt.Errorf("invalid cursor timestamp")
}
args = append(args, ts, id)
a, b := len(args)-1, len(args)
where = append(where, fmt.Sprintf("(p.updated_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
return args, where, nil
}
}
func keysetOp(sortOrder string) string {
if sortOrder == "asc" {
return ">"
}
return "<"
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
func productSortName(item map[string]any) string {
for _, key := range []string{"processed_name", "name"} {
if s := strings.TrimSpace(stringifyID(item[key])); s != "" {
return s
}
}
return ""
}
func stringifyID(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(t)
case uuid.UUID:
return t.String()
case [16]byte:
return uuid.UUID(t).String()
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
func asTime(v any) (time.Time, bool) {
switch t := v.(type) {
case time.Time:
return t, true
case *time.Time:
if t == nil {
return time.Time{}, false
}
return *t, true
case string:
if ts, err := time.Parse(time.RFC3339Nano, t); err == nil {
return ts, true
}
if ts, err := time.Parse(time.RFC3339, t); err == nil {
return ts, true
}
}
return time.Time{}, false
}
// resolveRawCursor decodes cursor or loads sort keys for after_id on raw_products.
// missing=true means after_id was not found for this company (caller should return an empty page).
func (s *Service) resolveRawCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
if c := strings.TrimSpace(f.Cursor); c != "" {
cur, err = DecodeProductCursor(c)
if err != nil {
return productCursor{}, false, false, ClientMsg("invalid cursor")
}
if !cur.matchesFilter(f) {
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
}
return cur, true, false, nil
}
after := strings.TrimSpace(f.AfterID)
if after == "" {
return productCursor{}, false, false, nil
}
id, parseErr := uuid.Parse(after)
if parseErr != nil {
return productCursor{}, false, false, ClientMsg("invalid after_id")
}
var name string
var createdAt, updatedAt time.Time
scanErr := s.Pool.QueryRow(ctx, `
SELECT COALESCE(NULLIF(mapped_data->>'name', ''), NULLIF(mapped_data->>'title', ''), ''),
created_at, updated_at
FROM raw_products
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &createdAt, &updatedAt)
if scanErr != nil {
if errors.Is(scanErr, pgx.ErrNoRows) {
return productCursor{}, true, true, nil
}
return productCursor{}, false, false, scanErr
}
cur = productCursorFromRow(f, id.String(), name, createdAt, updatedAt)
return cur, true, false, nil
}
// resolveProcessedCursor decodes cursor or loads sort keys for after_id on processed_products.
func (s *Service) resolveProcessedCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
if c := strings.TrimSpace(f.Cursor); c != "" {
cur, err = DecodeProductCursor(c)
if err != nil {
return productCursor{}, false, false, ClientMsg("invalid cursor")
}
if !cur.matchesFilter(f) {
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
}
return cur, true, false, nil
}
after := strings.TrimSpace(f.AfterID)
if after == "" {
return productCursor{}, false, false, nil
}
id, parseErr := uuid.Parse(after)
if parseErr != nil {
return productCursor{}, false, false, ClientMsg("invalid after_id")
}
var name, processedName string
var createdAt, updatedAt time.Time
scanErr := s.Pool.QueryRow(ctx, `
SELECT COALESCE(name, ''), COALESCE(processed_name, ''), created_at, updated_at
FROM processed_products
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &processedName, &createdAt, &updatedAt)
if scanErr != nil {
if errors.Is(scanErr, pgx.ErrNoRows) {
return productCursor{}, true, true, nil
}
return productCursor{}, false, false, scanErr
}
display := strings.TrimSpace(processedName)
if display == "" {
display = strings.TrimSpace(name)
}
cur = productCursorFromRow(f, id.String(), display, createdAt, updatedAt)
return cur, true, false, nil
}
func productCursorFromRow(f ListFilter, id, name string, createdAt, updatedAt time.Time) productCursor {
cur := productCursor{
V: productCursorVersion,
ID: id,
SB: f.SortBy,
SO: f.SortOrder,
}
switch f.SortBy {
case "name":
cur.Blank = name == ""
cur.K = strings.ToLower(name)
case "createdAt":
cur.K = createdAt.UTC().Format(time.RFC3339Nano)
default:
cur.K = updatedAt.UTC().Format(time.RFC3339Nano)
}
return cur
}
+62
View File
@@ -0,0 +1,62 @@
package catalog
import (
"strings"
"testing"
"time"
)
func TestEncodeDecodeProductCursorRoundTrip(t *testing.T) {
ts := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
item := map[string]any{
"id": "00000000-0000-4000-8000-000000000099",
"updated_at": ts,
}
enc, err := EncodeProductCursor(f, item)
if err != nil {
t.Fatal(err)
}
if enc == "" {
t.Fatal("empty cursor")
}
cur, err := DecodeProductCursor(enc)
if err != nil {
t.Fatal(err)
}
if cur.ID != "00000000-0000-4000-8000-000000000099" {
t.Fatalf("id=%q", cur.ID)
}
if cur.SB != "updatedAt" || cur.SO != "desc" {
t.Fatalf("sort meta: %+v", cur)
}
if !strings.HasPrefix(cur.K, "2026-08-04T12:00:00") {
t.Fatalf("key=%q", cur.K)
}
}
func TestNextProductCursor(t *testing.T) {
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
items := []map[string]any{
{"id": "00000000-0000-4000-8000-000000000001", "updated_at": time.Now().UTC()},
{"id": "00000000-0000-4000-8000-000000000002", "updated_at": time.Now().UTC()},
}
next, after := NextProductCursor(f, items, 2)
if next == "" || after != "00000000-0000-4000-8000-000000000002" {
t.Fatalf("next=%q after=%q", next, after)
}
none, noneAfter := NextProductCursor(f, items[:1], 2)
if none != "" || noneAfter != "" {
t.Fatalf("short page should end: next=%q after=%q", none, noneAfter)
}
}
func TestHasProductCursorClearsOffset(t *testing.T) {
f := NormalizeListFilter(ListFilter{Offset: 500, AfterID: "00000000-0000-4000-8000-000000000001"})
if !HasProductCursor(f) {
t.Fatal("expected cursor")
}
if f.Offset != 0 {
t.Fatalf("offset should clear with cursor: %d", f.Offset)
}
}
@@ -0,0 +1,170 @@
package catalog
import (
"context"
"encoding/json"
"github.com/google/uuid"
)
type ecommerceGroupDef struct {
Key string
Name string
Description string
Order int
}
type ecommerceFieldDef struct {
Key string
Name string
Type string
GroupKey string
Required bool
Enabled bool
Recommended bool
Unit string
DefaultValue string
SortOrder int
Hints []string
Description string
}
func ecommerceGroups() []ecommerceGroupDef {
return []ecommerceGroupDef{
{Key: "basic", Name: "Basic Information", Description: "Core product identifiers and content", Order: 10},
{Key: "pricing", Name: "Pricing", Description: "Price and currency fields", Order: 20},
{Key: "media", Name: "Media", Description: "Images and media URLs", Order: 30},
{Key: "taxonomy", Name: "Taxonomy", Description: "Categories and classification", Order: 40},
{Key: "inventory", Name: "Inventory", Description: "Stock and availability", Order: 50},
{Key: "attributes", Name: "Attributes", Description: "Variant and product attributes", Order: 60},
{Key: "shipping", Name: "Shipping", Description: "Weight and dimensions", Order: 70},
}
}
func ecommerceFields() []ecommerceFieldDef {
return []ecommerceFieldDef{
{Key: "gtin", Name: "GTIN/EAN", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"ean", "upc", "barcode", "gtin13"}, Description: "Product barcode"},
{Key: "title", Name: "Product name", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"name", "product_name", "product_title"}, Description: "Primary product title"},
{Key: "brand", Name: "Brand", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 30, Hints: []string{"manufacturer", "vendor"}, Description: "Brand or manufacturer"},
{Key: "description", Name: "Description", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 40, Hints: []string{"desc", "body", "long_description"}, Description: "Product description"},
{Key: "sku", Name: "SKU", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 50, Hints: []string{"item_sku", "article_number"}, Description: "Stock keeping unit"},
{Key: "mpn", Name: "MPN", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"manufacturer_part_number", "part_number"}, Description: "Manufacturer part number"},
{Key: "product_model", Name: "Product model", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 65, Hints: []string{"model", "productmodel", "product_model"}, Description: "Manufacturer model name/number"},
{Key: "product_url", Name: "Product URL", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"url", "link", "product_link"}, Description: "Canonical product page URL"},
{Key: "official_link", Name: "Official link", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 80, Hints: []string{"officiallink", "manufacturer_url"}, Description: "Manufacturer or brand product page"},
{Key: "price", Name: "Price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Unit: "EUR", Hints: []string{"regular_price", "list_price", "amount"}, Description: "Regular price"},
{Key: "sale_price", Name: "Sale price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Unit: "EUR", Hints: []string{"special_price", "discount_price"}, Description: "Promotional price"},
{Key: "purchase_price", Name: "Purchase price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 25, Unit: "EUR", Hints: []string{"purchaseprice", "cost", "buy_price"}, Description: "Cost / buy price"},
{Key: "currency", Name: "Currency", Type: "string", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 30, DefaultValue: "EUR", Hints: []string{"price_currency", "curr"}, Description: "ISO currency code"},
{Key: "image_url", Name: "Image URL", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"image", "image_link", "thumbnail"}, Description: "Primary product image"},
{Key: "main_image", Name: "Main image", Type: "image", GroupKey: "media", Required: true, Enabled: true, Recommended: true, SortOrder: 15, Hints: []string{"mainimage", "image", "image_url"}, Description: "Main gallery image URL"},
{Key: "additional_image_urls", Name: "Additional images", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"images", "gallery", "moreimages", "additional_images"}, Description: "Extra product images"},
{Key: "video_url", Name: "Video URL", Type: "url", GroupKey: "media", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"videourl", "video"}, Description: "Product video URL"},
{Key: "category", Name: "Category", Type: "string", GroupKey: "taxonomy", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"product_type", "google_product_category", "category_path"}, Description: "Product category"},
{Key: "availability", Name: "Availability", Type: "string", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"in_stock", "stock_status", "stockstatus"}, Description: "Availability status"},
{Key: "stock", Name: "Stock", Type: "number", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"quantity", "qty", "inventory"}, Description: "Stock quantity"},
{Key: "color", Name: "Color", Type: "color", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"colour", "farbe"}, Description: "Color attribute"},
{Key: "size", Name: "Size", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"groesse", "dimension_size"}, Description: "Size attribute"},
{Key: "material", Name: "Material", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"fabric", "composition"}, Description: "Material attribute"},
{Key: "warranty", Name: "Warranty", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"guarantee"}, Description: "Warranty term or text"},
{Key: "service", Name: "Service", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{}, Description: "Service or support notes"},
{Key: "specifications", Name: "Specifications", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"specs", "specification"}, Description: "Technical specifications"},
{Key: "eprel_id", Name: "EPREL ID", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"eprelid", "eprel"}, Description: "EU energy label identifier"},
{Key: "weight", Name: "Weight", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 10, Unit: "kg", Hints: []string{"shipping_weight", "product_weight", "netmass"}, Description: "Product weight"},
{Key: "net_depth", Name: "Net depth", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 20, Hints: []string{"netdepth", "depth"}, Description: "Net depth"},
{Key: "net_height", Name: "Net height", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"netheight", "height"}, Description: "Net height"},
{Key: "net_width", Name: "Net width", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"netwidth", "width"}, Description: "Net width"},
{Key: "net_mass", Name: "Net mass", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{"netmass", "mass"}, Description: "Net mass"},
}
}
func recommendedEcommerceKeys() []string {
out := make([]string, 0)
for _, f := range ecommerceFields() {
if f.Recommended {
out = append(out, f.Key)
}
}
return out
}
func (s *Service) ensureGroupID(ctx context.Context, companyID uuid.UUID, g ecommerceGroupDef) (uuid.UUID, error) {
var id uuid.UUID
err := s.Pool.QueryRow(ctx, `
SELECT id FROM field_groups WHERE company_id = $1 AND name = $2 LIMIT 1`,
companyID, g.Name).Scan(&id)
if err == nil {
_, _ = s.Pool.Exec(ctx, `
UPDATE field_groups SET description = $3, "order" = $4, is_system = true, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, g.Description, g.Order)
return id, nil
}
err = s.Pool.QueryRow(ctx, `
INSERT INTO field_groups (company_id, name, description, "order", is_system)
VALUES ($1, $2, $3, $4, true) RETURNING id`,
companyID, g.Name, g.Description, g.Order).Scan(&id)
return id, err
}
// EnsureEcommerceCatalog upserts system field groups and standard fields for ecommerce.
func (s *Service) EnsureEcommerceCatalog(ctx context.Context, companyID uuid.UUID) error {
groupIDs := map[string]uuid.UUID{}
for _, g := range ecommerceGroups() {
id, err := s.ensureGroupID(ctx, companyID, g)
if err != nil {
return err
}
groupIDs[g.Key] = id
}
for _, f := range ecommerceFields() {
gid, ok := groupIDs[f.GroupKey]
if !ok {
continue
}
hints, _ := json.Marshal(f.Hints)
var defVal *string
if f.DefaultValue != "" {
v := f.DefaultValue
defVal = &v
}
var unit *string
if f.Unit != "" {
u := f.Unit
unit = &u
}
var desc *string
if f.Description != "" {
d := f.Description
desc = &d
}
_, err := s.Pool.Exec(ctx, `
INSERT INTO standard_fields (
company_id, name, key, type, group_id, is_required, description, default_value,
is_system, enabled, unit, sort_order, mapping_hints
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true,$9,$10,$11,$12::jsonb)
ON CONFLICT (company_id, key) DO UPDATE SET
name = EXCLUDED.name,
type = EXCLUDED.type,
group_id = EXCLUDED.group_id,
is_required = standard_fields.is_required OR EXCLUDED.is_required,
description = COALESCE(EXCLUDED.description, standard_fields.description),
default_value = COALESCE(standard_fields.default_value, EXCLUDED.default_value),
unit = COALESCE(standard_fields.unit, EXCLUDED.unit),
sort_order = EXCLUDED.sort_order,
enabled = standard_fields.enabled OR EXCLUDED.enabled,
mapping_hints = CASE
WHEN standard_fields.mapping_hints IS NULL
OR standard_fields.mapping_hints = '[]'::jsonb
THEN EXCLUDED.mapping_hints
ELSE standard_fields.mapping_hints
END,
updated_at = now()`,
companyID, f.Name, f.Key, f.Type, gid, f.Required, desc, defVal,
f.Enabled, unit, f.SortOrder, string(hints))
if err != nil {
return err
}
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package catalog
import "errors"
var (
ErrSystemImmutable = errors.New("system records cannot be modified or deleted")
ErrNotFound = errors.New("not found")
)
// clientError is a validation/business message safe to return to API clients.
type clientError struct {
msg string
}
func (e *clientError) Error() string { return e.msg }
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
func ClientMsg(msg string) error {
return &clientError{msg: msg}
}
// ClientError reports whether err is a known client-facing catalog error.
func ClientError(err error) (msg string, ok bool) {
if err == nil {
return "", false
}
var ce *clientError
if errors.As(err, &ce) {
return ce.msg, true
}
switch {
case errors.Is(err, ErrNotFound):
return "not found", true
case errors.Is(err, ErrSystemImmutable):
return err.Error(), true
default:
return "", false
}
}
+273
View File
@@ -0,0 +1,273 @@
package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maxUploadBytes = 5 << 20 // 5 MiB
func sanitizeFileName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." {
return "upload.csv"
}
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
return "upload.csv"
}
return out
}
func (s *Service) SaveUpload(ctx context.Context, companyID, userID uuid.UUID, uploadDir, originalName, contentType, kind string, r io.Reader) (map[string]any, error) {
uploadDir = strings.TrimSpace(uploadDir)
if uploadDir == "" {
return nil, ClientMsg("upload directory not configured")
}
safe := sanitizeFileName(originalName)
lower := strings.ToLower(safe)
if !strings.HasSuffix(lower, ".csv") {
return nil, ClientMsg("only .csv uploads are allowed")
}
if contentType != "" &&
!strings.Contains(strings.ToLower(contentType), "csv") &&
!strings.Contains(strings.ToLower(contentType), "text/plain") &&
!strings.Contains(strings.ToLower(contentType), "octet-stream") {
return nil, ClientMsg("invalid content type for CSV upload")
}
kind = strings.ToLower(strings.TrimSpace(kind))
if kind == "" {
kind = "products"
}
metaBytes, _ := json.Marshal(map[string]any{"kind": kind})
fileID := uuid.New()
dir := filepath.Join(uploadDir, companyID.String())
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
rel := filepath.ToSlash(filepath.Join(companyID.String(), fileID.String()+"-"+safe))
abs := filepath.Join(uploadDir, filepath.FromSlash(rel))
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
if err != nil {
return nil, err
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(r, maxUploadBytes+1))
if err != nil {
_ = os.Remove(abs)
return nil, err
}
if n > maxUploadBytes {
_ = os.Remove(abs)
return nil, ClientMsg(fmt.Sprintf("file exceeds %d byte limit", maxUploadBytes))
}
var uid any
if userID != uuid.Nil {
uid = userID
}
var id uuid.UUID
err = s.Pool.QueryRow(ctx, `
INSERT INTO files (id, company_id, user_id, name, path, content_type, size_bytes, status, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8::jsonb)
RETURNING id`, fileID, companyID, uid, safe, rel, contentType, n, string(metaBytes)).Scan(&id)
if err != nil {
_ = os.Remove(abs)
return nil, err
}
return map[string]any{
"id": id.String(),
"name": safe,
"path": rel,
"content_type": contentType,
"size_bytes": n,
"status": "uploaded",
"kind": kind,
"metadata": map[string]any{"kind": kind},
}, nil
}
func (s *Service) ResolveUploadPath(uploadDir string, companyID uuid.UUID, rel string) (string, error) {
uploadDir = strings.TrimSpace(uploadDir)
if uploadDir == "" {
return "", ClientMsg("upload directory not configured")
}
rel = filepath.ToSlash(strings.TrimSpace(rel))
if rel == "" || strings.Contains(rel, "..") {
return "", ClientMsg("invalid path")
}
prefix := companyID.String() + "/"
if !strings.HasPrefix(rel, prefix) {
return "", ClientMsg("forbidden")
}
base, err := filepath.Abs(uploadDir)
if err != nil {
return "", err
}
abs, err := filepath.Abs(filepath.Join(uploadDir, filepath.FromSlash(rel)))
if err != nil {
return "", err
}
sep := string(os.PathSeparator)
if abs != base && !strings.HasPrefix(abs, base+sep) {
return "", ClientMsg("forbidden")
}
return abs, nil
}
func scanFileRow(rows pgx.Row) (map[string]any, error) {
var (
id uuid.UUID
companyID uuid.UUID
userID *uuid.UUID
name string
path *string
contentType *string
sizeBytes int64
status string
metadata []byte
createdAt time.Time
updatedAt time.Time
)
if err := rows.Scan(&id, &companyID, &userID, &name, &path, &contentType, &sizeBytes, &status, &metadata, &createdAt, &updatedAt); err != nil {
return nil, err
}
var meta any = map[string]any{}
if len(metadata) > 0 {
_ = json.Unmarshal(metadata, &meta)
}
out := map[string]any{
"id": id.String(),
"company_id": companyID.String(),
"name": name,
"size_bytes": sizeBytes,
"status": status,
"metadata": meta,
"created_at": createdAt.UTC().Format(time.RFC3339),
"updated_at": updatedAt.UTC().Format(time.RFC3339),
}
if userID != nil {
out["user_id"] = userID.String()
}
if path != nil {
out["path"] = *path
}
if contentType != nil {
out["content_type"] = *contentType
}
if m, ok := meta.(map[string]any); ok {
if k, ok := m["kind"].(string); ok {
out["kind"] = k
}
}
return out, nil
}
func (s *Service) ListFiles(ctx context.Context, companyID uuid.UUID, f ListFilter) ([]map[string]any, int, error) {
f = NormalizeListFilter(f)
var total int
if err := s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM files WHERE company_id = $1`, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
FROM files
WHERE company_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, companyID, f.Limit, f.Offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]map[string]any, 0)
for rows.Next() {
item, err := scanFileRow(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (s *Service) GetFile(ctx context.Context, companyID, fileID uuid.UUID) (map[string]any, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
item, err := scanFileRow(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
return item, err
}
func (s *Service) UpdateFileStatus(ctx context.Context, companyID, fileID uuid.UUID, status string, metadata map[string]any) (map[string]any, error) {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "uploaded", "processing", "completed", "failed":
default:
return nil, ClientMsg("invalid file status")
}
metaBytes := []byte("{}")
if metadata != nil {
b, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
metaBytes = b
}
_, err := s.Pool.Exec(ctx, `
UPDATE files
SET status = $3,
metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
updated_at = now()
WHERE company_id = $1 AND id = $2`, companyID, fileID, status, string(metaBytes))
if err != nil {
return nil, err
}
return s.GetFile(ctx, companyID, fileID)
}
func (s *Service) DeleteFile(ctx context.Context, companyID, fileID uuid.UUID, uploadDir string) error {
item, err := s.GetFile(ctx, companyID, fileID)
if err != nil {
return err
}
tag, err := s.Pool.Exec(ctx, `DELETE FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
if pathStr, ok := item["path"].(string); ok && pathStr != "" {
if abs, err := s.ResolveUploadPath(uploadDir, companyID, pathStr); err == nil {
_ = os.Remove(abs)
}
}
return nil
}
@@ -0,0 +1,46 @@
package catalog
import (
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
)
func TestResolveUploadPath(t *testing.T) {
t.Parallel()
base := t.TempDir()
cid := uuid.New()
rel := cid.String() + "/sample.csv"
absWant := filepath.Join(base, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(absWant), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(absWant, []byte("a,b\n1,2\n"), 0o640); err != nil {
t.Fatal(err)
}
svc := &Service{}
got, err := svc.ResolveUploadPath(base, cid, rel)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if filepath.Clean(got) != filepath.Clean(absWant) {
t.Fatalf("got %q want %q", got, absWant)
}
if _, err := svc.ResolveUploadPath("", cid, rel); err == nil {
t.Fatal("expected empty upload dir reject")
}
if _, err := svc.ResolveUploadPath(base, cid, "../etc/passwd"); err == nil {
t.Fatal("expected traversal reject")
}
if _, err := svc.ResolveUploadPath(base, cid, cid.String()+"/../outside.csv"); err == nil {
t.Fatal("expected nested traversal reject")
}
other := uuid.New()
if _, err := svc.ResolveUploadPath(base, cid, other.String()+"/x.csv"); err == nil {
t.Fatal("expected company mismatch reject")
}
}
+414
View File
@@ -0,0 +1,414 @@
package catalog
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestNormalizeListFilterDefaults(t *testing.T) {
f := NormalizeListFilter(ListFilter{})
if f.Limit != 50 {
t.Fatalf("default limit: got %d", f.Limit)
}
if f.Offset != 0 {
t.Fatalf("default offset: got %d", f.Offset)
}
if f.SortBy != "updatedAt" {
t.Fatalf("default sortBy: got %q", f.SortBy)
}
if f.SortOrder != "desc" {
t.Fatalf("default sortOrder: got %q", f.SortOrder)
}
}
func TestNormalizeListFilterCaps(t *testing.T) {
f := NormalizeListFilter(ListFilter{Limit: 9000, Offset: -3, Query: " abc ", SortBy: "bogus", SortOrder: "ASC"})
if f.Limit != 2000 {
t.Fatalf("cap limit: got %d", f.Limit)
}
if f.Offset != 0 {
t.Fatalf("offset floor: got %d", f.Offset)
}
if f.Query != "abc" {
t.Fatalf("query trim: got %q", f.Query)
}
if f.SortBy != "updatedAt" {
t.Fatalf("invalid sortBy fallback: got %q", f.SortBy)
}
if f.SortOrder != "asc" {
t.Fatalf("sortOrder normalize: got %q", f.SortOrder)
}
}
func TestNormalizeProductListFilterKeysetEnforcement(t *testing.T) {
ok, err := normalizeProductListFilter(ListFilter{Limit: 9000, Offset: MaxOffsetWithoutCursor})
if err != nil {
t.Fatal(err)
}
if ok.Limit != MaxProductPageLimit {
t.Fatalf("product limit cap: got %d", ok.Limit)
}
if ok.Offset != MaxOffsetWithoutCursor {
t.Fatalf("offset at cap should pass: got %d", ok.Offset)
}
_, err = normalizeProductListFilter(ListFilter{Offset: MaxOffsetWithoutCursor + 1})
if err == nil {
t.Fatal("expected deep offset rejected")
}
if msg, ok := ClientError(err); !ok || !strings.Contains(msg, "cursor") {
t.Fatalf("client msg: %v", err)
}
cur, err := normalizeProductListFilter(ListFilter{
Offset: MaxOffsetWithoutCursor + 50,
AfterID: "00000000-0000-4000-8000-000000000001",
})
if err != nil {
t.Fatal(err)
}
if cur.Offset != 0 {
t.Fatalf("cursor should clear offset: %d", cur.Offset)
}
}
func TestAppendRawProductFiltersSearchShape(t *testing.T) {
args := []any{"company"}
where := []string{"rp.company_id = $1"}
args, where = appendRawProductFilters(ListFilter{
Query: " widget ",
Status: "unprocessed",
FeedID: "00000000-0000-4000-8000-000000000001",
}, args, where)
if len(args) != 4 {
t.Fatalf("args len=%d want 4 (company, query, status, feed)", len(args))
}
if got, ok := args[1].(string); !ok || got != "% widget %" {
// Query is not trimmed here — callers NormalizeListFilter first.
t.Fatalf("query bind: %#v", args[1])
}
wSQL := strings.Join(where, " AND ")
for _, need := range []string{
"rp.gtin ILIKE",
"mapped_data->>'name'",
"mapped_data->>'title'",
"f.name",
"rp.processing_status = $3",
"rp.feed_id = $4",
} {
if !strings.Contains(wSQL, need) {
t.Fatalf("missing %q in %q", need, wSQL)
}
}
for _, banned := range []string{
"CAST(rp.mapped_data AS text)",
"CAST(rp.raw_data AS text)",
"attributes",
"@>",
} {
if strings.Contains(wSQL, banned) {
t.Fatalf("unexpected %q in %q", banned, wSQL)
}
}
}
func TestAppendProcessedProductFiltersSearchShape(t *testing.T) {
args := []any{"company"}
where := []string{"p.company_id = $1"}
args, where = appendProcessedProductFilters(ProductFilter{
Query: "sku-1",
Status: "published",
Category: "cat-a",
FeedID: "00000000-0000-4000-8000-000000000002",
}, args, where)
if len(args) != 5 {
t.Fatalf("args len=%d want 5", len(args))
}
if got, ok := args[1].(string); !ok || got != "%sku-1%" {
t.Fatalf("query bind: %#v", args[1])
}
wSQL := strings.Join(where, " AND ")
for _, need := range []string{
"p.name ILIKE",
"processed_name",
"p.product_id ILIKE",
"p.category ILIKE",
"r.gtin",
"p.status = $3",
"p.category = $4",
"EXISTS (",
"p.feed_id = $5",
} {
if !strings.Contains(wSQL, need) {
t.Fatalf("missing %q in %q", need, wSQL)
}
}
// Product JSON attributes are returned by detailed list APIs, not filtered in SQL.
for _, banned := range []string{
"p.attributes",
"processed_attributes",
"CAST(",
"@>",
} {
if strings.Contains(wSQL, banned) {
t.Fatalf("unexpected %q in %q", banned, wSQL)
}
}
}
func TestNormalizeCoverageFilter(t *testing.T) {
cases := map[string]string{
"": "",
"all": "",
"Complete": "complete",
"partial": "incomplete",
"missing-attributes": "missing_attributes",
"attrs": "missing_attributes",
"name": "missing_name",
"bogus": "",
}
for in, want := range cases {
if got := normalizeCoverageFilter(in); got != want {
t.Fatalf("normalizeCoverageFilter(%q)=%q want %q", in, got, want)
}
}
}
func TestNormalizeEprelFilter(t *testing.T) {
cases := map[string]string{
"": "",
"all": "",
"has_eprel": "has_eprel",
"with-eprel": "has_eprel",
"no_eprel": "no_eprel",
"missing_eprel": "no_eprel",
"bogus": "",
}
for in, want := range cases {
if got := normalizeEprelFilter(in); got != want {
t.Fatalf("normalizeEprelFilter(%q)=%q want %q", in, got, want)
}
}
}
func TestAppendProcessedEprelFilter(t *testing.T) {
where := appendProcessedEprelFilter("has_eprel", []string{"p.company_id = $1"})
wSQL := strings.Join(where, " AND ")
if !strings.Contains(wSQL, "eprel_id") {
t.Fatalf("expected eprel predicate in %q", wSQL)
}
if !processedListNeedsRawJoin(ProductFilter{Eprel: "has_eprel"}) {
t.Fatal("eprel filter must force raw join")
}
}
func TestAppendProcessedCoverageFilter(t *testing.T) {
args := []any{"company"}
where := []string{"p.company_id = $1"}
args, where = appendProcessedProductFilters(ProductFilter{
Coverage: "missing_attributes",
}, args, where)
if len(args) != 1 {
t.Fatalf("coverage should not bind args; got %d", len(args))
}
wSQL := strings.Join(where, " AND ")
if !strings.Contains(wSQL, "NOT") || !strings.Contains(wSQL, "processed_attributes") {
t.Fatalf("expected missing attributes predicate in %q", wSQL)
}
if !processedListNeedsRawJoin(ProductFilter{Coverage: "incomplete"}) {
t.Fatal("coverage filter must force raw join for count")
}
if processedListNeedsRawJoin(ProductFilter{}) {
t.Fatal("empty filter should not force raw join")
}
}
func TestAppendProcessedProductFiltersNeedsReviewAlias(t *testing.T) {
args := []any{"company"}
where := []string{"p.company_id = $1"}
args, where = appendProcessedProductFilters(ProductFilter{
Status: "needs_review",
}, args, where)
if len(args) != 1 {
t.Fatalf("needs_review should not bind status arg; args len=%d want 1", len(args))
}
wSQL := strings.Join(where, " AND ")
if !strings.Contains(wSQL, "p.status IN ('needs_review', 'processed')") {
t.Fatalf("expected legacy processed alias in %q", wSQL)
}
args2 := []any{"company"}
where2 := []string{"p.company_id = $1"}
args2, where2 = appendProcessedProductFilters(ProductFilter{
Status: "completed",
}, args2, where2)
if len(args2) != 2 {
t.Fatalf("completed should bind status; args len=%d want 2", len(args2))
}
w2 := strings.Join(where2, " AND ")
if !strings.Contains(w2, "p.status = $2") {
t.Fatalf("expected exact completed filter in %q", w2)
}
}
func TestRawAndProcessedCountFromSQL(t *testing.T) {
rawJoin := rawProductsCountFromSQL(true)
rawPlain := rawProductsCountFromSQL(false)
if !strings.Contains(rawJoin, "LEFT JOIN input_feeds") {
t.Fatalf("raw search count needs feed join: %q", rawJoin)
}
if strings.Contains(rawPlain, "LEFT JOIN") {
t.Fatalf("raw count without search should skip feed join: %q", rawPlain)
}
procJoin := processedProductsCountFromSQL(true)
procPlain := processedProductsCountFromSQL(false)
if !strings.Contains(procJoin, "LEFT JOIN raw_products") {
t.Fatalf("processed search count needs raw join: %q", procJoin)
}
if strings.Contains(procPlain, "LEFT JOIN") {
t.Fatalf("processed count without search should skip raw join: %q", procPlain)
}
}
func TestRawProductsOrderBy(t *testing.T) {
created := rawProductsOrderBy(ListFilter{SortBy: "createdAt", SortOrder: "desc"})
if !strings.Contains(created, "rp.created_at DESC") {
t.Fatalf("createdAt order: %q", created)
}
updated := rawProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "asc"})
if !strings.Contains(updated, "rp.updated_at ASC") {
t.Fatalf("updatedAt order: %q", updated)
}
}
func TestProcessedProductsOrderBy(t *testing.T) {
ascName := processedProductsOrderBy(ListFilter{SortBy: "name", SortOrder: "asc"})
if !strings.Contains(ascName, "processed_name") || !strings.Contains(ascName, "ASC") {
t.Fatalf("name asc order: %q", ascName)
}
if !strings.Contains(ascName, "THEN 1 ELSE 0") {
t.Fatalf("expected blank names last: %q", ascName)
}
descUpdated := processedProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "desc"})
if !strings.Contains(descUpdated, "p.updated_at DESC") {
t.Fatalf("updated desc order: %q", descUpdated)
}
}
func TestExactTotalFromPage(t *testing.T) {
cases := []struct {
name string
offset, limit int
pageLen int
wantTotal int64
wantOK bool
}{
{name: "empty first page", offset: 0, limit: 50, pageLen: 0, wantTotal: 0, wantOK: true},
{name: "short first page", offset: 0, limit: 50, pageLen: 12, wantTotal: 12, wantOK: true},
{name: "full first page", offset: 0, limit: 50, pageLen: 50, wantOK: false},
{name: "short later page", offset: 100, limit: 50, pageLen: 3, wantTotal: 103, wantOK: true},
{name: "empty later page", offset: 100, limit: 50, pageLen: 0, wantOK: false},
{name: "invalid limit", offset: 0, limit: 0, pageLen: 0, wantOK: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := exactTotalFromPage(c.offset, c.limit, c.pageLen)
if ok != c.wantOK {
t.Fatalf("ok=%v want %v", ok, c.wantOK)
}
if ok && got != c.wantTotal {
t.Fatalf("total=%d want %d", got, c.wantTotal)
}
})
}
}
func TestParallelCountAndList(t *testing.T) {
items, total, err := parallelCountAndList(context.Background(),
1, 0,
func(ctx context.Context) (int64, error) {
time.Sleep(20 * time.Millisecond)
return 42, nil
},
func(ctx context.Context) ([]map[string]any, error) {
time.Sleep(20 * time.Millisecond)
return []map[string]any{{"id": "a"}}, nil
},
)
if err != nil {
t.Fatal(err)
}
if total != 42 || len(items) != 1 {
t.Fatalf("total=%d items=%d", total, len(items))
}
}
func TestParallelCountAndListSkipsCountOnShortPage(t *testing.T) {
countCalls := 0
items, total, err := parallelCountAndList(context.Background(),
50, 0,
func(ctx context.Context) (int64, error) {
countCalls++
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-time.After(200 * time.Millisecond):
return 999, nil
}
},
func(ctx context.Context) ([]map[string]any, error) {
return []map[string]any{{"id": "a"}, {"id": "b"}}, nil
},
)
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("total=%d items=%d", total, len(items))
}
// Count may have started; short-page path must not wait on / require its success.
_ = countCalls
}
func TestParallelCountAndListPropagatesErrors(t *testing.T) {
_, _, err := parallelCountAndList(context.Background(),
1, 0,
func(ctx context.Context) (int64, error) {
return 0, errors.New("count failed")
},
func(ctx context.Context) ([]map[string]any, error) {
return []map[string]any{{"id": "a"}}, nil
},
)
if err == nil || !strings.Contains(err.Error(), "count failed") {
t.Fatalf("expected count error, got %v", err)
}
}
func TestHeaderIndex(t *testing.T) {
headers := []string{"Name", "unique_id", "GTIN"}
if headerIndex(headers, "unique_id", "id") != 1 {
t.Fatal("expected unique_id at 1")
}
if headerIndex(headers, "gtin", "ean") != 2 {
t.Fatal("expected gtin at 2")
}
if headerIndex(headers, "missing") != -1 {
t.Fatal("expected missing")
}
}
func TestSanitizeFileName(t *testing.T) {
if sanitizeFileName("../evil.csv") != "evil.csv" {
t.Fatalf("got %q", sanitizeFileName("../evil.csv"))
}
if sanitizeFileName("a b*.csv") != "a_b_.csv" {
t.Fatalf("got %q", sanitizeFileName("a b*.csv"))
}
}
+830
View File
@@ -0,0 +1,830 @@
package catalog
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/google/uuid"
)
const (
importCSVBatchSize = 500
importCSVMaxErrors = 50
)
type ImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
func (r *ImportResult) addError(msg string) {
if len(r.Errors) >= importCSVMaxErrors {
return
}
r.Errors = append(r.Errors, msg)
}
func headerIndex(headers []string, names ...string) int {
want := map[string]struct{}{}
for _, n := range names {
want[strings.ToLower(strings.TrimSpace(n))] = struct{}{}
}
for i, h := range headers {
if _, ok := want[strings.ToLower(strings.TrimSpace(h))]; ok {
return i
}
}
return -1
}
func cell(row []string, idx int) string {
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
type categoryCSVRow struct {
name string
uniqueID string
parent *string
desc *string
}
type categoryPathInfo struct {
id uuid.UUID
path string
level int
}
func (s *Service) ImportCategoriesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
res := ImportResult{}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iName := headerIndex(headers, "name")
iUID := headerIndex(headers, "unique_id", "id", "category_id")
iParent := headerIndex(headers, "parent_unique_id", "parent_id", "parent")
iDesc := headerIndex(headers, "description")
if iName < 0 || iUID < 0 {
return res, ClientMsg("CSV must include name and unique_id columns")
}
known := map[string]categoryPathInfo{}
batch := make([]categoryCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
name := cell(row, iName)
uid := cell(row, iUID)
if name == "" || uid == "" {
res.Skipped++
continue
}
var parent *string
if p := cell(row, iParent); p != "" {
parent = &p
}
var desc *string
if d := cell(row, iDesc); d != "" {
desc = &d
}
batch = append(batch, categoryCSVRow{name: name, uniqueID: uid, parent: parent, desc: desc})
if len(batch) >= importCSVBatchSize {
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushCategoryBatch(ctx context.Context, companyID uuid.UUID, batch []categoryCSVRow, known map[string]categoryPathInfo, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
byUID := make(map[string]categoryCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byUID[row.uniqueID]; !ok {
order = append(order, row.uniqueID)
}
byUID[row.uniqueID] = row
}
lookup := make([]string, 0, len(byUID)*2)
seenLookup := map[string]struct{}{}
addLookup := func(uid string) {
if uid == "" {
return
}
if _, ok := known[uid]; ok {
return
}
if _, ok := seenLookup[uid]; ok {
return
}
seenLookup[uid] = struct{}{}
lookup = append(lookup, uid)
}
for _, row := range byUID {
addLookup(row.uniqueID)
if row.parent != nil {
addLookup(*row.parent)
}
}
if err := s.loadCategoryPaths(ctx, companyID, lookup, known); err != nil {
return err
}
updIDs := make([]uuid.UUID, 0, len(order))
updNames := make([]string, 0, len(order))
updDescs := make([]string, 0, len(order))
pendingInserts := make([]categoryCSVRow, 0, len(order))
for _, uid := range order {
row := byUID[uid]
if info, ok := known[uid]; ok {
updIDs = append(updIDs, info.id)
updNames = append(updNames, row.name)
updDescs = append(updDescs, deref(row.desc))
continue
}
pendingInserts = append(pendingInserts, row)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if len(updIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE categories AS c SET
name = v.name,
description = CASE WHEN v.description <> '' THEN v.description ELSE c.description END,
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, description)
WHERE c.id = v.id AND c.company_id = $1`,
companyID, updIDs, updNames, updDescs)
if err != nil {
return err
}
res.Updated += len(updIDs)
}
for len(pendingInserts) > 0 {
insNames := make([]string, 0, len(pendingInserts))
insUIDs := make([]string, 0, len(pendingInserts))
insParents := make([]string, 0, len(pendingInserts))
insDescs := make([]string, 0, len(pendingInserts))
insPaths := make([]string, 0, len(pendingInserts))
insLevels := make([]int32, 0, len(pendingInserts))
next := make([]categoryCSVRow, 0, len(pendingInserts))
for _, row := range pendingInserts {
path, level, parentVal, err := resolveCategoryPath(row.uniqueID, row.parent, known)
if err != nil {
next = append(next, row)
continue
}
insNames = append(insNames, row.name)
insUIDs = append(insUIDs, row.uniqueID)
insParents = append(insParents, parentVal)
insDescs = append(insDescs, deref(row.desc))
insPaths = append(insPaths, path)
insLevels = append(insLevels, int32(level))
}
if len(insUIDs) == 0 {
for _, row := range next {
res.Skipped++
res.addError(fmt.Sprintf("%s: parent category not found", row.uniqueID))
}
break
}
rows, err := tx.Query(ctx, `
INSERT INTO categories (company_id, name, unique_id, parent_unique_id, description, path, level)
SELECT $1, v.name, v.unique_id, NULLIF(v.parent_unique_id, ''), NULLIF(v.description, ''), v.path, v.level
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::int[])
AS v(name, unique_id, parent_unique_id, description, path, level)
RETURNING id, unique_id, COALESCE(path, ''), level`,
companyID, insNames, insUIDs, insParents, insDescs, insPaths, insLevels)
if err != nil {
return err
}
for rows.Next() {
var id uuid.UUID
var uid, path string
var level int
if err := rows.Scan(&id, &uid, &path, &level); err != nil {
rows.Close()
return err
}
known[uid] = categoryPathInfo{id: id, path: path, level: level}
res.Created++
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
pendingInserts = next
}
return tx.Commit(ctx)
}
func (s *Service) loadCategoryPaths(ctx context.Context, companyID uuid.UUID, uids []string, known map[string]categoryPathInfo) error {
if len(uids) == 0 {
return nil
}
rows, err := s.Pool.Query(ctx, `
SELECT id, unique_id, COALESCE(path, ''), level
FROM categories
WHERE company_id = $1 AND unique_id = ANY($2)`, companyID, uids)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var info categoryPathInfo
var uid string
if err := rows.Scan(&info.id, &uid, &info.path, &info.level); err != nil {
return err
}
known[uid] = info
}
return rows.Err()
}
func resolveCategoryPath(uniqueID string, parent *string, known map[string]categoryPathInfo) (path string, level int, parentVal string, err error) {
path = uniqueID
level = 0
if parent == nil || strings.TrimSpace(*parent) == "" {
return path, level, "", nil
}
p := strings.TrimSpace(*parent)
pinfo, ok := known[p]
if !ok {
return "", 0, "", errors.New("parent category not found")
}
if pinfo.path != "" {
path = pinfo.path + "/" + uniqueID
} else {
path = p + "/" + uniqueID
}
return path, pinfo.level + 1, p, nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
type attributeCSVRow struct {
key, name, valueType string
unit, example, parent *string
}
func (s *Service) ImportAttributesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
res := ImportResult{}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iKey := headerIndex(headers, "attribute_key", "key")
iName := headerIndex(headers, "name")
iType := headerIndex(headers, "value_type", "type")
iUnit := headerIndex(headers, "unit")
iExample := headerIndex(headers, "example")
iParent := headerIndex(headers, "parent_key", "parent")
if iKey < 0 || iName < 0 {
return res, ClientMsg("CSV must include attribute_key and name columns")
}
batch := make([]attributeCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
key := cell(row, iKey)
name := cell(row, iName)
if key == "" || name == "" {
res.Skipped++
continue
}
valueType := cell(row, iType)
if valueType == "" {
valueType = "string"
}
var unit, example, parent *string
if u := cell(row, iUnit); u != "" {
unit = &u
}
if e := cell(row, iExample); e != "" {
example = &e
}
if p := cell(row, iParent); p != "" {
parent = &p
}
batch = append(batch, attributeCSVRow{
key: key, name: name, valueType: valueType,
unit: unit, example: example, parent: parent,
})
if len(batch) >= importCSVBatchSize {
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushAttributeBatch(ctx context.Context, companyID uuid.UUID, batch []attributeCSVRow, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
byKey := make(map[string]attributeCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byKey[row.key]; !ok {
order = append(order, row.key)
}
byKey[row.key] = row
}
keys := make([]string, 0, len(byKey))
keys = append(keys, order...)
existing := map[string]uuid.UUID{}
rows, err := s.Pool.Query(ctx, `
SELECT id, attribute_key FROM attributes
WHERE company_id = $1 AND attribute_key = ANY($2)`, companyID, keys)
if err != nil {
return err
}
for rows.Next() {
var id uuid.UUID
var key string
if err := rows.Scan(&id, &key); err != nil {
rows.Close()
return err
}
existing[key] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
updIDs := make([]uuid.UUID, 0, len(order))
updNames := make([]string, 0, len(order))
updTypes := make([]string, 0, len(order))
insKeys := make([]string, 0, len(order))
insNames := make([]string, 0, len(order))
insTypes := make([]string, 0, len(order))
insUnits := make([]string, 0, len(order))
insExamples := make([]string, 0, len(order))
insParents := make([]string, 0, len(order))
for _, key := range order {
row := byKey[key]
if id, ok := existing[key]; ok {
updIDs = append(updIDs, id)
updNames = append(updNames, row.name)
updTypes = append(updTypes, row.valueType)
continue
}
insKeys = append(insKeys, key)
insNames = append(insNames, row.name)
insTypes = append(insTypes, row.valueType)
insUnits = append(insUnits, deref(row.unit))
insExamples = append(insExamples, deref(row.example))
insParents = append(insParents, deref(row.parent))
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if len(updIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE attributes AS a SET
name = CASE WHEN v.name <> '' THEN v.name ELSE a.name END,
value_type = CASE WHEN v.value_type <> '' THEN v.value_type ELSE a.value_type END,
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, value_type)
WHERE a.id = v.id AND a.company_id = $1`,
companyID, updIDs, updNames, updTypes)
if err != nil {
return err
}
res.Updated += len(updIDs)
}
if len(insKeys) > 0 {
ct, err := tx.Exec(ctx, `
INSERT INTO attributes (company_id, attribute_key, name, value_type, unit, example, parent_key)
SELECT $1, v.attribute_key, v.name, v.value_type,
NULLIF(v.unit, ''), NULLIF(v.example, ''), NULLIF(v.parent_key, '')
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(attribute_key, name, value_type, unit, example, parent_key)`,
companyID, insKeys, insNames, insTypes, insUnits, insExamples, insParents)
if err != nil {
return err
}
res.Created += int(ct.RowsAffected())
}
return tx.Commit(ctx)
}
func (s *Service) MergeProductsByGTIN(ctx context.Context, companyID uuid.UUID) (bool, error) {
var merge bool
err := s.Pool.QueryRow(ctx, `SELECT merge_products_by_gtin FROM companies WHERE id = $1`, companyID).Scan(&merge)
return merge, err
}
type productCSVRow struct {
gtin, productID, name, category, desc, status string
rawJSON string
mergeable bool
}
func (s *Service) ImportProductsCSV(ctx context.Context, companyID uuid.UUID, r io.Reader, fileID *uuid.UUID) (ImportResult, error) {
res := ImportResult{}
merge, err := s.MergeProductsByGTIN(ctx, companyID)
if err != nil {
return res, err
}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iGTIN := headerIndex(headers, "gtin", "ean", "barcode")
iPID := headerIndex(headers, "product_id", "sku", "id")
iName := headerIndex(headers, "name", "title")
iCat := headerIndex(headers, "category")
iDesc := headerIndex(headers, "description")
iStatus := headerIndex(headers, "status")
if iName < 0 && iGTIN < 0 && iPID < 0 {
return res, ClientMsg("CSV must include name, gtin, or product_id")
}
batch := make([]productCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
gtin := cell(row, iGTIN)
productID := cell(row, iPID)
name := cell(row, iName)
category := cell(row, iCat)
desc := cell(row, iDesc)
status := cell(row, iStatus)
if status == "" {
status = "draft"
}
if gtin == "" && productID == "" && name == "" {
res.Skipped++
continue
}
if gtin == "" {
gtin = "nogtin-" + uuid.NewString()
}
rawData, _ := json.Marshal(map[string]any{
"product_id": productID,
"name": name,
"category": category,
"description": desc,
"status": status,
"gtin": gtin,
})
batch = append(batch, productCSVRow{
gtin: gtin, productID: productID, name: name, category: category,
desc: desc, status: status, rawJSON: string(rawData),
mergeable: merge && !strings.HasPrefix(gtin, "nogtin-"),
})
if len(batch) >= importCSVBatchSize {
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushProductBatch(ctx context.Context, companyID uuid.UUID, fileID *uuid.UUID, batch []productCSVRow, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
// Last row wins per GTIN so a single INSERT cannot hit the same unique key twice.
byGTIN := make(map[string]productCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byGTIN[row.gtin]; !ok {
order = append(order, row.gtin)
}
byGTIN[row.gtin] = row
}
deduped := make([]productCSVRow, 0, len(order))
for _, gtin := range order {
deduped = append(deduped, byGTIN[gtin])
}
batch = deduped
mergeGTINs := make([]string, 0, len(batch))
for _, row := range batch {
if row.mergeable {
mergeGTINs = append(mergeGTINs, row.gtin)
}
}
existingRaw := map[string]uuid.UUID{}
if len(mergeGTINs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT DISTINCT ON (gtin) id, gtin
FROM raw_products
WHERE company_id = $1 AND gtin = ANY($2)
ORDER BY gtin, updated_at DESC`, companyID, mergeGTINs)
if err != nil {
return err
}
for rows.Next() {
var id uuid.UUID
var gtin string
if err := rows.Scan(&id, &gtin); err != nil {
rows.Close()
return err
}
existingRaw[gtin] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
}
type pendingProcessed struct {
rawID uuid.UUID
productID, name, category, desc, status string
rawWasUpdate bool
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
updRawIDs := make([]uuid.UUID, 0, len(batch))
updRawJSON := make([]string, 0, len(batch))
updRawMeta := make([]pendingProcessed, 0, len(batch))
insGTINs := make([]string, 0, len(batch))
insJSON := make([]string, 0, len(batch))
insMeta := make([]pendingProcessed, 0, len(batch))
for _, row := range batch {
meta := pendingProcessed{
productID: row.productID, name: row.name, category: row.category,
desc: row.desc, status: row.status,
}
if row.mergeable {
if id, ok := existingRaw[row.gtin]; ok {
updRawIDs = append(updRawIDs, id)
updRawJSON = append(updRawJSON, row.rawJSON)
meta.rawID = id
meta.rawWasUpdate = true
updRawMeta = append(updRawMeta, meta)
continue
}
}
insGTINs = append(insGTINs, row.gtin)
insJSON = append(insJSON, row.rawJSON)
insMeta = append(insMeta, meta)
}
if len(updRawIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE raw_products AS r SET
raw_data = v.raw_data::jsonb,
mapped_data = v.raw_data::jsonb,
file_id = COALESCE($3, r.file_id),
updated_at = now()
FROM unnest($2::uuid[], $4::text[]) AS v(id, raw_data)
WHERE r.id = v.id AND r.company_id = $1`,
companyID, updRawIDs, fileID, updRawJSON)
if err != nil {
return err
}
}
pending := make([]pendingProcessed, 0, len(batch))
pending = append(pending, updRawMeta...)
if len(insGTINs) > 0 {
rows, err := tx.Query(ctx, `
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, file_id)
SELECT $1, v.gtin, v.raw_data::jsonb, v.raw_data::jsonb, 'unprocessed', $2
FROM unnest($3::text[], $4::text[]) AS v(gtin, raw_data)
ON CONFLICT (company_id, gtin) DO NOTHING
RETURNING id, gtin`,
companyID, fileID, insGTINs, insJSON)
if err != nil {
return err
}
insertedByGTIN := map[string]uuid.UUID{}
for rows.Next() {
var id uuid.UUID
var gtin string
if err := rows.Scan(&id, &gtin); err != nil {
rows.Close()
return err
}
insertedByGTIN[gtin] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
// Match inserts back to input order; conflicts (DO NOTHING) are skipped.
for i, gtin := range insGTINs {
id, ok := insertedByGTIN[gtin]
if !ok {
res.Skipped++
res.addError(fmt.Sprintf("%s: duplicate gtin", gtin))
continue
}
meta := insMeta[i]
meta.rawID = id
pending = append(pending, meta)
// Same gtin inserted twice in one batch: second RETURNING miss.
delete(insertedByGTIN, gtin)
}
}
if len(pending) == 0 {
return tx.Commit(ctx)
}
rawIDs := make([]uuid.UUID, len(pending))
for i, p := range pending {
rawIDs[i] = p.rawID
}
existingProcessed := map[uuid.UUID]uuid.UUID{}
prows, err := tx.Query(ctx, `
SELECT DISTINCT ON (raw_product_id) id, raw_product_id
FROM processed_products
WHERE company_id = $1 AND raw_product_id = ANY($2)
ORDER BY raw_product_id, updated_at DESC`, companyID, rawIDs)
if err != nil {
return err
}
for prows.Next() {
var id, rawID uuid.UUID
if err := prows.Scan(&id, &rawID); err != nil {
prows.Close()
return err
}
existingProcessed[rawID] = id
}
err = prows.Err()
prows.Close()
if err != nil {
return err
}
updProcIDs := make([]uuid.UUID, 0, len(pending))
updPIDs := make([]string, 0, len(pending))
updNames := make([]string, 0, len(pending))
updCats := make([]string, 0, len(pending))
updDescs := make([]string, 0, len(pending))
updStatuses := make([]string, 0, len(pending))
insRawIDs := make([]uuid.UUID, 0, len(pending))
insPIDs := make([]string, 0, len(pending))
insNames := make([]string, 0, len(pending))
insCats := make([]string, 0, len(pending))
insDescs := make([]string, 0, len(pending))
insStatuses := make([]string, 0, len(pending))
insWasUpdate := make([]bool, 0, len(pending))
for _, p := range pending {
if pid, ok := existingProcessed[p.rawID]; ok {
updProcIDs = append(updProcIDs, pid)
updPIDs = append(updPIDs, p.productID)
updNames = append(updNames, p.name)
updCats = append(updCats, p.category)
updDescs = append(updDescs, p.desc)
updStatuses = append(updStatuses, p.status)
continue
}
insRawIDs = append(insRawIDs, p.rawID)
insPIDs = append(insPIDs, p.productID)
insNames = append(insNames, p.name)
insCats = append(insCats, p.category)
insDescs = append(insDescs, p.desc)
insStatuses = append(insStatuses, p.status)
insWasUpdate = append(insWasUpdate, p.rawWasUpdate)
}
if len(updProcIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE processed_products AS p SET
product_id = COALESCE(NULLIF(v.product_id, ''), p.product_id),
name = COALESCE(NULLIF(v.name, ''), p.name),
category = COALESCE(NULLIF(v.category, ''), p.category),
description = COALESCE(NULLIF(v.description, ''), p.description),
status = COALESCE(NULLIF(v.status, ''), p.status),
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(id, product_id, name, category, description, status)
WHERE p.id = v.id AND p.company_id = $1`,
companyID, updProcIDs, updPIDs, updNames, updCats, updDescs, updStatuses)
if err != nil {
return err
}
res.Updated += len(updProcIDs)
}
if len(insRawIDs) > 0 {
_, err = tx.Exec(ctx, `
INSERT INTO processed_products (company_id, product_id, name, category, description, status, raw_product_id)
SELECT $1,
NULLIF(v.product_id, ''), NULLIF(v.name, ''), NULLIF(v.category, ''),
NULLIF(v.description, ''), v.status, v.raw_product_id
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(raw_product_id, product_id, name, category, description, status)`,
companyID, insRawIDs, insPIDs, insNames, insCats, insDescs, insStatuses)
if err != nil {
return err
}
for _, wasUpdate := range insWasUpdate {
if wasUpdate {
res.Updated++
} else {
res.Created++
}
}
}
return tx.Commit(ctx)
}
@@ -0,0 +1,243 @@
package catalog
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestHeaderIndexAndCell(t *testing.T) {
headers := []string{" Name ", "GTIN", "sku"}
if got := headerIndex(headers, "name"); got != 0 {
t.Fatalf("headerIndex name: got %d want 0", got)
}
if got := headerIndex(headers, "ean", "gtin"); got != 1 {
t.Fatalf("headerIndex gtin: got %d want 1", got)
}
if got := headerIndex(headers, "missing"); got != -1 {
t.Fatalf("headerIndex missing: got %d want -1", got)
}
row := []string{" a ", "b"}
if got := cell(row, 0); got != "a" {
t.Fatalf("cell: got %q want a", got)
}
if got := cell(row, 5); got != "" {
t.Fatalf("cell OOB: got %q", got)
}
}
func TestImportResultAddErrorCaps(t *testing.T) {
res := ImportResult{}
for i := 0; i < importCSVMaxErrors+20; i++ {
res.addError("err")
}
if len(res.Errors) != importCSVMaxErrors {
t.Fatalf("errors capped: got %d want %d", len(res.Errors), importCSVMaxErrors)
}
}
func TestResolveCategoryPath(t *testing.T) {
parentID := uuid.New()
known := map[string]categoryPathInfo{
"parent": {id: parentID, path: "root/parent", level: 1},
}
path, level, parentVal, err := resolveCategoryPath("child", strPtr("parent"), known)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if path != "root/parent/child" || level != 2 || parentVal != "parent" {
t.Fatalf("got path=%q level=%d parent=%q", path, level, parentVal)
}
_, _, _, err = resolveCategoryPath("child", strPtr("missing"), known)
if err == nil || err.Error() != "parent category not found" {
t.Fatalf("got %v, want parent category not found", err)
}
path, level, parentVal, err = resolveCategoryPath("root", nil, known)
if err != nil || path != "root" || level != 0 || parentVal != "" {
t.Fatalf("root: path=%q level=%d parent=%q err=%v", path, level, parentVal, err)
}
}
func TestImportCategoriesAndAttributesCSVBatch(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
companyID, "csv-import-"+companyID.String()[:8])
if err != nil {
t.Fatalf("insert company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
})
svc := &Service{Pool: pg}
prefix := companyID.String()[:8]
catCSV := strings.NewReader("name,unique_id,parent_unique_id,description\n" +
"Root,root-" + prefix + ",,root desc\n" +
"Child,child-" + prefix + ",root-" + prefix + ",child desc\n")
catRes, err := svc.ImportCategoriesCSV(ctx, companyID, catCSV)
if err != nil {
t.Fatalf("ImportCategoriesCSV: %v", err)
}
if catRes.Created != 2 || catRes.Updated != 0 {
t.Fatalf("categories create: %+v want created=2 updated=0", catRes)
}
catUpdate := strings.NewReader("name,unique_id,description\n" +
"Root,root-" + prefix + ",root updated\n")
catRes2, err := svc.ImportCategoriesCSV(ctx, companyID, catUpdate)
if err != nil {
t.Fatalf("ImportCategoriesCSV update: %v", err)
}
if catRes2.Created != 0 || catRes2.Updated != 1 {
t.Fatalf("categories update: %+v want created=0 updated=1", catRes2)
}
var rootName, rootDesc, childPath string
var childLevel int
err = pg.QueryRow(ctx, `
SELECT name, COALESCE(description, '') FROM categories
WHERE company_id = $1 AND unique_id = $2`, companyID, "root-"+prefix).
Scan(&rootName, &rootDesc)
if err != nil {
t.Fatalf("select root: %v", err)
}
if rootName != "Root" || rootDesc != "root updated" {
t.Fatalf("root fields: name=%q desc=%q", rootName, rootDesc)
}
err = pg.QueryRow(ctx, `
SELECT COALESCE(path, ''), level FROM categories
WHERE company_id = $1 AND unique_id = $2`, companyID, "child-"+prefix).
Scan(&childPath, &childLevel)
if err != nil {
t.Fatalf("select child: %v", err)
}
wantPath := "root-" + prefix + "/child-" + prefix
if childPath != wantPath || childLevel != 1 {
t.Fatalf("child path=%q level=%d want %q / 1", childPath, childLevel, wantPath)
}
attrCSV := strings.NewReader("attribute_key,name,value_type,unit\n" +
"color,Color,string,\n" +
"size,Size,string,cm\n")
attrRes, err := svc.ImportAttributesCSV(ctx, companyID, attrCSV)
if err != nil {
t.Fatalf("ImportAttributesCSV: %v", err)
}
if attrRes.Created != 2 || attrRes.Updated != 0 {
t.Fatalf("attributes create: %+v want created=2 updated=0", attrRes)
}
attrUpdate := strings.NewReader("attribute_key,name,value_type\n" +
"color,Colour,string\n")
attrRes2, err := svc.ImportAttributesCSV(ctx, companyID, attrUpdate)
if err != nil {
t.Fatalf("ImportAttributesCSV update: %v", err)
}
if attrRes2.Created != 0 || attrRes2.Updated != 1 {
t.Fatalf("attributes update: %+v want created=0 updated=1", attrRes2)
}
var colorName string
err = pg.QueryRow(ctx, `
SELECT name FROM attributes WHERE company_id = $1 AND attribute_key = 'color'`, companyID).
Scan(&colorName)
if err != nil {
t.Fatalf("select color: %v", err)
}
if colorName != "Colour" {
t.Fatalf("color name=%q want Colour", colorName)
}
}
func TestImportProductsCSVBatchMerge(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `
INSERT INTO companies (id, name, merge_products_by_gtin) VALUES ($1, $2, true)`,
companyID, "csv-products-"+companyID.String()[:8])
if err != nil {
t.Fatalf("insert company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
})
svc := &Service{Pool: pg}
gtin := "590123412345" + companyID.String()[:3]
csv1 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",First,SKU-1,draft\n")
res1, err := svc.ImportProductsCSV(ctx, companyID, csv1, nil)
if err != nil {
t.Fatalf("ImportProductsCSV create: %v", err)
}
if res1.Created != 1 || res1.Updated != 0 {
t.Fatalf("create result: %+v", res1)
}
csv2 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",Second,SKU-2,published\n")
res2, err := svc.ImportProductsCSV(ctx, companyID, csv2, nil)
if err != nil {
t.Fatalf("ImportProductsCSV update: %v", err)
}
if res2.Updated != 1 {
t.Fatalf("update result: %+v want updated=1", res2)
}
var rawCount int
var name string
err = pg.QueryRow(ctx, `
SELECT count(*) FROM raw_products WHERE company_id = $1 AND gtin = $2`, companyID, gtin).
Scan(&rawCount)
if err != nil {
t.Fatalf("count raw: %v", err)
}
if rawCount != 1 {
t.Fatalf("raw_products count=%d want 1", rawCount)
}
err = pg.QueryRow(ctx, `
SELECT COALESCE(p.name, '') FROM processed_products p
JOIN raw_products r ON r.id = p.raw_product_id
WHERE p.company_id = $1 AND r.gtin = $2`, companyID, gtin).Scan(&name)
if err != nil {
t.Fatalf("select processed: %v", err)
}
if name != "Second" {
t.Fatalf("processed name=%q want Second", name)
}
}
func TestImportCategoriesCSVRejectsMissingColumns(t *testing.T) {
svc := &Service{}
_, err := svc.ImportCategoriesCSV(context.Background(), uuid.New(), strings.NewReader("foo,bar\n1,2\n"))
if err == nil || err.Error() != "CSV must include name and unique_id columns" {
t.Fatalf("got %v", err)
}
}
func strPtr(s string) *string { return &s }
@@ -0,0 +1,159 @@
package catalog
import (
"fmt"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
)
// linkFeedSpecificationsIntoProduct expands mapped_data.specifications HTML/flat
// blobs into attribute_key→value maps and merges them into attributes when empty.
// Mutates item in place for GET product responses (does not persist).
func linkFeedSpecificationsIntoProduct(item map[string]any) {
if item == nil {
return
}
mapped, _ := item["mapped_data"].(map[string]any)
if mapped == nil {
return
}
linked := extractLinkedSpecs(mapped)
if len(linked) == 0 {
return
}
// Prefer structured object in response mapped_data for the Attributes/Feed UI.
if specs := mapped["specifications"]; isLooseSpecBlob(specs) {
mapped["specifications"] = linked
item["mapped_data"] = mapped
} else if specs := mapped["specs"]; isLooseSpecBlob(specs) {
mapped["specs"] = linked
item["mapped_data"] = mapped
}
attrs := asStringAnyMap(item["attributes"])
if len(attrs) == 0 {
item["attributes"] = linked
item["has_attributes"] = true
return
}
merged := make(map[string]any, len(attrs)+len(linked))
for k, v := range attrs {
merged[k] = v
}
for k, v := range linked {
if existing, ok := merged[k]; ok && strings.TrimSpace(stringifyAny(existing)) != "" {
continue
}
merged[k] = v
}
item["attributes"] = merged
item["has_attributes"] = true
}
func extractLinkedSpecs(mapped map[string]any) map[string]any {
out := map[string]any{}
put := func(label, value string) {
k := feeds.CanonicalAttributeKey(label)
if k == "" || strings.TrimSpace(value) == "" {
return
}
if _, exists := out[k]; exists {
return
}
out[k] = value
}
for _, key := range []string{"specifications", "specs"} {
v, ok := mapped[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
for _, p := range feeds.ParseSpecifications(t) {
put(p.Label, p.Value)
}
case map[string]any:
for k, val := range t {
if strings.EqualFold(k, "_raw") {
if s, ok := val.(string); ok {
for _, p := range feeds.ParseSpecifications(s) {
put(p.Label, p.Value)
}
}
continue
}
put(k, stringifyAny(val))
}
case map[string]string:
for k, val := range t {
if strings.EqualFold(k, "_raw") {
for _, p := range feeds.ParseSpecifications(val) {
put(p.Label, p.Value)
}
continue
}
put(k, val)
}
}
}
// Scalar mapped fields → standard attribute keys (net_height, eprel_id, …).
for _, src := range []string{
"warranty", "eprel_id", "eprel",
"netwidth", "net_width", "netheight", "net_height",
"netdepth", "net_depth", "netmass", "net_mass",
"productmodel", "product_model",
"visina", "sirina", "globina", "teza",
} {
if s := stringifyAny(mapped[src]); s != "" {
put(src, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
func isLooseSpecBlob(v any) bool {
switch t := v.(type) {
case string:
return strings.TrimSpace(t) != ""
case map[string]any:
_, hasRaw := t["_raw"]
return hasRaw
case map[string]string:
_, hasRaw := t["_raw"]
return hasRaw
default:
return false
}
}
func asStringAnyMap(v any) map[string]any {
switch t := v.(type) {
case map[string]any:
return t
case map[string]string:
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = val
}
return out
default:
return nil
}
}
func stringifyAny(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64, float32, int, int64, bool:
return strings.TrimSpace(fmt.Sprint(t))
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
@@ -0,0 +1,31 @@
package catalog
import "testing"
func TestLinkFeedSpecificationsIntoProductFillsAttributes(t *testing.T) {
item := map[string]any{
"mapped_data": map[string]any{
"description": "Feed original description",
"category": "46",
"specifications": "Barva: črna; Garancija: 24",
"warranty": "24",
"net_width": "10",
},
"attributes": map[string]any{},
}
linkFeedSpecificationsIntoProduct(item)
attrs, _ := item["attributes"].(map[string]any)
if len(attrs) == 0 {
t.Fatal("expected attributes filled from mapped feed specs")
}
if item["has_attributes"] != true {
t.Fatalf("has_attributes=%v want true", item["has_attributes"])
}
mapped, _ := item["mapped_data"].(map[string]any)
if mapped["description"] != "Feed original description" {
t.Fatalf("description stripped from mapped_data")
}
if mapped["category"] != "46" {
t.Fatalf("category stripped from mapped_data")
}
}
+179
View File
@@ -0,0 +1,179 @@
package catalog
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Service) ListCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string) ([]map[string]any, error) {
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
if categoryUniqueID == "" {
return nil, ClientMsg("category_unique_id required")
}
rows, err := s.Pool.Query(ctx, `
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
a.attribute_key, a.name, a.value_type
FROM category_attributes ca
INNER JOIN attributes a ON a.id = ca.attribute_id AND a.company_id = ca.company_id
WHERE ca.company_id = $1 AND ca.category_unique_id = $2
ORDER BY a.name`, companyID, categoryUniqueID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMaps(rows, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
}
func (s *Service) LinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID, required bool) (map[string]any, error) {
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
if categoryUniqueID == "" {
return nil, ClientMsg("category_unique_id required")
}
var catExists bool
if err := s.Pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
companyID, categoryUniqueID).Scan(&catExists); err != nil {
return nil, err
}
if !catExists {
return nil, ClientMsg("category not found")
}
var attrCompany uuid.UUID
err := s.Pool.QueryRow(ctx, `SELECT company_id FROM attributes WHERE id = $1`, attributeID).Scan(&attrCompany)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ClientMsg("attribute not found")
}
return nil, err
}
if attrCompany != companyID {
return nil, ClientMsg("attribute not found")
}
var id uuid.UUID
err = s.Pool.QueryRow(ctx, `
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id, category_unique_id, attribute_id)
DO UPDATE SET required = EXCLUDED.required, updated_at = now()
RETURNING id`, companyID, categoryUniqueID, attributeID, required).Scan(&id)
if err != nil {
return nil, err
}
row := s.Pool.QueryRow(ctx, `
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
a.attribute_key, a.name, a.value_type
FROM category_attributes ca
INNER JOIN attributes a ON a.id = ca.attribute_id
WHERE ca.id = $1 AND ca.company_id = $2`, id, companyID)
return scanMap(row, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
}
func (s *Service) UnlinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID) error {
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
ct, err := s.Pool.Exec(ctx, `
DELETE FROM category_attributes
WHERE company_id = $1 AND category_unique_id = $2 AND attribute_id = $3`,
companyID, categoryUniqueID, attributeID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) ReplaceCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeIDs []uuid.UUID, required map[string]bool) error {
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
if categoryUniqueID == "" {
return ClientMsg("category_unique_id required")
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var catExists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
companyID, categoryUniqueID).Scan(&catExists); err != nil {
return err
}
if !catExists {
return ClientMsg("category not found")
}
if _, err := tx.Exec(ctx, `
DELETE FROM category_attributes WHERE company_id = $1 AND category_unique_id = $2`,
companyID, categoryUniqueID); err != nil {
return err
}
if len(attributeIDs) == 0 {
return tx.Commit(ctx)
}
if required == nil {
required = map[string]bool{}
}
ownedRows, err := tx.Query(ctx, `
SELECT id FROM attributes WHERE company_id = $1 AND id = ANY($2::uuid[])`,
companyID, attributeIDs)
if err != nil {
return err
}
owned := make([]uuid.UUID, 0, len(attributeIDs))
for ownedRows.Next() {
var id uuid.UUID
if err := ownedRows.Scan(&id); err != nil {
ownedRows.Close()
return err
}
owned = append(owned, id)
}
err = ownedRows.Err()
ownedRows.Close()
if err != nil {
return err
}
if err := validateAttributeIDsOwned(attributeIDs, owned); err != nil {
return err
}
reqs := make([]bool, len(attributeIDs))
for i, aid := range attributeIDs {
reqs[i] = required[aid.String()]
}
if _, err := tx.Exec(ctx, `
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
SELECT $1, $2, u.attribute_id, u.required
FROM unnest($3::uuid[], $4::boolean[]) AS u(attribute_id, required)`,
companyID, categoryUniqueID, attributeIDs, reqs); err != nil {
return err
}
return tx.Commit(ctx)
}
// validateAttributeIDsOwned ensures every requested attribute ID is present in the
// company-scoped ownership query result. Missing or cross-tenant IDs surface as
// "attribute not found" (same message as the former per-row SELECT path).
func validateAttributeIDsOwned(attributeIDs, owned []uuid.UUID) error {
if len(attributeIDs) == 0 {
return nil
}
set := make(map[uuid.UUID]struct{}, len(owned))
for _, id := range owned {
set[id] = struct{}{}
}
for _, aid := range attributeIDs {
if _, ok := set[aid]; !ok {
return ClientMsg("attribute not found")
}
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package catalog
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestValidateAttributeIDsOwned(t *testing.T) {
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
c := uuid.MustParse("33333333-3333-3333-3333-333333333333")
t.Run("empty request", func(t *testing.T) {
if err := validateAttributeIDsOwned(nil, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("all owned", func(t *testing.T) {
if err := validateAttributeIDsOwned([]uuid.UUID{a, b}, []uuid.UUID{b, a}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("duplicate request ids still ok when owned", func(t *testing.T) {
// Ownership query returns distinct rows; duplicates are allowed through to INSERT
// (unique constraint rejects them later — same as the old per-row path).
if err := validateAttributeIDsOwned([]uuid.UUID{a, a}, []uuid.UUID{a}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("missing id", func(t *testing.T) {
err := validateAttributeIDsOwned([]uuid.UUID{a, c}, []uuid.UUID{a})
if err == nil || err.Error() != "attribute not found" {
t.Fatalf("got %v, want attribute not found", err)
}
})
t.Run("cross-tenant treated as missing", func(t *testing.T) {
err := validateAttributeIDsOwned([]uuid.UUID{b}, nil)
if err == nil || err.Error() != "attribute not found" {
t.Fatalf("got %v, want attribute not found", err)
}
})
}
func asUUID(t *testing.T, v any) uuid.UUID {
t.Helper()
switch x := v.(type) {
case uuid.UUID:
return x
case string:
id, err := uuid.Parse(x)
if err != nil {
t.Fatalf("parse uuid %q: %v", x, err)
}
return id
case [16]byte:
return uuid.UUID(x)
default:
t.Fatalf("unexpected uuid type %T", v)
return uuid.Nil
}
}
func TestReplaceCategoryAttributesBatch(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
companyID, "links-test-"+companyID.String()[:8])
if err != nil {
t.Fatalf("insert company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
})
svc := &Service{Pool: pg}
catUnique := "links-cat-" + companyID.String()[:8]
if _, err := svc.CreateCategory(ctx, companyID, "Links Test Cat", catUnique, nil, nil); err != nil {
t.Fatalf("CreateCategory: %v", err)
}
attrA, err := svc.CreateAttribute(ctx, companyID, "color", "Color", "string", nil, nil, nil)
if err != nil {
t.Fatalf("CreateAttribute A: %v", err)
}
attrB, err := svc.CreateAttribute(ctx, companyID, "size", "Size", "string", nil, nil, nil)
if err != nil {
t.Fatalf("CreateAttribute B: %v", err)
}
idA := asUUID(t, attrA["id"])
idB := asUUID(t, attrB["id"])
otherCompany := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
otherCompany, "links-other-"+otherCompany.String()[:8])
if err != nil {
t.Fatalf("insert other company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, otherCompany)
})
foreign, err := svc.CreateAttribute(ctx, otherCompany, "foreign", "Foreign", "string", nil, nil, nil)
if err != nil {
t.Fatalf("CreateAttribute foreign: %v", err)
}
foreignID := asUUID(t, foreign["id"])
t.Run("batch replace with required flags", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idA, idB}, map[string]bool{
idA.String(): true,
})
if err != nil {
t.Fatalf("ReplaceCategoryAttributes: %v", err)
}
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
if err != nil {
t.Fatalf("ListCategoryAttributes: %v", err)
}
if len(items) != 2 {
t.Fatalf("want 2 links, got %d", len(items))
}
byAttr := map[uuid.UUID]bool{}
for _, item := range items {
aid := asUUID(t, item["attribute_id"])
req, _ := item["required"].(bool)
byAttr[aid] = req
}
if !byAttr[idA] {
t.Fatal("attribute A should be required")
}
if byAttr[idB] {
t.Fatal("attribute B should not be required")
}
})
t.Run("replace clears previous links", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idB}, nil)
if err != nil {
t.Fatalf("ReplaceCategoryAttributes: %v", err)
}
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
if err != nil {
t.Fatalf("ListCategoryAttributes: %v", err)
}
if len(items) != 1 {
t.Fatalf("want 1 link, got %d", len(items))
}
if asUUID(t, items[0]["attribute_id"]) != idB {
t.Fatalf("want attribute B, got %v", items[0]["attribute_id"])
}
})
t.Run("empty list clears all", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, nil, nil)
if err != nil {
t.Fatalf("ReplaceCategoryAttributes: %v", err)
}
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
if err != nil {
t.Fatalf("ListCategoryAttributes: %v", err)
}
if len(items) != 0 {
t.Fatalf("want 0 links, got %d", len(items))
}
})
t.Run("missing attribute", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{uuid.New()}, nil)
if err == nil || err.Error() != "attribute not found" {
t.Fatalf("got %v, want attribute not found", err)
}
})
t.Run("cross-tenant attribute", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{foreignID}, nil)
if err == nil || err.Error() != "attribute not found" {
t.Fatalf("got %v, want attribute not found", err)
}
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
if err != nil {
t.Fatalf("ListCategoryAttributes: %v", err)
}
if len(items) != 0 {
t.Fatalf("failed replace must leave links empty, got %d", len(items))
}
})
t.Run("category not found", func(t *testing.T) {
err := svc.ReplaceCategoryAttributes(ctx, companyID, "no-such-category", []uuid.UUID{idA}, nil)
if err == nil || err.Error() != "category not found" {
t.Fatalf("got %v, want category not found", err)
}
})
}

Some files were not shown because too many files have changed in this diff Show More