From 025ab028f68678ef1581be029240bc9426036d4c Mon Sep 17 00:00:00 2001 From: Peter Turi Date: Fri, 31 Jan 2025 14:17:47 +0100 Subject: [PATCH] feat: support payment states --- api/spec/src/billing/invoices/invoice.tsp | 1 + openmeter/app/sandbox/app.go | 61 +++- openmeter/app/sandbox/errors.go | 5 + openmeter/billing/adapter/invoice.go | 8 + openmeter/billing/app.go | 36 +++ openmeter/billing/invoice.go | 114 +++++++- openmeter/billing/invoicestate.go | 69 +++++ openmeter/billing/service/invoice.go | 12 +- openmeter/billing/service/invoicestate.go | 267 +++++++++++------- openmeter/billing/validationissue.go | 2 +- .../billing/worker/subscription/sync_test.go | 6 +- .../ent/db/billinginvoice/billinginvoice.go | 2 +- openmeter/ent/db/migrate/schema.go | 2 +- test/billing/invoice_test.go | 77 ++++- 14 files changed, 534 insertions(+), 128 deletions(-) create mode 100644 openmeter/app/sandbox/errors.go create mode 100644 openmeter/billing/invoicestate.go diff --git a/api/spec/src/billing/invoices/invoice.tsp b/api/spec/src/billing/invoices/invoice.tsp index 25b5f128f..42c3f0df2 100644 --- a/api/spec/src/billing/invoices/invoice.tsp +++ b/api/spec/src/billing/invoices/invoice.tsp @@ -335,6 +335,7 @@ model InvoiceWorkflowSettings { workflow: OpenMeter.Billing.BillingWorkflow; } +// TODO: Update! /** * InvoiceStatus describes the status of an invoice. */ diff --git a/openmeter/app/sandbox/app.go b/openmeter/app/sandbox/app.go index 2b63a09f9..45030763b 100644 --- a/openmeter/app/sandbox/app.go +++ b/openmeter/app/sandbox/app.go @@ -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}}", @@ -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 { diff --git a/openmeter/app/sandbox/errors.go b/openmeter/app/sandbox/errors.go new file mode 100644 index 000000000..152dd6d1e --- /dev/null +++ b/openmeter/app/sandbox/errors.go @@ -0,0 +1,5 @@ +package appsandbox + +import "github.com/openmeterio/openmeter/openmeter/billing" + +var ErrSimulatedPaymentFailure = billing.NewValidationError("simulated_payment_failure", "simulated payment failure") diff --git a/openmeter/billing/adapter/invoice.go b/openmeter/billing/adapter/invoice.go index 04f2b450b..d98271659 100644 --- a/openmeter/billing/adapter/invoice.go +++ b/openmeter/billing/adapter/invoice.go @@ -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( diff --git a/openmeter/billing/app.go b/openmeter/billing/app.go index 98d23a4e4..bf9fc325d 100644 --- a/openmeter/billing/app.go +++ b/openmeter/billing/app.go @@ -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 @@ -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) diff --git a/openmeter/billing/invoice.go b/openmeter/billing/invoice.go index a8b8c3806..ea5668890 100644 --- a/openmeter/billing/invoice.go +++ b/openmeter/billing/invoice.go @@ -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 ( @@ -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{ @@ -89,9 +120,20 @@ var validStatuses = []InvoiceStatus{ InvoiceStatusDeleteFailed, InvoiceStatusDeleted, - InvoiceStatusIssuing, + InvoiceStatusIssuingSyncing, InvoiceStatusIssuingSyncFailed, + InvoiceStatusIssued, + + InvoiceStatusPaymentPending, + InvoiceStatusPaymentFailed, + InvoiceStatusPaymentActionRequired, + + InvoiceStatusOverdue, + + InvoiceStatusPaid, + + InvoiceStatusUncollectible, } func (s InvoiceStatus) Values() []string { @@ -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 { @@ -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 +} diff --git a/openmeter/billing/invoicestate.go b/openmeter/billing/invoicestate.go new file mode 100644 index 000000000..1475b6ab0 --- /dev/null +++ b/openmeter/billing/invoicestate.go @@ -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 +} diff --git a/openmeter/billing/service/invoice.go b/openmeter/billing/service/invoice.go index c7736be38..63ee5e694 100644 --- a/openmeter/billing/service/invoice.go +++ b/openmeter/billing/service/invoice.go @@ -581,7 +581,7 @@ func (s *Service) AdvanceInvoice(ctx context.Context, input billing.AdvanceInvoi Callback: func(ctx context.Context, sm *InvoiceStateMachine) error { preActivationStatus := sm.Invoice.Status - canAdvance, err := sm.CanFire(ctx, triggerNext) + canAdvance, err := sm.CanFire(ctx, billing.TriggerNext) if err != nil { return fmt.Errorf("checking if can advance: %w", err) } @@ -616,7 +616,7 @@ func (s *Service) AdvanceInvoice(ctx context.Context, input billing.AdvanceInvoi } func (s *Service) ApproveInvoice(ctx context.Context, input billing.ApproveInvoiceInput) (billing.Invoice, error) { - return s.executeTriggerOnInvoice(ctx, input, triggerApprove) + return s.executeTriggerOnInvoice(ctx, input, billing.TriggerApprove) } func (s *Service) RetryInvoice(ctx context.Context, input billing.RetryInvoiceInput) (billing.Invoice, error) { @@ -644,7 +644,7 @@ func (s *Service) RetryInvoice(ctx context.Context, input billing.RetryInvoiceIn return billing.Invoice{}, fmt.Errorf("updating invoice: %w", err) } - return s.executeTriggerOnInvoice(ctx, input, triggerRetry) + return s.executeTriggerOnInvoice(ctx, input, billing.TriggerRetry) }) } @@ -775,7 +775,7 @@ func (s *Service) DeleteInvoice(ctx context.Context, input billing.DeleteInvoice } } - invoice, err := s.executeTriggerOnInvoice(ctx, input, triggerDelete) + invoice, err := s.executeTriggerOnInvoice(ctx, input, billing.TriggerDelete) if err != nil { return err } @@ -876,7 +876,7 @@ func (s *Service) UpdateInvoice(ctx context.Context, input billing.UpdateInvoice return s.executeTriggerOnInvoice( ctx, input.Invoice, - triggerUpdated, + billing.TriggerUpdated, ExecuteTriggerWithIncludeDeletedLines(input.IncludeDeletedLines), ExecuteTriggerWithAllowInStates(billing.InvoiceStatusDraftUpdating), ExecuteTriggerWithEditCallback(func(sm *InvoiceStateMachine) error { @@ -933,7 +933,7 @@ func (s Service) checkIfLinesAreInvoicable(ctx context.Context, invoice *billing } period, err := lineSvc.CanBeInvoicedAsOf(ctx, lineservice.CanBeInvoicedAsOfInput{ - AsOf: line.Period.End, + AsOf: line.InvoiceAt, ProgressiveBilling: progressiveBilling, }) if err != nil { diff --git a/openmeter/billing/service/invoicestate.go b/openmeter/billing/service/invoicestate.go index 797e36990..e61891444 100644 --- a/openmeter/billing/service/invoicestate.go +++ b/openmeter/billing/service/invoicestate.go @@ -22,32 +22,6 @@ type InvoiceStateMachine struct { StateMachine *stateless.StateMachine } -var ( - // triggerRetry is used to retry a state transition that failed, used by the end user to invoke it manually - triggerRetry stateless.Trigger = "trigger_retry" - // triggerApprove is used to approve a state manually - triggerApprove stateless.Trigger = "trigger_approve" - // triggerNext is used to advance the invoice to the next state if automatically possible - triggerNext stateless.Trigger = "trigger_next" - // triggerFailed is used to trigger the failure state transition associated with the current state - triggerFailed stateless.Trigger = "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 stateless.Trigger = "trigger_updated" - // triggerDelete is used to delete the invoice - triggerDelete stateless.Trigger = "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) -) - -const ( - opValidate = "validate" - opSync = "sync" - opDelete = "delete" - opFinalize = "finalize" -) - var invoiceStateMachineCache = sync.Pool{ New: func() interface{} { return allocateStateMachine() @@ -89,17 +63,17 @@ func allocateStateMachine() *InvoiceStateMachine { // NOTE: we are not using the substate support of stateless for now, as the // substate inherits all the parent's state transitions resulting in unexpected behavior ( - // e.g. allowing triggerNext on the "superstate" causes all substates to have triggerNext). + // e.g. allowing billing.TriggerNext on the "superstate" causes all substates to have billing.TriggerNext). stateMachine.Configure(billing.InvoiceStatusDraftCreated). - Permit(triggerNext, billing.InvoiceStatusDraftValidating). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating). + Permit(billing.TriggerNext, billing.InvoiceStatusDraftValidating). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating). OnActive(out.calculateInvoice) stateMachine.Configure(billing.InvoiceStatusDraftUpdating). - Permit(triggerNext, billing.InvoiceStatusDraftValidating). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerNext, billing.InvoiceStatusDraftValidating). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). OnActive( allOf( out.calculateInvoice, @@ -109,58 +83,58 @@ func allocateStateMachine() *InvoiceStateMachine { stateMachine.Configure(billing.InvoiceStatusDraftValidating). Permit( - triggerNext, + billing.TriggerNext, billing.InvoiceStatusDraftSyncing, boolFn(out.noCriticalValidationErrors), ). - Permit(triggerFailed, billing.InvoiceStatusDraftInvalid). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerFailed, billing.InvoiceStatusDraftInvalid). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). // NOTE: we should permit update here, but stateless doesn't allow transitions to the same state - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating). OnActive(allOf( out.calculateInvoice, out.validateDraftInvoice, )) stateMachine.Configure(billing.InvoiceStatusDraftInvalid). - Permit(triggerRetry, billing.InvoiceStatusDraftValidating). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating) + Permit(billing.TriggerRetry, billing.InvoiceStatusDraftValidating). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating) stateMachine.Configure(billing.InvoiceStatusDraftSyncing). Permit( - triggerNext, + billing.TriggerNext, billing.InvoiceStatusDraftManualApprovalNeeded, boolFn(not(out.isAutoAdvanceEnabled)), boolFn(out.noCriticalValidationErrors), ). Permit( - triggerNext, + billing.TriggerNext, billing.InvoiceStatusDraftWaitingAutoApproval, boolFn(out.isAutoAdvanceEnabled), boolFn(out.noCriticalValidationErrors), ). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerFailed, billing.InvoiceStatusDraftSyncFailed). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerFailed, billing.InvoiceStatusDraftSyncFailed). OnActive(out.syncDraftInvoice) stateMachine.Configure(billing.InvoiceStatusDraftSyncFailed). - Permit(triggerRetry, billing.InvoiceStatusDraftValidating). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating) + Permit(billing.TriggerRetry, billing.InvoiceStatusDraftValidating). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating) stateMachine.Configure(billing.InvoiceStatusDraftReadyToIssue). - Permit(triggerNext, billing.InvoiceStatusIssuing). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating) + Permit(billing.TriggerNext, billing.InvoiceStatusIssuingSyncing). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating) // Automatic and manual approvals stateMachine.Configure(billing.InvoiceStatusDraftWaitingAutoApproval). // Manual approval forces the draft invoice to be issued regardless of the review period - Permit(triggerApprove, billing.InvoiceStatusDraftReadyToIssue). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerNext, + Permit(billing.TriggerApprove, billing.InvoiceStatusDraftReadyToIssue). + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerNext, billing.InvoiceStatusDraftReadyToIssue, boolFn(out.shouldAutoAdvance), boolFn(out.noCriticalValidationErrors), @@ -169,42 +143,79 @@ func allocateStateMachine() *InvoiceStateMachine { // This state is a pre-issuing state where we can halt the execution and execute issuing in the background // if needed stateMachine.Configure(billing.InvoiceStatusDraftManualApprovalNeeded). - Permit(triggerApprove, + Permit(billing.TriggerApprove, billing.InvoiceStatusDraftReadyToIssue, boolFn(out.noCriticalValidationErrors), ). - Permit(triggerUpdated, billing.InvoiceStatusDraftUpdating) + Permit(billing.TriggerUpdated, billing.InvoiceStatusDraftUpdating) // Deletion state stateMachine.Configure(billing.InvoiceStatusDeleteInProgress). - Permit(triggerNext, billing.InvoiceStatusDeleteSyncing). - Permit(triggerFailed, billing.InvoiceStatusDeleteFailed). + Permit(billing.TriggerNext, billing.InvoiceStatusDeleteSyncing). + Permit(billing.TriggerFailed, billing.InvoiceStatusDeleteFailed). OnActive(out.deleteInvoice) stateMachine.Configure(billing.InvoiceStatusDeleteSyncing). - Permit(triggerNext, billing.InvoiceStatusDeleted). - Permit(triggerFailed, billing.InvoiceStatusDeleteFailed). + Permit(billing.TriggerNext, billing.InvoiceStatusDeleted). + Permit(billing.TriggerFailed, billing.InvoiceStatusDeleteFailed). OnActive(out.syncDeletedInvoice) stateMachine.Configure(billing.InvoiceStatusDeleteFailed). - Permit(triggerRetry, billing.InvoiceStatusDeleteInProgress) + Permit(billing.TriggerRetry, billing.InvoiceStatusDeleteInProgress) stateMachine.Configure(billing.InvoiceStatusDeleted) // Issuing state - stateMachine.Configure(billing.InvoiceStatusIssuing). - Permit(triggerNext, billing.InvoiceStatusIssued). - Permit(triggerFailed, billing.InvoiceStatusIssuingSyncFailed). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). + stateMachine.Configure(billing.InvoiceStatusIssuingSyncing). + Permit(billing.TriggerNext, billing.InvoiceStatusIssued). // TODO: Do we need the interim state? + Permit(billing.TriggerFailed, billing.InvoiceStatusIssuingSyncFailed). + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). OnActive(out.finalizeInvoice) stateMachine.Configure(billing.InvoiceStatusIssuingSyncFailed). - Permit(triggerDelete, billing.InvoiceStatusDeleteInProgress). - Permit(triggerRetry, billing.InvoiceStatusIssuing) - - // Issued state (final) - stateMachine.Configure(billing.InvoiceStatusIssued) + Permit(billing.TriggerDelete, billing.InvoiceStatusDeleteInProgress). + Permit(billing.TriggerRetry, billing.InvoiceStatusIssuingSyncing) + + // Issued state + stateMachine.Configure(billing.InvoiceStatusIssued). + Permit(billing.TriggerNext, billing.InvoiceStatusPaymentPending) + + // Payment states + stateMachine.Configure(billing.InvoiceStatusPaymentPending). + Permit(billing.TriggerPaid, billing.InvoiceStatusPaid). + Permit(billing.TriggerFailed, billing.InvoiceStatusPaymentFailed). + Permit(billing.TriggerPaymentUncollectible, billing.InvoiceStatusUncollectible). + Permit(billing.TriggerPaymentOverdue, billing.InvoiceStatusOverdue). + Permit(billing.TriggerActionRequired, billing.InvoiceStatusPaymentActionRequired) + + stateMachine.Configure(billing.InvoiceStatusPaymentFailed). + Permit(billing.TriggerPaid, billing.InvoiceStatusPaid). + Permit(billing.TriggerRetry, billing.InvoiceStatusPaymentPending). + Permit(billing.TriggerPaymentOverdue, billing.InvoiceStatusOverdue). + Permit(billing.TriggerPaymentUncollectible, billing.InvoiceStatusUncollectible). + Permit(billing.TriggerActionRequired, billing.InvoiceStatusPaymentActionRequired) + + stateMachine.Configure(billing.InvoiceStatusPaymentActionRequired). + Permit(billing.TriggerPaid, billing.InvoiceStatusPaid). + Permit(billing.TriggerFailed, billing.InvoiceStatusPaymentFailed). + Permit(billing.TriggerRetry, billing.InvoiceStatusPaymentPending). + Permit(billing.TriggerPaymentOverdue, billing.InvoiceStatusOverdue). + Permit(billing.TriggerPaymentUncollectible, billing.InvoiceStatusUncollectible) + + // Payment overdue state + + stateMachine.Configure(billing.InvoiceStatusOverdue). + Permit(billing.TriggerPaid, billing.InvoiceStatusPaid). + Permit(billing.TriggerFailed, billing.InvoiceStatusPaymentFailed). + Permit(billing.TriggerRetry, billing.InvoiceStatusPaymentPending). + Permit(billing.TriggerPaymentUncollectible, billing.InvoiceStatusUncollectible). + Permit(billing.TriggerActionRequired, billing.InvoiceStatusPaymentActionRequired) + + // Final payment states + stateMachine.Configure(billing.InvoiceStatusPaid) + + stateMachine.Configure(billing.InvoiceStatusUncollectible) out.StateMachine = stateMachine @@ -260,23 +271,23 @@ func (m *InvoiceStateMachine) StatusDetails(ctx context.Context) (billing.Invoic var outErr, err error availableActions := billing.InvoiceAvailableActions{} - if availableActions.Advance, err = m.calculateAvailableActionDetails(ctx, triggerNext); err != nil { + if availableActions.Advance, err = m.calculateAvailableActionDetails(ctx, billing.TriggerNext); err != nil { outErr = errors.Join(outErr, err) } - if availableActions.Delete, err = m.calculateAvailableActionDetails(ctx, triggerDelete); err != nil { + if availableActions.Delete, err = m.calculateAvailableActionDetails(ctx, billing.TriggerDelete); err != nil { outErr = errors.Join(outErr, err) } - if availableActions.Retry, err = m.calculateAvailableActionDetails(ctx, triggerRetry); err != nil { + if availableActions.Retry, err = m.calculateAvailableActionDetails(ctx, billing.TriggerRetry); err != nil { outErr = errors.Join(outErr, err) } - if availableActions.Approve, err = m.calculateAvailableActionDetails(ctx, triggerApprove); err != nil { + if availableActions.Approve, err = m.calculateAvailableActionDetails(ctx, billing.TriggerApprove); err != nil { outErr = errors.Join(outErr, err) } - mutable, err := m.StateMachine.CanFireCtx(ctx, triggerUpdated) + mutable, err := m.StateMachine.CanFireCtx(ctx, billing.TriggerUpdated) if err != nil { outErr = errors.Join(outErr, err) } @@ -290,7 +301,7 @@ func (m *InvoiceStateMachine) StatusDetails(ctx context.Context) (billing.Invoic }, outErr } -func (m *InvoiceStateMachine) calculateAvailableActionDetails(ctx context.Context, baseTrigger stateless.Trigger) (*billing.InvoiceAvailableActionDetails, error) { +func (m *InvoiceStateMachine) calculateAvailableActionDetails(ctx context.Context, baseTrigger billing.InvoiceTrigger) (*billing.InvoiceAvailableActionDetails, error) { ok, err := m.StateMachine.CanFireCtx(ctx, baseTrigger) if err != nil { return nil, err @@ -313,7 +324,7 @@ func (m *InvoiceStateMachine) calculateAvailableActionDetails(ctx context.Contex } for { - canFire, err := m.StateMachine.CanFireCtx(ctx, triggerNext) + canFire, err := m.StateMachine.CanFireCtx(ctx, billing.TriggerNext) if err != nil { return nil, err } @@ -322,7 +333,7 @@ func (m *InvoiceStateMachine) calculateAvailableActionDetails(ctx context.Contex break } - if err := m.StateMachine.FireCtx(ctx, triggerNext); err != nil { + if err := m.StateMachine.FireCtx(ctx, billing.TriggerNext); err != nil { return nil, err } } @@ -338,30 +349,34 @@ func (m *InvoiceStateMachine) calculateAvailableActionDetails(ctx context.Contex func (m *InvoiceStateMachine) AdvanceUntilStateStable(ctx context.Context) error { for { - canFire, err := m.StateMachine.CanFireCtx(ctx, triggerNext) + canFire, err := m.StateMachine.CanFireCtx(ctx, billing.TriggerNext) if err != nil { return err } // We have reached a state that requires either manual intervention or that is final if !canFire { + if err := m.triggerPostAdvanceHooks(ctx); err != nil { + return err + } + return m.Invoice.ValidationIssues.AsError() } - if err := m.FireAndActivate(ctx, triggerNext); err != nil { + if err := m.FireAndActivate(ctx, billing.TriggerNext); err != nil { return fmt.Errorf("cannot transition to the next status [current_status=%s]: %w", m.Invoice.Status, err) } } } -func (m *InvoiceStateMachine) CanFire(ctx context.Context, trigger stateless.Trigger) (bool, error) { +func (m *InvoiceStateMachine) CanFire(ctx context.Context, trigger billing.InvoiceTrigger) (bool, error) { return m.StateMachine.CanFireCtx(ctx, trigger) } // FireAndActivate fires the trigger and activates the new state, if activation fails it automatically // transitions to the failed state and activates that. // In addition to the activation a calculation is always performed to ensure that the invoice is up to date. -func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger stateless.Trigger) error { +func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger billing.InvoiceTrigger) error { if err := m.StateMachine.FireCtx(ctx, trigger); err != nil { return err } @@ -371,7 +386,7 @@ func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger state validationIssues := m.Invoice.ValidationIssues.Clone() // There was an error activating the state, we should trigger a transition to the failed state - canFire, err := m.StateMachine.CanFireCtx(ctx, triggerFailed) + canFire, err := m.StateMachine.CanFireCtx(ctx, billing.TriggerFailed) if err != nil { return fmt.Errorf("failed to check if we can transition to failed state: %w", err) } @@ -380,7 +395,7 @@ func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger state return fmt.Errorf("cannot move into failed state: %w", activationError) } - if err := m.StateMachine.FireCtx(ctx, triggerFailed); err != nil { + if err := m.StateMachine.FireCtx(ctx, billing.TriggerFailed); err != nil { return fmt.Errorf("failed to transition to failed state: %w", err) } @@ -394,7 +409,7 @@ func (m *InvoiceStateMachine) FireAndActivate(ctx context.Context, trigger state return nil } -func (m *InvoiceStateMachine) withInvoicingApp(op string, cb func(billing.InvoicingApp) error) error { +func (m *InvoiceStateMachine) withInvoicingApp(op billing.InvoiceOperation, cb func(billing.InvoicingApp) (*billing.InvoiceOperation, error)) error { invocingBase := m.Invoice.Workflow.Apps.Invoicing invoicingApp, ok := invocingBase.(billing.InvoicingApp) if !ok { @@ -404,8 +419,15 @@ func (m *InvoiceStateMachine) withInvoicingApp(op string, cb func(billing.Invoic m.Invoice.Workflow.Apps.Invoicing.GetID().ID) } + opOverride, result := cb(invoicingApp) + if opOverride != nil { + op = *opOverride + if err := op.Validate(); err != nil { + return err + } + } + component := billing.AppTypeCapabilityToComponent(invocingBase.GetType(), appentitybase.CapabilityTypeInvoiceCustomers, op) - result := cb(invoicingApp) // Anything returned by the validation is considered a validation issue, thus in case of an error // we wouldn't roll back the state transitions. @@ -418,14 +440,63 @@ func (m *InvoiceStateMachine) withInvoicingApp(op string, cb func(billing.Invoic ) } +func (m *InvoiceStateMachine) triggerPostAdvanceHooks(ctx context.Context) error { + return m.withInvoicingApp(billing.InvoiceOpPostAdvanceHook, func(app billing.InvoicingApp) (*billing.InvoiceOperation, error) { + if hook, ok := app.(billing.InvoicingAppPostAdvanceHook); ok { + res, err := hook.PostAdvanceInvoiceHook(ctx, m.Invoice.Clone()) + if err != nil { + return nil, err + } + + if res == nil { + return nil, nil + } + + var opOverride *billing.InvoiceOperation + if trigger := res.GetTriggerToInvoke(); trigger != nil { + if trigger.ValidationErrors != nil { + opOverride = &trigger.ValidationErrors.Operation + } + + return opOverride, m.handleInvoiceTrigger(ctx, *trigger) + } + + return opOverride, nil + } + + return nil, nil + }) +} + +func (m *InvoiceStateMachine) handleInvoiceTrigger(ctx context.Context, trigger billing.InvoiceTriggerInput) error { + if err := trigger.Validate(); err != nil { + return err + } + + if trigger.Invoice != m.Invoice.InvoiceID() { + return fmt.Errorf("trigger invoice ID does not match the current invoice ID") + } + + err := m.FireAndActivate(ctx, trigger.Trigger) + if err != nil { + return err + } + + if trigger.ValidationErrors != nil { + return errors.Join(trigger.ValidationErrors.Errors...) + } + + return nil +} + func (m *InvoiceStateMachine) mergeUpsertInvoiceResult(result *billing.UpsertInvoiceResult) error { return billing.MergeUpsertInvoiceResult(&m.Invoice, result) } // validateDraftInvoice validates the draft invoice using the apps referenced in the invoice. func (m *InvoiceStateMachine) validateDraftInvoice(ctx context.Context) error { - return m.withInvoicingApp(opValidate, func(app billing.InvoicingApp) error { - return app.ValidateInvoice(ctx, m.Invoice.Clone()) + return m.withInvoicingApp(billing.InvoiceOpValidate, func(app billing.InvoicingApp) (*billing.InvoiceOperation, error) { + return nil, app.ValidateInvoice(ctx, m.Invoice.Clone()) }) } @@ -435,39 +506,39 @@ func (m *InvoiceStateMachine) calculateInvoice(ctx context.Context) error { // syncDraftInvoice syncs the draft invoice with the external system. func (m *InvoiceStateMachine) syncDraftInvoice(ctx context.Context) error { - return m.withInvoicingApp(opSync, func(app billing.InvoicingApp) error { + return m.withInvoicingApp(billing.InvoiceOpSync, func(app billing.InvoicingApp) (*billing.InvoiceOperation, error) { results, err := app.UpsertInvoice(ctx, m.Invoice.Clone()) if err != nil { - return err + return nil, err } if results == nil { - return nil + return nil, nil } - return m.mergeUpsertInvoiceResult(results) + return nil, m.mergeUpsertInvoiceResult(results) }) } // finalizeInvoice finalizes the invoice using the invoicing app and payment app (later). func (m *InvoiceStateMachine) finalizeInvoice(ctx context.Context) error { - return m.withInvoicingApp(opFinalize, func(app billing.InvoicingApp) error { + return m.withInvoicingApp(billing.InvoiceOpFinalize, func(app billing.InvoicingApp) (*billing.InvoiceOperation, error) { clonedInvoice := m.Invoice.Clone() // First we sync the invoice upsertResults, err := app.UpsertInvoice(ctx, clonedInvoice) if err != nil { - return err + return nil, err } if upsertResults != nil { if err := m.mergeUpsertInvoiceResult(upsertResults); err != nil { - return err + return nil, err } } results, err := app.FinalizeInvoice(ctx, clonedInvoice) if err != nil { - return err + return nil, err } if results != nil { @@ -484,14 +555,14 @@ func (m *InvoiceStateMachine) finalizeInvoice(ctx context.Context) error { } } - return nil + return nil, nil }) } // syncDeletedInvoice syncs the deleted invoice with the external system func (m *InvoiceStateMachine) syncDeletedInvoice(ctx context.Context) error { - return m.withInvoicingApp(opDelete, func(app billing.InvoicingApp) error { - return app.DeleteInvoice(ctx, m.Invoice.Clone()) + return m.withInvoicingApp(billing.InvoiceOpDelete, func(app billing.InvoicingApp) (*billing.InvoiceOperation, error) { + return nil, app.DeleteInvoice(ctx, m.Invoice.Clone()) }) } diff --git a/openmeter/billing/validationissue.go b/openmeter/billing/validationissue.go index 462a5d056..d0ea1870b 100644 --- a/openmeter/billing/validationissue.go +++ b/openmeter/billing/validationissue.go @@ -82,7 +82,7 @@ func NewValidationError(code, message string) ValidationIssue { type ComponentName string -func AppTypeCapabilityToComponent(appType appentitybase.AppType, cap appentitybase.CapabilityType, op string) ComponentName { +func AppTypeCapabilityToComponent(appType appentitybase.AppType, cap appentitybase.CapabilityType, op InvoiceOperation) ComponentName { return ComponentName(fmt.Sprintf("app.%s.%s.%s", appType, cap, op)) } diff --git a/openmeter/billing/worker/subscription/sync_test.go b/openmeter/billing/worker/subscription/sync_test.go index 41cc40739..b746c2c35 100644 --- a/openmeter/billing/worker/subscription/sync_test.go +++ b/openmeter/billing/worker/subscription/sync_test.go @@ -1278,7 +1278,7 @@ func (s *SubscriptionHandlerTestSuite) TestInAdvanceGatheringSyncIssuedInvoicePr approvedInvoice, err := s.BillingService.ApproveInvoice(ctx, draftInvoice.InvoiceID()) s.NoError(err) - s.Equal(billing.InvoiceStatusIssued, approvedInvoice.Status) + s.Equal(billing.InvoiceStatusPaid, approvedInvoice.Status) s.expectLines(approvedInvoice, subsView.Subscription.ID, []expectedLine{ { @@ -1854,7 +1854,7 @@ func (s *SubscriptionHandlerTestSuite) TestUsageBasedGatheringUpdateIssuedInvoic issuedInvoice, err := s.BillingService.ApproveInvoice(ctx, draftInvoice.InvoiceID()) s.NoError(err) - s.Equal(billing.InvoiceStatusIssued, issuedInvoice.Status) + s.Equal(billing.InvoiceStatusPaid, issuedInvoice.Status) s.Len(issuedInvoice.ValidationIssues, 0) s.DebugDumpInvoice("issued invoice", issuedInvoice) s.expectLines(issuedInvoice, subsView.Subscription.ID, []expectedLine{ @@ -2006,7 +2006,7 @@ func (s *SubscriptionHandlerTestSuite) TestUsageBasedUpdateWithLineSplits() { invoice1, err := s.BillingService.ApproveInvoice(ctx, draftInvoices1[0].InvoiceID()) s.NoError(err) - s.Equal(billing.InvoiceStatusIssued, invoice1.Status) + s.Equal(billing.InvoiceStatusPaid, invoice1.Status) s.populateChildIDsFromParents(&invoice1) s.DebugDumpInvoice("issued invoice1", invoice1) diff --git a/openmeter/ent/db/billinginvoice/billinginvoice.go b/openmeter/ent/db/billinginvoice/billinginvoice.go index 5b87af8a1..d9fc9a905 100644 --- a/openmeter/ent/db/billinginvoice/billinginvoice.go +++ b/openmeter/ent/db/billinginvoice/billinginvoice.go @@ -311,7 +311,7 @@ func TypeValidator(_type billing.InvoiceType) error { // StatusValidator is a validator for the "status" field enum values. It is called by the builders before save. func StatusValidator(s billing.InvoiceStatus) error { switch s { - case "gathering", "draft_created", "draft_updating", "draft_manual_approval_needed", "draft_validating", "draft_invalid", "draft_syncing", "draft_sync_failed", "draft_waiting_auto_approval", "draft_ready_to_issue", "delete_in_progress", "delete_syncing", "delete_failed", "deleted", "issuing_syncing", "issuing_sync_failed", "issued": + case "gathering", "draft_created", "draft_updating", "draft_manual_approval_needed", "draft_validating", "draft_invalid", "draft_syncing", "draft_sync_failed", "draft_waiting_auto_approval", "draft_ready_to_issue", "delete_in_progress", "delete_syncing", "delete_failed", "deleted", "issuing_syncing", "issuing_failed", "issued", "payment_pending", "payment_failed", "payment_action_required", "overdue", "paid", "uncollectible": return nil default: return fmt.Errorf("billinginvoice: invalid enum value for status field: %q", s) diff --git a/openmeter/ent/db/migrate/schema.go b/openmeter/ent/db/migrate/schema.go index feb2ee2cc..91da558e8 100644 --- a/openmeter/ent/db/migrate/schema.go +++ b/openmeter/ent/db/migrate/schema.go @@ -345,7 +345,7 @@ var ( {Name: "draft_until", Type: field.TypeTime, Nullable: true}, {Name: "currency", Type: field.TypeString, SchemaType: map[string]string{"postgres": "varchar(3)"}}, {Name: "due_at", Type: field.TypeTime, Nullable: true}, - {Name: "status", Type: field.TypeEnum, Enums: []string{"gathering", "draft_created", "draft_updating", "draft_manual_approval_needed", "draft_validating", "draft_invalid", "draft_syncing", "draft_sync_failed", "draft_waiting_auto_approval", "draft_ready_to_issue", "delete_in_progress", "delete_syncing", "delete_failed", "deleted", "issuing_syncing", "issuing_sync_failed", "issued"}}, + {Name: "status", Type: field.TypeEnum, Enums: []string{"gathering", "draft_created", "draft_updating", "draft_manual_approval_needed", "draft_validating", "draft_invalid", "draft_syncing", "draft_sync_failed", "draft_waiting_auto_approval", "draft_ready_to_issue", "delete_in_progress", "delete_syncing", "delete_failed", "deleted", "issuing_syncing", "issuing_failed", "issued", "payment_pending", "payment_failed", "payment_action_required", "overdue", "paid", "uncollectible"}}, {Name: "invoicing_app_external_id", Type: field.TypeString, Nullable: true}, {Name: "payment_app_external_id", Type: field.TypeString, Nullable: true}, {Name: "period_start", Type: field.TypeTime, Nullable: true}, diff --git a/test/billing/invoice_test.go b/test/billing/invoice_test.go index c866448f1..8ec871133 100644 --- a/test/billing/invoice_test.go +++ b/test/billing/invoice_test.go @@ -794,7 +794,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlow() { require.ErrorIs(t, err, billing.ErrInvoiceCannotAdvance) require.ErrorAs(t, err, &billing.ValidationError{}) }, - expectedState: billing.InvoiceStatusIssued, + expectedState: billing.InvoiceStatusPaid, }, { name: "draft period bypass with manual approve", @@ -821,9 +821,9 @@ func (s *InvoicingTestSuite) TestInvoicingFlow() { }) require.NoError(s.T(), err) - require.Equal(s.T(), billing.InvoiceStatusIssued, invoice.Status) + require.Equal(s.T(), billing.InvoiceStatusPaid, invoice.Status) }, - expectedState: billing.InvoiceStatusIssued, + expectedState: billing.InvoiceStatusPaid, }, { name: "manual approvement flow", @@ -845,7 +845,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlow() { require.Equal(s.T(), billing.InvoiceStatusDetails{ AvailableActions: billing.InvoiceAvailableActions{ Approve: &billing.InvoiceAvailableActionDetails{ - ResultingState: billing.InvoiceStatusIssued, + ResultingState: billing.InvoiceStatusPaymentPending, }, }, }, invoice.StatusDetails) @@ -857,9 +857,63 @@ func (s *InvoicingTestSuite) TestInvoicingFlow() { }) require.NoError(s.T(), err) - require.Equal(s.T(), billing.InvoiceStatusIssued, invoice.Status) + require.Equal(s.T(), billing.InvoiceStatusPaid, invoice.Status) }, - expectedState: billing.InvoiceStatusIssued, + expectedState: billing.InvoiceStatusPaid, + }, + // sandbox payment status override metadata + { + name: "app sandbox failed payment simulation", + workflowConfig: billing.WorkflowConfig{ + Collection: billing.CollectionConfig{ + Alignment: billing.AlignmentKindSubscription, + }, + Invoicing: billing.InvoicingConfig{ + AutoAdvance: false, + DraftPeriod: lo.Must(datex.ISOString("PT0H").Parse()), + DueAfter: lo.Must(datex.ISOString("P1W").Parse()), + }, + Payment: billing.PaymentConfig{ + CollectionMethod: billing.CollectionMethodChargeAutomatically, + }, + }, + advance: func(t *testing.T, ctx context.Context, invoice billing.Invoice) { + require.Equal(s.T(), billing.InvoiceStatusDraftManualApprovalNeeded, invoice.Status) + + // Let's instruct the sandbox to fail the invoice + _, err := s.BillingService.UpdateInvoice(ctx, billing.UpdateInvoiceInput{ + Invoice: invoice.InvoiceID(), + EditFn: func(invoice *billing.Invoice) error { + invoice.Metadata = map[string]string{ + appsandbox.TargetPaymentStatusMetadataKey: appsandbox.TargetPaymentStatusFailed, + } + + return nil + }, + }) + s.NoError(err) + + // Approve the invoice, should become InvoiceStatusPaymentFailed + invoice, err = s.BillingService.ApproveInvoice(ctx, billing.ApproveInvoiceInput{ + ID: invoice.ID, + Namespace: invoice.Namespace, + }) + + require.NoError(s.T(), err) + require.Equal(s.T(), billing.InvoiceStatusPaymentFailed, invoice.Status) + require.Len(s.T(), invoice.ValidationIssues, 1) + + validationIssue := invoice.ValidationIssues[0] + require.ElementsMatch(s.T(), billing.ValidationIssues{ + { + Severity: billing.ValidationIssueSeverityCritical, + Code: validationIssue.Code, + Message: validationIssue.Message, + Component: "app.sandbox.invoiceCustomers.initiate_payment", + }, + }, invoice.ValidationIssues.RemoveMetaForCompare()) + }, + expectedState: billing.InvoiceStatusPaymentFailed, }, } @@ -971,7 +1025,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlowErrorHandling() { require.Equal(s.T(), billing.InvoiceStatusDetails{ AvailableActions: billing.InvoiceAvailableActions{ Retry: &billing.InvoiceAvailableActionDetails{ - ResultingState: billing.InvoiceStatusIssued, + ResultingState: billing.InvoiceStatusPaymentPending, }, Delete: &billing.InvoiceAvailableActionDetails{ ResultingState: billing.InvoiceStatusDeleted, @@ -1028,7 +1082,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlowErrorHandling() { require.Equal(s.T(), billing.InvoiceStatusDetails{ AvailableActions: billing.InvoiceAvailableActions{ Retry: &billing.InvoiceAvailableActionDetails{ - ResultingState: billing.InvoiceStatusIssued, + ResultingState: billing.InvoiceStatusPaymentPending, }, Delete: &billing.InvoiceAvailableActionDetails{ ResultingState: billing.InvoiceStatusDeleted, @@ -1121,7 +1175,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlowErrorHandling() { require.Equal(s.T(), billing.InvoiceStatusDetails{ AvailableActions: billing.InvoiceAvailableActions{ Retry: &billing.InvoiceAvailableActionDetails{ - ResultingState: billing.InvoiceStatusIssued, + ResultingState: billing.InvoiceStatusPaymentPending, }, Delete: &billing.InvoiceAvailableActionDetails{ ResultingState: billing.InvoiceStatusDeleted, @@ -1196,7 +1250,8 @@ func (s *InvoicingTestSuite) TestInvoicingFlowErrorHandling() { }) require.NotNil(s.T(), invoice) - require.Equal(s.T(), billing.InvoiceStatusIssued, invoice.Status) + // We are using the mock app factory, so we won't have automatic payment handling provided by the sandbox app + require.Equal(s.T(), billing.InvoiceStatusPaymentPending, invoice.Status) require.Equal(s.T(), billing.InvoiceStatusDetails{ AvailableActions: billing.InvoiceAvailableActions{}, Immutable: true, @@ -1212,7 +1267,7 @@ func (s *InvoicingTestSuite) TestInvoicingFlowErrorHandling() { return &invoice }, - expectedState: billing.InvoiceStatusIssued, + expectedState: billing.InvoiceStatusPaymentPending, }, }