File size: 6,468 Bytes
16cdcb7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | package taxcode
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/samber/lo"
"github.com/openmeterio/openmeter/openmeter/namespace"
"github.com/openmeterio/openmeter/pkg/framework/transaction"
"github.com/openmeterio/openmeter/pkg/models"
)
// SeedEntry defines a tax code that should be provisioned for every namespace.
type SeedEntry struct {
Key string
Name string
Description *string
AppMappings TaxCodeAppMappings
DefaultInvoicing bool
DefaultCreditGrant bool
}
// NamespaceHandlerConfig holds the dependencies for the taxcode namespace handler.
type NamespaceHandlerConfig struct {
Logger *slog.Logger
Service Service
Seeds []SeedEntry
TransactionManager transaction.Creator
}
func (c NamespaceHandlerConfig) validate() error {
var errs []error
if c.Logger == nil {
errs = append(errs, errors.New("logger is required"))
}
if c.Service == nil {
errs = append(errs, errors.New("service is required"))
}
if c.TransactionManager == nil {
errs = append(errs, errors.New("transaction manager is required"))
}
if len(c.Seeds) == 0 {
errs = append(errs, errors.New("at least one seed entry is required"))
} else {
invoicingCount := lo.CountBy(c.Seeds, func(s SeedEntry) bool { return s.DefaultInvoicing })
creditGrantCount := lo.CountBy(c.Seeds, func(s SeedEntry) bool { return s.DefaultCreditGrant })
if invoicingCount != 1 {
errs = append(errs, fmt.Errorf("exactly one seed must have DefaultInvoicing=true, got %d", invoicingCount))
}
if creditGrantCount != 1 {
errs = append(errs, fmt.Errorf("exactly one seed must have DefaultCreditGrant=true, got %d", creditGrantCount))
}
}
return errors.Join(errs...)
}
// NamespaceHandler implements namespace.Handler for the taxcode domain.
type NamespaceHandler struct {
logger *slog.Logger
service Service
seeds []SeedEntry
transactionManager transaction.Creator
}
var _ namespace.Handler = (*NamespaceHandler)(nil)
// NewNamespaceHandler creates a *NamespaceHandler that seeds tax codes and org
// defaults when a new namespace is provisioned.
func NewNamespaceHandler(cfg NamespaceHandlerConfig) (*NamespaceHandler, error) {
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid namespace handler config: %w", err)
}
return &NamespaceHandler{
logger: cfg.Logger,
service: cfg.Service,
seeds: cfg.Seeds,
transactionManager: cfg.TransactionManager,
}, nil
}
// CreateNamespace provisions the configured seed tax codes and sets the per-namespace
// OrganizationDefaultTaxCodes. The operation is idempotent: pre-existing tax codes are
// left unchanged and a pre-existing org-defaults row skips the org-defaults upsert (seed
// creation always runs regardless).
// All seed creates and the org-defaults upsert run inside a single transaction.
func (h *NamespaceHandler) CreateNamespace(ctx context.Context, ns string) error {
return transaction.RunWithNoValue(ctx, h.transactionManager, func(ctx context.Context) error {
// List existing tax codes once; ensureTaxCode does map lookups against this set
// and only re-lists on a concurrent-create conflict.
listed, err := h.service.ListTaxCodes(ctx, ListTaxCodesInput{Namespace: ns})
if err != nil {
return fmt.Errorf("list tax codes: %w", err)
}
existingByKey := lo.SliceToMap(listed.Items, func(tc TaxCode) (string, TaxCode) {
return tc.Key, tc
})
var invoicingID, creditGrantID string
for _, seed := range h.seeds {
id, err := h.ensureTaxCode(ctx, ns, seed, existingByKey)
if err != nil {
return fmt.Errorf("seed tax code %q: %w", seed.Key, err)
}
if seed.DefaultInvoicing {
invoicingID = id
}
if seed.DefaultCreditGrant {
creditGrantID = id
}
}
// Idempotency check: if org defaults already exist, skip upsert.
_, err = h.service.GetOrganizationDefaultTaxCodes(ctx, GetOrganizationDefaultTaxCodesInput{
Namespace: ns,
})
if err != nil && !IsOrganizationDefaultTaxCodesNotFoundError(err) {
return fmt.Errorf("get organization default tax codes: %w", err)
}
if err == nil {
// Already provisioned — nothing to do.
return nil
}
if _, err := h.service.UpsertOrganizationDefaultTaxCodes(ctx, UpsertOrganizationDefaultTaxCodesInput{
Namespace: ns,
InvoicingTaxCodeID: invoicingID,
CreditGrantTaxCodeID: creditGrantID,
}); err != nil {
return fmt.Errorf("upsert organization default tax codes: %w", err)
}
return nil
})
}
// DeleteNamespace is a no-op; tax codes belong to the namespace and are cleaned up
// by the database cascade or a dedicated purge job.
func (h *NamespaceHandler) DeleteNamespace(_ context.Context, _ string) error {
return nil
}
// ensureTaxCode returns the ID of the tax code identified by seed.Key in namespace ns.
// If it does not exist yet, it is created. Pre-existing codes are never mutated.
// existingByKey is the pre-fetched index built by CreateNamespace; the conflict path
// re-lists from the service to reconcile concurrent inserts that the index missed.
func (h *NamespaceHandler) ensureTaxCode(ctx context.Context, ns string, seed SeedEntry, existingByKey map[string]TaxCode) (string, error) {
if existing, found := existingByKey[seed.Key]; found {
return existing.ID, nil
}
// Not found — create it.
created, createErr := h.service.CreateTaxCode(ctx, CreateTaxCodeInput{
Namespace: ns,
Key: seed.Key,
Name: seed.Name,
Description: seed.Description,
AppMappings: seed.AppMappings,
Annotations: models.Annotations{
AnnotationKeyManagedBy: AnnotationValueManagedBySystem,
},
})
if createErr != nil {
// Another goroutine may have created it concurrently; re-fetch by listing.
if models.IsGenericConflictError(createErr) {
result2, listErr := h.service.ListTaxCodes(ctx, ListTaxCodesInput{
Namespace: ns,
})
if listErr != nil {
return "", fmt.Errorf("list tax codes after conflict: %w", listErr)
}
concurrent, ok := lo.Find(result2.Items, func(tc TaxCode) bool {
return tc.Key == seed.Key
})
if ok {
return concurrent.ID, nil
}
return "", fmt.Errorf("tax code with key %q: conflict reported by create but key not found after re-list: %w", seed.Key, createErr)
}
return "", fmt.Errorf("create tax code: %w", createErr)
}
return created.ID, nil
}
|