openmeter / app /stripe /httpdriver /webhook.go
Leon4gr45's picture
Upload folder using huggingface_hub
429334c verified
Raw
History Blame Contribute Delete
17.2 kB
package httpdriver
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/samber/lo"
"github.com/stripe/stripe-go/v80"
"github.com/stripe/stripe-go/v80/webhook"
"github.com/openmeterio/openmeter/api"
"github.com/openmeterio/openmeter/openmeter/app"
appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe"
stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
"github.com/openmeterio/openmeter/pkg/models"
)
type AppStripeWebhookParams struct {
AppID string
Payload []byte
}
type AppStripeWebhookRequest struct {
AppID app.AppID
Event stripe.Event
}
type (
AppStripeWebhookResponse = api.StripeWebhookResponse
AppStripeWebhookHandler httptransport.HandlerWithArgs[AppStripeWebhookRequest, AppStripeWebhookResponse, AppStripeWebhookParams]
)
// AppStripeWebhook returns a new httptransport.Handler for creating a customer.
func (h *handler) AppStripeWebhook() AppStripeWebhookHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params AppStripeWebhookParams) (AppStripeWebhookRequest, error) {
// Note that the webhook handler has no namespace resolver
// We only know the namespace from the app id. Which we trust because
// we validate the payload signature with the app's webhook secret.
// Get the webhook secret for the app
secret, err := h.service.GetWebhookSecret(ctx, appstripe.GetWebhookSecretInput{
AppID: params.AppID,
})
if err != nil {
return AppStripeWebhookRequest{}, err
}
// Validate the webhook event
event, err := webhook.ConstructEventWithTolerance(params.Payload, r.Header.Get("Stripe-Signature"), secret.Value, time.Hour*10000)
if err != nil {
return AppStripeWebhookRequest{}, models.NewGenericValidationError(
fmt.Errorf("failed to construct webhook event: %w", err),
)
}
appID := app.AppID{
Namespace: secret.SecretID.Namespace,
ID: params.AppID,
}
req := AppStripeWebhookRequest{
AppID: appID,
Event: event,
}
return req, nil
},
func(ctx context.Context, request AppStripeWebhookRequest) (AppStripeWebhookResponse, error) {
ctx = context.WithValue(ctx, StripeEventIDAttributeName, request.Event.ID)
ctx = context.WithValue(ctx, StripeEventTypeAttributeName, request.Event.Type)
ctx = context.WithValue(ctx, AppIDAttributeName, request.AppID)
// Handle the webhook event based on the event type
switch request.Event.Type {
case stripeclient.WebhookEventTypeSetupIntentSucceeded:
// Unmarshal to payment intent object
var paymentIntent stripe.PaymentIntent
err := json.Unmarshal(request.Event.Data.Raw, &paymentIntent)
if err != nil {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("failed to unmarshal payment intent for app: %s in event: %s: %w", request.AppID.ID, request.Event.ID, err),
)
}
// Validate the payment intent metadata
metadataAppId, hasMetadataAppId := paymentIntent.Metadata[stripeclient.StripeMetadataAppID]
// If the event has not app metadata it's not initiated by an OpenMeter app and we ignore it.
// This can be the case when someone manually creates a payment intent.
if !hasMetadataAppId {
return AppStripeWebhookResponse{
Message: lo.ToPtr("ignoring event as it was not initiated by the openmeter app"),
}, nil
}
// When the OpenMeter app id is set it cannot be empty
if metadataAppId == "" {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("appid metadata cannot be empty if provided for app: %s in event: %s", request.AppID.ID, request.Event.ID),
)
}
// If someone installs the same Stripe account in multiple apps, we need to ignore the event from other apps
if metadataAppId != request.AppID.ID {
// Ignore the event from other apps
return AppStripeWebhookResponse{
Message: lo.ToPtr("ignoring event as it was initiated by a different openmeter app"),
}, nil
}
// Validate the namespace
// At this point we know that the event is for this specific app so require the namespace.
metadataNamespace, hasMetadataNamespace := paymentIntent.Metadata[stripeclient.StripeMetadataNamespace]
if !hasMetadataNamespace {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("namespace metadata is required for app: %s in event: %s", request.AppID.ID, request.Event.ID),
)
}
// When the namespace is set it cannot be empty
if metadataNamespace == "" {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("namespace metadata cannot be empty if provided for app: %s in event: %s", request.AppID.ID, request.Event.ID),
)
}
// As we already checked that this event is for this specific app we validate the namespace
if metadataNamespace != request.AppID.Namespace {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("namespace mismatch: in request %s, in payment intent metadata %s in event: %s", request.AppID.Namespace, metadataNamespace, request.Event.ID),
)
}
// Validate the payment intent object
if paymentIntent.Customer == nil {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("payment intent customer is required for app: %s in event: %s", request.AppID.ID, request.Event.ID),
)
}
if paymentIntent.PaymentMethod == nil {
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("payment intent payment method is required for app %s in event: %s", request.AppID.ID, request.Event.ID),
)
}
// Set the default payment method for the customer
out, err := h.service.HandleSetupIntentSucceeded(ctx,
appstripe.HandleSetupIntentSucceededInput{
SetCustomerDefaultPaymentMethodInput: appstripe.SetCustomerDefaultPaymentMethodInput{
AppID: request.AppID,
StripeCustomerID: paymentIntent.Customer.ID,
PaymentMethodID: paymentIntent.PaymentMethod.ID,
},
PaymentIntentMetadata: paymentIntent.Metadata,
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
// In the response, we return what resources took action
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
CustomerId: &out.CustomerID.ID,
Message: lo.ToPtr("customer default payment method set"),
}, 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
case stripeclient.WebhookEventTypeInvoiceFinalizationFailed:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerFailed,
TargetStatuses: []billing.StandardInvoiceStatus{
billing.StandardInvoiceStatusIssuingSyncFailed,
billing.StandardInvoiceStatusPaymentProcessingFailed,
},
IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{
billing.StandardInvoiceStatusCategoryPaymentProcessing,
billing.StandardInvoiceStatusCategoryPaid,
billing.StandardInvoiceStatusCategoryUncollectible,
},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
return stripeInvoice.LastFinalizationError != nil, nil
},
GetValidationErrors: func(stripeInvoice *stripe.Invoice) (*appstripe.ValidationErrorsInput, error) {
return &appstripe.ValidationErrorsInput{
Op: billing.StandardInvoiceOpFinalize,
Errors: []*stripe.Error{stripeInvoice.LastFinalizationError},
}, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoiceSent:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceSentEvent(ctx, appstripe.HandleInvoiceSentEventInput{
AppID: request.AppID,
Invoice: invoice,
SentAt: request.Event.Created,
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoiceVoided:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerVoid,
TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusVoided},
IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{
billing.StandardInvoiceStatusCategoryPaid,
},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is voided
return stripeInvoice.Status == stripe.InvoiceStatusVoid, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoiceMarkedUncollectible:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerPaymentUncollectible,
TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusUncollectible},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is uncollectible
return stripeInvoice.Status == stripe.InvoiceStatusUncollectible, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoiceOverdue:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerPaymentOverdue,
TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusOverdue},
IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{
billing.StandardInvoiceStatusCategoryUncollectible,
},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is still open
return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoicePaid:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerPaid,
TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusPaid},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is paid
return stripeInvoice.Status == stripe.InvoiceStatusPaid, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoicePaymentActionRequired:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerActionRequired,
TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusPaymentProcessingActionRequired},
IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{
billing.StandardInvoiceStatusCategoryPaid,
billing.StandardInvoiceStatusCategoryUncollectible,
},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is still open
return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoicePaymentFailed:
invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw)
if err != nil {
return AppStripeWebhookResponse{}, err
}
err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{
AppID: request.AppID,
Invoice: invoice,
Trigger: billing.TriggerFailed,
TargetStatuses: []billing.StandardInvoiceStatus{
billing.StandardInvoiceStatusPaymentProcessingFailed,
},
IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{
billing.StandardInvoiceStatusCategoryPaid,
billing.StandardInvoiceStatusCategoryUncollectible,
},
ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) {
// Let's only invoke the state transition if the upstream invoice is still open
return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil
},
})
if err != nil {
return AppStripeWebhookResponse{}, err
}
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
case stripeclient.WebhookEventTypeInvoicePaymentSucceeded:
// We ignore this event for now, as we handle the invoice.paid event instead
// Details: https://docs.stripe.com/invoicing/integration
// Successful invoice payments trigger both an invoice.paid and invoice.payment_succeeded event. Both event
// types contain the same invoice data, so it’s only necessary to listen to one of them to be notified of successful
// invoice payments. The difference is that invoice.payment_succeeded events are sent for successful invoice payments,
// but aren’t sent when you mark an invoice as paid_out_of_band. invoice.paid events, on the other hand, are triggered for
// both successful payments and out of band payments. Because invoice.paid covers both scenarios, we typically recommend
// listening to invoice.paid rather than invoice.payment_succeeded.
return AppStripeWebhookResponse{
NamespaceId: request.AppID.Namespace,
AppId: request.AppID.ID,
}, nil
}
return AppStripeWebhookResponse{}, models.NewGenericValidationError(
fmt.Errorf("unsupported event type: %s", request.Event.Type),
)
},
commonhttp.JSONResponseEncoderWithStatus[AppStripeWebhookResponse](http.StatusCreated),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("appStripeWebhook"),
)...,
)
}
func unmarshalInvoiceEvent(data []byte) (stripe.Invoice, error) {
var invoice stripe.Invoice
if err := json.Unmarshal(data, &invoice); err != nil {
return stripe.Invoice{}, models.NewGenericValidationError(
fmt.Errorf("failed to unmarshal invoice: %w", err),
)
}
return invoice, nil
}