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
}