easyai-ai-gateway/apps/api/internal/store/pricing_rules.go

342 lines
11 KiB
Go

package store
import (
"context"
"encoding/json"
"strings"
"github.com/jackc/pgx/v5"
)
const pricingRuleSetColumns = `
id::text, rule_set_key, name, COALESCE(description, ''), category, currency,
status, metadata, created_at, updated_at`
const pricingRuleColumns = `
id::text, COALESCE(rule_set_id::text, ''), rule_key, display_name, scope_type,
COALESCE(scope_id::text, ''), resource_type, unit, base_price::float8, currency,
base_weight, dynamic_weight, calculator_type, dimension_schema, formula_config,
priority, status, metadata, created_at, updated_at`
type PricingRuleInput struct {
RuleKey string `json:"ruleKey"`
DisplayName string `json:"displayName"`
ResourceType string `json:"resourceType"`
Unit string `json:"unit"`
BasePrice float64 `json:"basePrice"`
Currency string `json:"currency"`
BaseWeight map[string]any `json:"baseWeight"`
DynamicWeight map[string]any `json:"dynamicWeight"`
CalculatorType string `json:"calculatorType"`
DimensionSchema map[string]any `json:"dimensionSchema"`
FormulaConfig map[string]any `json:"formulaConfig"`
Priority int `json:"priority"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
}
type PricingRuleSetInput struct {
RuleSetKey string `json:"ruleSetKey"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Currency string `json:"currency"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata"`
Rules []PricingRuleInput `json:"rules"`
}
type pricingScanner interface {
Scan(dest ...any) error
}
func (s *Store) ListPricingRuleSets(ctx context.Context) ([]PricingRuleSet, error) {
rows, err := s.pool.Query(ctx, `SELECT `+pricingRuleSetColumns+` FROM model_pricing_rule_sets ORDER BY category ASC, name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]PricingRuleSet, 0)
byID := map[string]int{}
for rows.Next() {
item, err := scanPricingRuleSet(rows)
if err != nil {
return nil, err
}
byID[item.ID] = len(items)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
ruleRows, err := s.pool.Query(ctx, `
SELECT `+pricingRuleColumns+`
FROM model_pricing_rules
WHERE rule_set_id IS NOT NULL
ORDER BY rule_set_id, priority ASC, resource_type ASC, rule_key ASC`)
if err != nil {
return nil, err
}
defer ruleRows.Close()
for ruleRows.Next() {
rule, err := scanPricingRule(ruleRows)
if err != nil {
return nil, err
}
if index, ok := byID[rule.RuleSetID]; ok {
items[index].Rules = append(items[index].Rules, rule)
}
}
return items, ruleRows.Err()
}
func (s *Store) CreatePricingRuleSet(ctx context.Context, input PricingRuleSetInput) (PricingRuleSet, error) {
input = normalizePricingRuleSet(input)
tx, err := s.pool.Begin(ctx)
if err != nil {
return PricingRuleSet{}, err
}
defer tx.Rollback(ctx)
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
item, err := scanPricingRuleSet(tx.QueryRow(ctx, `
INSERT INTO model_pricing_rule_sets (rule_set_key, name, description, category, currency, status, metadata)
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7)
RETURNING `+pricingRuleSetColumns,
input.RuleSetKey, input.Name, input.Description, input.Category, input.Currency, input.Status, metadata,
))
if err != nil {
return PricingRuleSet{}, err
}
if err := insertPricingRules(ctx, tx, item.ID, input.Currency, input.Rules); err != nil {
return PricingRuleSet{}, err
}
if err := tx.Commit(ctx); err != nil {
return PricingRuleSet{}, err
}
item.Rules = pricingInputsToRules(item.ID, input.Currency, input.Rules)
return item, nil
}
func (s *Store) UpdatePricingRuleSet(ctx context.Context, id string, input PricingRuleSetInput) (PricingRuleSet, error) {
input = normalizePricingRuleSet(input)
tx, err := s.pool.Begin(ctx)
if err != nil {
return PricingRuleSet{}, err
}
defer tx.Rollback(ctx)
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
item, err := scanPricingRuleSet(tx.QueryRow(ctx, `
UPDATE model_pricing_rule_sets
SET rule_set_key = $2,
name = $3,
description = NULLIF($4, ''),
category = $5,
currency = $6,
status = $7,
metadata = $8,
updated_at = now()
WHERE id = $1::uuid
RETURNING `+pricingRuleSetColumns,
id, input.RuleSetKey, input.Name, input.Description, input.Category, input.Currency, input.Status, metadata,
))
if err != nil {
return PricingRuleSet{}, err
}
if _, err := tx.Exec(ctx, `DELETE FROM model_pricing_rules WHERE rule_set_id = $1::uuid`, id); err != nil {
return PricingRuleSet{}, err
}
if err := insertPricingRules(ctx, tx, item.ID, input.Currency, input.Rules); err != nil {
return PricingRuleSet{}, err
}
if err := tx.Commit(ctx); err != nil {
return PricingRuleSet{}, err
}
item.Rules = pricingInputsToRules(item.ID, input.Currency, input.Rules)
return item, nil
}
func (s *Store) DeletePricingRuleSet(ctx context.Context, id string) error {
var ruleSetKey string
if err := s.pool.QueryRow(ctx, `SELECT rule_set_key FROM model_pricing_rule_sets WHERE id = $1::uuid`, id).Scan(&ruleSetKey); err != nil {
return err
}
if ruleSetKey == "default-multimodal-v1" {
return ErrProtectedDefault
}
result, err := s.pool.Exec(ctx, `DELETE FROM model_pricing_rule_sets WHERE id = $1::uuid`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func insertPricingRules(ctx context.Context, tx pgx.Tx, ruleSetID string, defaultCurrency string, rules []PricingRuleInput) error {
for index, rule := range rules {
rule = normalizePricingRule(rule, index, defaultCurrency)
baseWeight, _ := json.Marshal(emptyObjectIfNil(rule.BaseWeight))
dynamicWeight, _ := json.Marshal(emptyObjectIfNil(rule.DynamicWeight))
dimensionSchema, _ := json.Marshal(emptyObjectIfNil(rule.DimensionSchema))
formulaConfig, _ := json.Marshal(emptyObjectIfNil(rule.FormulaConfig))
metadata, _ := json.Marshal(emptyObjectIfNil(rule.Metadata))
if _, err := tx.Exec(ctx, `
INSERT INTO model_pricing_rules (
rule_set_id, rule_key, display_name, scope_type, scope_id, resource_type,
unit, base_price, currency, base_weight, dynamic_weight, calculator_type,
dimension_schema, formula_config, priority, status, metadata
)
VALUES ($1::uuid, $2, $3, 'rule_set', $1::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
ruleSetID, rule.RuleKey, rule.DisplayName, rule.ResourceType, rule.Unit,
rule.BasePrice, rule.Currency, baseWeight, dynamicWeight, rule.CalculatorType,
dimensionSchema, formulaConfig, rule.Priority, rule.Status, metadata,
); err != nil {
return err
}
}
return nil
}
func scanPricingRuleSet(scanner pricingScanner) (PricingRuleSet, error) {
var item PricingRuleSet
var metadata []byte
if err := scanner.Scan(
&item.ID,
&item.RuleSetKey,
&item.Name,
&item.Description,
&item.Category,
&item.Currency,
&item.Status,
&metadata,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return PricingRuleSet{}, err
}
item.Metadata = decodeObject(metadata)
item.Rules = []PricingRule{}
return item, nil
}
func scanPricingRule(scanner pricingScanner) (PricingRule, error) {
var item PricingRule
var baseWeight []byte
var dynamicWeight []byte
var dimensionSchema []byte
var formulaConfig []byte
var metadata []byte
if err := scanner.Scan(
&item.ID,
&item.RuleSetID,
&item.RuleKey,
&item.DisplayName,
&item.ScopeType,
&item.ScopeID,
&item.ResourceType,
&item.Unit,
&item.BasePrice,
&item.Currency,
&baseWeight,
&dynamicWeight,
&item.CalculatorType,
&dimensionSchema,
&formulaConfig,
&item.Priority,
&item.Status,
&metadata,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return PricingRule{}, err
}
item.BaseWeight = decodeObject(baseWeight)
item.DynamicWeight = decodeObject(dynamicWeight)
item.DimensionSchema = decodeObject(dimensionSchema)
item.FormulaConfig = decodeObject(formulaConfig)
item.Metadata = decodeObject(metadata)
return item, nil
}
func normalizePricingRuleSet(input PricingRuleSetInput) PricingRuleSetInput {
input.RuleSetKey = strings.TrimSpace(input.RuleSetKey)
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
input.Category = strings.TrimSpace(input.Category)
input.Currency = strings.TrimSpace(input.Currency)
input.Status = strings.TrimSpace(input.Status)
if input.Category == "" {
input.Category = "custom"
}
if input.Currency == "" {
input.Currency = "resource"
}
if input.Status == "" {
input.Status = "active"
}
return input
}
func normalizePricingRule(input PricingRuleInput, index int, defaultCurrency string) PricingRuleInput {
input.RuleKey = strings.TrimSpace(input.RuleKey)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.ResourceType = strings.TrimSpace(input.ResourceType)
input.Unit = strings.TrimSpace(input.Unit)
input.Currency = strings.TrimSpace(input.Currency)
input.CalculatorType = strings.TrimSpace(input.CalculatorType)
input.Status = strings.TrimSpace(input.Status)
if input.RuleKey == "" {
input.RuleKey = "rule_" + strings.ReplaceAll(input.ResourceType+"_"+input.Unit, " ", "_")
}
if input.DisplayName == "" {
input.DisplayName = input.ResourceType
}
if input.Unit == "" {
input.Unit = "item"
}
if input.Currency == "" {
input.Currency = defaultCurrency
}
if input.CalculatorType == "" {
input.CalculatorType = "unit_weight"
}
if input.Priority == 0 {
input.Priority = (index + 1) * 10
}
if input.Status == "" {
input.Status = "active"
}
return input
}
func pricingInputsToRules(ruleSetID string, defaultCurrency string, rules []PricingRuleInput) []PricingRule {
items := make([]PricingRule, 0, len(rules))
for index, input := range rules {
input = normalizePricingRule(input, index, defaultCurrency)
items = append(items, PricingRule{
RuleSetID: ruleSetID,
RuleKey: input.RuleKey,
DisplayName: input.DisplayName,
ScopeType: "rule_set",
ScopeID: ruleSetID,
ResourceType: input.ResourceType,
Unit: input.Unit,
BasePrice: input.BasePrice,
Currency: input.Currency,
BaseWeight: emptyObjectIfNil(input.BaseWeight),
DynamicWeight: emptyObjectIfNil(input.DynamicWeight),
CalculatorType: input.CalculatorType,
DimensionSchema: emptyObjectIfNil(input.DimensionSchema),
FormulaConfig: emptyObjectIfNil(input.FormulaConfig),
Priority: input.Priority,
Status: input.Status,
Metadata: emptyObjectIfNil(input.Metadata),
})
}
return items
}