diff --git a/openmeter/app/stripe/client/appclient.go b/openmeter/app/stripe/client/appclient.go index 5dcd45d1a..3db82fe94 100644 --- a/openmeter/app/stripe/client/appclient.go +++ b/openmeter/app/stripe/client/appclient.go @@ -22,7 +22,33 @@ const ( SetupIntentDataMetadataAppID = "om_app_id" SetupIntentDataMetadataCustomerID = "om_customer_id" + // Stripe Webhook event types + + // Occurs when an SetupIntent has successfully setup a payment method. WebhookEventTypeSetupIntentSucceeded = "setup_intent.succeeded" + // Occurs when an SetupIntent has failed to set up a payment method. + WebhookEventTypeSetupIntentFailed = "setup_intent.setup_failed" + // Occurs when a SetupIntent is in requires_action state. + WebhookEventTypeSetupIntentRequiresAction = "setup_intent.requires_action" + + // Occurs whenever a draft invoice cannot be finalized + WebhookEventTypeInvoiceFinalizationFailed = "invoice.finalization_failed" + // Occurs whenever an invoice is marked uncollectible + WebhookEventTypeInvoiceMarkedUncollectible = "invoice.marked_uncollectible" + // Occurs X number of days after an invoice becomes due—where X is determined by Automations + WebhookEventTypeInvoiceOverdue = "invoice.overdue" + // Occurs whenever an invoice payment attempt succeeds or an invoice is marked as paid out-of-band. + WebhookEventTypeInvoicePaid = "invoice.paid" + // Occurs whenever an invoice payment attempt requires further user action to complete. + WebhookEventTypeInvoicePaymentActionRequired = "invoice.payment_action_required" + // Occurs whenever an invoice payment attempt fails, due either to a declined payment or to the lack of a stored payment method. + WebhookEventTypeInvoicePaymentFailed = "invoice.payment_failed" + // Occurs whenever an invoice payment attempt succeeds. + WebhookEventTypeInvoicePaymentSucceeded = "invoice.payment_succeeded" + // Occurs whenever an invoice email is sent out. + WebhookEventTypeInvoiceSent = "invoice.sent" + // Occurs whenever an invoice is voided. + WebhookEventTypeInvoiceVoided = "invoice.voided" ) // StripeAppClient is a client for the stripe API for an installed app. diff --git a/openmeter/app/stripe/client/client.go b/openmeter/app/stripe/client/client.go index edafe089f..b56483208 100644 --- a/openmeter/app/stripe/client/client.go +++ b/openmeter/app/stripe/client/client.go @@ -87,7 +87,21 @@ func (c *stripeClient) SetupWebhook(ctx context.Context, input SetupWebhookInput params := &stripe.WebhookEndpointParams{ EnabledEvents: []*string{ + // Setup intents lo.ToPtr(WebhookEventTypeSetupIntentSucceeded), + lo.ToPtr(WebhookEventTypeSetupIntentFailed), + lo.ToPtr(WebhookEventTypeSetupIntentRequiresAction), + + // Invoices + lo.ToPtr(WebhookEventTypeInvoiceFinalizationFailed), + lo.ToPtr(WebhookEventTypeInvoiceMarkedUncollectible), + lo.ToPtr(WebhookEventTypeInvoiceOverdue), + lo.ToPtr(WebhookEventTypeInvoicePaid), + lo.ToPtr(WebhookEventTypeInvoicePaymentActionRequired), + lo.ToPtr(WebhookEventTypeInvoicePaymentFailed), + lo.ToPtr(WebhookEventTypeInvoicePaymentSucceeded), + lo.ToPtr(WebhookEventTypeInvoiceSent), + lo.ToPtr(WebhookEventTypeInvoiceVoided), }, URL: lo.ToPtr(webhookURL), Description: lo.ToPtr("OpenMeter Stripe Webhook, do not delete or modify manually"), diff --git a/openmeter/app/stripe/client/invoice.go b/openmeter/app/stripe/client/invoice.go index 12d024575..488773474 100644 --- a/openmeter/app/stripe/client/invoice.go +++ b/openmeter/app/stripe/client/invoice.go @@ -19,7 +19,6 @@ func (c *stripeAppClient) CreateInvoice(ctx context.Context, input CreateInvoice } params := &stripe.InvoiceParams{ - Number: input.Number, Currency: lo.ToPtr(string(input.Currency)), Customer: lo.ToPtr(input.StripeCustomerID), // FinalizeInvoice will advance the invoice @@ -29,7 +28,7 @@ func (c *stripeAppClient) CreateInvoice(ctx context.Context, input CreateInvoice CollectionMethod: lo.ToPtr(string(stripe.InvoiceCollectionMethodChargeAutomatically)), // If not set, defaults to the default payment method in the customer’s invoice settings. DefaultPaymentMethod: input.StripeDefaultPaymentMethodID, - StatementDescriptor: lo.ToPtr(input.StatementDescriptor), + StatementDescriptor: input.StatementDescriptor, } if input.AutomaticTaxEnabled { @@ -52,8 +51,7 @@ func (c *stripeAppClient) UpdateInvoice(ctx context.Context, input UpdateInvoice } params := &stripe.InvoiceParams{ - StatementDescriptor: lo.ToPtr(input.StatementDescriptor), - Number: input.Number, + StatementDescriptor: input.StatementDescriptor, } if input.DueDate != nil { @@ -91,8 +89,7 @@ type CreateInvoiceInput struct { AutomaticTaxEnabled bool Currency currencyx.Code DueDate *time.Time - Number *string - StatementDescriptor string + StatementDescriptor *string } func (i CreateInvoiceInput) Validate() error { @@ -108,12 +105,8 @@ func (i CreateInvoiceInput) Validate() error { return errors.New("due date cannot be zero") } - if i.Number != nil && *i.Number == "" { - return errors.New("invoice number cannot be empty") - } - - if i.StatementDescriptor == "" { - return errors.New("statement descriptor is required") + if i.StatementDescriptor != nil && *i.StatementDescriptor == "" { + return errors.New("statement descriptor cannot be empty") } return nil @@ -123,8 +116,7 @@ func (i CreateInvoiceInput) Validate() error { type UpdateInvoiceInput struct { StripeInvoiceID string DueDate *time.Time - Number *string - StatementDescriptor string + StatementDescriptor *string } func (i UpdateInvoiceInput) Validate() error { @@ -136,12 +128,8 @@ func (i UpdateInvoiceInput) Validate() error { return errors.New("due date cannot be zero") } - if i.Number != nil && *i.Number == "" { - return errors.New("invoice number cannot be empty") - } - - if i.StatementDescriptor == "" { - return errors.New("statement descriptor is required") + if i.StatementDescriptor != nil && *i.StatementDescriptor == "" { + return errors.New("statement descriptor cannot be empty") } return nil diff --git a/openmeter/app/stripe/entity/app/invoice.go b/openmeter/app/stripe/entity/app/invoice.go index 0eccd0f33..1f0514ec7 100644 --- a/openmeter/app/stripe/entity/app/invoice.go +++ b/openmeter/app/stripe/entity/app/invoice.go @@ -106,6 +106,10 @@ func (a App) FinalizeInvoice(ctx context.Context, invoice billing.Invoice) (*bil // Result result := billing.NewFinalizeInvoiceResult() + // Stripe is the source of truth for invoice number + // We set it on result to save it + result.SetInvoiceNumber(stripeInvoice.Number) + // The PaymentIntent is generated when the invoice is finalized, // and can then be used to pay the invoice. // https://docs.stripe.com/api/invoices/object#invoice_object-payment_intent @@ -150,10 +154,8 @@ func (a App) createInvoice(ctx context.Context, invoice billing.Invoice) (*billi AutomaticTaxEnabled: true, Currency: invoice.Currency, DueDate: invoice.DueAt, - Number: invoice.Number, StripeCustomerID: stripeCustomerData.StripeCustomerID, StripeDefaultPaymentMethodID: stripeCustomerData.StripeDefaultPaymentMethodID, - StatementDescriptor: getInvoiceStatementDescriptor(invoice), } stripeInvoice, err := stripeClient.CreateInvoice(ctx, createInvoiceParams) @@ -164,6 +166,9 @@ func (a App) createInvoice(ctx context.Context, invoice billing.Invoice) (*billi // Return the result result := billing.NewUpsertInvoiceResult() result.SetExternalID(stripeInvoice.ID) + + // Stripe is the source of truth for invoice number + // We set it on result to save it result.SetInvoiceNumber(stripeInvoice.Number) // Add lines to the Stripe invoice @@ -220,10 +225,8 @@ func (a App) updateInvoice(ctx context.Context, invoice billing.Invoice) (*billi // Update the invoice in Stripe stripeInvoice, err := stripeClient.UpdateInvoice(ctx, stripeclient.UpdateInvoiceInput{ - StripeInvoiceID: invoice.ExternalIDs.Invoicing, - DueDate: invoice.DueAt, - Number: invoice.Number, - StatementDescriptor: getInvoiceStatementDescriptor(invoice), + StripeInvoiceID: invoice.ExternalIDs.Invoicing, + DueDate: invoice.DueAt, }) if err != nil { return nil, fmt.Errorf("failed to update invoice in stripe: %w", err) @@ -232,6 +235,9 @@ func (a App) updateInvoice(ctx context.Context, invoice billing.Invoice) (*billi // The result result := billing.NewUpsertInvoiceResult() result.SetExternalID(stripeInvoice.ID) + + // Stripe is the source of truth for invoice number + // We set it on result to save it result.SetInvoiceNumber(stripeInvoice.Number) // Collect the existing line items @@ -519,12 +525,6 @@ func getLineName(line *billing.Line) string { return name } -// TODO (OM-1064): should we include invoice description in the statement descriptor? -// getInvoiceStatementDescriptor returns the invoice statement descriptor -func getInvoiceStatementDescriptor(invoice billing.Invoice) string { - return invoice.Supplier.Name -} - // addResultExternalIDs adds the Stripe line item IDs to the result external IDs func addResultExternalIDs( params []*stripe.InvoiceAddLinesLineParams, diff --git a/openmeter/app/stripe/httpdriver/webhook.go b/openmeter/app/stripe/httpdriver/webhook.go index 724e46594..efd2853d7 100644 --- a/openmeter/app/stripe/httpdriver/webhook.go +++ b/openmeter/app/stripe/httpdriver/webhook.go @@ -134,6 +134,65 @@ func (h *handler) AppStripeWebhook() AppStripeWebhookHandler { AppId: request.AppID.ID, CustomerId: &out.CustomerID.ID, }, nil + + case stripeclient.WebhookEventTypeSetupIntentFailed: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeSetupIntentRequiresAction: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + + // Invoice events + // TODO: update invoice payment status + case stripeclient.WebhookEventTypeInvoiceFinalizationFailed: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoiceMarkedUncollectible: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoiceOverdue: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaid: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentActionRequired: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentFailed: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentSucceeded: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoiceSent: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoiceVoided: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil } return AppStripeWebhookResponse{}, appstripe.ValidationError{ diff --git a/openmeter/billing/app.go b/openmeter/billing/app.go index b94f119be..6856286b2 100644 --- a/openmeter/billing/app.go +++ b/openmeter/billing/app.go @@ -74,6 +74,7 @@ func NewUpsertInvoiceResult() *UpsertInvoiceResult { } type FinalizeInvoiceResult struct { + invoiceNumber string paymentExternalID string } @@ -89,6 +90,14 @@ func (f *FinalizeInvoiceResult) SetPaymentExternalID(paymentExternalID string) { f.paymentExternalID = paymentExternalID } +func (u *FinalizeInvoiceResult) GetInvoiceNumber() (string, bool) { + return u.invoiceNumber, u.invoiceNumber != "" +} + +func (f *FinalizeInvoiceResult) SetInvoiceNumber(invoiceNumber string) { + f.invoiceNumber = invoiceNumber +} + type InvoicingApp interface { // ValidateInvoice validates if the app can run for the given invoice ValidateInvoice(ctx context.Context, invoice Invoice) error diff --git a/openmeter/billing/service/invoicestate.go b/openmeter/billing/service/invoicestate.go index 49ae50ab0..32e7e28b0 100644 --- a/openmeter/billing/service/invoicestate.go +++ b/openmeter/billing/service/invoicestate.go @@ -474,6 +474,10 @@ func (m *InvoiceStateMachine) finalizeInvoice(ctx context.Context) error { if paymentExternalID, ok := results.GetPaymentExternalID(); ok { m.Invoice.ExternalIDs.Payment = paymentExternalID } + + if invoiceNumber, ok := results.GetInvoiceNumber(); ok { + m.Invoice.Number = lo.ToPtr(invoiceNumber) + } } return nil diff --git a/test/app/stripe/invoice_test.go b/test/app/stripe/invoice_test.go index 5d25422df..1f405af72 100644 --- a/test/app/stripe/invoice_test.go +++ b/test/app/stripe/invoice_test.go @@ -417,7 +417,6 @@ func (s *StripeInvoiceTestSuite) TestComplexInvoice() { AutomaticTaxEnabled: true, StripeCustomerID: customerData.StripeCustomerID, Currency: "USD", - StatementDescriptor: invoice.Supplier.Name, }). Return(&stripe.Invoice{ ID: "stripe-invoice-id", @@ -710,8 +709,7 @@ func (s *StripeInvoiceTestSuite) TestComplexInvoice() { s.StripeAppClient. On("UpdateInvoice", stripeclient.UpdateInvoiceInput{ - StripeInvoiceID: updateInvoice.ExternalIDs.Invoicing, - StatementDescriptor: updateInvoice.Supplier.Name, + StripeInvoiceID: updateInvoice.ExternalIDs.Invoicing, }). // We return the updated invoice. Return(stripeInvoiceUpdated, nil)