From 3dae1e96009baf88f79a730b73554e86f769e843 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 2 Jul 2026 17:07:22 +0530 Subject: [PATCH] fix(billing): advance credit overdraft invoice window past last invoiced range --- billing/invoice/service.go | 42 ++++++++------- billing/invoice/service_test.go | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/billing/invoice/service.go b/billing/invoice/service.go index 0f300663a..eeb30b608 100644 --- a/billing/invoice/service.go +++ b/billing/invoice/service.go @@ -507,23 +507,7 @@ func (s *Service) GenerateForCredits(ctx context.Context) error { } } - // check if invoice line items are of type credit and matches the current range - // if yes, don't create a new invoice - var alreadyInvoiced bool - if lastOverdraftInvoice != nil { - for _, item := range lastOverdraftInvoice.Items { - if item.TimeRangeEnd != nil && item.TimeRangeStart.Before(startRange) { - // if before the start range, update the start range - startRange = *item.TimeRangeEnd - } - - if item.TimeRangeStart != nil && item.TimeRangeStart.Equal(startRange) && - item.TimeRangeEnd != nil && item.TimeRangeEnd.Equal(endRange) { - alreadyInvoiced = true - break - } - } - } + startRange, alreadyInvoiced := computeOverdraftWindow(startRange, endRange, lastOverdraftInvoice) if alreadyInvoiced { continue } @@ -572,6 +556,30 @@ func (s *Service) GenerateForCredits(ctx context.Context) error { return nil } +// computeOverdraftWindow returns the start of the invoice window for the +// customer and whether the current range is already covered by the last +// credit overdraft invoice. startRange is the fallback window start when no +// overdraft invoice exists, normally the customer creation time. +func computeOverdraftWindow(startRange time.Time, endRange time.Time, lastOverdraftInvoice *Invoice) (time.Time, bool) { + var alreadyInvoiced bool + if lastOverdraftInvoice != nil { + for _, item := range lastOverdraftInvoice.Items { + if item.TimeRangeEnd == nil { + continue + } + // move the window start past the range billed by the last invoice + if item.TimeRangeEnd.After(startRange) { + startRange = *item.TimeRangeEnd + } + if !item.TimeRangeEnd.Before(endRange) { + // the last invoice already covers the current range end + alreadyInvoiced = true + } + } + } + return startRange, alreadyInvoiced +} + // getCreditOverdraftEndDate get range start and end, end date will be the current date with time set to 00:00:00 // start date will be the end date minus creditOverdraftRangeOfInvoice func (s *Service) getCreditOverdraftEndDate(current time.Time) time.Time { diff --git a/billing/invoice/service_test.go b/billing/invoice/service_test.go index 851712e79..ebbe6e39d 100644 --- a/billing/invoice/service_test.go +++ b/billing/invoice/service_test.go @@ -6,6 +6,96 @@ import ( "time" ) +func TestService_computeOverdraftWindow(t *testing.T) { + createdAt := time.Date(2024, 8, 12, 7, 59, 50, 544323000, time.UTC) + // providers store item ranges in whole seconds, so the first invoice's + // start is the creation time truncated to the second + createdAtTruncated := createdAt.Truncate(time.Second) + feb1 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + + creditItem := func(start, end *time.Time) Invoice { + return Invoice{ + Items: []Item{ + { + Type: CreditItemType, + TimeRangeStart: start, + TimeRangeEnd: end, + }, + }, + } + } + + tests := []struct { + name string + endRange time.Time + lastInvoice *Invoice + wantStart time.Time + wantAlreadyInvoiced bool + }{ + { + name: "no previous overdraft invoice starts window at customer creation", + endRange: jul1, + wantStart: createdAt, + }, + { + name: "first invoice anchors window at its end", + endRange: mar1, + lastInvoice: ptr(creditItem(&createdAtTruncated, &feb1)), + wantStart: feb1, + }, + { + name: "first invoice anchors window when its start equals creation time", + endRange: mar1, + lastInvoice: ptr(creditItem(&createdAt, &feb1)), + wantStart: feb1, + }, + { + name: "later invoice anchors window at its end instead of customer creation", + endRange: jul1, + lastInvoice: ptr(creditItem(&may1, &jun1)), + wantStart: jun1, + }, + { + name: "range ending at window end is already invoiced", + endRange: jul1, + lastInvoice: ptr(creditItem(&may1, &jul1)), + wantStart: jul1, + wantAlreadyInvoiced: true, + }, + { + name: "item without range start does not panic", + endRange: jul1, + lastInvoice: ptr(creditItem(nil, &jun1)), + wantStart: jun1, + }, + { + name: "item without range end keeps window at customer creation", + endRange: jul1, + lastInvoice: ptr(creditItem(&may1, nil)), + wantStart: createdAt, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, alreadyInvoiced := computeOverdraftWindow(createdAt, tt.endRange, tt.lastInvoice) + if !start.Equal(tt.wantStart) { + t.Errorf("computeOverdraftWindow() start = %v, want %v", start, tt.wantStart) + } + if alreadyInvoiced != tt.wantAlreadyInvoiced { + t.Errorf("computeOverdraftWindow() alreadyInvoiced = %v, want %v", alreadyInvoiced, tt.wantAlreadyInvoiced) + } + }) + } +} + +func ptr[T any](v T) *T { + return &v +} + func TestService_getCreditOverdraftRange(t *testing.T) { tests := []struct { name string