Skip to content

Commit ba4bc93

Browse files
committed
feat: support line deletion
1 parent 8540650 commit ba4bc93

File tree

13 files changed

+300
-11
lines changed

13 files changed

+300
-11
lines changed

openmeter/billing/adapter/invoice.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (a *adapter) GetInvoiceById(ctx context.Context, in billing.GetInvoiceByIdI
4444
WithBillingWorkflowConfig()
4545

4646
if in.Expand.Lines {
47-
query = tx.expandInvoiceLineItems(query)
47+
query = tx.expandInvoiceLineItems(query, in.Expand.DeletedLines)
4848
}
4949

5050
invoice, err := query.Only(ctx)
@@ -64,13 +64,17 @@ func (a *adapter) GetInvoiceById(ctx context.Context, in billing.GetInvoiceByIdI
6464
})
6565
}
6666

67-
func (a *adapter) expandInvoiceLineItems(query *db.BillingInvoiceQuery) *db.BillingInvoiceQuery {
67+
func (a *adapter) expandInvoiceLineItems(query *db.BillingInvoiceQuery, includeDeleted bool) *db.BillingInvoiceQuery {
6868
return query.WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) {
69+
if !includeDeleted {
70+
q = q.Where(billinginvoiceline.DeletedAtIsNil())
71+
}
72+
6973
q = q.Where(
70-
billinginvoiceline.DeletedAtIsNil(),
7174
// Detailed lines are sub-lines of a line and should not be included in the top-level invoice
7275
billinginvoiceline.StatusIn(billingentity.InvoiceLineStatusValid),
7376
)
77+
7478
a.expandLineItems(q)
7579
})
7680
}
@@ -184,7 +188,7 @@ func (a *adapter) ListInvoices(ctx context.Context, input billing.ListInvoicesIn
184188
}
185189

186190
if input.Expand.Lines {
187-
query = tx.expandInvoiceLineItems(query)
191+
query = tx.expandInvoiceLineItems(query, input.Expand.DeletedLines)
188192
}
189193

190194
switch input.OrderBy {

openmeter/billing/adapter/invoicelinediff.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,11 @@ func diffInvoiceLines(lines []*billingentity.Line) (*invoiceLineDiff, error) {
177177

178178
diff.AffectedLineIDs = set.Subtract(
179179
set.Union(diff.AffectedLineIDs, diff.ChildrenDiff.AffectedLineIDs),
180-
set.New(lo.Map(diff.LineBase.ToUpdate, func(l *billingentity.Line, _ int) string {
181-
return l.ID
182-
})...),
183-
set.New(lo.Map(diff.ChildrenDiff.LineBase.ToUpdate, func(l *billingentity.Line, _ int) string {
184-
return l.ID
185-
})...),
180+
lineIDsAsSet(diff.LineBase.ToUpdate),
181+
lineIDsAsSet(diff.ChildrenDiff.LineBase.ToUpdate),
182+
183+
lineIDsAsSet(diff.LineBase.ToDelete),
184+
lineIDsAsSet(diff.ChildrenDiff.LineBase.ToDelete),
186185
)
187186

188187
// Let's make sure we are not leaking any in-progress calculation details
@@ -192,6 +191,12 @@ func diffInvoiceLines(lines []*billingentity.Line) (*invoiceLineDiff, error) {
192191
return &diff, nil
193192
}
194193

194+
func lineIDsAsSet(lines []*billingentity.Line) *set.Set[string] {
195+
return set.New(lo.Map(lines, func(l *billingentity.Line, _ int) string {
196+
return l.ID
197+
})...)
198+
}
199+
195200
func diffLineBaseEntities(line *billingentity.Line, out *invoiceLineDiff) error {
196201
if line.DBState.ID == "" {
197202
// This should not happen, as we fill the DBState after the DB fetch, it's more
@@ -205,7 +210,36 @@ func diffLineBaseEntities(line *billingentity.Line, out *invoiceLineDiff) error
205210

206211
baseNeedsUpdate := false
207212
if !line.DBState.LineBase.Equal(line.LineBase) {
208-
baseNeedsUpdate = true
213+
switch {
214+
case (line.DBState.LineBase.DeletedAt == nil) && (line.LineBase.DeletedAt != nil):
215+
// The line got deleted
216+
out.LineBase.NeedsDelete(line)
217+
out.AffectedLineIDs.Add(lineParentIDs(line, lineIDExcludeSelf)...)
218+
219+
if line.Children.IsPresent() {
220+
// We need to delete the children as well
221+
if err := deleteLineChildren(line, out); err != nil {
222+
return err
223+
}
224+
}
225+
226+
if err := handleLineDependantEntities(line, operationDelete, out); err != nil {
227+
return err
228+
}
229+
230+
return nil
231+
case (line.DBState.LineBase.DeletedAt != nil) && (line.LineBase.DeletedAt == nil):
232+
// The line got undeleted
233+
234+
// Warning: it's up to the caller to make sure that child objects are properly updated too
235+
baseNeedsUpdate = true
236+
237+
case line.DBState.LineBase.DeletedAt != nil && line.LineBase.DeletedAt != nil:
238+
// The line is deleted, we don't need to update anything
239+
return nil
240+
default:
241+
baseNeedsUpdate = true
242+
}
209243
}
210244

211245
switch line.Type {
@@ -289,6 +323,20 @@ func getChildrenActions(dbSave []*billingentity.Line, current []*billingentity.L
289323
return nil
290324
}
291325

326+
func deleteLineChildren(line *billingentity.Line, out *invoiceLineDiff) error {
327+
for _, child := range line.DBState.Children.OrEmpty() {
328+
out.ChildrenDiff.LineBase.NeedsDelete(child)
329+
330+
if err := handleLineDependantEntities(child, operationDelete, out.ChildrenDiff); err != nil {
331+
return err
332+
}
333+
334+
out.ChildrenDiff.AffectedLineIDs.Add(lineParentIDs(child, lineIDExcludeSelf)...)
335+
}
336+
337+
return nil
338+
}
339+
292340
func handleLineDependantEntities(line *billingentity.Line, lineOperation operation, out *invoiceLineDiff) error {
293341
return handleLineDiscounts(line, lineOperation, out)
294342
}

openmeter/billing/adapter/invoicelinediff_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/stretchr/testify/require"
99

1010
billingentity "github.com/openmeterio/openmeter/openmeter/billing/entity"
11+
"github.com/openmeterio/openmeter/pkg/clock"
1112
)
1213

1314
type idDiff = diff[string]
@@ -263,6 +264,105 @@ func TestInvoiceLineDiffing(t *testing.T) {
263264
},
264265
}, lineDiff)
265266
})
267+
268+
// DeletedAt handling
269+
t.Run("support for detailed lines being deleted using deletedAt", func(t *testing.T) {
270+
base := cloneLines(template)
271+
snapshotAsDBState(base)
272+
273+
base[1].Children.GetByID("2.1").DeletedAt = lo.ToPtr(clock.Now())
274+
275+
lineDiff, err := diffInvoiceLines(base)
276+
require.NoError(t, err)
277+
278+
requireDiff(t, lineDiffExpectation{
279+
AffectedLineIDs: []string{"2"},
280+
ChildrenDiff: &lineDiffExpectation{
281+
LineBase: idDiff{
282+
ToDelete: []string{"2.1"},
283+
},
284+
Discounts: idDiff{
285+
ToDelete: []string{"D2.1.1"},
286+
},
287+
},
288+
}, lineDiff)
289+
})
290+
291+
t.Run("support for parent lines with children being deleted using deletedAt", func(t *testing.T) {
292+
base := cloneLines(template)
293+
snapshotAsDBState(base)
294+
295+
base[1].DeletedAt = lo.ToPtr(clock.Now())
296+
297+
lineDiff, err := diffInvoiceLines(base)
298+
require.NoError(t, err)
299+
300+
requireDiff(t, lineDiffExpectation{
301+
LineBase: idDiff{
302+
ToDelete: []string{"2"},
303+
},
304+
ChildrenDiff: &lineDiffExpectation{
305+
LineBase: idDiff{
306+
ToDelete: []string{"2.1", "2.2"},
307+
},
308+
Discounts: idDiff{
309+
ToDelete: []string{"D2.1.1"},
310+
},
311+
},
312+
}, lineDiff)
313+
})
314+
315+
t.Run("support for parent lines without children being deleted using deletedAt", func(t *testing.T) {
316+
base := cloneLines(template)
317+
snapshotAsDBState(base)
318+
319+
base[0].DeletedAt = lo.ToPtr(clock.Now())
320+
321+
lineDiff, err := diffInvoiceLines(base)
322+
require.NoError(t, err)
323+
324+
requireDiff(t, lineDiffExpectation{
325+
LineBase: idDiff{
326+
ToDelete: []string{"1"},
327+
},
328+
}, lineDiff)
329+
})
330+
331+
t.Run("deleted, changed lines are not triggering updates", func(t *testing.T) {
332+
base := cloneLines(template)
333+
base[0].DeletedAt = lo.ToPtr(clock.Now())
334+
snapshotAsDBState(base)
335+
base[0].Description = lo.ToPtr("test")
336+
337+
lineDiff, err := diffInvoiceLines(base)
338+
require.NoError(t, err)
339+
340+
requireDiff(t, lineDiffExpectation{}, lineDiff)
341+
})
342+
343+
t.Run("deleted, changed lines are not triggering updates", func(t *testing.T) {
344+
base := cloneLines(template)
345+
base[1].DeletedAt = lo.ToPtr(clock.Now())
346+
base[1].Children.GetByID("2.1").DeletedAt = lo.ToPtr(clock.Now())
347+
348+
snapshotAsDBState(base)
349+
base[1].DeletedAt = nil
350+
base[1].Children.GetByID("2.1").DeletedAt = nil
351+
352+
lineDiff, err := diffInvoiceLines(base)
353+
require.NoError(t, err)
354+
355+
requireDiff(t, lineDiffExpectation{
356+
LineBase: idDiff{
357+
ToUpdate: []string{"2"},
358+
},
359+
ChildrenDiff: &lineDiffExpectation{
360+
LineBase: idDiff{
361+
ToUpdate: []string{"2.1"},
362+
},
363+
},
364+
}, lineDiff)
365+
})
266366
}
267367

268368
func mapLinesToIDs(lines []*billingentity.Line) []string {

openmeter/billing/entity/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ var (
3737
ErrInvoiceLineVolumeSplitNotSupported = NewValidationError("invoice_line_graduated_split_not_supported", "graduated tiered pricing is not supported for split periods")
3838
ErrInvoiceLineNoTiers = NewValidationError("invoice_line_no_tiers", "usage based invoice line: no tiers found")
3939
ErrInvoiceLineMissingOpenEndedTier = NewValidationError("invoice_line_missing_open_ended_tier", "usage based invoice line: missing open ended tier")
40+
ErrInvoiceLineDeleteInvalidStatus = NewValidationError("invoice_line_delete_invalid_status", "invoice line cannot be deleted in the current state (only valid lines can be deleted)")
4041
ErrInvoiceCreateNoLines = NewValidationError("invoice_create_no_lines", "the new invoice would have no lines")
4142
ErrInvoiceCreateUBPLineCustomerHasNoSubjects = NewValidationError("invoice_create_ubp_line_customer_has_no_subjects", "creating an usage based line: customer has no subjects")
4243
ErrInvoiceCreateUBPLinePeriodIsEmpty = NewValidationError("invoice_create_ubp_line_period_is_empty", "creating an usage based line: truncated period is empty")

openmeter/billing/entity/invoice.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,14 @@ type InvoiceExpand struct {
118118
Preceding bool
119119
WorkflowApps bool
120120
Lines bool
121+
DeletedLines bool
121122
}
122123

123124
var InvoiceExpandAll = InvoiceExpand{
124125
Preceding: true,
125126
WorkflowApps: true,
126127
Lines: true,
128+
DeletedLines: false,
127129
}
128130

129131
func (e InvoiceExpand) Validate() error {
@@ -135,6 +137,11 @@ func (e InvoiceExpand) SetLines(v bool) InvoiceExpand {
135137
return e
136138
}
137139

140+
func (e InvoiceExpand) SetDeletedLines(v bool) InvoiceExpand {
141+
e.DeletedLines = v
142+
return e
143+
}
144+
138145
type InvoiceBase struct {
139146
Namespace string `json:"namespace"`
140147
ID string `json:"id"`

openmeter/billing/entity/invoiceline.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ type Line struct {
232232
DBState *Line
233233
}
234234

235+
func (i Line) LineID() LineID {
236+
return LineID{
237+
Namespace: i.Namespace,
238+
ID: i.ID,
239+
}
240+
}
241+
235242
// CloneWithoutDependencies returns a clone of the line without any external dependencies. Could be used
236243
// for creating a new line without any references to the parent or children (or config IDs).
237244
func (i Line) CloneWithoutDependencies() *Line {

openmeter/billing/invoiceline.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,5 @@ func (v ValidateLineOwnershipInput) Validate() error {
338338

339339
return nil
340340
}
341+
342+
type DeleteInvoiceLineInput = billingentity.LineID

openmeter/billing/service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type InvoiceLineService interface {
3535
CreateInvoiceLines(ctx context.Context, input CreateInvoiceLinesInput) ([]*billingentity.Line, error)
3636
GetInvoiceLine(ctx context.Context, input GetInvoiceLineInput) (*billingentity.Line, error)
3737
UpdateInvoiceLine(ctx context.Context, input UpdateInvoiceLineInput) (*billingentity.Line, error)
38+
DeleteInvoiceLine(ctx context.Context, input DeleteInvoiceLineInput) error
3839

3940
ValidateLineOwnership(ctx context.Context, input ValidateLineOwnershipInput) error
4041
}

openmeter/billing/service/invoicecalc/details.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ func RecalculateDetailedLinesAndTotals(invoice *billingentity.Invoice, deps Calc
2222
var outErr error
2323

2424
for _, line := range lines {
25+
if line.IsDeleted() {
26+
continue
27+
}
28+
2529
if err := line.CalculateDetailedLines(); err != nil {
2630
outErr = errors.Join(outErr,
2731
billingentity.ValidationWithFieldPrefix(fmt.Sprintf("line[%s]", line.ID()),
@@ -41,6 +45,11 @@ func RecalculateDetailedLinesAndTotals(invoice *billingentity.Invoice, deps Calc
4145
totals := billingentity.Totals{}
4246

4347
totals = totals.Add(lo.Map(invoice.Lines.OrEmpty(), func(line *billingentity.Line, _ int) billingentity.Totals {
48+
// Deleted lines are not contributing to the totals
49+
if line.DeletedAt != nil {
50+
return billingentity.Totals{}
51+
}
52+
4453
return line.Totals
4554
})...)
4655

openmeter/billing/service/invoiceline.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
billingentity "github.com/openmeterio/openmeter/openmeter/billing/entity"
1313
lineservice "github.com/openmeterio/openmeter/openmeter/billing/service/lineservice"
1414
customerentity "github.com/openmeterio/openmeter/openmeter/customer/entity"
15+
"github.com/openmeterio/openmeter/pkg/clock"
1516
"github.com/openmeterio/openmeter/pkg/currencyx"
1617
"github.com/openmeterio/openmeter/pkg/framework/transaction"
1718
"github.com/openmeterio/openmeter/pkg/pagination"
@@ -370,3 +371,48 @@ func (s *Service) UpdateInvoiceLine(ctx context.Context, input billing.UpdateInv
370371

371372
return updatedLine, nil
372373
}
374+
375+
func (s *Service) DeleteInvoiceLine(ctx context.Context, input billing.DeleteInvoiceLineInput) error {
376+
if err := input.Validate(); err != nil {
377+
return billingentity.ValidationError{
378+
Err: err,
379+
}
380+
}
381+
382+
existingLine, err := s.adapter.GetInvoiceLine(ctx, input)
383+
if err != nil {
384+
return err
385+
}
386+
387+
if existingLine.Status != billingentity.InvoiceLineStatusValid {
388+
return billingentity.ValidationError{
389+
Err: fmt.Errorf("line[%s]: %w", existingLine.ID, billingentity.ErrInvoiceLineDeleteInvalidStatus),
390+
}
391+
}
392+
393+
return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error {
394+
_, err := s.executeTriggerOnInvoice(
395+
ctx,
396+
billingentity.InvoiceID{
397+
ID: existingLine.InvoiceID,
398+
Namespace: existingLine.Namespace,
399+
},
400+
triggerUpdated,
401+
ExecuteTriggerWithAllowInStates(billingentity.InvoiceStatusDraftUpdating),
402+
ExecuteTriggerWithEditCallback(func(sm *InvoiceStateMachine) error {
403+
line := sm.Invoice.Lines.GetByID(existingLine.ID)
404+
if line == nil || line.DeletedAt != nil {
405+
return billingentity.NotFoundError{
406+
Err: fmt.Errorf("line[%s]: not found in invoice", existingLine.ID),
407+
}
408+
}
409+
410+
line.DeletedAt = lo.ToPtr(clock.Now())
411+
412+
return nil
413+
}),
414+
)
415+
416+
return err
417+
})
418+
}

0 commit comments

Comments
 (0)