Skip to content

Commit

Permalink
feat: support payment states
Browse files Browse the repository at this point in the history
  • Loading branch information
turip committed Jan 31, 2025
1 parent 064237e commit 025ab02
Show file tree
Hide file tree
Showing 14 changed files with 534 additions and 128 deletions.
1 change: 1 addition & 0 deletions api/spec/src/billing/invoices/invoice.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ model InvoiceWorkflowSettings {
workflow: OpenMeter.Billing.BillingWorkflow;
}

// TODO: Update!
/**
* InvoiceStatus describes the status of an invoice.
*/
Expand Down
61 changes: 58 additions & 3 deletions openmeter/app/sandbox/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ import (
"github.com/openmeterio/openmeter/pkg/clock"
)

const (
TargetPaymentStatusMetadataKey = "openmeter.io/sandbox/target-payment-status"

TargetPaymentStatusPaid = "paid"
TargetPaymentStatusFailed = "failed"
TargetPaymentStatusUncollectible = "uncollectible"
TargetPaymentStatusActionRequired = "action_required"
)

var (
_ customerapp.App = (*App)(nil)
_ billing.InvoicingApp = (*App)(nil)
_ appentity.CustomerData = (*CustomerData)(nil)
_ customerapp.App = (*App)(nil)
_ billing.InvoicingApp = (*App)(nil)
_ billing.InvoicingAppPostAdvanceHook = (*App)(nil)
_ appentity.CustomerData = (*CustomerData)(nil)

InvoiceSequenceNumber = billing.SequenceDefinition{
Template: "OM-SANDBOX-{{.CustomerPrefix}}-{{.NextSequenceNumber}}",
Expand Down Expand Up @@ -81,6 +91,51 @@ func (a App) DeleteInvoice(ctx context.Context, invoice billing.Invoice) error {
return nil
}

func (a App) PostAdvanceInvoiceHook(ctx context.Context, invoice billing.Invoice) (*billing.PostAdvanceHookResult, error) {
if invoice.Status != billing.InvoiceStatusPaymentPending {
return nil, nil
}

targetStatus := TargetPaymentStatusPaid

// Allow overriding via metadata for testing (unit, customer) purposes
override, ok := invoice.Metadata[TargetPaymentStatusMetadataKey]
if ok && override != "" {
targetStatus = override
}

out := billing.NewPostAdvanceHookResult()
// Let's simulate the payment status by invoking the right trigger
switch targetStatus {
case TargetPaymentStatusFailed:
return out.InvokeTrigger(billing.InvoiceTriggerInput{
Invoice: invoice.InvoiceID(),
Trigger: billing.TriggerFailed,
ValidationErrors: &billing.InvoiceTriggerValidationInput{
Operation: billing.InvoiceOpInitiatePayment,
Errors: []error{ErrSimulatedPaymentFailure},
},
}), nil
case TargetPaymentStatusUncollectible:
return out.InvokeTrigger(billing.InvoiceTriggerInput{
Invoice: invoice.InvoiceID(),
Trigger: billing.TriggerPaymentUncollectible,
}), nil
case TargetPaymentStatusActionRequired:
return out.InvokeTrigger(billing.InvoiceTriggerInput{
Invoice: invoice.InvoiceID(),
Trigger: billing.TriggerActionRequired,
}), nil
case TargetPaymentStatusPaid:
fallthrough
default:
return out.InvokeTrigger(billing.InvoiceTriggerInput{
Invoice: invoice.InvoiceID(),
Trigger: billing.TriggerPaid,
}), nil
}
}

type CustomerData struct{}

func (c CustomerData) Validate() error {
Expand Down
5 changes: 5 additions & 0 deletions openmeter/app/sandbox/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package appsandbox

import "github.com/openmeterio/openmeter/openmeter/billing"

var ErrSimulatedPaymentFailure = billing.NewValidationError("simulated_payment_failure", "simulated payment failure")
8 changes: 8 additions & 0 deletions openmeter/billing/adapter/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,9 +817,17 @@ func (a *adapter) IsAppUsed(ctx context.Context, appID appentitybase.AppID) (boo
Query().
Where(billinginvoice.Namespace(appID.Namespace)).
Where(
// The non-final states are listed here, so that we can make sure that all
// invoices can reach a final state before the app is removed.
billinginvoice.StatusIn(
billing.InvoiceStatusGathering,
billing.InvoiceStatusIssuingSyncing,
billing.InvoiceStatusIssuingSyncFailed,
billing.InvoiceStatusIssued,
billing.InvoiceStatusPaymentPending,
billing.InvoiceStatusPaymentFailed,
billing.InvoiceStatusPaymentActionRequired,
billing.InvoiceStatusOverdue,
),
).
Where(
Expand Down
36 changes: 36 additions & 0 deletions openmeter/billing/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ func (f *FinalizeInvoiceResult) SetSentToCustomerAt(sentToCustomerAt time.Time)
return f
}

type PostAdvanceHookResult struct {
trigger *InvoiceTriggerInput
}

func NewPostAdvanceHookResult() *PostAdvanceHookResult {
return &PostAdvanceHookResult{}
}

func (p *PostAdvanceHookResult) InvokeTrigger(trigger InvoiceTriggerInput) *PostAdvanceHookResult {
p.trigger = &trigger
return p
}

func (p *PostAdvanceHookResult) GetTriggerToInvoke() *InvoiceTriggerInput {
return p.trigger
}

// InvoicingApp is the interface that should be implemented by the app to handle the invoicing
//
// apps can also implement InvoicingAppPostAdvanceHook to perform additional actions after the invoice
// has been advanced

// Warning: The received invoice is
// - read-only (e.g. any changes made to it are lost to prevent manipulation of the invoice state)
// - reflects the current in memory state of the invoice, thus if you fetched from the db
// an earlier version of the invoice will be passed, thus do not call any billingService methods
// from these callbacks.
type InvoicingApp interface {
// ValidateInvoice validates if the app can run for the given invoice
ValidateInvoice(ctx context.Context, invoice Invoice) error
Expand All @@ -136,6 +163,15 @@ type InvoicingApp interface {
DeleteInvoice(ctx context.Context, invoice Invoice) error
}

type InvoicingAppPostAdvanceHook interface {
// PostAdvanceInvoiceHook is called after the invoice has been advanced to the next stable state
// (e.g. no next trigger is available)
//
// Can be used by the app to perform additional actions in case there are some post-processing steps
// required on the invoice.
PostAdvanceInvoiceHook(ctx context.Context, invoice Invoice) (*PostAdvanceHookResult, error)
}

// GetApp returns the app from the app entity
func GetApp(app appentity.App) (InvoicingApp, error) {
customerApp, ok := app.(InvoicingApp)
Expand Down
114 changes: 110 additions & 4 deletions openmeter/billing/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ func (t InvoiceType) Validate() error {
return fmt.Errorf("invalid invoice type: %s", t)
}

type InvoiceStatusCategory string

const (
InvoiceStatusCategoryGathering InvoiceStatusCategory = "gathering"
InvoiceStatusCategoryDraft InvoiceStatusCategory = "draft"
InvoiceStatusCategoryDelete InvoiceStatusCategory = "delete"
InvoiceStatusCategoryDeleted InvoiceStatusCategory = "deleted"
InvoiceStatusCategoryIssuing InvoiceStatusCategory = "issuing"
InvoiceStatusCategoryIssued InvoiceStatusCategory = "issued"
InvoiceStatusCategorySent InvoiceStatusCategory = "sent"
InvoiceStatusCategoryPayment InvoiceStatusCategory = "payment"
InvoiceStatusCategoryOverdue InvoiceStatusCategory = "overdue"
InvoiceStatusCategoryPaid InvoiceStatusCategory = "paid"
InvoiceStatusCategoryUncollectible InvoiceStatusCategory = "uncollectible"
)

func (s InvoiceStatusCategory) MatchesInvoiceStatus(status InvoiceStatus) bool {
return status.ShortStatus() == string(s)
}

type InvoiceStatus string

const (
Expand All @@ -65,11 +85,22 @@ const (
InvoiceStatusDeleteFailed InvoiceStatus = "delete_failed"
InvoiceStatusDeleted InvoiceStatus = "deleted"

InvoiceStatusIssuing InvoiceStatus = "issuing_syncing"
InvoiceStatusIssuingSyncFailed InvoiceStatus = "issuing_sync_failed"
InvoiceStatusIssuingSyncing InvoiceStatus = "issuing_syncing"
InvoiceStatusIssuingSyncFailed InvoiceStatus = "issuing_failed"

// InvoiceStatusIssued is the status of an invoice that has been issued.
InvoiceStatusIssued InvoiceStatus = "issued"

InvoiceStatusPaymentPending InvoiceStatus = "payment_pending"
InvoiceStatusPaymentFailed InvoiceStatus = "payment_failed"
InvoiceStatusPaymentActionRequired InvoiceStatus = "payment_action_required"

// These are separate statuses to allow for more gradual filtering on the API without having to understand sub-statuses

InvoiceStatusOverdue InvoiceStatus = "overdue"

InvoiceStatusPaid InvoiceStatus = "paid"

InvoiceStatusUncollectible InvoiceStatus = "uncollectible"
)

var validStatuses = []InvoiceStatus{
Expand All @@ -89,9 +120,20 @@ var validStatuses = []InvoiceStatus{
InvoiceStatusDeleteFailed,
InvoiceStatusDeleted,

InvoiceStatusIssuing,
InvoiceStatusIssuingSyncing,
InvoiceStatusIssuingSyncFailed,

InvoiceStatusIssued,

InvoiceStatusPaymentPending,
InvoiceStatusPaymentFailed,
InvoiceStatusPaymentActionRequired,

InvoiceStatusOverdue,

InvoiceStatusPaid,

InvoiceStatusUncollectible,
}

func (s InvoiceStatus) Values() []string {
Expand All @@ -108,10 +150,29 @@ func (s InvoiceStatus) ShortStatus() string {
return parts[0]
}

type InvoiceStatusMatcher interface {
MatchesInvoiceStatus(InvoiceStatus) bool
}

func (s InvoiceStatus) Matches(statuses ...InvoiceStatusMatcher) bool {
for _, matcher := range statuses {
if matcher.MatchesInvoiceStatus(s) {
return true
}
}

return false
}

func (s InvoiceStatus) MatchesInvoiceStatus(status InvoiceStatus) bool {
return s == status
}

var failedStatuses = []InvoiceStatus{
InvoiceStatusDraftSyncFailed,
InvoiceStatusIssuingSyncFailed,
InvoiceStatusDeleteFailed,
InvoiceStatusPaymentFailed,
}

func (s InvoiceStatus) IsFailed() bool {
Expand Down Expand Up @@ -763,3 +824,48 @@ func (i UpsertValidationIssuesInput) Validate() error {

return nil
}

type InvoiceTriggerValidationInput struct {
// Operation specifies the operation that yielded the validation errors
// previous validation errors from this operation will be replaced by this one
Operation InvoiceOperation
Errors []error
}

func (i InvoiceTriggerValidationInput) Validate() error {
if err := i.Operation.Validate(); err != nil {
return fmt.Errorf("operation: %w", err)
}

if len(i.Errors) == 0 {
return errors.New("validation errors are required")
}

return nil
}

type InvoiceTriggerInput struct {
Invoice InvoiceID
// Trigger specifies the trigger that caused the invoice to be changed, only triggerPaid and triggerPayment* are allowed
Trigger InvoiceTrigger

ValidationErrors *InvoiceTriggerValidationInput
}

func (i InvoiceTriggerInput) Validate() error {
if err := i.Invoice.Validate(); err != nil {
return fmt.Errorf("id: %w", err)
}

if i.Trigger == "" {
return errors.New("trigger is required")
}

if i.ValidationErrors != nil {
if err := i.ValidationErrors.Validate(); err != nil {
return fmt.Errorf("validation errors: %w", err)
}
}

return nil
}
69 changes: 69 additions & 0 deletions openmeter/billing/invoicestate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package billing

import (
"fmt"
"slices"

"github.com/qmuntal/stateless"
)

type InvoiceTrigger = stateless.Trigger

var (
// TriggerRetry is used to retry a state transition that failed, used by the end user to invoke it manually
TriggerRetry InvoiceTrigger = "trigger_retry"
// TriggerApprove is used to approve a state manually
TriggerApprove InvoiceTrigger = "trigger_approve"
// TriggerNext is used to advance the invoice to the next state if automatically possible
TriggerNext InvoiceTrigger = "trigger_next"
// TriggerFailed is used to trigger the failure state transition associated with the current state
TriggerFailed InvoiceTrigger = "trigger_failed"
// TriggerUpdated is used to trigger a change in the invoice (we are using this to calculate the immutable states
// and trigger re-validation)
TriggerUpdated InvoiceTrigger = "trigger_updated"
// triggerDelete is used to delete the invoice
TriggerDelete InvoiceTrigger = "trigger_delete"

// TODO[OM-989]: we should have a triggerAsyncNext to signify that a transition should be done asynchronously (
// e.g. the invoice needs to be synced to an external system such as stripe)

// TriggerPaid is used to signify that the invoice has been paid
TriggerPaid InvoiceTrigger = "trigger_paid"
// TriggerActionRequired is used to signify that the invoice requires action
TriggerActionRequired InvoiceTrigger = "trigger_action_required"

// TriggerPaymentUncollectible is used to signify that the invoice is uncollectible
TriggerPaymentUncollectible InvoiceTrigger = "trigger_payment_uncollectible"
// TriggerPaymentOverdue is used to signify that the invoice is overdue
TriggerPaymentOverdue InvoiceTrigger = "trigger_payment_overdue"
)

type InvoiceOperation string

const (
InvoiceOpValidate InvoiceOperation = "validate"
InvoiceOpSync InvoiceOperation = "sync"
InvoiceOpDelete InvoiceOperation = "delete"
InvoiceOpFinalize InvoiceOperation = "finalize"
InvoiceOpInitiatePayment InvoiceOperation = "initiate_payment"

InvoiceOpPostAdvanceHook InvoiceOperation = "post_advance_hook"
)

var InvoiceOperations = []InvoiceOperation{
InvoiceOpValidate,
InvoiceOpSync,
InvoiceOpDelete,
InvoiceOpFinalize,
InvoiceOpInitiatePayment,

InvoiceOpPostAdvanceHook,
}

func (o InvoiceOperation) Validate() error {
if !slices.Contains(InvoiceOperations, o) {
return fmt.Errorf("invalid invoice operation: %s", o)
}

return nil
}
Loading

0 comments on commit 025ab02

Please sign in to comment.