File size: 11,300 Bytes
1c4c66b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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,
		// Charge-managed invoice lines are edited through charge patches, so subscription sync loads the
		// charge entities instead of reconciling those lines directly.
		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},
		// Subscription sync reconciles subscription-owned source state, so API
		// override deletion must not hide a charge whose base intent is still live.
		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) {
	// Subscription sync diffs against meter-compatible time windows. Historical persisted
	// lines can still carry sub-second timestamps from older writes, but the meter engine
	// only supports MinimumWindowSizeDuration precision. We normalize persisted state on
	// read so reconciliation does not keep proposing no-op repairs purely because the DB
	// preserved finer precision than the target state can legally represent.
	// TODO: Add a migration to normalize existing billing timestamps to the precision
	// supported by meter queries.
	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
	}

	// Historical billing rows can carry sub-second subscription billing periods even
	// though subscription sync and meter queries operate on MinimumWindowSizeDuration
	// precision. Normalize the persisted subscription reference on read so legacy
	// timestamp precision does not leak into reconciliation decisions.
	// TODO: Add a migration to normalize existing billing timestamps to the precision
	// supported by meter queries.
	ref.BillingPeriod = ref.BillingPeriod.Truncate(streaming.MinimumWindowSizeDuration)
}