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
|
xorm.io/xorm v1.3.10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/stripe/stripe-go/v82 v82.5.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/compute/metadata v0.8.0 // indirect
|
cloud.google.com/go/compute/metadata v0.8.0 // indirect
|
||||||
code.gitea.io/gitea-vet v0.2.3 // 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
|
||||||
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
|
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=
|
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(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(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(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
|
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 ''"`
|
SocialCardBgImage string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
|
||||||
SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"`
|
SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"`
|
||||||
Topics []string `xorm:"TEXT JSON"`
|
Topics []string `xorm:"TEXT JSON"`
|
||||||
|
SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`
|
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`
|
||||||
|
|
||||||
TrustModel TrustModelType
|
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)
|
loadGlobalLockFrom(cfg)
|
||||||
loadOtherFrom(cfg)
|
loadOtherFrom(cfg)
|
||||||
loadPluginsFrom(cfg)
|
loadPluginsFrom(cfg)
|
||||||
|
loadMonetizeFrom(cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ func NewFuncMap() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return a / b
|
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,
|
"JsonUtils": NewJsonUtils,
|
||||||
"DateUtils": NewDateUtils,
|
"DateUtils": NewDateUtils,
|
||||||
|
|
||||||
|
|||||||
@@ -4088,6 +4088,33 @@
|
|||||||
"repo.settings.cross_promote.invalid_repo": "Repository not found",
|
"repo.settings.cross_promote.invalid_repo": "Repository not found",
|
||||||
"repo.settings.cross_promote.self_promote": "Cannot promote current repository",
|
"repo.settings.cross_promote.self_promote": "Cannot promote current repository",
|
||||||
"repo.settings.cross_promote.empty": "No cross-promoted repositories yet",
|
"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.cross_promoted": "Also Check Out",
|
||||||
"repo.settings.license": "License",
|
"repo.settings.license": "License",
|
||||||
"repo.settings.license_type": "License Type",
|
"repo.settings.license_type": "License Type",
|
||||||
@@ -4376,6 +4403,29 @@
|
|||||||
"admin.plugins.license_invalid": "Invalid",
|
"admin.plugins.license_invalid": "Invalid",
|
||||||
"admin.plugins.license_not_required": "Free",
|
"admin.plugins.license_not_required": "Free",
|
||||||
"admin.plugins.none": "No plugins loaded",
|
"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.title": "Vault",
|
||||||
"vault.secrets": "Secrets",
|
"vault.secrets": "Secrets",
|
||||||
"vault.audit": "Audit Log",
|
"vault.audit": "Audit Log",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
auth_model "code.gitcaddy.com/server/v3/models/auth"
|
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/organization"
|
||||||
"code.gitcaddy.com/server/v3/models/perm"
|
"code.gitcaddy.com/server/v3/models/perm"
|
||||||
access_model "code.gitcaddy.com/server/v3/models/perm/access"
|
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
|
// reqAnyRepoReader user should have any permission to read repository or permissions of site admin
|
||||||
func reqAnyRepoReader() func(ctx *context.APIContext) {
|
func reqAnyRepoReader() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
@@ -1235,9 +1267,9 @@ func Routes() *web.Router {
|
|||||||
Put(reqAdmin(), repo.AddTeam).
|
Put(reqAdmin(), repo.AddTeam).
|
||||||
Delete(reqAdmin(), repo.DeleteTeam)
|
Delete(reqAdmin(), repo.DeleteTeam)
|
||||||
}, reqToken())
|
}, reqToken())
|
||||||
m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile)
|
m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), repo.GetRawFile)
|
||||||
m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS)
|
m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), repo.GetRawFileOrLFS)
|
||||||
m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.GetArchive)
|
m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo(true), repo.GetArchive)
|
||||||
m.Combo("/forks").Get(repo.ListForks).
|
m.Combo("/forks").Get(repo.ListForks).
|
||||||
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
|
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
|
||||||
m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream)
|
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)
|
m.Delete("", bind(api.DeleteFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.DeleteFile)
|
||||||
})
|
})
|
||||||
}, mustEnableEditor, reqToken())
|
}, mustEnableEditor, reqToken())
|
||||||
}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
|
}, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo())
|
||||||
m.Group("/contents-ext", func() {
|
m.Group("/contents-ext", func() {
|
||||||
m.Get("", repo.GetContentsExt)
|
m.Get("", repo.GetContentsExt)
|
||||||
m.Get("/*", repo.GetContentsExt)
|
m.Get("/*", repo.GetContentsExt)
|
||||||
}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
|
}, reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo())
|
||||||
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()).
|
m.Combo("/file-contents", reqRepoReader(unit.TypeCode), reqSubscriptionForCode(), context.ReferencesGitRepo()).
|
||||||
Get(repo.GetFileContentsGet).
|
Get(repo.GetFileContentsGet).
|
||||||
Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // the POST method requires "write" permission, so we also support "GET" method above
|
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)
|
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"
|
"time"
|
||||||
|
|
||||||
auth_model "code.gitcaddy.com/server/v3/models/auth"
|
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/organization"
|
||||||
"code.gitcaddy.com/server/v3/models/perm"
|
"code.gitcaddy.com/server/v3/models/perm"
|
||||||
access_model "code.gitcaddy.com/server/v3/models/perm/access"
|
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 {
|
if !isPull && repo.IsMirror {
|
||||||
ctx.PlainText(http.StatusForbidden, "mirror repository is read-only")
|
ctx.PlainText(http.StatusForbidden, "mirror repository is read-only")
|
||||||
return nil
|
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/feed"
|
||||||
"code.gitcaddy.com/server/v3/routers/web/healthcheck"
|
"code.gitcaddy.com/server/v3/routers/web/healthcheck"
|
||||||
"code.gitcaddy.com/server/v3/routers/web/misc"
|
"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"
|
"code.gitcaddy.com/server/v3/routers/web/org"
|
||||||
org_setting "code.gitcaddy.com/server/v3/routers/web/org/setting"
|
org_setting "code.gitcaddy.com/server/v3/routers/web/org/setting"
|
||||||
"code.gitcaddy.com/server/v3/routers/web/pages"
|
"code.gitcaddy.com/server/v3/routers/web/pages"
|
||||||
@@ -894,9 +895,22 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
addSettingsSecretsRoutes()
|
addSettingsSecretsRoutes()
|
||||||
addSettingsVariablesRoutes()
|
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 *****
|
// ***** 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.Group("", func() {
|
||||||
m.Get("/{username}", user.UsernameSubRoute)
|
m.Get("/{username}", user.UsernameSubRoute)
|
||||||
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment)
|
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
|
// 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)
|
reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki)
|
||||||
reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode)
|
reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode)
|
||||||
|
reqSubscriptionForCode := context.RequireSubscriptionForCode()
|
||||||
reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues)
|
reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues)
|
||||||
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
|
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
|
||||||
reqUnitWikiReader := context.RequireUnitReader(unit.TypeWiki)
|
reqUnitWikiReader := context.RequireUnitReader(unit.TypeWiki)
|
||||||
@@ -1170,7 +1185,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Group("/migrate", func() {
|
m.Group("/migrate", func() {
|
||||||
m.Get("/status", repo.MigrateStatus)
|
m.Get("/status", repo.MigrateStatus)
|
||||||
})
|
})
|
||||||
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
|
}, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
|
||||||
// end "/{username}/{reponame}/-": migrate
|
// end "/{username}/{reponame}/-": migrate
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}/settings", func() {
|
m.Group("/{username}/{reponame}/settings", func() {
|
||||||
@@ -1311,6 +1326,14 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, actions.MustEnableActions)
|
}, 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
|
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
|
||||||
m.Group("/migrate", func() {
|
m.Group("/migrate", func() {
|
||||||
m.Post("/retry", repo.MigrateRetryPost)
|
m.Post("/retry", repo.MigrateRetryPost)
|
||||||
@@ -1318,7 +1341,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
reqSignIn, context.RepoAssignment, reqRepoAdmin,
|
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"
|
// 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.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)
|
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("/{username}/{reponame}", func() {
|
||||||
m.Group("/tree-list", func() {
|
m.Group("/tree-list", func() {
|
||||||
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
|
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).
|
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
|
||||||
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
|
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
|
||||||
m.Get("/pulls/new/*", repo.PullsNewRedirect)
|
m.Get("/pulls/new/*", repo.PullsNewRedirect)
|
||||||
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
|
}, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
|
||||||
// end "/{username}/{reponame}": repo code: find, compare, list
|
// end "/{username}/{reponame}": repo code: find, compare, list
|
||||||
|
|
||||||
addIssuesPullsViewRoutes := func() {
|
addIssuesPullsViewRoutes := func() {
|
||||||
@@ -1538,7 +1565,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Get("/list", repo.GetTagList)
|
m.Get("/list", repo.GetTagList)
|
||||||
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed))
|
}, ctxDataSet("EnableFeed", setting.Other.EnableFeed))
|
||||||
m.Post("/tags/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteTag)
|
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
|
// end "/{username}/{reponame}": repo tags
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}", func() { // repo releases
|
m.Group("/{username}/{reponame}", func() { // repo releases
|
||||||
@@ -1819,7 +1846,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Get("/forks", repo.Forks)
|
m.Get("/forks", repo.Forks)
|
||||||
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
|
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)
|
m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
|
||||||
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
|
}, optSignIn, context.RepoAssignment, reqUnitCodeReader, reqSubscriptionForCode)
|
||||||
// end "/{username}/{reponame}": repo code
|
// end "/{username}/{reponame}": repo code
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}", func() {
|
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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{{end}}
|
{{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}}>
|
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
||||||
<summary>{{ctx.Locale.Tr "admin.config"}}</summary>
|
<summary>{{ctx.Locale.Tr "admin.config"}}</summary>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
|
|||||||
@@ -101,5 +101,21 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
||||||
</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 {callInitFunctions} from './modules/init.ts';
|
||||||
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||||
import {initRepoHiddenFolderToggle} from './features/repo-hidden-folders.ts';
|
import {initRepoHiddenFolderToggle} from './features/repo-hidden-folders.ts';
|
||||||
|
import {initSubscribePage} from './features/repo-subscribe.ts';
|
||||||
|
|
||||||
const initStartTime = performance.now();
|
const initStartTime = performance.now();
|
||||||
const initPerformanceTracer = callInitFunctions([
|
const initPerformanceTracer = callInitFunctions([
|
||||||
@@ -138,6 +139,7 @@ const initPerformanceTracer = callInitFunctions([
|
|||||||
initRepoTopicBar,
|
initRepoTopicBar,
|
||||||
initRepoViewFileTree,
|
initRepoViewFileTree,
|
||||||
initRepoHiddenFolderToggle,
|
initRepoHiddenFolderToggle,
|
||||||
|
initSubscribePage,
|
||||||
initRepoWikiForm,
|
initRepoWikiForm,
|
||||||
initRepository,
|
initRepository,
|
||||||
initRepositoryActionView,
|
initRepositoryActionView,
|
||||||
|
|||||||
Reference in New Issue
Block a user