Skip to content

Commit 6f256a4

Browse files
authored
feat: invoice in advance flat fees on subs creation (#2470)
1 parent 5b31402 commit 6f256a4

File tree

3 files changed

+185
-1
lines changed

3 files changed

+185
-1
lines changed

openmeter/billing/worker/subscription/handlers.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"time"
78

89
"github.com/samber/lo"
10+
"github.com/samber/mo"
911

1012
"github.com/openmeterio/openmeter/openmeter/billing"
1113
"github.com/openmeterio/openmeter/openmeter/customer"
1214
"github.com/openmeterio/openmeter/openmeter/subscription"
1315
"github.com/openmeterio/openmeter/pkg/clock"
16+
"github.com/openmeterio/openmeter/pkg/currencyx"
1417
"github.com/openmeterio/openmeter/pkg/models"
1518
)
1619

@@ -103,3 +106,76 @@ func (h *Handler) HandleInvoiceCreation(ctx context.Context, invoice billing.Eve
103106

104107
return nil
105108
}
109+
110+
func (h *Handler) HandleSubscriptionCreated(ctx context.Context, subs subscription.SubscriptionView, asOf time.Time) error {
111+
if err := h.SyncronizeSubscription(ctx, subs, asOf); err != nil {
112+
return err
113+
}
114+
115+
if subs.Spec.HasBillables() {
116+
// Let's check if there are any pending lines that we can invoice now (those will be in-advance fees, so we don't have to wait
117+
// for the collection period)
118+
119+
gatheringInvoices, err := h.billingService.ListInvoices(ctx, billing.ListInvoicesInput{
120+
Namespaces: []string{subs.Subscription.Namespace},
121+
Customers: []string{subs.Customer.ID},
122+
ExtendedStatuses: []billing.InvoiceStatus{billing.InvoiceStatusGathering},
123+
Currencies: []currencyx.Code{subs.Spec.Currency},
124+
Expand: billing.InvoiceExpand{
125+
Lines: true,
126+
},
127+
})
128+
if err != nil {
129+
return fmt.Errorf("failed to list gathering invoices: %w", err)
130+
}
131+
132+
now := clock.Now()
133+
134+
invoicableLines := []string{}
135+
136+
for _, invoice := range gatheringInvoices.Items {
137+
inScopeLines := lo.Filter(invoice.Lines.OrEmpty(), func(line *billing.Line, _ int) bool {
138+
if line.Subscription.SubscriptionID != subs.Subscription.ID {
139+
return false
140+
}
141+
142+
if line.Status != billing.InvoiceLineStatusValid {
143+
return false
144+
}
145+
146+
return !line.InvoiceAt.After(now)
147+
})
148+
149+
if len(inScopeLines) == 0 {
150+
continue
151+
}
152+
153+
invoicableLines = append(invoicableLines, lo.Map(inScopeLines, func(line *billing.Line, _ int) string {
154+
return line.ID
155+
})...)
156+
}
157+
158+
if len(invoicableLines) > 0 {
159+
invoices, err := h.billingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{
160+
Customer: customer.CustomerID{
161+
Namespace: subs.Subscription.Namespace,
162+
ID: subs.Customer.ID,
163+
},
164+
165+
IncludePendingLines: mo.Some(invoicableLines),
166+
})
167+
if err != nil {
168+
return fmt.Errorf("failed to create invoice: %w", err)
169+
}
170+
171+
h.logger.Info("created invoice on subscription creation trigger",
172+
"customer_id", subs.Customer.ID,
173+
"invoice_ids", lo.Map(invoices, func(inv billing.Invoice, _ int) string {
174+
return fmt.Sprintf("%s/%s", inv.Number, inv.ID)
175+
}),
176+
)
177+
}
178+
}
179+
180+
return nil
181+
}

openmeter/billing/worker/subscription/sync_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2804,6 +2804,114 @@ func (s *SubscriptionHandlerTestSuite) TestRateCardTaxSync() {
28042804
}
28052805
}
28062806

2807+
func (s *SubscriptionHandlerTestSuite) TestInAdvanceInstantBillingOnSubscriptionCreation() {
2808+
ctx := s.Context
2809+
clock.FreezeTime(s.mustParseTime("2024-01-01T00:00:00Z"))
2810+
2811+
// Given
2812+
// we have a subscription with a single phase with an in advance fee
2813+
// When
2814+
// we start the subscription
2815+
// Then
2816+
// the gathering invoice will automatically be invoiced so that the in advance fee is billed (those are always flat fees)
2817+
2818+
subsView := s.createSubscriptionFromPlanPhases([]productcatalog.Phase{
2819+
{
2820+
PhaseMeta: s.phaseMeta("first-phase", ""),
2821+
RateCards: productcatalog.RateCards{
2822+
&productcatalog.UsageBasedRateCard{
2823+
RateCardMeta: productcatalog.RateCardMeta{
2824+
Key: "in-advance",
2825+
Name: "in-advance",
2826+
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
2827+
Amount: alpacadecimal.NewFromFloat(6),
2828+
PaymentTerm: productcatalog.InAdvancePaymentTerm,
2829+
}),
2830+
},
2831+
BillingCadence: isodate.MustParse(s.T(), "P1D"),
2832+
},
2833+
&productcatalog.UsageBasedRateCard{
2834+
RateCardMeta: productcatalog.RateCardMeta{
2835+
Key: s.APIRequestsTotalFeature.Key,
2836+
Name: s.APIRequestsTotalFeature.Key,
2837+
Feature: &s.APIRequestsTotalFeature,
2838+
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{
2839+
Amount: alpacadecimal.NewFromFloat(10),
2840+
}),
2841+
},
2842+
BillingCadence: isodate.MustParse(s.T(), "P1D"),
2843+
},
2844+
},
2845+
},
2846+
})
2847+
2848+
s.NoError(s.Handler.HandleSubscriptionCreated(ctx, subsView, clock.Now()))
2849+
2850+
invoices, err := s.BillingService.ListInvoices(ctx, billing.ListInvoicesInput{
2851+
Customers: []string{s.Customer.ID},
2852+
Expand: billing.InvoiceExpandAll,
2853+
})
2854+
s.NoError(err)
2855+
s.Len(invoices.Items, 2)
2856+
2857+
var gatheringInvoice *billing.Invoice
2858+
var instantInvoice *billing.Invoice
2859+
2860+
for _, invoice := range invoices.Items {
2861+
if invoice.Status == billing.InvoiceStatusGathering {
2862+
gatheringInvoice = &invoice
2863+
continue
2864+
}
2865+
2866+
instantInvoice = &invoice
2867+
}
2868+
2869+
s.NotNil(gatheringInvoice, "gathering invoice should be present")
2870+
s.NotNil(instantInvoice, "instant invoice should be present")
2871+
2872+
s.DebugDumpInvoice("gathering invoice", *gatheringInvoice)
2873+
s.DebugDumpInvoice("instant invoice", *instantInvoice)
2874+
2875+
// Gathering invoice should have the UBP line
2876+
s.expectLines(*gatheringInvoice, subsView.Subscription.ID, []expectedLine{
2877+
{
2878+
Matcher: recurringLineMatcher{
2879+
PhaseKey: "first-phase",
2880+
ItemKey: s.APIRequestsTotalFeature.Key,
2881+
},
2882+
Price: mo.Some(productcatalog.NewPriceFrom(productcatalog.UnitPrice{
2883+
Amount: alpacadecimal.NewFromFloat(10),
2884+
})),
2885+
Periods: []billing.Period{
2886+
{
2887+
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
2888+
End: s.mustParseTime("2024-01-02T00:00:00Z"),
2889+
},
2890+
},
2891+
InvoiceAt: []time.Time{s.mustParseTime("2024-01-02T00:00:00Z")},
2892+
},
2893+
})
2894+
2895+
// Instant invoice should have the in advance fee
2896+
s.expectLines(*instantInvoice, subsView.Subscription.ID, []expectedLine{
2897+
{
2898+
Matcher: recurringLineMatcher{
2899+
PhaseKey: "first-phase",
2900+
ItemKey: "in-advance",
2901+
},
2902+
Qty: mo.Some[float64](1),
2903+
UnitPrice: mo.Some[float64](6),
2904+
Periods: []billing.Period{
2905+
{
2906+
Start: s.mustParseTime("2024-01-01T00:00:00Z"),
2907+
End: s.mustParseTime("2024-01-02T00:00:00Z"),
2908+
},
2909+
},
2910+
InvoiceAt: []time.Time{s.mustParseTime("2024-01-01T00:00:00Z")},
2911+
},
2912+
})
2913+
}
2914+
28072915
func (s *SubscriptionHandlerTestSuite) expectValidationIssueForLine(line *billing.Line, issue billing.ValidationIssue) {
28082916
s.Equal(billing.ValidationIssueSeverityWarning, issue.Severity)
28092917
s.Equal(billing.ImmutableInvoiceHandlingNotSupportedErrorCode, issue.Code)

openmeter/billing/worker/worker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func (w *Worker) eventHandler(opts WorkerOptions) (*grouphandler.NoPublishingHan
142142
opts.Router.MetricMeter,
143143

144144
grouphandler.NewGroupEventHandler(func(ctx context.Context, event *subscription.CreatedEvent) error {
145-
return w.subscriptionHandler.SyncronizeSubscription(ctx, event.SubscriptionView, time.Now())
145+
return w.subscriptionHandler.HandleSubscriptionCreated(ctx, event.SubscriptionView, time.Now())
146146
}),
147147
grouphandler.NewGroupEventHandler(func(ctx context.Context, event *subscription.CancelledEvent) error {
148148
return w.subscriptionHandler.HandleCancelledEvent(ctx, event)

0 commit comments

Comments
 (0)