Repository: theopenlane/core
Pull request #645: stripe webhook updates; additional ent and webhook metrics
Author: login: matoszz
Labels: name: enhancement
description: New feature or request
name: httpserve
Created: 2025-04-19T16:16:52Z | Merged: 2025-04-19T18:11:39Z

- changes the current webhook handler behavior from returning a code in the error range (4xx 5xx) when an event type we dont support is received to a success response code - we are discarding the event regardless, but when you return 4xx/5xx to stripe they attempt to resend every event up to 5+ times which is a lot of extra overhead on us (and results in getting emails from them, etc.)
- updates / corrects several of the field evaluations in the `syncOrgSubscriptionWithStripe` function to perform updates if the fields are _not_ alike rather than if they are (`if !slices.Equal` vs. `if slices.Equal` 🤦 )
- fixes a bug in `InterceptorSubscriptionURL` causing none of the URL's to be populated and populates different log error text to make it more obvious which individual URL may have been the culprit


Diff:
diff --git a/internal/ent/hooks/metrics.go b/internal/ent/hooks/metrics.go
index ecef4ee189..0096e3705d 100644
--- a/internal/ent/hooks/metrics.go
+++ b/internal/ent/hooks/metrics.go
@@ -49,11 +49,23 @@ func initOpsDuration() *prometheus.HistogramVec {
 	)
 }
 
+// initOpsTotalDuration creates a collector for total duration counter
+func initOpsTotalDuration() *prometheus.CounterVec {
+	return promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "ent_operation_total_duration_seconds",
+			Help: "Total time in seconds per operation type and mutation",
+		},
+		entLabels,
+	)
+}
+
 // initialize the collectors, prometheus will register them automatically
 var (
 	opsProcessedTotal = initOpsProcessedTotal()
 	opsProcessedError = initOpsProcessedError()
 	opsDuration       = initOpsDuration()
+	opsTotalDuration  = initOpsTotalDuration()
 )
 
 // MetricsHook inits the collectors with count total at beginning, error on mutation error and a duration after the mutation
@@ -75,6 +87,7 @@ func MetricsHook() ent.Hook {
 
 			duration := time.Since(start)
 			opsDuration.With(labels).Observe(duration.Seconds())
+			opsTotalDuration.With(labels).Add(duration.Seconds())
 
 			return v, err
 		})
diff --git a/internal/ent/interceptors/orgsubscription.go b/internal/ent/interceptors/orgsubscription.go
index bccfa9e59c..76c9082de4 100644
--- a/internal/ent/interceptors/orgsubscription.go
+++ b/internal/ent/interceptors/orgsubscription.go
@@ -12,6 +12,7 @@ import (
 	"github.com/theopenlane/core/internal/ent/generated/intercept"
 )
 
+// InterceptorSubscriptionURL is an ent interceptor to fetch data from an external source (in this case stripe) and populate the URLs in the graph return response
 func InterceptorSubscriptionURL() ent.Interceptor {
 	return ent.InterceptFunc(func(next ent.Querier) ent.Querier {
 		return intercept.OrgSubscriptionFunc(func(ctx context.Context, q *generated.OrgSubscriptionQuery) (generated.Value, error) {
@@ -75,21 +76,21 @@ func setSubscriptionURL(ctx context.Context, orgSub *generated.OrgSubscription,
 	// create a billing portal session
 	updateSubscription, err := q.EntitlementManager.CreateBillingPortalUpdateSession(orgSub.StripeSubscriptionID, orgSub.StripeCustomerID)
 	if err != nil {
-		zerolog.Ctx(ctx).Err(err).Msg("failed to create billing portal session")
+		zerolog.Ctx(ctx).Err(err).Msg("failed to create update subscription billing portal session type")
 
 		return err
 	}
 
 	cancelSubscription, err := q.EntitlementManager.CancellationBillingPortalSession(orgSub.StripeSubscriptionID, orgSub.StripeCustomerID)
 	if err != nil {
-		zerolog.Ctx(ctx).Err(err).Msg("failed to create billing portal session")
+		zerolog.Ctx(ctx).Err(err).Msg("failed to create cancel subscription billing portal session type")
 
 		return err
 	}
 
-	updatePaymentMethod, err := q.EntitlementManager.CreateBillingPortalPaymentMethods(orgSub.StripeSubscriptionID)
+	updatePaymentMethod, err := q.EntitlementManager.CreateBillingPortalPaymentMethods(orgSub.StripeCustomerID)
 	if err != nil {
-		zerolog.Ctx(ctx).Err(err).Msg("failed to create billing portal session")
+		zerolog.Ctx(ctx).Err(err).Msg("failed to create update payment method billing portal session type")
 
 		return err
 	}
diff --git a/internal/httpserve/authmanager/authmanager.go b/internal/httpserve/authmanager/authmanager.go
index 7ea837dfe2..90e4e59554 100644
--- a/internal/httpserve/authmanager/authmanager.go
+++ b/internal/httpserve/authmanager/authmanager.go
@@ -2,11 +2,11 @@ package authmanager
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 
 	"github.com/golang-jwt/jwt/v5"
+	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 
 	"github.com/theopenlane/iam/auth"
@@ -101,13 +101,19 @@ func (a *Client) GenerateOauthAuthSession(ctx context.Context, w http.ResponseWr
 	return auth, nil
 }
 
-// checkActiveSubscription checks if the user has an active subscription for the organization
+// checkActiveSubscription checks if the organization has an active subscription
 func (a *Client) checkActiveSubscription(ctx context.Context, orgID string) (active bool, err error) {
 	// if the entitlement manager is disabled, we can skip the check
 	if a.db.EntitlementManager == nil {
 		return true, nil
 	}
 
+	if orgID == "" {
+		log.Debug().Msg("organization ID is required to check for active subscription")
+
+		return false, nil
+	}
+
 	if _, ok := contextx.From[auth.OrganizationCreationContextKey](ctx); ok {
 		return true, nil
 	}
@@ -118,6 +124,8 @@ func (a *Client) checkActiveSubscription(ctx context.Context, orgID string) (act
 
 	subscription, err := a.db.OrgSubscription.Query().Select("active").Where(orgsubscription.OwnerID(orgID)).Only(allowCtx)
 	if err != nil {
+		zerolog.Ctx(ctx).Error().Err(err).Str("organization_id", orgID).Msg("failed to find org subscription for organization")
+
 		return false, err
 	}
 
@@ -212,13 +220,6 @@ func (a *Client) generateOauthUserSession(ctx context.Context, w http.ResponseWr
 	return session, nil
 }
 
-var (
-	// ErrSubscriptionNotFound is the error message when the subscription is not found
-	ErrSubscriptionNotFound = errors.New("subscription not found")
-	// ErrSubscriptionNotActive is the error message when the subscription is not active
-	ErrSubscriptionNotActive = errors.New("subscription not active")
-)
-
 // authCheck checks if the user has access to the target organization before issuing a new session and claims
 // if the user does not have access to the target organization, the user's default org is used (or falls back)
 // to their personal org
diff --git a/internal/httpserve/handlers/tools_test.go b/internal/httpserve/handlers/tools_test.go
index 528a238f5b..475c9a7401 100644
--- a/internal/httpserve/handlers/tools_test.go
+++ b/internal/httpserve/handlers/tools_test.go
@@ -207,6 +207,7 @@ func handlerSetup(db *ent.Client) *handlers.Handler {
 		RedisClient:   db.SessionConfig.RedisClient,
 		SessionConfig: db.SessionConfig,
 		AuthManager:   as,
+		Entitlements:  db.EntitlementManager,
 	}
 
 	return h
diff --git a/internal/httpserve/handlers/webhook.go b/internal/httpserve/handlers/webhook.go
index d7a391eb52..b3e6a64637 100644
--- a/internal/httpserve/handlers/webhook.go
+++ b/internal/httpserve/handlers/webhook.go
@@ -9,6 +9,7 @@ import (
 	"slices"
 	"time"
 
+	"github.com/prometheus/client_golang/prometheus"
 	"github.com/rs/zerolog/log"
 	"github.com/stripe/stripe-go/v81"
 	"github.com/stripe/stripe-go/v81/webhook"
@@ -46,13 +47,49 @@ var supportedEventTypes = map[stripe.EventType]bool{
 	stripe.EventTypeCustomerUpdated: false,
 }
 
+var (
+	webhookReceivedCounter = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "webhook_received_total",
+			Help: "Total number of webhooks received",
+		},
+		[]string{"event_type"},
+	)
+
+	webhookProcessingLatency = prometheus.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Name:    "webhook_processing_latency_seconds",
+			Help:    "Latency of webhook processing in seconds",
+			Buckets: prometheus.DefBuckets,
+		},
+		[]string{"event_type"},
+	)
+
+	webhookResponseCounter = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "webhook_response_total",
+			Help: "Total number of webhook responses grouped by event type and status code",
+		},
+		[]string{"event_type", "status_code"},
+	)
+)
+
+func init() {
+	prometheus.MustRegister(webhookReceivedCounter)
+	prometheus.MustRegister(webhookProcessingLatency)
+	prometheus.MustRegister(webhookResponseCounter)
+}
+
 // WebhookReceiverHandler handles incoming stripe webhook events for the supported event types
 func (h *Handler) WebhookReceiverHandler(ctx echo.Context) error {
+	startTime := time.Now()
+
 	req := ctx.Request()
 	res := ctx.Response()
 
 	payload, err := io.ReadAll(http.MaxBytesReader(res.Writer, req.Body, maxBodyBytes))
 	if err != nil {
+		webhookResponseCounter.WithLabelValues("payload_exceeded", "500").Inc()
 		log.Error().Err(err).Msg("failed to read request body")
 
 		return h.InternalServerError(ctx, err)
@@ -60,15 +97,19 @@ func (h *Handler) WebhookReceiverHandler(ctx echo.Context) error {
 
 	event, err := webhook.ConstructEvent(payload, req.Header.Get(stripeSignatureHeaderKey), h.Entitlements.Config.StripeWebhookSecret)
 	if err != nil {
+		webhookResponseCounter.WithLabelValues("event_signature_failure", "400").Inc()
 		log.Error().Err(err).Msg("failed to construct event")
 
-		return ctx.String(http.StatusConflict, fmt.Errorf("error verifying webhook signature. Error: %w", err).Error())
+		return h.BadRequest(ctx, err)
 	}
 
+	webhookReceivedCounter.WithLabelValues(string(event.Type)).Inc()
+
 	if !supportedEventTypes[event.Type] {
+		webhookResponseCounter.WithLabelValues(string(event.Type)+"_discarded", "200").Inc()
 		log.Debug().Str("event_type", string(event.Type)).Msg("unsupported event type")
 
-		return h.BadRequest(ctx, ErrUnsupportedEventType)
+		return h.Success(ctx, "unsupported event type")
 	}
 
 	newCtx := privacy.DecisionContext(req.Context(), privacy.Allow)
@@ -76,6 +117,7 @@ func (h *Handler) WebhookReceiverHandler(ctx echo.Context) error {
 
 	exists, err := h.checkForEventID(newCtx, event.ID)
 	if err != nil {
+		webhookResponseCounter.WithLabelValues(string(event.Type), "500").Inc()
 		log.Error().Err(err).Msg("failed to check for event ID")
 
 		return h.InternalServerError(ctx, err)
@@ -84,6 +126,7 @@ func (h *Handler) WebhookReceiverHandler(ctx echo.Context) error {
 	if !exists {
 		meowevent, err := h.createEvent(newCtx, ent.CreateEventInput{EventID: &event.ID})
 		if err != nil {
+			webhookResponseCounter.WithLabelValues(string(event.Type), "500").Inc()
 			log.Error().Err(err).Msg("failed to create event")
 
 			return h.InternalServerError(ctx, err)
@@ -92,12 +135,17 @@ func (h *Handler) WebhookReceiverHandler(ctx echo.Context) error {
 		log.Debug().Msgf("internal event: %v", meowevent)
 
 		if err = h.HandleEvent(newCtx, &event); err != nil {
+			webhookResponseCounter.WithLabelValues(string(event.Type), "500").Inc()
 			log.Error().Err(err).Msg("failed to handle event")
 
 			return h.InternalServerError(ctx, err)
 		}
 	}
 
+	duration := time.Since(startTime).Seconds()
+	webhookProcessingLatency.WithLabelValues(string(event.Type)).Observe(duration)
+	webhookResponseCounter.WithLabelValues(string(event.Type)+"_processed", "200").Inc()
+
 	return h.Success(ctx, nil)
 }
 
@@ -144,14 +192,6 @@ func (h *Handler) HandleEvent(c context.Context, e *stripe.Event) error {
 		}
 
 		return h.handleSubscriptionPaused(c, subscription)
-	// TODO: add support for these events so details like billing address can be updated
-	// case stripe.EventTypeCustomerUpdated:
-	// customer, err := unmarshalEventData[stripe.Customer](e)
-	// if err != nil {
-	// 	return err
-	// }
-
-	// return h.handleCustomerUpdated(c, customer)
 	default:
 		log.Warn().Str("event_type", string(e.Type)).Msg("unsupported event type")
 
@@ -301,7 +341,7 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("status", orgSubscription.StripeSubscriptionStatus).Msg("stripe subscription status changed")
 	}
 
-	if slices.Equal(orgSubscription.Features, stripeOrgSubscription.Features) {
+	if !slices.Equal(orgSubscription.Features, stripeOrgSubscription.Features) {
 		mutation.SetFeatures(stripeOrgSubscription.Features)
 
 		changed = true
@@ -318,7 +358,11 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 	}
 
 	if orgSubscription.ProductPrice != stripeOrgSubscription.ProductPrice {
-		stripeOrgSubscription.ProductPrice.Amount /= 100 // nolint:mnd // convert to dollars from cents
+		productPriceCopy := stripeOrgSubscription.ProductPrice
+
+		productPriceCopy.Amount /= 100 // convert to dollars from cents
+
+		mutation.SetProductPrice(productPriceCopy)
 
 		mutation.SetProductPrice(stripeOrgSubscription.ProductPrice)
 
@@ -327,15 +371,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("price", orgSubscription.ProductPrice.String()).Msgf("product price changed")
 	}
 
-	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.ExpiresAt != stripeOrgSubscription.ExpiresAt {
-		mutation.SetExpiresAt(*stripeOrgSubscription.ExpiresAt)
+	if stripeOrgSubscription.TrialExpiresAt != nil && orgSubscription.TrialExpiresAt != stripeOrgSubscription.TrialExpiresAt {
+		mutation.SetTrialExpiresAt(*stripeOrgSubscription.TrialExpiresAt)
 
 		changed = true
 
-		log.Debug().Str("subscription_id", orgSubscription.ID).Str("expires_at", stripeOrgSubscription.ExpiresAt.String()).Msg("subscription expiration changed")
+		log.Debug().Str("subscription_id", orgSubscription.ID).Str("trial_expires_at", stripeOrgSubscription.TrialExpiresAt.String()).Msg("subscription trial expiration changed")
 	}
 
-	if slices.Equal(orgSubscription.FeatureLookupKeys, stripeOrgSubscription.FeatureLookupKeys) {
+	if !slices.Equal(orgSubscription.FeatureLookupKeys, stripeOrgSubscription.FeatureLookupKeys) {
 		mutation.SetFeatureLookupKeys(stripeOrgSubscription.FeatureLookupKeys)
 
 		changed = true
@@ -364,7 +408,11 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 
 		changed = true
 
-		log.Debug().Str("subscription_id", orgSubscription.ID).Bool("payment_method_added", *orgSubscription.PaymentMethodAdded).Msg("payment method added changed")
+		if orgSubscription.PaymentMethodAdded != nil {
+			log.Debug().Str("subscription_id", orgSubscription.ID).Bool("payment_method_added", *orgSubscription.PaymentMethodAdded).Msg("payment method added changed")
+		} else {
+			log.Debug().Str("subscription_id", orgSubscription.ID).Msg("payment method added changed but was previously nil")
+		}
 	}
 
 	if orgSubscription.Active != stripeOrgSubscription.Active {
@@ -376,13 +424,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 	}
 
 	if changed {
-		// Save the updated OrgSubscription
+		// Collect all changes and execute the mutation once
 		err = mutation.Exec(ctx)
 		if err != nil {
 			log.Error().Err(err).Msg("failed to update OrgSubscription")
 
 			return nil, err
 		}
+
+		log.Debug().Str("subscription_id", orgSubscription.ID).Msg("OrgSubscription updated successfully")
 	}
 
 	return &orgSubscription.OwnerID, nil
diff --git a/internal/httpserve/handlers/webhook_test.go b/internal/httpserve/handlers/webhook_test.go
index 02d7d72e22..ca960c914a 100644
--- a/internal/httpserve/handlers/webhook_test.go
+++ b/internal/httpserve/handlers/webhook_test.go
@@ -103,7 +103,7 @@ func (suite *HandlerTestSuite) TestWebhookReceiverHandler() {
 					Raw: json.RawMessage(jsonDataUpdate),
 				},
 			},
-			expectedStatus: http.StatusConflict,
+			expectedStatus: http.StatusBadRequest,
 		},
 		{
 			name: "valid payload - subscription updated",
@@ -148,7 +148,7 @@ func (suite *HandlerTestSuite) TestWebhookReceiverHandler() {
 				Type:       stripe.EventTypeCustomerUpdated,
 				APIVersion: stripe.APIVersion,
 			},
-			expectedStatus: http.StatusBadRequest,
+			expectedStatus: http.StatusOK,
 		},
 	}
 


Reviews:
user: login: golanglemonade
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T16:19:55Z
user: login: golanglemonade
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T16:23:06Z
user: login: matoszz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T16:34:25Z
user: login: golanglemonade
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T16:36:39Z
user: login: matoszz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T16:40:42Z
user: login: golanglemonade
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-19T16:41:20Z
user: login: golanglemonade
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-19T17:54:29Z

Review comments:
diff_hunk: @@ -116,8 +122,10 @@ func (a *Client) checkActiveSubscription(ctx context.Context, orgID string) (act
 	allowCtx := privacy.DecisionContext(ctx, privacy.Allow)
 	allowCtx = contextx.With(allowCtx, auth.OrgSubscriptionContextKey{})
 
-	subscription, err := a.db.OrgSubscription.Query().Select("active").Where(orgsubscription.OwnerID(orgID)).Only(allowCtx)
path: internal/httpserve/authmanager/authmanager.go
user: login: golanglemonade
body: Any reason we need all the fields now instead of just `active`? I dont see changes below that would require this
created_at: 2025-04-19T16:19:54Z
author_association: MEMBER
diff_hunk: @@ -327,15 +371,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("price", orgSubscription.ProductPrice.String()).Msgf("product price changed")
 	}
 
-	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.ExpiresAt != stripeOrgSubscription.ExpiresAt {
-		mutation.SetExpiresAt(*stripeOrgSubscription.ExpiresAt)
+	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.TrialExpiresAt != stripeOrgSubscription.TrialExpiresAt {
+		mutation.SetTrialExpiresAt(*stripeOrgSubscription.ExpiresAt)
 
 		changed = true
 
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("expires_at", stripeOrgSubscription.ExpiresAt.String()).Msg("subscription expiration changed")
path: internal/httpserve/handlers/webhook.go
user: login: golanglemonade
body: Should this also be `TrialExpiresAt`? 

Also - for my information - why is this being changed?
created_at: 2025-04-19T16:23:06Z
author_association: MEMBER
diff_hunk: @@ -327,15 +371,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("price", orgSubscription.ProductPrice.String()).Msgf("product price changed")
 	}
 
-	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.ExpiresAt != stripeOrgSubscription.ExpiresAt {
-		mutation.SetExpiresAt(*stripeOrgSubscription.ExpiresAt)
+	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.TrialExpiresAt != stripeOrgSubscription.TrialExpiresAt {
+		mutation.SetTrialExpiresAt(*stripeOrgSubscription.ExpiresAt)
 
 		changed = true
 
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("expires_at", stripeOrgSubscription.ExpiresAt.String()).Msg("subscription expiration changed")
path: internal/httpserve/handlers/webhook.go
user: login: matoszz
body: There are 2 fields on the orgsubscription schema, the `TrialExpiresAt` and `ExpiresAt` - the `ExpiresAt` is only set / should be set when the subscription is cancelled and there's a prorated number of days remaining within the billing cycle; the `TrialExpiresAt` has a default set on creation, and the trial could also be extended by us manually (in which case we'd push this date out but keep the `active` flag). In the future our subscription check / evaluation logic will need to get more complex to support a la carte feature-based billing where each feature might have a trial period (or maybe not, not 100% sure) so I wanted to be sure we were syncing the dates in the correct fields given 1 of the expiresAt is specific to cancellations, the other is specific to trials
created_at: 2025-04-19T16:34:25Z
author_association: MEMBER
diff_hunk: @@ -327,15 +371,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("price", orgSubscription.ProductPrice.String()).Msgf("product price changed")
 	}
 
-	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.ExpiresAt != stripeOrgSubscription.ExpiresAt {
-		mutation.SetExpiresAt(*stripeOrgSubscription.ExpiresAt)
+	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.TrialExpiresAt != stripeOrgSubscription.TrialExpiresAt {
+		mutation.SetTrialExpiresAt(*stripeOrgSubscription.ExpiresAt)
 
 		changed = true
 
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("expires_at", stripeOrgSubscription.ExpiresAt.String()).Msg("subscription expiration changed")
path: internal/httpserve/handlers/webhook.go
user: login: golanglemonade
body: thanks thats helpful! - should the comment be updated to match the check though?
created_at: 2025-04-19T16:36:39Z
author_association: MEMBER
diff_hunk: @@ -327,15 +371,15 @@ func syncOrgSubscriptionWithStripe(ctx context.Context, subscription *stripe.Sub
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("price", orgSubscription.ProductPrice.String()).Msgf("product price changed")
 	}
 
-	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.ExpiresAt != stripeOrgSubscription.ExpiresAt {
-		mutation.SetExpiresAt(*stripeOrgSubscription.ExpiresAt)
+	if stripeOrgSubscription.ExpiresAt != nil && orgSubscription.TrialExpiresAt != stripeOrgSubscription.TrialExpiresAt {
+		mutation.SetTrialExpiresAt(*stripeOrgSubscription.ExpiresAt)
 
 		changed = true
 
 		log.Debug().Str("subscription_id", orgSubscription.ID).Str("expires_at", stripeOrgSubscription.ExpiresAt.String()).Msg("subscription expiration changed")
path: internal/httpserve/handlers/webhook.go
user: login: matoszz
body: and yes the fields should match lol - pushed an update
created_at: 2025-04-19T16:40:42Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-19T18:11:40Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=theopenlane_core&pullRequest=645) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=theopenlane_core&pullRequest=645&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=theopenlane_core&pullRequest=645&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=theopenlane_core&pullRequest=645&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [64.4% Coverage on New Code](https://sonarcloud.io/component_measures?id=theopenlane_core&pullRequest=645&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=theopenlane_core&pullRequest=645&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=theopenlane_core&pullRequest=645)


author_association: NONE