File size: 11,215 Bytes
5a22efd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
package billing

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"time"

	"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
	"github.com/openmeterio/openmeter/pkg/timeutil"
)

type ChangeSource string

const (
	ChangeSourceSystem     ChangeSource = "system"
	ChangeSourceAPIRequest ChangeSource = "api_request"
)

func (ChangeSource) Values() []string {
	return []string{
		string(ChangeSourceSystem),
		string(ChangeSourceAPIRequest),
	}
}

func (i ChangeSource) Validate() error {
	if !slices.Contains(ChangeSource("").Values(), string(i)) {
		return fmt.Errorf("invalid change source: %s", i)
	}

	return nil
}

func (i ChangeSource) Require(value ChangeSource) error {
	if err := i.Validate(); err != nil {
		return err
	}

	if i != value {
		return fmt.Errorf("must be %s", value)
	}

	return nil
}

type LineEngineType string

const (
	LineEngineTypeInvoice              LineEngineType = "invoicing"
	LineEngineTypeChargeFlatFee        LineEngineType = "charge_flatfee"
	LineEngineTypeChargeUsageBased     LineEngineType = "charge_usagebased"
	LineEngineTypeChargeCreditPurchase LineEngineType = "charge_creditpurchase"
)

func (b LineEngineType) Values() []string {
	return []string{
		string(LineEngineTypeInvoice),
		string(LineEngineTypeChargeFlatFee),
		string(LineEngineTypeChargeUsageBased),
		string(LineEngineTypeChargeCreditPurchase),
	}
}

func (b LineEngineType) Validate() error {
	if !slices.Contains(b.Values(), string(b)) {
		return fmt.Errorf("invalid line engine type: %s", b)
	}

	return nil
}

func (b LineEngineType) IsCharge() bool {
	switch b {
	case LineEngineTypeChargeFlatFee, LineEngineTypeChargeUsageBased, LineEngineTypeChargeCreditPurchase:
		return true
	default:
		return false
	}
}

type LineBillability struct {
	IsBillable      bool
	ValidationError error
}

type LineBillabilities []LineBillability

type BuildStandardInvoiceLinesInput struct {
	// Invoice is the target standard invoice that will own the built lines.
	Invoice StandardInvoice
	// GatheringLines are the source lines already assigned to this engine.
	GatheringLines GatheringLines
}

func (i BuildStandardInvoiceLinesInput) Validate() error {
	var errs []error

	if i.Invoice.ID == "" {
		errs = append(errs, fmt.Errorf("invoice id is required"))
	}

	if len(i.GatheringLines) == 0 {
		errs = append(errs, fmt.Errorf("gathering lines are required"))
	}

	if err := i.GatheringLines.Validate(); err != nil {
		errs = append(errs, fmt.Errorf("gathering lines: %w", err))
	}

	return errors.Join(errs...)
}

type CalculateLinesInput struct {
	// Invoice is the standard invoice owning the lines being recalculated.
	Invoice StandardInvoice
	// Lines are the standard invoice lines already assigned to this engine.
	Lines StandardLines
}

func (i CalculateLinesInput) Validate() error {
	var errs []error

	if i.Invoice.ID == "" {
		errs = append(errs, fmt.Errorf("invoice id is required"))
	}

	if len(i.Lines) == 0 {
		errs = append(errs, fmt.Errorf("lines are required"))
	}

	if err := i.Lines.Validate(); err != nil {
		errs = append(errs, fmt.Errorf("lines: %w", err))
	}

	return errors.Join(errs...)
}

type StandardLineEventInput struct {
	// Invoice is the standard invoice whose lines are being processed for a lifecycle event.
	Invoice StandardInvoice
	// Lines are the standard invoice lines already assigned to this engine.
	Lines StandardLines
}

func (i StandardLineEventInput) Validate() error {
	var errs []error

	if i.Invoice.ID == "" {
		errs = append(errs, fmt.Errorf("invoice id is required"))
	}

	if len(i.Lines) == 0 {
		errs = append(errs, fmt.Errorf("lines are required"))
	}

	if err := i.Lines.Validate(); err != nil {
		errs = append(errs, fmt.Errorf("lines: %w", err))
	}

	return errors.Join(errs...)
}

type (
	OnStandardInvoiceCreatedInput      = StandardLineEventInput
	OnCollectionCompletedInput         = StandardLineEventInput
	OnMutableStandardLinesDeletedInput = StandardLineEventInput
	OnUnsupportedCreditNoteInput       = StandardLineEventInput
	OnInvoiceIssuedInput               = StandardLineEventInput
	OnPaymentAuthorizedInput           = StandardLineEventInput
	OnPaymentSettledInput              = StandardLineEventInput
)

type IsLineBillableAsOfInput struct {
	Line                   GatheringLine
	AsOf                   time.Time
	ProgressiveBilling     bool
	FeatureMeters          feature.FeatureMeters
	ResolvedBillablePeriod timeutil.ClosedPeriod
}

func (i IsLineBillableAsOfInput) Validate() error {
	if err := i.ResolvedBillablePeriod.Validate(); err != nil {
		return fmt.Errorf("validating resolved billable period: %w", err)
	}

	if i.AsOf.IsZero() {
		return fmt.Errorf("as of is required")
	}

	return nil
}

type SplitGatheringLineInput struct {
	Line          GatheringLine
	FeatureMeters feature.FeatureMeters
	SplitAt       time.Time
}

func (i SplitGatheringLineInput) Validate() error {
	var errs []error

	if err := i.Line.Validate(); err != nil {
		errs = append(errs, fmt.Errorf("line: %w", err))
	}

	if i.SplitAt.IsZero() {
		errs = append(errs, fmt.Errorf("split at is required"))
	}

	if i.FeatureMeters == nil {
		errs = append(errs, fmt.Errorf("feature meters are required"))
	}

	return errors.Join(errs...)
}

type SplitGatheringLineResult struct {
	PreSplitAtLine  GatheringLine
	PostSplitAtLine *GatheringLine
}

func (r SplitGatheringLineResult) Validate() error {
	var errs []error

	if err := r.PreSplitAtLine.Validate(); err != nil {
		errs = append(errs, fmt.Errorf("pre split at line: %w", err))
	}

	if r.PostSplitAtLine != nil {
		if err := r.PostSplitAtLine.Validate(); err != nil {
			errs = append(errs, fmt.Errorf("post split at line: %w", err))
		}
	}

	return errors.Join(errs...)
}

type LineEngine interface {
	// GetLineEngineType returns the discriminator owned by this engine implementation.
	GetLineEngineType() LineEngineType

	// IsLineBillableAsOf returns true if the line is billable as of the given time.
	IsLineBillableAsOf(ctx context.Context, input IsLineBillableAsOfInput) (bool, error)

	// SplitGatheringLine splits a gathering line on an engine-specific boundary if required.
	SplitGatheringLine(ctx context.Context, input SplitGatheringLineInput) (SplitGatheringLineResult, error)
	// BuildStandardInvoiceLines materializes gathering lines into standard lines for a target invoice.
	// Returned standard lines must reuse the exact same line IDs as the input gathering lines.
	BuildStandardInvoiceLines(ctx context.Context, input BuildStandardInvoiceLinesInput) (StandardLines, error)
	// BuildStandardLinesForGatheringPreview materializes gathering lines from BuildStandardInvoiceLinesInput
	// into transient StandardLines for a read-only standard invoice preview. Implementations must be
	// side-effect free: they must not persist realization state, modify or allocate credits, mutate
	// input IDs, emit events, or perform external billing side effects. Returned StandardLines must
	// reuse the exact same line IDs as the input gathering lines.
	BuildStandardLinesForGatheringPreview(ctx context.Context, input BuildStandardInvoiceLinesInput) (StandardLines, error)
	// OnStandardInvoiceCreated is invoked after the standard invoice and its standard lines have been persisted.
	OnStandardInvoiceCreated(ctx context.Context, input OnStandardInvoiceCreatedInput) (StandardLines, error)
	// OnCollectionCompleted is invoked when a standard invoice collection window closes.
	OnCollectionCompleted(ctx context.Context, input OnCollectionCompletedInput) (StandardLines, error)
	// OnMutableStandardLinesDeletedBySystem is invoked after mutable standard invoice lines are marked deleted by the system.
	OnMutableStandardLinesDeletedBySystem(ctx context.Context, input OnMutableStandardLinesDeletedInput) error
	// ValidateMutableInvoiceLineEditViaAPI is invoked before mutable invoice lines are edited through the API.
	// Can be used to reject edits that are not supported by the engine (including deletion, etc.) to prevent the
	// invoice from entering an invalid state without recovery.
	//
	// Additional checks can be performed in OnMutableInvoiceLinesEditedViaAPI but those errors will become
	// validation issues, thus alter the invoice state.
	//
	// For API requests it is better to reject and edit before, the existing validation issue logic is geared
	// towards state machine failures.
	//
	// Implementations must not mutate invoice, charge, ledger, or external state from this hook.
	ValidateMutableInvoiceLineEditViaAPI(ctx context.Context, input OnMutableInvoiceUpdateInput) error
	// OnMutableInvoiceLinesEditedViaAPI is invoked after mutable invoice lines are edited through the API.
	// Implementations must return exactly one CreatedLines entry for each input Created line and
	// exactly one UpdatedLines entry for each input Updated override, even when they only accept
	// the line unchanged.
	// Charge-backed creation semantics are documented in billing/README.md under
	// "Lineengine Charges Integration Plan".
	OnMutableInvoiceLinesEditedViaAPI(ctx context.Context, input OnMutableInvoiceUpdateInput) (OnMutableInvoiceUpdateResult, error)
	// OnUnsupportedCreditNote is invoked when a line deletion targets an immutable invoice but credit-note support is not available yet.
	OnUnsupportedCreditNote(ctx context.Context, input OnUnsupportedCreditNoteInput) error
	// OnInvoiceIssued is invoked when a standard invoice reaches the issued state.
	OnInvoiceIssued(ctx context.Context, input OnInvoiceIssuedInput) error
	// OnPaymentAuthorized is invoked when a standard invoice reaches the payment authorized state.
	OnPaymentAuthorized(ctx context.Context, input OnPaymentAuthorizedInput) error
	// OnPaymentSettled is invoked when a standard invoice reaches the paid state.
	OnPaymentSettled(ctx context.Context, input OnPaymentSettledInput) error
}

type LineCalculator interface {
	// CalculateLines recalculates detailed lines and totals for standard-invoice lines owned by this engine.
	CalculateLines(input CalculateLinesInput) (StandardLines, error)
}

func LineEngineValidationComponent(engineType LineEngineType) ComponentName {
	return ComponentName(fmt.Sprintf("openmeter.lineengine.%s", engineType))
}

func NewLineEngineValidationError(engine LineEngine, err error) error {
	if err == nil {
		return nil
	}

	if engine == nil {
		return fmt.Errorf("line engine is required")
	}

	component := LineEngineValidationComponent(engine.GetLineEngineType())
	validationErr := ValidationWithComponent(component, err)

	if _, convertErr := ToValidationIssues(validationErr); convertErr == nil {
		return validationErr
	}

	return ValidationWithComponent(
		component,
		ValidationIssue{
			Severity:  ValidationIssueSeverityCritical,
			Code:      ValidationIssueCodeLineEngineCollectionCompletedFailed,
			Message:   err.Error(),
			Component: component,
		},
	)
}

type CreateLineRouter interface {
	GetLineEngineForCreateLine(line GenericInvoiceLineReader) (LineEngineType, error)
}

type DefaultCreateLineRouter struct{}

func (DefaultCreateLineRouter) GetLineEngineForCreateLine(line GenericInvoiceLineReader) (LineEngineType, error) {
	if line == nil {
		return "", fmt.Errorf("line is required")
	}

	return LineEngineTypeInvoice, nil
}