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

+

{{ .paymentAccepted }}

+

{{ .planLine }}

+

{{ .accessLine }}

+

{{ .toCreateAccount }}

+
+ {{ .linkButton }} +
+
+ +
+
diff --git a/mail/purchased-invite-email.txt b/mail/purchased-invite-email.txt new file mode 100644 index 00000000..d685b0de --- /dev/null +++ b/mail/purchased-invite-email.txt @@ -0,0 +1,9 @@ +{{ .hello }}, +{{ .paymentAccepted }} +{{ .planLine }} +{{ .accessLine }} +{{ .toCreateAccount }} + +{{ .inviteURL }} + +{{ .footer }} diff --git a/migrations.go b/migrations.go index 8826acb6..d1c6cbb3 100644 --- a/migrations.go +++ b/migrations.go @@ -375,6 +375,9 @@ func intialiseCustomContent(app *appContext) { if _, ok := app.storage.GetCustomContentKey("InviteEmail"); !ok { app.storage.SetCustomContentKey("InviteEmail", emptyCC) } + if _, ok := app.storage.GetCustomContentKey("PurchasedInviteEmail"); !ok { + app.storage.SetCustomContentKey("PurchasedInviteEmail", emptyCC) + } if _, ok := app.storage.GetCustomContentKey("WelcomeEmail"); !ok { app.storage.SetCustomContentKey("WelcomeEmail", emptyCC) } From 827c6147a73c051e9e6976a85d5cec0df55a7982 Mon Sep 17 00:00:00 2001 From: wander <127889726+heywander@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:23:26 -0400 Subject: [PATCH 6/8] payments: reconcile Stripe lifecycle state --- stripe_reconcile.go | 828 +++++++++++++++++++++++++++ stripe_subscription_notifications.go | 127 ++++ stripe_test.go | 663 +++++++++++++++++++++ tasks.go | 36 ++ user-d.go | 12 + 5 files changed, 1666 insertions(+) create mode 100644 stripe_reconcile.go create mode 100644 stripe_subscription_notifications.go create mode 100644 stripe_test.go diff --git a/stripe_reconcile.go b/stripe_reconcile.go new file mode 100644 index 00000000..ceb0c129 --- /dev/null +++ b/stripe_reconcile.go @@ -0,0 +1,828 @@ +package main + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + lm "github.com/hrfee/jfa-go/logmessages" + "github.com/lithammer/shortuuid/v3" + "github.com/stripe/stripe-go/v86" + chargeapi "github.com/stripe/stripe-go/v86/charge" + "github.com/stripe/stripe-go/v86/checkout/session" + invoiceapi "github.com/stripe/stripe-go/v86/invoice" + invoicepaymentapi "github.com/stripe/stripe-go/v86/invoicepayment" + paymentintentapi "github.com/stripe/stripe-go/v86/paymentintent" + subscriptionapi "github.com/stripe/stripe-go/v86/subscription" + "gopkg.in/ini.v1" +) + +const ( + stripeMetadataSource = "source" + stripeMetadataSourceJFA = "jfa-go" + stripeMetadataInstanceID = "instance_id" + stripeMetadataEmail = "target_email" + stripeMetadataPlanID = "plan_id" + stripeMetadataPlan = "plan" + stripeMetadataProfile = "profile" + stripeMetadataAccessMonths = "access_months" + stripeMetadataAccessDays = "access_days" + stripeMetadataRecurring = "recurring" + stripeMetadataInterval = "interval" + stripeMetadataIntervalCount = "interval_count" + stripeMetadataFlow = "flow" + stripeMetadataInviteCode = "invite_code" + + stripeMetadataFlowInviteUnlock = "invite_unlock" + stripeMetadataFlowStorePurchase = "store_purchase" + + stripeReconcileLookbackDays = 30 +) + +func (app *appContext) paymentInstanceID() string { + id := strings.TrimSpace(app.config.Section("stripe").Key("instance_id").String()) + if id != "" { + return id + } + + id = "jfa_" + shortuuid.New() + app.config.Section("stripe").Key("instance_id").SetValue(id) + if app.configPath == "" { + return id + } + + tempConfig, err := ini.ShadowLoad(app.configPath) + if err != nil { + if app.err != nil { + app.err.Printf(lm.FailedLoadConfig, app.configPath, err) + } + return id + } + tempConfig.Section("stripe").Key("instance_id").SetValue(id) + if err = tempConfig.SaveTo(app.configPath); err != nil && app.err != nil { + app.err.Printf(lm.FailedWriting, app.configPath, err) + } + return id +} + +func (app *appContext) stripePaymentMetadata(values map[string]string) map[string]string { + metadata := map[string]string{ + stripeMetadataSource: stripeMetadataSourceJFA, + stripeMetadataInstanceID: app.paymentInstanceID(), + } + for k, v := range values { + if v != "" { + metadata[k] = v + } + } + return metadata +} + +func (app *appContext) ReconcileStripePayments(gc *gin.Context) { + if !stripeEnabled { + respond(400, "Stripe disabled", gc) + return + } + + result := app.reconcileStripePayments() + if result.Error != "" { + gc.JSON(500, result) + return + } + gc.JSON(200, result) +} + +func (app *appContext) reconcileStripePayments() ReconcilePaymentsDTO { + instanceID := app.paymentInstanceID() + params := &stripe.CheckoutSessionListParams{ + CreatedRange: &stripe.RangeQueryParams{ + GreaterThanOrEqual: time.Now().AddDate(0, 0, -stripeReconcileLookbackDays).Unix(), + }, + } + params.Limit = stripe.Int64(100) + params.AddExpand("data.payment_intent.latest_charge") + params.AddExpand("data.subscription") + params.AddExpand("data.subscription.latest_invoice") + params.AddExpand("data.invoice") + + sessions := []*stripe.CheckoutSession{} + iter := session.List(params) + for iter.Next() { + sessions = append(sessions, iter.CheckoutSession()) + } + if err := iter.Err(); err != nil { + return ReconcilePaymentsDTO{Error: err.Error()} + } + + result := app.reconcileStripeCheckoutSessions(instanceID, sessions) + app.reconcileStoredStripePayments(instanceID, &result) + return result +} + +func (app *appContext) reconcileStripeCheckoutSessions(instanceID string, sessions []*stripe.CheckoutSession) ReconcilePaymentsDTO { + result := ReconcilePaymentsDTO{} + for _, session := range sessions { + result.Scanned++ + if !stripeSessionMatchesInstance(instanceID, session) { + result.Skipped++ + continue + } + result.Matched++ + created, needsReview, lifecycleUpdated := app.reconcileStripeCheckoutSession(instanceID, session) + if created { + result.Created++ + } else { + result.Updated++ + } + if needsReview { + result.NeedsReview++ + } + if lifecycleUpdated { + result.LifecycleUpdates++ + } + } + return result +} + +func stripeSessionMatchesInstance(instanceID string, session *stripe.CheckoutSession) bool { + if session == nil || session.Metadata == nil { + return false + } + return session.Metadata[stripeMetadataSource] == stripeMetadataSourceJFA && + session.Metadata[stripeMetadataInstanceID] == instanceID +} + +func (app *appContext) reconcileStripeCheckoutSession(instanceID string, session *stripe.CheckoutSession) (bool, bool, bool) { + _, existed := app.storage.GetPaymentKey(session.ID) + needsReview := false + lifecycleUpdated := false + + app.setPayment(session.ID, func(payment *Payment) { + previousStatus := payment.Status + applyStripeSessionToPayment(instanceID, session, payment) + app.recoverPaymentLocalLink(payment) + if !app.paymentHasRecoverableLocalLink(payment) && stripeSessionPaid(session) { + payment.Status = paymentStatusNeedsReview + payment.Error = "Stripe checkout is paid, but no local invite or Jellyfin user link could be recovered" + needsReview = true + } + lifecycleUpdated = previousStatus != payment.Status && paymentStatusIsLifecycle(payment.Status) + }) + if app.fulfillRecoveredStripeCheckout(session) { + needsReview = false + } + + return !existed, needsReview, lifecycleUpdated +} + +func (app *appContext) reconcileStoredStripePayments(instanceID string, result *ReconcilePaymentsDTO) { + for _, payment := range app.storage.GetPayments() { + if payment.Provider != lm.Stripe || (payment.InstanceID != "" && payment.InstanceID != instanceID) { + continue + } + refreshed, needsReview, lifecycleUpdated := app.reconcileStoredStripePayment(instanceID, payment) + if !refreshed { + result.Skipped++ + continue + } + result.Refreshed++ + result.Updated++ + if needsReview { + result.NeedsReview++ + } + if lifecycleUpdated { + result.LifecycleUpdates++ + } + } +} + +func (app *appContext) reconcileStoredStripePayment(instanceID string, stored Payment) (bool, bool, bool) { + state := stripePaymentState{} + state.load(app, stored) + + needsReview := false + lifecycleUpdated := false + app.setPayment(stored.ID, func(payment *Payment) { + previousStatus := payment.Status + payment.LastReconciledAt = time.Now() + + if state.session != nil { + applyStripeSessionToPayment(instanceID, state.session, payment) + } + if state.invoice != nil { + applyStripeInvoiceToPayment(state.invoice, payment) + } + if state.subscription != nil { + applyStripeSubscriptionToPayment(state.subscription, payment) + } + if state.paymentIntent != nil { + applyStripePaymentIntentToPayment(state.paymentIntent, payment) + } + if state.charge != nil { + applyStripeChargeToPayment(state.charge, payment) + } + + app.recoverPaymentLocalLink(payment) + if !app.paymentHasRecoverableLocalLink(payment) && !payment.PaidAt.IsZero() && !paymentStatusIsLifecycle(payment.Status) { + payment.Status = paymentStatusNeedsReview + payment.Error = "Stripe payment is paid, but no local invite or Jellyfin user link could be recovered" + needsReview = true + } + if state.errText != "" && payment.Error == "" { + payment.Error = state.errText + } + lifecycleUpdated = previousStatus != payment.Status && paymentStatusIsLifecycle(payment.Status) + }) + if app.fulfillRecoveredStripeCheckout(state.session) { + needsReview = false + } + + return state.loaded(), needsReview, lifecycleUpdated +} + +func (app *appContext) fulfillRecoveredStripeCheckout(session *stripe.CheckoutSession) bool { + if session == nil || session.ID == "" || !stripeSessionPaid(session) { + return false + } + payment, ok := app.storage.GetPaymentKey(session.ID) + if ok && !payment.FulfilledAt.IsZero() { + if app.repairStaleStorePaymentAsInvite(payment) { + return true + } + if shouldSendStorePaymentConfirmation(payment) { + expiry := time.Time{} + if userExpiry, ok := app.storage.GetUserExpiryKey(payment.JellyfinID); ok { + expiry = userExpiry.Expiry + } + app.sendStorePaymentConfirmation(payment.JellyfinID, payment.TargetEmail, payment.ID, payment.Provider, payment.Plan, expiry, payment.Recurring) + } + return true + } + + metadata := session.Metadata + if metadata == nil { + metadata = map[string]string{} + } + + refID := session.ClientReferenceID + targetEmail := metadata[stripeMetadataEmail] + if stripeSessionIsInviteUnlock(metadata) || targetEmail == "" { + return app.fulfillStripeInviteUnlock(session.ID, refID, metadata) + } + + subscriptionID := stripeSessionSubscriptionID(session) + 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, "") + } else if result.JellyfinID != "" && result.Duplicate { + app.markPaymentEmail(session.ID, paymentEmailNotApplicable, "") + } + return result.InviteCode != "" || result.JellyfinID != "" +} + +func stripeSessionIsInviteUnlock(metadata map[string]string) bool { + if metadata == nil { + return false + } + return metadata[stripeMetadataFlow] == stripeMetadataFlowInviteUnlock || metadata[stripeMetadataInviteCode] != "" +} + +func (app *appContext) fulfillStripeInviteUnlock(paymentID, refID string, metadata map[string]string) bool { + inviteCode := "" + if metadata != nil { + inviteCode = metadata[stripeMetadataInviteCode] + } + if inviteCode == "" { + inviteCode = refID + } + if inviteCode == "" { + return false + } + + app.info.Printf(lm.StripePaymentOldInvite, inviteCode) + inv, ok := app.storage.GetInvitesKey(inviteCode) + if !ok { + return false + } + inv.PaymentID = paymentID + inv.PaymentStatus = "paid" + app.storage.SetInvitesKey(inviteCode, inv) + app.markPaymentFulfilled(paymentID, paymentFulfillmentResult{InviteCode: inviteCode}) + app.markPaymentEmail(paymentID, paymentEmailNotApplicable, "") + return true +} + +type stripePaymentState struct { + session *stripe.CheckoutSession + invoice *stripe.Invoice + subscription *stripe.Subscription + paymentIntent *stripe.PaymentIntent + charge *stripe.Charge + errText string +} + +func (s *stripePaymentState) loaded() bool { + return s.session != nil || s.invoice != nil || s.subscription != nil || s.paymentIntent != nil || s.charge != nil || s.errText != "" +} + +func (s *stripePaymentState) load(app *appContext, stored Payment) { + stripeID := firstNonEmpty(stored.ProviderPaymentID, stored.ID) + if strings.HasPrefix(stripeID, "cs_") { + params := &stripe.CheckoutSessionParams{} + params.AddExpand("payment_intent.latest_charge") + params.AddExpand("subscription") + params.AddExpand("subscription.latest_invoice") + params.AddExpand("invoice") + checkoutSession, err := session.Get(stripeID, params) + if err != nil { + s.addErr(err) + } else { + s.session = checkoutSession + } + } + + invoiceID := firstNonEmpty(stored.InvoiceID, stripeSessionInvoiceID(s.session)) + if invoiceID == "" && strings.HasPrefix(stripeID, "in_") { + invoiceID = stripeID + } + if invoiceID != "" { + params := &stripe.InvoiceParams{} + params.AddExpand("parent.subscription_details.subscription") + inv, err := invoiceapi.Get(invoiceID, params) + if err != nil { + s.addErr(err) + } else { + s.invoice = inv + s.loadInvoicePayment(inv.ID) + } + } + + subscriptionID := firstNonEmpty(stored.SubscriptionID, stripeSessionSubscriptionID(s.session), stripeInvoiceSubscriptionID(s.invoice)) + if subscriptionID == "" && strings.HasPrefix(stripeID, "sub_") { + subscriptionID = stripeID + } + if subscriptionID != "" { + params := &stripe.SubscriptionParams{} + params.AddExpand("latest_invoice") + sub, err := subscriptionapi.Get(subscriptionID, params) + if err != nil { + s.addErr(err) + } else { + s.subscription = sub + if s.invoice == nil && sub.LatestInvoice != nil { + s.invoice = sub.LatestInvoice + s.loadInvoicePayment(sub.LatestInvoice.ID) + } + } + } + + paymentIntentID := firstNonEmpty(stored.PaymentIntentID, stripeSessionPaymentIntentID(s.session), stripeInvoicePaymentIntentID(s.invoice)) + if paymentIntentID == "" && strings.HasPrefix(stripeID, "pi_") { + paymentIntentID = stripeID + } + if paymentIntentID != "" && (s.paymentIntent == nil || s.paymentIntent.ID != paymentIntentID) { + params := &stripe.PaymentIntentParams{} + params.AddExpand("latest_charge") + pi, err := paymentintentapi.Get(paymentIntentID, params) + if err != nil { + s.addErr(err) + } else { + s.paymentIntent = pi + } + } + + chargeID := firstNonEmpty(stored.ChargeID, stripePaymentIntentChargeID(s.paymentIntent)) + if chargeID == "" && strings.HasPrefix(stripeID, "ch_") { + chargeID = stripeID + } + if chargeID != "" && (s.charge == nil || s.charge.ID != chargeID) { + ch, err := chargeapi.Get(chargeID, nil) + if err != nil { + s.addErr(err) + } else { + s.charge = ch + } + } + + if app.debug != nil && s.errText != "" { + app.debug.Printf("Stripe reconciliation warning for %s: %s", stored.ID, s.errText) + } +} + +func (s *stripePaymentState) loadInvoicePayment(invoiceID string) { + if invoiceID == "" { + return + } + + params := &stripe.InvoicePaymentListParams{Invoice: stripe.String(invoiceID)} + params.Limit = stripe.Int64(10) + params.AddExpand("data.payment.payment_intent.latest_charge") + params.AddExpand("data.payment.charge") + iter := invoicepaymentapi.List(params) + for iter.Next() { + ip := iter.InvoicePayment() + if ip.Payment == nil { + continue + } + if ip.Payment.PaymentIntent != nil { + s.paymentIntent = ip.Payment.PaymentIntent + } + if ip.Payment.Charge != nil { + s.charge = ip.Payment.Charge + } + } + if err := iter.Err(); err != nil { + s.addErr(err) + } +} + +func (s *stripePaymentState) addErr(err error) { + if err == nil { + return + } + if s.errText == "" { + s.errText = "Stripe reconciliation: " + err.Error() + return + } + s.errText += "; " + err.Error() +} + +func (app *appContext) recoverPaymentLocalLink(payment *Payment) { + if payment.JellyfinID != "" || payment.TargetEmail == "" { + return + } + if userID, _, ok := app.findUserByEmail(payment.TargetEmail); ok { + payment.JellyfinID = userID + } +} + +func (app *appContext) paymentHasRecoverableLocalLink(payment *Payment) bool { + if payment.JellyfinID != "" { + return true + } + if payment.InviteCode == "" { + return false + } + _, ok := app.storage.GetInvitesKey(payment.InviteCode) + return ok +} + +func applyStripeSessionToPayment(instanceID string, session *stripe.CheckoutSession, payment *Payment) { + payment.Provider = lm.Stripe + payment.InstanceID = instanceID + payment.ProviderPaymentID = session.ID + payment.ProviderLiveMode = session.Livemode + payment.Amount = session.AmountTotal + payment.Currency = string(session.Currency) + payment.LastReconciledAt = time.Now() + if session.Customer != nil { + payment.CustomerID = session.Customer.ID + } + + if session.Invoice != nil { + payment.InvoiceID = session.Invoice.ID + applyStripeInvoiceToPayment(session.Invoice, payment) + } + if session.Subscription != nil { + payment.SubscriptionID = session.Subscription.ID + applyStripeSubscriptionToPayment(session.Subscription, payment) + } + if session.PaymentIntent != nil { + applyStripePaymentIntentToPayment(session.PaymentIntent, payment) + } + if session.Created > 0 && payment.Created.IsZero() { + payment.Created = time.Unix(session.Created, 0) + } + if stripeSessionPaid(session) && payment.PaidAt.IsZero() { + if session.Created > 0 { + payment.PaidAt = time.Unix(session.Created, 0) + } else { + payment.PaidAt = time.Now() + } + } + + metadata := session.Metadata + if metadata == nil { + metadata = map[string]string{} + } + if payment.TargetEmail == "" { + payment.TargetEmail = metadata[stripeMetadataEmail] + } + if payment.TargetEmail == "" { + payment.TargetEmail = session.CustomerEmail + } + paymentPlanSnapshotFromMetadata(metadata).apply(payment) + if payment.Profile == "" { + payment.Profile = metadata[stripeMetadataProfile] + } + if payment.Profile == "" { + payment.Profile = paymentDefaultProfile + } + if payment.InviteCode == "" { + payment.InviteCode = metadata[stripeMetadataInviteCode] + } + if payment.EmailStatus == "" { + payment.EmailStatus = paymentEmailNotStarted + } + if session.Status == stripe.CheckoutSessionStatusExpired { + setPaymentLifecycleStatus(payment, paymentStatusCheckoutExpired, "Stripe checkout session expired") + } else if stripeSessionPaid(session) && (payment.Status == "" || payment.Status == paymentStatusCheckoutCreated || payment.Status == paymentStatusCheckoutExpired) { + payment.Status = paymentStatusPaid + } else if payment.Status == "" { + payment.Status = paymentStatusCheckoutCreated + } +} + +func stripeSessionPaid(session *stripe.CheckoutSession) bool { + return session.PaymentStatus == stripe.CheckoutSessionPaymentStatusPaid || + session.PaymentStatus == stripe.CheckoutSessionPaymentStatusNoPaymentRequired +} + +func applyStripeInvoiceToPayment(invoice *stripe.Invoice, payment *Payment) { + if invoice == nil || invoice.ID == "" { + return + } + payment.InvoiceID = invoice.ID + payment.ProviderLiveMode = invoice.Livemode + payment.InvoiceStatus = string(invoice.Status) + if invoice.Customer != nil { + payment.CustomerID = invoice.Customer.ID + } + if invoice.AmountPaid > 0 { + payment.Amount = invoice.AmountPaid + } else if invoice.AmountDue > 0 { + payment.Amount = invoice.AmountDue + } + if invoice.Currency != "" { + payment.Currency = string(invoice.Currency) + } + if invoice.CustomerEmail != "" && payment.TargetEmail == "" { + payment.TargetEmail = invoice.CustomerEmail + } + if invoice.Created > 0 && payment.Created.IsZero() { + payment.Created = time.Unix(invoice.Created, 0) + } + if invoice.Status == stripe.InvoiceStatusPaid && payment.PaidAt.IsZero() { + if invoice.Created > 0 { + payment.PaidAt = time.Unix(invoice.Created, 0) + } else { + payment.PaidAt = time.Now() + } + } + if subID := stripeInvoiceSubscriptionID(invoice); subID != "" { + payment.SubscriptionID = subID + } + if invoice.Parent != nil && invoice.Parent.SubscriptionDetails != nil { + metadata := invoice.Parent.SubscriptionDetails.Metadata + if metadata[stripeMetadataEmail] != "" && payment.TargetEmail == "" { + payment.TargetEmail = metadata[stripeMetadataEmail] + } + paymentPlanSnapshotFromMetadata(metadata).apply(payment) + if metadata[stripeMetadataProfile] != "" && payment.Profile == "" { + payment.Profile = metadata[stripeMetadataProfile] + } + } + + switch invoice.Status { + case stripe.InvoiceStatusPaid: + if payment.Status == "" || payment.Status == paymentStatusCheckoutCreated || payment.Status == paymentStatusFailed || payment.Status == paymentStatusSubscriptionPastDue || payment.Status == paymentStatusSubscriptionLapsed { + payment.Status = paymentStatusPaid + payment.Error = "" + } + case stripe.InvoiceStatusOpen: + if invoice.Attempted && invoice.AmountRemaining > 0 { + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionPastDue, "Stripe invoice payment is past due") + } + case stripe.InvoiceStatusUncollectible, stripe.InvoiceStatusVoid: + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionLapsed, "Stripe invoice is "+string(invoice.Status)) + } +} + +func applyStripeSubscriptionToPayment(sub *stripe.Subscription, payment *Payment) { + if sub == nil || sub.ID == "" { + return + } + payment.SubscriptionID = sub.ID + payment.ProviderLiveMode = sub.Livemode + payment.SubscriptionStatus = string(sub.Status) + if sub.Customer != nil { + payment.CustomerID = sub.Customer.ID + } + payment.SubscriptionCancelAt = sub.CancelAt + payment.SubscriptionCancelAtPeriodEnd = sub.CancelAtPeriodEnd + payment.SubscriptionCanceledAt = sub.CanceledAt + payment.SubscriptionEndedAt = sub.EndedAt + if sub.Currency != "" && payment.Currency == "" { + payment.Currency = string(sub.Currency) + } + if sub.Created > 0 && payment.Created.IsZero() { + payment.Created = time.Unix(sub.Created, 0) + } + if sub.Metadata != nil { + if sub.Metadata[stripeMetadataEmail] != "" && payment.TargetEmail == "" { + payment.TargetEmail = sub.Metadata[stripeMetadataEmail] + } + paymentPlanSnapshotFromMetadata(sub.Metadata).apply(payment) + if sub.Metadata[stripeMetadataProfile] != "" && payment.Profile == "" { + payment.Profile = sub.Metadata[stripeMetadataProfile] + } + } + + switch sub.Status { + case stripe.SubscriptionStatusCanceled: + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionCanceled, "") + case stripe.SubscriptionStatusUnpaid, stripe.SubscriptionStatusIncompleteExpired, stripe.SubscriptionStatusPaused: + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionLapsed, "Stripe subscription is "+string(sub.Status)) + case stripe.SubscriptionStatusPastDue, stripe.SubscriptionStatusIncomplete: + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionPastDue, "Stripe subscription is "+string(sub.Status)) + case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing: + if sub.CancelAtPeriodEnd { + setPaymentLifecycleStatus(payment, paymentStatusSubscriptionCanceling, stripeSubscriptionCancelingDetail(sub)) + } else { + clearPaymentLifecycleStatus(payment) + } + } +} + +func applyStripePaymentIntentToPayment(paymentIntent *stripe.PaymentIntent, payment *Payment) { + if paymentIntent == nil || paymentIntent.ID == "" { + return + } + payment.PaymentIntentID = paymentIntent.ID + payment.ProviderLiveMode = paymentIntent.Livemode + if paymentIntent.Customer != nil { + payment.CustomerID = paymentIntent.Customer.ID + } + if paymentIntent.AmountReceived > 0 { + payment.Amount = paymentIntent.AmountReceived + } else if paymentIntent.Amount > 0 && payment.Amount == 0 { + payment.Amount = paymentIntent.Amount + } + if paymentIntent.Currency != "" { + payment.Currency = string(paymentIntent.Currency) + } + if paymentIntent.ReceiptEmail != "" && payment.TargetEmail == "" { + payment.TargetEmail = paymentIntent.ReceiptEmail + } + if paymentIntent.Created > 0 && payment.Created.IsZero() { + payment.Created = time.Unix(paymentIntent.Created, 0) + } + if paymentIntent.LatestCharge != nil { + payment.ChargeID = paymentIntent.LatestCharge.ID + applyStripeChargeToPayment(paymentIntent.LatestCharge, payment) + } + + switch paymentIntent.Status { + case stripe.PaymentIntentStatusSucceeded: + if payment.PaidAt.IsZero() { + if paymentIntent.Created > 0 { + payment.PaidAt = time.Unix(paymentIntent.Created, 0) + } else { + payment.PaidAt = time.Now() + } + } + if payment.Status == "" || payment.Status == paymentStatusCheckoutCreated || payment.Status == paymentStatusPaymentCanceled || payment.Status == paymentStatusFailed { + payment.Status = paymentStatusPaid + payment.Error = "" + } + case stripe.PaymentIntentStatusCanceled: + setPaymentLifecycleStatus(payment, paymentStatusPaymentCanceled, stripePaymentIntentCanceledDetail(paymentIntent)) + case stripe.PaymentIntentStatusRequiresPaymentMethod: + setPaymentLifecycleStatus(payment, paymentStatusFailed, "Stripe payment requires a new payment method") + } +} + +func applyStripeChargeToPayment(charge *stripe.Charge, payment *Payment) { + if charge == nil || charge.ID == "" { + return + } + payment.ChargeID = charge.ID + payment.ProviderLiveMode = charge.Livemode + if charge.Customer != nil { + payment.CustomerID = charge.Customer.ID + } + if charge.PaymentIntent != nil { + payment.PaymentIntentID = charge.PaymentIntent.ID + } + if charge.Amount > 0 && payment.Amount == 0 { + payment.Amount = charge.Amount + } + if charge.Currency != "" { + payment.Currency = string(charge.Currency) + } + if charge.ReceiptEmail != "" && payment.TargetEmail == "" { + payment.TargetEmail = charge.ReceiptEmail + } + if charge.Created > 0 && payment.Created.IsZero() { + payment.Created = time.Unix(charge.Created, 0) + } + payment.RefundedAmount = charge.AmountRefunded + if charge.AmountRefunded > 0 { + if charge.Refunded || charge.AmountRefunded >= charge.Amount { + setPaymentLifecycleStatus(payment, paymentStatusRefunded, "") + } else { + setPaymentLifecycleStatus(payment, paymentStatusPartiallyRefunded, "") + } + } + if charge.Status == stripe.ChargeStatusFailed { + setPaymentLifecycleStatus(payment, paymentStatusFailed, firstNonEmpty(charge.FailureMessage, charge.FailureCode, "Stripe charge failed")) + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func stripeSessionInvoiceID(session *stripe.CheckoutSession) string { + if session == nil || session.Invoice == nil { + return "" + } + return session.Invoice.ID +} + +func stripeSessionSubscriptionID(session *stripe.CheckoutSession) string { + if session == nil || session.Subscription == nil { + return "" + } + return session.Subscription.ID +} + +func stripeSessionPaymentIntentID(session *stripe.CheckoutSession) string { + if session == nil || session.PaymentIntent == nil { + return "" + } + return session.PaymentIntent.ID +} + +func stripeInvoiceSubscriptionID(invoice *stripe.Invoice) string { + if invoice == nil || invoice.Parent == nil || invoice.Parent.SubscriptionDetails == nil || invoice.Parent.SubscriptionDetails.Subscription == nil { + return "" + } + return invoice.Parent.SubscriptionDetails.Subscription.ID +} + +func stripeInvoicePaymentIntentID(invoice *stripe.Invoice) string { + if invoice == nil || invoice.Payments == nil { + return "" + } + for _, payment := range invoice.Payments.Data { + if payment.Payment != nil && payment.Payment.PaymentIntent != nil { + return payment.Payment.PaymentIntent.ID + } + } + return "" +} + +func stripePaymentIntentChargeID(paymentIntent *stripe.PaymentIntent) string { + if paymentIntent == nil || paymentIntent.LatestCharge == nil { + return "" + } + return paymentIntent.LatestCharge.ID +} + +func stripePaymentIntentCanceledDetail(paymentIntent *stripe.PaymentIntent) string { + if paymentIntent == nil || paymentIntent.CancellationReason == "" { + return "Stripe payment was canceled" + } + return "Stripe payment was canceled: " + string(paymentIntent.CancellationReason) +} + +func stripeSubscriptionCancelingDetail(sub *stripe.Subscription) string { + if sub == nil || sub.CancelAt == 0 { + return "Stripe subscription will cancel at period end" + } + return "Stripe subscription will cancel at " + time.Unix(sub.CancelAt, 0).Format(time.RFC3339) +} diff --git a/stripe_subscription_notifications.go b/stripe_subscription_notifications.go new file mode 100644 index 00000000..d51f108e --- /dev/null +++ b/stripe_subscription_notifications.go @@ -0,0 +1,127 @@ +package main + +import ( + "strings" + "sync" + "time" + + "github.com/stripe/stripe-go/v86" +) + +var stripeSubscriptionCancellationNotifyMu sync.Mutex + +func (app *appContext) notifyStripeSubscriptionCancellation(sub *stripe.Subscription, source string) { + if sub == nil || sub.ID == "" || !emailEnabled || app.email == nil || app.email.sender == nil { + return + } + + targetEmail := stripeSubscriptionTargetEmail(sub, app) + if targetEmail == "" { + return + } + if !app.reserveStripeSubscriptionCancellationNotification(sub.ID) { + return + } + + userID, _, found := app.findUserByEmail(targetEmail) + username := targetEmail + if found { + if user, err := app.jf.UserByID(userID, false); err == nil && user.Name != "" { + username = user.Name + } + } + + msg := app.constructStripeSubscriptionCancellationMessage(username, userID, sub, source) + if err := app.email.send(msg, targetEmail); err != nil { + app.clearStripeSubscriptionCancellationNotified(sub.ID) + app.err.Printf("Failed to send Stripe subscription cancellation email for %s to %s: %v", sub.ID, targetEmail, err) + return + } + + app.info.Printf("Sent Stripe subscription cancellation email for %s to %s", sub.ID, targetEmail) +} + +func (app *appContext) constructStripeSubscriptionCancellationMessage(username, userID string, sub *stripe.Subscription, source string) *Message { + serverName := serverHeader(app.config, nil) + lines := []string{ + "Hi " + username + ",", + "", + "Your " + serverName + " subscription has been canceled.", + } + if source == "user" { + lines = append(lines, "This change was requested from your account page.") + } else if source == "admin" { + lines = append(lines, "This change was made by an administrator.") + } else { + lines = append(lines, "This change was received from Stripe.") + } + + cancelAt := app.stripeSubscriptionCancellationTime(userID, sub) + if !cancelAt.IsZero() && cancelAt.After(time.Now()) { + lines = append(lines, "Your account will remain available until "+formatDatetime(cancelAt)+".") + } else { + lines = append(lines, "Your account access may be disabled soon.") + } + + lines = append(lines, "", "If you did not request this, contact the administrator.") + return &Message{ + Subject: "Subscription cancellation update - " + serverName, + Text: strings.Join(lines, "\n"), + } +} + +func (app *appContext) stripeSubscriptionCancellationTime(userID string, sub *stripe.Subscription) time.Time { + if sub != nil { + if sub.CancelAt > 0 { + return time.Unix(sub.CancelAt, 0) + } + if sub.EndedAt > 0 { + return time.Unix(sub.EndedAt, 0) + } + if sub.CanceledAt > 0 && !sub.CancelAtPeriodEnd { + return time.Unix(sub.CanceledAt, 0) + } + } + if userID != "" { + if expiry, ok := app.storage.GetUserExpiryKey(userID); ok { + return expiry.Expiry + } + } + return time.Time{} +} + +func (app *appContext) stripeSubscriptionCancellationNotified(subscriptionID string) bool { + for _, payment := range app.storage.GetPayments() { + if payment.SubscriptionID == subscriptionID && !payment.SubscriptionCancelNotifiedAt.IsZero() { + return true + } + } + return false +} + +func (app *appContext) reserveStripeSubscriptionCancellationNotification(subscriptionID string) bool { + stripeSubscriptionCancellationNotifyMu.Lock() + defer stripeSubscriptionCancellationNotifyMu.Unlock() + + if app.stripeSubscriptionCancellationNotified(subscriptionID) { + return false + } + now := time.Now() + marked := false + app.setPaymentsByStripeIDs("", "", "", subscriptionID, func(payment *Payment) { + if payment.SubscriptionCancelNotifiedAt.IsZero() { + payment.SubscriptionCancelNotifiedAt = now + marked = true + } + }) + return marked +} + +func (app *appContext) clearStripeSubscriptionCancellationNotified(subscriptionID string) { + stripeSubscriptionCancellationNotifyMu.Lock() + defer stripeSubscriptionCancellationNotifyMu.Unlock() + + app.setPaymentsByStripeIDs("", "", "", subscriptionID, func(payment *Payment) { + payment.SubscriptionCancelNotifiedAt = time.Time{} + }) +} diff --git a/stripe_test.go b/stripe_test.go new file mode 100644 index 00000000..8150afab --- /dev/null +++ b/stripe_test.go @@ -0,0 +1,663 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/hrfee/jfa-go/logger" + lm "github.com/hrfee/jfa-go/logmessages" + "github.com/stripe/stripe-go/v86" + "github.com/timshannon/badgerhold/v4" + "gopkg.in/ini.v1" +) + +func signedStripeHeader(payload []byte, secret string, ts int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + fmt.Fprintf(mac, "%d.%s", ts, payload) + return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))) +} + +func stripeTestPayload(apiVersion string) []byte { + return []byte(fmt.Sprintf(`{ + "id": "evt_test_webhook_compat", + "object": "event", + "api_version": %q, + "created": 1782620000, + "type": "ping", + "livemode": false, + "data": {"object": {"object": "ping"}} + }`, apiVersion)) +} + +func TestStripeWebhookSupportedAPIVersion(t *testing.T) { + secret := "whsec_test" + payload := stripeTestPayload(supportedStripeWebhookAPIVersion) + header := signedStripeHeader(payload, secret, time.Now().Unix()) + + event, err := HandleWebhook(payload, header, secret, true) + if err != nil { + t.Fatalf("supported Stripe webhook API version %q was rejected by stripe-go %s (%s): %v", supportedStripeWebhookAPIVersion, stripe.ClientVersion, stripe.APIVersion, err) + } + if event.APIVersion != supportedStripeWebhookAPIVersion { + t.Fatalf("expected API version %q, got %q", supportedStripeWebhookAPIVersion, event.APIVersion) + } +} + +func TestStripeWebhookRejectsUnsupportedReleaseTrain(t *testing.T) { + secret := "whsec_test" + payload := stripeTestPayload("2099-01-01.future") + header := signedStripeHeader(payload, secret, time.Now().Unix()) + + _, err := HandleWebhook(payload, header, secret, true) + if err == nil { + t.Fatal("expected unsupported Stripe webhook API release train to be rejected") + } + if !strings.Contains(err.Error(), "API version") { + t.Fatalf("expected API version error, got: %v", err) + } +} + +func newStripePaymentTestApp(t *testing.T) *appContext { + t.Helper() + opts := badgerhold.DefaultOptions + opts.Dir = t.TempDir() + opts.ValueDir = opts.Dir + opts.Logger = nil + + db, err := badgerhold.Open(opts) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + t.Cleanup(func() { + db.Close() + }) + + conf, err := ini.Load([]byte("[stripe]\ninstance_id = instance_test\n")) + if err != nil { + t.Fatalf("failed to load test config: %v", err) + } + + oldEmailEnabled := emailEnabled + emailEnabled = false + t.Cleanup(func() { + emailEnabled = oldEmailEnabled + }) + + storage := &Storage{db: db, debug: logger.NewEmptyLogger(), logActions: func(string) DebugLogAction { return NoLog }} + storage.SetProfileKey(paymentDefaultProfile, Profile{Default: true}) + + return &appContext{ + config: &Config{File: conf}, + storage: storage, + LoggerSet: LoggerSet{ + info: logger.NewEmptyLogger(), + debug: logger.NewEmptyLogger(), + err: logger.NewEmptyLogger(), + }, + } +} + +func stripeReconcileSession(instanceID string) *stripe.CheckoutSession { + return &stripe.CheckoutSession{ + ID: "cs_test_reconcile", + AmountTotal: 200, + Currency: stripe.CurrencyUSD, + Created: 1782620000, + PaymentStatus: stripe.CheckoutSessionPaymentStatusPaid, + Status: stripe.CheckoutSessionStatusComplete, + Metadata: map[string]string{ + stripeMetadataSource: stripeMetadataSourceJFA, + stripeMetadataInstanceID: instanceID, + stripeMetadataEmail: "test@example.com", + stripeMetadataPlan: paymentPlanMonthly, + stripeMetadataProfile: paymentDefaultProfile, + }, + } +} + +func TestStripePaymentMetadataIncludesAndPersistsInstanceID(t *testing.T) { + confPath := filepath.Join(t.TempDir(), "config.ini") + if err := os.WriteFile(confPath, []byte("[stripe]\n"), 0600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + conf, err := ini.Load(confPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + app := &appContext{ + config: &Config{File: conf}, + configPath: confPath, + } + + metadata := app.stripePaymentMetadata(map[string]string{ + stripeMetadataEmail: "test@example.com", + stripeMetadataPlan: paymentPlanMonthly, + stripeMetadataProfile: paymentDefaultProfile, + }) + + if metadata[stripeMetadataSource] != stripeMetadataSourceJFA { + t.Fatalf("expected metadata source %q, got %q", stripeMetadataSourceJFA, metadata[stripeMetadataSource]) + } + instanceID := metadata[stripeMetadataInstanceID] + if !strings.HasPrefix(instanceID, "jfa_") { + t.Fatalf("expected generated instance ID to start with jfa_, got %q", instanceID) + } + + reloaded, err := ini.Load(confPath) + if err != nil { + t.Fatalf("failed to reload config: %v", err) + } + if got := reloaded.Section("stripe").Key("instance_id").String(); got != instanceID { + t.Fatalf("expected persisted instance ID %q, got %q", instanceID, got) + } +} + +func TestPaidInvitePaymentLockRequiresHashedToken(t *testing.T) { + app := newStripePaymentTestApp(t) + token, hash, err := newPaymentLockToken() + if err != nil { + t.Fatalf("failed to create payment lock: %v", err) + } + + app.storage.SetPaymentKey("cs_lock", Payment{ + InviteCode: "invite_lock", + InviteLockHash: hash, + InviteLockCreatedAt: time.Now(), + }) + invite := Invite{ + Code: "invite_lock", + RequiredPayment: true, + PaymentID: "cs_lock", + PaymentStatus: paymentStatusPaid, + } + + if !app.validPaidInvitePaymentLock(invite, token) { + t.Fatal("expected matching payment lock token to be valid") + } + if app.validPaidInvitePaymentLock(invite, invite.Code) { + t.Fatal("did not expect invite code cookie to unlock a hashed payment lock") + } + if app.validPaidInvitePaymentLock(invite, "") { + t.Fatal("did not expect an empty payment lock token to be valid") + } +} + +func TestPaidInvitePaymentLockAllowsLegacyCodeCookieOnlyWithoutHash(t *testing.T) { + app := newStripePaymentTestApp(t) + invite := Invite{ + Code: "invite_legacy", + RequiredPayment: true, + PaymentStatus: paymentStatusPaid, + } + + if !app.validPaidInvitePaymentLock(invite, invite.Code) { + t.Fatal("expected legacy invite code cookie to be valid when no hashed lock exists") + } + if app.validPaidInvitePaymentLock(invite, "wrong") { + t.Fatal("did not expect mismatched legacy payment lock cookie to be valid") + } +} + +func TestStripeReconcileRecoversPaidStoreCheckout(t *testing.T) { + app := newStripePaymentTestApp(t) + summary := app.reconcileStripeCheckoutSessions("instance_test", []*stripe.CheckoutSession{ + stripeReconcileSession("instance_test"), + }) + + if summary.Scanned != 1 || summary.Matched != 1 || summary.Created != 1 || summary.NeedsReview != 0 { + t.Fatalf("unexpected summary: %+v", summary) + } + + payment, ok := app.storage.GetPaymentKey("cs_test_reconcile") + if !ok { + t.Fatal("expected reconciled payment to be stored") + } + if payment.Status != paymentStatusFulfilled { + t.Fatalf("expected status %q, got %q", paymentStatusFulfilled, payment.Status) + } + if payment.InstanceID != "instance_test" { + t.Fatalf("expected instance ID recorded, got %q", payment.InstanceID) + } + if payment.TargetEmail != "test@example.com" || payment.Plan != paymentPlanMonthly || payment.Amount != 200 || payment.Currency != string(stripe.CurrencyUSD) { + t.Fatalf("unexpected payment fields: %+v", payment) + } + if payment.InviteCode == "" { + t.Fatalf("expected reconciled payment to create a purchased invite: %+v", payment) + } + if payment.EmailStatus != paymentEmailDisabled { + t.Fatalf("expected email status %q, got %q", paymentEmailDisabled, payment.EmailStatus) + } +} + +func TestStripeReconcileIgnoresDifferentInstance(t *testing.T) { + app := newStripePaymentTestApp(t) + summary := app.reconcileStripeCheckoutSessions("instance_test", []*stripe.CheckoutSession{ + stripeReconcileSession("other_instance"), + }) + + if summary.Scanned != 1 || summary.Skipped != 1 || summary.Matched != 0 || summary.Created != 0 { + t.Fatalf("unexpected summary: %+v", summary) + } + if payments := app.storage.GetPayments(); len(payments) != 0 { + t.Fatalf("expected no payments, got %d", len(payments)) + } +} + +func TestStripeReconcileRecoversExistingInviteLink(t *testing.T) { + app := newStripePaymentTestApp(t) + app.storage.SetInvitesKey("invite_test", Invite{Code: "invite_test"}) + session := stripeReconcileSession("instance_test") + session.Metadata[stripeMetadataInviteCode] = "invite_test" + + summary := app.reconcileStripeCheckoutSessions("instance_test", []*stripe.CheckoutSession{session}) + if summary.Created != 1 || summary.NeedsReview != 0 { + t.Fatalf("unexpected summary: %+v", summary) + } + + payment, ok := app.storage.GetPaymentKey("cs_test_reconcile") + if !ok { + t.Fatal("expected reconciled payment to be stored") + } + if payment.Status == paymentStatusNeedsReview { + t.Fatalf("did not expect needs_review for existing invite link: %+v", payment) + } + if payment.InviteCode != "invite_test" { + t.Fatalf("expected invite link to be recovered, got %q", payment.InviteCode) + } + invite, ok := app.storage.GetInvitesKey("invite_test") + if !ok { + t.Fatal("expected invite link to remain stored") + } + if invite.PaymentID != "cs_test_reconcile" || invite.PaymentStatus != paymentStatusPaid { + t.Fatalf("expected invite payment link to be recorded, got %+v", invite) + } +} + +func TestStripeCheckoutExpiredMarksLifecycleStatus(t *testing.T) { + session := stripeReconcileSession("instance_test") + session.PaymentStatus = stripe.CheckoutSessionPaymentStatusUnpaid + session.Status = stripe.CheckoutSessionStatusExpired + + payment := Payment{Status: paymentStatusCheckoutCreated} + applyStripeSessionToPayment("instance_test", session, &payment) + + if payment.Status != paymentStatusCheckoutExpired { + t.Fatalf("expected status %q, got %q", paymentStatusCheckoutExpired, payment.Status) + } + if payment.Error == "" { + t.Fatal("expected checkout expiration detail to be stored") + } +} + +func TestStripeRefundOverridesEmailFailure(t *testing.T) { + payment := Payment{ + Amount: 200, + Currency: "usd", + Status: paymentStatusEmailFailed, + EmailStatus: paymentEmailFailed, + Error: "smtp unavailable", + } + + applyStripeChargeToPayment(&stripe.Charge{ + ID: "ch_refunded", + Amount: 200, + AmountRefunded: 200, + Refunded: true, + Currency: stripe.CurrencyUSD, + Status: stripe.ChargeStatusSucceeded, + PaymentIntent: &stripe.PaymentIntent{ID: "pi_refunded"}, + }, &payment) + + if payment.Status != paymentStatusRefunded { + t.Fatalf("expected status %q, got %q", paymentStatusRefunded, payment.Status) + } + if payment.RefundedAmount != 200 { + t.Fatalf("expected refunded amount 200, got %d", payment.RefundedAmount) + } + if payment.PaymentIntentID != "pi_refunded" || payment.ChargeID != "ch_refunded" { + t.Fatalf("expected Stripe IDs to be stored, got %+v", payment) + } +} + +func TestStorePaymentConfirmationEligibility(t *testing.T) { + base := Payment{ + ID: "cs_existing", + JellyfinID: "jf_user", + TargetEmail: "test@example.com", + Status: paymentStatusFulfilled, + EmailStatus: paymentEmailNotStarted, + } + + if !shouldSendStorePaymentConfirmation(base) { + t.Fatal("expected fulfilled existing-user payment to be eligible for confirmation") + } + + for name, mutate := range map[string]func(*Payment){ + "invite payment": func(p *Payment) { p.InviteCode = "invite_test" }, + "missing user": func(p *Payment) { + p.JellyfinID = "" + }, + "already sent": func(p *Payment) { + p.EmailStatus = paymentEmailSent + }, + "pending": func(p *Payment) { + p.EmailStatus = paymentEmailPending + }, + "canceled": func(p *Payment) { + p.Status = paymentStatusSubscriptionCanceled + }, + } { + t.Run(name, func(t *testing.T) { + payment := base + mutate(&payment) + if shouldSendStorePaymentConfirmation(payment) { + t.Fatalf("did not expect %s to be eligible", name) + } + }) + } +} + +func TestStripePartialRefundIsVisible(t *testing.T) { + payment := Payment{ + Amount: 200, + Status: paymentStatusFulfilled, + } + + applyStripeChargeToPayment(&stripe.Charge{ + ID: "ch_partial", + Amount: 200, + AmountRefunded: 75, + Currency: stripe.CurrencyUSD, + Status: stripe.ChargeStatusSucceeded, + }, &payment) + + if payment.Status != paymentStatusPartiallyRefunded { + t.Fatalf("expected status %q, got %q", paymentStatusPartiallyRefunded, payment.Status) + } + if payment.RefundedAmount != 75 { + t.Fatalf("expected refunded amount 75, got %d", payment.RefundedAmount) + } +} + +func TestStripeSubscriptionLapseAndRecoveryAreVisible(t *testing.T) { + fulfilledAt := time.Now().Add(-time.Hour) + payment := Payment{ + Status: paymentStatusFulfilled, + FulfilledAt: fulfilledAt, + } + + applyStripeSubscriptionToPayment(&stripe.Subscription{ + ID: "sub_lapsed", + Status: stripe.SubscriptionStatusPastDue, + }, &payment) + + if payment.Status != paymentStatusSubscriptionPastDue { + t.Fatalf("expected status %q, got %q", paymentStatusSubscriptionPastDue, payment.Status) + } + if payment.SubscriptionStatus != string(stripe.SubscriptionStatusPastDue) { + t.Fatalf("expected provider subscription status recorded, got %q", payment.SubscriptionStatus) + } + + applyStripeSubscriptionToPayment(&stripe.Subscription{ + ID: "sub_lapsed", + Status: stripe.SubscriptionStatusActive, + }, &payment) + + if payment.Status != paymentStatusFulfilled { + t.Fatalf("expected active subscription to restore fulfilled status, got %q", payment.Status) + } + if !payment.FulfilledAt.Equal(fulfilledAt) { + t.Fatalf("expected fulfilled timestamp to be preserved") + } +} + +func TestStripeSubscriptionCancelingIsVisibleBeforePeriodEnd(t *testing.T) { + cancelAt := time.Now().AddDate(0, 0, 7).Unix() + payment := Payment{Status: paymentStatusFulfilled} + + applyStripeSubscriptionToPayment(&stripe.Subscription{ + ID: "sub_canceling", + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: true, + CancelAt: cancelAt, + }, &payment) + + if payment.Status != paymentStatusSubscriptionCanceling { + t.Fatalf("expected status %q, got %q", paymentStatusSubscriptionCanceling, payment.Status) + } + if !strings.Contains(payment.Error, time.Unix(cancelAt, 0).Format("2006-01-02")) { + t.Fatalf("expected cancel date in detail, got %q", payment.Error) + } +} + +func TestTaskListIncludesStripeWhenEnabled(t *testing.T) { + oldStripeEnabled := stripeEnabled + stripeEnabled = true + t.Cleanup(func() { + stripeEnabled = oldStripeEnabled + }) + + gin.SetMode(gin.TestMode) + app := newStripePaymentTestApp(t) + w := httptest.NewRecorder() + gc, _ := gin.CreateTestContext(w) + + app.TaskList(gc) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + resp := TasksDTO{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode tasks response: %v", err) + } + for _, task := range resp.Tasks { + if task.URL == "/tasks/stripe" { + return + } + } + t.Fatalf("expected Stripe task in response: %+v", resp.Tasks) +} + +func TestExpiredPaidUserSkipsCleanupForActiveStripeSubscription(t *testing.T) { + oldStripeEnabled := stripeEnabled + stripeEnabled = true + t.Cleanup(func() { + stripeEnabled = oldStripeEnabled + }) + + app := newStripePaymentTestApp(t) + app.storage.SetEmailsKey("jf_user", EmailAddress{Addr: "test@example.com"}) + app.storage.SetPaymentKey("cs_active", Payment{ + Provider: lm.Stripe, + InstanceID: "instance_test", + SubscriptionID: "sub_active", + TargetEmail: "test@example.com", + JellyfinID: "jf_user", + Status: paymentStatusFulfilled, + SubscriptionStatus: string(stripe.SubscriptionStatusActive), + }) + + reconciled := false + skip := app.shouldSkipExpiredPaidUserWithReconcile("jf_user", UserExpiry{ + Expiry: time.Now().Add(-time.Hour), + DeleteAfterPeriod: true, + }, func() ReconcilePaymentsDTO { + reconciled = true + return ReconcilePaymentsDTO{} + }) + + if !skip { + t.Fatal("expected active Stripe subscription to skip expiry cleanup") + } + if !reconciled { + t.Fatal("expected active Stripe subscription check to trigger reconciliation") + } +} + +func TestExpiredPaidUserDoesNotSkipCleanupForTerminalStripeSubscription(t *testing.T) { + oldStripeEnabled := stripeEnabled + stripeEnabled = true + t.Cleanup(func() { + stripeEnabled = oldStripeEnabled + }) + + app := newStripePaymentTestApp(t) + app.storage.SetEmailsKey("jf_user", EmailAddress{Addr: "test@example.com"}) + app.storage.SetPaymentKey("cs_canceled", Payment{ + Provider: lm.Stripe, + InstanceID: "instance_test", + SubscriptionID: "sub_canceled", + TargetEmail: "test@example.com", + JellyfinID: "jf_user", + Status: paymentStatusSubscriptionCanceled, + SubscriptionStatus: string(stripe.SubscriptionStatusCanceled), + }) + + reconciled := false + skip := app.shouldSkipExpiredPaidUserWithReconcile("jf_user", UserExpiry{ + Expiry: time.Now().Add(-time.Hour), + }, func() ReconcilePaymentsDTO { + reconciled = true + return ReconcilePaymentsDTO{} + }) + + if skip { + t.Fatal("did not expect canceled Stripe subscription to skip expiry cleanup") + } + if reconciled { + t.Fatal("did not expect terminal Stripe subscription to trigger reconciliation") + } +} + +func TestCancelingStripeSubscriptionSkipsCleanupUntilCancelDate(t *testing.T) { + oldStripeEnabled := stripeEnabled + stripeEnabled = true + t.Cleanup(func() { + stripeEnabled = oldStripeEnabled + }) + + app := newStripePaymentTestApp(t) + app.storage.SetEmailsKey("jf_user", EmailAddress{Addr: "test@example.com"}) + app.storage.SetPaymentKey("cs_canceling", Payment{ + Provider: lm.Stripe, + InstanceID: "instance_test", + SubscriptionID: "sub_canceling", + TargetEmail: "test@example.com", + JellyfinID: "jf_user", + Status: paymentStatusSubscriptionCanceling, + SubscriptionStatus: string(stripe.SubscriptionStatusActive), + SubscriptionCancelAt: time.Now().Add(24 * time.Hour).Unix(), + SubscriptionCancelAtPeriodEnd: true, + }) + + skip := app.shouldSkipExpiredPaidUserWithReconcile("jf_user", UserExpiry{ + Expiry: time.Now().Add(-time.Hour), + }, func() ReconcilePaymentsDTO { + return ReconcilePaymentsDTO{} + }) + + if !skip { + t.Fatal("expected canceling Stripe subscription to skip cleanup before cancel date") + } +} + +func TestExpiredPaidInviteIsPreservedForRecovery(t *testing.T) { + app := newStripePaymentTestApp(t) + expired := time.Now().Add(-time.Hour) + invite := Invite{ + Code: "paid_invite", + ValidTill: expired, + PaymentID: "cs_paid", + PaymentStatus: paymentStatusPaid, + UserExpiry: true, + UserMonths: 1, + } + app.storage.SetInvitesKey(invite.Code, invite) + app.storage.SetPaymentKey("cs_paid", Payment{ + Provider: lm.Stripe, + InviteCode: invite.Code, + TargetEmail: "test@example.com", + Plan: paymentPlanMonthly, + Status: paymentStatusEmailSent, + PaidAt: time.Now().Add(-2 * time.Hour), + }) + + app.deleteExpiredInvite(invite) + + preserved, ok := app.storage.GetInvitesKey(invite.Code) + if !ok { + t.Fatal("expected paid invite to be preserved") + } + if !preserved.ValidTill.Equal(expired) { + t.Fatal("expected housekeeping to preserve, not extend, the paid invite") + } + payment, ok := app.storage.GetPaymentKey("cs_paid") + if !ok { + t.Fatal("expected payment to remain stored") + } + if payment.Status != paymentStatusNeedsReview { + t.Fatalf("expected payment status %q, got %q", paymentStatusNeedsReview, payment.Status) + } + if payment.Error != paymentInviteExpiredNeedsReview { + t.Fatalf("expected payment review reason %q, got %q", paymentInviteExpiredNeedsReview, payment.Error) + } +} + +func TestExpiredUnpaidInviteIsDeleted(t *testing.T) { + app := newStripePaymentTestApp(t) + invite := Invite{ + Code: "unpaid_invite", + ValidTill: time.Now().Add(-time.Hour), + } + app.storage.SetInvitesKey(invite.Code, invite) + + app.deleteExpiredInvite(invite) + + if _, ok := app.storage.GetInvitesKey(invite.Code); ok { + t.Fatal("expected unpaid expired invite to be deleted") + } +} + +func TestExpiredPaidInviteRefreshesBeforeResend(t *testing.T) { + app := newStripePaymentTestApp(t) + invite := Invite{ + Code: "paid_invite", + ValidTill: time.Now().Add(-time.Hour), + UserExpiry: true, + } + payment := Payment{ + ID: "cs_paid", + Provider: lm.Stripe, + InviteCode: invite.Code, + Plan: paymentPlanMonthly, + AccessMonths: 1, + Status: paymentStatusNeedsReview, + PaidAt: time.Now().Add(-2 * time.Hour), + } + app.storage.SetInvitesKey(invite.Code, invite) + + refreshed := app.refreshPurchasedInviteForResend(payment, invite) + + if !refreshed.ValidTill.After(time.Now()) { + t.Fatalf("expected refreshed invite expiry to be in the future, got %s", refreshed.ValidTill) + } + stored, ok := app.storage.GetInvitesKey(invite.Code) + if !ok { + t.Fatal("expected refreshed invite to be stored") + } + if !stored.ValidTill.Equal(refreshed.ValidTill) { + t.Fatalf("expected stored invite expiry %s, got %s", refreshed.ValidTill, stored.ValidTill) + } +} diff --git a/tasks.go b/tasks.go index 45cda74d..813889c8 100644 --- a/tasks.go +++ b/tasks.go @@ -26,6 +26,13 @@ func (app *appContext) TaskList(gc *gin.Context) { Description: "Checks for (pending) account expiries and performs the appropriate actions.", }, }} + if stripeEnabled { + resp.Tasks = append(resp.Tasks, TaskDTO{ + URL: "/tasks/stripe", + Name: "Stripe reconciliation", + Description: "Reconciles local payment records with Stripe and repairs recoverable paid invite/user links.", + }) + } if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { resp.Tasks = append(resp.Tasks, TaskDTO{ URL: "/tasks/jellyseerr", @@ -56,6 +63,35 @@ func (app *appContext) TaskUserCleanup(gc *gin.Context) { gc.Status(http.StatusNoContent) } +// @Summary Triggers Stripe payment reconciliation. +// @Success 204 +// @Router /tasks/stripe [post] +// @Security Bearer +// @tags Tasks +func (app *appContext) TaskStripeReconcile(gc *gin.Context) { + go app.runStripeReconcileTask() + gc.Status(http.StatusNoContent) +} + +func (app *appContext) runStripeReconcileTask() { + result := app.reconcileStripePayments() + if result.Error != "" { + app.err.Printf("Stripe reconciliation task failed: %s", result.Error) + return + } + app.info.Printf( + "Stripe reconciliation task complete: scanned=%d matched=%d created=%d updated=%d skipped=%d refreshed=%d lifecycle_updates=%d needs_review=%d", + result.Scanned, + result.Matched, + result.Created, + result.Updated, + result.Skipped, + result.Refreshed, + result.LifecycleUpdates, + result.NeedsReview, + ) +} + // @Summary Triggers sync of user details with Jellyseerr. Not usually needed after one run, details are synced on change anyway. // @Success 204 // @Router /tasks/jellyseerr [post] diff --git a/user-d.go b/user-d.go index c1fd102f..ba5aeb93 100644 --- a/user-d.go +++ b/user-d.go @@ -94,6 +94,18 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { continue } + if app.shouldSkipExpiredPaidUser(user.ID, expiry) { + if expiry.DeleteAfterPeriod { + expiry.DeleteAfterPeriod = false + app.storage.SetUserExpiryKey(user.ID, expiry) + } + if user.Policy.IsDisabled { + app.reEnablePaidUser(user.ID) + shouldInvalidateCache = true + } + continue + } + // True when "Delete after period" enabled and this user's account has already expired. alreadyExpired := false // True when the user has expired and N days has passed for them to be deleted. From 10c78e727aa09a0c805a764c94ab3331c6277d93 Mon Sep 17 00:00:00 2001 From: wander <127889726+heywander@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:23:43 -0400 Subject: [PATCH 7/8] payments: add store and account UI --- html/admin.html | 106 +++++ html/form.html | 324 +++++++------ html/login-modal.html | 2 +- html/payment_success.html | 29 ++ html/store.html | 61 +++ html/user.html | 11 +- lang/admin/en-us.json | 25 + lang/form/en-us.json | 27 ++ ts/admin.ts | 21 + ts/form.ts | 30 ++ ts/modules/invites.ts | 1 + ts/modules/store.ts | 964 ++++++++++++++++++++++++++++++++++++++ ts/setup.ts | 6 +- ts/store.ts | 67 +++ ts/typings/d.ts | 2 + ts/user.ts | 136 ++++++ 16 files changed, 1666 insertions(+), 146 deletions(-) create mode 100644 html/payment_success.html create mode 100644 html/store.html create mode 100644 ts/modules/store.ts create mode 100644 ts/store.ts diff --git a/html/admin.html b/html/admin.html index b0b1590d..92703002 100644 --- a/html/admin.html +++ b/html/admin.html @@ -12,6 +12,7 @@ window.jellyfinLogin = {{ .jellyfinLogin }}; window.jfAdminOnly = {{ .jfAdminOnly }}; window.jfAllowAll = {{ .jfAllowAll }}; + window.stripeEnabled = {{ .stripeEnabled }}; window.loginAppearance = "{{ .loginAppearance }}"; @@ -81,6 +82,37 @@

{{ .strings.tasks }}× +
@@ -921,6 +956,77 @@

+ {{ if .stripeEnabled }} +
+
+
+ {{ .strings.payments }} +
+ + +
+
+
+
+
+
+ {{ .strings.payments }} + {{ .strings.paymentsDescription }} +
+
+ + +
+
+ + + + + + + + + + + + + + + +
{{ .strings.paymentsCreated }}{{ .strings.paymentsEmail }}{{ .strings.paymentsPlan }}{{ .strings.paymentsAmount }}{{ .strings.paymentsStatus }}{{ .strings.paymentsInviteUser }}{{ .strings.paymentsProvider }}
+
+
+ {{ .strings.paymentsEmpty }} +
+
+
+
+
+
+ {{ .strings.paymentsStore }} + {{ .strings.paymentsStoreDescription }} +
+
+ + +
+
+ +
+
+
+
+ {{ .strings.paymentsNoPlanSelected }} + {{ .strings.paymentsSelectPlan }} +
+
+
+
+
+
+ {{ end }} 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 }} - {{ .strings.passwordReset }} + + + {{ template "header.txt" . }} + {{ if .passwordReset }} + {{ .strings.passwordReset }} + {{ else }} + {{ .strings.pageTitle }} + {{ end }} + + + + +