Skip to content

Commit f4a702d

Browse files
authored
Monetization (#124)
* Integrate LemonSqueezy for payment processing (#113) * handle lemonsqueezy subscriptions and create entitlements from them * update existing entitlements * add premium page and start implementing endpoints * add pricing list to premium page * fix build errors * move plan info to backend and more refactoring * implement subscription management * store plan id in entitlements instead of lemonsqueezy ids * [WIP] Entitlements (#123) * implement mvp entitlement feature computation * centralize feature computation * enforce monthly credit usage limit * enforce guild count limit * minor * minor * Collaborators (#121) * collaborators MVP * fix rebase * enforce max collaborators from entitlements * minor * minor * Hand out roles to subscribers (#126) * update json example * hand out roles to premium subscribers * run plan manager * sqlc generate
1 parent 72acc97 commit f4a702d

File tree

97 files changed

+3174
-88
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+3174
-88
lines changed

go.work.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT
139139
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
140140
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
141141
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
142+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
142143
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
143144
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
144145
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=

kite-service/cmd/server.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import (
1111
"github.com/kitecloud/kite/kite-service/internal/core/engine"
1212
"github.com/kitecloud/kite/kite-service/internal/core/event"
1313
"github.com/kitecloud/kite/kite-service/internal/core/gateway"
14+
"github.com/kitecloud/kite/kite-service/internal/core/plan"
1415
"github.com/kitecloud/kite/kite-service/internal/core/usage"
1516
"github.com/kitecloud/kite/kite-service/internal/db/postgres"
1617
"github.com/kitecloud/kite/kite-service/internal/db/s3"
1718
"github.com/kitecloud/kite/kite-service/internal/logging"
19+
"github.com/kitecloud/kite/kite-service/internal/model"
1820
"github.com/sashabaranov/go-openai"
1921
"github.com/urfave/cli/v2"
2022
)
@@ -88,10 +90,21 @@ func serverStartCMD(c *cli.Context) error {
8890

8991
handler := event.NewEventHandlerWrapper(engine, pg)
9092

91-
gateway := gateway.NewGatewayManager(pg, pg, handler)
93+
billingPlans := make([]model.Plan, len(cfg.Billing.Plans))
94+
for i, plan := range cfg.Billing.Plans {
95+
billingPlans[i] = model.Plan(plan)
96+
}
97+
98+
planManager := plan.NewPlanManager(pg, pg, billingPlans, plan.PlanManagerConfig{
99+
DiscordBotToken: cfg.Discord.BotToken,
100+
DiscordGuildID: cfg.Discord.GuildID,
101+
})
102+
planManager.Run(ctx)
103+
104+
gateway := gateway.NewGatewayManager(pg, pg, planManager, handler)
92105
gateway.Run(ctx)
93106

94-
usage := usage.NewUsageManager(pg, pg, cfg.UserLimits.CreditsPerMonth)
107+
usage := usage.NewUsageManager(pg, pg, planManager)
95108
usage.Run(ctx)
96109

97110
apiServer := api.NewAPIServer(api.APIServerConfig{
@@ -108,9 +121,15 @@ func serverStartCMD(c *cli.Context) error {
108121
MaxMessagesPerApp: cfg.UserLimits.MaxMessagesPerApp,
109122
MaxEventListenersPerApp: cfg.UserLimits.MaxEventListenersPerApp,
110123
MaxAssetSize: cfg.UserLimits.MaxAssetSize,
111-
CreditsPerMonth: cfg.UserLimits.CreditsPerMonth,
112124
},
113-
}, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, assetStore, gateway)
125+
Billing: api.BillingConfig{
126+
LemonSqueezyAPIKey: cfg.Billing.LemonSqueezyAPIKey,
127+
LemonSqueezySigningSecret: cfg.Billing.LemonSqueezySigningSecret,
128+
LemonSqueezyStoreID: cfg.Billing.LemonSqueezyStoreID,
129+
TestMode: cfg.Billing.TestMode,
130+
Plans: cfg.Billing.Plans,
131+
},
132+
}, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, pg, assetStore, gateway, planManager)
114133
address := fmt.Sprintf("%s:%d", cfg.API.Host, cfg.API.Port)
115134
if err := apiServer.Serve(ctx, address); err != nil {
116135
slog.With("error", err).Error("Failed to start API server")

kite-service/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ require (
3737
)
3838

3939
require (
40+
github.com/NdoleStudio/lemonsqueezy-go v1.2.4 // indirect
4041
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
4142
github.com/beorn7/perks v1.0.1 // indirect
4243
github.com/cespare/xxhash/v2 v2.3.0 // indirect

kite-service/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
22
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
33
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
44
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
5+
github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA=
6+
github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw=
57
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
68
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
79
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=

kite-service/internal/api/access/middleware.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55

66
"github.com/kitecloud/kite/kite-service/internal/api/handler"
7+
"github.com/kitecloud/kite/kite-service/internal/model"
78
"github.com/kitecloud/kite/kite-service/internal/store"
89
)
910

@@ -20,7 +21,17 @@ func (m *AccessManager) AppAccess(next handler.HandlerFunc) handler.HandlerFunc
2021
}
2122

2223
if app.OwnerUserID != c.Session.UserID {
23-
return handler.ErrForbidden("missing_access", "Access to app missing")
24+
collaborator, err := m.appStore.Collaborator(c.Context(), appID, c.Session.UserID)
25+
if err != nil {
26+
if errors.Is(err, store.ErrNotFound) {
27+
return handler.ErrForbidden("missing_access", "Access to app missing")
28+
}
29+
return err
30+
}
31+
32+
c.UserAppRole = collaborator.Role
33+
} else {
34+
c.UserAppRole = model.AppCollaboratorRoleOwner
2435
}
2536

2637
c.App = app
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package app
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"github.com/kitecloud/kite/kite-service/internal/api/handler"
9+
"github.com/kitecloud/kite/kite-service/internal/api/wire"
10+
"github.com/kitecloud/kite/kite-service/internal/model"
11+
"github.com/kitecloud/kite/kite-service/internal/store"
12+
)
13+
14+
func (h *AppHandler) HandleAppCollaboratorsList(c *handler.Context) (*wire.AppCollaboratorListResponse, error) {
15+
collaborators, err := h.appStore.CollaboratorsByApp(c.Context(), c.App.ID)
16+
if err != nil {
17+
return nil, err
18+
}
19+
20+
ownerUser, err := h.userStore.User(c.Context(), c.App.OwnerUserID)
21+
if err != nil {
22+
return nil, err
23+
}
24+
25+
res := make(wire.AppCollaboratorListResponse, len(collaborators)+1)
26+
res[0] = &wire.AppCollaborator{
27+
User: *wire.UserToWire(ownerUser, true),
28+
Role: string(model.AppCollaboratorRoleOwner),
29+
CreatedAt: c.App.CreatedAt,
30+
UpdatedAt: c.App.CreatedAt,
31+
}
32+
33+
for i, collaborator := range collaborators {
34+
res[i+1] = wire.CollaboratorToWire(collaborator)
35+
}
36+
37+
return &res, nil
38+
}
39+
40+
func (h *AppHandler) HandleAppCollaboratorCreate(c *handler.Context, req wire.AppCollaboratorCreateRequest) (*wire.AppCollaboratorCreateResponse, error) {
41+
if !c.UserAppRole.CanManageCollaborators() {
42+
return nil, handler.ErrForbidden("missing_permissions", "You don't have permissions to add collaborators to this app")
43+
}
44+
45+
features := h.planManager.AppFeatures(c.Context(), c.App.ID)
46+
if features.MaxCollaborators != 0 {
47+
collaboratorCount, err := h.appStore.CountCollaboratorsByApp(c.Context(), c.App.ID)
48+
if err != nil {
49+
return nil, fmt.Errorf("failed to count collaborators: %w", err)
50+
}
51+
52+
// We count the owner as a collaborator
53+
if (collaboratorCount + 1) >= features.MaxCollaborators {
54+
return nil, handler.ErrBadRequest("resource_limit", fmt.Sprintf("maximum number of collaborators (%d) reached", features.MaxCollaborators))
55+
}
56+
}
57+
58+
user, err := h.userStore.UserByDiscordID(c.Context(), req.DiscordUserID)
59+
if err != nil {
60+
if errors.Is(err, store.ErrNotFound) {
61+
return nil, handler.ErrNotFound("unknown_user", "User not found")
62+
}
63+
return nil, err
64+
}
65+
66+
if user.ID == c.App.OwnerUserID {
67+
return nil, handler.ErrBadRequest("cannot_add_owner", "Cannot add owner as collaborator")
68+
}
69+
70+
collaborator, err := h.appStore.CreateCollaborator(c.Context(), &model.AppCollaborator{
71+
AppID: c.App.ID,
72+
UserID: user.ID,
73+
Role: model.AppCollaboratorRole(req.Role),
74+
CreatedAt: time.Now().UTC(),
75+
UpdatedAt: time.Now().UTC(),
76+
})
77+
if err != nil {
78+
return nil, err
79+
}
80+
81+
return wire.CollaboratorToWire(collaborator), nil
82+
}
83+
84+
func (h *AppHandler) HandleAppCollaboratorDelete(c *handler.Context) (*wire.AppCollaboratorDeleteResponse, error) {
85+
if !c.UserAppRole.CanManageCollaborators() {
86+
return nil, handler.ErrForbidden("missing_permissions", "You don't have permissions to delete collaborators from this app")
87+
}
88+
89+
userID := c.Param("userID")
90+
91+
err := h.appStore.DeleteCollaborator(c.Context(), c.App.ID, userID)
92+
if err != nil {
93+
return nil, err
94+
}
95+
96+
return &wire.AppCollaboratorDeleteResponse{}, nil
97+
}

kite-service/internal/api/handler/app/handler.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/kitecloud/kite/kite-service/internal/api/handler"
99
"github.com/kitecloud/kite/kite-service/internal/api/wire"
10+
"github.com/kitecloud/kite/kite-service/internal/core/plan"
1011
"github.com/kitecloud/kite/kite-service/internal/model"
1112
"github.com/kitecloud/kite/kite-service/internal/store"
1213
"github.com/kitecloud/kite/kite-service/internal/util"
@@ -15,12 +16,21 @@ import (
1516

1617
type AppHandler struct {
1718
appStore store.AppStore
19+
userStore store.UserStore
20+
planManager *plan.PlanManager
1821
maxAppsPerUser int
1922
}
2023

21-
func NewAppHandler(appStore store.AppStore, maxAppsPerUser int) *AppHandler {
24+
func NewAppHandler(
25+
appStore store.AppStore,
26+
userStore store.UserStore,
27+
planManager *plan.PlanManager,
28+
maxAppsPerUser int,
29+
) *AppHandler {
2230
return &AppHandler{
2331
appStore: appStore,
32+
userStore: userStore,
33+
planManager: planManager,
2434
maxAppsPerUser: maxAppsPerUser,
2535
}
2636
}
@@ -196,6 +206,10 @@ func (h *AppHandler) HandleAppTokenUpdate(c *handler.Context, req wire.AppTokenU
196206
}
197207

198208
func (h *AppHandler) HandleAppDelete(c *handler.Context) (*wire.AppDeleteResponse, error) {
209+
if !c.UserAppRole.CanDeleteApp() {
210+
return nil, handler.ErrForbidden("missing_permissions", "You don't have permissions to delete this app")
211+
}
212+
199213
if err := h.appStore.DeleteApp(c.Context(), c.App.ID); err != nil {
200214
return nil, fmt.Errorf("failed to delete app: %w", err)
201215
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package billing
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"time"
7+
8+
"github.com/NdoleStudio/lemonsqueezy-go"
9+
"github.com/kitecloud/kite/kite-service/internal/api/handler"
10+
"github.com/kitecloud/kite/kite-service/internal/api/wire"
11+
)
12+
13+
func (h *BillingHandler) HandleAppCheckout(c *handler.Context, req wire.BillingCheckoutRequest) (*wire.BillingCheckoutResponse, error) {
14+
user, err := h.userStore.User(c.Context(), c.Session.UserID)
15+
if err != nil {
16+
return nil, fmt.Errorf("failed to get user: %w", err)
17+
}
18+
19+
redirectURL := fmt.Sprintf("%s/apps/%s/premium", h.config.AppPublicBaseURL, c.App.ID)
20+
21+
variantID, err := strconv.Atoi(req.LemonSqueezyVariantID)
22+
if err != nil {
23+
return nil, fmt.Errorf("failed to convert variant ID to int: %w", err)
24+
}
25+
26+
storeID, err := strconv.Atoi(h.config.LemonSqueezyStoreID)
27+
if err != nil {
28+
return nil, fmt.Errorf("failed to convert store ID to int: %w", err)
29+
}
30+
31+
res, _, err := h.client.Checkouts.Create(
32+
c.Context(),
33+
storeID,
34+
variantID,
35+
&lemonsqueezy.CheckoutCreateAttributes{
36+
TestMode: ptr(h.config.TestMode),
37+
CheckoutOptions: lemonsqueezy.CheckoutCreateOptions{
38+
Embed: ptr(true),
39+
},
40+
CheckoutData: lemonsqueezy.CheckoutCreateData{
41+
Name: user.DisplayName,
42+
Email: user.Email,
43+
Custom: map[string]any{
44+
"user_id": c.Session.UserID,
45+
"app_id": c.App.ID,
46+
},
47+
},
48+
ProductOptions: lemonsqueezy.CheckoutCreateProductOptions{
49+
RedirectURL: redirectURL,
50+
},
51+
ExpiresAt: ptr(time.Now().UTC().Add(time.Hour).Format(time.RFC3339)),
52+
},
53+
)
54+
if err != nil {
55+
return nil, fmt.Errorf("failed to create checkout: %w", err)
56+
}
57+
58+
return &wire.BillingCheckoutResponse{
59+
URL: res.Data.Attributes.URL,
60+
}, nil
61+
}
62+
63+
func ptr[T any](v T) *T {
64+
return &v
65+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package billing
2+
3+
import (
4+
"github.com/kitecloud/kite/kite-service/internal/api/handler"
5+
"github.com/kitecloud/kite/kite-service/internal/api/wire"
6+
)
7+
8+
func (h *BillingHandler) HandleFeaturesGet(c *handler.Context) (*wire.FeaturesGetResponse, error) {
9+
features := h.planManager.AppFeatures(c.Context(), c.App.ID)
10+
11+
res := wire.Features(features)
12+
return &res, nil
13+
}
14+
15+
func max(a, b int) int {
16+
if a > b {
17+
return a
18+
}
19+
return b
20+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package billing
2+
3+
import (
4+
"github.com/NdoleStudio/lemonsqueezy-go"
5+
"github.com/kitecloud/kite/kite-service/internal/core/plan"
6+
"github.com/kitecloud/kite/kite-service/internal/store"
7+
)
8+
9+
type BillingHandlerConfig struct {
10+
LemonSqueezyAPIKey string
11+
LemonSqueezySigningSecret string
12+
LemonSqueezyStoreID string
13+
TestMode bool
14+
AppPublicBaseURL string
15+
}
16+
17+
type BillingHandler struct {
18+
config BillingHandlerConfig
19+
userStore store.UserStore
20+
subscriptionStore store.SubscriptionStore
21+
entitlementStore store.EntitlementStore
22+
planManager *plan.PlanManager
23+
24+
client *lemonsqueezy.Client
25+
}
26+
27+
func NewBillingHandler(
28+
config BillingHandlerConfig,
29+
userStore store.UserStore,
30+
subscriptionStore store.SubscriptionStore,
31+
entitlementStore store.EntitlementStore,
32+
planManager *plan.PlanManager,
33+
) *BillingHandler {
34+
client := lemonsqueezy.New(
35+
lemonsqueezy.WithAPIKey(config.LemonSqueezyAPIKey),
36+
lemonsqueezy.WithSigningSecret(config.LemonSqueezySigningSecret),
37+
)
38+
39+
return &BillingHandler{
40+
config: config,
41+
userStore: userStore,
42+
subscriptionStore: subscriptionStore,
43+
entitlementStore: entitlementStore,
44+
planManager: planManager,
45+
46+
client: client,
47+
}
48+
}

0 commit comments

Comments
 (0)