From a3e05246b616bd2052943ad25fd93b6a5ad4950e Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Mon, 16 Mar 2026 03:40:47 +0000 Subject: [PATCH 1/3] Integrate Stripe subscriptions for user billing - Add stripe-go v76 dependency - Add Stripe config values (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID) to .env - Extend User model with subscription fields (StripeCustomerID, SubscriptionID, SubscriptionStatus, etc.) - Create subscription API endpoints: checkout session, portal session, status, webhook - Modify LoginCallback to redirect unsubscribed users to Stripe Checkout after auth - Replace BusinessMiddleware with SubscriptionMiddleware (checks subscription_status) - Update UserInfo API to return subscription_status and is_subscribed instead of business_mode_enabled - Update frontend TUserInfo type and views to use is_subscribed - Clean up all dead BusinessMode code from backend and frontend - Update tests to reflect new subscription-based gating Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/.env | 6 +- backend/api/dashboard_data_test.go | 4 +- backend/api/dashboard_fetch_test.go | 4 +- backend/api/dashboard_team_members_test.go | 12 +- backend/api/login.go | 57 +++- backend/api/router.go | 14 +- backend/api/subscription.go | 315 ++++++++++++++++++ backend/api/test_utils.go | 12 +- backend/api/user_info.go | 38 ++- backend/api/user_info_test.go | 10 +- backend/api/utils.go | 6 +- backend/api/utils_test.go | 14 +- backend/database/models.go | 7 +- backend/go.mod | 1 + backend/go.sum | 3 + .../navigation_sidebar/IntegrationLinks.tsx | 2 +- .../src/components/views/LeaderboardView.tsx | 2 +- .../components/views/SuperDashboardView.tsx | 2 +- frontend/src/utils/types.ts | 3 +- 19 files changed, 453 insertions(+), 59 deletions(-) create mode 100644 backend/api/subscription.go diff --git a/backend/.env b/backend/.env index c0da0a9175..e5e7557f0b 100644 --- a/backend/.env +++ b/backend/.env @@ -35,4 +35,8 @@ ASANA_OAUTH_CLIENT_SECRET=dummy_value # Open AI only requires secret OPEN_AI_CLIENT_SECRET=dummy_value # Mandrill (Mailchimp) only requires secret -MANDRILL_CLIENT_SECRET=dummy_value \ No newline at end of file +MANDRILL_CLIENT_SECRET=dummy_value +# Stripe subscription config +STRIPE_SECRET_KEY=dummy_value +STRIPE_WEBHOOK_SECRET=dummy_value +STRIPE_PRICE_ID=dummy_value diff --git a/backend/api/dashboard_data_test.go b/backend/api/dashboard_data_test.go index aaa0d78056..976f0cdf93 100644 --- a/backend/api/dashboard_data_test.go +++ b/backend/api/dashboard_data_test.go @@ -132,8 +132,8 @@ func TestDashboardData(t *testing.T) { }) assert.NoError(t, err) UnauthorizedTest(t, "GET", "/dashboard/data/", nil) - NoBusinessAccessTest(t, "GET", "/dashboard/data/", api, authToken) - EnableBusinessAccess(t, api, userID) + NoSubscriptionAccessTest(t, "GET", "/dashboard/data/", api, authToken) + EnableSubscriptionAccess(t, api, userID) t.Run("Success", func(t *testing.T) { request, _ := http.NewRequest("GET", "/dashboard/data/", nil) request.Header.Add("Authorization", "Bearer "+authToken) diff --git a/backend/api/dashboard_fetch_test.go b/backend/api/dashboard_fetch_test.go index 07f7866131..ccc6837587 100644 --- a/backend/api/dashboard_fetch_test.go +++ b/backend/api/dashboard_fetch_test.go @@ -127,8 +127,8 @@ func TestDashboardFetch(t *testing.T) { }) assert.NoError(t, err) - NoBusinessAccessTest(t, "GET", "/dashboard/data/fetch/", api, authToken) - EnableBusinessAccess(t, api, userID) + NoSubscriptionAccessTest(t, "GET", "/dashboard/data/fetch/", api, authToken) + EnableSubscriptionAccess(t, api, userID) t.Run("Success", func(t *testing.T) { request, _ := http.NewRequest("GET", "/dashboard/data/fetch/", nil) diff --git a/backend/api/dashboard_team_members_test.go b/backend/api/dashboard_team_members_test.go index 1a09aa6002..687da1acd0 100644 --- a/backend/api/dashboard_team_members_test.go +++ b/backend/api/dashboard_team_members_test.go @@ -24,8 +24,8 @@ func TestDashboardTeamMemberCreate(t *testing.T) { userID := getUserIDFromAuthToken(t, api.DB, authToken) UnauthorizedTest(t, "POST", "/dashboard/team_members/", nil) - NoBusinessAccessTest(t, "POST", "/dashboard/team_members/", api, authToken) - EnableBusinessAccess(t, api, userID) + NoSubscriptionAccessTest(t, "POST", "/dashboard/team_members/", api, authToken) + EnableSubscriptionAccess(t, api, userID) t.Run("MissingName", func(t *testing.T) { database.GetDashboardTeamMemberCollection(api.DB).DeleteMany(context.Background(), bson.M{}) bodyParams, err := json.Marshal(DashboardTeamMemberCreateParams{ @@ -130,8 +130,8 @@ func TestDashboardTeamMemberDelete(t *testing.T) { teamMemberCollection := database.GetDashboardTeamMemberCollection(api.DB) UnauthorizedTest(t, "DELETE", "/dashboard/team_members/id/", nil) - NoBusinessAccessTest(t, "DELETE", "/dashboard/team_members/id/", api, authToken) - EnableBusinessAccess(t, api, userID) + NoSubscriptionAccessTest(t, "DELETE", "/dashboard/team_members/id/", api, authToken) + EnableSubscriptionAccess(t, api, userID) t.Run("InvalidID", func(t *testing.T) { ServeRequest(t, authToken, "DELETE", "/dashboard/team_members/123/", nil, http.StatusNotFound, api) }) @@ -165,8 +165,8 @@ func TestDashboardTeamMemberList(t *testing.T) { teamMemberCollection := database.GetDashboardTeamMemberCollection(api.DB) UnauthorizedTest(t, "GET", "/dashboard/team_members/", nil) - NoBusinessAccessTest(t, "GET", "/dashboard/team_members/", api, authToken) - EnableBusinessAccess(t, api, userID) + NoSubscriptionAccessTest(t, "GET", "/dashboard/team_members/", api, authToken) + EnableSubscriptionAccess(t, api, userID) t.Run("SuccessNoResults", func(t *testing.T) { teamMemberCollection.DeleteMany(context.Background(), bson.M{}) response := ServeRequest(t, authToken, "GET", "/dashboard/team_members/", nil, http.StatusOK, api) diff --git a/backend/api/login.go b/backend/api/login.go index 7fd16b29f0..d7ebc18b51 100644 --- a/backend/api/login.go +++ b/backend/api/login.go @@ -11,6 +11,9 @@ import ( "github.com/GeneralTask/task-manager/backend/external" "github.com/gin-gonic/gin" guuid "github.com/google/uuid" + "github.com/stripe/stripe-go/v76" + checkoutsession "github.com/stripe/stripe-go/v76/checkout/session" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) @@ -158,11 +161,63 @@ func (api *API) LoginCallback(c *gin.Context) { if userIsNew != nil && *userIsNew { c.Redirect(302, config.GetConfigValue("HOME_URL")+"tos-summary") } else { - c.Redirect(302, config.GetConfigValue("HOME_URL")) + // Check subscription status and redirect to Stripe Checkout if not subscribed + redirectURL := getPostLoginRedirectURL(api, userID) + c.Redirect(302, redirectURL) } } } +func getPostLoginRedirectURL(api *API, userID primitive.ObjectID) string { + userCollection := database.GetUserCollection(api.DB) + var userObject database.User + err := userCollection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&userObject) + if err != nil { + api.Logger.Error().Err(err).Msg("failed to find user for subscription check on login") + return config.GetConfigValue("HOME_URL") + } + + if isUserSubscribed(&userObject) { + return config.GetConfigValue("HOME_URL") + } + + // User is not subscribed — create a Stripe Checkout session and redirect + stripeCustomerID, err := getOrCreateStripeCustomer(api, &userObject) + if err != nil { + api.Logger.Error().Err(err).Msg("failed to create stripe customer on login") + return config.GetConfigValue("HOME_URL") + } + + stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") + priceID := config.GetConfigValue("STRIPE_PRICE_ID") + homeURL := config.GetConfigValue("HOME_URL") + + params := &stripe.CheckoutSessionParams{ + Customer: stripe.String(stripeCustomerID), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + SuccessURL: stripe.String(homeURL + "?subscription=success"), + CancelURL: stripe.String(homeURL + "?subscription=canceled"), + AllowPromotionCodes: stripe.Bool(true), + SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ + TrialPeriodDays: stripe.Int64(14), + }, + } + + session, err := checkoutsession.New(params) + if err != nil { + api.Logger.Error().Err(err).Msg("failed to create checkout session on login redirect") + return config.GetConfigValue("HOME_URL") + } + + return session.URL +} + func createNewUserTasks(userID primitive.ObjectID, db *mongo.Database) error { taskCollection := database.GetTaskCollection(db) for index, title := range constants.StarterTasks { diff --git a/backend/api/router.go b/backend/api/router.go index c2e3879464..d6d006acec 100644 --- a/backend/api/router.go +++ b/backend/api/router.go @@ -46,6 +46,9 @@ func GetRouter(handlers *API) *gin.Engine { router.POST("/linear/webhook/", handlers.LinearWebhook) + // Stripe webhook (unauthenticated, signature-verified) + router.POST("/subscriptions/webhook/", handlers.StripeWebhook) + // Slack App (Workspace level) endpoint for oauth verification // We need this as we don't actually use the token provided, but still need to access it to // successfully install our app in a new Workspace @@ -137,14 +140,19 @@ func GetRouter(handlers *API) *gin.Engine { router.GET("/daily_task_completion/", handlers.DailyTaskCompletionList) - // Add business middleware. Endpoints below this require business mode to be enabled - router.Use(BusinessMiddleware(handlers.DB)) + // Subscription management endpoints + router.GET("/subscriptions/status/", handlers.SubscriptionStatus) + router.POST("/subscriptions/create-checkout-session/", handlers.CreateCheckoutSession) + router.POST("/subscriptions/create-portal-session/", handlers.CreatePortalSession) + + // Add subscription middleware. Endpoints below this require an active subscription + router.Use(SubscriptionMiddleware(handlers.DB)) router.GET("/dashboard/data/", handlers.DashboardData) router.GET("/dashboard/team_members/", handlers.DashboardTeamMembersList) router.POST("/dashboard/team_members/", handlers.DashboardTeamMemberCreate) router.DELETE("/dashboard/team_members/:team_member_id/", handlers.DashboardTeamMemberDelete) router.GET("/dashboard/data/fetch/", handlers.DashboardFetch) - router.GET("/ping_business/", handlers.Ping) + router.GET("/ping_subscribed/", handlers.Ping) return router } diff --git a/backend/api/subscription.go b/backend/api/subscription.go new file mode 100644 index 0000000000..bfdb29d70a --- /dev/null +++ b/backend/api/subscription.go @@ -0,0 +1,315 @@ +package api + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "time" + + "github.com/GeneralTask/task-manager/backend/config" + "github.com/GeneralTask/task-manager/backend/database" + "github.com/GeneralTask/task-manager/backend/logging" + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v76" + billingportalsession "github.com/stripe/stripe-go/v76/billingportal/session" + checkoutsession "github.com/stripe/stripe-go/v76/checkout/session" + "github.com/stripe/stripe-go/v76/customer" + "github.com/stripe/stripe-go/v76/webhook" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + SubscriptionStatusActive = "active" + SubscriptionStatusTrialing = "trialing" +) + +func init() { + stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") +} + +// SubscriptionStatus godoc +// @Summary Returns the user's subscription status +// @Description Returns subscription status, plan, and expiry +// @Tags subscription +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} string "internal server error" +// @Router /subscriptions/status/ [get] +func (api *API) SubscriptionStatus(c *gin.Context) { + userID := getUserIDFromContext(c) + userCollection := database.GetUserCollection(api.DB) + var userObject database.User + err := userCollection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&userObject) + if err != nil { + api.Logger.Error().Err(err).Msg("failed to find user for subscription status") + Handle500(c) + return + } + + c.JSON(200, gin.H{ + "subscription_status": userObject.SubscriptionStatus, + "subscription_id": userObject.SubscriptionID, + "subscription_price_id": userObject.SubscriptionPriceID, + "subscription_current_period_end": userObject.SubscriptionCurrentPeriodEnd, + "is_subscribed": isUserSubscribed(&userObject), + }) +} + +// CreateCheckoutSession godoc +// @Summary Creates a Stripe Checkout session for subscribing +// @Description Returns the checkout URL to redirect the user to +// @Tags subscription +// @Success 200 {object} map[string]string +// @Failure 500 {object} string "internal server error" +// @Router /subscriptions/create-checkout-session/ [post] +func (api *API) CreateCheckoutSession(c *gin.Context) { + logger := logging.GetSentryLogger() + stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") + + userID := getUserIDFromContext(c) + userCollection := database.GetUserCollection(api.DB) + var userObject database.User + err := userCollection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&userObject) + if err != nil { + logger.Error().Err(err).Msg("failed to find user for checkout session") + Handle500(c) + return + } + + // Create or retrieve Stripe customer + stripeCustomerID, err := getOrCreateStripeCustomer(api, &userObject) + if err != nil { + logger.Error().Err(err).Msg("failed to create/retrieve stripe customer") + Handle500(c) + return + } + + priceID := config.GetConfigValue("STRIPE_PRICE_ID") + homeURL := config.GetConfigValue("HOME_URL") + + params := &stripe.CheckoutSessionParams{ + Customer: stripe.String(stripeCustomerID), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + SuccessURL: stripe.String(homeURL + "?subscription=success"), + CancelURL: stripe.String(homeURL + "?subscription=canceled"), + AllowPromotionCodes: stripe.Bool(true), + SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ + TrialPeriodDays: stripe.Int64(14), + }, + } + + session, err := checkoutsession.New(params) + if err != nil { + logger.Error().Err(err).Msg("failed to create checkout session") + Handle500(c) + return + } + + c.JSON(200, gin.H{"checkout_url": session.URL}) +} + +// CreatePortalSession godoc +// @Summary Creates a Stripe Customer Portal session +// @Description Returns the portal URL to redirect the user to manage their subscription +// @Tags subscription +// @Success 200 {object} map[string]string +// @Failure 500 {object} string "internal server error" +// @Router /subscriptions/create-portal-session/ [post] +func (api *API) CreatePortalSession(c *gin.Context) { + logger := logging.GetSentryLogger() + stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") + + userID := getUserIDFromContext(c) + userCollection := database.GetUserCollection(api.DB) + var userObject database.User + err := userCollection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&userObject) + if err != nil { + logger.Error().Err(err).Msg("failed to find user for portal session") + Handle500(c) + return + } + + if userObject.StripeCustomerID == "" { + c.JSON(400, gin.H{"detail": "no stripe customer found for this user"}) + return + } + + homeURL := config.GetConfigValue("HOME_URL") + params := &stripe.BillingPortalSessionParams{ + Customer: stripe.String(userObject.StripeCustomerID), + ReturnURL: stripe.String(homeURL), + } + + session, err := billingportalsession.New(params) + if err != nil { + logger.Error().Err(err).Msg("failed to create portal session") + Handle500(c) + return + } + + c.JSON(200, gin.H{"portal_url": session.URL}) +} + +// StripeWebhook godoc +// @Summary Handles Stripe webhook events +// @Description Processes subscription lifecycle events from Stripe +// @Tags subscription +// @Success 200 {object} string +// @Failure 400 {object} string "bad request" +// @Router /subscriptions/webhook/ [post] +func (api *API) StripeWebhook(c *gin.Context) { + logger := logging.GetSentryLogger() + + body, err := ioutil.ReadAll(c.Request.Body) + if err != nil { + logger.Error().Err(err).Msg("failed to read webhook body") + c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to read body"}) + return + } + + webhookSecret := config.GetConfigValue("STRIPE_WEBHOOK_SECRET") + event, err := webhook.ConstructEvent(body, c.Request.Header.Get("Stripe-Signature"), webhookSecret) + if err != nil { + logger.Error().Err(err).Msg("failed to verify webhook signature") + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid signature"}) + return + } + + switch event.Type { + case "checkout.session.completed": + var session stripe.CheckoutSession + err := json.Unmarshal(event.Data.Raw, &session) + if err != nil { + logger.Error().Err(err).Msg("failed to unmarshal checkout session") + c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to parse event"}) + return + } + if session.Subscription != nil { + err = updateUserSubscription(api, session.Customer.ID, session.Subscription.ID, "active") + if err != nil { + logger.Error().Err(err).Msg("failed to update subscription after checkout") + } + } + + case "customer.subscription.updated": + var subscription stripe.Subscription + err := json.Unmarshal(event.Data.Raw, &subscription) + if err != nil { + logger.Error().Err(err).Msg("failed to unmarshal subscription update") + c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to parse event"}) + return + } + priceID := "" + if len(subscription.Items.Data) > 0 { + priceID = subscription.Items.Data[0].Price.ID + } + periodEnd := primitive.NewDateTimeFromTime(time.Unix(subscription.CurrentPeriodEnd, 0)) + err = updateUserSubscriptionFull(api, subscription.Customer.ID, subscription.ID, string(subscription.Status), priceID, periodEnd) + if err != nil { + logger.Error().Err(err).Msg("failed to update subscription") + } + + case "customer.subscription.deleted": + var subscription stripe.Subscription + err := json.Unmarshal(event.Data.Raw, &subscription) + if err != nil { + logger.Error().Err(err).Msg("failed to unmarshal subscription deletion") + c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to parse event"}) + return + } + err = updateUserSubscription(api, subscription.Customer.ID, subscription.ID, "canceled") + if err != nil { + logger.Error().Err(err).Msg("failed to update subscription after deletion") + } + + case "invoice.payment_failed": + var invoice stripe.Invoice + err := json.Unmarshal(event.Data.Raw, &invoice) + if err != nil { + logger.Error().Err(err).Msg("failed to unmarshal invoice") + c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to parse event"}) + return + } + if invoice.Subscription != nil { + err = updateUserSubscription(api, invoice.Customer.ID, invoice.Subscription.ID, "past_due") + if err != nil { + logger.Error().Err(err).Msg("failed to update subscription after payment failure") + } + } + } + + c.JSON(200, gin.H{"received": true}) +} + +// getOrCreateStripeCustomer ensures the user has a Stripe customer record +func getOrCreateStripeCustomer(api *API, user *database.User) (string, error) { + if user.StripeCustomerID != "" { + return user.StripeCustomerID, nil + } + + stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") + params := &stripe.CustomerParams{ + Email: stripe.String(user.Email), + Name: stripe.String(user.Name), + } + params.AddMetadata("general_task_user_id", user.ID.Hex()) + + cust, err := customer.New(params) + if err != nil { + return "", err + } + + userCollection := database.GetUserCollection(api.DB) + _, err = userCollection.UpdateOne( + context.Background(), + bson.M{"_id": user.ID}, + bson.M{"$set": bson.M{"stripe_customer_id": cust.ID}}, + ) + if err != nil { + return "", err + } + + return cust.ID, nil +} + +// updateUserSubscription updates basic subscription status by Stripe customer ID +func updateUserSubscription(api *API, stripeCustomerID string, subscriptionID string, status string) error { + userCollection := database.GetUserCollection(api.DB) + _, err := userCollection.UpdateOne( + context.Background(), + bson.M{"stripe_customer_id": stripeCustomerID}, + bson.M{"$set": bson.M{ + "subscription_id": subscriptionID, + "subscription_status": status, + }}, + ) + return err +} + +// updateUserSubscriptionFull updates all subscription fields by Stripe customer ID +func updateUserSubscriptionFull(api *API, stripeCustomerID string, subscriptionID string, status string, priceID string, periodEnd primitive.DateTime) error { + userCollection := database.GetUserCollection(api.DB) + _, err := userCollection.UpdateOne( + context.Background(), + bson.M{"stripe_customer_id": stripeCustomerID}, + bson.M{"$set": bson.M{ + "subscription_id": subscriptionID, + "subscription_status": status, + "subscription_price_id": priceID, + "subscription_current_period_end": periodEnd, + }}, + ) + return err +} + +// isUserSubscribed checks if a user has an active or trialing subscription +func isUserSubscribed(user *database.User) bool { + return user.SubscriptionStatus == "active" || user.SubscriptionStatus == "trialing" +} diff --git a/backend/api/test_utils.go b/backend/api/test_utils.go index 92246b9b1e..210041dbf7 100644 --- a/backend/api/test_utils.go +++ b/backend/api/test_utils.go @@ -242,12 +242,12 @@ func runAuthenticatedEndpoint(attemptedHeader string) *httptest.ResponseRecorder return recorder } -func runBusinessEndpoint(attemptedHeader string) *httptest.ResponseRecorder { +func runSubscribedEndpoint(attemptedHeader string) *httptest.ResponseRecorder { api, dbCleanup := GetAPIWithDBCleanup() defer dbCleanup() router := GetRouter(api) - request, _ := http.NewRequest("GET", "/ping_business/", nil) + request, _ := http.NewRequest("GET", "/ping_subscribed/", nil) request.Header.Add("Authorization", attemptedHeader) recorder := httptest.NewRecorder() @@ -290,13 +290,13 @@ func UnauthorizedTest(t *testing.T, method string, url string, body io.Reader) b }) } -func NoBusinessAccessTest(t *testing.T, method string, url string, api *API, authToken string) { - t.Run("NoBusinessAccess", func(t *testing.T) { +func NoSubscriptionAccessTest(t *testing.T, method string, url string, api *API, authToken string) { + t.Run("NoSubscriptionAccess", func(t *testing.T) { ServeRequest(t, authToken, method, url, nil, http.StatusForbidden, api) }) } -func EnableBusinessAccess(t *testing.T, api *API, userID primitive.ObjectID) { - _, err := database.GetUserCollection(api.DB).UpdateOne(context.Background(), bson.M{"_id": userID}, bson.M{"$set": bson.M{"business_mode_enabled": true}}) +func EnableSubscriptionAccess(t *testing.T, api *API, userID primitive.ObjectID) { + _, err := database.GetUserCollection(api.DB).UpdateOne(context.Background(), bson.M{"_id": userID}, bson.M{"$set": bson.M{"subscription_status": "active"}}) assert.NoError(t, err) } diff --git a/backend/api/user_info.go b/backend/api/user_info.go index 526c0ba50b..dc286c3cf5 100644 --- a/backend/api/user_info.go +++ b/backend/api/user_info.go @@ -11,15 +11,16 @@ import ( ) type UserInfo struct { - AgreedToTerms bool `json:"agreed_to_terms"` - OptedIntoMarketing bool `json:"opted_into_marketing"` - BusinessModeEnabled bool `json:"business_mode_enabled"` - Name string `json:"name"` - IsEmployee bool `json:"is_employee"` - Email string `json:"email"` - IsCompanyEmail bool `json:"is_company_email"` - LinearName string `json:"linear_name,omitempty"` - LinearDisplayName string `json:"linear_display_name,omitempty"` + AgreedToTerms bool `json:"agreed_to_terms"` + OptedIntoMarketing bool `json:"opted_into_marketing"` + Name string `json:"name"` + IsEmployee bool `json:"is_employee"` + Email string `json:"email"` + IsCompanyEmail bool `json:"is_company_email"` + LinearName string `json:"linear_name,omitempty"` + LinearDisplayName string `json:"linear_display_name,omitempty"` + SubscriptionStatus string `json:"subscription_status,omitempty"` + IsSubscribed bool `json:"is_subscribed"` } type UserInfoParams struct { @@ -39,15 +40,16 @@ func (api *API) UserInfoGet(c *gin.Context) { return } c.JSON(200, UserInfo{ - AgreedToTerms: userObject.AgreedToTerms != nil && *userObject.AgreedToTerms, - OptedIntoMarketing: userObject.OptedIntoMarketing != nil && *userObject.OptedIntoMarketing, - BusinessModeEnabled: userObject.BusinessModeEnabled != nil && *userObject.BusinessModeEnabled, - Name: userObject.Name, - IsEmployee: strings.HasSuffix(strings.ToLower(userObject.Email), "@generaltask.com"), - Email: userObject.Email, - IsCompanyEmail: isCompanyEmail(userObject.Email), - LinearName: userObject.LinearName, - LinearDisplayName: userObject.LinearDisplayName, + AgreedToTerms: userObject.AgreedToTerms != nil && *userObject.AgreedToTerms, + OptedIntoMarketing: userObject.OptedIntoMarketing != nil && *userObject.OptedIntoMarketing, + Name: userObject.Name, + IsEmployee: strings.HasSuffix(strings.ToLower(userObject.Email), "@generaltask.com"), + Email: userObject.Email, + IsCompanyEmail: isCompanyEmail(userObject.Email), + LinearName: userObject.LinearName, + LinearDisplayName: userObject.LinearDisplayName, + SubscriptionStatus: userObject.SubscriptionStatus, + IsSubscribed: isUserSubscribed(&userObject), }) } diff --git a/backend/api/user_info_test.go b/backend/api/user_info_test.go index 70123f1782..aef7c9d823 100644 --- a/backend/api/user_info_test.go +++ b/backend/api/user_info_test.go @@ -44,7 +44,7 @@ func TestUserInfo(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) body, err := io.ReadAll(recorder.Body) assert.NoError(t, err) - assert.Equal(t, `{"agreed_to_terms":false,"opted_into_marketing":false,"business_mode_enabled":false,"name":"name","is_employee":true,"email":"userinfo@generaltask.com","is_company_email":true,"linear_name":"linearName","linear_display_name":"linearDisplayName"}`, string(body)) + assert.Equal(t, `{"agreed_to_terms":false,"opted_into_marketing":false,"name":"name","is_employee":true,"email":"userinfo@generaltask.com","is_company_email":true,"linear_name":"linearName","linear_display_name":"linearDisplayName","is_subscribed":false}`, string(body)) }) authToken = login("userinfo2@generaltask.com", "") t.Run("SuccessNonEmployee", func(t *testing.T) { @@ -59,7 +59,7 @@ func TestUserInfo(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) body, err := io.ReadAll(recorder.Body) assert.NoError(t, err) - assert.Equal(t, "{\"agreed_to_terms\":false,\"opted_into_marketing\":false,\"business_mode_enabled\":false,\"name\":\"\",\"is_employee\":false,\"email\":\"userinfo@gmail.com\",\"is_company_email\":false}", string(body)) + assert.Equal(t, "{\"agreed_to_terms\":false,\"opted_into_marketing\":false,\"name\":\"\",\"is_employee\":false,\"email\":\"userinfo@gmail.com\",\"is_company_email\":false,\"is_subscribed\":false}", string(body)) }) UnauthorizedTest(t, "PATCH", "/user_info/", nil) t.Run("EmptyPayload", func(t *testing.T) { @@ -98,7 +98,7 @@ func TestUserInfo(t *testing.T) { request, _ := http.NewRequest( "PATCH", "/user_info/", - bytes.NewBuffer([]byte(`{"agreed_to_terms":true,"opted_into_marketing":true,"business_mode_enabled":true}`))) + bytes.NewBuffer([]byte(`{"agreed_to_terms":true,"opted_into_marketing":true}`))) request.Header.Add("Authorization", "Bearer "+authToken) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) @@ -115,7 +115,7 @@ func TestUserInfo(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) body, err = io.ReadAll(recorder.Body) assert.NoError(t, err) - assert.Equal(t, "{\"agreed_to_terms\":true,\"opted_into_marketing\":true,\"business_mode_enabled\":false,\"name\":\"\",\"is_employee\":true,\"email\":\"userinfo2@generaltask.com\",\"is_company_email\":true}", string(body)) + assert.Equal(t, "{\"agreed_to_terms\":true,\"opted_into_marketing\":true,\"name\":\"\",\"is_employee\":true,\"email\":\"userinfo2@generaltask.com\",\"is_company_email\":true,\"is_subscribed\":false}", string(body)) }) t.Run("SuccessPartialUpdate", func(t *testing.T) { // assuming the fields are still true as above @@ -142,6 +142,6 @@ func TestUserInfo(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) body, err = io.ReadAll(recorder.Body) assert.NoError(t, err) - assert.Equal(t, "{\"agreed_to_terms\":true,\"opted_into_marketing\":false,\"business_mode_enabled\":false,\"name\":\"\",\"is_employee\":true,\"email\":\"userinfo2@generaltask.com\",\"is_company_email\":true}", string(body)) + assert.Equal(t, "{\"agreed_to_terms\":true,\"opted_into_marketing\":false,\"name\":\"\",\"is_employee\":true,\"email\":\"userinfo2@generaltask.com\",\"is_company_email\":true,\"is_subscribed\":false}", string(body)) }) } diff --git a/backend/api/utils.go b/backend/api/utils.go index 728c8e40d0..8cea5e57f3 100644 --- a/backend/api/utils.go +++ b/backend/api/utils.go @@ -68,7 +68,7 @@ func (api *API) Ping(c *gin.Context) { c.JSON(200, "success") } -func BusinessMiddleware(db *mongo.Database) func(c *gin.Context) { +func SubscriptionMiddleware(db *mongo.Database) func(c *gin.Context) { return func(c *gin.Context) { handlerName := c.HandlerName() if handlerName[len(handlerName)-9:] == "Handle404" { @@ -79,8 +79,8 @@ func BusinessMiddleware(db *mongo.Database) func(c *gin.Context) { userCollection := database.GetUserCollection(db) var userObject database.User err := userCollection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&userObject) - if err != nil || userObject.BusinessModeEnabled == nil || !*userObject.BusinessModeEnabled { - c.AbortWithStatusJSON(403, gin.H{"detail": "business access is required to use this endpoint"}) + if err != nil || !isUserSubscribed(&userObject) { + c.AbortWithStatusJSON(403, gin.H{"detail": "an active subscription is required to use this endpoint"}) return } } diff --git a/backend/api/utils_test.go b/backend/api/utils_test.go index 04d1d32241..f0e7669676 100644 --- a/backend/api/utils_test.go +++ b/backend/api/utils_test.go @@ -104,27 +104,27 @@ func TestAuthenticationMiddleware(t *testing.T) { }) } -func TestBusinessMiddleware(t *testing.T) { - authToken := login("test_business_middleware@generaltask.com", "") +func TestSubscriptionMiddleware(t *testing.T) { + authToken := login("test_subscription_middleware@generaltask.com", "") t.Run("Forbidden", func(t *testing.T) { - recorder := runBusinessEndpoint("Bearer " + authToken) + recorder := runSubscribedEndpoint("Bearer " + authToken) assert.Equal(t, http.StatusForbidden, recorder.Code) body, err := io.ReadAll(recorder.Body) assert.NoError(t, err) - assert.Equal(t, "{\"detail\":\"business access is required to use this endpoint\"}", string(body)) + assert.Equal(t, "{\"detail\":\"an active subscription is required to use this endpoint\"}", string(body)) }) t.Run("Success", func(t *testing.T) { - // update to enable business mode flag + // update to enable subscription db, dbCleanup, err := database.GetDBConnection() assert.NoError(t, err) defer dbCleanup() userID := getUserIDFromAuthToken(t, db, authToken) - _, err = database.GetUserCollection(db).UpdateOne(context.Background(), bson.M{"_id": userID}, bson.M{"$set": bson.M{"business_mode_enabled": true}}) + _, err = database.GetUserCollection(db).UpdateOne(context.Background(), bson.M{"_id": userID}, bson.M{"$set": bson.M{"subscription_status": "active"}}) assert.NoError(t, err) - recorder := runBusinessEndpoint("Bearer " + authToken) + recorder := runSubscribedEndpoint("Bearer " + authToken) assert.Equal(t, http.StatusOK, recorder.Code) body, err := io.ReadAll(recorder.Body) assert.NoError(t, err) diff --git a/backend/database/models.go b/backend/database/models.go index dfd172024e..b96f47a7eb 100644 --- a/backend/database/models.go +++ b/backend/database/models.go @@ -15,12 +15,17 @@ type User struct { LastRefreshed primitive.DateTime `bson:"last_refreshed,omitempty"` AgreedToTerms *bool `bson:"agreed_to_terms,omitempty"` OptedIntoMarketing *bool `bson:"opted_into_marketing,omitempty"` - BusinessModeEnabled *bool `bson:"business_mode_enabled,omitempty"` CreatedAt primitive.DateTime `bson:"created_at,omitempty"` LinearName string `bson:"linear_name"` LinearDisplayName string `bson:"linear_display_name"` GPTSuggestionsLeft int `bson:"gpt_suggestions_left"` GPTLastSuggestionTime primitive.DateTime `bson:"gpt_last_suggestion_time"` + // Stripe subscription fields + StripeCustomerID string `bson:"stripe_customer_id,omitempty"` + SubscriptionID string `bson:"subscription_id,omitempty"` + SubscriptionStatus string `bson:"subscription_status,omitempty"` + SubscriptionPriceID string `bson:"subscription_price_id,omitempty"` + SubscriptionCurrentPeriodEnd primitive.DateTime `bson:"subscription_current_period_end,omitempty"` } type UserChangeable struct { diff --git a/backend/go.mod b/backend/go.mod index 0d35b68dce..804c7399e1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -31,6 +31,7 @@ require ( github.com/go-co-op/gocron v1.18.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/square/mongo-lock v0.0.0-20220601164918-701ecf357cd7 // indirect + github.com/stripe/stripe-go/v76 v76.25.0 // indirect ) require ( diff --git a/backend/go.sum b/backend/go.sum index b9dd58ec8e..d2f9ef7b21 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -624,6 +624,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA= +github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4= github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= github.com/swaggo/gin-swagger v1.5.0 h1:hlLbxPj6qvbtX2wpbsZuOIlcnPRCUDGccA0zMKVNpME= @@ -813,6 +815,7 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA= diff --git a/frontend/src/components/navigation_sidebar/IntegrationLinks.tsx b/frontend/src/components/navigation_sidebar/IntegrationLinks.tsx index f599170d33..a5ee06ced9 100644 --- a/frontend/src/components/navigation_sidebar/IntegrationLinks.tsx +++ b/frontend/src/components/navigation_sidebar/IntegrationLinks.tsx @@ -104,7 +104,7 @@ const IntegrationLinks = ({ isCollapsed }: IntegrationLinksProps) => { isCollapsed={isCollapsed} /> - {userInfo?.business_mode_enabled && ( + {userInfo?.is_subscribed && ( <> { const { data: userInfo, isLoading: isUserInfoLoading } = useGetUserInfo() - if (!userInfo?.business_mode_enabled && !isUserInfoLoading) { + if (!userInfo?.is_subscribed && !isUserInfoLoading) { return } diff --git a/frontend/src/components/views/SuperDashboardView.tsx b/frontend/src/components/views/SuperDashboardView.tsx index 36035d2058..19b167ab6b 100644 --- a/frontend/src/components/views/SuperDashboardView.tsx +++ b/frontend/src/components/views/SuperDashboardView.tsx @@ -22,7 +22,7 @@ const SuperDashboardView = () => { const { data: dashboard, isLoading: isDashboardLoading } = useGetDashboardData() useFetchExternalDashboardData() - if (!userInfo?.business_mode_enabled && !isUserInfoLoading) { + if (!userInfo?.is_subscribed && !isUserInfoLoading) { return } if (isDashboardLoading || !dashboard || isUserInfoLoading) { diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index c787ed1d1f..8679305988 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -300,7 +300,8 @@ export interface TUserInfo { is_company_email: boolean linear_name?: string linear_display_name?: string - business_mode_enabled?: boolean + subscription_status?: string + is_subscribed?: boolean } // React-DND Item Types From 3bfa8f025170510e5107991123a49f6c4b91e8fd Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Mon, 16 Mar 2026 03:41:44 +0000 Subject: [PATCH 2/3] Fix deprecated ioutil.ReadAll and remove redundant stripe.Key assignments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/api/subscription.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/api/subscription.go b/backend/api/subscription.go index bfdb29d70a..1b47c8136a 100644 --- a/backend/api/subscription.go +++ b/backend/api/subscription.go @@ -3,7 +3,7 @@ package api import ( "context" "encoding/json" - "io/ioutil" + "io" "net/http" "time" @@ -65,7 +65,6 @@ func (api *API) SubscriptionStatus(c *gin.Context) { // @Router /subscriptions/create-checkout-session/ [post] func (api *API) CreateCheckoutSession(c *gin.Context) { logger := logging.GetSentryLogger() - stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") userID := getUserIDFromContext(c) userCollection := database.GetUserCollection(api.DB) @@ -124,7 +123,6 @@ func (api *API) CreateCheckoutSession(c *gin.Context) { // @Router /subscriptions/create-portal-session/ [post] func (api *API) CreatePortalSession(c *gin.Context) { logger := logging.GetSentryLogger() - stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") userID := getUserIDFromContext(c) userCollection := database.GetUserCollection(api.DB) @@ -167,7 +165,7 @@ func (api *API) CreatePortalSession(c *gin.Context) { func (api *API) StripeWebhook(c *gin.Context) { logger := logging.GetSentryLogger() - body, err := ioutil.ReadAll(c.Request.Body) + body, err := io.ReadAll(c.Request.Body) if err != nil { logger.Error().Err(err).Msg("failed to read webhook body") c.JSON(http.StatusBadRequest, gin.H{"detail": "failed to read body"}) @@ -253,8 +251,6 @@ func getOrCreateStripeCustomer(api *API, user *database.User) (string, error) { if user.StripeCustomerID != "" { return user.StripeCustomerID, nil } - - stripe.Key = config.GetConfigValue("STRIPE_SECRET_KEY") params := &stripe.CustomerParams{ Email: stripe.String(user.Email), Name: stripe.String(user.Name), From 3c86a59821f304d8f18b9fecc14c025d9fcce4f8 Mon Sep 17 00:00:00 2001 From: John Reinstra Date: Mon, 16 Mar 2026 03:51:31 +0000 Subject: [PATCH 3/3] Add unit tests for subscription.go Tests cover: - isUserSubscribed with all status variants (active, trialing, canceled, past_due, empty, unknown) - SubscriptionStatus endpoint (no subscription, active, trialing, canceled) - CreateCheckoutSession unauthorized access - CreatePortalSession unauthorized access and no-stripe-customer error case - StripeWebhook with empty body and invalid signature - updateUserSubscription (active, canceled, past_due transitions) - updateUserSubscriptionFull (all fields updated, field transitions) - getOrCreateStripeCustomer with existing customer ID - SubscriptionStatus constants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/api/subscription_test.go | 421 +++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 backend/api/subscription_test.go diff --git a/backend/api/subscription_test.go b/backend/api/subscription_test.go new file mode 100644 index 0000000000..bfde95c85c --- /dev/null +++ b/backend/api/subscription_test.go @@ -0,0 +1,421 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GeneralTask/task-manager/backend/database" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestIsUserSubscribed(t *testing.T) { + t.Run("ActiveSubscription", func(t *testing.T) { + user := &database.User{SubscriptionStatus: "active"} + assert.True(t, isUserSubscribed(user)) + }) + t.Run("TrialingSubscription", func(t *testing.T) { + user := &database.User{SubscriptionStatus: "trialing"} + assert.True(t, isUserSubscribed(user)) + }) + t.Run("CanceledSubscription", func(t *testing.T) { + user := &database.User{SubscriptionStatus: "canceled"} + assert.False(t, isUserSubscribed(user)) + }) + t.Run("PastDueSubscription", func(t *testing.T) { + user := &database.User{SubscriptionStatus: "past_due"} + assert.False(t, isUserSubscribed(user)) + }) + t.Run("EmptySubscription", func(t *testing.T) { + user := &database.User{SubscriptionStatus: ""} + assert.False(t, isUserSubscribed(user)) + }) + t.Run("RandomStatus", func(t *testing.T) { + user := &database.User{SubscriptionStatus: "unknown_status"} + assert.False(t, isUserSubscribed(user)) + }) +} + +func TestSubscriptionStatusEndpoint(t *testing.T) { + authToken := login("subscription_status_test@generaltask.com", "") + UnauthorizedTest(t, "GET", "/subscriptions/status/", nil) + + t.Run("NoSubscription", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + router := GetRouter(api) + + request, _ := http.NewRequest("GET", "/subscriptions/status/", nil) + request.Header.Add("Authorization", "Bearer "+authToken) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + assert.NoError(t, err) + assert.Equal(t, "", result["subscription_status"]) + assert.Equal(t, false, result["is_subscribed"]) + }) + + t.Run("ActiveSubscription", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + userID := getUserIDFromAuthToken(t, api.DB, authToken) + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "subscription_status": "active", + "subscription_id": "sub_test123", + "subscription_price_id": "price_test123", + }}, + ) + assert.NoError(t, err) + + router := GetRouter(api) + request, _ := http.NewRequest("GET", "/subscriptions/status/", nil) + request.Header.Add("Authorization", "Bearer "+authToken) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + assert.NoError(t, err) + assert.Equal(t, "active", result["subscription_status"]) + assert.Equal(t, "sub_test123", result["subscription_id"]) + assert.Equal(t, "price_test123", result["subscription_price_id"]) + assert.Equal(t, true, result["is_subscribed"]) + }) + + t.Run("TrialingSubscription", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + userID := getUserIDFromAuthToken(t, api.DB, authToken) + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "subscription_status": "trialing", + "subscription_id": "sub_trial456", + }}, + ) + assert.NoError(t, err) + + router := GetRouter(api) + request, _ := http.NewRequest("GET", "/subscriptions/status/", nil) + request.Header.Add("Authorization", "Bearer "+authToken) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + assert.NoError(t, err) + assert.Equal(t, "trialing", result["subscription_status"]) + assert.Equal(t, true, result["is_subscribed"]) + }) + + t.Run("CanceledSubscription", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + userID := getUserIDFromAuthToken(t, api.DB, authToken) + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "subscription_status": "canceled", + }}, + ) + assert.NoError(t, err) + + router := GetRouter(api) + request, _ := http.NewRequest("GET", "/subscriptions/status/", nil) + request.Header.Add("Authorization", "Bearer "+authToken) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + assert.NoError(t, err) + assert.Equal(t, "canceled", result["subscription_status"]) + assert.Equal(t, false, result["is_subscribed"]) + }) +} + +func TestCreateCheckoutSessionEndpoint(t *testing.T) { + UnauthorizedTest(t, "POST", "/subscriptions/create-checkout-session/", nil) +} + +func TestCreatePortalSessionEndpoint(t *testing.T) { + authToken := login("portal_session_test@generaltask.com", "") + UnauthorizedTest(t, "POST", "/subscriptions/create-portal-session/", nil) + + t.Run("NoStripeCustomer", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + router := GetRouter(api) + + request, _ := http.NewRequest("POST", "/subscriptions/create-portal-session/", nil) + request.Header.Add("Authorization", "Bearer "+authToken) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + assert.Equal(t, "{\"detail\":\"no stripe customer found for this user\"}", string(body)) + }) +} + +func TestStripeWebhookEndpoint(t *testing.T) { + t.Run("EmptyBody", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + router := GetRouter(api) + + request, _ := http.NewRequest("POST", "/subscriptions/webhook/", bytes.NewBuffer([]byte(""))) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + assert.Equal(t, "{\"detail\":\"invalid signature\"}", string(body)) + }) + + t.Run("InvalidSignature", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + router := GetRouter(api) + + payload := []byte(`{"type": "checkout.session.completed"}`) + request, _ := http.NewRequest("POST", "/subscriptions/webhook/", bytes.NewBuffer(payload)) + request.Header.Set("Stripe-Signature", "t=12345,v1=invalidsignature") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + body, err := io.ReadAll(recorder.Body) + assert.NoError(t, err) + assert.Equal(t, "{\"detail\":\"invalid signature\"}", string(body)) + }) +} + +func TestUpdateUserSubscription(t *testing.T) { + t.Run("Success", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + authToken := login("update_sub_test@generaltask.com", "") + userID := getUserIDFromAuthToken(t, api.DB, authToken) + + // Set up stripe customer ID on the user + stripeCustomerID := "cus_test_update_sub" + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{"stripe_customer_id": stripeCustomerID}}, + ) + assert.NoError(t, err) + + // Update subscription + err = updateUserSubscription(api, stripeCustomerID, "sub_123", "active") + assert.NoError(t, err) + + // Verify the update + var user database.User + err = database.GetUserCollection(api.DB).FindOne( + context.Background(), + bson.M{"_id": userID}, + ).Decode(&user) + assert.NoError(t, err) + assert.Equal(t, "sub_123", user.SubscriptionID) + assert.Equal(t, "active", user.SubscriptionStatus) + }) + + t.Run("UpdateToCanceled", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + authToken := login("update_sub_cancel@generaltask.com", "") + userID := getUserIDFromAuthToken(t, api.DB, authToken) + + stripeCustomerID := "cus_test_cancel" + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "stripe_customer_id": stripeCustomerID, + "subscription_status": "active", + "subscription_id": "sub_456", + }}, + ) + assert.NoError(t, err) + + // Cancel subscription + err = updateUserSubscription(api, stripeCustomerID, "sub_456", "canceled") + assert.NoError(t, err) + + // Verify the update + var user database.User + err = database.GetUserCollection(api.DB).FindOne( + context.Background(), + bson.M{"_id": userID}, + ).Decode(&user) + assert.NoError(t, err) + assert.Equal(t, "sub_456", user.SubscriptionID) + assert.Equal(t, "canceled", user.SubscriptionStatus) + assert.False(t, isUserSubscribed(&user)) + }) + + t.Run("UpdateToPastDue", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + authToken := login("update_sub_pastdue@generaltask.com", "") + userID := getUserIDFromAuthToken(t, api.DB, authToken) + + stripeCustomerID := "cus_test_pastdue" + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "stripe_customer_id": stripeCustomerID, + "subscription_status": "active", + "subscription_id": "sub_789", + }}, + ) + assert.NoError(t, err) + + err = updateUserSubscription(api, stripeCustomerID, "sub_789", "past_due") + assert.NoError(t, err) + + var user database.User + err = database.GetUserCollection(api.DB).FindOne( + context.Background(), + bson.M{"_id": userID}, + ).Decode(&user) + assert.NoError(t, err) + assert.Equal(t, "past_due", user.SubscriptionStatus) + assert.False(t, isUserSubscribed(&user)) + }) +} + +func TestUpdateUserSubscriptionFull(t *testing.T) { + t.Run("Success", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + authToken := login("update_sub_full@generaltask.com", "") + userID := getUserIDFromAuthToken(t, api.DB, authToken) + + stripeCustomerID := "cus_test_full_update" + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{"stripe_customer_id": stripeCustomerID}}, + ) + assert.NoError(t, err) + + periodEnd := primitive.NewDateTimeFromTime(time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)) + err = updateUserSubscriptionFull(api, stripeCustomerID, "sub_full_123", "active", "price_test_full", periodEnd) + assert.NoError(t, err) + + var user database.User + err = database.GetUserCollection(api.DB).FindOne( + context.Background(), + bson.M{"_id": userID}, + ).Decode(&user) + assert.NoError(t, err) + assert.Equal(t, "sub_full_123", user.SubscriptionID) + assert.Equal(t, "active", user.SubscriptionStatus) + assert.Equal(t, "price_test_full", user.SubscriptionPriceID) + assert.Equal(t, periodEnd, user.SubscriptionCurrentPeriodEnd) + }) + + t.Run("UpdateAllFields", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + authToken := login("update_sub_full_all@generaltask.com", "") + userID := getUserIDFromAuthToken(t, api.DB, authToken) + + stripeCustomerID := "cus_test_full_all" + _, err := database.GetUserCollection(api.DB).UpdateOne( + context.Background(), + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "stripe_customer_id": stripeCustomerID, + "subscription_id": "sub_old", + "subscription_status": "trialing", + "subscription_price_id": "price_old", + "subscription_current_period_end": primitive.NewDateTimeFromTime(time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)), + }}, + ) + assert.NoError(t, err) + + newPeriodEnd := primitive.NewDateTimeFromTime(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + err = updateUserSubscriptionFull(api, stripeCustomerID, "sub_new", "active", "price_new", newPeriodEnd) + assert.NoError(t, err) + + var user database.User + err = database.GetUserCollection(api.DB).FindOne( + context.Background(), + bson.M{"_id": userID}, + ).Decode(&user) + assert.NoError(t, err) + assert.Equal(t, "sub_new", user.SubscriptionID) + assert.Equal(t, "active", user.SubscriptionStatus) + assert.Equal(t, "price_new", user.SubscriptionPriceID) + assert.Equal(t, newPeriodEnd, user.SubscriptionCurrentPeriodEnd) + assert.True(t, isUserSubscribed(&user)) + }) +} + +func TestGetOrCreateStripeCustomer(t *testing.T) { + t.Run("ExistingCustomer", func(t *testing.T) { + api, dbCleanup := GetAPIWithDBCleanup() + defer dbCleanup() + + existingCustomerID := "cus_existing_12345" + user := &database.User{ + ID: primitive.NewObjectID(), + Email: "existing_customer@test.com", + StripeCustomerID: existingCustomerID, + } + + customerID, err := getOrCreateStripeCustomer(api, user) + assert.NoError(t, err) + assert.Equal(t, existingCustomerID, customerID) + }) +} + +func TestSubscriptionConstants(t *testing.T) { + assert.Equal(t, "active", SubscriptionStatusActive) + assert.Equal(t, "trialing", SubscriptionStatusTrialing) +}