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 }