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
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:
2
go.mod
2
go.mod
@@ -141,6 +141,8 @@ require (
|
||||
xorm.io/xorm v1.3.10
|
||||
)
|
||||
|
||||
require github.com/stripe/stripe-go/v82 v82.5.1
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.8.0 // indirect
|
||||
code.gitea.io/gitea-vet v0.2.3 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -766,6 +766,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
|
||||
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
models/migrations/v1_26/v345.go
Normal file
24
models/migrations/v1_26/v345.go
Normal 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))
|
||||
}
|
||||
29
models/migrations/v1_26/v346.go
Normal file
29
models/migrations/v1_26/v346.go
Normal 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))
|
||||
}
|
||||
31
models/migrations/v1_26/v347.go
Normal file
31
models/migrations/v1_26/v347.go
Normal 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))
|
||||
}
|
||||
15
models/migrations/v1_26/v348.go
Normal file
15
models/migrations/v1_26/v348.go
Normal 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))
|
||||
}
|
||||
63
models/monetize/monetize_setting.go
Normal file
63
models/monetize/monetize_setting.go
Normal 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
|
||||
}
|
||||
209
models/monetize/subscription.go
Normal file
209
models/monetize/subscription.go
Normal 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
|
||||
}
|
||||
102
models/monetize/subscription_product.go
Normal file
102
models/monetize/subscription_product.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
49
modules/monetize/manager.go
Normal file
49
modules/monetize/manager.go
Normal 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
244
modules/monetize/paypal.go
Normal 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
|
||||
}
|
||||
48
modules/monetize/provider.go
Normal file
48
modules/monetize/provider.go
Normal 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
140
modules/monetize/stripe.go
Normal 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
|
||||
}
|
||||
16
modules/setting/monetize.go
Normal file
16
modules/setting/monetize.go
Normal 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)
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
loadGlobalLockFrom(cfg)
|
||||
loadOtherFrom(cfg)
|
||||
loadPluginsFrom(cfg)
|
||||
loadMonetizeFrom(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
127
routers/web/admin/monetize.go
Normal file
127
routers/web/admin/monetize.go
Normal 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)
|
||||
}
|
||||
350
routers/web/monetize/webhooks.go
Normal file
350
routers/web/monetize/webhooks.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
146
routers/web/repo/setting/subscriptions.go
Normal file
146
routers/web/repo/setting/subscriptions.go
Normal 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)
|
||||
}
|
||||
101
routers/web/repo/subscribe.go
Normal file
101
routers/web/repo/subscribe.go
Normal 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")
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
86
services/context/subscription.go
Normal file
86
services/context/subscription.go
Normal 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)
|
||||
}
|
||||
}
|
||||
47
services/forms/monetize_form.go
Normal file
47
services/forms/monetize_form.go
Normal 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)
|
||||
}
|
||||
73
templates/admin/monetize/clients.tmpl
Normal file
73
templates/admin/monetize/clients.tmpl
Normal 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" .}}
|
||||
62
templates/admin/monetize/general.tmpl
Normal file
62
templates/admin/monetize/general.tmpl
Normal 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" .}}
|
||||
43
templates/admin/monetize/repos.tmpl
Normal file
43
templates/admin/monetize/repos.tmpl
Normal 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" .}}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
templates/repo/settings/subscriptions.tmpl
Normal file
7
templates/repo/settings/subscriptions.tmpl
Normal 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}}
|
||||
65
templates/repo/settings/subscriptions_clients.tmpl
Normal file
65
templates/repo/settings/subscriptions_clients.tmpl
Normal 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" .}}
|
||||
22
templates/repo/settings/subscriptions_general.tmpl
Normal file
22
templates/repo/settings/subscriptions_general.tmpl
Normal 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" .}}
|
||||
100
templates/repo/settings/subscriptions_products.tmpl
Normal file
100
templates/repo/settings/subscriptions_products.tmpl
Normal 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" .}}
|
||||
87
templates/repo/subscribe.tmpl
Normal file
87
templates/repo/subscribe.tmpl
Normal 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" .}}
|
||||
188
web_src/js/features/repo-subscribe.ts
Normal file
188
web_src/js/features/repo-subscribe.ts
Normal 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};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user