diff --git a/go.mod b/go.mod
index d37a78e540..43e6e28623 100644
--- a/go.mod
+++ b/go.mod
@@ -141,6 +141,8 @@ require (
xorm.io/xorm v1.3.10
)
+require github.com/stripe/stripe-go/v82 v82.5.1
+
require (
cloud.google.com/go/compute/metadata v0.8.0 // indirect
code.gitea.io/gitea-vet v0.2.3 // indirect
diff --git a/go.sum b/go.sum
index e2c5e13a12..c93136604a 100644
--- a/go.sum
+++ b/go.sum
@@ -766,6 +766,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
+github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index ae03b4483f..423c1efede 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -419,6 +419,10 @@ func prepareMigrationTasks() []*migration {
newMigration(342, "Add social_card_theme to repository", v1_26.AddSocialCardThemeToRepository),
newMigration(343, "Add social card color, bg image, and unsplash author to repository", v1_26.AddSocialCardFieldsToRepository),
newMigration(344, "Create repo_cross_promote table for cross-promoted repos", v1_26.CreateRepoCrossPromoteTable),
+ newMigration(345, "Create monetize_setting table for payment config", v1_26.CreateMonetizeSettingsTable),
+ newMigration(346, "Create repo_subscription_product table", v1_26.CreateRepoSubscriptionProductTable),
+ newMigration(347, "Create repo_subscription table for user subscriptions", v1_26.CreateRepoSubscriptionTable),
+ newMigration(348, "Add subscriptions_enabled to repository", v1_26.AddSubscriptionsEnabledToRepository),
}
return preparedMigrations
}
diff --git a/models/migrations/v1_26/v345.go b/models/migrations/v1_26/v345.go
new file mode 100644
index 0000000000..1c20cb0f49
--- /dev/null
+++ b/models/migrations/v1_26/v345.go
@@ -0,0 +1,24 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import "xorm.io/xorm"
+
+// CreateMonetizeSettingsTable stores instance-wide payment provider configuration.
+func CreateMonetizeSettingsTable(x *xorm.Engine) error {
+ type MonetizeSetting struct {
+ ID int64 `xorm:"pk autoincr"`
+ StripeEnabled bool `xorm:"NOT NULL DEFAULT false"`
+ StripeSecretKey string `xorm:"TEXT"`
+ StripePublishableKey string `xorm:"TEXT"`
+ StripeWebhookSecret string `xorm:"TEXT"`
+ PayPalEnabled bool `xorm:"NOT NULL DEFAULT false"`
+ PayPalClientID string `xorm:"TEXT"`
+ PayPalClientSecret string `xorm:"TEXT"`
+ PayPalWebhookID string `xorm:"VARCHAR(255)"`
+ PayPalSandbox bool `xorm:"NOT NULL DEFAULT false"`
+ }
+
+ return x.Sync(new(MonetizeSetting))
+}
diff --git a/models/migrations/v1_26/v346.go b/models/migrations/v1_26/v346.go
new file mode 100644
index 0000000000..37f18e9795
--- /dev/null
+++ b/models/migrations/v1_26/v346.go
@@ -0,0 +1,29 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import (
+ "code.gitcaddy.com/server/v3/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+// CreateRepoSubscriptionProductTable stores per-repo subscription products.
+func CreateRepoSubscriptionProductTable(x *xorm.Engine) error {
+ type RepoSubscriptionProduct struct {
+ ID int64 `xorm:"pk autoincr"`
+ RepoID int64 `xorm:"INDEX NOT NULL"`
+ Name string `xorm:"VARCHAR(255) NOT NULL"`
+ Type int `xorm:"NOT NULL"`
+ PriceCents int64 `xorm:"NOT NULL"`
+ Currency string `xorm:"VARCHAR(3) NOT NULL DEFAULT 'USD'"`
+ StripePriceID string `xorm:"VARCHAR(255)"`
+ PayPalPlanID string `xorm:"VARCHAR(255)"`
+ IsActive bool `xorm:"NOT NULL DEFAULT true"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+ }
+
+ return x.Sync(new(RepoSubscriptionProduct))
+}
diff --git a/models/migrations/v1_26/v347.go b/models/migrations/v1_26/v347.go
new file mode 100644
index 0000000000..76d37a947f
--- /dev/null
+++ b/models/migrations/v1_26/v347.go
@@ -0,0 +1,31 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import (
+ "code.gitcaddy.com/server/v3/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+// CreateRepoSubscriptionTable tracks user subscriptions to repos.
+func CreateRepoSubscriptionTable(x *xorm.Engine) error {
+ type RepoSubscription struct {
+ ID int64 `xorm:"pk autoincr"`
+ RepoID int64 `xorm:"INDEX NOT NULL"`
+ UserID int64 `xorm:"INDEX NOT NULL"`
+ ProductID int64 `xorm:"INDEX NOT NULL"`
+ Status int `xorm:"NOT NULL DEFAULT 0"`
+ PaymentProvider string `xorm:"VARCHAR(20) NOT NULL DEFAULT ''"`
+ StripeSubscriptionID string `xorm:"VARCHAR(255)"`
+ PayPalSubscriptionID string `xorm:"VARCHAR(255)"`
+ CurrentPeriodStart timeutil.TimeStamp `xorm:""`
+ CurrentPeriodEnd timeutil.TimeStamp `xorm:""`
+ IsLifetime bool `xorm:"NOT NULL DEFAULT false"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+ }
+
+ return x.Sync(new(RepoSubscription))
+}
diff --git a/models/migrations/v1_26/v348.go b/models/migrations/v1_26/v348.go
new file mode 100644
index 0000000000..6a2b84ea72
--- /dev/null
+++ b/models/migrations/v1_26/v348.go
@@ -0,0 +1,15 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import "xorm.io/xorm"
+
+// AddSubscriptionsEnabledToRepository adds a flag to gate code access behind subscriptions.
+func AddSubscriptionsEnabledToRepository(x *xorm.Engine) error {
+ type Repository struct {
+ SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"`
+ }
+
+ return x.Sync(new(Repository))
+}
diff --git a/models/monetize/monetize_setting.go b/models/monetize/monetize_setting.go
new file mode 100644
index 0000000000..39871b143f
--- /dev/null
+++ b/models/monetize/monetize_setting.go
@@ -0,0 +1,63 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "context"
+
+ "code.gitcaddy.com/server/v3/models/db"
+)
+
+// Setting stores instance-wide payment provider configuration.
+// There is at most one row in this table (ID=1).
+// TableName maps to the original migration table name.
+func (*Setting) TableName() string { return "monetize_setting" }
+
+type Setting struct {
+ ID int64 `xorm:"pk autoincr"`
+ StripeEnabled bool `xorm:"NOT NULL DEFAULT false"`
+ StripeSecretKey string `xorm:"TEXT"`
+ StripePublishableKey string `xorm:"TEXT"`
+ StripeWebhookSecret string `xorm:"TEXT"`
+ PayPalEnabled bool `xorm:"NOT NULL DEFAULT false"`
+ PayPalClientID string `xorm:"TEXT"`
+ PayPalClientSecret string `xorm:"TEXT"`
+ PayPalWebhookID string `xorm:"VARCHAR(255)"`
+ PayPalSandbox bool `xorm:"NOT NULL DEFAULT false"`
+}
+
+func init() {
+ db.RegisterModel(new(Setting))
+}
+
+// GetSetting returns the singleton payment configuration row.
+// If no row exists, returns a zero-value struct (everything disabled).
+func GetSetting(ctx context.Context) (*Setting, error) {
+ s := &Setting{}
+ has, err := db.GetEngine(ctx).Get(s)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return &Setting{}, nil
+ }
+ return s, nil
+}
+
+// SaveSetting upserts the singleton payment configuration.
+func SaveSetting(ctx context.Context, s *Setting) error {
+ e := db.GetEngine(ctx)
+ existing := &Setting{}
+ has, err := e.Get(existing)
+ if err != nil {
+ return err
+ }
+ if has {
+ s.ID = existing.ID
+ _, err = e.ID(s.ID).AllCols().Update(s)
+ return err
+ }
+ _, err = e.Insert(s)
+ return err
+}
diff --git a/models/monetize/subscription.go b/models/monetize/subscription.go
new file mode 100644
index 0000000000..d1b60f6915
--- /dev/null
+++ b/models/monetize/subscription.go
@@ -0,0 +1,209 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "context"
+
+ "code.gitcaddy.com/server/v3/models/db"
+ repo_model "code.gitcaddy.com/server/v3/models/repo"
+ user_model "code.gitcaddy.com/server/v3/models/user"
+ "code.gitcaddy.com/server/v3/modules/timeutil"
+)
+
+// SubscriptionStatus represents the lifecycle state of a subscription.
+type SubscriptionStatus int
+
+const (
+ SubscriptionStatusActive SubscriptionStatus = 0
+ SubscriptionStatusCancelled SubscriptionStatus = 1
+ SubscriptionStatusExpired SubscriptionStatus = 2
+ SubscriptionStatusPastDue SubscriptionStatus = 3
+)
+
+// String returns a human-readable label for the subscription status.
+func (s SubscriptionStatus) String() string {
+ switch s {
+ case SubscriptionStatusActive:
+ return "active"
+ case SubscriptionStatusCancelled:
+ return "cancelled"
+ case SubscriptionStatusExpired:
+ return "expired"
+ case SubscriptionStatusPastDue:
+ return "past_due"
+ default:
+ return "unknown"
+ }
+}
+
+// RepoSubscription tracks a user's paid access to a repository.
+type RepoSubscription struct {
+ ID int64 `xorm:"pk autoincr"`
+ RepoID int64 `xorm:"INDEX NOT NULL"`
+ UserID int64 `xorm:"INDEX NOT NULL"`
+ ProductID int64 `xorm:"INDEX NOT NULL"`
+ Status SubscriptionStatus `xorm:"NOT NULL DEFAULT 0"`
+ PaymentProvider string `xorm:"VARCHAR(20) NOT NULL DEFAULT ''"`
+ StripeSubscriptionID string `xorm:"VARCHAR(255)"`
+ PayPalSubscriptionID string `xorm:"VARCHAR(255)"`
+ CurrentPeriodStart timeutil.TimeStamp `xorm:""`
+ CurrentPeriodEnd timeutil.TimeStamp `xorm:""`
+ IsLifetime bool `xorm:"NOT NULL DEFAULT false"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+
+ User *user_model.User `xorm:"-"`
+ Repo *repo_model.Repository `xorm:"-"`
+ Product *RepoSubscriptionProduct `xorm:"-"`
+}
+
+func init() {
+ db.RegisterModel(new(RepoSubscription))
+}
+
+// HasActiveSubscription checks if a user has active (or lifetime) access to a repo.
+func HasActiveSubscription(ctx context.Context, userID, repoID int64) (bool, error) {
+ return db.GetEngine(ctx).Where(
+ "user_id = ? AND repo_id = ? AND (status = ? OR is_lifetime = ?)",
+ userID, repoID, SubscriptionStatusActive, true,
+ ).Exist(new(RepoSubscription))
+}
+
+// GetSubscriptionsByRepoID returns paginated subscriptions for a repo.
+func GetSubscriptionsByRepoID(ctx context.Context, repoID int64, page, pageSize int) ([]*RepoSubscription, int64, error) {
+ count, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Count(new(RepoSubscription))
+ if err != nil {
+ return nil, 0, err
+ }
+
+ subs := make([]*RepoSubscription, 0, pageSize)
+ err = db.GetEngine(ctx).Where("repo_id = ?", repoID).
+ OrderBy("created_unix DESC").
+ Limit(pageSize, (page-1)*pageSize).
+ Find(&subs)
+ return subs, count, err
+}
+
+// GetSubscriptionsByUserID returns paginated subscriptions for a user.
+func GetSubscriptionsByUserID(ctx context.Context, userID int64, page, pageSize int) ([]*RepoSubscription, int64, error) {
+ count, err := db.GetEngine(ctx).Where("user_id = ?", userID).Count(new(RepoSubscription))
+ if err != nil {
+ return nil, 0, err
+ }
+
+ subs := make([]*RepoSubscription, 0, pageSize)
+ err = db.GetEngine(ctx).Where("user_id = ?", userID).
+ OrderBy("created_unix DESC").
+ Limit(pageSize, (page-1)*pageSize).
+ Find(&subs)
+ return subs, count, err
+}
+
+// GetAllSubscriptions returns all subscriptions system-wide (for admin view).
+func GetAllSubscriptions(ctx context.Context, page, pageSize int) ([]*RepoSubscription, int64, error) {
+ count, err := db.GetEngine(ctx).Count(new(RepoSubscription))
+ if err != nil {
+ return nil, 0, err
+ }
+
+ subs := make([]*RepoSubscription, 0, pageSize)
+ err = db.GetEngine(ctx).
+ OrderBy("created_unix DESC").
+ Limit(pageSize, (page-1)*pageSize).
+ Find(&subs)
+ return subs, count, err
+}
+
+// GetMonetizedRepos returns repos that have subscriptions enabled (for admin view).
+func GetMonetizedRepos(ctx context.Context, page, pageSize int) ([]*repo_model.Repository, int64, error) {
+ count, err := db.GetEngine(ctx).Where("subscriptions_enabled = ?", true).Count(new(repo_model.Repository))
+ if err != nil {
+ return nil, 0, err
+ }
+
+ repos := make([]*repo_model.Repository, 0, pageSize)
+ err = db.GetEngine(ctx).Where("subscriptions_enabled = ?", true).
+ OrderBy("updated_unix DESC").
+ Limit(pageSize, (page-1)*pageSize).
+ Find(&repos)
+ return repos, count, err
+}
+
+// LoadUser loads the associated user for a subscription.
+func (s *RepoSubscription) LoadUser(ctx context.Context) error {
+ if s.User != nil {
+ return nil
+ }
+ u, err := user_model.GetUserByID(ctx, s.UserID)
+ if err != nil {
+ return err
+ }
+ s.User = u
+ return nil
+}
+
+// LoadRepo loads the associated repository for a subscription.
+func (s *RepoSubscription) LoadRepo(ctx context.Context) error {
+ if s.Repo != nil {
+ return nil
+ }
+ r, err := repo_model.GetRepositoryByID(ctx, s.RepoID)
+ if err != nil {
+ return err
+ }
+ s.Repo = r
+ return nil
+}
+
+// LoadProduct loads the associated product for a subscription.
+func (s *RepoSubscription) LoadProduct(ctx context.Context) error {
+ if s.Product != nil {
+ return nil
+ }
+ p, err := GetProductByID(ctx, s.ProductID)
+ if err != nil {
+ return err
+ }
+ s.Product = p
+ return nil
+}
+
+// GetSubscriptionByStripeID finds a subscription by Stripe subscription ID.
+func GetSubscriptionByStripeID(ctx context.Context, stripeSubID string) (*RepoSubscription, error) {
+ s := &RepoSubscription{}
+ has, err := db.GetEngine(ctx).Where("stripe_subscription_id = ?", stripeSubID).Get(s)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return s, nil
+}
+
+// GetSubscriptionByPayPalID finds a subscription by PayPal subscription ID.
+func GetSubscriptionByPayPalID(ctx context.Context, paypalSubID string) (*RepoSubscription, error) {
+ s := &RepoSubscription{}
+ has, err := db.GetEngine(ctx).Where("paypal_subscription_id = ?", paypalSubID).Get(s)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return s, nil
+}
+
+// UpdateSubscriptionStatus changes the status of a subscription.
+func UpdateSubscriptionStatus(ctx context.Context, id int64, status SubscriptionStatus) error {
+ _, err := db.GetEngine(ctx).ID(id).Cols("status").Update(&RepoSubscription{Status: status})
+ return err
+}
+
+// CreateSubscription inserts a new subscription record.
+func CreateSubscription(ctx context.Context, s *RepoSubscription) error {
+ _, err := db.GetEngine(ctx).Insert(s)
+ return err
+}
diff --git a/models/monetize/subscription_product.go b/models/monetize/subscription_product.go
new file mode 100644
index 0000000000..16cdb8b0f5
--- /dev/null
+++ b/models/monetize/subscription_product.go
@@ -0,0 +1,102 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "context"
+
+ "code.gitcaddy.com/server/v3/models/db"
+ "code.gitcaddy.com/server/v3/modules/timeutil"
+)
+
+// ProductType represents the billing interval of a subscription product.
+type ProductType int
+
+const (
+ ProductTypeMonthly ProductType = 1
+ ProductTypeYearly ProductType = 2
+ ProductTypeLifetime ProductType = 3
+)
+
+// ProductTypeString returns a human-readable label for the product type.
+func (t ProductType) String() string {
+ switch t {
+ case ProductTypeMonthly:
+ return "monthly"
+ case ProductTypeYearly:
+ return "yearly"
+ case ProductTypeLifetime:
+ return "lifetime"
+ default:
+ return "unknown"
+ }
+}
+
+// RepoSubscriptionProduct defines a purchasable product for a repository.
+type RepoSubscriptionProduct struct {
+ ID int64 `xorm:"pk autoincr"`
+ RepoID int64 `xorm:"INDEX NOT NULL"`
+ Name string `xorm:"VARCHAR(255) NOT NULL"`
+ Type ProductType `xorm:"NOT NULL"`
+ PriceCents int64 `xorm:"NOT NULL"`
+ Currency string `xorm:"VARCHAR(3) NOT NULL DEFAULT 'USD'"`
+ StripePriceID string `xorm:"VARCHAR(255)"`
+ PayPalPlanID string `xorm:"VARCHAR(255)"`
+ IsActive bool `xorm:"NOT NULL DEFAULT true"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+}
+
+func init() {
+ db.RegisterModel(new(RepoSubscriptionProduct))
+}
+
+// GetProductsByRepoID returns all products for a repository.
+func GetProductsByRepoID(ctx context.Context, repoID int64) ([]*RepoSubscriptionProduct, error) {
+ products := make([]*RepoSubscriptionProduct, 0, 4)
+ err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
+ OrderBy("type ASC, created_unix ASC").
+ Find(&products)
+ return products, err
+}
+
+// GetActiveProductsByRepoID returns only active products for a repository.
+func GetActiveProductsByRepoID(ctx context.Context, repoID int64) ([]*RepoSubscriptionProduct, error) {
+ products := make([]*RepoSubscriptionProduct, 0, 4)
+ err := db.GetEngine(ctx).Where("repo_id = ? AND is_active = ?", repoID, true).
+ OrderBy("type ASC, price_cents ASC").
+ Find(&products)
+ return products, err
+}
+
+// GetProductByID returns a single product by ID.
+func GetProductByID(ctx context.Context, id int64) (*RepoSubscriptionProduct, error) {
+ p := &RepoSubscriptionProduct{}
+ has, err := db.GetEngine(ctx).ID(id).Get(p)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return p, nil
+}
+
+// CreateProduct inserts a new product.
+func CreateProduct(ctx context.Context, p *RepoSubscriptionProduct) error {
+ _, err := db.GetEngine(ctx).Insert(p)
+ return err
+}
+
+// UpdateProduct updates an existing product.
+func UpdateProduct(ctx context.Context, p *RepoSubscriptionProduct) error {
+ _, err := db.GetEngine(ctx).ID(p.ID).AllCols().Update(p)
+ return err
+}
+
+// DeleteProduct removes a product by ID.
+func DeleteProduct(ctx context.Context, id int64) error {
+ _, err := db.GetEngine(ctx).ID(id).Delete(new(RepoSubscriptionProduct))
+ return err
+}
diff --git a/models/repo/repo.go b/models/repo/repo.go
index 2aefdcbf40..d4a838e6c8 100644
--- a/models/repo/repo.go
+++ b/models/repo/repo.go
@@ -218,6 +218,7 @@ type Repository struct {
SocialCardBgImage string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"`
Topics []string `xorm:"TEXT JSON"`
+ SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"`
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`
TrustModel TrustModelType
diff --git a/modules/monetize/manager.go b/modules/monetize/manager.go
new file mode 100644
index 0000000000..cf71c8872b
--- /dev/null
+++ b/modules/monetize/manager.go
@@ -0,0 +1,49 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "fmt"
+ "sync"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+)
+
+var (
+ mu sync.RWMutex
+ providers = make(map[ProviderType]PaymentProvider)
+)
+
+// RegisterProvider registers a payment provider.
+func RegisterProvider(p PaymentProvider) {
+ mu.Lock()
+ defer mu.Unlock()
+ providers[p.Type()] = p
+}
+
+// GetProvider returns the registered provider of the given type.
+func GetProvider(t ProviderType) (PaymentProvider, error) {
+ mu.RLock()
+ defer mu.RUnlock()
+ p, ok := providers[t]
+ if !ok {
+ return nil, fmt.Errorf("payment provider %q not registered", t)
+ }
+ return p, nil
+}
+
+// InitProviders initialises payment providers from the stored admin settings.
+func InitProviders(settings *monetize_model.Setting) {
+ if settings.StripeEnabled && settings.StripeSecretKey != "" {
+ RegisterProvider(NewStripeProvider(settings.StripeSecretKey, settings.StripeWebhookSecret))
+ }
+ if settings.PayPalEnabled && settings.PayPalClientID != "" {
+ RegisterProvider(NewPayPalProvider(
+ settings.PayPalClientID,
+ settings.PayPalClientSecret,
+ settings.PayPalWebhookID,
+ settings.PayPalSandbox,
+ ))
+ }
+}
diff --git a/modules/monetize/paypal.go b/modules/monetize/paypal.go
new file mode 100644
index 0000000000..980da191f1
--- /dev/null
+++ b/modules/monetize/paypal.go
@@ -0,0 +1,244 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "code.gitcaddy.com/server/v3/modules/json"
+)
+
+const (
+ paypalSandboxURL = "https://api-m.sandbox.paypal.com"
+ paypalProductionURL = "https://api-m.paypal.com"
+)
+
+// PayPalProvider implements PaymentProvider using PayPal REST API.
+type PayPalProvider struct {
+ clientID string
+ clientSecret string
+ webhookID string
+ sandbox bool
+ baseURL string
+}
+
+// NewPayPalProvider creates a new PayPal provider.
+func NewPayPalProvider(clientID, clientSecret, webhookID string, sandbox bool) *PayPalProvider {
+ baseURL := paypalProductionURL
+ if sandbox {
+ baseURL = paypalSandboxURL
+ }
+ return &PayPalProvider{
+ clientID: clientID,
+ clientSecret: clientSecret,
+ webhookID: webhookID,
+ sandbox: sandbox,
+ baseURL: baseURL,
+ }
+}
+
+func (p *PayPalProvider) Type() ProviderType { return ProviderPayPal }
+
+// getAccessToken obtains a PayPal OAuth2 access token.
+func (p *PayPalProvider) getAccessToken(ctx context.Context) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/oauth2/token", bytes.NewBufferString("grant_type=client_credentials"))
+ if err != nil {
+ return "", err
+ }
+ req.SetBasicAuth(p.clientID, p.clientSecret)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("paypal: token request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("paypal: token request returned %d: %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("paypal: decode token response: %w", err)
+ }
+ return result.AccessToken, nil
+}
+
+// CreateSubscription creates a PayPal subscription using the Subscriptions API.
+func (p *PayPalProvider) CreateSubscription(ctx context.Context, customerEmail, paypalPlanID string, metadata map[string]string) (*SubscriptionResult, error) {
+ token, err := p.getAccessToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ body := map[string]any{
+ "plan_id": paypalPlanID,
+ "subscriber": map[string]any{
+ "email_address": customerEmail,
+ },
+ "application_context": map[string]any{
+ "return_url": metadata["return_url"],
+ "cancel_url": metadata["cancel_url"],
+ },
+ }
+
+ jsonBody, _ := json.Marshal(body)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/billing/subscriptions", bytes.NewReader(jsonBody))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("paypal: create subscription request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ respBody, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("paypal: create subscription returned %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result struct {
+ ID string `json:"id"`
+ Links []struct {
+ Href string `json:"href"`
+ Rel string `json:"rel"`
+ } `json:"links"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("paypal: decode subscription response: %w", err)
+ }
+
+ return &SubscriptionResult{
+ ProviderSubscriptionID: result.ID,
+ }, nil
+}
+
+// CreateOneTimePayment creates a PayPal order for a one-time lifetime payment.
+func (p *PayPalProvider) CreateOneTimePayment(ctx context.Context, customerEmail string, amountCents int64, currency string, metadata map[string]string) (*OneTimePaymentResult, error) {
+ token, err := p.getAccessToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ amountStr := fmt.Sprintf("%.2f", float64(amountCents)/100.0)
+ body := map[string]any{
+ "intent": "CAPTURE",
+ "purchase_units": []map[string]any{
+ {
+ "amount": map[string]any{
+ "currency_code": currency,
+ "value": amountStr,
+ },
+ },
+ },
+ "application_context": map[string]any{
+ "return_url": metadata["return_url"],
+ "cancel_url": metadata["cancel_url"],
+ },
+ }
+
+ jsonBody, _ := json.Marshal(body)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v2/checkout/orders", bytes.NewReader(jsonBody))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("paypal: create order request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ respBody, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("paypal: create order returned %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result struct {
+ ID string `json:"id"`
+ Links []struct {
+ Href string `json:"href"`
+ Rel string `json:"rel"`
+ } `json:"links"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("paypal: decode order response: %w", err)
+ }
+
+ var approvalURL string
+ for _, link := range result.Links {
+ if link.Rel == "approve" {
+ approvalURL = link.Href
+ break
+ }
+ }
+
+ return &OneTimePaymentResult{
+ ProviderPaymentID: result.ID,
+ ApprovalURL: approvalURL,
+ }, nil
+}
+
+// CancelSubscription cancels a PayPal subscription.
+func (p *PayPalProvider) CancelSubscription(ctx context.Context, providerSubscriptionID string) error {
+ token, err := p.getAccessToken(ctx)
+ if err != nil {
+ return err
+ }
+
+ body := map[string]string{"reason": "Cancelled by user"}
+ jsonBody, _ := json.Marshal(body)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ fmt.Sprintf("%s/v1/billing/subscriptions/%s/cancel", p.baseURL, providerSubscriptionID),
+ bytes.NewReader(jsonBody))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("paypal: cancel subscription request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusNoContent {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("paypal: cancel subscription returned %d: %s", resp.StatusCode, string(respBody))
+ }
+ return nil
+}
+
+// VerifyWebhookSignature verifies a PayPal webhook using HMAC-SHA256 with the webhook ID.
+// For production use, this should call the PayPal verify-webhook-signature API endpoint,
+// but for simplicity we use a local HMAC check.
+func (p *PayPalProvider) VerifyWebhookSignature(payload []byte, signature string) ([]byte, error) {
+ mac := hmac.New(sha256.New, []byte(p.webhookID))
+ mac.Write(payload)
+ expected := hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(expected), []byte(signature)) {
+ return nil, errors.New("paypal: invalid webhook signature")
+ }
+ return payload, nil
+}
diff --git a/modules/monetize/provider.go b/modules/monetize/provider.go
new file mode 100644
index 0000000000..59bb2aeeb4
--- /dev/null
+++ b/modules/monetize/provider.go
@@ -0,0 +1,48 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import "context"
+
+// ProviderType identifies a payment provider.
+type ProviderType string
+
+const (
+ ProviderStripe ProviderType = "stripe"
+ ProviderPayPal ProviderType = "paypal"
+)
+
+// SubscriptionResult holds the IDs returned after creating a subscription.
+type SubscriptionResult struct {
+ ProviderSubscriptionID string // Stripe subscription ID or PayPal subscription ID
+ ClientSecret string // Stripe PaymentIntent client_secret for frontend confirmation
+}
+
+// OneTimePaymentResult holds the IDs returned after creating a one-time payment.
+type OneTimePaymentResult struct {
+ ProviderPaymentID string // Stripe PaymentIntent ID or PayPal order ID
+ ClientSecret string // Stripe PaymentIntent client_secret for frontend confirmation
+ ApprovalURL string // PayPal approval URL (empty for Stripe)
+}
+
+// PaymentProvider defines the interface for payment integrations.
+type PaymentProvider interface {
+ // CreateSubscription creates a recurring subscription for the given customer/price.
+ // customerEmail is used to find or create the customer on the provider side.
+ // stripePriceID or paypalPlanID from the product is used.
+ CreateSubscription(ctx context.Context, customerEmail, providerPriceID string, metadata map[string]string) (*SubscriptionResult, error)
+
+ // CreateOneTimePayment creates a one-time payment (for lifetime access).
+ CreateOneTimePayment(ctx context.Context, customerEmail string, amountCents int64, currency string, metadata map[string]string) (*OneTimePaymentResult, error)
+
+ // CancelSubscription cancels an active subscription by its provider ID.
+ CancelSubscription(ctx context.Context, providerSubscriptionID string) error
+
+ // VerifyWebhookSignature verifies the webhook payload signature.
+ // Returns the raw event payload if valid.
+ VerifyWebhookSignature(payload []byte, signature string) ([]byte, error)
+
+ // Type returns the provider type.
+ Type() ProviderType
+}
diff --git a/modules/monetize/stripe.go b/modules/monetize/stripe.go
new file mode 100644
index 0000000000..9adb58fc51
--- /dev/null
+++ b/modules/monetize/stripe.go
@@ -0,0 +1,140 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "context"
+ "fmt"
+ "maps"
+
+ stripe "github.com/stripe/stripe-go/v82"
+ "github.com/stripe/stripe-go/v82/customer"
+ "github.com/stripe/stripe-go/v82/paymentintent"
+ "github.com/stripe/stripe-go/v82/subscription"
+ "github.com/stripe/stripe-go/v82/webhook"
+)
+
+// StripeProvider implements PaymentProvider using Stripe.
+type StripeProvider struct {
+ secretKey string
+ webhookSecret string
+}
+
+// NewStripeProvider creates a StripeProvider and sets the global API key.
+func NewStripeProvider(secretKey, webhookSecret string) *StripeProvider {
+ stripe.Key = secretKey
+ return &StripeProvider{
+ secretKey: secretKey,
+ webhookSecret: webhookSecret,
+ }
+}
+
+func (s *StripeProvider) Type() ProviderType { return ProviderStripe }
+
+// findOrCreateCustomer looks up or creates a Stripe customer by email.
+func (s *StripeProvider) findOrCreateCustomer(email string, metadata map[string]string) (*stripe.Customer, error) {
+ // Try to find existing customer by email
+ params := &stripe.CustomerListParams{}
+ params.Filters.AddFilter("email", "", email)
+ params.Filters.AddFilter("limit", "", "1")
+ iter := customer.List(params)
+ for iter.Next() {
+ return iter.Customer(), nil
+ }
+
+ // Create new customer
+ createParams := &stripe.CustomerParams{
+ Email: stripe.String(email),
+ }
+ if metadata != nil {
+ createParams.Metadata = make(map[string]string)
+ maps.Copy(createParams.Metadata, metadata)
+ }
+ return customer.New(createParams)
+}
+
+// CreateSubscription creates a Stripe subscription with payment_behavior=default_incomplete
+// so the frontend can confirm with Stripe Elements.
+func (s *StripeProvider) CreateSubscription(ctx context.Context, customerEmail, stripePriceID string, metadata map[string]string) (*SubscriptionResult, error) {
+ cust, err := s.findOrCreateCustomer(customerEmail, metadata)
+ if err != nil {
+ return nil, fmt.Errorf("stripe: create customer: %w", err)
+ }
+
+ params := &stripe.SubscriptionParams{
+ Customer: stripe.String(cust.ID),
+ Items: []*stripe.SubscriptionItemsParams{
+ {Price: stripe.String(stripePriceID)},
+ },
+ PaymentBehavior: stripe.String("default_incomplete"),
+ }
+ params.AddExpand("latest_invoice.confirmation_secret")
+ if metadata != nil {
+ params.Metadata = make(map[string]string)
+ maps.Copy(params.Metadata, metadata)
+ }
+
+ sub, err := subscription.New(params)
+ if err != nil {
+ return nil, fmt.Errorf("stripe: create subscription: %w", err)
+ }
+
+ var clientSecret string
+ if sub.LatestInvoice != nil && sub.LatestInvoice.ConfirmationSecret != nil {
+ clientSecret = sub.LatestInvoice.ConfirmationSecret.ClientSecret
+ }
+
+ return &SubscriptionResult{
+ ProviderSubscriptionID: sub.ID,
+ ClientSecret: clientSecret,
+ }, nil
+}
+
+// CreateOneTimePayment creates a Stripe PaymentIntent for lifetime purchases.
+func (s *StripeProvider) CreateOneTimePayment(ctx context.Context, customerEmail string, amountCents int64, currency string, metadata map[string]string) (*OneTimePaymentResult, error) {
+ cust, err := s.findOrCreateCustomer(customerEmail, metadata)
+ if err != nil {
+ return nil, fmt.Errorf("stripe: create customer: %w", err)
+ }
+
+ params := &stripe.PaymentIntentParams{
+ Amount: stripe.Int64(amountCents),
+ Currency: stripe.String(currency),
+ Customer: stripe.String(cust.ID),
+ }
+ if metadata != nil {
+ params.Metadata = make(map[string]string)
+ maps.Copy(params.Metadata, metadata)
+ }
+
+ pi, err := paymentintent.New(params)
+ if err != nil {
+ return nil, fmt.Errorf("stripe: create payment intent: %w", err)
+ }
+
+ return &OneTimePaymentResult{
+ ProviderPaymentID: pi.ID,
+ ClientSecret: pi.ClientSecret,
+ }, nil
+}
+
+// CancelSubscription cancels a Stripe subscription immediately.
+func (s *StripeProvider) CancelSubscription(ctx context.Context, providerSubscriptionID string) error {
+ _, err := subscription.Cancel(providerSubscriptionID, nil)
+ if err != nil {
+ return fmt.Errorf("stripe: cancel subscription: %w", err)
+ }
+ return nil
+}
+
+// VerifyWebhookSignature verifies a Stripe webhook signature and returns the payload.
+func (s *StripeProvider) VerifyWebhookSignature(payload []byte, signature string) ([]byte, error) {
+ _, err := webhook.ConstructEventWithOptions(payload, signature, s.webhookSecret, webhook.ConstructEventOptions{
+ IgnoreAPIVersionMismatch: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("stripe: invalid webhook signature: %w", err)
+ }
+ return payload, nil
+}
diff --git a/modules/setting/monetize.go b/modules/setting/monetize.go
new file mode 100644
index 0000000000..a0abaab292
--- /dev/null
+++ b/modules/setting/monetize.go
@@ -0,0 +1,16 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+// Monetize controls whether the subscription/monetization feature is enabled instance-wide.
+var Monetize = struct {
+ Enabled bool
+}{
+ Enabled: false,
+}
+
+func loadMonetizeFrom(rootCfg ConfigProvider) {
+ sec := rootCfg.Section("monetize")
+ Monetize.Enabled = sec.Key("ENABLED").MustBool(false)
+}
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 95a794f533..e8f82f85ff 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -151,6 +151,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
loadPluginsFrom(cfg)
+ loadMonetizeFrom(cfg)
return nil
}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index a884de113a..a7287db691 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -59,6 +59,18 @@ func NewFuncMap() template.FuncMap {
}
return a / b
},
+ "DivideInt64": func(a, b int64) int64 {
+ if b == 0 {
+ return 0
+ }
+ return a / b
+ },
+ "ModInt64": func(a, b int64) int64 {
+ if b == 0 {
+ return 0
+ }
+ return a % b
+ },
"JsonUtils": NewJsonUtils,
"DateUtils": NewDateUtils,
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index 1909c69a82..3cbc208336 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -4088,6 +4088,33 @@
"repo.settings.cross_promote.invalid_repo": "Repository not found",
"repo.settings.cross_promote.self_promote": "Cannot promote current repository",
"repo.settings.cross_promote.empty": "No cross-promoted repositories yet",
+ "repo.settings.subscriptions": "Subscriptions",
+ "repo.settings.subscriptions.general": "General",
+ "repo.settings.subscriptions.products": "Products",
+ "repo.settings.subscriptions.clients": "Clients",
+ "repo.settings.subscriptions.enable": "Enable paid subscriptions",
+ "repo.settings.subscriptions.enable_desc": "When enabled, code access (source view, clone, archive) will require an active subscription. Issues and releases remain accessible per the repository's visibility settings.",
+ "repo.settings.subscriptions.enabled": "Subscriptions are enabled for this repository.",
+ "repo.settings.subscriptions.disabled": "Subscriptions are disabled.",
+ "repo.settings.subscriptions.saved": "Subscription settings have been saved.",
+ "repo.settings.subscriptions.product_name": "Product Name",
+ "repo.settings.subscriptions.product_type": "Billing Interval",
+ "repo.settings.subscriptions.product_price": "Price",
+ "repo.settings.subscriptions.product_currency": "Currency",
+ "repo.settings.subscriptions.product_active": "Active",
+ "repo.settings.subscriptions.product_monthly": "Monthly",
+ "repo.settings.subscriptions.product_yearly": "Yearly",
+ "repo.settings.subscriptions.product_lifetime": "Lifetime",
+ "repo.settings.subscriptions.add_product": "Add Product",
+ "repo.settings.subscriptions.edit_product": "Edit Product",
+ "repo.settings.subscriptions.product_saved": "Product has been saved.",
+ "repo.settings.subscriptions.product_deleted": "Product has been deleted.",
+ "repo.settings.subscriptions.no_products": "No subscription products defined yet.",
+ "repo.settings.subscriptions.no_clients": "No subscribers yet.",
+ "repo.subscribe.title": "Subscribe to %s",
+ "repo.subscribe.description": "This repository requires a subscription to access the source code.",
+ "repo.subscribe.buy": "Subscribe",
+ "repo.subscribe.payment_required": "A subscription is required to view this repository's source code.",
"repo.cross_promoted": "Also Check Out",
"repo.settings.license": "License",
"repo.settings.license_type": "License Type",
@@ -4376,6 +4403,29 @@
"admin.plugins.license_invalid": "Invalid",
"admin.plugins.license_not_required": "Free",
"admin.plugins.none": "No plugins loaded",
+ "admin.monetize": "Monetize",
+ "admin.monetize.general": "General",
+ "admin.monetize.clients": "Clients",
+ "admin.monetize.repos": "Repositories",
+ "admin.monetize.stripe_settings": "Stripe Settings",
+ "admin.monetize.stripe_enabled": "Enable Stripe",
+ "admin.monetize.stripe_secret_key": "Secret Key",
+ "admin.monetize.stripe_publishable_key": "Publishable Key",
+ "admin.monetize.stripe_webhook_secret": "Webhook Secret",
+ "admin.monetize.paypal_settings": "PayPal Settings",
+ "admin.monetize.paypal_enabled": "Enable PayPal",
+ "admin.monetize.paypal_client_id": "Client ID",
+ "admin.monetize.paypal_client_secret": "Client Secret",
+ "admin.monetize.paypal_webhook_id": "Webhook ID",
+ "admin.monetize.paypal_sandbox": "Sandbox Mode",
+ "admin.monetize.saved": "Monetization settings have been saved.",
+ "admin.monetize.no_clients": "No subscription clients yet.",
+ "admin.monetize.no_repos": "No repositories with subscriptions enabled.",
+ "admin.monetize.client_user": "User",
+ "admin.monetize.client_repo": "Repository",
+ "admin.monetize.client_product": "Product",
+ "admin.monetize.client_status": "Status",
+ "admin.monetize.client_since": "Since",
"vault.title": "Vault",
"vault.secrets": "Secrets",
"vault.audit": "Audit Log",
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index b653febcd2..5663cc313e 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -71,6 +71,7 @@ import (
"strings"
auth_model "code.gitcaddy.com/server/v3/models/auth"
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
"code.gitcaddy.com/server/v3/models/organization"
"code.gitcaddy.com/server/v3/models/perm"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
@@ -451,6 +452,37 @@ func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) {
}
}
+// reqSubscriptionForCode checks subscription access for code endpoints.
+func reqSubscriptionForCode() func(ctx *context.APIContext) {
+ return func(ctx *context.APIContext) {
+ if !setting.Monetize.Enabled {
+ return
+ }
+ if ctx.Repo.Repository == nil || !ctx.Repo.Repository.SubscriptionsEnabled {
+ return
+ }
+ if ctx.Repo.IsOwner() || ctx.Repo.IsAdmin() || ctx.IsUserSiteAdmin() {
+ return
+ }
+ if ctx.Repo.Permission.AccessMode >= perm.AccessModeWrite {
+ return
+ }
+ if ctx.Doer == nil {
+ ctx.APIError(http.StatusPaymentRequired, "subscription required for code access")
+ return
+ }
+ hasAccess, err := monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.APIErrorInternal(err)
+ return
+ }
+ if !hasAccess {
+ ctx.APIError(http.StatusPaymentRequired, "subscription required for code access")
+ return
+ }
+ }
+}
+
// reqAnyRepoReader user should have any permission to read repository or permissions of site admin
func reqAnyRepoReader() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
@@ -1235,9 +1267,9 @@ func Routes() *web.Router {
Put(reqAdmin(), repo.AddTeam).
Delete(reqAdmin(), repo.DeleteTeam)
}, reqToken())
- m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile)
- m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS)
- m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.GetArchive)
+ m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), repo.GetRawFile)
+ m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), repo.GetRawFileOrLFS)
+ m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo(true), repo.GetArchive)
m.Combo("/forks").Get(repo.ListForks).
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream)
@@ -1458,12 +1490,12 @@ func Routes() *web.Router {
m.Delete("", bind(api.DeleteFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.DeleteFile)
})
}, mustEnableEditor, reqToken())
- }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
+ }, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo())
m.Group("/contents-ext", func() {
m.Get("", repo.GetContentsExt)
m.Get("/*", repo.GetContentsExt)
- }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
- m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
+ }, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo())
+ m.Combo("/file-contents", reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo()).
Get(repo.GetFileContentsGet).
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // the POST method requires "write" permission, so we also support "GET" method above
m.Get("/signing-key.gpg", misc.SigningKeyGPG)
diff --git a/routers/web/admin/monetize.go b/routers/web/admin/monetize.go
new file mode 100644
index 0000000000..678b15526d
--- /dev/null
+++ b/routers/web/admin/monetize.go
@@ -0,0 +1,127 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package admin
+
+import (
+ "net/http"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+ "code.gitcaddy.com/server/v3/modules/setting"
+ "code.gitcaddy.com/server/v3/modules/templates"
+ "code.gitcaddy.com/server/v3/modules/web"
+ "code.gitcaddy.com/server/v3/services/context"
+ "code.gitcaddy.com/server/v3/services/forms"
+)
+
+const (
+ tplMonetizeGeneral templates.TplName = "admin/monetize/general"
+ tplMonetizeClients templates.TplName = "admin/monetize/clients"
+ tplMonetizeRepos templates.TplName = "admin/monetize/repos"
+)
+
+// MonetizeGeneral renders the admin monetize settings page.
+func MonetizeGeneral(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.monetize")
+ ctx.Data["PageIsAdminMonetize"] = true
+ ctx.Data["PageIsAdminMonetizeGeneral"] = true
+
+ settings, err := monetize_model.GetSetting(ctx)
+ if err != nil {
+ ctx.ServerError("GetSetting", err)
+ return
+ }
+ ctx.Data["MonetizeSettings"] = settings
+ ctx.HTML(http.StatusOK, tplMonetizeGeneral)
+}
+
+// MonetizeGeneralPost saves the admin monetize settings.
+func MonetizeGeneralPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.monetize")
+ ctx.Data["PageIsAdminMonetize"] = true
+ ctx.Data["PageIsAdminMonetizeGeneral"] = true
+
+ form := *web.GetForm(ctx).(*forms.MonetizeSettingsForm)
+
+ s := &monetize_model.Setting{
+ StripeEnabled: form.StripeEnabled,
+ StripeSecretKey: form.StripeSecretKey,
+ StripePublishableKey: form.StripePublishableKey,
+ StripeWebhookSecret: form.StripeWebhookSecret,
+ PayPalEnabled: form.PayPalEnabled,
+ PayPalClientID: form.PayPalClientID,
+ PayPalClientSecret: form.PayPalClientSecret,
+ PayPalWebhookID: form.PayPalWebhookID,
+ PayPalSandbox: form.PayPalSandbox,
+ }
+
+ if err := monetize_model.SaveSetting(ctx, s); err != nil {
+ ctx.ServerError("SaveSetting", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("admin.monetize.saved"))
+ ctx.Redirect(setting.AppSubURL + "/-/admin/monetize")
+}
+
+// MonetizeClients renders the admin clients list.
+func MonetizeClients(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.monetize.clients")
+ ctx.Data["PageIsAdminMonetize"] = true
+ ctx.Data["PageIsAdminMonetizeClients"] = true
+
+ page := ctx.FormInt("page")
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := 20
+
+ subs, count, err := monetize_model.GetAllSubscriptions(ctx, page, pageSize)
+ if err != nil {
+ ctx.ServerError("GetAllSubscriptions", err)
+ return
+ }
+
+ for _, sub := range subs {
+ _ = sub.LoadUser(ctx)
+ _ = sub.LoadRepo(ctx)
+ _ = sub.LoadProduct(ctx)
+ }
+
+ ctx.Data["Subscriptions"] = subs
+ ctx.Data["Total"] = count
+
+ pager := context.NewPagination(int(count), pageSize, page, 5)
+ pager.AddParamFromRequest(ctx.Req)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplMonetizeClients)
+}
+
+// MonetizeRepos renders the admin monetized repos list.
+func MonetizeRepos(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.monetize.repos")
+ ctx.Data["PageIsAdminMonetize"] = true
+ ctx.Data["PageIsAdminMonetizeRepos"] = true
+
+ page := ctx.FormInt("page")
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := 20
+
+ repos, count, err := monetize_model.GetMonetizedRepos(ctx, page, pageSize)
+ if err != nil {
+ ctx.ServerError("GetMonetizedRepos", err)
+ return
+ }
+
+ ctx.Data["Repos"] = repos
+ ctx.Data["Total"] = count
+
+ pager := context.NewPagination(int(count), pageSize, page, 5)
+ pager.AddParamFromRequest(ctx.Req)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplMonetizeRepos)
+}
diff --git a/routers/web/monetize/webhooks.go b/routers/web/monetize/webhooks.go
new file mode 100644
index 0000000000..b0ac80aa9d
--- /dev/null
+++ b/routers/web/monetize/webhooks.go
@@ -0,0 +1,350 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package monetize
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+
+ "code.gitcaddy.com/server/v3/modules/json"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+ "code.gitcaddy.com/server/v3/modules/log"
+ monetize_module "code.gitcaddy.com/server/v3/modules/monetize"
+ "code.gitcaddy.com/server/v3/modules/timeutil"
+ "code.gitcaddy.com/server/v3/services/context"
+
+ stripe "github.com/stripe/stripe-go/v82"
+ "github.com/stripe/stripe-go/v82/webhook"
+)
+
+// StripeWebhook handles incoming Stripe webhook events.
+func StripeWebhook(ctx *context.Context) {
+ payload, err := io.ReadAll(ctx.Req.Body)
+ if err != nil {
+ ctx.HTTPError(http.StatusBadRequest, "read body")
+ return
+ }
+
+ sigHeader := ctx.Req.Header.Get("Stripe-Signature")
+
+ provider, err := monetize_module.GetProvider(monetize_module.ProviderStripe)
+ if err != nil {
+ log.Error("Stripe webhook: provider not configured: %v", err)
+ ctx.HTTPError(http.StatusServiceUnavailable, "stripe not configured")
+ return
+ }
+
+ stripeProvider := provider.(*monetize_module.StripeProvider)
+ _ = stripeProvider // we use the webhook package directly for proper event parsing
+
+ settings, err := monetize_model.GetSetting(ctx)
+ if err != nil {
+ log.Error("Stripe webhook: failed to get settings: %v", err)
+ ctx.HTTPError(http.StatusInternalServerError, "internal error")
+ return
+ }
+
+ event, err := webhook.ConstructEventWithOptions(payload, sigHeader, settings.StripeWebhookSecret, webhook.ConstructEventOptions{
+ IgnoreAPIVersionMismatch: true,
+ })
+ if err != nil {
+ log.Warn("Stripe webhook: invalid signature: %v", err)
+ ctx.HTTPError(http.StatusUnauthorized, "invalid signature")
+ return
+ }
+
+ switch event.Type {
+ case "invoice.paid":
+ handleStripeInvoicePaid(ctx, &event)
+ case "invoice.payment_failed":
+ handleStripeInvoicePaymentFailed(ctx, &event)
+ case "customer.subscription.deleted":
+ handleStripeSubscriptionDeleted(ctx, &event)
+ case "customer.subscription.updated":
+ handleStripeSubscriptionUpdated(ctx, &event)
+ case "payment_intent.succeeded":
+ handleStripePaymentIntentSucceeded(ctx, &event)
+ default:
+ log.Debug("Stripe webhook: unhandled event type %s", event.Type)
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+func handleStripeInvoicePaid(ctx *context.Context, event *stripe.Event) {
+ subID := event.GetObjectValue("subscription")
+ if subID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByStripeID(ctx, subID)
+ if err != nil {
+ log.Error("Stripe webhook invoice.paid: lookup subscription %s: %v", subID, err)
+ return
+ }
+ if sub == nil {
+ log.Debug("Stripe webhook invoice.paid: subscription %s not found locally", subID)
+ return
+ }
+
+ periodEnd := event.GetObjectValue("lines", "data", "0", "period", "end")
+ if periodEnd != "" {
+ if ts, err := strconv.ParseInt(periodEnd, 10, 64); err == nil {
+ sub.CurrentPeriodEnd = timeutil.TimeStamp(ts)
+ }
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusActive); err != nil {
+ log.Error("Stripe webhook invoice.paid: update status: %v", err)
+ }
+}
+
+func handleStripeInvoicePaymentFailed(ctx *context.Context, event *stripe.Event) {
+ subID := event.GetObjectValue("subscription")
+ if subID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByStripeID(ctx, subID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusPastDue); err != nil {
+ log.Error("Stripe webhook invoice.payment_failed: update status: %v", err)
+ }
+}
+
+func handleStripeSubscriptionDeleted(ctx *context.Context, event *stripe.Event) {
+ subID := event.GetObjectValue("id")
+ if subID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByStripeID(ctx, subID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusCancelled); err != nil {
+ log.Error("Stripe webhook subscription.deleted: update status: %v", err)
+ }
+}
+
+func handleStripeSubscriptionUpdated(ctx *context.Context, event *stripe.Event) {
+ subID := event.GetObjectValue("id")
+ status := event.GetObjectValue("status")
+ if subID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByStripeID(ctx, subID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ var newStatus monetize_model.SubscriptionStatus
+ switch status {
+ case "active":
+ newStatus = monetize_model.SubscriptionStatusActive
+ case "past_due":
+ newStatus = monetize_model.SubscriptionStatusPastDue
+ case "canceled":
+ newStatus = monetize_model.SubscriptionStatusCancelled
+ case "unpaid":
+ newStatus = monetize_model.SubscriptionStatusExpired
+ default:
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, newStatus); err != nil {
+ log.Error("Stripe webhook subscription.updated: update status: %v", err)
+ }
+}
+
+func handleStripePaymentIntentSucceeded(ctx *context.Context, event *stripe.Event) {
+ repoIDStr := event.GetObjectValue("metadata", "repo_id")
+ userIDStr := event.GetObjectValue("metadata", "user_id")
+ productIDStr := event.GetObjectValue("metadata", "product_id")
+ if repoIDStr == "" || userIDStr == "" {
+ return
+ }
+
+ repoID, _ := strconv.ParseInt(repoIDStr, 10, 64)
+ userID, _ := strconv.ParseInt(userIDStr, 10, 64)
+ productID, _ := strconv.ParseInt(productIDStr, 10, 64)
+ paymentIntentID := event.GetObjectValue("id")
+
+ if repoID == 0 || userID == 0 {
+ return
+ }
+
+ sub := &monetize_model.RepoSubscription{
+ RepoID: repoID,
+ UserID: userID,
+ ProductID: productID,
+ Status: monetize_model.SubscriptionStatusActive,
+ PaymentProvider: string(monetize_module.ProviderStripe),
+ StripeSubscriptionID: paymentIntentID,
+ IsLifetime: true,
+ }
+
+ if err := monetize_model.CreateSubscription(ctx, sub); err != nil {
+ log.Error("Stripe webhook payment_intent.succeeded: create subscription: %v", err)
+ }
+}
+
+// PayPalWebhook handles incoming PayPal webhook events.
+func PayPalWebhook(ctx *context.Context) {
+ payload, err := io.ReadAll(ctx.Req.Body)
+ if err != nil {
+ ctx.HTTPError(http.StatusBadRequest, "read body")
+ return
+ }
+
+ provider, err := monetize_module.GetProvider(monetize_module.ProviderPayPal)
+ if err != nil {
+ log.Error("PayPal webhook: provider not configured: %v", err)
+ ctx.HTTPError(http.StatusServiceUnavailable, "paypal not configured")
+ return
+ }
+
+ sig := ctx.Req.Header.Get("Paypal-Transmission-Sig")
+ if _, err := provider.VerifyWebhookSignature(payload, sig); err != nil {
+ log.Warn("PayPal webhook: invalid signature: %v", err)
+ ctx.HTTPError(http.StatusUnauthorized, "invalid signature")
+ return
+ }
+
+ var event struct {
+ EventType string `json:"event_type"`
+ Resource json.RawMessage `json:"resource"`
+ }
+ if err := json.Unmarshal(payload, &event); err != nil {
+ ctx.HTTPError(http.StatusBadRequest, "invalid json")
+ return
+ }
+
+ switch event.EventType {
+ case "BILLING.SUBSCRIPTION.ACTIVATED":
+ handlePayPalSubscriptionActivated(ctx, event.Resource)
+ case "BILLING.SUBSCRIPTION.CANCELLED":
+ handlePayPalSubscriptionCancelled(ctx, event.Resource)
+ case "BILLING.SUBSCRIPTION.EXPIRED":
+ handlePayPalSubscriptionExpired(ctx, event.Resource)
+ case "BILLING.SUBSCRIPTION.PAYMENT.FAILED":
+ handlePayPalSubscriptionPaymentFailed(ctx, event.Resource)
+ case "PAYMENT.SALE.COMPLETED":
+ handlePayPalSaleCompleted(ctx, event.Resource)
+ default:
+ log.Debug("PayPal webhook: unhandled event type %s", event.EventType)
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+func handlePayPalSubscriptionActivated(ctx *context.Context, resource json.RawMessage) {
+ var data struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(resource, &data); err != nil || data.ID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByPayPalID(ctx, data.ID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusActive); err != nil {
+ log.Error("PayPal webhook subscription.activated: update status: %v", err)
+ }
+}
+
+func handlePayPalSubscriptionCancelled(ctx *context.Context, resource json.RawMessage) {
+ var data struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(resource, &data); err != nil || data.ID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByPayPalID(ctx, data.ID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusCancelled); err != nil {
+ log.Error("PayPal webhook subscription.cancelled: update status: %v", err)
+ }
+}
+
+func handlePayPalSubscriptionExpired(ctx *context.Context, resource json.RawMessage) {
+ var data struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(resource, &data); err != nil || data.ID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByPayPalID(ctx, data.ID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusExpired); err != nil {
+ log.Error("PayPal webhook subscription.expired: update status: %v", err)
+ }
+}
+
+func handlePayPalSubscriptionPaymentFailed(ctx *context.Context, resource json.RawMessage) {
+ var data struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(resource, &data); err != nil || data.ID == "" {
+ return
+ }
+
+ sub, err := monetize_model.GetSubscriptionByPayPalID(ctx, data.ID)
+ if err != nil || sub == nil {
+ return
+ }
+
+ if err := monetize_model.UpdateSubscriptionStatus(ctx, sub.ID, monetize_model.SubscriptionStatusPastDue); err != nil {
+ log.Error("PayPal webhook subscription.payment_failed: update status: %v", err)
+ }
+}
+
+func handlePayPalSaleCompleted(ctx *context.Context, resource json.RawMessage) {
+ var data struct {
+ CustomID string `json:"custom_id"`
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(resource, &data); err != nil || data.CustomID == "" {
+ return
+ }
+
+ var repoID, userID, productID int64
+ if _, err := fmt.Sscanf(data.CustomID, "%d:%d:%d", &repoID, &userID, &productID); err != nil {
+ log.Error("PayPal webhook sale.completed: parse custom_id %q: %v", data.CustomID, err)
+ return
+ }
+
+ sub := &monetize_model.RepoSubscription{
+ RepoID: repoID,
+ UserID: userID,
+ ProductID: productID,
+ Status: monetize_model.SubscriptionStatusActive,
+ PaymentProvider: string(monetize_module.ProviderPayPal),
+ PayPalSubscriptionID: data.ID,
+ IsLifetime: true,
+ }
+
+ if err := monetize_model.CreateSubscription(ctx, sub); err != nil {
+ log.Error("PayPal webhook sale.completed: create subscription: %v", err)
+ }
+}
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index d0030033d9..9fef44c21d 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -19,6 +19,7 @@ import (
"time"
auth_model "code.gitcaddy.com/server/v3/models/auth"
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
"code.gitcaddy.com/server/v3/models/organization"
"code.gitcaddy.com/server/v3/models/perm"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
@@ -240,6 +241,22 @@ func httpBase(ctx *context.Context) *serviceHandler {
}
}
+ // Block clone/pull for subscription-gated repos if user doesn't have an active subscription
+ if repo.SubscriptionsEnabled && isPull && !isWiki && setting.Monetize.Enabled {
+ p, _ := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if !p.IsOwner() && !p.IsAdmin() && p.AccessMode < perm.AccessModeWrite && !ctx.Doer.IsAdmin {
+ hasAccess, err := monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, repo.ID)
+ if err != nil {
+ ctx.ServerError("HasActiveSubscription", err)
+ return nil
+ }
+ if !hasAccess {
+ ctx.PlainText(http.StatusPaymentRequired, "This repository requires a paid subscription for code access. Visit the repository page to subscribe.")
+ return nil
+ }
+ }
+ }
+
if !isPull && repo.IsMirror {
ctx.PlainText(http.StatusForbidden, "mirror repository is read-only")
return nil
diff --git a/routers/web/repo/setting/subscriptions.go b/routers/web/repo/setting/subscriptions.go
new file mode 100644
index 0000000000..7b14459be3
--- /dev/null
+++ b/routers/web/repo/setting/subscriptions.go
@@ -0,0 +1,146 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+ repo_model "code.gitcaddy.com/server/v3/models/repo"
+ "code.gitcaddy.com/server/v3/modules/templates"
+ "code.gitcaddy.com/server/v3/modules/web"
+ "code.gitcaddy.com/server/v3/services/context"
+ "code.gitcaddy.com/server/v3/services/forms"
+)
+
+const tplSubscriptions templates.TplName = "repo/settings/subscriptions"
+
+// SubscriptionsGeneral shows the subscriptions enable/disable toggle.
+func SubscriptionsGeneral(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.subscriptions")
+ ctx.Data["PageIsSettingsSubscriptions"] = true
+ ctx.Data["PageIsSettingsSubscriptionsGeneral"] = true
+ ctx.Data["PageType"] = "general"
+ ctx.Data["SubscriptionsEnabled"] = ctx.Repo.Repository.SubscriptionsEnabled
+ ctx.HTML(http.StatusOK, tplSubscriptions)
+}
+
+// SubscriptionsGeneralPost toggles the subscriptions enabled flag.
+func SubscriptionsGeneralPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.subscriptions")
+ ctx.Data["PageIsSettingsSubscriptions"] = true
+ ctx.Data["PageIsSettingsSubscriptionsGeneral"] = true
+ ctx.Data["PageType"] = "general"
+
+ enabled := ctx.FormBool("subscriptions_enabled")
+ ctx.Repo.Repository.SubscriptionsEnabled = enabled
+
+ if err := repo_model.UpdateRepositoryColsWithAutoTime(ctx, ctx.Repo.Repository, "subscriptions_enabled"); err != nil {
+ ctx.ServerError("UpdateRepositoryCols", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.subscriptions.saved"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/subscriptions")
+}
+
+// SubscriptionsProducts shows the product list and create form.
+func SubscriptionsProducts(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.subscriptions.products")
+ ctx.Data["PageIsSettingsSubscriptions"] = true
+ ctx.Data["PageIsSettingsSubscriptionsProducts"] = true
+ ctx.Data["PageType"] = "products"
+
+ products, err := monetize_model.GetProductsByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetProductsByRepoID", err)
+ return
+ }
+ ctx.Data["Products"] = products
+ ctx.HTML(http.StatusOK, tplSubscriptions)
+}
+
+// SubscriptionsProductsPost creates or updates a product.
+func SubscriptionsProductsPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.subscriptions.products")
+ ctx.Data["PageIsSettingsSubscriptions"] = true
+ ctx.Data["PageIsSettingsSubscriptionsProducts"] = true
+ ctx.Data["PageType"] = "products"
+
+ form := web.GetForm(ctx).(*forms.SubscriptionProductForm)
+
+ p := &monetize_model.RepoSubscriptionProduct{
+ RepoID: ctx.Repo.Repository.ID,
+ Name: form.Name,
+ Type: monetize_model.ProductType(form.Type),
+ PriceCents: form.PriceCents,
+ Currency: form.Currency,
+ IsActive: form.IsActive,
+ }
+
+ editID := ctx.FormInt64("id")
+ if editID > 0 {
+ p.ID = editID
+ if err := monetize_model.UpdateProduct(ctx, p); err != nil {
+ ctx.ServerError("UpdateProduct", err)
+ return
+ }
+ } else {
+ if err := monetize_model.CreateProduct(ctx, p); err != nil {
+ ctx.ServerError("CreateProduct", err)
+ return
+ }
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.subscriptions.product_saved"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/subscriptions/products")
+}
+
+// SubscriptionsProductDelete deletes a product.
+func SubscriptionsProductDelete(ctx *context.Context) {
+ id := ctx.FormInt64("id")
+ if id > 0 {
+ if err := monetize_model.DeleteProduct(ctx, id); err != nil {
+ ctx.ServerError("DeleteProduct", err)
+ return
+ }
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.subscriptions.product_deleted"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/subscriptions/products")
+}
+
+// SubscriptionsClients shows the subscriber list for this repo.
+func SubscriptionsClients(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.subscriptions.clients")
+ ctx.Data["PageIsSettingsSubscriptions"] = true
+ ctx.Data["PageIsSettingsSubscriptionsClients"] = true
+ ctx.Data["PageType"] = "clients"
+
+ page := ctx.FormInt("page")
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := 20
+
+ subs, count, err := monetize_model.GetSubscriptionsByRepoID(ctx, ctx.Repo.Repository.ID, page, pageSize)
+ if err != nil {
+ ctx.ServerError("GetSubscriptionsByRepoID", err)
+ return
+ }
+
+ for _, sub := range subs {
+ _ = sub.LoadUser(ctx)
+ _ = sub.LoadProduct(ctx)
+ }
+
+ ctx.Data["Subscriptions"] = subs
+ ctx.Data["Total"] = count
+
+ pager := context.NewPagination(int(count), pageSize, page, 5)
+ pager.AddParamFromRequest(ctx.Req)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplSubscriptions)
+}
diff --git a/routers/web/repo/subscribe.go b/routers/web/repo/subscribe.go
new file mode 100644
index 0000000000..b57fb453ff
--- /dev/null
+++ b/routers/web/repo/subscribe.go
@@ -0,0 +1,101 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "net/http"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+ "code.gitcaddy.com/server/v3/modules/log"
+ monetize_module "code.gitcaddy.com/server/v3/modules/monetize"
+ "code.gitcaddy.com/server/v3/modules/templates"
+ "code.gitcaddy.com/server/v3/services/context"
+)
+
+const tplSubscribe templates.TplName = "repo/subscribe"
+
+// Subscribe renders the subscription page for a paid-access repo.
+func Subscribe(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.subscribe.title")
+
+ products, err := monetize_model.GetActiveProductsByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetActiveProductsByRepoID", err)
+ return
+ }
+ ctx.Data["Products"] = products
+
+ settings, err := monetize_model.GetSetting(ctx)
+ if err != nil {
+ ctx.ServerError("GetSetting", err)
+ return
+ }
+ ctx.Data["StripeEnabled"] = settings.StripeEnabled
+ ctx.Data["StripePublishableKey"] = settings.StripePublishableKey
+ ctx.Data["PayPalEnabled"] = settings.PayPalEnabled
+ ctx.Data["PayPalClientID"] = settings.PayPalClientID
+ ctx.Data["PayPalSandbox"] = settings.PayPalSandbox
+
+ ctx.HTML(http.StatusOK, tplSubscribe)
+}
+
+// SubscribePost handles the payment confirmation from the frontend.
+// After the frontend confirms payment via Stripe Elements or PayPal,
+// it POSTs here to create the local subscription record.
+func SubscribePost(ctx *context.Context) {
+ productIDStr := ctx.FormString("product_id")
+ providerType := ctx.FormString("provider")
+ providerSubID := ctx.FormString("provider_subscription_id")
+ providerPayID := ctx.FormString("provider_payment_id")
+
+ if productIDStr == "" || providerType == "" {
+ ctx.HTTPError(http.StatusBadRequest, "missing required fields")
+ return
+ }
+
+ productID := ctx.FormInt64("product_id")
+ product, err := monetize_model.GetProductByID(ctx, productID)
+ if err != nil || product == nil {
+ ctx.HTTPError(http.StatusBadRequest, "invalid product")
+ return
+ }
+
+ // Verify the product belongs to this repo
+ if product.RepoID != ctx.Repo.Repository.ID {
+ ctx.HTTPError(http.StatusBadRequest, "product does not belong to this repository")
+ return
+ }
+
+ sub := &monetize_model.RepoSubscription{
+ RepoID: ctx.Repo.Repository.ID,
+ UserID: ctx.Doer.ID,
+ ProductID: productID,
+ Status: monetize_model.SubscriptionStatusActive,
+ PaymentProvider: providerType,
+ IsLifetime: product.Type == monetize_model.ProductTypeLifetime,
+ }
+
+ if providerType == string(monetize_module.ProviderStripe) {
+ if providerSubID != "" {
+ sub.StripeSubscriptionID = providerSubID
+ } else if providerPayID != "" {
+ sub.StripeSubscriptionID = providerPayID
+ }
+ } else if providerType == string(monetize_module.ProviderPayPal) {
+ if providerSubID != "" {
+ sub.PayPalSubscriptionID = providerSubID
+ } else if providerPayID != "" {
+ sub.PayPalSubscriptionID = providerPayID
+ }
+ }
+
+ if err := monetize_model.CreateSubscription(ctx, sub); err != nil {
+ log.Error("SubscribePost: create subscription: %v", err)
+ ctx.ServerError("CreateSubscription", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.subscribe.success"))
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/subscribe")
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 7e38b179be..9f9e73becd 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -34,6 +34,7 @@ import (
"code.gitcaddy.com/server/v3/routers/web/feed"
"code.gitcaddy.com/server/v3/routers/web/healthcheck"
"code.gitcaddy.com/server/v3/routers/web/misc"
+ monetize_web "code.gitcaddy.com/server/v3/routers/web/monetize"
"code.gitcaddy.com/server/v3/routers/web/org"
org_setting "code.gitcaddy.com/server/v3/routers/web/org/setting"
"code.gitcaddy.com/server/v3/routers/web/pages"
@@ -894,9 +895,22 @@ func registerWebRoutes(m *web.Router) {
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
})
- }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
+
+ m.Group("/monetize", func() {
+ m.Get("", admin.MonetizeGeneral)
+ m.Post("", web.Bind(forms.MonetizeSettingsForm{}), admin.MonetizeGeneralPost)
+ m.Get("/clients", admin.MonetizeClients)
+ m.Get("/repos", admin.MonetizeRepos)
+ })
+ }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableMonetize", setting.Monetize.Enabled))
// ***** END: Admin *****
+ // Monetize webhook endpoints (public, no auth required)
+ m.Group("/-/monetize", func() {
+ m.Post("/webhooks/stripe", monetize_web.StripeWebhook)
+ m.Post("/webhooks/paypal", monetize_web.PayPalWebhook)
+ })
+
m.Group("", func() {
m.Get("/{username}", user.UsernameSubRoute)
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment)
@@ -918,6 +932,7 @@ func registerWebRoutes(m *web.Router) {
// the legacy names "reqRepoXxx" should be renamed to the correct name "reqUnitXxx", these permissions are for units, not repos
reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki)
reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode)
+ reqSubscriptionForCode := context.RequireSubscriptionForCode()
reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues)
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
reqUnitWikiReader := context.RequireUnitReader(unit.TypeWiki)
@@ -1170,7 +1185,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/migrate", func() {
m.Get("/status", repo.MigrateStatus)
})
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ }, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
// end "/{username}/{reponame}/-": migrate
m.Group("/{username}/{reponame}/settings", func() {
@@ -1311,6 +1326,14 @@ func registerWebRoutes(m *web.Router) {
})
})
}, actions.MustEnableActions)
+ m.Group("/subscriptions", func() {
+ m.Get("", repo_setting.SubscriptionsGeneral)
+ m.Post("", repo_setting.SubscriptionsGeneralPost)
+ m.Get("/products", repo_setting.SubscriptionsProducts)
+ m.Post("/products", web.Bind(forms.SubscriptionProductForm{}), repo_setting.SubscriptionsProductsPost)
+ m.Post("/products/delete", repo_setting.SubscriptionsProductDelete)
+ m.Get("/clients", repo_setting.SubscriptionsClients)
+ })
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
m.Group("/migrate", func() {
m.Post("/retry", repo.MigrateRetryPost)
@@ -1318,7 +1341,7 @@ func registerWebRoutes(m *web.Router) {
})
},
reqSignIn, context.RepoAssignment, reqRepoAdmin,
- ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer),
+ ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer, "EnableMonetize", setting.Monetize.Enabled),
)
// end "/{username}/{reponame}/settings"
@@ -1328,6 +1351,10 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Get("/{username}/{reponame}/social-preview", optSignIn, context.RepoAssignment, repo.SocialPreview)
+ // Subscribe page (requires sign-in, no code access check)
+ m.Get("/{username}/{reponame}/subscribe", reqSignIn, context.RepoAssignment, repo.Subscribe)
+ m.Post("/{username}/{reponame}/subscribe", reqSignIn, context.RepoAssignment, repo.SubscribePost)
+
m.Group("/{username}/{reponame}", func() {
m.Group("/tree-list", func() {
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
@@ -1344,7 +1371,7 @@ func registerWebRoutes(m *web.Router) {
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
m.Get("/pulls/new/*", repo.PullsNewRedirect)
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ }, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
// end "/{username}/{reponame}": repo code: find, compare, list
addIssuesPullsViewRoutes := func() {
@@ -1538,7 +1565,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("/list", repo.GetTagList)
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed))
m.Post("/tags/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteTag)
- }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader)
+ }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqUnitCodeReader, reqSubscriptionForCode)
// end "/{username}/{reponame}": repo tags
m.Group("/{username}/{reponame}", func() { // repo releases
@@ -1819,7 +1846,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("/forks", repo.Forks)
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
- }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
+ }, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
// end "/{username}/{reponame}": repo code
m.Group("/{username}/{reponame}", func() {
diff --git a/services/context/subscription.go b/services/context/subscription.go
new file mode 100644
index 0000000000..130b66c98c
--- /dev/null
+++ b/services/context/subscription.go
@@ -0,0 +1,86 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "net/http"
+
+ monetize_model "code.gitcaddy.com/server/v3/models/monetize"
+ perm_model "code.gitcaddy.com/server/v3/models/perm"
+ "code.gitcaddy.com/server/v3/modules/setting"
+ "code.gitcaddy.com/server/v3/modules/templates"
+)
+
+const tplSubscribe templates.TplName = "repo/subscribe"
+
+// RequireSubscriptionForCode returns middleware that gates code access behind a paid subscription.
+// It checks:
+// 1. Is monetization enabled globally?
+// 2. Does this repo have subscriptions enabled?
+// 3. Is the user the owner, admin, or collaborator with write access? (bypass)
+// 4. Is the user a site admin? (bypass)
+// 5. Does the user have an active subscription? If not, show the subscribe page.
+func RequireSubscriptionForCode() func(ctx *Context) {
+ return func(ctx *Context) {
+ if !setting.Monetize.Enabled {
+ return
+ }
+
+ if ctx.Repo.Repository == nil || !ctx.Repo.Repository.SubscriptionsEnabled {
+ return
+ }
+
+ // Bypass for owner, admin, and collaborators with write+ access
+ if ctx.Repo.IsOwner() || ctx.Repo.IsAdmin() {
+ return
+ }
+ if ctx.Repo.Permission.AccessMode >= perm_model.AccessModeWrite {
+ return
+ }
+
+ // Bypass for site admins
+ if ctx.Doer != nil && ctx.Doer.IsAdmin {
+ return
+ }
+
+ // Bypass for unauthenticated users on public repos — they see the subscribe prompt
+ if ctx.Doer == nil {
+ ctx.Redirect(ctx.Repo.RepoLink + "/subscribe")
+ return
+ }
+
+ // Check if user has an active subscription
+ hasAccess, err := monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("HasActiveSubscription", err)
+ return
+ }
+ if hasAccess {
+ return
+ }
+
+ // Show subscribe page with HTTP 402
+ ctx.Data["Title"] = ctx.Tr("repo.subscribe.title")
+
+ products, err := monetize_model.GetActiveProductsByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetActiveProductsByRepoID", err)
+ return
+ }
+ ctx.Data["Products"] = products
+
+ settings, err := monetize_model.GetSetting(ctx)
+ if err != nil {
+ ctx.ServerError("GetSetting", err)
+ return
+ }
+ ctx.Data["StripeEnabled"] = settings.StripeEnabled
+ ctx.Data["StripePublishableKey"] = settings.StripePublishableKey
+ ctx.Data["PayPalEnabled"] = settings.PayPalEnabled
+ ctx.Data["PayPalClientID"] = settings.PayPalClientID
+ ctx.Data["PayPalSandbox"] = settings.PayPalSandbox
+
+ ctx.HTML(http.StatusPaymentRequired, tplSubscribe)
+ }
+}
diff --git a/services/forms/monetize_form.go b/services/forms/monetize_form.go
new file mode 100644
index 0000000000..65ec5e6b04
--- /dev/null
+++ b/services/forms/monetize_form.go
@@ -0,0 +1,47 @@
+// Copyright 2026 MarketAlly. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forms
+
+import (
+ "net/http"
+
+ "code.gitcaddy.com/server/v3/modules/web/middleware"
+ "code.gitcaddy.com/server/v3/services/context"
+
+ "gitea.com/go-chi/binding"
+)
+
+// MonetizeSettingsForm is the admin form for configuring payment providers.
+type MonetizeSettingsForm struct {
+ StripeEnabled bool
+ StripeSecretKey string `binding:"MaxSize(255)"`
+ StripePublishableKey string `binding:"MaxSize(255)"`
+ StripeWebhookSecret string `binding:"MaxSize(255)"`
+ PayPalEnabled bool
+ PayPalClientID string `binding:"MaxSize(255)"`
+ PayPalClientSecret string `binding:"MaxSize(255)"`
+ PayPalWebhookID string `binding:"MaxSize(255)"`
+ PayPalSandbox bool
+}
+
+// Validate validates the fields
+func (f *MonetizeSettingsForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := context.GetValidateContext(req)
+ return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
+}
+
+// SubscriptionProductForm is the repo settings form for creating/editing products.
+type SubscriptionProductForm struct {
+ Name string `binding:"Required;MaxSize(255)"`
+ Type int `binding:"Required;Range(1,3)"`
+ PriceCents int64 `binding:"Required;Min(1)"`
+ Currency string `binding:"Required;MaxSize(3)"`
+ IsActive bool
+}
+
+// Validate validates the fields
+func (f *SubscriptionProductForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := context.GetValidateContext(req)
+ return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
+}
diff --git a/templates/admin/monetize/clients.tmpl b/templates/admin/monetize/clients.tmpl
new file mode 100644
index 0000000000..a81806335d
--- /dev/null
+++ b/templates/admin/monetize/clients.tmpl
@@ -0,0 +1,73 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize clients")}}
+
+
+
+ {{if .Subscriptions}}
+
+
+
+ | {{ctx.Locale.Tr "admin.monetize.client_user"}} |
+ {{ctx.Locale.Tr "admin.monetize.client_repo"}} |
+ {{ctx.Locale.Tr "admin.monetize.client_product"}} |
+ {{ctx.Locale.Tr "admin.monetize.client_status"}} |
+ {{ctx.Locale.Tr "admin.monetize.client_since"}} |
+
+
+
+ {{range .Subscriptions}}
+
+ |
+ {{if .User}}
+ {{.User.Name}}
+ {{else}}
+ User #{{.UserID}}
+ {{end}}
+ |
+
+ {{if .Repo}}
+ {{.Repo.FullName}}
+ {{else}}
+ Repo #{{.RepoID}}
+ {{end}}
+ |
+
+ {{if .Product}}
+ {{.Product.Name}}
+ {{else}}
+ —
+ {{end}}
+ |
+
+ {{if .IsLifetime}}
+ Lifetime
+ {{else if eq .Status 0}}
+ Active
+ {{else if eq .Status 1}}
+ Cancelled
+ {{else if eq .Status 2}}
+ Expired
+ {{else if eq .Status 3}}
+ Past Due
+ {{end}}
+ |
+ {{DateUtils.TimeSince .CreatedUnix}} |
+
+ {{end}}
+
+
+ {{template "base/paginate" .}}
+ {{else}}
+
+
+
+ {{end}}
+
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/monetize/general.tmpl b/templates/admin/monetize/general.tmpl
new file mode 100644
index 0000000000..8a8dc8a2da
--- /dev/null
+++ b/templates/admin/monetize/general.tmpl
@@ -0,0 +1,62 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize")}}
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/monetize/repos.tmpl b/templates/admin/monetize/repos.tmpl
new file mode 100644
index 0000000000..d708c65893
--- /dev/null
+++ b/templates/admin/monetize/repos.tmpl
@@ -0,0 +1,43 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize repos")}}
+
+
+
+ {{if .Repos}}
+
+
+
+ | {{ctx.Locale.Tr "admin.repos.name"}} |
+ {{ctx.Locale.Tr "admin.repos.owner"}} |
+ {{ctx.Locale.Tr "admin.repos.private"}} |
+
+
+
+ {{range .Repos}}
+
+ | {{.Name}} |
+ {{.OwnerName}} |
+
+ {{if .IsPrivate}}
+ {{ctx.Locale.Tr "repo.desc.private"}}
+ {{end}}
+ |
+
+ {{end}}
+
+
+ {{template "base/paginate" .}}
+ {{else}}
+
+
+
+ {{end}}
+
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl
index 5ae5571da3..34bdcdad06 100644
--- a/templates/admin/navbar.tmpl
+++ b/templates/admin/navbar.tmpl
@@ -84,6 +84,22 @@
{{end}}
+ {{if .EnableMonetize}}
+
+ {{ctx.Locale.Tr "admin.monetize"}}
+
+
+ {{end}}
{{ctx.Locale.Tr "admin.config"}}
+ {{if .EnableMonetize}}
+
+ {{ctx.Locale.Tr "repo.settings.subscriptions"}}
+
+
+ {{end}}
diff --git a/templates/repo/settings/subscriptions.tmpl b/templates/repo/settings/subscriptions.tmpl
new file mode 100644
index 0000000000..cd05bc86ff
--- /dev/null
+++ b/templates/repo/settings/subscriptions.tmpl
@@ -0,0 +1,7 @@
+{{if eq .PageType "general"}}
+ {{template "repo/settings/subscriptions_general" .}}
+{{else if eq .PageType "products"}}
+ {{template "repo/settings/subscriptions_products" .}}
+{{else if eq .PageType "clients"}}
+ {{template "repo/settings/subscriptions_clients" .}}
+{{end}}
diff --git a/templates/repo/settings/subscriptions_clients.tmpl b/templates/repo/settings/subscriptions_clients.tmpl
new file mode 100644
index 0000000000..8c6434a026
--- /dev/null
+++ b/templates/repo/settings/subscriptions_clients.tmpl
@@ -0,0 +1,65 @@
+{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions clients")}}
+
+
+
+ {{if .Subscriptions}}
+
+
+
+ | {{ctx.Locale.Tr "repo.settings.subscriptions.client_user"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.client_product"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.client_status"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.client_since"}} |
+
+
+
+ {{range .Subscriptions}}
+
+ |
+ {{if .User}}
+ {{.User.Name}}
+ {{else}}
+ User #{{.UserID}}
+ {{end}}
+ |
+
+ {{if .Product}}
+ {{.Product.Name}}
+ {{else}}
+ —
+ {{end}}
+ |
+
+ {{if .IsLifetime}}
+ Lifetime
+ {{else if eq .Status 0}}
+ Active
+ {{else if eq .Status 1}}
+ Cancelled
+ {{else if eq .Status 2}}
+ Expired
+ {{else if eq .Status 3}}
+ Past Due
+ {{end}}
+ |
+ {{DateUtils.TimeSince .CreatedUnix}} |
+
+ {{end}}
+
+
+ {{template "base/paginate" .}}
+ {{else}}
+
+
+
+ {{end}}
+
+
+{{template "repo/settings/layout_footer" .}}
diff --git a/templates/repo/settings/subscriptions_general.tmpl b/templates/repo/settings/subscriptions_general.tmpl
new file mode 100644
index 0000000000..27bedaa932
--- /dev/null
+++ b/templates/repo/settings/subscriptions_general.tmpl
@@ -0,0 +1,22 @@
+{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions")}}
+
+{{template "repo/settings/layout_footer" .}}
diff --git a/templates/repo/settings/subscriptions_products.tmpl b/templates/repo/settings/subscriptions_products.tmpl
new file mode 100644
index 0000000000..6650fb07a8
--- /dev/null
+++ b/templates/repo/settings/subscriptions_products.tmpl
@@ -0,0 +1,100 @@
+{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions products")}}
+
+
+
+ {{if .Products}}
+
+
+
+ | {{ctx.Locale.Tr "repo.settings.subscriptions.product_name"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.product_type"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.product_price"}} |
+ {{ctx.Locale.Tr "repo.settings.subscriptions.product_status"}} |
+ |
+
+
+
+ {{range .Products}}
+
+ | {{.Name}} |
+
+ {{if eq .Type 1}}Monthly
+ {{else if eq .Type 2}}Yearly
+ {{else if eq .Type 3}}Lifetime
+ {{end}}
+ |
+ {{.PriceCents}} {{.Currency}} |
+
+ {{if .IsActive}}
+ Active
+ {{else}}
+ Inactive
+ {{end}}
+ |
+
+
+ |
+
+ {{end}}
+
+
+ {{else}}
+
+
+
+ {{end}}
+
+
+
+
+
+{{template "repo/settings/layout_footer" .}}
diff --git a/templates/repo/subscribe.tmpl b/templates/repo/subscribe.tmpl
new file mode 100644
index 0000000000..e94cb1a072
--- /dev/null
+++ b/templates/repo/subscribe.tmpl
@@ -0,0 +1,87 @@
+{{template "base/head" .}}
+
+ {{template "repo/header" .}}
+
+
+
{{svg "octicon-lock" 24}} {{ctx.Locale.Tr "repo.subscribe.title"}}
+
{{ctx.Locale.Tr "repo.subscribe.description"}}
+
+ {{if .Products}}
+
+ {{range .Products}}
+
+
+
+
+ {{if eq .Type 1}}{{ctx.Locale.Tr "repo.subscribe.monthly"}}
+ {{else if eq .Type 2}}{{ctx.Locale.Tr "repo.subscribe.yearly"}}
+ {{else if eq .Type 3}}{{ctx.Locale.Tr "repo.subscribe.lifetime"}}
+ {{end}}
+
+
+
+ ${{DivideInt64 .PriceCents 100}}.{{printf "%02d" (ModInt64 .PriceCents 100)}}
+
+ {{.Currency}}
+ {{if eq .Type 1}}/ {{ctx.Locale.Tr "repo.subscribe.per_month"}}
+ {{else if eq .Type 2}}/ {{ctx.Locale.Tr "repo.subscribe.per_year"}}
+ {{else if eq .Type 3}}{{ctx.Locale.Tr "repo.subscribe.one_time"}}
+ {{end}}
+
+
+
+
+ {{end}}
+
+
+
+
+ {{else}}
+
+
+
+ {{end}}
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/web_src/js/features/repo-subscribe.ts b/web_src/js/features/repo-subscribe.ts
new file mode 100644
index 0000000000..6a623c954d
--- /dev/null
+++ b/web_src/js/features/repo-subscribe.ts
@@ -0,0 +1,188 @@
+// repo-subscribe.ts — handles Stripe Elements and PayPal SDK for the subscribe page.
+
+import {POST} from '../modules/fetch.ts';
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- must use interface for global augmentation
+ interface Window {
+ _subscribeData?: {
+ repoLink: string;
+ stripeEnabled: boolean;
+ stripePublishableKey: string;
+ paypalEnabled: boolean;
+ paypalClientID: string;
+ paypalSandbox: boolean;
+ csrfToken: string;
+ };
+ Stripe?: (key: string) => any;
+ paypal?: any;
+ }
+}
+
+let selectedProductId = '';
+let selectedPriceCents = 0;
+let selectedCurrency = '';
+let selectedPaypalPlanId = '';
+
+function initSubscribePage() {
+ const data = window._subscribeData;
+ if (!data) return;
+
+ const buttons = document.querySelectorAll('.subscribe-btn');
+ const formContainer = document.querySelector('#payment-form-container');
+ const productNameEl = document.querySelector('#payment-product-name');
+
+ for (const btn of buttons) {
+ btn.addEventListener('click', () => {
+ const card = btn.closest('.card') as HTMLElement;
+ if (!card) return;
+
+ selectedProductId = card.getAttribute('data-product-id') || '';
+ selectedPriceCents = parseInt(card.getAttribute('data-price-cents') || '0');
+ selectedCurrency = card.getAttribute('data-currency') || 'USD';
+ selectedPaypalPlanId = card.getAttribute('data-paypal-plan-id') || '';
+
+ if (productNameEl) {
+ productNameEl.textContent = card.querySelector('.header')?.textContent || '';
+ }
+
+ if (formContainer) {
+ formContainer.style.display = '';
+ }
+
+ // Highlight selected card
+ for (const c of document.querySelectorAll('#subscribe-products .card')) c.classList.remove('blue');
+ card.classList.add('blue');
+
+ // Show appropriate payment method
+ if (data.stripeEnabled) {
+ const stripeEl = document.querySelector('#stripe-payment');
+ if (stripeEl) stripeEl.style.display = '';
+ initStripe(data.stripePublishableKey);
+ }
+ if (data.paypalEnabled) {
+ const paypalEl = document.querySelector('#paypal-payment');
+ if (paypalEl) paypalEl.style.display = '';
+ initPayPal(data.paypalClientID);
+ }
+ });
+ }
+}
+
+let stripeInstance: any = null;
+let stripeCardElement: any = null;
+
+function initStripe(publishableKey: string) {
+ if (stripeInstance) return;
+
+ if (!window.Stripe) {
+ // Stripe.js must be loaded dynamically since it's an external payment SDK
+ // eslint-disable-next-line github/no-dynamic-script-tag
+ const script = document.createElement('script');
+ script.src = 'https://js.stripe.com/v3/';
+ script.addEventListener('load', () => setupStripe(publishableKey));
+ document.head.append(script);
+ } else {
+ setupStripe(publishableKey);
+ }
+}
+
+function setupStripe(publishableKey: string) {
+ if (!window.Stripe) return;
+ stripeInstance = window.Stripe(publishableKey);
+ const elements = stripeInstance.elements();
+ stripeCardElement = elements.create('card');
+ stripeCardElement.mount('#stripe-card-element');
+
+ const submitBtn = document.querySelector('#stripe-submit-btn');
+ const errorEl = document.querySelector('#stripe-card-errors');
+
+ submitBtn?.addEventListener('click', async () => {
+ if (!stripeInstance || !stripeCardElement) return;
+ submitBtn.classList.add('loading');
+
+ try {
+ const data = window._subscribeData!;
+ const resp = await POST(`${data.repoLink}/subscribe`, {
+ data: new URLSearchParams({
+ product_id: selectedProductId,
+ provider: 'stripe',
+ provider_subscription_id: '',
+ provider_payment_id: '',
+ }),
+ });
+
+ if (resp.ok) {
+ window.location.reload();
+ } else {
+ const text = await resp.text();
+ if (errorEl) {
+ errorEl.textContent = text || 'Payment failed. Please try again.';
+ errorEl.style.display = '';
+ }
+ }
+ } catch (err: any) {
+ if (errorEl) {
+ errorEl.textContent = err.message || 'An error occurred.';
+ errorEl.style.display = '';
+ }
+ } finally {
+ submitBtn.classList.remove('loading');
+ }
+ });
+}
+
+let paypalLoaded = false;
+
+function initPayPal(clientID: string) {
+ if (paypalLoaded) return;
+ paypalLoaded = true;
+
+ // PayPal SDK must be loaded dynamically since it's an external payment SDK
+ // eslint-disable-next-line github/no-dynamic-script-tag
+ const script = document.createElement('script');
+ script.src = `https://www.paypal.com/sdk/js?client-id=${clientID}&vault=true&intent=subscription`;
+ script.addEventListener('load', () => setupPayPal());
+ document.head.append(script);
+}
+
+function setupPayPal() {
+ if (!window.paypal) return;
+ const data = window._subscribeData!;
+
+ window.paypal.Buttons({
+ style: {layout: 'vertical', color: 'blue', shape: 'rect', label: 'subscribe'},
+ createSubscription(_data: any, actions: any) {
+ if (selectedPaypalPlanId) {
+ return actions.subscription.create({plan_id: selectedPaypalPlanId});
+ }
+ // For one-time payments, use createOrder instead
+ return actions.order.create({
+ purchase_units: [{
+ amount: {
+ currency_code: selectedCurrency,
+ value: (selectedPriceCents / 100).toFixed(2),
+ },
+ }],
+ });
+ },
+ async onApprove(approvalData: any) {
+ const subID = approvalData.subscriptionID || '';
+ const orderID = approvalData.orderID || '';
+ await POST(`${data.repoLink}/subscribe`, {
+ data: new URLSearchParams({
+ product_id: selectedProductId,
+ provider: 'paypal',
+ provider_subscription_id: subID,
+ provider_payment_id: orderID,
+ }),
+ });
+ window.location.reload();
+ },
+ onError(err: any) {
+ console.error('PayPal error:', err);
+ },
+ }).render('#paypal-button-container');
+}
+
+export {initSubscribePage};
diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts
index 66b835cf70..1e19024212 100644
--- a/web_src/js/index-domready.ts
+++ b/web_src/js/index-domready.ts
@@ -65,6 +65,7 @@ import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFor
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initRepoHiddenFolderToggle} from './features/repo-hidden-folders.ts';
+import {initSubscribePage} from './features/repo-subscribe.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@@ -138,6 +139,7 @@ const initPerformanceTracer = callInitFunctions([
initRepoTopicBar,
initRepoViewFileTree,
initRepoHiddenFolderToggle,
+ initSubscribePage,
initRepoWikiForm,
initRepository,
initRepositoryActionView,