Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4eec8c3
jf_activity: paginated list in ui
hrfee Dec 20, 2025
3d56266
jf_activity: ui changes
hrfee Dec 21, 2025
f987a68
api-users: add individual user summary route
hrfee Dec 21, 2025
f0fabb6
ui: add RadioBasedTabs
hrfee Dec 21, 2025
0019cba
ui: select by index, id over name, buttonHTML on RadioBasedTabSelector
hrfee Dec 23, 2025
3f6bc1b
accounts: show table row when clicking into details, more usage of
hrfee Dec 23, 2025
34591cb
admin: tab system improvement, search: ?search qp
hrfee Dec 24, 2025
a19f53c
list/search/accounts: fully(?) page-based search
hrfee Dec 25, 2025
ed5e81d
Implement Stripe payments for invites and improve Admin UI
Salpertio Dec 28, 2025
57fd4e2
models.go: Corrected the bad struct tag syntax from json:'labels" to …
Salpertio Dec 29, 2025
2fd9b7b
feat: pivot to Pay-to-Generate flow
Salpertio Dec 30, 2025
0df82d0
feat: implement monthly subscription tier with auto-renewal
Salpertio Dec 30, 2025
e39ba9f
store: add dynamic pricing config, remove standard plan
Salpertio Jan 2, 2026
fcea969
feat: [Feature Request] Stripe / Paypal integration
Salpertio Jan 3, 2026
bb118b0
Fix Stripe Webhooks (Async Email + Metadata) & Restore Cancellation H…
Salpertio Jan 3, 2026
b844b9e
list: add load queue
hrfee Jan 5, 2026
9d8aad6
tabs: add clearURL method, loading tabs clears previous qps
hrfee Jan 5, 2026
19f04f7
tooltip: rework as pseudo-component, fix overflow
hrfee Jan 5, 2026
133212e
userpage: fix confirmation required message display
hrfee Jan 5, 2026
153226c
email: complain about invalid from address
hrfee Mar 1, 2026
9de657c
jf_activity: fix infinite load
hrfee Mar 1, 2026
d75e715
settings: remove debug console.logs
hrfee Mar 1, 2026
7b4cfff
PWR: add optional polling mode
hrfee Mar 15, 2026
b281e93
refactor: align payment integration with project code style
Salpertio Apr 15, 2026
457c595
Merge upstream/main into feature/paypal-stripe-integration
Salpertio Apr 15, 2026
22d465c
Merge remote-tracking branch 'upstream/main' into feature/paypal-stri…
Salpertio Apr 16, 2026
be02a9f
feat: replace PayPal with BTCPay Server payment integration
Salpertio Apr 16, 2026
1f70811
refactor: align btcpay with project code style
Salpertio Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
data/
bin/
dist/
build/
node_modules/
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,20 @@ start.sh
ts/*.tsbuildinfo
ts/**/*.tsbuildinfo
js/
google-chrome-stable_current_amd64.deb
zorin
zorin.pub
.gitignore
traffic_capture.txt
.gitignore
traffic.dump
config.ini_.bak
.gitignore
config.ini
jfa-go

# BTCPay Server local dev stack
btcpay/

# Monero stagenet stack
monero/
271 changes: 271 additions & 0 deletions api-btcpay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package main

import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/btcpay"
lm "github.com/hrfee/jfa-go/logmessages"
)

var btcpayClient *btcpay.Client

// InitBTCPay initializes the BTCPay Server client from config.
func InitBTCPay(config *Config) {
server := config.Section("btcpay").Key("server").String()
apiKey := config.Section("btcpay").Key("api_key").String()
storeID := config.Section("btcpay").Key("store_id").String()
webhookSecret := config.Section("btcpay").Key("webhook_secret").String()

if server == "" || apiKey == "" || storeID == "" {
btcpayEnabled = false
return
}

if !strings.HasPrefix(server, "http://") && !strings.HasPrefix(server, "https://") {
server = "https://" + server
}

btcpayClient = btcpay.NewClient(server, apiKey, storeID, webhookSecret)
btcpayEnabled = true
}

type btcpayCheckoutDTO struct {
Email string `json:"email" binding:"required,email"`
Plan string `json:"plan" binding:"required"`
}

// @Summary Create a BTCPay checkout invoice (Pay-to-Generate).
// @Produce json
// @Param body body btcpayCheckoutDTO true "Checkout Request"
// @Success 200 {object} stringResponse
// @Router /btcpay/create-checkout [post]
func (app *appContext) PostBTCPayCreateCheckout(gc *gin.Context) {
if !btcpayEnabled {
respond(400, "BTCPay disabled", gc)
return
}

var req btcpayCheckoutDTO
if err := gc.ShouldBindJSON(&req); err != nil {
respond(400, "Invalid request: "+err.Error(), gc)
return
}

if req.Plan == "Monthly" {
if userID, _, found := app.findUserByEmail(req.Email); found {
user, err := app.jf.UserByID(userID, false)
if err == nil && !user.Policy.IsDisabled {
expiry := time.Now()
if userExpiry, ok := app.storage.GetUserExpiryKey(userID); ok {
expiry = userExpiry.Expiry
}
if expiry.After(time.Now()) {
app.info.Printf(lm.BTCPayBlockedDuplicate, userID, req.Email)
respond(409, "You already have an active subscription.", gc)
return
}
}
}
}

currency := app.config.Section("btcpay").Key("price_currency").MustString("USD")
priceMonthly := app.config.Section("btcpay").Key("price_monthly").MustFloat64(2.00)

var priceAmount float64
var profileName = "Default"

if req.Plan == "Monthly" {
priceAmount = priceMonthly
} else {
req.Plan = "Standard"
priceAmount = priceMonthly // fallback; extend as needed
}

refID := "btcpay-" + strconv.FormatInt(time.Now().Unix(), 10)

baseURL := ExternalURI(gc)
successURL := fmt.Sprintf("%s/payment/success", baseURL)

invoice, err := btcpayClient.CreateInvoice(btcpay.InvoiceRequest{
Amount: priceAmount,
Currency: currency,
Metadata: map[string]string{
"target_email": req.Email,
"plan": req.Plan,
"profile": profileName,
"ref_id": refID,
},
Checkout: &btcpay.InvoiceCheckout{
RedirectURL: successURL,
RedirectAutomatically: true,
},
})
if err != nil {
app.err.Printf(lm.FailedCreateBTCPayInvoice, err)
respond(500, "Failed to create BTCPay invoice", gc)
return
}

app.info.Printf(lm.BTCPayInvoiceCreated, invoice.ID, req.Email)
gc.JSON(200, stringResponse{Response: invoice.CheckoutLink})
}

// @Summary Handle BTCPay Server Webhooks
// @Router /btcpay/webhook [post]
func (app *appContext) BTCPayWebhook(gc *gin.Context) {
if !btcpayEnabled {
gc.AbortWithStatus(404)
return
}

const MaxBodyBytes = int64(65536)
gc.Request.Body = http.MaxBytesReader(gc.Writer, gc.Request.Body, MaxBodyBytes)
payload, err := io.ReadAll(gc.Request.Body)
if err != nil {
app.err.Printf(lm.FailedReading, "request body", err)
gc.AbortWithStatus(400)
return
}

verifySignature := app.config.Section("btcpay").Key("verify_signature").MustBool(true)
if verifySignature {
sigHeader := gc.GetHeader("BTCPay-Sig")
if !btcpayClient.VerifyWebhookSignature(payload, sigHeader) {
app.err.Printf(lm.BTCPayWebhookError, fmt.Errorf("invalid webhook signature"))
gc.AbortWithStatus(403)
return
}
}

event, err := btcpay.ParseWebhookEvent(payload)
if err != nil {
app.err.Printf(lm.BTCPayWebhookError, err)
gc.AbortWithStatus(400)
return
}

app.info.Printf(lm.BTCPayWebhookReceived, event.Type, event.InvoiceID)

switch event.Type {
case "InvoiceSettled":
app.handleBTCPayInvoiceSettled(event)
case "InvoiceExpired":
app.debug.Printf(lm.BTCPayInvoiceExpired, event.InvoiceID)
case "InvoiceInvalid":
app.err.Printf(lm.BTCPayInvoiceInvalid, event.InvoiceID)
}

gc.Status(200)
}

func (app *appContext) handleBTCPayInvoiceSettled(event *btcpay.WebhookEvent) {
invoice, err := btcpayClient.GetInvoice(event.InvoiceID)
if err != nil {
app.err.Printf(lm.BTCPayWebhookError, fmt.Errorf("failed to get invoice %s: %w", event.InvoiceID, err))
return
}

targetEmail := invoice.Metadata["target_email"]
plan := invoice.Metadata["plan"]
profile := invoice.Metadata["profile"]

if targetEmail == "" {
app.err.Printf(lm.BTCPayWebhookError, fmt.Errorf("invoice %s has no target_email in metadata", event.InvoiceID))
return
}

app.info.Printf(lm.BTCPayPaymentReceived, plan, targetEmail, event.InvoiceID)

existingUserID, existingEmail, found := app.findUserByEmail(targetEmail)
if found {
app.info.Printf(lm.ExistingUserFound, targetEmail, existingUserID)

existingEmail.Label = "BTCPay Invoice: " + event.InvoiceID
app.storage.SetEmailsKey(existingUserID, existingEmail)

expiry := time.Now()
lastTx := ""
if userExpiry, ok := app.storage.GetUserExpiryKey(existingUserID); ok {
expiry = userExpiry.Expiry
lastTx = userExpiry.LastTransactionID
}

if lastTx == event.InvoiceID {
app.info.Printf(lm.BTCPayInvoiceAlreadyProcessed, event.InvoiceID, existingUserID)
return
}

if expiry.Before(time.Now()) {
expiry = time.Now()
}
var newExpiry time.Time
if plan == "Monthly" {
newExpiry = expiry.AddDate(0, 1, 0)
} else {
newExpiry = expiry.AddDate(10, 0, 0)
}
app.storage.SetUserExpiryKey(existingUserID, UserExpiry{Expiry: newExpiry, LastTransactionID: event.InvoiceID})

if paramsUser, err := app.jf.UserByID(existingUserID, false); err == nil {
if err, _, _ = app.SetUserDisabled(paramsUser, false); err != nil {
app.err.Printf(lm.FailedReEnableUser, existingUserID, err)
}
app.InvalidateUserCaches()
}

app.info.Printf(lm.UserReactivated, existingUserID, newExpiry)
return
}

inviteCode := GenerateInviteCode()
if profile == "" {
profile = "Default"
}
if _, ok := app.storage.GetProfileKey(profile); !ok {
app.debug.Printf(lm.FailedGetProfile, profile)
profile = "Default"
}

invite := Invite{
Code: inviteCode,
Created: time.Now(),
Label: "BTCPay " + plan + " by " + targetEmail,
UserLabel: "Purchased via BTCPay",
RemainingUses: 1,
Profile: profile,
SendTo: targetEmail,
}

if plan == "Monthly" {
invite.ValidTill = time.Now().AddDate(0, 1, 0)
invite.UserExpiry = true
invite.UserMonths = 1
} else {
invite.ValidTill = time.Now().AddDate(10, 0, 0)
invite.UserExpiry = false
}

app.storage.SetInvitesKey(inviteCode, invite)
app.info.Printf(lm.GeneratedInviteForPurchase, inviteCode, targetEmail)

if emailEnabled {
go func(inv Invite, tEmail string) {
msg, err := app.email.constructInvite(&inv, false)
if err != nil {
app.err.Printf(lm.FailedConstructInviteMessage, tEmail, err)
return
}
if err = app.email.send(msg, tEmail); err != nil {
app.err.Printf(lm.FailedSendInviteMessage, inv.Code, tEmail, err)
} else {
app.info.Printf(lm.SentInviteMessage, inv.Code, tEmail)
}
}(invite, targetEmail)
}
}
14 changes: 14 additions & 0 deletions api-invites.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
newInv.RemainingUses--
}
newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})

// Reset PaymentStatus to ensure multi-use invites require payment PER USE.
if newInv.PaymentStatus == "paid" {
newInv.PaymentStatus = ""
}

if !del {
app.storage.SetInvitesKey(code, newInv)
}
Expand Down Expand Up @@ -358,6 +364,14 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.UserLabel = req.UserLabel
}
invite.Created = currentTime
if req.Price > 0 {
invite.RequiredPayment = true
invite.PriceAmount = req.Price
invite.PriceCurrency = req.Currency
if invite.PriceCurrency == "" {
invite.PriceCurrency = app.config.Section("stripe").Key("price_currency").MustString("usd")
}
}
if req.MultipleUses {
if req.NoLimit {
invite.NoLimit = true
Expand Down
Loading