diff --git a/frontend/web/src/pages/SubscriptionSetting.tsx b/frontend/web/src/pages/SubscriptionSetting.tsx
index 11236ec..35ce6e5 100644
--- a/frontend/web/src/pages/SubscriptionSetting.tsx
+++ b/frontend/web/src/pages/SubscriptionSetting.tsx
@@ -76,7 +76,7 @@ const SubscriptionSetting: React.FC = () => {
{subscription.plan === PlanType.FREE && (
-
+
Buy a license key
@@ -179,7 +179,7 @@ const SubscriptionSetting: React.FC = () => {
diff --git a/server/service/license/gumroad/requests.go b/server/service/license/gumroad/requests.go
new file mode 100644
index 0000000..cc8b7b3
--- /dev/null
+++ b/server/service/license/gumroad/requests.go
@@ -0,0 +1,204 @@
+package gumroad
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/pkg/errors"
+)
+
+const (
+ // The base API URL for the Gumroad API.
+ baseAPIURL = "https://api.gumroad.com"
+ // The product ID from Gumroad (base64 encoded).
+ productID = "mjSmJtQJcYNtu4vdxeQWNg=="
+)
+
+type Purchase struct {
+ SellerID string `json:"seller_id"`
+ ProductID string `json:"product_id"`
+ ProductName string `json:"product_name"`
+ Permalink string `json:"permalink"`
+ ProductPermalink string `json:"product_permalink"`
+ Email string `json:"email"`
+ Price int `json:"price"`
+ GumroadFee int `json:"gumroad_fee"`
+ Currency string `json:"currency"`
+ Quantity int `json:"quantity"`
+ SaleID string `json:"sale_id"`
+ SaleTimestamp string `json:"sale_timestamp"`
+ PurchaserID string `json:"purchaser_id,omitempty"`
+ SubscriptionID string `json:"subscription_id,omitempty"`
+ LicenseKey string `json:"license_key"`
+ IsMultiseatLicense bool `json:"is_multiseat_license"`
+ IPCountry string `json:"ip_country"`
+ Recurrence string `json:"recurrence,omitempty"`
+ Refunded bool `json:"refunded"`
+ Disputed bool `json:"disputed"`
+ DisputeWon bool `json:"dispute_won"`
+ Chargebacked bool `json:"chargebacked"`
+ ID string `json:"id"`
+ CreatedAt string `json:"created_at"`
+ SubscriptionEndedAt *string `json:"subscription_ended_at"`
+ SubscriptionCancelledAt *string `json:"subscription_cancelled_at"`
+ SubscriptionFailedAt *string `json:"subscription_failed_at"`
+}
+
+type ValidateLicenseKeyResponse struct {
+ Success bool `json:"success"`
+ Uses int `json:"uses"`
+ Purchase *Purchase `json:"purchase"`
+ Message *string `json:"message"`
+}
+
+type ActiveLicenseKeyResponse struct {
+ Success bool `json:"success"`
+ Uses int `json:"uses"`
+ Purchase *Purchase `json:"purchase"`
+ Message *string `json:"message"`
+}
+
+func ValidateLicenseKey(licenseKey string, instanceName string) (*ValidateLicenseKeyResponse, error) {
+ data := map[string]string{
+ "product_id": productID,
+ "license_key": licenseKey,
+ }
+ if instanceName != "" {
+ data["increment_uses_count"] = "false"
+ }
+ payload, err := json.Marshal(data)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal data")
+ }
+
+ req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/licenses/verify", baseAPIURL), bytes.NewBuffer(payload))
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to create request")
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to do request")
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var response ValidateLicenseKeyResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ return nil, err
+ }
+
+ // Check for errors
+ if response.Message != nil && *response.Message != "" {
+ return nil, errors.New(*response.Message)
+ }
+
+ // Validate the response
+ if response.Success && response.Purchase != nil {
+ purchase := response.Purchase
+
+ // Check if purchase was refunded/chargebacked
+ if purchase.Refunded {
+ return nil, errors.New("license key has been refunded")
+ }
+ if purchase.Chargebacked {
+ return nil, errors.New("license key has been chargebacked")
+ }
+ if purchase.Disputed && !purchase.DisputeWon {
+ return nil, errors.New("license key is disputed")
+ }
+
+ // Check subscription status (for membership products)
+ if purchase.SubscriptionEndedAt != nil && *purchase.SubscriptionEndedAt != "" {
+ return nil, errors.New("subscription has ended")
+ }
+ if purchase.SubscriptionCancelledAt != nil && *purchase.SubscriptionCancelledAt != "" {
+ return nil, errors.New("subscription has been cancelled")
+ }
+ if purchase.SubscriptionFailedAt != nil && *purchase.SubscriptionFailedAt != "" {
+ return nil, errors.New("subscription payment failed")
+ }
+ }
+
+ return &response, nil
+}
+
+func ActiveLicenseKey(licenseKey string, instanceName string) (*ActiveLicenseKeyResponse, error) {
+ data := map[string]string{
+ "product_id": productID,
+ "license_key": licenseKey,
+ "increment_uses_count": "true",
+ }
+ payload, err := json.Marshal(data)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to marshal data")
+ }
+
+ req, err := http.NewRequest("POST", fmt.Sprintf("%s/v2/licenses/verify", baseAPIURL), bytes.NewBuffer(payload))
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to create request")
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to do request")
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var response ActiveLicenseKeyResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ return nil, err
+ }
+
+ // Check for errors
+ if response.Message != nil && *response.Message != "" {
+ return nil, errors.New(*response.Message)
+ }
+
+ // Validate the response
+ if response.Success && response.Purchase != nil {
+ purchase := response.Purchase
+
+ // Check if purchase was refunded/chargebacked
+ if purchase.Refunded {
+ return nil, errors.New("license key has been refunded")
+ }
+ if purchase.Chargebacked {
+ return nil, errors.New("license key has been chargebacked")
+ }
+ if purchase.Disputed && !purchase.DisputeWon {
+ return nil, errors.New("license key is disputed")
+ }
+
+ // Check subscription status (for membership products)
+ if purchase.SubscriptionEndedAt != nil && *purchase.SubscriptionEndedAt != "" {
+ return nil, errors.New("subscription has ended")
+ }
+ if purchase.SubscriptionCancelledAt != nil && *purchase.SubscriptionCancelledAt != "" {
+ return nil, errors.New("subscription has been cancelled")
+ }
+ if purchase.SubscriptionFailedAt != nil && *purchase.SubscriptionFailedAt != "" {
+ return nil, errors.New("subscription payment failed")
+ }
+ }
+
+ return &response, nil
+}
diff --git a/server/service/license/license.go b/server/service/license/license.go
index f3e1af9..edd3bdf 100644
--- a/server/service/license/license.go
+++ b/server/service/license/license.go
@@ -13,6 +13,7 @@ import (
v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
storepb "github.com/yourselfhosted/slash/proto/gen/store"
"github.com/yourselfhosted/slash/server/profile"
+ "github.com/yourselfhosted/slash/server/service/license/gumroad"
"github.com/yourselfhosted/slash/server/service/license/lemonsqueezy"
"github.com/yourselfhosted/slash/store"
)
@@ -159,18 +160,31 @@ func validateLicenseKey(licenseKey string) (*ValidateResult, error) {
return result, nil
}
- // Try to validate the license key with the license server.
- validateResponse, err := lemonsqueezy.ValidateLicenseKey(licenseKey, "")
- if err != nil {
- return nil, errors.Wrap(err, "failed to validate license key")
+ // Try to validate the license key with Gumroad.
+ gumroadResponse, err := gumroad.ValidateLicenseKey(licenseKey, "")
+ if err == nil && gumroadResponse.Success {
+ result := &ValidateResult{
+ Plan: v1pb.PlanType_PRO,
+ Features: getDefaultFeatures(v1pb.PlanType_PRO),
+ Seats: -1, // Unlimited seats for Gumroad subscriptions
+ }
+ // For subscription products, set expiration based on recurrence
+ if gumroadResponse.Purchase != nil && gumroadResponse.Purchase.Recurrence != "" {
+ // Subscription is active, set expiration to 1 year from now
+ result.ExpiresTime = time.Now().AddDate(1, 0, 0)
+ }
+ return result, nil
}
- if validateResponse.Valid {
+
+ // Try to validate the license key with LemonSqueezy (fallback for existing customers).
+ lemonsqueezyResponse, err := lemonsqueezy.ValidateLicenseKey(licenseKey, "")
+ if err == nil && lemonsqueezyResponse.Valid {
result := &ValidateResult{
Plan: v1pb.PlanType_PRO,
Features: getDefaultFeatures(v1pb.PlanType_PRO),
}
- if validateResponse.LicenseKey.ExpiresAt != nil && *validateResponse.LicenseKey.ExpiresAt != "" {
- expiresTime, err := time.Parse(time.RFC3339Nano, *validateResponse.LicenseKey.ExpiresAt)
+ if lemonsqueezyResponse.LicenseKey.ExpiresAt != nil && *lemonsqueezyResponse.LicenseKey.ExpiresAt != "" {
+ expiresTime, err := time.Parse(time.RFC3339Nano, *lemonsqueezyResponse.LicenseKey.ExpiresAt)
if err != nil {
return nil, errors.Wrap(err, "failed to parse license key expires time")
}