2
0

feat(ci): add repository subscription monetization system
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m10s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m13s
Build and Release / Lint (push) Successful in 5m25s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m13s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h5m42s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m30s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m55s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m36s

Implement complete subscription monetization system for repositories with Stripe and PayPal integration. Includes:
- Database models and migrations for monetization settings, subscription products, and user subscriptions
- Payment provider abstraction layer with Stripe and PayPal implementations
- Admin UI for configuring payment providers and viewing subscriptions
- Repository settings UI for managing subscription products and tiers
- Subscription checkout flow and webhook handlers for payment events
- Access control to gate repository code behind active subscriptions
This commit is contained in:
2026-01-31 13:37:07 -05:00
parent 7ca828cb94
commit d1f20f6b46
40 changed files with 2668 additions and 12 deletions

2
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
))
}
}

244
modules/monetize/paypal.go Normal file
View File

@@ -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
}

View File

@@ -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
}

140
modules/monetize/stripe.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -151,6 +151,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
loadPluginsFrom(cfg)
loadMonetizeFrom(cfg)
return nil
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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() {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,73 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize clients")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.monetize.clients"}}
</h4>
<div class="ui attached segment">
{{if .Subscriptions}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "admin.monetize.client_user"}}</th>
<th>{{ctx.Locale.Tr "admin.monetize.client_repo"}}</th>
<th>{{ctx.Locale.Tr "admin.monetize.client_product"}}</th>
<th>{{ctx.Locale.Tr "admin.monetize.client_status"}}</th>
<th>{{ctx.Locale.Tr "admin.monetize.client_since"}}</th>
</tr>
</thead>
<tbody>
{{range .Subscriptions}}
<tr>
<td>
{{if .User}}
<a href="{{.User.HomeLink}}">{{.User.Name}}</a>
{{else}}
<span class="text grey">User #{{.UserID}}</span>
{{end}}
</td>
<td>
{{if .Repo}}
<a href="{{.Repo.Link}}">{{.Repo.FullName}}</a>
{{else}}
<span class="text grey">Repo #{{.RepoID}}</span>
{{end}}
</td>
<td>
{{if .Product}}
{{.Product.Name}}
{{else}}
<span class="text grey">—</span>
{{end}}
</td>
<td>
{{if .IsLifetime}}
<span class="ui small label green">Lifetime</span>
{{else if eq .Status 0}}
<span class="ui small label green">Active</span>
{{else if eq .Status 1}}
<span class="ui small label grey">Cancelled</span>
{{else if eq .Status 2}}
<span class="ui small label red">Expired</span>
{{else if eq .Status 3}}
<span class="ui small label orange">Past Due</span>
{{end}}
</td>
<td>{{DateUtils.TimeSince .CreatedUnix}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "base/paginate" .}}
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-people" 48}}
<div class="content">
{{ctx.Locale.Tr "admin.monetize.no_clients"}}
</div>
</div>
</div>
{{end}}
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,62 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.monetize.stripe_settings"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/monetize">
{{.CsrfTokenHtml}}
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="stripe_enabled" {{if .MonetizeSettings.StripeEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.monetize.stripe_enabled"}}</label>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.stripe_publishable_key"}}</label>
<input type="text" name="stripe_publishable_key" value="{{.MonetizeSettings.StripePublishableKey}}" placeholder="pk_...">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.stripe_secret_key"}}</label>
<input type="password" name="stripe_secret_key" value="{{.MonetizeSettings.StripeSecretKey}}" placeholder="sk_...">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.stripe_webhook_secret"}}</label>
<input type="password" name="stripe_webhook_secret" value="{{.MonetizeSettings.StripeWebhookSecret}}" placeholder="whsec_...">
</div>
<div class="divider"></div>
<h4>{{ctx.Locale.Tr "admin.monetize.paypal_settings"}}</h4>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="paypal_enabled" {{if .MonetizeSettings.PayPalEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.monetize.paypal_enabled"}}</label>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.paypal_client_id"}}</label>
<input type="text" name="paypal_client_id" value="{{.MonetizeSettings.PayPalClientID}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.paypal_client_secret"}}</label>
<input type="password" name="paypal_client_secret" value="{{.MonetizeSettings.PayPalClientSecret}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.monetize.paypal_webhook_id"}}</label>
<input type="text" name="paypal_webhook_id" value="{{.MonetizeSettings.PayPalWebhookID}}">
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="paypal_sandbox" {{if .MonetizeSettings.PayPalSandbox}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.monetize.paypal_sandbox"}}</label>
</div>
</div>
<div class="field">
<button class="ui primary button">{{svg "octicon-check" 16}} {{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,43 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monetize repos")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.monetize.repos"}}
</h4>
<div class="ui attached segment">
{{if .Repos}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "admin.repos.name"}}</th>
<th>{{ctx.Locale.Tr "admin.repos.owner"}}</th>
<th>{{ctx.Locale.Tr "admin.repos.private"}}</th>
</tr>
</thead>
<tbody>
{{range .Repos}}
<tr>
<td><a href="{{.Link}}">{{.Name}}</a></td>
<td>{{.OwnerName}}</td>
<td>
{{if .IsPrivate}}
<span class="ui small label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{template "base/paginate" .}}
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-repo" 48}}
<div class="content">
{{ctx.Locale.Tr "admin.monetize.no_repos"}}
</div>
</div>
</div>
{{end}}
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -84,6 +84,22 @@
</div>
</details>
{{end}}
{{if .EnableMonetize}}
<details class="item toggleable-item" {{if or .PageIsAdminMonetizeGeneral .PageIsAdminMonetizeClients .PageIsAdminMonetizeRepos}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.monetize"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminMonetizeGeneral}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monetize">
{{ctx.Locale.Tr "admin.monetize.general"}}
</a>
<a class="{{if .PageIsAdminMonetizeClients}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monetize/clients">
{{ctx.Locale.Tr "admin.monetize.clients"}}
</a>
<a class="{{if .PageIsAdminMonetizeRepos}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monetize/repos">
{{ctx.Locale.Tr "admin.monetize.repos"}}
</a>
</div>
</details>
{{end}}
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.config"}}</summary>
<div class="menu">

View File

@@ -101,5 +101,21 @@
{{end}}
</div>
</details>
{{if .EnableMonetize}}
<details class="item toggleable-item" {{if or .PageIsSettingsSubscriptionsGeneral .PageIsSettingsSubscriptionsProducts .PageIsSettingsSubscriptionsClients}}open{{end}}>
<summary>{{ctx.Locale.Tr "repo.settings.subscriptions"}}</summary>
<div class="menu">
<a class="{{if .PageIsSettingsSubscriptionsGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/subscriptions">
{{ctx.Locale.Tr "repo.settings.subscriptions.general"}}
</a>
<a class="{{if .PageIsSettingsSubscriptionsProducts}}active {{end}}item" href="{{.RepoLink}}/settings/subscriptions/products">
{{ctx.Locale.Tr "repo.settings.subscriptions.products"}}
</a>
<a class="{{if .PageIsSettingsSubscriptionsClients}}active {{end}}item" href="{{.RepoLink}}/settings/subscriptions/clients">
{{ctx.Locale.Tr "repo.settings.subscriptions.clients"}}
</a>
</div>
</details>
{{end}}
</div>
</div>

View File

@@ -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}}

View File

@@ -0,0 +1,65 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions clients")}}
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.subscriptions.clients"}}
</h4>
<div class="ui attached segment">
{{if .Subscriptions}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.client_user"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.client_product"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.client_status"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.client_since"}}</th>
</tr>
</thead>
<tbody>
{{range .Subscriptions}}
<tr>
<td>
{{if .User}}
<a href="{{.User.HomeLink}}">{{.User.Name}}</a>
{{else}}
<span class="text grey">User #{{.UserID}}</span>
{{end}}
</td>
<td>
{{if .Product}}
{{.Product.Name}}
{{else}}
<span class="text grey">—</span>
{{end}}
</td>
<td>
{{if .IsLifetime}}
<span class="ui small label green">Lifetime</span>
{{else if eq .Status 0}}
<span class="ui small label green">Active</span>
{{else if eq .Status 1}}
<span class="ui small label grey">Cancelled</span>
{{else if eq .Status 2}}
<span class="ui small label red">Expired</span>
{{else if eq .Status 3}}
<span class="ui small label orange">Past Due</span>
{{end}}
</td>
<td>{{DateUtils.TimeSince .CreatedUnix}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "base/paginate" .}}
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-people" 48}}
<div class="content">
{{ctx.Locale.Tr "repo.settings.subscriptions.no_clients"}}
</div>
</div>
</div>
{{end}}
</div>
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -0,0 +1,22 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions")}}
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.subscriptions"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{.RepoLink}}/settings/subscriptions">
{{.CsrfTokenHtml}}
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="subscriptions_enabled" {{if .SubscriptionsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.enable"}}</label>
</div>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.subscriptions.enable_help"}}</p>
<div class="field">
<button class="ui primary button">{{svg "octicon-check" 16}} {{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -0,0 +1,100 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings subscriptions products")}}
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.subscriptions.products"}}
</h4>
<div class="ui attached segment">
{{if .Products}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.product_name"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.product_type"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.product_price"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.subscriptions.product_status"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Products}}
<tr>
<td>{{.Name}}</td>
<td>
{{if eq .Type 1}}Monthly
{{else if eq .Type 2}}Yearly
{{else if eq .Type 3}}Lifetime
{{end}}
</td>
<td>{{.PriceCents}} {{.Currency}}</td>
<td>
{{if .IsActive}}
<span class="ui small label green">Active</span>
{{else}}
<span class="ui small label grey">Inactive</span>
{{end}}
</td>
<td class="right aligned">
<form method="post" action="{{$.RepoLink}}/settings/subscriptions/products/delete">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{.ID}}">
<button class="ui red tiny button">{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "remove"}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-package" 48}}
<div class="content">
{{ctx.Locale.Tr "repo.settings.subscriptions.no_products"}}
</div>
</div>
</div>
{{end}}
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.subscriptions.add_product"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{.RepoLink}}/settings/subscriptions/products">
{{.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.product_name"}}</label>
<input type="text" name="name" required placeholder="e.g. Monthly Access">
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.product_type"}}</label>
<select name="type" class="ui dropdown">
<option value="1">Monthly</option>
<option value="2">Yearly</option>
<option value="3">Lifetime</option>
</select>
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.product_price"}}</label>
<div class="ui right labeled input">
<input type="number" name="price_cents" min="1" required placeholder="999">
<div class="ui basic label">cents</div>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.product_currency"}}</label>
<input type="text" name="currency" value="USD" maxlength="3" placeholder="USD">
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="is_active" checked>
<label>{{ctx.Locale.Tr "repo.settings.subscriptions.product_active"}}</label>
</div>
</div>
<div class="field">
<button class="ui primary button">{{svg "octicon-plus" 16}} {{ctx.Locale.Tr "repo.settings.subscriptions.add_product"}}</button>
</div>
</form>
</div>
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -0,0 +1,87 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui segment">
<h2>{{svg "octicon-lock" 24}} {{ctx.Locale.Tr "repo.subscribe.title"}}</h2>
<p>{{ctx.Locale.Tr "repo.subscribe.description"}}</p>
{{if .Products}}
<div class="ui three stackable cards" id="subscribe-products">
{{range .Products}}
<div class="ui card" data-product-id="{{.ID}}" data-product-type="{{.Type}}" data-price-cents="{{.PriceCents}}" data-currency="{{.Currency}}" data-stripe-price-id="{{.StripePriceID}}" data-paypal-plan-id="{{.PayPalPlanID}}">
<div class="content">
<div class="header">{{.Name}}</div>
<div class="meta">
{{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}}
</div>
<div class="description">
<span class="ui large text" style="font-size: 2em; font-weight: bold;">
${{DivideInt64 .PriceCents 100}}<sup style="font-size: 0.5em;">.{{printf "%02d" (ModInt64 .PriceCents 100)}}</sup>
</span>
<span class="text grey">{{.Currency}}</span>
{{if eq .Type 1}}<span class="text grey">/ {{ctx.Locale.Tr "repo.subscribe.per_month"}}</span>
{{else if eq .Type 2}}<span class="text grey">/ {{ctx.Locale.Tr "repo.subscribe.per_year"}}</span>
{{else if eq .Type 3}}<span class="text grey">{{ctx.Locale.Tr "repo.subscribe.one_time"}}</span>
{{end}}
</div>
</div>
<div class="extra content">
<button class="ui primary fluid button subscribe-btn" data-product-id="{{.ID}}">
{{svg "octicon-credit-card" 16}} {{ctx.Locale.Tr "repo.subscribe.choose"}}
</button>
</div>
</div>
{{end}}
</div>
<!-- Payment Form Container (shown after selecting a product) -->
<div id="payment-form-container" class="ui segment" style="display: none; margin-top: 1em;">
<h3 id="payment-product-name"></h3>
{{if .StripeEnabled}}
<div id="stripe-payment" style="display: none;">
<div id="stripe-card-element" style="padding: 12px; border: 1px solid var(--color-secondary); border-radius: 4px; margin-bottom: 1em;"></div>
<div id="stripe-card-errors" class="ui error message" style="display: none;"></div>
<button id="stripe-submit-btn" class="ui primary button">
{{svg "octicon-credit-card" 16}} {{ctx.Locale.Tr "repo.subscribe.pay_with_stripe"}}
</button>
</div>
{{end}}
{{if .PayPalEnabled}}
<div id="paypal-payment" style="{{if .StripeEnabled}}margin-top: 1em;{{end}}">
<div id="paypal-button-container"></div>
</div>
{{end}}
</div>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-package" 48}}
<div class="content">
{{ctx.Locale.Tr "repo.subscribe.no_products"}}
</div>
</div>
</div>
{{end}}
</div>
</div>
</div>
<script>
window._subscribeData = {
repoLink: {{.RepoLink}},
stripeEnabled: {{if .StripeEnabled}}true{{else}}false{{end}},
stripePublishableKey: {{if .StripeEnabled}}{{.StripePublishableKey}}{{else}}""{{end}},
paypalEnabled: {{if .PayPalEnabled}}true{{else}}false{{end}},
paypalClientID: {{if .PayPalEnabled}}{{.PayPalClientID}}{{else}}""{{end}},
paypalSandbox: {{if .PayPalSandbox}}true{{else}}false{{end}},
csrfToken: {{.CsrfToken}}
};
</script>
{{template "base/footer" .}}

View File

@@ -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<HTMLButtonElement>('.subscribe-btn');
const formContainer = document.querySelector<HTMLElement>('#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<HTMLElement>('#stripe-payment');
if (stripeEl) stripeEl.style.display = '';
initStripe(data.stripePublishableKey);
}
if (data.paypalEnabled) {
const paypalEl = document.querySelector<HTMLElement>('#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<HTMLElement>('#stripe-submit-btn');
const errorEl = document.querySelector<HTMLElement>('#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};

View File

@@ -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,