Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/spec/src/billing/invoices.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ model InvoiceListParams {
enum InvoiceExpand {
lines: "lines",
preceding: "preceding",

/**
* @deprecated We are always expanding the workflow apps.
*/
workflowApps: "workflow.apps",
}

Expand Down
8 changes: 5 additions & 3 deletions app/common/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ func NewAppCustomInvoicingService(logger *slog.Logger, db *entdb.Client, appsCon
}

service, err := appcustominvoicingservice.New(appcustominvoicingservice.Config{
Adapter: appCustomInvoicingAdapter,
Logger: logger,
AppService: appService,
Adapter: appCustomInvoicingAdapter,
Logger: logger,
AppService: appService,
BillingService: billingService,
})
if err != nil {
return nil, fmt.Errorf("failed to create appcustominvoicing service: %w", err)
Expand All @@ -125,6 +126,7 @@ func NewAppCustomInvoicingService(logger *slog.Logger, db *entdb.Client, appsCon
_, err = appcustominvoicing.NewFactory(appcustominvoicing.FactoryConfig{
AppService: appService,
CustomInvoicingService: service,
BillingService: billingService,
})
if err != nil {
return nil, fmt.Errorf("failed to create appcustominvoicing factory: %w", err)
Expand Down
104 changes: 100 additions & 4 deletions openmeter/app/custominvoicing/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,35 @@ package appcustominvoicing
import (
"context"
"fmt"
"strings"

"github.com/openmeterio/openmeter/openmeter/app"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/customer"
customerapp "github.com/openmeterio/openmeter/openmeter/customer/app"
)

var _ customerapp.App = (*App)(nil)
var (
_ customerapp.App = (*App)(nil)
_ billing.InvoicingApp = (*App)(nil)
_ billing.InvoicingAppAsyncSyncer = (*App)(nil)
)

// TODO[later]: Implement invoicing app
// _ billing.InvoicingApp = (*App)(nil)
// _ billing.InvoicingAppPostAdvanceHook = (*App)(nil)
var DefaultInvoiceSequenceNumber = billing.SequenceDefinition{
Template: "INV-{{.CustomerPrefix}}-{{.NextSequenceNumber}}",
Scope: "invoices/custom-invoicing",
}

type Configuration struct {
EnableDraftSyncHook bool `json:"enable_draft_sync_hook"`
EnableIssuingSyncHook bool `json:"enable_issuing_sync_hook"`
}

const (
MetadataKeyDraftSyncedAt = "openmeter.io/custominvoicing/draft-synced-at"
MetadataKeyFinalizedAt = "openmeter.io/custominvoicing/finalized-at"
)

func (c Configuration) Validate() error {
return nil
}
Expand All @@ -29,6 +41,7 @@ type App struct {
Configuration

customInvoicingService Service
billingService billing.Service
}

func (a App) ValidateCustomer(ctx context.Context, customer *customer.Customer, capabilities []app.CapabilityType) error {
Expand All @@ -50,3 +63,86 @@ func (a App) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) err
Configuration: cfg,
})
}

// InvoicingApp
// These are no-ops as whatever is meaningful, is handled via the http driver of the custominvoicing app.

// ValidateInvoice is a no-op as any validation issues are published via the draft.syncing and finalizations syncing
// flow.
func (a App) ValidateInvoice(ctx context.Context, invoice billing.Invoice) error {
return nil
}

func (a App) UpsertInvoice(ctx context.Context, invoice billing.Invoice) (*billing.UpsertInvoiceResult, error) {
return nil, nil
}

func (a App) FinalizeInvoice(ctx context.Context, invoice billing.Invoice) (*billing.FinalizeInvoiceResult, error) {
canAdvance, err := a.CanIssuingSyncAdvance(invoice)
if err != nil {
return nil, err
}

res := billing.NewFinalizeInvoiceResult()

// If we are done with the hook work, let's make sure that the invoice has a non-draft invoice number
if canAdvance {
// If the invoice still has a draft invoice number, let's generate a non-draft one
if strings.HasPrefix(invoice.Number, "DRAFT-") {
invoiceNumber, err := a.billingService.GenerateInvoiceSequenceNumber(ctx,
billing.SequenceGenerationInput{
Namespace: invoice.Namespace,
CustomerName: invoice.Customer.Name,
Currency: invoice.Currency,
},
DefaultInvoiceSequenceNumber,
)
if err != nil {
return nil, fmt.Errorf("generating invoice number: %w", err)
}

res.SetInvoiceNumber(invoiceNumber)
}
}

return res, nil
}

// DeleteInvoice is a no-op as this should happen via the notifications webhook
func (a App) DeleteInvoice(ctx context.Context, invoice billing.Invoice) error {
return nil
}

// InvoicingAppAsyncSyncer

func (a App) CanDraftSyncAdvance(invoice billing.Invoice) (bool, error) {
if !a.Configuration.EnableDraftSyncHook {
return true, nil
}

if invoice.Metadata == nil {
return false, nil
}

if _, ok := invoice.Metadata[MetadataKeyDraftSyncedAt]; ok {
return true, nil
}

return false, nil
}

func (a App) CanIssuingSyncAdvance(invoice billing.Invoice) (bool, error) {
if !a.Configuration.EnableIssuingSyncHook {
return true, nil
}

if invoice.Metadata == nil {
return false, nil
}

if _, ok := invoice.Metadata[MetadataKeyFinalizedAt]; ok {
return true, nil
}

return false, nil
}
9 changes: 9 additions & 0 deletions openmeter/app/custominvoicing/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/openmeterio/openmeter/openmeter/app"
"github.com/openmeterio/openmeter/openmeter/billing"
)

var (
Expand Down Expand Up @@ -47,11 +48,13 @@ var (
type Factory struct {
appService app.Service
customInvoicingService Service
billingService billing.Service
}

type FactoryConfig struct {
AppService app.Service
CustomInvoicingService Service
BillingService billing.Service
}

func (c FactoryConfig) Validate() error {
Expand All @@ -63,6 +66,10 @@ func (c FactoryConfig) Validate() error {
return fmt.Errorf("custom invoicing service is required")
}

if c.BillingService == nil {
return fmt.Errorf("billing service is required")
}

return nil
}

Expand All @@ -74,6 +81,7 @@ func NewFactory(config FactoryConfig) (*Factory, error) {
fact := &Factory{
appService: config.AppService,
customInvoicingService: config.CustomInvoicingService,
billingService: config.BillingService,
}

err := config.AppService.RegisterMarketplaceListing(app.RegistryItem{
Expand All @@ -98,6 +106,7 @@ func (f *Factory) NewApp(ctx context.Context, appBase app.AppBase) (app.App, err
AppBase: appBase,
Configuration: cfg,
customInvoicingService: f.customInvoicingService,
billingService: f.billingService,
}, nil
}

Expand Down
10 changes: 9 additions & 1 deletion openmeter/app/custominvoicing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import (
"context"

"github.com/openmeterio/openmeter/openmeter/app"
"github.com/openmeterio/openmeter/openmeter/billing"
)

type Service interface {
CustomerDataService

FactoryService
SyncService
}

type CustomerDataService interface {
Expand All @@ -24,3 +25,10 @@ type FactoryService interface {
UpsertAppConfiguration(ctx context.Context, input UpsertAppConfigurationInput) error
GetAppConfiguration(ctx context.Context, appID app.AppID) (Configuration, error)
}

type SyncService interface {
SyncDraftInvoice(ctx context.Context, input SyncDraftInvoiceInput) (billing.Invoice, error)
SyncIssuingInvoice(ctx context.Context, input SyncIssuingInvoiceInput) (billing.Invoice, error)

HandlePaymentTrigger(ctx context.Context, input HandlePaymentTriggerInput) (billing.Invoice, error)
}
18 changes: 13 additions & 5 deletions openmeter/app/custominvoicing/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/openmeterio/openmeter/openmeter/app"
appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing"
"github.com/openmeterio/openmeter/openmeter/billing"
)

var _ appcustominvoicing.Service = (*Service)(nil)
Expand All @@ -15,14 +16,16 @@ type Service struct {
logger *slog.Logger

// dependencies
appService app.Service
appService app.Service
billingService billing.Service
}

type Config struct {
Adapter appcustominvoicing.Adapter
Logger *slog.Logger

AppService app.Service
AppService app.Service
BillingService billing.Service
}

func (c Config) Validate() error {
Expand All @@ -38,6 +41,10 @@ func (c Config) Validate() error {
return errors.New("app service cannot be nil")
}

if c.BillingService == nil {
return errors.New("billing service cannot be nil")
}

return nil
}

Expand All @@ -47,8 +54,9 @@ func New(config Config) (*Service, error) {
}

return &Service{
adapter: config.Adapter,
logger: config.Logger,
appService: config.AppService,
adapter: config.Adapter,
logger: config.Logger,
appService: config.AppService,
billingService: config.BillingService,
}, nil
}
86 changes: 86 additions & 0 deletions openmeter/app/custominvoicing/service/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package service

import (
"context"
"time"

"github.com/samber/lo"

"github.com/openmeterio/openmeter/openmeter/app"
appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/framework/transaction"
)

var _ appcustominvoicing.SyncService = (*Service)(nil)

func (s *Service) SyncDraftInvoice(ctx context.Context, input appcustominvoicing.SyncDraftInvoiceInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, err
}

return s.billingService.SyncDraftInvoice(ctx, billing.SyncDraftInvoiceInput{
InvoiceID: input.InvoiceID,
UpsertInvoiceResults: input.UpsertInvoiceResults,
AdditionalMetadata: map[string]string{
appcustominvoicing.MetadataKeyDraftSyncedAt: clock.Now().Format(time.RFC3339),
},
})
}

func (s *Service) SyncIssuingInvoice(ctx context.Context, input appcustominvoicing.SyncIssuingInvoiceInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, err
}

return s.billingService.SyncIssuingInvoice(ctx, billing.SyncIssuingInvoiceInput{
InvoiceID: input.InvoiceID,
FinalizeInvoiceResult: input.FinalizeInvoiceResult,
AdditionalMetadata: map[string]string{
appcustominvoicing.MetadataKeyFinalizedAt: clock.Now().Format(time.RFC3339),
},
})
}

func (s *Service) HandlePaymentTrigger(ctx context.Context, input appcustominvoicing.HandlePaymentTriggerInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, err
}

return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.Invoice, error) {
err := s.billingService.TriggerInvoice(ctx, billing.InvoiceTriggerServiceInput{
InvoiceTriggerInput: billing.InvoiceTriggerInput{
Invoice: input.InvoiceID,
Trigger: input.Trigger,
},
AppType: app.AppTypeCustomInvoicing,
Capability: app.CapabilityTypeCollectPayments,
})
if err != nil {
return billing.Invoice{}, err
}

invoice, err := s.billingService.GetInvoiceByID(ctx, billing.GetInvoiceByIdInput{
Invoice: input.InvoiceID,
})
if err != nil {
return billing.Invoice{}, err
}

if len(invoice.ValidationIssues) > 0 {
criticalIssues := lo.Filter(invoice.ValidationIssues, func(issue billing.ValidationIssue, _ int) bool {
return issue.Severity == billing.ValidationIssueSeverityCritical
})

if len(criticalIssues) > 0 {
// Warning: This causes a rollback of the transaction
return billing.Invoice{}, billing.ValidationError{
Err: criticalIssues.AsError(),
}
}
}

return invoice, nil
})
}
Loading
Loading