From 7f2e55c567ab693167c0d19264465ea67b3f6e90 Mon Sep 17 00:00:00 2001
From: wander <127889726+heywander@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:22:22 -0400
Subject: [PATCH 1/8] chore: clean up existing Go literals
---
jf_activity_test.go | 2 +-
migrations.go | 4 ++--
models.go | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/jf_activity_test.go b/jf_activity_test.go
index 4c295b9c..dfdb1a91 100644
--- a/jf_activity_test.go
+++ b/jf_activity_test.go
@@ -24,7 +24,7 @@ func (m *MockActivityLogSource) run(size int, delay time.Duration, finished *boo
m.lock.Lock()
log := mediabrowser.ActivityLogEntry{
ID: int64(i),
- Date: mediabrowser.Time{time.Now()},
+ Date: mediabrowser.Time{Time: time.Now()},
}
m.logs[i] = log
m.i = i + 1
diff --git a/migrations.go b/migrations.go
index 692c47be..8826acb6 100644
--- a/migrations.go
+++ b/migrations.go
@@ -195,8 +195,8 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error {
continue
}
_, err = app.ombi.SetNotificationPrefs(ombiUser, []ombi.NotificationPref{
- {ombi.NotifAgentDiscord, ombiUser["id"].(string), ids[0], true},
- {ombi.NotifAgentTelegram, ombiUser["id"].(string), ids[1], true},
+ {Agent: ombi.NotifAgentDiscord, UserID: ombiUser["id"].(string), Value: ids[0], Enabled: true},
+ {Agent: ombi.NotifAgentTelegram, UserID: ombiUser["id"].(string), Value: ids[1], Enabled: true},
})
if err != nil {
app.debug.Printf("Failed to set prefs for Ombi user \"%s\": %v", ombiUser["userName"].(string), err)
diff --git a/models.go b/models.go
index feade6b7..a7c91003 100644
--- a/models.go
+++ b/models.go
@@ -529,7 +529,7 @@ type TaskDTO struct {
}
type LabelsDTO struct {
- Labels []string `json:'labels"`
+ Labels []string `json:"labels"`
}
type ActivityLogEntriesDTO struct {
From 2c42818a2af7bfd176bb736e049cf8328515829e Mon Sep 17 00:00:00 2001
From: wander <127889726+heywander@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:22:26 -0400
Subject: [PATCH 2/8] jellyseerr: normalize server URL
---
jellyseerr/jellyseerr.go | 3 ++-
jellyseerr/jellyseerr_test.go | 16 ++++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go
index f781d935..a79260b7 100644
--- a/jellyseerr/jellyseerr.go
+++ b/jellyseerr/jellyseerr.go
@@ -37,6 +37,7 @@ type Jellyseerr struct {
// NewJellyseerr returns an Ombi object.
func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellyseerr {
+ server = strings.TrimRight(server, "/")
if !strings.HasSuffix(server, API_SUFFIX) {
server = server + API_SUFFIX
}
@@ -430,7 +431,7 @@ func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsFie
switch v.(type) {
case string:
conf[FieldDiscord] = []string{v.(string)}
- }
+ }
}
u, err := js.getUser(jfID)
if err != nil {
diff --git a/jellyseerr/jellyseerr_test.go b/jellyseerr/jellyseerr_test.go
index 11807582..94fcacfa 100644
--- a/jellyseerr/jellyseerr_test.go
+++ b/jellyseerr/jellyseerr_test.go
@@ -16,6 +16,22 @@ func client() *Jellyseerr {
return NewJellyseerr(URI, API_KEY, common.NewTimeoutHandler("Jellyseerr", URI, false))
}
+func TestNewJellyseerrNormalizesServerURL(t *testing.T) {
+ tests := map[string]string{
+ "http://localhost:5055": "http://localhost:5055/api/v1",
+ "http://localhost:5055/": "http://localhost:5055/api/v1",
+ "http://localhost:5055/api/v1": "http://localhost:5055/api/v1",
+ "http://localhost:5055/api/v1/": "http://localhost:5055/api/v1",
+ "http://localhost:5055////": "http://localhost:5055/api/v1",
+ }
+ for server, want := range tests {
+ js := NewJellyseerr(server, API_KEY, common.NewTimeoutHandler("Jellyseerr", server, false))
+ if js.server != want {
+ t.Fatalf("NewJellyseerr(%q) server = %q, want %q", server, js.server, want)
+ }
+ }
+}
+
func TestMe(t *testing.T) {
js := client()
u, err := js.Me()
From f60501cd31e253da65126d4d2f00bb1cb8a44e02 Mon Sep 17 00:00:00 2001
From: wander <127889726+heywander@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:22:37 -0400
Subject: [PATCH 3/8] payments: add Stripe config and storage
---
config.go | 8 +
config/config-base.yaml | 80 ++++
go.mod | 1 +
go.sum | 2 +
models.go | 96 +++-
payment_lock.go | 84 ++++
payment_plans.go | 483 ++++++++++++++++++++
payments.go | 951 ++++++++++++++++++++++++++++++++++++++++
storage.go | 96 +++-
stripe.go | 101 +++++
10 files changed, 1898 insertions(+), 4 deletions(-)
create mode 100644 payment_lock.go
create mode 100644 payment_plans.go
create mode 100644 payments.go
create mode 100644 stripe.go
diff --git a/config.go b/config.go
index 39be0e29..4ffd0b4f 100644
--- a/config.go
+++ b/config.go
@@ -30,6 +30,7 @@ var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
+var stripeEnabled = false
// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating.
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
@@ -266,6 +267,11 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf
config.MustSetValue("smtp", "auth_type", "4")
config.MustSetValue("smtp", "port", "465")
+ config.MustSetValue("stripe", stripePaymentPlansSetting, defaultPaymentPlansJSON(
+ config.Section("stripe").Key("price_monthly").MustInt64(200),
+ config.Section("stripe").Key("price_currency").MustString("usd"),
+ ))
+
config.MustSetValue("activity_log", "keep_n_records", "1000")
config.MustSetValue("activity_log", "delete_after_days", "90")
@@ -343,6 +349,8 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf
messagesEnabled = false
}
+ stripeEnabled = config.Section("stripe").Key("enabled").MustBool(false)
+
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
config.proxyConfig = &easyproxy.ProxyConfig{}
config.proxyConfig.Protocol = easyproxy.HTTP
diff --git a/config/config-base.yaml b/config/config-base.yaml
index bc5fcc48..2dc22f42 100644
--- a/config/config-base.yaml
+++ b/config/config-base.yaml
@@ -22,6 +22,7 @@ groups:
- section: ombi
- section: jellyseerr
- section: webhooks
+ - section: stripe
- group: email
name: "Email"
description: "Options for sending emails through jfa-go."
@@ -558,6 +559,68 @@ sections:
value: none
description: 'Extra debug logging for writes to the database. *: Deletion also
includes blanking out major fields, e.g. an email address.'
+- section: stripe
+ meta:
+ name: Stripe
+ description: Settings for Stripe payments.
+ settings:
+ - setting: enabled
+ name: Enabled
+ requires_restart: true
+ type: bool
+ value: false
+ description: Enable Stripe payments.
+ - setting: api_key
+ name: API Key
+ depends_true: enabled
+ requires_restart: true
+ type: password
+ description: Stripe Secret API Key (begins with sk_).
+ - setting: webhook_secret
+ name: Webhook Secret
+ depends_true: enabled
+ requires_restart: true
+ type: password
+ description: Stripe Webhook Signing Secret (begins with whsec_).
+ - setting: instance_id
+ name: Instance ID
+ depends_true: enabled
+ requires_restart: true
+ advanced: true
+ type: text
+ description: Stable ID written to Stripe metadata for payment reconciliation. Leave blank to generate automatically.
+ - setting: price_currency
+ name: Currency
+ depends_true: enabled
+ type: select
+ options:
+ - ["usd", "USD"]
+ - ["eur", "EUR"]
+ - ["gbp", "GBP"]
+ - ["cad", "CAD"]
+ - ["aud", "AUD"]
+ value: usd
+ description: Currency for store prices.
+ - setting: price_monthly
+ name: Monthly Plan Price
+ depends_true: enabled
+ type: number
+ value: 200
+ description: Price for monthly subscription (in cents).
+ - setting: payment_plans
+ name: Payment Plans
+ depends_true: enabled
+ advanced: true
+ type: text
+ value: '[{"id":"monthly","name":"Monthly","description":"Recurring monthly access.","enabled":true,"price":200,"currency":"usd","recurring":true,"stripe_interval":"month","stripe_interval_count":1,"access_months":1,"profile":"Default"},{"id":"quarterly","name":"Quarterly","description":"Recurring access billed every three months.","enabled":true,"price":600,"currency":"usd","recurring":true,"stripe_interval":"month","stripe_interval_count":3,"access_months":3,"profile":"Default"},{"id":"yearly","name":"Yearly","description":"Recurring annual access.","enabled":true,"price":2400,"currency":"usd","recurring":true,"stripe_interval":"year","stripe_interval_count":1,"access_months":12,"profile":"Default"},{"id":"one_month","name":"One Month Pass","description":"One-time access for one month.","enabled":false,"price":200,"currency":"usd","recurring":false,"access_months":1,"profile":"Default"}]'
+ description: JSON payment plan catalog. Prefer editing plans from the Payments tab.
+ - setting: verify_signature
+ name: Verify Webhook Signature
+ depends_true: enabled
+ requires_restart: true
+ type: bool
+ value: true
+ description: Enforce Stripe webhook signature verification. Disable only if your reverse proxy modifies webhook payloads.
- section: activity_log
meta:
name: Activity Log
@@ -1273,6 +1336,23 @@ sections:
depends_true: enabled
type: text
description: Subject of invite emails.
+ - setting: purchased_email_html
+ name: Purchased invite email (HTML)
+ advanced: true
+ depends_true: enabled
+ type: text
+ description: Path to custom purchased invite email HTML.
+ - setting: purchased_email_text
+ name: Purchased invite email (plaintext)
+ advanced: true
+ depends_true: enabled
+ type: text
+ description: Path to custom purchased invite email in plain text.
+ - setting: purchased_subject
+ name: Purchased invite subject
+ depends_true: enabled
+ type: text
+ description: Subject of purchased invite emails.
- setting: url_base
name: External jfa-go URL
required: true
diff --git a/go.mod b/go.mod
index 85bba00b..96fd2311 100644
--- a/go.mod
+++ b/go.mod
@@ -51,6 +51,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1
+ github.com/stripe/stripe-go/v86 v86.1.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/timshannon/badgerhold/v4 v4.0.3
diff --git a/go.sum b/go.sum
index 172fced8..51708d5e 100644
--- a/go.sum
+++ b/go.sum
@@ -319,6 +319,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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stripe/stripe-go/v86 v86.1.0 h1:xisqyg8BzooIEpaB74d8SD26d9JIPVQ1TkeB6t7gBKo=
+github.com/stripe/stripe-go/v86 v86.1.0/go.mod h1:Co7QRXCKGNOPTugAdvjgRo+KcMtd9hxy+pZMN0yThsQ=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
diff --git a/models.go b/models.go
index a7c91003..99392b6f 100644
--- a/models.go
+++ b/models.go
@@ -70,6 +70,8 @@ type generateInviteDTO struct {
Profile string `json:"profile" example:"DefaultProfile"` // Name of profile to apply on this invite
Label string `json:"label" example:"For Friends"` // Optional label for the invite
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
+ Price int64 `json:"price,omitempty"` // Price in cents
+ Currency string `json:"currency,omitempty"` // Currency code (e.g., "usd")
}
type SendInviteDTO struct {
@@ -117,9 +119,11 @@ type inviteDTO struct {
Created int64 `json:"created" example:"1617737207510"` // Date of creation
UsedBy map[string]int64 `json:"used_by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no_limit"` // If true, invite can be used any number of times
- RemainingUses int `json:"remaining_uses,omitempty"` // Remaining number of uses (if applicable)
- SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable)
- SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed.
+ RemainingUses int `json:"remaining_uses"`
+ Price int64 `json:"price,omitempty"`
+ Currency string `json:"currency,omitempty"`
+ SendTo string `json:"send_to,omitempty"`
+ SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed.
}
type EditableInviteDTO struct {
@@ -141,6 +145,91 @@ type getInvitesDTO struct {
Invites []inviteDTO `json:"invites"` // List of invites
}
+type paymentDTO struct {
+ ID string `json:"id"`
+ Provider string `json:"provider"`
+ InstanceID string `json:"instance_id,omitempty"`
+ ProviderPaymentID string `json:"provider_payment_id"`
+ ProviderLiveMode bool `json:"provider_live_mode,omitempty"`
+ CustomerID string `json:"customer_id,omitempty"`
+ PaymentIntentID string `json:"payment_intent_id,omitempty"`
+ ChargeID string `json:"charge_id,omitempty"`
+ InvoiceID string `json:"invoice_id,omitempty"`
+ SubscriptionID string `json:"subscription_id,omitempty"`
+ TargetEmail string `json:"target_email"`
+ PlanID string `json:"plan_id,omitempty"`
+ Plan string `json:"plan"`
+ Profile string `json:"profile"`
+ AccessMonths int `json:"access_months,omitempty"`
+ AccessDays int `json:"access_days,omitempty"`
+ Recurring bool `json:"recurring,omitempty"`
+ StripeInterval string `json:"stripe_interval,omitempty"`
+ StripeIntervalCount int64 `json:"stripe_interval_count,omitempty"`
+ Amount int64 `json:"amount"`
+ RefundedAmount int64 `json:"refunded_amount,omitempty"`
+ Currency string `json:"currency"`
+ Status string `json:"status"`
+ EmailStatus string `json:"email_status"`
+ InvoiceStatus string `json:"invoice_status,omitempty"`
+ SubscriptionStatus string `json:"subscription_status,omitempty"`
+ SubscriptionCancelAt int64 `json:"subscription_cancel_at,omitempty"`
+ SubscriptionCancelAtPeriodEnd bool `json:"subscription_cancel_at_period_end,omitempty"`
+ SubscriptionCanceledAt int64 `json:"subscription_canceled_at,omitempty"`
+ SubscriptionEndedAt int64 `json:"subscription_ended_at,omitempty"`
+ SubscriptionCancelNotifiedAt int64 `json:"subscription_cancel_notified_at,omitempty"`
+ InviteCode string `json:"invite_code,omitempty"`
+ JellyfinID string `json:"jellyfin_id,omitempty"`
+ Error string `json:"error,omitempty"`
+ Created int64 `json:"created"`
+ Updated int64 `json:"updated"`
+ PaidAt int64 `json:"paid_at,omitempty"`
+ FulfilledAt int64 `json:"fulfilled_at,omitempty"`
+ EmailSentAt int64 `json:"email_sent_at,omitempty"`
+ LastReconciledAt int64 `json:"last_reconciled_at,omitempty"`
+}
+
+type getPaymentsDTO struct {
+ Payments []paymentDTO `json:"payments"`
+}
+
+type ReconcilePaymentsDTO struct {
+ Scanned int `json:"scanned"`
+ Matched int `json:"matched"`
+ Created int `json:"created"`
+ Updated int `json:"updated"`
+ Skipped int `json:"skipped"`
+ Refreshed int `json:"refreshed"`
+ LifecycleUpdates int `json:"lifecycle_updates"`
+ NeedsReview int `json:"needs_review"`
+ Error string `json:"error,omitempty"`
+}
+
+type cancelPaymentSubscriptionDTO struct {
+ When string `json:"when"`
+ CancelAt int64 `json:"cancel_at,omitempty"`
+ Refund bool `json:"refund"`
+}
+
+type cancelPaymentSubscriptionResponseDTO struct {
+ Payment paymentDTO `json:"payment"`
+ SubscriptionID string `json:"subscription_id"`
+ RefundID string `json:"refund_id,omitempty"`
+}
+
+type MySubscriptionDTO struct {
+ Provider string `json:"provider"`
+ SubscriptionID string `json:"subscription_id"`
+ Status string `json:"status"`
+ PaymentStatus string `json:"payment_status"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ CancelAt int64 `json:"cancel_at,omitempty"`
+ CanceledAt int64 `json:"canceled_at,omitempty"`
+ EndedAt int64 `json:"ended_at,omitempty"`
+ PaidThrough int64 `json:"paid_through,omitempty"`
+ Amount int64 `json:"amount,omitempty"`
+ Currency string `json:"currency,omitempty"`
+}
+
// fake DTO, if i actually used this the code would be a lot longer
type setNotifyValues map[string]struct {
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
@@ -414,6 +503,7 @@ type MyDetailsDTO struct {
Discord *MyDetailsContactMethodsDTO `json:"discord,omitempty"`
Telegram *MyDetailsContactMethodsDTO `json:"telegram,omitempty"`
Matrix *MyDetailsContactMethodsDTO `json:"matrix,omitempty"`
+ Subscription *MySubscriptionDTO `json:"subscription,omitempty"`
HasReferrals bool `json:"has_referrals,omitempty"`
}
diff --git a/payment_lock.go b/payment_lock.go
new file mode 100644
index 00000000..bfdaa775
--- /dev/null
+++ b/payment_lock.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "time"
+)
+
+const (
+ paymentLockCookieName = "jfa_payment_lock"
+ paymentLockMaxAge = 24 * 3600
+)
+
+func newPaymentLockToken() (token, hash string, err error) {
+ raw := make([]byte, 32)
+ if _, err = rand.Read(raw); err != nil {
+ return "", "", err
+ }
+ token = base64.RawURLEncoding.EncodeToString(raw)
+ return token, hashPaymentLockToken(token), nil
+}
+
+func hashPaymentLockToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+func (app *appContext) paymentForInviteUnlock(invite Invite) (Payment, bool) {
+ if invite.PaymentID != "" {
+ if payment, ok := app.storage.GetPaymentKey(invite.PaymentID); ok {
+ return payment, true
+ }
+ }
+
+ var best Payment
+ found := false
+ for _, payment := range app.storage.GetPayments() {
+ if payment.InviteCode != invite.Code || payment.InviteLockHash == "" {
+ continue
+ }
+ if !found || payment.Created.After(best.Created) {
+ best = payment
+ found = true
+ }
+ }
+ return best, found
+}
+
+func (app *appContext) validPaidInvitePaymentLock(invite Invite, token string) bool {
+ if !invite.RequiredPayment || invite.PaymentStatus != paymentStatusPaid {
+ return true
+ }
+ if token == "" {
+ return false
+ }
+
+ if payment, ok := app.paymentForInviteUnlock(invite); ok && payment.InviteLockHash != "" {
+ return subtle.ConstantTimeCompare([]byte(payment.InviteLockHash), []byte(hashPaymentLockToken(token))) == 1
+ }
+
+ // Compatibility for paid unlocks created before random payment locks existed.
+ return subtle.ConstantTimeCompare([]byte(token), []byte(invite.Code)) == 1
+}
+
+func (app *appContext) paidInvitePaymentLockFromCookie(gc interface {
+ Cookie(string) (string, error)
+}, invite Invite) bool {
+ if !invite.RequiredPayment || invite.PaymentStatus != paymentStatusPaid {
+ return true
+ }
+ token, err := gc.Cookie(paymentLockCookieName)
+ if err != nil {
+ return false
+ }
+ return app.validPaidInvitePaymentLock(invite, token)
+}
+
+func setPaymentLockCreated(payment *Payment) {
+ if payment.InviteLockCreatedAt.IsZero() {
+ payment.InviteLockCreatedAt = time.Now()
+ }
+}
diff --git a/payment_plans.go b/payment_plans.go
new file mode 100644
index 00000000..6ac2afec
--- /dev/null
+++ b/payment_plans.go
@@ -0,0 +1,483 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "gopkg.in/ini.v1"
+)
+
+const stripePaymentPlansSetting = "payment_plans"
+
+var paymentPlanIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+
+type PaymentPlan struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Enabled bool `json:"enabled"`
+ Price int64 `json:"price"`
+ Currency string `json:"currency"`
+ Recurring bool `json:"recurring"`
+ StripeInterval string `json:"stripe_interval,omitempty"`
+ StripeIntervalCount int64 `json:"stripe_interval_count,omitempty"`
+ AccessMonths int `json:"access_months,omitempty"`
+ AccessDays int `json:"access_days,omitempty"`
+ Profile string `json:"profile,omitempty"`
+}
+
+type paymentPlansDTO struct {
+ Plans []PaymentPlan `json:"plans"`
+}
+
+type storePlanView struct {
+ ID string
+ Name string
+ Description string
+ Price string
+ Currency string
+ Billing string
+ Access string
+}
+
+func defaultPaymentPlans(monthlyPrice int64, currency string) []PaymentPlan {
+ currency = normalizePaymentCurrency(currency)
+ if monthlyPrice <= 0 {
+ monthlyPrice = 200
+ }
+ return []PaymentPlan{
+ {
+ ID: "monthly",
+ Name: paymentPlanMonthly,
+ Description: "Recurring monthly access.",
+ Enabled: true,
+ Price: monthlyPrice,
+ Currency: currency,
+ Recurring: true,
+ StripeInterval: "month",
+ StripeIntervalCount: 1,
+ AccessMonths: 1,
+ Profile: paymentDefaultProfile,
+ },
+ {
+ ID: "quarterly",
+ Name: "Quarterly",
+ Description: "Recurring access billed every three months.",
+ Enabled: true,
+ Price: monthlyPrice * 3,
+ Currency: currency,
+ Recurring: true,
+ StripeInterval: "month",
+ StripeIntervalCount: 3,
+ AccessMonths: 3,
+ Profile: paymentDefaultProfile,
+ },
+ {
+ ID: "yearly",
+ Name: "Yearly",
+ Description: "Recurring annual access.",
+ Enabled: true,
+ Price: monthlyPrice * 12,
+ Currency: currency,
+ Recurring: true,
+ StripeInterval: "year",
+ StripeIntervalCount: 1,
+ AccessMonths: 12,
+ Profile: paymentDefaultProfile,
+ },
+ {
+ ID: "one_month",
+ Name: "One Month Pass",
+ Description: "One-time access for one month.",
+ Enabled: false,
+ Price: monthlyPrice,
+ Currency: currency,
+ Recurring: false,
+ AccessMonths: 1,
+ Profile: paymentDefaultProfile,
+ },
+ }
+}
+
+func defaultPaymentPlansJSON(monthlyPrice int64, currency string) string {
+ b, _ := json.Marshal(defaultPaymentPlans(monthlyPrice, currency))
+ return string(b)
+}
+
+func normalizePaymentCurrency(currency string) string {
+ currency = strings.ToLower(strings.TrimSpace(currency))
+ if len(currency) != 3 {
+ return "usd"
+ }
+ return currency
+}
+
+func normalizePaymentPlanID(id string) string {
+ id = strings.ToLower(strings.TrimSpace(id))
+ id = strings.ReplaceAll(id, " ", "_")
+ out := strings.Builder{}
+ for _, r := range id {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ out.WriteRune(r)
+ case r == '_' || r == '-':
+ out.WriteRune(r)
+ }
+ }
+ return strings.Trim(out.String(), "_-")
+}
+
+func normalizeStripeInterval(interval string) string {
+ switch strings.ToLower(strings.TrimSpace(interval)) {
+ case "day", "week", "month", "year":
+ return strings.ToLower(strings.TrimSpace(interval))
+ default:
+ return "month"
+ }
+}
+
+func normalizePaymentPlans(plans []PaymentPlan, defaultCurrency string) ([]PaymentPlan, error) {
+ defaultCurrency = normalizePaymentCurrency(defaultCurrency)
+ seen := map[string]bool{}
+ out := make([]PaymentPlan, 0, len(plans))
+ for i, plan := range plans {
+ plan.ID = normalizePaymentPlanID(plan.ID)
+ if plan.ID == "" {
+ plan.ID = normalizePaymentPlanID(plan.Name)
+ }
+ if plan.ID == "" || !paymentPlanIDPattern.MatchString(plan.ID) {
+ return nil, fmt.Errorf("plan %d has an invalid ID", i+1)
+ }
+ if seen[plan.ID] {
+ return nil, fmt.Errorf("duplicate plan ID %q", plan.ID)
+ }
+ seen[plan.ID] = true
+
+ plan.Name = strings.TrimSpace(plan.Name)
+ if plan.Name == "" {
+ return nil, fmt.Errorf("plan %q needs a name", plan.ID)
+ }
+ plan.Description = strings.TrimSpace(plan.Description)
+ if plan.Price <= 0 {
+ return nil, fmt.Errorf("plan %q needs a positive price", plan.ID)
+ }
+ plan.Currency = normalizePaymentCurrency(firstNonEmpty(plan.Currency, defaultCurrency))
+ if plan.Profile == "" {
+ plan.Profile = paymentDefaultProfile
+ }
+ if plan.AccessMonths < 0 || plan.AccessDays < 0 {
+ return nil, fmt.Errorf("plan %q has a negative access duration", plan.ID)
+ }
+ if plan.AccessMonths == 0 && plan.AccessDays == 0 {
+ return nil, fmt.Errorf("plan %q needs an access duration", plan.ID)
+ }
+ if plan.Recurring {
+ plan.StripeInterval = normalizeStripeInterval(plan.StripeInterval)
+ if plan.StripeIntervalCount <= 0 {
+ plan.StripeIntervalCount = 1
+ }
+ } else {
+ plan.StripeInterval = ""
+ plan.StripeIntervalCount = 0
+ }
+ out = append(out, plan)
+ }
+ if len(out) == 0 {
+ return nil, errors.New("at least one payment plan is required")
+ }
+ return out, nil
+}
+
+func (app *appContext) paymentPlans() []PaymentPlan {
+ defaultCurrency := app.config.Section("stripe").Key("price_currency").MustString("usd")
+ defaultMonthly := app.config.Section("stripe").Key("price_monthly").MustInt64(200)
+ raw := strings.TrimSpace(app.config.Section("stripe").Key(stripePaymentPlansSetting).String())
+ if raw == "" {
+ return defaultPaymentPlans(defaultMonthly, defaultCurrency)
+ }
+
+ var plans []PaymentPlan
+ if err := json.Unmarshal([]byte(raw), &plans); err != nil {
+ if app.err != nil {
+ app.err.Printf("Failed to parse Stripe payment plans, using defaults: %v", err)
+ }
+ return defaultPaymentPlans(defaultMonthly, defaultCurrency)
+ }
+ plans, err := normalizePaymentPlans(plans, defaultCurrency)
+ if err != nil {
+ if app.err != nil {
+ app.err.Printf("Invalid Stripe payment plans, using defaults: %v", err)
+ }
+ return defaultPaymentPlans(defaultMonthly, defaultCurrency)
+ }
+ return plans
+}
+
+func (app *appContext) publicPaymentPlans() []PaymentPlan {
+ plans := app.paymentPlans()
+ out := make([]PaymentPlan, 0, len(plans))
+ for _, plan := range plans {
+ if plan.Enabled {
+ out = append(out, plan)
+ }
+ }
+ return out
+}
+
+func (app *appContext) paymentPlanByID(id string) (PaymentPlan, bool) {
+ id = strings.TrimSpace(id)
+ normalized := normalizePaymentPlanID(id)
+ for _, plan := range app.paymentPlans() {
+ if plan.ID == normalized || strings.EqualFold(plan.Name, id) {
+ app.warnIfPaymentPlanProfileAdmin(plan)
+ return plan, true
+ }
+ }
+ return PaymentPlan{}, false
+}
+
+func (app *appContext) savePaymentPlans(plans []PaymentPlan) error {
+ plans, err := normalizePaymentPlans(plans, app.config.Section("stripe").Key("price_currency").MustString("usd"))
+ if err != nil {
+ return err
+ }
+ for _, plan := range plans {
+ app.warnIfPaymentPlanProfileAdmin(plan)
+ }
+ encoded, err := json.Marshal(plans)
+ if err != nil {
+ return err
+ }
+
+ tempConfig, err := ini.ShadowLoad(app.configPath)
+ if err != nil {
+ return err
+ }
+ tempConfig.Section("stripe").Key(stripePaymentPlansSetting).SetValue(string(encoded))
+ if len(plans) > 0 {
+ tempConfig.Section("stripe").Key("price_currency").SetValue(plans[0].Currency)
+ if monthly, ok := planByLegacyName(plans, paymentPlanMonthly); ok {
+ tempConfig.Section("stripe").Key("price_monthly").SetValue(strconv.FormatInt(monthly.Price, 10))
+ }
+ }
+ if err = tempConfig.SaveTo(app.configPath); err != nil {
+ return err
+ }
+ app.ReloadConfig()
+ app.PatchConfigBase()
+ return nil
+}
+
+func (app *appContext) warnIfPaymentPlanProfileAdmin(plan PaymentPlan) {
+ if app == nil || app.storage == nil || app.err == nil || plan.Profile == "" {
+ return
+ }
+ profile, ok := app.storage.GetProfileKey(plan.Profile)
+ if !ok || !profile.Policy.IsAdministrator {
+ return
+ }
+ app.err.Printf("Stripe payment plan %q uses profile %q with Jellyfin administrator privileges; paid users created with this plan will become admins.", plan.Name, plan.Profile)
+}
+
+func planByLegacyName(plans []PaymentPlan, name string) (PaymentPlan, bool) {
+ for _, plan := range plans {
+ if strings.EqualFold(plan.Name, name) || strings.EqualFold(plan.ID, name) {
+ return plan, true
+ }
+ }
+ return PaymentPlan{}, false
+}
+
+func (p PaymentPlan) metadata() map[string]string {
+ return map[string]string{
+ stripeMetadataPlanID: p.ID,
+ stripeMetadataPlan: p.Name,
+ stripeMetadataProfile: p.Profile,
+ stripeMetadataAccessMonths: strconv.Itoa(p.AccessMonths),
+ stripeMetadataAccessDays: strconv.Itoa(p.AccessDays),
+ stripeMetadataRecurring: strconv.FormatBool(p.Recurring),
+ stripeMetadataInterval: p.StripeInterval,
+ stripeMetadataIntervalCount: strconv.FormatInt(p.StripeIntervalCount, 10),
+ }
+}
+
+func paymentPlanSnapshotFromMetadata(metadata map[string]string) paymentPlanSnapshot {
+ if metadata == nil {
+ return paymentPlanSnapshot{}
+ }
+ months, _ := strconv.Atoi(metadata[stripeMetadataAccessMonths])
+ days, _ := strconv.Atoi(metadata[stripeMetadataAccessDays])
+ intervalCount, _ := strconv.ParseInt(metadata[stripeMetadataIntervalCount], 10, 64)
+ recurring, _ := strconv.ParseBool(metadata[stripeMetadataRecurring])
+ return paymentPlanSnapshot{
+ ID: metadata[stripeMetadataPlanID],
+ Name: normalizePaymentPlanNameFromMetadata(metadata[stripeMetadataPlan]),
+ Profile: metadata[stripeMetadataProfile],
+ AccessMonths: months,
+ AccessDays: days,
+ Recurring: recurring,
+ StripeInterval: metadata[stripeMetadataInterval],
+ StripeIntervalCount: intervalCount,
+ }
+}
+
+func normalizePaymentPlanNameFromMetadata(plan string) string {
+ plan = strings.TrimSpace(plan)
+ if plan == "" {
+ return ""
+ }
+ return normalizePaymentPlan(plan)
+}
+
+type paymentPlanSnapshot struct {
+ ID string
+ Name string
+ Profile string
+ AccessMonths int
+ AccessDays int
+ Recurring bool
+ StripeInterval string
+ StripeIntervalCount int64
+}
+
+func (s paymentPlanSnapshot) apply(payment *Payment) {
+ if s.ID != "" && payment.PlanID == "" {
+ payment.PlanID = s.ID
+ }
+ if s.Name != "" && payment.Plan == "" {
+ payment.Plan = s.Name
+ }
+ if s.Profile != "" && payment.Profile == "" {
+ payment.Profile = s.Profile
+ }
+ if s.AccessMonths > 0 && payment.AccessMonths == 0 {
+ payment.AccessMonths = s.AccessMonths
+ }
+ if s.AccessDays > 0 && payment.AccessDays == 0 {
+ payment.AccessDays = s.AccessDays
+ }
+ if s.Recurring && !payment.Recurring {
+ payment.Recurring = true
+ }
+ if s.StripeInterval != "" && payment.StripeInterval == "" {
+ payment.StripeInterval = s.StripeInterval
+ }
+ if s.StripeIntervalCount > 0 && payment.StripeIntervalCount == 0 {
+ payment.StripeIntervalCount = s.StripeIntervalCount
+ }
+}
+
+func paymentPlanSnapshotFromPayment(payment Payment) paymentPlanSnapshot {
+ return paymentPlanSnapshot{
+ ID: payment.PlanID,
+ Name: payment.Plan,
+ Profile: payment.Profile,
+ AccessMonths: payment.AccessMonths,
+ AccessDays: payment.AccessDays,
+ Recurring: payment.Recurring,
+ StripeInterval: payment.StripeInterval,
+ StripeIntervalCount: payment.StripeIntervalCount,
+ }
+}
+
+func (app *appContext) paymentPlanSnapshotForSubscription(subscriptionID string, metadata map[string]string) paymentPlanSnapshot {
+ snapshot := paymentPlanSnapshotFromMetadata(metadata)
+ if snapshot.Name != "" || snapshot.AccessMonths > 0 || snapshot.AccessDays > 0 {
+ return snapshot
+ }
+ for _, payment := range app.storage.GetPayments() {
+ if payment.SubscriptionID == subscriptionID {
+ snapshot = paymentPlanSnapshotFromPayment(payment)
+ if snapshot.Name != "" || snapshot.AccessMonths > 0 || snapshot.AccessDays > 0 {
+ return snapshot
+ }
+ }
+ }
+ return paymentPlanSnapshot{Name: paymentPlanMonthly, AccessMonths: 1, Recurring: true, StripeInterval: "month", StripeIntervalCount: 1}
+}
+
+func paymentPlanExpiry(plan string, accessMonths, accessDays int, base time.Time) (time.Time, bool) {
+ if accessMonths > 0 || accessDays > 0 {
+ return base.AddDate(0, accessMonths, accessDays), true
+ }
+ switch normalizePaymentPlan(plan) {
+ case paymentPlanMonthly:
+ return base.AddDate(0, 1, 0), true
+ case paymentPlanStandard:
+ return base.AddDate(10, 0, 0), false
+ default:
+ return base.AddDate(0, 1, 0), true
+ }
+}
+
+func storePlanViews(plans []PaymentPlan) []storePlanView {
+ views := make([]storePlanView, 0, len(plans))
+ for _, plan := range plans {
+ views = append(views, storePlanView{
+ ID: plan.ID,
+ Name: plan.Name,
+ Description: plan.Description,
+ Price: fmt.Sprintf("%.2f", float64(plan.Price)/100.0),
+ Currency: strings.ToUpper(plan.Currency),
+ Billing: planBillingLabel(plan),
+ Access: planAccessLabel(plan),
+ })
+ }
+ return views
+}
+
+func planBillingLabel(plan PaymentPlan) string {
+ if !plan.Recurring {
+ return "One-time payment"
+ }
+ count := plan.StripeIntervalCount
+ if count <= 1 {
+ return "Billed every " + plan.StripeInterval
+ }
+ return fmt.Sprintf("Billed every %d %ss", count, plan.StripeInterval)
+}
+
+func planAccessLabel(plan PaymentPlan) string {
+ parts := []string{}
+ if plan.AccessMonths > 0 {
+ unit := "months"
+ if plan.AccessMonths == 1 {
+ unit = "month"
+ }
+ parts = append(parts, fmt.Sprintf("%d %s", plan.AccessMonths, unit))
+ }
+ if plan.AccessDays > 0 {
+ unit := "days"
+ if plan.AccessDays == 1 {
+ unit = "day"
+ }
+ parts = append(parts, fmt.Sprintf("%d %s", plan.AccessDays, unit))
+ }
+ if len(parts) == 0 {
+ return "Access duration not set"
+ }
+ return strings.Join(parts, " + ") + " access"
+}
+
+func (app *appContext) GetPaymentPlans(gc *gin.Context) {
+ gc.JSON(200, paymentPlansDTO{Plans: app.paymentPlans()})
+}
+
+func (app *appContext) SetPaymentPlans(gc *gin.Context) {
+ var req paymentPlansDTO
+ if err := gc.ShouldBindJSON(&req); err != nil {
+ respond(400, "Invalid request: "+err.Error(), gc)
+ return
+ }
+ if err := app.savePaymentPlans(req.Plans); err != nil {
+ respond(400, err.Error(), gc)
+ return
+ }
+ gc.JSON(200, paymentPlansDTO{Plans: app.paymentPlans()})
+}
diff --git a/payments.go b/payments.go
new file mode 100644
index 00000000..e516f246
--- /dev/null
+++ b/payments.go
@@ -0,0 +1,951 @@
+package main
+
+import (
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ lm "github.com/hrfee/jfa-go/logmessages"
+)
+
+const (
+ paymentDefaultProfile = "Default"
+ paymentPlanMonthly = "Monthly"
+ paymentPlanStandard = "Standard"
+
+ paymentStatusCheckoutCreated = "checkout_created"
+ paymentStatusCheckoutExpired = "checkout_expired"
+ paymentStatusPaid = "paid"
+ paymentStatusFulfilled = "fulfilled"
+ paymentStatusEmailSent = "email_sent"
+ paymentStatusEmailFailed = "email_failed"
+ paymentStatusPaymentCanceled = "payment_canceled"
+ paymentStatusRefunded = "refunded"
+ paymentStatusPartiallyRefunded = "partially_refunded"
+ paymentStatusSubscriptionCanceling = "subscription_canceling"
+ paymentStatusSubscriptionCanceled = "subscription_canceled"
+ paymentStatusSubscriptionPastDue = "subscription_past_due"
+ paymentStatusSubscriptionLapsed = "subscription_lapsed"
+ paymentStatusFailed = "failed"
+ paymentStatusNeedsReview = "needs_review"
+
+ paymentEmailNotStarted = "not_started"
+ paymentEmailNotApplicable = "not_applicable"
+ paymentEmailPending = "pending"
+ paymentEmailSent = "sent"
+ paymentEmailFailed = "failed"
+ paymentEmailDisabled = "disabled"
+
+ paymentInviteExpiredNeedsReview = "Paid invite expired before redemption"
+)
+
+type paymentFulfillment struct {
+ Provider string
+ TransactionID string
+ SubscriptionID string
+ TargetEmail string
+ PlanID string
+ Plan string
+ Profile string
+ AccessMonths int
+ AccessDays int
+ Recurring bool
+ StripeInterval string
+ StripeIntervalCount int64
+}
+
+type paymentFulfillmentResult struct {
+ Duplicate bool
+ Invite Invite
+ InviteCode string
+ JellyfinID string
+ Expiry time.Time
+ ShouldSendInvite bool
+}
+
+func paymentUnix(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.Unix()
+}
+
+func paymentStatusPriority(status string) int {
+ switch status {
+ case paymentStatusRefunded:
+ return 110
+ case paymentStatusSubscriptionCanceled:
+ return 100
+ case paymentStatusSubscriptionLapsed:
+ return 95
+ case paymentStatusPartiallyRefunded:
+ return 90
+ case paymentStatusSubscriptionPastDue:
+ return 80
+ case paymentStatusSubscriptionCanceling:
+ return 70
+ case paymentStatusPaymentCanceled, paymentStatusCheckoutExpired:
+ return 60
+ case paymentStatusNeedsReview:
+ return 50
+ case paymentStatusEmailFailed, paymentStatusFailed:
+ return 40
+ case paymentStatusEmailSent, paymentStatusFulfilled:
+ return 30
+ case paymentStatusPaid:
+ return 20
+ case paymentStatusCheckoutCreated:
+ return 10
+ default:
+ return 0
+ }
+}
+
+func paymentStatusIsLifecycle(status string) bool {
+ switch status {
+ case paymentStatusRefunded,
+ paymentStatusPartiallyRefunded,
+ paymentStatusSubscriptionCanceling,
+ paymentStatusSubscriptionCanceled,
+ paymentStatusSubscriptionPastDue,
+ paymentStatusSubscriptionLapsed,
+ paymentStatusPaymentCanceled,
+ paymentStatusCheckoutExpired:
+ return true
+ default:
+ return false
+ }
+}
+
+func paymentStatusIsRefund(status string) bool {
+ return status == paymentStatusRefunded || status == paymentStatusPartiallyRefunded
+}
+
+func setPaymentLifecycleStatus(payment *Payment, status, detail string) {
+ if status == "" {
+ return
+ }
+ if payment.Status == "" || paymentStatusPriority(status) >= paymentStatusPriority(payment.Status) {
+ payment.Status = status
+ payment.Error = detail
+ }
+}
+
+func clearPaymentLifecycleStatus(payment *Payment) {
+ if paymentStatusIsRefund(payment.Status) {
+ return
+ }
+ if !paymentStatusIsLifecycle(payment.Status) {
+ return
+ }
+ payment.Error = ""
+ if !payment.FulfilledAt.IsZero() {
+ if payment.EmailStatus == paymentEmailSent {
+ payment.Status = paymentStatusEmailSent
+ return
+ }
+ if payment.EmailStatus == paymentEmailFailed {
+ payment.Status = paymentStatusEmailFailed
+ return
+ }
+ payment.Status = paymentStatusFulfilled
+ return
+ }
+ if !payment.PaidAt.IsZero() {
+ payment.Status = paymentStatusPaid
+ return
+ }
+ payment.Status = paymentStatusCheckoutCreated
+}
+
+func paymentToDTO(payment Payment) paymentDTO {
+ return paymentDTO{
+ ID: payment.ID,
+ Provider: payment.Provider,
+ InstanceID: payment.InstanceID,
+ ProviderPaymentID: payment.ProviderPaymentID,
+ ProviderLiveMode: payment.ProviderLiveMode,
+ CustomerID: payment.CustomerID,
+ PaymentIntentID: payment.PaymentIntentID,
+ ChargeID: payment.ChargeID,
+ InvoiceID: payment.InvoiceID,
+ SubscriptionID: payment.SubscriptionID,
+ TargetEmail: payment.TargetEmail,
+ PlanID: payment.PlanID,
+ Plan: payment.Plan,
+ Profile: payment.Profile,
+ AccessMonths: payment.AccessMonths,
+ AccessDays: payment.AccessDays,
+ Recurring: payment.Recurring,
+ StripeInterval: payment.StripeInterval,
+ StripeIntervalCount: payment.StripeIntervalCount,
+ Amount: payment.Amount,
+ RefundedAmount: payment.RefundedAmount,
+ Currency: payment.Currency,
+ Status: payment.Status,
+ EmailStatus: payment.EmailStatus,
+ InvoiceStatus: payment.InvoiceStatus,
+ SubscriptionStatus: payment.SubscriptionStatus,
+ SubscriptionCancelAt: payment.SubscriptionCancelAt,
+ SubscriptionCancelAtPeriodEnd: payment.SubscriptionCancelAtPeriodEnd,
+ SubscriptionCanceledAt: payment.SubscriptionCanceledAt,
+ SubscriptionEndedAt: payment.SubscriptionEndedAt,
+ SubscriptionCancelNotifiedAt: paymentUnix(payment.SubscriptionCancelNotifiedAt),
+ InviteCode: payment.InviteCode,
+ JellyfinID: payment.JellyfinID,
+ Error: payment.Error,
+ Created: paymentUnix(payment.Created),
+ Updated: paymentUnix(payment.Updated),
+ PaidAt: paymentUnix(payment.PaidAt),
+ FulfilledAt: paymentUnix(payment.FulfilledAt),
+ EmailSentAt: paymentUnix(payment.EmailSentAt),
+ LastReconciledAt: paymentUnix(payment.LastReconciledAt),
+ }
+}
+
+func (app *appContext) GetPayments(gc *gin.Context) {
+ payments := app.storage.GetPayments()
+ sort.Slice(payments, func(i, j int) bool {
+ return payments[i].Created.After(payments[j].Created)
+ })
+
+ dto := getPaymentsDTO{Payments: make([]paymentDTO, len(payments))}
+ for i, payment := range payments {
+ dto.Payments[i] = paymentToDTO(payment)
+ }
+ gc.JSON(200, dto)
+}
+
+func (app *appContext) ResendPaymentInvite(gc *gin.Context) {
+ paymentID := gc.Param("id")
+ payment, ok := app.storage.GetPaymentKey(paymentID)
+ if !ok {
+ respond(404, "Payment not found", gc)
+ return
+ }
+ if payment.InviteCode == "" {
+ respond(400, "Payment has no invite to resend", gc)
+ return
+ }
+ if payment.TargetEmail == "" {
+ respond(400, "Payment has no target email", gc)
+ return
+ }
+ if !emailEnabled {
+ respond(400, "Email is disabled", gc)
+ return
+ }
+
+ invite, ok := app.storage.GetInvitesKey(payment.InviteCode)
+ if !ok {
+ respond(404, "Invite not found", gc)
+ return
+ }
+ if !paymentCanRecoverInvite(payment) {
+ respond(400, "Payment is not eligible for invite resend", gc)
+ return
+ }
+
+ invite = app.refreshPurchasedInviteForResend(payment, invite)
+ app.sendPurchasedInvite(invite, payment.TargetEmail, paymentID, payment.Plan)
+ gc.JSON(200, stringResponse{Response: "Invite queued"})
+}
+
+func (app *appContext) setPayment(id string, mutate func(*Payment)) {
+ if id == "" {
+ return
+ }
+
+ payment, ok := app.storage.GetPaymentKey(id)
+ now := time.Now()
+ if !ok {
+ payment = Payment{
+ ID: id,
+ Created: now,
+ }
+ }
+ if payment.Created.IsZero() {
+ payment.Created = now
+ }
+
+ mutate(&payment)
+
+ payment.ID = id
+ payment.Updated = now
+ app.storage.SetPaymentKey(id, payment)
+}
+
+func (app *appContext) setPaymentsByStripeIDs(paymentIntentID, chargeID, invoiceID, subscriptionID string, mutate func(*Payment)) bool {
+ seen := map[string]bool{}
+ found := false
+ for _, payment := range app.storage.GetPayments() {
+ if payment.Provider != lm.Stripe {
+ continue
+ }
+ if seen[payment.ID] || !paymentMatchesStripeIDs(payment, paymentIntentID, chargeID, invoiceID, subscriptionID) {
+ continue
+ }
+ seen[payment.ID] = true
+ found = true
+ app.setPayment(payment.ID, mutate)
+ }
+ return found
+}
+
+func paymentMatchesStripeIDs(payment Payment, paymentIntentID, chargeID, invoiceID, subscriptionID string) bool {
+ return stripeIDMatches(paymentIntentID, payment.PaymentIntentID, payment.ProviderPaymentID, payment.ID) ||
+ stripeIDMatches(chargeID, payment.ChargeID, payment.ProviderPaymentID, payment.ID) ||
+ stripeIDMatches(invoiceID, payment.InvoiceID, payment.ProviderPaymentID, payment.ID) ||
+ stripeIDMatches(subscriptionID, payment.SubscriptionID, payment.ProviderPaymentID, payment.ID)
+}
+
+func (app *appContext) stripeSubscriptionForUser(userID string) (Payment, bool) {
+ if userID == "" {
+ return Payment{}, false
+ }
+
+ email := ""
+ if emailStore, ok := app.storage.GetEmailsKey(userID); ok {
+ email = emailStore.Addr
+ }
+
+ instanceID := app.paymentInstanceID()
+ bestScore := int64(-1)
+ best := Payment{}
+ for _, payment := range app.storage.GetPayments() {
+ if payment.Provider != lm.Stripe || payment.SubscriptionID == "" {
+ continue
+ }
+ if payment.InstanceID != "" && payment.InstanceID != instanceID {
+ continue
+ }
+ if payment.JellyfinID != userID && (email == "" || !strings.EqualFold(payment.TargetEmail, email)) {
+ continue
+ }
+
+ score := stripeSubscriptionPaymentScore(payment)
+ if score > bestScore {
+ bestScore = score
+ best = payment
+ }
+ }
+ return best, bestScore >= 0
+}
+
+func stripeSubscriptionPaymentScore(payment Payment) int64 {
+ statusScore := int64(0)
+ switch payment.SubscriptionStatus {
+ case "active", "trialing":
+ statusScore = 500
+ case "past_due", "incomplete":
+ statusScore = 400
+ case "unpaid", "paused", "incomplete_expired":
+ statusScore = 200
+ case "canceled":
+ statusScore = 100
+ }
+
+ switch payment.Status {
+ case paymentStatusSubscriptionCanceling:
+ statusScore += 60
+ case paymentStatusPaid, paymentStatusFulfilled, paymentStatusEmailSent:
+ statusScore += 40
+ case paymentStatusSubscriptionPastDue:
+ statusScore += 30
+ case paymentStatusSubscriptionCanceled, paymentStatusSubscriptionLapsed, paymentStatusRefunded:
+ statusScore -= 50
+ }
+ if payment.SubscriptionCancelAtPeriodEnd {
+ statusScore += 20
+ }
+
+ created := payment.Created.Unix()
+ if created < 0 {
+ created = 0
+ }
+ return statusScore*1_000_000_000_000 + created
+}
+
+func (app *appContext) shouldSkipExpiredPaidUser(userID string, expiry UserExpiry) bool {
+ return app.shouldSkipExpiredPaidUserWithReconcile(userID, expiry, app.reconcileStripePayments)
+}
+
+func (app *appContext) shouldSkipExpiredPaidUserWithReconcile(userID string, expiry UserExpiry, reconcile func() ReconcilePaymentsDTO) bool {
+ if !stripeEnabled {
+ return false
+ }
+
+ payment, ok := app.stripeSubscriptionForUser(userID)
+ if !ok || !stripeSubscriptionGrantsUserAccess(payment, expiry, time.Now()) {
+ return false
+ }
+
+ if reconcile != nil {
+ result := reconcile()
+ if result.Error != "" && app.err != nil {
+ app.err.Printf("Stripe reconciliation failed while checking paid expiry for %s: %s", userID, result.Error)
+ }
+ }
+
+ payment, ok = app.stripeSubscriptionForUser(userID)
+ return ok && stripeSubscriptionGrantsUserAccess(payment, expiry, time.Now())
+}
+
+func stripeSubscriptionGrantsUserAccess(payment Payment, expiry UserExpiry, now time.Time) bool {
+ if payment.Provider != lm.Stripe || payment.SubscriptionID == "" {
+ return false
+ }
+
+ switch payment.Status {
+ case paymentStatusRefunded,
+ paymentStatusSubscriptionCanceled,
+ paymentStatusSubscriptionLapsed,
+ paymentStatusPaymentCanceled,
+ paymentStatusCheckoutExpired:
+ return false
+ }
+
+ switch payment.SubscriptionStatus {
+ case "active", "trialing":
+ if payment.SubscriptionCancelAt > 0 || payment.SubscriptionCancelAtPeriodEnd || payment.Status == paymentStatusSubscriptionCanceling {
+ return stripeSubscriptionAccessUntil(payment, expiry).After(now)
+ }
+ return true
+ default:
+ if payment.Status == paymentStatusSubscriptionCanceling {
+ return stripeSubscriptionAccessUntil(payment, expiry).After(now)
+ }
+ return false
+ }
+}
+
+func stripeSubscriptionAccessUntil(payment Payment, expiry UserExpiry) time.Time {
+ if payment.SubscriptionCancelAt > 0 {
+ return time.Unix(payment.SubscriptionCancelAt, 0)
+ }
+ if !expiry.Expiry.IsZero() {
+ return expiry.Expiry
+ }
+ return time.Time{}
+}
+
+func (app *appContext) mySubscriptionDTO(userID string) *MySubscriptionDTO {
+ payment, ok := app.stripeSubscriptionForUser(userID)
+ if !ok {
+ return nil
+ }
+
+ dto := &MySubscriptionDTO{
+ Provider: payment.Provider,
+ SubscriptionID: payment.SubscriptionID,
+ Status: payment.SubscriptionStatus,
+ PaymentStatus: payment.Status,
+ CancelAtPeriodEnd: payment.SubscriptionCancelAtPeriodEnd,
+ CancelAt: payment.SubscriptionCancelAt,
+ CanceledAt: payment.SubscriptionCanceledAt,
+ EndedAt: payment.SubscriptionEndedAt,
+ Amount: payment.Amount,
+ Currency: payment.Currency,
+ }
+ if dto.Status == "" {
+ dto.Status = payment.Status
+ }
+ if expiry, ok := app.storage.GetUserExpiryKey(userID); ok {
+ dto.PaidThrough = paymentUnix(expiry.Expiry)
+ if dto.CancelAt == 0 && dto.CancelAtPeriodEnd {
+ dto.CancelAt = dto.PaidThrough
+ }
+ }
+ return dto
+}
+
+func stripeIDMatches(id string, candidates ...string) bool {
+ if id == "" {
+ return false
+ }
+ for _, candidate := range candidates {
+ if candidate == id {
+ return true
+ }
+ }
+ return false
+}
+
+func (app *appContext) markPaymentFulfilled(id string, result paymentFulfillmentResult) {
+ app.setPayment(id, func(payment *Payment) {
+ if result.InviteCode != "" {
+ payment.InviteCode = result.InviteCode
+ }
+ if result.JellyfinID != "" {
+ payment.JellyfinID = result.JellyfinID
+ }
+ if payment.FulfilledAt.IsZero() {
+ payment.FulfilledAt = time.Now()
+ }
+ if payment.EmailStatus == "" {
+ payment.EmailStatus = paymentEmailNotStarted
+ }
+ if payment.Status != paymentStatusEmailSent && payment.Status != paymentStatusEmailFailed && !paymentStatusIsLifecycle(payment.Status) {
+ payment.Status = paymentStatusFulfilled
+ }
+ })
+}
+
+func (app *appContext) markPaymentEmail(id, emailStatus, errText string) {
+ app.setPayment(id, func(payment *Payment) {
+ payment.EmailStatus = emailStatus
+ if !paymentStatusIsLifecycle(payment.Status) {
+ payment.Error = errText
+ }
+
+ switch emailStatus {
+ case paymentEmailSent:
+ if !paymentStatusIsLifecycle(payment.Status) {
+ payment.Status = paymentStatusEmailSent
+ }
+ payment.EmailSentAt = time.Now()
+ case paymentEmailFailed:
+ if !paymentStatusIsLifecycle(payment.Status) {
+ payment.Status = paymentStatusEmailFailed
+ }
+ case paymentEmailPending, paymentEmailDisabled:
+ if !paymentStatusIsLifecycle(payment.Status) && (payment.Status == "" || payment.Status == paymentStatusPaid || payment.Status == paymentStatusCheckoutCreated) {
+ payment.Status = paymentStatusFulfilled
+ }
+ }
+ })
+}
+
+func shouldSendStorePaymentConfirmation(payment Payment) bool {
+ if payment.JellyfinID == "" || payment.TargetEmail == "" || payment.InviteCode != "" {
+ return false
+ }
+ if paymentStatusIsLifecycle(payment.Status) {
+ return false
+ }
+ switch payment.EmailStatus {
+ case paymentEmailSent, paymentEmailPending, paymentEmailDisabled, paymentEmailNotApplicable:
+ return false
+ default:
+ return true
+ }
+}
+
+func (app *appContext) repairStaleStorePaymentAsInvite(payment Payment) bool {
+ if payment.JellyfinID == "" || payment.TargetEmail == "" || payment.InviteCode != "" || paymentStatusIsLifecycle(payment.Status) {
+ return false
+ }
+ if app.jf.MediaBrowser == nil {
+ return false
+ }
+ if _, err := app.jf.UserByID(payment.JellyfinID, false); err == nil {
+ return false
+ }
+
+ app.info.Printf("Repairing Stripe payment %s for %q: stored Jellyfin user %q no longer exists", payment.ID, payment.TargetEmail, payment.JellyfinID)
+ app.storage.DeleteEmailsKey(payment.JellyfinID)
+ app.storage.DeleteUserExpiryKey(payment.JellyfinID)
+
+ invite := app.createPurchasedInvite(payment.Provider, payment.TargetEmail, payment.Plan, payment.Profile, payment.ID, payment.AccessMonths, payment.AccessDays)
+ app.setPayment(payment.ID, func(payment *Payment) {
+ payment.JellyfinID = ""
+ payment.InviteCode = invite.Code
+ payment.Error = ""
+ })
+ if emailEnabled {
+ app.sendPurchasedInvite(invite, payment.TargetEmail, payment.ID, payment.Plan)
+ } else {
+ app.markPaymentEmail(payment.ID, paymentEmailDisabled, "")
+ }
+ return true
+}
+
+func (app *appContext) markPaymentError(id, errText string) {
+ app.setPayment(id, func(payment *Payment) {
+ setPaymentLifecycleStatus(payment, paymentStatusFailed, errText)
+ })
+}
+
+func (app *appContext) markPaymentSubscriptionCanceled(subscriptionID, targetEmail string) {
+ if subscriptionID == "" {
+ return
+ }
+
+ found := false
+ for _, payment := range app.storage.GetPayments() {
+ if payment.SubscriptionID != subscriptionID {
+ continue
+ }
+ found = true
+ app.setPayment(payment.ID, func(payment *Payment) {
+ setPaymentLifecycleStatus(payment, paymentStatusSubscriptionCanceled, "")
+ })
+ }
+ if found {
+ return
+ }
+
+ app.setPayment("subscription-"+subscriptionID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = app.paymentInstanceID()
+ payment.ProviderPaymentID = subscriptionID
+ payment.SubscriptionID = subscriptionID
+ payment.TargetEmail = targetEmail
+ payment.Plan = paymentPlanMonthly
+ setPaymentLifecycleStatus(payment, paymentStatusSubscriptionCanceled, "")
+ payment.EmailStatus = paymentEmailNotApplicable
+ })
+}
+
+// findUserByEmail looks up a Jellyfin user ID and their stored EmailAddress by email.
+// Returns ("", EmailAddress{}, false) if no match is found.
+func (app *appContext) findUserByEmail(addr string) (string, EmailAddress, bool) {
+ for _, em := range app.storage.GetEmails() {
+ if strings.EqualFold(em.Addr, addr) {
+ return em.JellyfinID, em, true
+ }
+ }
+ return "", EmailAddress{}, false
+}
+
+func normalizePaymentPlan(plan string) string {
+ plan = strings.TrimSpace(plan)
+ if strings.EqualFold(plan, paymentPlanMonthly) {
+ return paymentPlanMonthly
+ }
+ if strings.EqualFold(plan, paymentPlanStandard) || plan == "" {
+ return paymentPlanStandard
+ }
+ return plan
+}
+
+func paidPlanExpiry(plan string, accessMonths, accessDays int, base time.Time) (time.Time, bool) {
+ return paymentPlanExpiry(plan, accessMonths, accessDays, base)
+}
+
+func (app *appContext) userHasActivePaidExpiry(addr string) (string, bool) {
+ userID, _, found := app.findUserByEmail(addr)
+ if !found {
+ return "", false
+ }
+
+ user, err := app.jf.UserByID(userID, false)
+ if err != nil || user.Policy.IsDisabled {
+ return userID, false
+ }
+
+ expiry, ok := app.storage.GetUserExpiryKey(userID)
+ return userID, ok && expiry.Expiry.After(time.Now())
+}
+
+func (app *appContext) fulfillStorePayment(f paymentFulfillment) paymentFulfillmentResult {
+ f.Plan = normalizePaymentPlan(f.Plan)
+ if f.Profile == "" {
+ f.Profile = paymentDefaultProfile
+ }
+
+ existingUserID, existingEmail, found := app.findUserByEmail(f.TargetEmail)
+ if found {
+ if app.jf.MediaBrowser != nil {
+ if _, err := app.jf.UserByID(existingUserID, false); err != nil {
+ app.info.Printf("Ignoring stale email mapping for %q to missing Jellyfin user %q: %v", f.TargetEmail, existingUserID, err)
+ app.storage.DeleteEmailsKey(existingUserID)
+ app.storage.DeleteUserExpiryKey(existingUserID)
+ found = false
+ }
+ }
+ }
+ if found {
+ app.info.Printf(lm.ExistingUserFound, f.TargetEmail, existingUserID)
+ if f.SubscriptionID != "" {
+ existingEmail.Label = f.Provider + " Subscription: " + f.SubscriptionID
+ } else {
+ existingEmail.Label = f.Provider + " Payment: " + f.TransactionID
+ }
+ app.storage.SetEmailsKey(existingUserID, existingEmail)
+
+ newExpiry, changed := app.extendPaidUserExpiry(existingUserID, f.TransactionID, f.Plan, f.AccessMonths, f.AccessDays)
+ if !changed {
+ app.info.Printf(lm.PaymentTransactionAlreadyProcessed, f.Provider, f.TransactionID, existingUserID)
+ return paymentFulfillmentResult{Duplicate: true, JellyfinID: existingUserID, Expiry: newExpiry}
+ }
+ app.reEnablePaidUser(existingUserID)
+ app.info.Printf(lm.UserReactivated, existingUserID, newExpiry)
+ return paymentFulfillmentResult{JellyfinID: existingUserID, Expiry: newExpiry}
+ }
+
+ if f.TransactionID != "" {
+ if invite, ok := app.purchasedInviteByTransaction(f.TransactionID); ok {
+ app.info.Printf(lm.PaymentTransactionAlreadyProcessed, f.Provider, f.TransactionID, "pending invite")
+ return paymentFulfillmentResult{Duplicate: true, Invite: invite, InviteCode: invite.Code}
+ }
+ }
+
+ invite := app.createPurchasedInvite(f.Provider, f.TargetEmail, f.Plan, f.Profile, f.TransactionID, f.AccessMonths, f.AccessDays)
+ return paymentFulfillmentResult{
+ Invite: invite,
+ InviteCode: invite.Code,
+ ShouldSendInvite: emailEnabled,
+ }
+}
+
+func (app *appContext) purchasedInviteByTransaction(transactionID string) (Invite, bool) {
+ for _, invite := range app.storage.GetInvites() {
+ if invite.PaymentID == transactionID {
+ return invite, true
+ }
+ }
+ return Invite{}, false
+}
+
+func (app *appContext) extendPaidUserExpiry(userID, transactionID, plan string, accessMonths, accessDays int) (time.Time, bool) {
+ userExpiry, ok := app.storage.GetUserExpiryKey(userID)
+ if !ok {
+ userExpiry = UserExpiry{Expiry: time.Now()}
+ }
+ if transactionID != "" && userExpiry.LastTransactionID == transactionID {
+ return userExpiry.Expiry, false
+ }
+
+ base := userExpiry.Expiry
+ if now := time.Now(); base.Before(now) {
+ base = now
+ }
+ userExpiry.Expiry, _ = paidPlanExpiry(plan, accessMonths, accessDays, base)
+ userExpiry.LastTransactionID = transactionID
+ app.storage.SetUserExpiryKey(userID, userExpiry)
+ return userExpiry.Expiry, true
+}
+
+func (app *appContext) reEnablePaidUser(userID string) {
+ paramsUser, err := app.jf.UserByID(userID, false)
+ if err != nil {
+ return
+ }
+ if err, _, _ = app.SetUserDisabled(paramsUser, false); err != nil {
+ app.err.Printf(lm.FailedReEnableUser, userID, err)
+ return
+ }
+ app.InvalidateUserCaches()
+}
+
+func (app *appContext) expirePaidUserNow(userID string) {
+ userExpiry, _ := app.storage.GetUserExpiryKey(userID)
+ userExpiry.Expiry = time.Now().Add(-1 * time.Second)
+ app.storage.SetUserExpiryKey(userID, userExpiry)
+}
+
+func (app *appContext) createPurchasedInvite(provider, targetEmail, plan, profile, transactionID string, accessMonths, accessDays int) Invite {
+ if _, ok := app.storage.GetProfileKey(profile); !ok {
+ app.debug.Printf(lm.FailedGetProfile, profile)
+ if _, ok := app.storage.GetProfileKey(paymentDefaultProfile); ok {
+ profile = paymentDefaultProfile
+ } else {
+ profile = ""
+ }
+ }
+
+ inviteCode := GenerateInviteCode()
+ invite := Invite{
+ Code: inviteCode,
+ Created: time.Now(),
+ Label: provider + " " + plan + " by " + targetEmail,
+ UserLabel: "Purchased via " + provider,
+ RemainingUses: 1,
+ Profile: profile,
+ SendTo: targetEmail,
+ PaymentID: transactionID,
+ }
+
+ var userExpiry bool
+ invite.ValidTill, userExpiry = paidPlanExpiry(plan, accessMonths, accessDays, time.Now())
+ invite.UserExpiry = userExpiry
+ if userExpiry {
+ invite.UserMonths = accessMonths
+ invite.UserDays = accessDays
+ if invite.UserMonths == 0 && invite.UserDays == 0 && normalizePaymentPlan(plan) == paymentPlanMonthly {
+ invite.UserMonths = 1
+ }
+ }
+
+ app.storage.SetInvitesKey(inviteCode, invite)
+ app.info.Printf(lm.GeneratedInviteForPurchase, inviteCode, targetEmail)
+ return invite
+}
+
+func (app *appContext) preserveExpiredPaidInvite(invite Invite) bool {
+ payment, ok := app.paymentForInvite(invite)
+ if !ok && invite.PaymentStatus != paymentStatusPaid {
+ return false
+ }
+ if ok && !paymentCanRecoverInvite(payment) {
+ return false
+ }
+
+ paymentID := invite.PaymentID
+ if ok {
+ paymentID = payment.ID
+ app.setPayment(payment.ID, func(payment *Payment) {
+ if payment.InviteCode == "" {
+ payment.InviteCode = invite.Code
+ }
+ if !paymentStatusIsLifecycle(payment.Status) {
+ payment.Status = paymentStatusNeedsReview
+ payment.Error = paymentInviteExpiredNeedsReview
+ } else if payment.Error == "" {
+ payment.Error = paymentInviteExpiredNeedsReview
+ }
+ })
+ }
+
+ app.info.Printf("Preserving expired paid invite %s for payment %s", invite.Code, paymentID)
+ return true
+}
+
+func (app *appContext) paymentForInvite(invite Invite) (Payment, bool) {
+ if invite.PaymentID != "" {
+ if payment, ok := app.storage.GetPaymentKey(invite.PaymentID); ok {
+ return payment, true
+ }
+ }
+ for _, payment := range app.storage.GetPayments() {
+ if payment.InviteCode == invite.Code {
+ return payment, true
+ }
+ }
+ return Payment{}, false
+}
+
+func paymentCanRecoverInvite(payment Payment) bool {
+ switch payment.Status {
+ case paymentStatusRefunded,
+ paymentStatusSubscriptionCanceled,
+ paymentStatusSubscriptionLapsed,
+ paymentStatusPaymentCanceled,
+ paymentStatusCheckoutExpired:
+ return false
+ }
+ return payment.Status == paymentStatusPaid ||
+ payment.Status == paymentStatusFulfilled ||
+ payment.Status == paymentStatusEmailSent ||
+ payment.Status == paymentStatusEmailFailed ||
+ payment.Status == paymentStatusNeedsReview ||
+ payment.Status == paymentStatusSubscriptionCanceling ||
+ payment.Status == paymentStatusSubscriptionPastDue ||
+ !payment.PaidAt.IsZero()
+}
+
+func (app *appContext) refreshPurchasedInviteForResend(payment Payment, invite Invite) Invite {
+ if !time.Now().After(invite.ValidTill) {
+ return invite
+ }
+
+ var userExpiry bool
+ invite.ValidTill, userExpiry = paidPlanExpiry(payment.Plan, payment.AccessMonths, payment.AccessDays, time.Now())
+ if userExpiry {
+ invite.UserExpiry = true
+ if payment.AccessMonths > 0 || payment.AccessDays > 0 {
+ invite.UserMonths = payment.AccessMonths
+ invite.UserDays = payment.AccessDays
+ } else if invite.UserMonths == 0 && invite.UserDays == 0 && normalizePaymentPlan(payment.Plan) == paymentPlanMonthly {
+ invite.UserMonths = 1
+ }
+ }
+
+ app.storage.SetInvitesKey(invite.Code, invite)
+ return invite
+}
+
+func (app *appContext) sendPurchasedInvite(invite Invite, targetEmail, paymentID, plan string) {
+ if paymentID != "" {
+ app.markPaymentEmail(paymentID, paymentEmailPending, "")
+ }
+
+ go func() {
+ msg, err := app.email.constructPurchasedInvite(&invite, plan, false)
+ if err != nil {
+ app.err.Printf(lm.FailedConstructInviteMessage, targetEmail, err)
+ if paymentID != "" {
+ app.markPaymentEmail(paymentID, paymentEmailFailed, err.Error())
+ }
+ return
+ }
+ if err = app.email.send(msg, targetEmail); err != nil {
+ app.err.Printf(lm.FailedSendInviteMessage, invite.Code, targetEmail, err)
+ if paymentID != "" {
+ app.markPaymentEmail(paymentID, paymentEmailFailed, err.Error())
+ }
+ } else {
+ app.info.Printf(lm.SentInviteMessage, invite.Code, targetEmail)
+ if paymentID != "" {
+ app.markPaymentEmail(paymentID, paymentEmailSent, "")
+ }
+ }
+ }()
+}
+
+func (app *appContext) sendStorePaymentConfirmation(userID, targetEmail, paymentID, provider, plan string, expiry time.Time, recurring bool) {
+ if paymentID == "" || targetEmail == "" {
+ return
+ }
+ if !emailEnabled {
+ app.markPaymentEmail(paymentID, paymentEmailDisabled, "")
+ return
+ }
+ if app.email == nil || app.email.sender == nil {
+ app.markPaymentEmail(paymentID, paymentEmailFailed, "email sender is not configured")
+ return
+ }
+
+ app.markPaymentEmail(paymentID, paymentEmailPending, "")
+ go func() {
+ username := targetEmail
+ if userID != "" && app.jf.MediaBrowser != nil {
+ if user, err := app.jf.UserByID(userID, false); err == nil && user.Name != "" {
+ username = user.Name
+ }
+ }
+
+ msg := app.constructStorePaymentConfirmationMessage(username, provider, plan, expiry, recurring)
+ if err := app.email.send(msg, targetEmail); err != nil {
+ app.err.Printf("Failed to send %s payment confirmation email for %s to %s: %v", provider, paymentID, targetEmail, err)
+ app.markPaymentEmail(paymentID, paymentEmailFailed, err.Error())
+ return
+ }
+ app.info.Printf("Sent %s payment confirmation email for %s to %s", provider, paymentID, targetEmail)
+ app.markPaymentEmail(paymentID, paymentEmailSent, "")
+ }()
+}
+
+func (app *appContext) constructStorePaymentConfirmationMessage(username, provider, plan string, expiry time.Time, recurring bool) *Message {
+ serverName := serverHeader(app.config, nil)
+ if plan == "" {
+ plan = "access"
+ }
+ if provider == "" {
+ provider = "payment"
+ }
+
+ lines := []string{
+ "Hi " + username + ",",
+ "",
+ "Your " + serverName + " subscription is active.",
+ "Plan: " + plan,
+ }
+ if !expiry.IsZero() {
+ lines = append(lines, "Paid through: "+formatDatetime(expiry))
+ }
+ if recurring {
+ lines = append(lines, "Future renewals will be handled by "+provider+".")
+ }
+ if accountURL := strings.TrimRight(ExternalURI(nil), "/") + PAGES.MyAccount; accountURL != "" && PAGES.MyAccount != "" && PAGES.MyAccount != "disabled" {
+ lines = append(lines, "", "Manage your account: "+accountURL)
+ }
+ lines = append(lines, "", "If you did not make this purchase, contact the administrator.")
+
+ return &Message{
+ Subject: "Subscription active - " + serverName,
+ Text: strings.Join(lines, "\n"),
+ }
+}
diff --git a/storage.go b/storage.go
index c0f2c984..006f46f9 100644
--- a/storage.go
+++ b/storage.go
@@ -66,6 +66,52 @@ type UserExpiry struct {
Expiry time.Time
DeleteAfterPeriod bool // Whether or not to further disable the user later on
LastNotified time.Time // Last time an expiry notification/reminder was sent to the user.
+ LastTransactionID string // ID of the last processed payment transaction to prevent duplicate credits.
+}
+
+type Payment struct {
+ ID string `badgerhold:"key"`
+ Provider string `badgerhold:"index"`
+ InstanceID string `badgerhold:"index"`
+ ProviderPaymentID string
+ ProviderLiveMode bool
+ CustomerID string
+ PaymentIntentID string
+ ChargeID string
+ InvoiceID string
+ SubscriptionID string
+ TargetEmail string
+ PlanID string
+ Plan string
+ Profile string
+ AccessMonths int
+ AccessDays int
+ Recurring bool
+ StripeInterval string
+ StripeIntervalCount int64
+ Amount int64
+ RefundedAmount int64
+ Currency string
+ Status string `badgerhold:"index"`
+ EmailStatus string
+ InvoiceStatus string
+ SubscriptionStatus string
+ SubscriptionCancelAt int64
+ SubscriptionCancelAtPeriodEnd bool
+ SubscriptionCanceledAt int64
+ SubscriptionEndedAt int64
+ SubscriptionCancelNotifiedAt time.Time
+ InviteCode string
+ InviteLockHash string
+ InviteLockCreatedAt time.Time
+ JellyfinID string
+ Error string
+ Created time.Time
+ Updated time.Time
+ PaidAt time.Time
+ FulfilledAt time.Time
+ EmailSentAt time.Time
+ LastReconciledAt time.Time
}
type DebugLogAction int
@@ -153,6 +199,7 @@ const (
StoredExpiries
StoredProfiles
StoredCustomContent
+ StoredPayments
)
// DebugWatch logs database writes according on the advanced debugging settings in the Advanced section
@@ -180,6 +227,8 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
actionKey = "profiles"
case StoredCustomContent:
actionKey = "custom_content"
+ case StoredPayments:
+ actionKey = "payments"
}
logAction := st.logActions(actionKey)
@@ -197,7 +246,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
func generateLogActions(c *Config) func(k string) DebugLogAction {
m := map[string]DebugLogAction{}
- for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} {
+ for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content", "payments"} {
switch c.Section("advanced").Key("debug_log_" + v).MustString("none") {
case "none":
m[v] = NoLog
@@ -475,6 +524,44 @@ func (st *Storage) DeleteUserExpiryKey(k string) {
st.db.Delete(k, UserExpiry{})
}
+// GetPayments returns a copy of the store.
+func (st *Storage) GetPayments() []Payment {
+ result := []Payment{}
+ err := st.db.Find(&result, &badgerhold.Query{})
+ if err != nil {
+ // fmt.Printf("Failed to find payments: %v\n", err)
+ }
+ return result
+}
+
+// GetPaymentKey returns the value stored in the store's key.
+func (st *Storage) GetPaymentKey(k string) (Payment, bool) {
+ result := Payment{}
+ err := st.db.Get(k, &result)
+ ok := true
+ if err != nil {
+ // fmt.Printf("Failed to find payment: %v\n", err)
+ ok = false
+ }
+ return result, ok
+}
+
+// SetPaymentKey stores value v in key k.
+func (st *Storage) SetPaymentKey(k string, v Payment) {
+ st.DebugWatch(StoredPayments, k, v.Status)
+ v.ID = k
+ err := st.db.Upsert(k, v)
+ if err != nil {
+ // fmt.Printf("Failed to set payment: %v\n", err)
+ }
+}
+
+// DeletePaymentKey deletes value at key k.
+func (st *Storage) DeletePaymentKey(k string) {
+ st.DebugWatch(StoredPayments, k, "")
+ st.db.Delete(k, Payment{})
+}
+
// GetProfiles returns a copy of the store.
func (st *Storage) GetProfiles() []Profile {
result := []Profile{}
@@ -810,6 +897,11 @@ type Invite struct {
IsReferral bool `json:"is_referral" badgerhold:"index"`
ReferrerJellyfinID string `json:"referrer_id"`
UseReferralExpiry bool `json:"use_referral_expiry"`
+ RequiredPayment bool `json:"required_payment"`
+ PriceAmount int64 `json:"price_amount"`
+ PriceCurrency string `json:"price_currency"`
+ PaymentID string `json:"payment_id"`
+ PaymentStatus string `json:"payment_status"`
}
func (invite Invite) Source() (ActivitySource, string) {
@@ -1378,6 +1470,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled)
patchLang(&lang.UserExpiryAdjusted, &fallback.UserExpiryAdjusted, &english.UserExpiryAdjusted)
patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail)
+ patchLang(&lang.PurchasedInvite, &fallback.PurchasedInvite, &english.PurchasedInvite)
patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired)
@@ -1394,6 +1487,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
patchLang(&lang.UserEnabled, &english.UserEnabled)
patchLang(&lang.UserExpiryAdjusted, &english.UserExpiryAdjusted)
patchLang(&lang.InviteEmail, &english.InviteEmail)
+ patchLang(&lang.PurchasedInvite, &english.PurchasedInvite)
patchLang(&lang.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &english.UserExpired)
diff --git a/stripe.go b/stripe.go
new file mode 100644
index 00000000..643a7424
--- /dev/null
+++ b/stripe.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/stripe/stripe-go/v86"
+ "github.com/stripe/stripe-go/v86/checkout/session"
+ stripeEvent "github.com/stripe/stripe-go/v86/event"
+ "github.com/stripe/stripe-go/v86/webhook"
+)
+
+const supportedStripeWebhookAPIVersion = "2026-06-24.dahlia"
+
+func InitStripe(apiKey string) {
+ stripe.Key = apiKey
+}
+
+func CreateCheckoutSession(inviteCode string, amount int64, currency, productName, successURL, cancelURL string, metadata map[string]string, interval string, intervalCount int64) (*stripe.CheckoutSession, error) {
+ if productName == "" {
+ productName = "Invite Code: " + inviteCode
+ }
+ params := &stripe.CheckoutSessionParams{
+ PaymentMethodTypes: stripe.StringSlice([]string{
+ "card",
+ }),
+ LineItems: []*stripe.CheckoutSessionLineItemParams{
+ {
+ PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
+ Currency: stripe.String(currency),
+ ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
+ Name: stripe.String(productName),
+ },
+ UnitAmount: stripe.Int64(amount),
+ },
+ Quantity: stripe.Int64(1),
+ },
+ },
+ SuccessURL: stripe.String(successURL),
+ CancelURL: stripe.String(cancelURL),
+ ClientReferenceID: stripe.String(inviteCode),
+ }
+
+ if interval != "" {
+ params.Mode = stripe.String(string(stripe.CheckoutSessionModeSubscription))
+ params.LineItems[0].PriceData.Recurring = &stripe.CheckoutSessionLineItemPriceDataRecurringParams{
+ Interval: stripe.String(interval),
+ }
+ if intervalCount > 1 {
+ params.LineItems[0].PriceData.Recurring.IntervalCount = stripe.Int64(intervalCount)
+ }
+ } else {
+ params.Mode = stripe.String(string(stripe.CheckoutSessionModePayment))
+ }
+
+ if metadata != nil {
+ params.Metadata = metadata
+ if interval != "" {
+ params.SubscriptionData = &stripe.CheckoutSessionSubscriptionDataParams{
+ Metadata: metadata,
+ }
+ }
+ }
+
+ s, err := session.New(params)
+ if err != nil {
+ return nil, err
+ }
+
+ return s, nil
+}
+
+func HandleWebhook(payload []byte, signature string, secret string, verifySignature bool) (*stripe.Event, error) {
+ var event stripe.Event
+ var err error
+
+ if verifySignature {
+ event, err = webhook.ConstructEvent(payload, signature, secret)
+ if err != nil {
+ return nil, fmt.Errorf("bad_signature: %w", err)
+ }
+ } else {
+ // Bypass Signature: Use explicit API Call-Back to verify event authenticity.
+ var untrustedEvent stripe.Event
+ if err := json.Unmarshal(payload, &untrustedEvent); err != nil {
+ return nil, fmt.Errorf("webhook_json_parse_error: %w", err)
+ }
+
+ eventPtr, err := stripeEvent.Get(untrustedEvent.ID, nil)
+ if err != nil {
+ return nil, fmt.Errorf("api_verification_failed: %w", err)
+ }
+ event = *eventPtr
+ }
+
+ if event.APIVersion != supportedStripeWebhookAPIVersion {
+ return nil, fmt.Errorf("Stripe webhook API version %q does not match supported version %q", event.APIVersion, supportedStripeWebhookAPIVersion)
+ }
+
+ return &event, nil
+}
From 1f07de80ef865a9bbf594db9ebfb8bb5413ea54e Mon Sep 17 00:00:00 2001
From: wander <127889726+heywander@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:23:10 -0400
Subject: [PATCH 4/8] payments: add paid signup API flow
---
api-invites.go | 19 ++
api-payment-subscriptions.go | 185 +++++++++++
api-stripe.go | 629 +++++++++++++++++++++++++++++++++++
api-user-subscription.go | 61 ++++
api-userpage.go | 4 +
api-users.go | 50 ++-
main.go | 12 +-
router.go | 27 ++
users.go | 2 +-
views.go | 42 ++-
10 files changed, 1005 insertions(+), 26 deletions(-)
create mode 100644 api-payment-subscriptions.go
create mode 100644 api-stripe.go
create mode 100644 api-user-subscription.go
diff --git a/api-invites.go b/api-invites.go
index 0544b038..bdb3db55 100644
--- a/api-invites.go
+++ b/api-invites.go
@@ -88,6 +88,13 @@ 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)})
+
+ // Multi-use paid invites require a separate payment for each use.
+ if newInv.PaymentStatus == "paid" {
+ newInv.PaymentID = ""
+ newInv.PaymentStatus = ""
+ }
+
if !del {
app.storage.SetInvitesKey(code, newInv)
}
@@ -96,6 +103,10 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
func (app *appContext) deleteExpiredInvite(data Invite) {
+ if app.preserveExpiredPaidInvite(data) {
+ return
+ }
+
app.debug.Printf(lm.DeleteOldInvite, data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
@@ -358,6 +369,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
diff --git a/api-payment-subscriptions.go b/api-payment-subscriptions.go
new file mode 100644
index 00000000..fe860d7b
--- /dev/null
+++ b/api-payment-subscriptions.go
@@ -0,0 +1,185 @@
+package main
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ lm "github.com/hrfee/jfa-go/logmessages"
+ "github.com/stripe/stripe-go/v86"
+ chargeapi "github.com/stripe/stripe-go/v86/charge"
+ refundapi "github.com/stripe/stripe-go/v86/refund"
+ subscriptionapi "github.com/stripe/stripe-go/v86/subscription"
+)
+
+const (
+ paymentSubscriptionCancelNow = "now"
+ paymentSubscriptionCancelPeriodEnd = "period_end"
+ paymentSubscriptionCancelCustom = "custom"
+)
+
+func (app *appContext) CancelPaymentSubscription(gc *gin.Context) {
+ if !stripeEnabled {
+ respond(400, "Stripe disabled", gc)
+ return
+ }
+
+ paymentID := gc.Param("id")
+ payment, ok := app.storage.GetPaymentKey(paymentID)
+ if !ok {
+ respond(404, "Payment not found", gc)
+ return
+ }
+ if payment.Provider != lm.Stripe || payment.SubscriptionID == "" {
+ respond(400, "Payment has no Stripe subscription", gc)
+ return
+ }
+ if payment.SubscriptionStatus == string(stripe.SubscriptionStatusCanceled) || payment.Status == paymentStatusSubscriptionCanceled {
+ respond(400, "Subscription is already canceled", gc)
+ return
+ }
+
+ var req cancelPaymentSubscriptionDTO
+ if err := gc.ShouldBindJSON(&req); err != nil {
+ respond(400, "Invalid request: "+err.Error(), gc)
+ return
+ }
+ if req.When == "" {
+ req.When = paymentSubscriptionCancelPeriodEnd
+ }
+
+ sub, err := app.cancelStripeSubscription(payment.SubscriptionID, req)
+ if err != nil {
+ app.err.Printf("Failed to cancel Stripe subscription %s from payment %s: %v", payment.SubscriptionID, payment.ID, err)
+ respond(500, "Failed to cancel subscription", gc)
+ return
+ }
+
+ app.applyStripeSubscriptionEvent(sub)
+ app.notifyStripeSubscriptionCancellation(sub, "admin")
+ if req.When == paymentSubscriptionCancelNow && sub.Status == stripe.SubscriptionStatusCanceled {
+ app.expireStripeSubscriptionUser(sub)
+ }
+
+ refundID := ""
+ if req.Refund {
+ refund, err := app.refundPaymentLatestPayment(payment)
+ if err != nil {
+ app.err.Printf("Failed to refund Stripe payment %s while canceling subscription %s: %v", payment.ID, payment.SubscriptionID, err)
+ respond(500, "Subscription canceled, but refund failed: "+err.Error(), gc)
+ return
+ }
+ if refund != nil {
+ refundID = refund.ID
+ }
+ }
+
+ updated, _ := app.storage.GetPaymentKey(payment.ID)
+ gc.JSON(200, cancelPaymentSubscriptionResponseDTO{
+ Payment: paymentToDTO(updated),
+ SubscriptionID: sub.ID,
+ RefundID: refundID,
+ })
+}
+
+func (app *appContext) cancelStripeSubscription(subscriptionID string, req cancelPaymentSubscriptionDTO) (*stripe.Subscription, error) {
+ switch req.When {
+ case paymentSubscriptionCancelNow:
+ return subscriptionapi.Cancel(subscriptionID, &stripe.SubscriptionCancelParams{
+ InvoiceNow: stripe.Bool(false),
+ Prorate: stripe.Bool(false),
+ })
+ case paymentSubscriptionCancelPeriodEnd:
+ return subscriptionapi.Update(subscriptionID, &stripe.SubscriptionParams{
+ CancelAtPeriodEnd: stripe.Bool(true),
+ })
+ case paymentSubscriptionCancelCustom:
+ if req.CancelAt <= time.Now().Unix() {
+ return nil, fmt.Errorf("custom cancellation date must be in the future")
+ }
+ return subscriptionapi.Update(subscriptionID, &stripe.SubscriptionParams{
+ CancelAt: stripe.Int64(req.CancelAt),
+ CancelAtPeriodEnd: stripe.Bool(false),
+ ProrationBehavior: stripe.String("none"),
+ CancellationDetails: &stripe.SubscriptionCancellationDetailsParams{},
+ })
+ default:
+ return nil, fmt.Errorf("invalid cancellation timing %q", req.When)
+ }
+}
+
+func (app *appContext) refundPaymentLatestPayment(payment Payment) (*stripe.Refund, error) {
+ payment = app.latestRefundablePayment(payment)
+ remaining := payment.Amount - payment.RefundedAmount
+ if remaining <= 0 {
+ return nil, fmt.Errorf("payment is already fully refunded")
+ }
+
+ params := &stripe.RefundParams{
+ Amount: stripe.Int64(remaining),
+ Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
+ }
+ switch {
+ case payment.PaymentIntentID != "":
+ params.PaymentIntent = stripe.String(payment.PaymentIntentID)
+ case payment.ChargeID != "":
+ params.Charge = stripe.String(payment.ChargeID)
+ default:
+ return nil, fmt.Errorf("payment has no refundable Stripe charge or payment intent")
+ }
+
+ refund, err := refundapi.New(params)
+ if err != nil {
+ return nil, err
+ }
+
+ chargeID := payment.ChargeID
+ if refund.Charge != nil && refund.Charge.ID != "" {
+ chargeID = refund.Charge.ID
+ }
+ if chargeID != "" {
+ if charge, err := chargeapi.Get(chargeID, nil); err == nil {
+ app.applyStripeChargeEvent(charge)
+ return refund, nil
+ }
+ }
+
+ app.setPayment(payment.ID, func(stored *Payment) {
+ applyStripeRefundToPayment(refund, stored)
+ stored.LastReconciledAt = time.Now()
+ })
+ return refund, nil
+}
+
+func (app *appContext) latestRefundablePayment(seed Payment) Payment {
+ best := seed
+ bestTime := paymentRefundSortTime(best)
+ for _, payment := range app.storage.GetPayments() {
+ if payment.Provider != lm.Stripe || payment.SubscriptionID == "" || payment.SubscriptionID != seed.SubscriptionID {
+ continue
+ }
+ if payment.Amount-payment.RefundedAmount <= 0 {
+ continue
+ }
+ if payment.PaymentIntentID == "" && payment.ChargeID == "" {
+ continue
+ }
+ if best.PaymentIntentID == "" && best.ChargeID == "" {
+ best = payment
+ bestTime = paymentRefundSortTime(payment)
+ continue
+ }
+ if t := paymentRefundSortTime(payment); t.After(bestTime) {
+ best = payment
+ bestTime = t
+ }
+ }
+ return best
+}
+
+func paymentRefundSortTime(payment Payment) time.Time {
+ if !payment.PaidAt.IsZero() {
+ return payment.PaidAt
+ }
+ return payment.Created
+}
diff --git a/api-stripe.go b/api-stripe.go
new file mode 100644
index 00000000..0f0e1915
--- /dev/null
+++ b/api-stripe.go
@@ -0,0 +1,629 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ lm "github.com/hrfee/jfa-go/logmessages"
+ "github.com/stripe/stripe-go/v86"
+ chargeapi "github.com/stripe/stripe-go/v86/charge"
+)
+
+// @Summary Create a checkout session for an existing invite (Pay-to-Unlock).
+// @Produce json
+// @Param code path string true "Invite Code"
+// @Success 200 {object} stringResponse
+// @Failure 400 {object} stringResponse
+// @Router /stripe/checkout/{code} [post]
+func (app *appContext) PostStripeCheckout(gc *gin.Context) {
+ if !stripeEnabled {
+ respond(400, "Stripe disabled", gc)
+ return
+ }
+ code := gc.Param("code")
+ inv, ok := app.storage.GetInvitesKey(code)
+ if !ok {
+ respond(400, "Invalid invite code", gc)
+ return
+ }
+
+ if !inv.RequiredPayment || inv.PriceAmount == 0 {
+ respond(200, "Payment not required", gc)
+ return
+ }
+
+ baseURL := ExternalURI(gc)
+ successURL := fmt.Sprintf("%s/invite/%s?success=payment", baseURL, code)
+ cancelURL := fmt.Sprintf("%s/invite/%s?canceled=payment", baseURL, code)
+ lockToken, lockHash, err := newPaymentLockToken()
+ if err != nil {
+ app.err.Printf("Failed to create payment lock: %v", err)
+ respond(500, "Failed to create payment lock", gc)
+ return
+ }
+
+ metadata := app.stripePaymentMetadata(map[string]string{
+ stripeMetadataFlow: stripeMetadataFlowInviteUnlock,
+ stripeMetadataInviteCode: code,
+ stripeMetadataEmail: inv.SendTo,
+ stripeMetadataPlan: "Invite",
+ stripeMetadataProfile: inv.Profile,
+ })
+
+ session, err := CreateCheckoutSession(code, inv.PriceAmount, inv.PriceCurrency, "Invite Code: "+code, successURL, cancelURL, metadata, "", 0)
+ if err != nil {
+ app.err.Printf(lm.FailedCreateCheckoutSession, err)
+ respond(500, "Failed to create checkout session", gc)
+ return
+ }
+
+ app.setPayment(session.ID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = metadata[stripeMetadataInstanceID]
+ payment.ProviderPaymentID = session.ID
+ payment.ProviderLiveMode = session.Livemode
+ if session.Customer != nil {
+ payment.CustomerID = session.Customer.ID
+ }
+ payment.TargetEmail = inv.SendTo
+ payment.Plan = "Invite"
+ payment.Profile = inv.Profile
+ payment.Amount = inv.PriceAmount
+ payment.Currency = inv.PriceCurrency
+ payment.Status = paymentStatusCheckoutCreated
+ payment.EmailStatus = paymentEmailNotApplicable
+ payment.InviteCode = code
+ payment.InviteLockHash = lockHash
+ setPaymentLockCreated(payment)
+ if session.Created > 0 {
+ payment.Created = time.Unix(session.Created, 0)
+ }
+ })
+
+ gc.SetCookie(paymentLockCookieName, lockToken, paymentLockMaxAge, "/", "", false, true)
+
+ gc.JSON(200, stringResponse{Response: session.URL})
+}
+
+type createCheckoutDTO struct {
+ Email string `json:"email" binding:"required,email"`
+ Plan string `json:"plan" binding:"required"`
+}
+
+// @Summary Create a checkout session for a new invite (Pay-to-Generate).
+// @Produce json
+// @Param body body createCheckoutDTO true "Checkout Request"
+// @Success 200 {object} stringResponse
+// @Router /stripe/create-checkout [post]
+func (app *appContext) PostStripeCreateCheckout(gc *gin.Context) {
+ if !stripeEnabled {
+ respond(400, "Stripe disabled", gc)
+ return
+ }
+
+ var req createCheckoutDTO
+ if err := gc.ShouldBindJSON(&req); err != nil {
+ respond(400, "Invalid request: "+err.Error(), gc)
+ return
+ }
+
+ plan, ok := app.paymentPlanByID(req.Plan)
+ if !ok || !plan.Enabled {
+ respond(400, "Invalid payment plan", gc)
+ return
+ }
+
+ // Double-billing prevention for subscription plans
+ if plan.Recurring {
+ if userID, active := app.userHasActivePaidExpiry(req.Email); active {
+ app.info.Printf(lm.StripeBlockedDuplicate, userID, req.Email)
+ respond(409, "You already have an active subscription.", gc)
+ return
+ }
+ }
+
+ refID := "purchase-" + strconv.FormatInt(time.Now().Unix(), 10)
+
+ baseURL := ExternalURI(gc)
+ successURL := fmt.Sprintf("%s/payment/success", baseURL)
+ cancelURL := fmt.Sprintf("%s/store?canceled=true", baseURL)
+
+ metadata := app.stripePaymentMetadata(plan.metadata())
+ metadata[stripeMetadataFlow] = stripeMetadataFlowStorePurchase
+ metadata[stripeMetadataEmail] = req.Email
+
+ session, err := CreateCheckoutSession(refID, plan.Price, plan.Currency, plan.Name, successURL, cancelURL, metadata, plan.StripeInterval, plan.StripeIntervalCount)
+ if err != nil {
+ app.err.Printf(lm.FailedCreateCheckoutSession, err)
+ respond(500, "Failed to create checkout session", gc)
+ return
+ }
+
+ app.setPayment(session.ID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = metadata[stripeMetadataInstanceID]
+ payment.ProviderPaymentID = session.ID
+ payment.ProviderLiveMode = session.Livemode
+ if session.Customer != nil {
+ payment.CustomerID = session.Customer.ID
+ }
+ payment.TargetEmail = req.Email
+ paymentPlanSnapshotFromMetadata(metadata).apply(payment)
+ payment.Amount = plan.Price
+ payment.Currency = plan.Currency
+ payment.Status = paymentStatusCheckoutCreated
+ payment.EmailStatus = paymentEmailNotStarted
+ if session.Created > 0 {
+ payment.Created = time.Unix(session.Created, 0)
+ }
+ })
+
+ gc.JSON(200, stringResponse{Response: session.URL})
+}
+
+// @Summary Handle Stripe Webhooks
+// @Router /stripe/webhook [post]
+func (app *appContext) StripeWebhook(gc *gin.Context) {
+ if !stripeEnabled {
+ 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
+ }
+
+ sigHeader := gc.GetHeader("Stripe-Signature")
+ webhookSecret := strings.TrimSpace(app.config.Section("stripe").Key("webhook_secret").String())
+ verifySignature := app.config.Section("stripe").Key("verify_signature").MustBool(true)
+
+ if !verifySignature {
+ app.debug.Println(lm.StripeSignatureBypass)
+ }
+
+ event, err := HandleWebhook(payload, sigHeader, webhookSecret, verifySignature)
+ if err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ gc.AbortWithStatus(400)
+ return
+ }
+ app.info.Printf("Stripe webhook received: %s (%s)", event.Type, event.ID)
+
+ switch event.Type {
+ case "checkout.session.completed":
+ app.handleStripeCheckoutCompleted(event)
+ case "checkout.session.expired":
+ app.handleStripeCheckoutExpired(event)
+ case "invoice.payment_succeeded":
+ app.handleStripeInvoiceSucceeded(event)
+ case "invoice.payment_failed", "invoice.marked_uncollectible", "invoice.voided":
+ app.handleStripeInvoicePaymentFailed(event)
+ case "payment_intent.canceled", "payment_intent.payment_failed", "payment_intent.succeeded":
+ app.handleStripePaymentIntentUpdated(event)
+ case "charge.refunded", "charge.updated", "charge.failed":
+ app.handleStripeChargeUpdated(event)
+ case "refund.created", "refund.updated":
+ app.handleStripeRefundUpdated(event)
+ case "customer.subscription.updated":
+ app.handleStripeSubscriptionUpdated(event)
+ case "customer.subscription.deleted":
+ app.handleStripeSubscriptionDeleted(event)
+ }
+
+ gc.Status(200)
+}
+
+func (app *appContext) handleStripeCheckoutCompleted(event *stripe.Event) {
+ var session stripe.CheckoutSession
+ if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ refID := session.ClientReferenceID
+ metadata := session.Metadata
+ if metadata == nil {
+ metadata = map[string]string{}
+ }
+ subscriptionID := ""
+ if session.Subscription != nil {
+ subscriptionID = session.Subscription.ID
+ }
+
+ app.setPayment(session.ID, func(payment *Payment) {
+ applyStripeSessionToPayment(firstNonEmpty(metadata[stripeMetadataInstanceID], app.paymentInstanceID()), &session, payment)
+ paymentPlanSnapshotFromMetadata(metadata).apply(payment)
+ if subscriptionID != "" {
+ payment.SubscriptionID = subscriptionID
+ }
+ payment.Status = paymentStatusPaid
+ payment.PaidAt = time.Now()
+ })
+
+ if stripeSessionIsInviteUnlock(metadata) {
+ if app.fulfillStripeInviteUnlock(session.ID, refID, metadata) {
+ return
+ }
+ app.setPayment(session.ID, func(payment *Payment) {
+ payment.Status = paymentStatusNeedsReview
+ payment.Error = "Stripe invite unlock is paid, but the local invite could not be found"
+ })
+ return
+ }
+
+ targetEmail, ok := metadata[stripeMetadataEmail]
+ if !ok {
+ // Legacy pay-to-unlock flow
+ app.fulfillStripeInviteUnlock(session.ID, refID, metadata)
+ return
+ }
+
+ snapshot := paymentPlanSnapshotFromMetadata(metadata)
+ plan := snapshot.Name
+ app.info.Printf(lm.StripePaymentReceived, plan, targetEmail)
+
+ app.setPayment(session.ID, func(payment *Payment) {
+ payment.TargetEmail = targetEmail
+ snapshot.apply(payment)
+ })
+
+ result := app.fulfillStorePayment(paymentFulfillment{
+ Provider: lm.Stripe,
+ TransactionID: session.ID,
+ SubscriptionID: subscriptionID,
+ TargetEmail: targetEmail,
+ PlanID: snapshot.ID,
+ Plan: plan,
+ Profile: snapshot.Profile,
+ AccessMonths: snapshot.AccessMonths,
+ AccessDays: snapshot.AccessDays,
+ Recurring: snapshot.Recurring,
+ StripeInterval: snapshot.StripeInterval,
+ StripeIntervalCount: snapshot.StripeIntervalCount,
+ })
+ app.markPaymentFulfilled(session.ID, result)
+ if result.JellyfinID != "" && !result.Duplicate {
+ app.sendStorePaymentConfirmation(result.JellyfinID, targetEmail, session.ID, lm.Stripe, plan, result.Expiry, snapshot.Recurring)
+ }
+ if result.ShouldSendInvite {
+ app.sendPurchasedInvite(result.Invite, targetEmail, session.ID, plan)
+ } else if result.InviteCode != "" {
+ app.markPaymentEmail(session.ID, paymentEmailDisabled, "")
+ }
+}
+
+func (app *appContext) handleStripeCheckoutExpired(event *stripe.Event) {
+ var session stripe.CheckoutSession
+ if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ app.setPayment(session.ID, func(payment *Payment) {
+ applyStripeSessionToPayment(firstNonEmpty(session.Metadata[stripeMetadataInstanceID], app.paymentInstanceID()), &session, payment)
+ setPaymentLifecycleStatus(payment, paymentStatusCheckoutExpired, "Stripe checkout session expired")
+ })
+}
+
+func (app *appContext) handleStripeInvoiceSucceeded(event *stripe.Event) {
+ var invoice stripe.Invoice
+ if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ if invoice.BillingReason != stripe.InvoiceBillingReasonSubscriptionCycle {
+ return
+ }
+
+ subscriptionID := ""
+ instanceID := app.paymentInstanceID()
+ metadata := map[string]string{}
+ if invoice.Parent != nil &&
+ invoice.Parent.SubscriptionDetails != nil &&
+ invoice.Parent.SubscriptionDetails.Subscription != nil {
+ subscriptionID = invoice.Parent.SubscriptionDetails.Subscription.ID
+ if invoice.Parent.SubscriptionDetails.Metadata != nil {
+ metadata = invoice.Parent.SubscriptionDetails.Metadata
+ if metadataInstanceID := invoice.Parent.SubscriptionDetails.Metadata[stripeMetadataInstanceID]; metadataInstanceID != "" {
+ instanceID = metadataInstanceID
+ }
+ }
+ }
+ planSnapshot := app.paymentPlanSnapshotForSubscription(subscriptionID, metadata)
+ app.setPayment(invoice.ID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = instanceID
+ payment.ProviderPaymentID = invoice.ID
+ applyStripeInvoiceToPayment(&invoice, payment)
+ planSnapshot.apply(payment)
+ payment.SubscriptionID = subscriptionID
+ payment.TargetEmail = invoice.CustomerEmail
+ if payment.Plan == "" {
+ payment.Plan = paymentPlanMonthly
+ }
+ payment.Status = paymentStatusPaid
+ payment.EmailStatus = paymentEmailNotApplicable
+ if invoice.Created > 0 {
+ created := time.Unix(invoice.Created, 0)
+ payment.Created = created
+ payment.PaidAt = created
+ } else {
+ payment.PaidAt = time.Now()
+ }
+ })
+
+ if invoice.CustomerEmail == "" {
+ app.markPaymentError(invoice.ID, fmt.Sprintf("invoice %s has no customer email", invoice.ID))
+ app.err.Printf(lm.FailedFindUserByEmail, fmt.Sprintf("invoice %s (no email)", invoice.ID))
+ return
+ }
+
+ email := invoice.CustomerEmail
+ app.info.Printf(lm.StripeRenewalReceived, email)
+
+ userID, _, found := app.findUserByEmail(email)
+ if !found {
+ app.markPaymentError(invoice.ID, fmt.Sprintf("could not find user with email %s", email))
+ app.err.Printf(lm.FailedFindUserByEmail, email)
+ return
+ }
+
+ newExpiry, changed := app.extendPaidUserExpiry(userID, invoice.ID, planSnapshot.Name, planSnapshot.AccessMonths, planSnapshot.AccessDays)
+ if !changed {
+ app.info.Printf(lm.PaymentTransactionAlreadyProcessed, lm.Stripe, invoice.ID, userID)
+ app.markPaymentFulfilled(invoice.ID, paymentFulfillmentResult{JellyfinID: userID})
+ return
+ }
+ app.reEnablePaidUser(userID)
+ app.markPaymentFulfilled(invoice.ID, paymentFulfillmentResult{JellyfinID: userID})
+ app.sendStorePaymentConfirmation(userID, email, invoice.ID, lm.Stripe, planSnapshot.Name, newExpiry, planSnapshot.Recurring)
+
+ app.info.Printf(lm.UserExpiryExtended, userID, email, newExpiry)
+}
+
+func (app *appContext) handleStripeInvoicePaymentFailed(event *stripe.Event) {
+ var invoice stripe.Invoice
+ if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ subscriptionID := stripeInvoiceSubscriptionID(&invoice)
+ instanceID := app.paymentInstanceID()
+ metadata := map[string]string{}
+ if invoice.Parent != nil && invoice.Parent.SubscriptionDetails != nil && invoice.Parent.SubscriptionDetails.Metadata != nil {
+ metadata = invoice.Parent.SubscriptionDetails.Metadata
+ if metadataInstanceID := invoice.Parent.SubscriptionDetails.Metadata[stripeMetadataInstanceID]; metadataInstanceID != "" {
+ instanceID = metadataInstanceID
+ }
+ }
+ planSnapshot := app.paymentPlanSnapshotForSubscription(subscriptionID, metadata)
+
+ app.setPayment(invoice.ID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = instanceID
+ payment.ProviderPaymentID = invoice.ID
+ payment.EmailStatus = paymentEmailNotApplicable
+ planSnapshot.apply(payment)
+ if payment.Plan == "" {
+ payment.Plan = paymentPlanMonthly
+ }
+ applyStripeInvoiceToPayment(&invoice, payment)
+ if subscriptionID != "" {
+ payment.SubscriptionID = subscriptionID
+ }
+ })
+ if subscriptionID != "" {
+ app.setPaymentsByStripeIDs("", "", "", subscriptionID, func(payment *Payment) {
+ applyStripeInvoiceToPayment(&invoice, payment)
+ })
+ }
+}
+
+func (app *appContext) handleStripePaymentIntentUpdated(event *stripe.Event) {
+ var paymentIntent stripe.PaymentIntent
+ if err := json.Unmarshal(event.Data.Raw, &paymentIntent); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ chargeID := stripePaymentIntentChargeID(&paymentIntent)
+ app.setPaymentsByStripeIDs(paymentIntent.ID, chargeID, "", "", func(payment *Payment) {
+ applyStripePaymentIntentToPayment(&paymentIntent, payment)
+ })
+}
+
+func (app *appContext) handleStripeChargeUpdated(event *stripe.Event) {
+ var charge stripe.Charge
+ if err := json.Unmarshal(event.Data.Raw, &charge); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ app.applyStripeChargeEvent(&charge)
+}
+
+func (app *appContext) handleStripeRefundUpdated(event *stripe.Event) {
+ var refund stripe.Refund
+ if err := json.Unmarshal(event.Data.Raw, &refund); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ chargeID := ""
+ if refund.Charge != nil {
+ chargeID = refund.Charge.ID
+ }
+ if chargeID != "" {
+ if charge, err := chargeapi.Get(chargeID, nil); err == nil {
+ app.applyStripeChargeEvent(charge)
+ return
+ }
+ }
+
+ paymentIntentID := ""
+ if refund.PaymentIntent != nil {
+ paymentIntentID = refund.PaymentIntent.ID
+ }
+ app.setPaymentsByStripeIDs(paymentIntentID, chargeID, "", "", func(payment *Payment) {
+ applyStripeRefundToPayment(&refund, payment)
+ })
+}
+
+func (app *appContext) applyStripeChargeEvent(charge *stripe.Charge) {
+ if charge == nil {
+ return
+ }
+ paymentIntentID := ""
+ if charge.PaymentIntent != nil {
+ paymentIntentID = charge.PaymentIntent.ID
+ }
+ app.setPaymentsByStripeIDs(paymentIntentID, charge.ID, "", "", func(payment *Payment) {
+ applyStripeChargeToPayment(charge, payment)
+ })
+}
+
+func applyStripeRefundToPayment(refund *stripe.Refund, payment *Payment) {
+ if refund == nil || refund.ID == "" {
+ return
+ }
+ if refund.PaymentIntent != nil {
+ payment.PaymentIntentID = refund.PaymentIntent.ID
+ }
+ if refund.Charge != nil {
+ payment.ChargeID = refund.Charge.ID
+ }
+ if refund.Currency != "" {
+ payment.Currency = string(refund.Currency)
+ }
+ if refund.Status != stripe.RefundStatusSucceeded && refund.Status != stripe.RefundStatusPending {
+ return
+ }
+ if refund.Amount > payment.RefundedAmount {
+ payment.RefundedAmount = refund.Amount
+ }
+ if payment.Amount > 0 && payment.RefundedAmount >= payment.Amount {
+ setPaymentLifecycleStatus(payment, paymentStatusRefunded, "")
+ return
+ }
+ setPaymentLifecycleStatus(payment, paymentStatusPartiallyRefunded, "")
+}
+
+func (app *appContext) handleStripeSubscriptionUpdated(event *stripe.Event) {
+ var sub stripe.Subscription
+ if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ app.applyStripeSubscriptionEvent(&sub)
+ if sub.CancelAtPeriodEnd || sub.Status == stripe.SubscriptionStatusCanceled {
+ app.notifyStripeSubscriptionCancellation(&sub, "stripe")
+ }
+ if sub.Status == stripe.SubscriptionStatusCanceled {
+ app.expireStripeSubscriptionUser(&sub)
+ }
+}
+
+func (app *appContext) handleStripeSubscriptionDeleted(event *stripe.Event) {
+ var sub stripe.Subscription
+ if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
+ app.err.Printf(lm.StripeWebhookError, err)
+ return
+ }
+
+ app.applyStripeSubscriptionEvent(&sub)
+ app.notifyStripeSubscriptionCancellation(&sub, "stripe")
+ targetEmail := stripeSubscriptionTargetEmail(&sub, app)
+ if targetEmail == "" {
+ app.debug.Printf(lm.StripeSubscriptionDeleted, sub.ID, "unknown (no metadata)")
+ return
+ }
+
+ app.info.Printf(lm.StripeSubscriptionDeleted, sub.ID, targetEmail)
+ app.expireStripeSubscriptionUser(&sub)
+}
+
+func (app *appContext) applyStripeSubscriptionEvent(sub *stripe.Subscription) {
+ if sub == nil || sub.ID == "" {
+ return
+ }
+ found := app.setPaymentsByStripeIDs("", "", "", sub.ID, func(payment *Payment) {
+ applyStripeSubscriptionToPayment(sub, payment)
+ })
+ if found || !stripeSubscriptionMatchesInstance(app.paymentInstanceID(), sub) {
+ return
+ }
+ targetEmail := stripeSubscriptionTargetEmail(sub, app)
+ if targetEmail == "" {
+ return
+ }
+ app.setPayment("subscription-"+sub.ID, func(payment *Payment) {
+ payment.Provider = lm.Stripe
+ payment.InstanceID = app.paymentInstanceID()
+ payment.ProviderPaymentID = sub.ID
+ payment.EmailStatus = paymentEmailNotApplicable
+ app.paymentPlanSnapshotForSubscription(sub.ID, sub.Metadata).apply(payment)
+ if payment.Plan == "" {
+ payment.Plan = paymentPlanMonthly
+ }
+ applyStripeSubscriptionToPayment(sub, payment)
+ })
+}
+
+func (app *appContext) expireStripeSubscriptionUser(sub *stripe.Subscription) {
+ targetEmail := stripeSubscriptionTargetEmail(sub, app)
+ if targetEmail == "" {
+ return
+ }
+ userID, _, found := app.findUserByEmail(targetEmail)
+ if !found {
+ app.err.Printf(lm.FailedFindUserByEmail, targetEmail)
+ return
+ }
+
+ app.expirePaidUserNow(userID)
+
+ if paramsUser, err := app.jf.UserByID(userID, false); err == nil {
+ if err, _, _ = app.SetUserDisabled(paramsUser, true); err != nil {
+ app.err.Printf(lm.FailedDisableUser, userID, err)
+ } else {
+ app.info.Printf(lm.UserDisabledDueToCancellation, userID)
+ }
+ app.InvalidateUserCaches()
+ }
+}
+
+func stripeSubscriptionTargetEmail(sub *stripe.Subscription, app *appContext) string {
+ if sub != nil && sub.Metadata != nil && sub.Metadata[stripeMetadataEmail] != "" {
+ return sub.Metadata[stripeMetadataEmail]
+ }
+ if sub != nil {
+ for _, payment := range app.storage.GetPayments() {
+ if payment.SubscriptionID == sub.ID && payment.TargetEmail != "" {
+ return payment.TargetEmail
+ }
+ }
+ }
+ return ""
+}
+
+func stripeSubscriptionMatchesInstance(instanceID string, sub *stripe.Subscription) bool {
+ if sub == nil || sub.Metadata == nil {
+ return false
+ }
+ return sub.Metadata[stripeMetadataSource] == stripeMetadataSourceJFA &&
+ sub.Metadata[stripeMetadataInstanceID] == instanceID
+}
diff --git a/api-user-subscription.go b/api-user-subscription.go
new file mode 100644
index 00000000..0ca03cae
--- /dev/null
+++ b/api-user-subscription.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/stripe/stripe-go/v86"
+ subscriptionapi "github.com/stripe/stripe-go/v86/subscription"
+)
+
+// @Summary Cancel the logged-in user's Stripe subscription at period end.
+// @Produce json
+// @Success 200 {object} MySubscriptionDTO
+// @Failure 400 {object} stringResponse
+// @Failure 404 {object} stringResponse
+// @Failure 500 {object} stringResponse
+// @Router /my/subscription/cancel [post]
+// @Security Bearer
+// @tags User Page
+func (app *appContext) CancelMySubscription(gc *gin.Context) {
+ if !stripeEnabled {
+ respond(400, "Stripe disabled", gc)
+ return
+ }
+
+ userID := gc.GetString("jfId")
+ payment, ok := app.stripeSubscriptionForUser(userID)
+ if !ok || payment.SubscriptionID == "" {
+ respond(404, "No Stripe subscription found", gc)
+ return
+ }
+ if payment.SubscriptionStatus == string(stripe.SubscriptionStatusCanceled) || payment.Status == paymentStatusSubscriptionCanceled {
+ respond(400, "Subscription is already canceled", gc)
+ return
+ }
+
+ sub, err := subscriptionapi.Update(payment.SubscriptionID, &stripe.SubscriptionParams{
+ CancelAtPeriodEnd: stripe.Bool(true),
+ })
+ if err != nil {
+ app.err.Printf("Failed to schedule Stripe subscription %s cancellation for user %s: %v", payment.SubscriptionID, userID, err)
+ respond(500, "Failed to cancel subscription", gc)
+ return
+ }
+
+ app.applyStripeSubscriptionEvent(sub)
+ app.notifyStripeSubscriptionCancellation(sub, "user")
+ app.info.Printf("Stripe subscription %s scheduled for cancellation by user %s", sub.ID, userID)
+
+ if dto := app.mySubscriptionDTO(userID); dto != nil {
+ gc.JSON(200, dto)
+ return
+ }
+ gc.JSON(200, MySubscriptionDTO{
+ Provider: payment.Provider,
+ SubscriptionID: sub.ID,
+ Status: string(sub.Status),
+ CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
+ CancelAt: sub.CancelAt,
+ CanceledAt: sub.CanceledAt,
+ EndedAt: sub.EndedAt,
+ })
+}
diff --git a/api-userpage.go b/api-userpage.go
index af14f687..87df0d5e 100644
--- a/api-userpage.go
+++ b/api-userpage.go
@@ -51,6 +51,10 @@ func (app *appContext) MyDetails(gc *gin.Context) {
resp.Expiry = exp.Expiry.Unix()
}
+ if stripeEnabled {
+ resp.Subscription = app.mySubscriptionDTO(user.ID)
+ }
+
if emailEnabled {
resp.Email = &MyDetailsContactMethodsDTO{}
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
diff --git a/api-users.go b/api-users.go
index 3a1592c0..4cff78de 100644
--- a/api-users.go
+++ b/api-users.go
@@ -17,6 +17,21 @@ import (
"github.com/timshannon/badgerhold/v4"
)
+func (app *appContext) profileForInvite(profileName string) *Profile {
+ if profileName == "" {
+ return nil
+ }
+ p, ok := app.storage.GetProfileKey(profileName)
+ if !ok {
+ app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, profileName)
+ p = app.storage.GetDefaultProfile()
+ }
+ if p.Name == "" {
+ return nil
+ }
+ return &p
+}
+
// @Summary Creates a new Jellyfin user without an invite.
// @Produce json
// @Param newUserDTO body newUserDTO true "New user request object"
@@ -37,10 +52,15 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
- profile := app.storage.GetDefaultProfile()
- if req.Profile != "" && req.Profile != "none" {
- if p, ok := app.storage.GetProfileKey(req.Profile); ok {
- profile = p
+ var profile *Profile
+ if req.Profile != "none" {
+ if req.Profile != "" {
+ profile = app.profileForInvite(req.Profile)
+ } else {
+ p := app.storage.GetDefaultProfile()
+ if p.Name != "" {
+ profile = &p
+ }
}
}
nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{
@@ -48,7 +68,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
ContextForIPLogging: gc,
- Profile: &profile,
+ Profile: profile,
})
if !nu.Success {
nu.Log()
@@ -64,7 +84,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
}
for _, tps := range app.thirdPartyServices {
- if !tps.Enabled(app, &profile) {
+ if !tps.Enabled(app, profile) {
continue
}
// We only have email
@@ -219,18 +239,18 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
}
invite, _ := app.storage.GetInvitesKey(req.Code)
+ if invite.RequiredPayment && invite.PaymentStatus != "paid" {
+ respond(402, "errorPaymentRequired", gc)
+ return
+ }
+ if invite.RequiredPayment && invite.PaymentStatus == "paid" && !app.paidInvitePaymentLockFromCookie(gc, invite) {
+ respond(403, "errorPaymentRequired", gc)
+ return
+ }
sourceType, source := invite.Source()
- var profile *Profile = nil
- if invite.Profile != "" {
- p, ok := app.storage.GetProfileKey(invite.Profile)
- if !ok {
- app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile)
- p = app.storage.GetDefaultProfile()
- }
- profile = &p
- }
+ profile := app.profileForInvite(invite.Profile)
nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{
Req: req,
diff --git a/main.go b/main.go
index 07444b0b..77879a77 100644
--- a/main.go
+++ b/main.go
@@ -461,7 +461,17 @@ func start(asDaemon, firstCall bool) {
RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
LogFailures: true,
}
- _, err = app.jf.MustAuthenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String(), retryOpts)
+
+ if stripeEnabled {
+ apiKey := app.config.Section("stripe").Key("api_key").String()
+ app.paymentInstanceID()
+ InitStripe(apiKey)
+ app.info.Println(lm.InitStripe)
+ }
+
+ u := app.config.Section("jellyfin").Key("username").String()
+ p := app.config.Section("jellyfin").Key("password").String()
+ _, err = app.jf.MustAuthenticate(u, p, retryOpts)
if err != nil {
app.err.Fatalf(lm.FailedAuthJellyfin, server, status, err)
}
diff --git a/router.go b/router.go
index a5fbdc28..facc508f 100644
--- a/router.go
+++ b/router.go
@@ -145,6 +145,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
router.GET(p+PAGES.Admin+"/settings", app.AdminPage)
router.GET(p+PAGES.Admin+"/activity", app.AdminPage)
+ if stripeEnabled {
+ router.GET(p+PAGES.Admin+"/payments", app.AdminPage)
+ }
router.GET(p+PAGES.Admin+"/accounts/user/:userID", app.AdminPage)
router.GET(p+PAGES.Admin+"/invites/:code", app.AdminPage)
router.GET(p+"/lang/:page/:file", app.ServeLang)
@@ -172,6 +175,16 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+PAGES.Form+"/:invCode/matrix/user", app.MatrixSendPIN)
router.POST(p+"/users/matrix", app.MatrixConnect)
}
+ if stripeEnabled {
+ router.GET(p+"/store", app.StorePage)
+ router.GET(p+"/payment/success", app.PaymentSuccessPage)
+ }
+ if stripeEnabled {
+ router.POST(p+"/stripe/checkout/:code", app.PostStripeCheckout)
+ router.POST(p+"/stripe/events", app.StripeWebhook)
+ router.POST(p+"/stripe/webhook", app.StripeWebhook)
+ router.POST(p+"/stripe/create-checkout", app.PostStripeCreateCheckout)
+ }
if userPageEnabled {
router.GET(p+PAGES.MyAccount, app.MyUserPage)
router.GET(p+PAGES.MyAccount+"/password/reset", app.MyUserPage)
@@ -249,9 +262,20 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
api.GET(p+"/logs", app.GetLog)
+ if stripeEnabled {
+ api.GET(p+"/payments/plans", app.GetPaymentPlans)
+ api.POST(p+"/payments/plans", app.SetPaymentPlans)
+ api.GET(p+"/payments/list", app.GetPayments)
+ api.POST(p+"/payments/reconcile/stripe", app.ReconcileStripePayments)
+ api.POST(p+"/payments/:id/resend", app.ResendPaymentInvite)
+ api.POST(p+"/payments/:id/subscription/cancel", app.CancelPaymentSubscription)
+ }
api.GET(p+"/tasks", app.TaskList)
api.POST(p+"/tasks/housekeeping", app.TaskHousekeeping)
api.POST(p+"/tasks/users", app.TaskUserCleanup)
+ if stripeEnabled {
+ api.POST(p+"/tasks/stripe", app.TaskStripeReconcile)
+ }
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
api.POST(p+"/tasks/jellyseerr", app.TaskJellyseerrImport)
}
@@ -312,6 +336,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
user.DELETE("/discord", app.UnlinkMyDiscord)
user.DELETE("/telegram", app.UnlinkMyTelegram)
user.DELETE("/matrix", app.UnlinkMyMatrix)
+ if stripeEnabled {
+ user.POST("/subscription/cancel", app.CancelMySubscription)
+ }
user.POST("/password", app.ChangeMyPassword)
if app.config.Section("user_page").Key("referrals").MustBool(false) {
user.GET("/referral", app.GetMyReferral)
diff --git a/users.go b/users.go
index eec7c93b..0ea567a4 100644
--- a/users.go
+++ b/users.go
@@ -112,7 +112,7 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData
Time: time.Now(),
}, p.ContextForIPLogging, (p.SourceType != ActivityAdmin))
- if p.Profile != nil {
+ if p.Profile != nil && p.Profile.Name != "" {
err = app.jf.SetPolicy(out.User.ID, p.Profile.Policy)
if err != nil {
app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, out.User.ID, err)
diff --git a/views.go b/views.go
index 3c0a94c2..6f26ddf3 100644
--- a/views.go
+++ b/views.go
@@ -246,6 +246,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"jfAllowAll": jfAllowAll,
"userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
"showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true),
+ "stripeEnabled": stripeEnabled,
"loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"),
})
}
@@ -682,15 +683,7 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan
sourceType, source := invite.Source()
- var profile *Profile = nil
- if invite.Profile != "" {
- p, ok := app.storage.GetProfileKey(invite.Profile)
- if !ok {
- app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile)
- p = app.storage.GetDefaultProfile()
- }
- profile = &p
- }
+ profile := app.profileForInvite(invite.Profile)
// FIXME: Email and contract method linking?????
@@ -754,6 +747,14 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
return
}
+ if invite.PaymentStatus == "paid" && !app.paidInvitePaymentLockFromCookie(gc, invite) {
+ app.info.Printf("Blocked access to paid invite %s due to missing/mismatching payment lock.", invite.Code)
+ app.gcHTML(gc, 403, "invalidCode.html", FormPage, lang, gin.H{
+ "contactMessage": "This invite has been paid for by another browser session. To prevent theft, paid invites are locked to the device that made the payment.",
+ })
+ return
+ }
+
if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
app.NewUserFromConfirmationKey(invite, key, lang, gc)
return
@@ -812,6 +813,11 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
"userPageAddress": userPageAddress,
"fromUser": fromUser,
+ "price": invite.PriceAmount,
+ "currency": strings.ToUpper(invite.PriceCurrency),
+ "requiredPayment": invite.RequiredPayment,
+ "paid": invite.PaymentStatus == "paid",
+ "stripeEnabled": stripeEnabled,
}
if telegram {
data["telegramPIN"] = app.telegram.NewAuthToken()
@@ -882,3 +888,21 @@ func (app *appContext) NoRouteHandler(gc *gin.Context) {
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
+
+// StorePage serves the public store page
+func (app *appContext) StorePage(gc *gin.Context) {
+ lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang)
+ app.gcHTML(gc, 200, "store.html", OtherPage, lang, gin.H{
+ "strings": app.storage.lang.User[lang].Strings,
+ "plans": storePlanViews(app.publicPaymentPlans()),
+ })
+}
+
+// PaymentSuccessPage serves the dedicated payment success page
+func (app *appContext) PaymentSuccessPage(gc *gin.Context) {
+ lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang)
+ app.gcHTML(gc, 200, "payment_success.html", OtherPage, lang, gin.H{
+ "strings": app.storage.lang.User[lang].Strings,
+ "contactMessage": app.config.Section("ui").Key("contact_message").String(),
+ })
+}
From c582ee03be6278d90463840055256b47d8009620 Mon Sep 17 00:00:00 2001
From: wander <127889726+heywander@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:23:21 -0400
Subject: [PATCH 5/8] payments: add purchased invite email
---
api-messages.go | 2 ++
customcontent.go | 23 +++++++++++++++++++++
email.go | 28 ++++++++++++++++++++++++++
email_test.go | 34 ++++++++++++++++++++++++++++++++
lang.go | 1 +
lang/email/en-us.json | 10 ++++++++++
logmessages/logmessages.go | 23 +++++++++++++++++++++
mail/purchased-invite-email.mjml | 19 ++++++++++++++++++
mail/purchased-invite-email.txt | 9 +++++++++
migrations.go | 3 +++
10 files changed, 152 insertions(+)
create mode 100644 mail/purchased-invite-email.mjml
create mode 100644 mail/purchased-invite-email.txt
diff --git a/api-messages.go b/api-messages.go
index 8f8e41c8..d05074f1 100644
--- a/api-messages.go
+++ b/api-messages.go
@@ -160,6 +160,8 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail":
msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
+ case "PurchasedInviteEmail":
+ msg, err = app.email.constructPurchasedInvite(&Invite{Code: "", UserExpiry: true, UserMonths: 1}, "Monthly", true)
case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation":
diff --git a/customcontent.go b/customcontent.go
index 54fcccb4..22dfb9f7 100644
--- a/customcontent.go
+++ b/customcontent.go
@@ -99,6 +99,29 @@ var customContent = map[string]CustomContentInfo{
DefaultValue: "invite-email",
},
},
+ "PurchasedInviteEmail": {
+ Name: "PurchasedInviteEmail",
+ ContentType: CustomMessage,
+ DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].PurchasedInvite["name"] },
+ Subject: func(config *Config, lang *emailLang) string {
+ return config.Section("invite_emails").Key("purchased_subject").MustString(lang.PurchasedInvite.get("title"))
+ },
+ Variables: []string{
+ "plan",
+ "accessDuration",
+ "inviteURL",
+ },
+ Placeholders: defaultVals(map[string]any{
+ "plan": "Monthly",
+ "accessDuration": "1 month access",
+ "inviteURL": "https://sub2.test.url/invite/xxxxxx",
+ }),
+ SourceFile: ContentSourceFileInfo{
+ Section: "invite_emails",
+ SettingPrefix: "purchased_email_",
+ DefaultValue: "purchased-invite-email",
+ },
+ },
"InviteExpiry": {
Name: "InviteExpiry",
ContentType: CustomMessage,
diff --git a/email.go b/email.go
index ac3ae564..eb8d1264 100644
--- a/email.go
+++ b/email.go
@@ -404,6 +404,34 @@ func (emailer *Emailer) constructInvite(invite *Invite, placeholders bool) (*Mes
return emailer.construct(contentInfo, cc, template)
}
+func (emailer *Emailer) constructPurchasedInvite(invite *Invite, plan string, placeholders bool) (*Message, error) {
+ if plan == "" {
+ plan = "Access"
+ }
+ accessDuration := "ongoing access"
+ if invite.UserExpiry {
+ accessDuration = planAccessLabel(PaymentPlan{AccessMonths: invite.UserMonths, AccessDays: invite.UserDays})
+ }
+ inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
+ contentInfo, template := emailer.baseValues("PurchasedInviteEmail", "", placeholders, map[string]any{
+ "hello": emailer.lang.PurchasedInvite.get("hello"),
+ "paymentAccepted": emailer.lang.PurchasedInvite.get("paymentAccepted"),
+ "planLine": emailer.lang.PurchasedInvite.get("planLine"),
+ "accessLine": emailer.lang.PurchasedInvite.get("accessLine"),
+ "toCreateAccount": emailer.lang.PurchasedInvite.get("toCreateAccount"),
+ "linkButton": emailer.lang.PurchasedInvite.get("linkButton"),
+ "plan": plan,
+ "accessDuration": accessDuration,
+ "inviteURL": inviteLink,
+ })
+ if !placeholders {
+ template["planLine"] = emailer.lang.PurchasedInvite.template("planLine", template)
+ template["accessLine"] = emailer.lang.PurchasedInvite.template("accessLine", template)
+ }
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
+}
+
func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
expiry := formatDatetime(invite.ValidTill)
contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
diff --git a/email_test.go b/email_test.go
index 6729d315..32febcc7 100644
--- a/email_test.go
+++ b/email_test.go
@@ -195,6 +195,40 @@ func TestInvite(t *testing.T) {
})
}
+func TestPurchasedInvite(t *testing.T) {
+ e := testDummyEmailerInit(t)
+ defer dbClose(e)
+ if db == nil {
+ t.Fatalf("db nil")
+ }
+ testContent(e, customContent["PurchasedInviteEmail"], t, func(t *testing.T) {
+ inv := Invite{
+ Code: shortuuid.New(),
+ Created: time.Now(),
+ UserExpiry: true,
+ UserMonths: 3,
+ }
+ msg, err := e.constructPurchasedInvite(&inv, "Quarterly", false)
+ if err != nil {
+ t.Fatalf("failed construct: %+v", err)
+ }
+ for _, content := range []string{msg.Text, msg.HTML} {
+ if !strings.Contains(content, inv.Code) {
+ t.Fatalf("code not found in output: %s", content)
+ }
+ if !strings.Contains(content, "Quarterly") {
+ t.Fatalf("plan not found in output: %s", content)
+ }
+ if !strings.Contains(content, "3 months access") {
+ t.Fatalf("access duration not found in output: %s", content)
+ }
+ if strings.Contains(strings.ToLower(content), "expire") {
+ t.Fatalf("invite expiry wording found in purchased invite output: %s", content)
+ }
+ }
+ })
+}
+
// constructExpiry(code string, invite Invite, placeholders bool)
func TestExpiry(t *testing.T) {
e := testDummyEmailerInit(t)
diff --git a/lang.go b/lang.go
index f0a82b3a..f3498168 100644
--- a/lang.go
+++ b/lang.go
@@ -109,6 +109,7 @@ type emailLang struct {
UserEnabled langSection `json:"userEnabled"`
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
InviteEmail langSection `json:"inviteEmail"`
+ PurchasedInvite langSection `json:"purchasedInviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
diff --git a/lang/email/en-us.json b/lang/email/en-us.json
index 8539778f..87f5f982 100644
--- a/lang/email/en-us.json
+++ b/lang/email/en-us.json
@@ -61,6 +61,16 @@
"inviteExpiry": "This invite will expire on {date} at {time}, which is in {expiresInMinutes}, so act quick.",
"linkButton": "Setup your account"
},
+ "purchasedInviteEmail": {
+ "name": "Purchased invite email",
+ "title": "Your access is ready - Jellyfin",
+ "hello": "Hi",
+ "paymentAccepted": "Your payment was accepted and your access is ready.",
+ "planLine": "Plan: {plan}",
+ "accessLine": "This plan grants {accessDuration}. The access period starts when your account is created.",
+ "toCreateAccount": "Use the link below to create your account.",
+ "linkButton": "Setup your account"
+ },
"welcomeEmail": {
"name": "Welcome",
"title": "Welcome to Jellyfin",
diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go
index 76e35024..4297d8b8 100644
--- a/logmessages/logmessages.go
+++ b/logmessages/logmessages.go
@@ -337,6 +337,29 @@ const (
// usercache.go
CacheRefreshCompleted = "Usercache refreshed, %d in %.2fs (%f.2u/sec)"
+ // stripe.go / api-stripe.go
+ Stripe = "Stripe"
+ InitStripe = "Initialized " + Stripe
+ FailedCreateCheckoutSession = "Failed to create " + Stripe + " checkout session: %v"
+ StripeWebhookError = Stripe + " webhook error: %v"
+ StripePaymentReceived = Stripe + " payment received for checkout session (Plan: %s) to \"%s\""
+ StripePaymentOldInvite = Stripe + " payment received for existing invite: \"%s\""
+ StripeRenewalReceived = Stripe + " subscription renewal received for \"%s\""
+ StripeSubscriptionDeleted = Stripe + " subscription %s deleted for \"%s\", disabling user"
+ StripeSignatureBypass = Stripe + " signature verification disabled, verifying event via API"
+ StripeBlockedDuplicate = Stripe + ": blocked duplicate subscription attempt for active user \"%s\" (\"%s\")"
+
+ // Common payment messages
+ GeneratedInviteForPurchase = "Generated invite code \"%s\" for \"%s\""
+ PaymentTransactionAlreadyProcessed = "%s transaction %s already processed for user \"%s\", skipping"
+ UserReactivated = "Reactivated user \"%s\" to %s"
+ UserExpiryExtended = "Extended expiry for user \"%s\" (\"%s\") to %s"
+ UserDisabledDueToCancellation = "Disabled user \"%s\" due to subscription cancellation"
+ FailedFindUserByEmail = "Could not find user with email \"%s\""
+ ExistingUserFound = "Existing user found for \"%s\" (\"%s\"), reactivating subscription"
+ FailedReEnableUser = "Failed to re-enable user \"%s\": %v"
+ FailedDisableUser = "Failed to disable user \"%s\": %v"
+
// Other
GotNEntries = "got %d entries"
)
diff --git a/mail/purchased-invite-email.mjml b/mail/purchased-invite-email.mjml
new file mode 100644
index 00000000..8b1981c9
--- /dev/null
+++ b/mail/purchased-invite-email.mjml
@@ -0,0 +1,19 @@
+ {{ .hello }}, {{ .planLine }} {{ .accessLine }} {{ .toCreateAccount }}{{ .paymentAccepted }}
+
| {{ .strings.paymentsCreated }} | +{{ .strings.paymentsEmail }} | +{{ .strings.paymentsPlan }} | +{{ .strings.paymentsAmount }} | +{{ .strings.paymentsStatus }} | +{{ .strings.paymentsInviteUser }} | +{{ .strings.paymentsProvider }} | ++ |
|---|
{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ + .successMessage }}{{ end }}
+ {{ if .userPageEnabled }}{{ .strings.userPageSuccessMessage }}
{{ end }} + {{ .strings.continue }} +{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}
- {{ if .userPageEnabled }}{{ .strings.userPageSuccessMessage }}
{{ end }} - {{ .strings.continue }} -{{ .strings.confirmationRequiredMessage }}
{{ .strings.confirmationRequiredMessage }}
-This invite requires a payment of .
+ +diff --git a/html/form.html b/html/form.html index 5b6560ca..ee4d0e55 100644 --- a/html/form.html +++ b/html/form.html @@ -1,169 +1,211 @@ -
- {{ template "header.txt" . }} - {{ if .passwordReset }} -
+ +
+ {{ template "header.txt" . }} + {{ if .passwordReset }} +
+ {{ else }} +
+ {{ end }} + + + +