2
0
Files
gitcaddy-server/routers/web/monetize/webhooks.go
logikonline d1f20f6b46
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
feat(ci): add repository subscription monetization system
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
2026-01-31 13:37:07 -05:00

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