Skip to content

Commit 10cdf05

Browse files
authored
feat: add async invoice collection event (#4625)
1 parent 9cea4ab commit 10cdf05

8 files changed

Lines changed: 210 additions & 1 deletion

File tree

app/common/openmeter_billingworker.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/openmeterio/openmeter/app/config"
1414
billingworker "github.com/openmeterio/openmeter/openmeter/billing/worker"
15+
billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect"
1516
"github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync"
1617
watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka"
1718
"github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
@@ -38,6 +39,7 @@ var BillingWorker = wire.NewSet(
3839

3940
NewBillingWorkerOptions,
4041
NewBillingWorker,
42+
NewBillingCollector,
4143
NewBillingSubscriptionSyncAdapter,
4244
NewBillingSubscriptionSyncService,
4345
BillingWorkerGroup,
@@ -77,6 +79,7 @@ func NewBillingWorkerOptions(
7779
billingRegistry BillingRegistry,
7880
subscriptionServices SubscriptionServiceWithWorkflow,
7981
subscriptionSyncService subscriptionsync.Service,
82+
billingCollector *billingworkercollect.InvoiceCollector,
8083
billingFsConfig config.BillingFeatureSwitchesConfiguration,
8184
logger *slog.Logger,
8285
) billingworker.WorkerOptions {
@@ -86,6 +89,7 @@ func NewBillingWorkerOptions(
8689
Router: routerOptions,
8790
EventBus: eventBus,
8891
BillingService: billingRegistry.Billing,
92+
BillingCollector: billingCollector,
8993
ChargesService: billingRegistry.ChargesServiceOrNil(),
9094
SubscriptionService: subscriptionServices.Service,
9195
BillingSubscriptionSync: subscriptionSyncService,

cmd/billing-worker/wire_gen.go

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package billing
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"github.com/openmeterio/openmeter/openmeter/event/metadata"
9+
"github.com/openmeterio/openmeter/pkg/models"
10+
)
11+
12+
type CollectCustomerInvoicesEvent struct {
13+
Namespace string `json:"namespace"`
14+
CustomerID string `json:"customer_id"`
15+
AsOf time.Time `json:"as_of"`
16+
}
17+
18+
func (e CollectCustomerInvoicesEvent) EventName() string {
19+
return metadata.GetEventName(metadata.EventType{
20+
Subsystem: EventSubsystem,
21+
Name: "invoice.collect",
22+
Version: "v1",
23+
})
24+
}
25+
26+
func (e CollectCustomerInvoicesEvent) EventMetadata() metadata.EventMetadata {
27+
return metadata.EventMetadata{
28+
Source: metadata.ComposeResourcePath(e.Namespace, metadata.EntityCustomer, e.CustomerID),
29+
Subject: metadata.ComposeResourcePath(e.Namespace, metadata.EntityCustomer, e.CustomerID),
30+
}
31+
}
32+
33+
func (e CollectCustomerInvoicesEvent) Validate() error {
34+
var errs []error
35+
36+
if e.Namespace == "" {
37+
errs = append(errs, fmt.Errorf("namespace cannot be empty"))
38+
}
39+
40+
if e.CustomerID == "" {
41+
errs = append(errs, fmt.Errorf("customer_id cannot be empty"))
42+
}
43+
44+
if e.AsOf.IsZero() {
45+
errs = append(errs, fmt.Errorf("as_of cannot be zero"))
46+
}
47+
48+
return models.NewNillableGenericValidationError(errors.Join(errs...))
49+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package billing_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/openmeterio/openmeter/openmeter/billing"
10+
"github.com/openmeterio/openmeter/openmeter/event/metadata"
11+
)
12+
13+
func TestCollectCustomerInvoicesEvent(t *testing.T) {
14+
asOf := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
15+
16+
event := billing.CollectCustomerInvoicesEvent{
17+
Namespace: "ns",
18+
CustomerID: "customer-id",
19+
AsOf: asOf,
20+
}
21+
22+
require.NoError(t, event.Validate())
23+
require.Equal(t, metadata.GetEventName(metadata.EventType{
24+
Subsystem: billing.EventSubsystem,
25+
Name: "invoice.collect",
26+
Version: "v1",
27+
}), event.EventName())
28+
29+
eventMetadata := event.EventMetadata()
30+
require.Equal(t, metadata.ComposeResourcePath("ns", metadata.EntityCustomer, "customer-id"), eventMetadata.Source)
31+
require.Equal(t, metadata.ComposeResourcePath("ns", metadata.EntityCustomer, "customer-id"), eventMetadata.Subject)
32+
}
33+
34+
func TestCollectCustomerInvoicesEventValidate(t *testing.T) {
35+
asOf := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
36+
37+
tests := []struct {
38+
name string
39+
event billing.CollectCustomerInvoicesEvent
40+
wantErr string
41+
}{
42+
{
43+
name: "namespace is required",
44+
event: billing.CollectCustomerInvoicesEvent{
45+
CustomerID: "customer-id",
46+
AsOf: asOf,
47+
},
48+
wantErr: "namespace cannot be empty",
49+
},
50+
{
51+
name: "customer id is required",
52+
event: billing.CollectCustomerInvoicesEvent{
53+
Namespace: "ns",
54+
AsOf: asOf,
55+
},
56+
wantErr: "customer_id cannot be empty",
57+
},
58+
{
59+
name: "as of is required",
60+
event: billing.CollectCustomerInvoicesEvent{
61+
Namespace: "ns",
62+
CustomerID: "customer-id",
63+
},
64+
wantErr: "as_of cannot be zero",
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
require.ErrorContains(t, tt.event.Validate(), tt.wantErr)
71+
})
72+
}
73+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package billingworkercollect
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/openmeterio/openmeter/openmeter/billing"
8+
"github.com/openmeterio/openmeter/openmeter/customer"
9+
)
10+
11+
func (a *InvoiceCollector) HandleCollectCustomerInvoicesEvent(ctx context.Context, event *billing.CollectCustomerInvoicesEvent) error {
12+
if event == nil {
13+
return nil
14+
}
15+
16+
if err := event.Validate(); err != nil {
17+
return fmt.Errorf("invalid collect customer invoices event: %w", err)
18+
}
19+
20+
_, err := a.CollectCustomerInvoice(ctx, CollectCustomerInvoiceInput{
21+
CustomerID: customer.CustomerID{
22+
Namespace: event.Namespace,
23+
ID: event.CustomerID,
24+
},
25+
AsOf: event.AsOf,
26+
})
27+
28+
return err
29+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package billingworkercollect
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/openmeterio/openmeter/openmeter/billing"
9+
)
10+
11+
func TestInvoiceCollectorHandleCollectCustomerInvoicesEvent(t *testing.T) {
12+
collector := &InvoiceCollector{}
13+
14+
t.Run("nil event", func(t *testing.T) {
15+
require.NoError(t, collector.HandleCollectCustomerInvoicesEvent(t.Context(), nil))
16+
})
17+
18+
t.Run("invalid event", func(t *testing.T) {
19+
err := collector.HandleCollectCustomerInvoicesEvent(t.Context(), &billing.CollectCustomerInvoicesEvent{
20+
Namespace: "ns",
21+
CustomerID: "customer-id",
22+
})
23+
24+
require.ErrorContains(t, err, "invalid collect customer invoices event")
25+
require.ErrorContains(t, err, "as_of cannot be zero")
26+
})
27+
}

openmeter/billing/worker/collect/collect.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func (a *InvoiceCollector) CollectCustomerInvoice(ctx context.Context, params Co
106106
ctx,
107107
billing.InvoicePendingLinesInput{
108108
Customer: params.CustomerID,
109+
AsOf: lo.ToPtr(params.AsOf),
109110
},
110111
// We want to make sure that system collection does not use progressive billing.
111112
billing.WithPartialInvoiceLinesDisabled(),

openmeter/billing/worker/worker.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/openmeterio/openmeter/openmeter/billing/charges"
1414
chargesasyncadvance "github.com/openmeterio/openmeter/openmeter/billing/charges/worker/asyncadvance"
1515
"github.com/openmeterio/openmeter/openmeter/billing/worker/asyncadvance"
16+
billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect"
1617
"github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync"
1718
"github.com/openmeterio/openmeter/openmeter/subscription"
1819
"github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
@@ -30,6 +31,7 @@ type WorkerOptions struct {
3031

3132
BillingService billing.Service
3233
BillingSubscriptionSync subscriptionsync.Service
34+
BillingCollector *billingworkercollect.InvoiceCollector
3335
// ChargesService is optional; when non-nil the worker handles AdvanceChargesEvent.
3436
ChargesService charges.ChargeService
3537
// External connectors
@@ -67,6 +69,10 @@ func (w WorkerOptions) Validate() error {
6769
return fmt.Errorf("billing subscription sync handler is required")
6870
}
6971

72+
if w.BillingCollector == nil {
73+
return fmt.Errorf("billing collector is required")
74+
}
75+
7076
return nil
7177
}
7278

@@ -75,6 +81,7 @@ type Worker struct {
7581

7682
billingService billing.Service
7783
subscriptionSync subscriptionsync.Service
84+
invoiceCollector *billingworkercollect.InvoiceCollector
7885
asyncAdvanceHandler *asyncadvance.Handler
7986
asyncAdvanceChargesHandler *chargesasyncadvance.Handler
8087
nonPublishingHandler *grouphandler.NoPublishingHandler
@@ -109,6 +116,7 @@ func New(opts WorkerOptions) (*Worker, error) {
109116
worker := &Worker{
110117
billingService: opts.BillingService,
111118
subscriptionSync: opts.BillingSubscriptionSync,
119+
invoiceCollector: opts.BillingCollector,
112120
asyncAdvanceHandler: asyncAdvancer,
113121
asyncAdvanceChargesHandler: asyncAdvanceChargesHandler,
114122
lockdownNamespaces: opts.LockdownNamespaces,
@@ -199,6 +207,13 @@ func (w *Worker) eventHandler(opts WorkerOptions) (*grouphandler.NoPublishingHan
199207

200208
return w.asyncAdvanceHandler.Handle(ctx, event)
201209
}),
210+
grouphandler.NewGroupEventHandler(func(ctx context.Context, event *billing.CollectCustomerInvoicesEvent) error {
211+
if event != nil && slices.Contains(w.lockdownNamespaces, event.Namespace) {
212+
return nil
213+
}
214+
215+
return w.invoiceCollector.HandleCollectCustomerInvoicesEvent(ctx, event)
216+
}),
202217
grouphandler.NewGroupEventHandler(func(ctx context.Context, event *charges.AdvanceChargesEvent) error {
203218
if w.asyncAdvanceChargesHandler == nil {
204219
return nil

0 commit comments

Comments
 (0)