diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3fef0640 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +data/ +bin/ +dist/ +build/ +node_modules/ diff --git a/Makefile b/Makefile index d8c347f8..79dbe079 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,7 @@ INLINE_SRC = html/crash.html INLINE_TARGET = $(DATA)/crash.html $(INLINE_TARGET): $(CSS_FULLTARGET) $(INLINE_SRC) cp html/crash.html $(DATA)/crash.html + sed -i 's#web/css/v[^"]*bundle.css#web/css/$(CSSVERSION)bundle.css#g' $(DATA)/crash.html $(UNCSS) # generates $(DATA)/bundle.css for us node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html rm $(DATA)/bundle.css 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-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/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/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/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/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/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 }} + + + + +