| package persistedstate |
|
|
| import ( |
| "context" |
| "fmt" |
|
|
| "github.com/samber/lo" |
|
|
| "github.com/openmeterio/openmeter/openmeter/billing" |
| "github.com/openmeterio/openmeter/openmeter/billing/charges" |
| "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" |
| "github.com/openmeterio/openmeter/openmeter/streaming" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/pkg/pagination" |
| "github.com/openmeterio/openmeter/pkg/slicesx" |
| "github.com/openmeterio/openmeter/pkg/timeutil" |
| ) |
|
|
| type billingService interface { |
| GetLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) |
| ListInvoices(ctx context.Context, input billing.ListInvoicesInput) (billing.ListInvoicesResponse, error) |
| } |
|
|
| type chargeService interface { |
| ListCharges(ctx context.Context, input charges.ListChargesInput) (pagination.Result[charges.Charge], error) |
| } |
|
|
| type Loader struct { |
| billingService billingService |
| chargeService chargeService |
| } |
|
|
| func NewLoader(billingService billingService, chargeService chargeService) Loader { |
| return Loader{ |
| billingService: billingService, |
| chargeService: chargeService, |
| } |
| } |
|
|
| func (l Loader) LoadForSubscription(ctx context.Context, subs subscription.Subscription) (State, error) { |
| lines, err := l.billingService.GetLinesForSubscription(ctx, billing.GetLinesForSubscriptionInput{ |
| Namespace: subs.Namespace, |
| SubscriptionID: subs.ID, |
| CustomerID: subs.CustomerId, |
| |
| |
| IncludeChargeManaged: false, |
| }) |
| if err != nil { |
| return State{}, fmt.Errorf("getting existing lines: %w", err) |
| } |
|
|
| lines, err = slicesx.MapWithErr(lines, normalizePersistedLineOrHierarchy) |
| if err != nil { |
| return State{}, fmt.Errorf("normalizing existing lines: %w", err) |
| } |
|
|
| byUniqueID := make(map[string]Item, len(lines)) |
| for _, line := range lines { |
| uniqueID := line.ChildUniqueReferenceID() |
| if uniqueID == nil { |
| continue |
| } |
|
|
| item, err := NewItemFromLineOrHierarchy(line) |
| if err != nil { |
| return State{}, fmt.Errorf("creating persisted item[%s]: %w", *uniqueID, err) |
| } |
|
|
| if _, ok := byUniqueID[*uniqueID]; ok { |
| return State{}, fmt.Errorf("duplicate unique ids in the existing lines") |
| } |
|
|
| byUniqueID[*uniqueID] = item |
| } |
|
|
| invoices, err := l.loadInvoicesForSubscriptionLines(ctx, subs, lines) |
| if err != nil { |
| return State{}, err |
| } |
|
|
| chargesByUniqueID, err := l.loadChargesForSubscription(ctx, subs) |
| if err != nil { |
| return State{}, err |
| } |
|
|
| for uniqueID := range chargesByUniqueID { |
| if _, ok := byUniqueID[uniqueID]; ok { |
| return State{}, fmt.Errorf("duplicate unique id across persisted lines and charges: %s", uniqueID) |
| } |
|
|
| byUniqueID[uniqueID] = chargesByUniqueID[uniqueID] |
| } |
|
|
| return State{ |
| ByUniqueID: byUniqueID, |
| Invoices: invoices, |
| }, nil |
| } |
|
|
| func (l Loader) loadChargesForSubscription(ctx context.Context, subs subscription.Subscription) (map[string]Item, error) { |
| if l.chargeService == nil { |
| return map[string]Item{}, nil |
| } |
|
|
| listedCharges, err := l.chargeService.ListCharges(ctx, charges.ListChargesInput{ |
| Namespace: subs.Namespace, |
| SubscriptionIDs: []string{subs.ID}, |
| |
| |
| DeletedAtFilter: charges.ListChargesDeletedAtFilterBaseIntent, |
| Expands: meta.ExpandNone, |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("listing charges for subscription: %w", err) |
| } |
|
|
| byUniqueID := make(map[string]Item, len(listedCharges.Items)) |
|
|
| for _, charge := range listedCharges.Items { |
| switch charge.Type() { |
| case meta.ChargeTypeUsageBased: |
| usageBasedCharge, err := charge.AsUsageBasedCharge() |
| if err != nil { |
| return nil, fmt.Errorf("getting usage based charge: %w", err) |
| } |
|
|
| uniqueReferenceID := usageBasedCharge.Intent.GetUniqueReferenceID() |
| if uniqueReferenceID == nil { |
| continue |
| } |
|
|
| item, err := NewChargeItemFromChargeType(meta.ChargeTypeUsageBased, &usageBasedCharge, nil) |
| if err != nil { |
| return nil, fmt.Errorf("creating persisted usage based charge item[%s]: %w", *uniqueReferenceID, err) |
| } |
|
|
| if _, ok := byUniqueID[*uniqueReferenceID]; ok { |
| return nil, fmt.Errorf("duplicate unique ids in the existing charges") |
| } |
|
|
| byUniqueID[*uniqueReferenceID] = item |
| case meta.ChargeTypeFlatFee: |
| flatFeeCharge, err := charge.AsFlatFeeCharge() |
| if err != nil { |
| return nil, fmt.Errorf("getting flat fee charge: %w", err) |
| } |
|
|
| uniqueReferenceID := flatFeeCharge.Intent.GetUniqueReferenceID() |
| if uniqueReferenceID == nil { |
| continue |
| } |
|
|
| item, err := NewChargeItemFromChargeType(meta.ChargeTypeFlatFee, nil, &flatFeeCharge) |
| if err != nil { |
| return nil, fmt.Errorf("creating persisted flat fee charge item[%s]: %w", *uniqueReferenceID, err) |
| } |
|
|
| if _, ok := byUniqueID[*uniqueReferenceID]; ok { |
| return nil, fmt.Errorf("duplicate unique ids in the existing charges") |
| } |
|
|
| byUniqueID[*uniqueReferenceID] = item |
| case meta.ChargeTypeCreditPurchase: |
| creditPurchaseCharge, err := charge.AsCreditPurchaseCharge() |
| if err != nil { |
| return nil, fmt.Errorf("getting credit purchase charge: %w", err) |
| } |
|
|
| return nil, fmt.Errorf("credit purchase charges tied to subscriptions are unsupported [charge_id=%s, subscription_id=%s]", creditPurchaseCharge.ID, subs.ID) |
| default: |
| return nil, fmt.Errorf("unsupported charge type in persisted subscription state: %s", charge.Type()) |
| } |
| } |
|
|
| return byUniqueID, nil |
| } |
|
|
| func (l Loader) loadInvoicesForSubscriptionLines(ctx context.Context, subs subscription.Subscription, lines []billing.LineOrHierarchy) (Invoices, error) { |
| invoiceIDs := make(map[string]struct{}) |
|
|
| for _, line := range lines { |
| switch line.Type() { |
| case billing.LineOrHierarchyTypeLine: |
| genericLine, err := line.AsGenericLine() |
| if err != nil { |
| return Invoices{}, fmt.Errorf("getting line invoice id: %w", err) |
| } |
|
|
| invoiceIDs[genericLine.GetInvoiceID()] = struct{}{} |
| case billing.LineOrHierarchyTypeHierarchy: |
| hierarchy, err := line.AsHierarchy() |
| if err != nil { |
| return Invoices{}, fmt.Errorf("getting hierarchy invoice ids: %w", err) |
| } |
|
|
| for _, child := range hierarchy.Lines { |
| invoiceIDs[child.Invoice.GetID()] = struct{}{} |
| } |
| } |
| } |
|
|
| if len(invoiceIDs) == 0 { |
| return Invoices{}, nil |
| } |
|
|
| invoices, err := l.loadInvoices(ctx, subs.Namespace, lo.Keys(invoiceIDs)) |
| if err != nil { |
| return Invoices{}, err |
| } |
|
|
| for invoiceID := range invoiceIDs { |
| if _, ok := invoices[invoiceID]; !ok { |
| return Invoices{}, fmt.Errorf("invoice not found for persisted subscription state: %s", invoiceID) |
| } |
| } |
|
|
| return invoices, nil |
| } |
|
|
| func (l Loader) loadInvoices(ctx context.Context, namespace string, invoiceIDs []string) (Invoices, error) { |
| invoices, err := l.billingService.ListInvoices(ctx, billing.ListInvoicesInput{ |
| Namespaces: []string{namespace}, |
| IDs: invoiceIDs, |
| IncludeDeleted: true, |
| }) |
| if err != nil { |
| return Invoices{}, fmt.Errorf("listing invoices: %w", err) |
| } |
|
|
| byID := make(map[string]billing.Invoice, len(invoices.Items)) |
| for _, invoice := range invoices.Items { |
| genericInvoice, err := invoice.AsGenericInvoice() |
| if err != nil { |
| return Invoices{}, fmt.Errorf("converting invoice to generic invoice: %w", err) |
| } |
|
|
| byID[genericInvoice.GetID()] = invoice |
| } |
|
|
| return Invoices(byID), nil |
| } |
|
|
| func normalizePersistedLineOrHierarchy(lineOrHierarchy billing.LineOrHierarchy) (billing.LineOrHierarchy, error) { |
| |
| |
| |
| |
| |
| |
| |
| switch lineOrHierarchy.Type() { |
| case billing.LineOrHierarchyTypeLine: |
| line, err := lineOrHierarchy.AsGenericLine() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("getting line: %w", err) |
| } |
|
|
| cloned, err := line.Clone() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("cloning line: %w", err) |
| } |
|
|
| cloned.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { |
| *period = period.Truncate(streaming.MinimumWindowSizeDuration) |
| }) |
|
|
| if invoiceAtAccessor, ok := cloned.(billing.InvoiceAtAccessor); ok { |
| invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) |
| } |
|
|
| normalizeSubscriptionReference(cloned.GetSubscriptionReference()) |
|
|
| invoiceLine := cloned.AsInvoiceLine() |
| switch invoiceLine.Type() { |
| case billing.InvoiceLineTypeStandard: |
| standardLine, err := invoiceLine.AsStandardLine() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("getting standard line: %w", err) |
| } |
|
|
| return billing.NewLineOrHierarchy(&standardLine), nil |
| case billing.InvoiceLineTypeGathering: |
| gatheringLine, err := invoiceLine.AsGatheringLine() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("getting gathering line: %w", err) |
| } |
|
|
| return billing.NewLineOrHierarchy(gatheringLine), nil |
| default: |
| return billing.LineOrHierarchy{}, fmt.Errorf("unsupported invoice line type: %s", invoiceLine.Type()) |
| } |
| case billing.LineOrHierarchyTypeHierarchy: |
| hierarchy, err := lineOrHierarchy.AsHierarchy() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("getting hierarchy: %w", err) |
| } |
|
|
| cloned, err := hierarchy.Clone() |
| if err != nil { |
| return billing.LineOrHierarchy{}, fmt.Errorf("cloning hierarchy: %w", err) |
| } |
|
|
| cloned.Group.ServicePeriod = cloned.Group.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) |
|
|
| for i := range cloned.Lines { |
| cloned.Lines[i].Line.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { |
| *period = period.Truncate(streaming.MinimumWindowSizeDuration) |
| }) |
|
|
| if invoiceAtAccessor, ok := cloned.Lines[i].Line.(billing.InvoiceAtAccessor); ok { |
| invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) |
| } |
|
|
| normalizeSubscriptionReference(cloned.Lines[i].Line.GetSubscriptionReference()) |
| } |
|
|
| return billing.NewLineOrHierarchy(&cloned), nil |
| default: |
| return lineOrHierarchy, nil |
| } |
| } |
|
|
| func normalizeSubscriptionReference(ref *billing.SubscriptionReference) { |
| if ref == nil { |
| return |
| } |
|
|
| |
| |
| |
| |
| |
| |
| ref.BillingPeriod = ref.BillingPeriod.Truncate(streaming.MinimumWindowSizeDuration) |
| } |
|
|