| 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] |
| ) |
|
|
| |
| func (h *handler) AppStripeWebhook() AppStripeWebhookHandler { |
| return httptransport.NewHandlerWithArgs( |
| func(ctx context.Context, r *http.Request, params AppStripeWebhookParams) (AppStripeWebhookRequest, error) { |
| |
| |
| |
|
|
| |
| secret, err := h.service.GetWebhookSecret(ctx, appstripe.GetWebhookSecretInput{ |
| AppID: params.AppID, |
| }) |
| if err != nil { |
| return AppStripeWebhookRequest{}, err |
| } |
|
|
| |
| 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) |
|
|
| |
| switch request.Event.Type { |
| case stripeclient.WebhookEventTypeSetupIntentSucceeded: |
| |
| 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), |
| ) |
| } |
|
|
| |
| metadataAppId, hasMetadataAppId := paymentIntent.Metadata[stripeclient.StripeMetadataAppID] |
|
|
| |
| |
| if !hasMetadataAppId { |
| return AppStripeWebhookResponse{ |
| Message: lo.ToPtr("ignoring event as it was not initiated by the openmeter app"), |
| }, nil |
| } |
|
|
| |
| 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 metadataAppId != request.AppID.ID { |
| |
| return AppStripeWebhookResponse{ |
| Message: lo.ToPtr("ignoring event as it was initiated by a different openmeter app"), |
| }, nil |
| } |
|
|
| |
| |
| 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), |
| ) |
| } |
|
|
| |
| 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), |
| ) |
| } |
|
|
| |
| 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), |
| ) |
| } |
|
|
| |
| 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), |
| ) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
|
|
| |
| 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) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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) { |
| |
| 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: |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| 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 |
| } |
|
|