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
351 lines
10 KiB
Go
351 lines
10 KiB
Go
// 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)
|
|
}
|
|
}
|