Skip to content

Commit

Permalink
feat(stripe): subscribe to invoice webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
hekike committed Jan 21, 2025
1 parent acc207f commit b26951c
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 35 deletions.
26 changes: 26 additions & 0 deletions openmeter/app/stripe/client/appclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions openmeter/app/stripe/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
28 changes: 8 additions & 20 deletions openmeter/app/stripe/client/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
24 changes: 12 additions & 12 deletions openmeter/app/stripe/entity/app/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions openmeter/app/stripe/httpdriver/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 4 additions & 0 deletions openmeter/billing/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ func (f *FinalizeInvoiceResult) SetPaymentExternalID(paymentExternalID string) {
f.paymentExternalID = paymentExternalID
}

func (f *FinalizeInvoiceResult) SetInvoiceNumber(number string) {
f.SetInvoiceNumber(number)
}

type InvoicingApp interface {
// ValidateInvoice validates if the app can run for the given invoice
ValidateInvoice(ctx context.Context, invoice Invoice) error
Expand Down
4 changes: 1 addition & 3 deletions test/app/stripe/invoice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit b26951c

Please sign in to comment.