package server import ( "database/sql" "encoding/json" "fmt" "log/slog" "math" "net/http" "strconv" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" ) // BillingAccountViewModel represents a billing account for operator template rendering type BillingAccountViewModel struct { BillingAccountID string OrgID string OrgName string Name string Status string StripeCustomerID string StripeSyncStatus string CreatedAt string } // BillingAccountsData holds data for the billing accounts list partial type BillingAccountsData struct { Accounts []BillingAccountViewModel Error string } // GetBillingAccounts handles GET /partials/operator/billing/accounts func (h *OperatorPartialsHandler) GetBillingAccounts(w http.ResponseWriter, r *http.Request) { accounts, err := h.BillingQ.ListBillingAccounts(r.Context()) if err != nil { h.Logger.Error("failed to list billing accounts", "error", err) h.renderError(w, "operator_billing_accounts.html", "Failed to load billing accounts") return } vms := make([]BillingAccountViewModel, len(accounts)) for i, acc := range accounts { // Get org name orgName := "" if org, err := h.OrgQ.GetOrganizationByID(r.Context(), acc.OrgID); err == nil { orgName = org.Name } // Get stripe customer mapping stripeCustomerID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetCustomerMappingByBillingAccountID(r.Context(), acc.BillingAccountID); err == nil { if mapping.StripeCustomerID.Valid { stripeCustomerID = mapping.StripeCustomerID.String } syncStatus = mapping.SyncStatus } vms[i] = BillingAccountViewModel{ BillingAccountID: acc.BillingAccountID, OrgID: acc.OrgID, OrgName: orgName, Name: acc.Name, Status: acc.Status, StripeCustomerID: stripeCustomerID, StripeSyncStatus: syncStatus, CreatedAt: acc.CreatedAt.Format("Jan 2, 2006"), } } data := BillingAccountsData{Accounts: vms} h.Templates.Render(w, "operator_billing_accounts.html", data) } // SubscriptionViewModel represents a subscription for operator template rendering type SubscriptionViewModel struct { SubscriptionID string BillingAccountID string BillingAccountName string Status string StatusClass string CurrentPeriodStart string CurrentPeriodEnd string CancelAtPeriodEnd bool StripeSubscriptionID string StripeSyncStatus string CreatedAt string } // SubscriptionsData holds data for the subscriptions list partial type SubscriptionsData struct { Subscriptions []SubscriptionViewModel Error string } // GetSubscriptions handles GET /partials/operator/billing/subscriptions func (h *OperatorPartialsHandler) GetSubscriptions(w http.ResponseWriter, r *http.Request) { subs, err := h.BillingQ.ListSubscriptions(r.Context()) if err != nil { h.Logger.Error("failed to list subscriptions", "error", err) h.renderError(w, "operator_subscriptions.html", "Failed to load subscriptions") return } vms := make([]SubscriptionViewModel, len(subs)) for i, sub := range subs { // Get stripe subscription mapping stripeSubID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetSubscriptionMappingBySubscriptionID(r.Context(), sub.SubscriptionID); err == nil { if mapping.StripeSubscriptionID.Valid { stripeSubID = mapping.StripeSubscriptionID.String } syncStatus = mapping.SyncStatus } // Format period dates periodStart := "" if sub.CurrentPeriodStart.Valid { periodStart = sub.CurrentPeriodStart.Time.Format("Jan 2, 2006") } periodEnd := "" if sub.CurrentPeriodEnd.Valid { periodEnd = sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006") } vms[i] = SubscriptionViewModel{ SubscriptionID: sub.SubscriptionID, BillingAccountID: sub.BillingAccountID, BillingAccountName: sub.BillingAccountName, Status: sub.Status, StatusClass: getStatusBadgeClass(sub.Status), CurrentPeriodStart: periodStart, CurrentPeriodEnd: periodEnd, CancelAtPeriodEnd: sub.CancelAtPeriodEnd, StripeSubscriptionID: stripeSubID, StripeSyncStatus: syncStatus, CreatedAt: sub.CreatedAt.Format("Jan 2, 2006"), } } data := SubscriptionsData{Subscriptions: vms} h.Templates.Render(w, "operator_subscriptions.html", data) } // InvoiceViewModel represents an invoice for operator template rendering type InvoiceViewModel struct { InvoiceID string BillingAccountID string BillingAccountName string Status string StatusClass string AmountDue string AmountPaid string Currency string DueDate string PaidAt string StripeInvoiceID string StripeSyncStatus string CreatedAt string } // InvoicesData holds data for the invoices list partial type InvoicesData struct { Invoices []InvoiceViewModel Error string } // formatCurrency converts cents to formatted currency string func formatCurrency(amount int32, currency string) string { // Convert cents to dollars dollars := float64(amount) / 100 return fmt.Sprintf("$%.2f %s", dollars, currency) } // GetInvoices handles GET /partials/operator/billing/invoices func (h *OperatorPartialsHandler) GetInvoices(w http.ResponseWriter, r *http.Request) { invoices, err := h.BillingQ.ListInvoices(r.Context()) if err != nil { h.Logger.Error("failed to list invoices", "error", err) h.renderError(w, "operator_invoices.html", "Failed to load invoices") return } vms := make([]InvoiceViewModel, len(invoices)) for i, inv := range invoices { // Get stripe invoice mapping stripeInvoiceID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(r.Context(), inv.InvoiceID); err == nil { if mapping.StripeInvoiceID.Valid { stripeInvoiceID = mapping.StripeInvoiceID.String } syncStatus = mapping.SyncStatus } // Format dates dueDate := "" if inv.DueDate.Valid { dueDate = inv.DueDate.Time.Format("Jan 2, 2006") } paidAt := "" if inv.PaidAt.Valid { paidAt = inv.PaidAt.Time.Format("Jan 2, 2006") } vms[i] = InvoiceViewModel{ InvoiceID: inv.InvoiceID, BillingAccountID: inv.BillingAccountID, BillingAccountName: inv.BillingAccountName, Status: inv.Status, StatusClass: getInvoiceStatusBadgeClass(inv.Status, inv.AmountDue, inv.AmountPaid), AmountDue: formatCurrency(inv.AmountDue, inv.Currency), AmountPaid: formatCurrency(inv.AmountPaid, inv.Currency), Currency: inv.Currency, DueDate: dueDate, PaidAt: paidAt, StripeInvoiceID: stripeInvoiceID, StripeSyncStatus: syncStatus, CreatedAt: inv.CreatedAt.Format("Jan 2, 2006"), } } data := InvoicesData{Invoices: vms} h.Templates.Render(w, "operator_invoices.html", data) } // PaymentViewModel represents a payment for operator template rendering type PaymentViewModel struct { PaymentID string InvoiceID string BillingAccountID string BillingAccountName string Status string StatusClass string Amount string Currency string PaymentMethod string StripePaymentIntentID string StripeSyncStatus string FailedAt string CreatedAt string } // PaymentsData holds data for the payments list partial type PaymentsData struct { Payments []PaymentViewModel Error string } // GetPayments handles GET /partials/operator/billing/payments func (h *OperatorPartialsHandler) GetPayments(w http.ResponseWriter, r *http.Request) { payments, err := h.BillingQ.ListPayments(r.Context()) if err != nil { h.Logger.Error("failed to list payments", "error", err) h.renderError(w, "operator_payments.html", "Failed to load payments") return } vms := make([]PaymentViewModel, len(payments)) for i, pay := range payments { // Get stripe payment mapping stripePaymentIntentID := "" syncStatus := "not_mapped" // Note: Payment mappings are indexed by stripe_payment_intent_id, not payment_id // We would need a lookup by payment_id - for now, leave as not_mapped // This can be enhanced by adding a query to lookup payment mapping by payment_id // Format payment method display paymentMethod := "Unknown" if pay.PaymentMethodType.Valid { if pay.PaymentMethodType.String == "card" && pay.CardBrand.Valid && pay.CardLast4.Valid { paymentMethod = fmt.Sprintf("%s •••• %s", pay.CardBrand.String, pay.CardLast4.String) } else { paymentMethod = pay.PaymentMethodType.String } } // Format failed date failedAt := "" if pay.FailedAt.Valid { failedAt = pay.FailedAt.Time.Format("Jan 2, 2006") } vms[i] = PaymentViewModel{ PaymentID: pay.PaymentID, InvoiceID: pay.InvoiceID, BillingAccountID: pay.BillingAccountID, BillingAccountName: pay.BillingAccountName, Status: pay.Status, StatusClass: getPaymentStatusBadgeClass(pay.Status), Amount: formatCurrency(pay.Amount, pay.Currency), Currency: pay.Currency, PaymentMethod: paymentMethod, StripePaymentIntentID: stripePaymentIntentID, StripeSyncStatus: syncStatus, FailedAt: failedAt, CreatedAt: pay.CreatedAt.Format("Jan 2, 2006"), } } data := PaymentsData{Payments: vms} h.Templates.Render(w, "operator_payments.html", data) } // PriceViewModel represents a price for operator template rendering type PriceViewModel struct { PriceID string ProductID string UnitAmount string Currency string RecurringInterval string IsRecurring bool TrialPeriodDays int32 IsActive bool StripePriceID string StripeSyncStatus string CreatedAt string } // ProductPricesData holds data for the product prices partial type ProductPricesData struct { ProductID string ProductName string Prices []PriceViewModel StripeDashboardURL string Success string Error string } // GetProductPrices handles GET /partials/operator/products/{productID}/prices func (h *OperatorPartialsHandler) GetProductPrices(w http.ResponseWriter, r *http.Request) { productID := r.PathValue("productID") h.renderProductPricesPage(w, r, productID, "", "") } // CreatePrice handles POST /partials/operator/products/{productID}/prices func (h *OperatorPartialsHandler) CreatePrice(w http.ResponseWriter, r *http.Request) { productID := r.PathValue("productID") if err := r.ParseForm(); err != nil { h.renderProductPricesPage(w, r, productID, "", "Invalid request") return } amountStr := strings.TrimSpace(r.FormValue("amount")) currency := strings.TrimSpace(r.FormValue("currency")) interval := r.FormValue("recurring_interval") trialDaysStr := strings.TrimSpace(r.FormValue("trial_period_days")) if amountStr == "" || currency == "" { h.renderProductPricesPage(w, r, productID, "", "Amount and currency are required") return } // Parse dollar amount and convert to cents dollars, err := strconv.ParseFloat(amountStr, 64) if err != nil || dollars <= 0 { h.renderProductPricesPage(w, r, productID, "", "Amount must be a positive number") return } unitAmount := int32(math.Round(dollars * 100)) // Build nullable fields recurringInterval := sql.NullString{} if interval != "" && interval != "one_time" { recurringInterval = sql.NullString{String: interval, Valid: true} } trialPeriodDays := sql.NullInt32{} if trialDaysStr != "" { days, err := strconv.Atoi(trialDaysStr) if err != nil || days < 0 { h.renderProductPricesPage(w, r, productID, "", "Trial period must be a non-negative integer") return } if days > 0 { trialPeriodDays = sql.NullInt32{Int32: int32(days), Valid: true} } } price, err := h.BillingQ.CreatePrice(r.Context(), billing.CreatePriceParams{ ProductID: productID, Currency: currency, UnitAmount: unitAmount, RecurringInterval: recurringInterval, TrialPeriodDays: trialPeriodDays, }) if err != nil { h.Logger.Error("failed to create price", slog.Any("error", err)) h.renderProductPricesPage(w, r, productID, "", "Failed to create price: "+err.Error()) return } // Enqueue outbox entry for Stripe sync payload, _ := json.Marshal(map[string]interface{}{ "price_id": price.PriceID, "product_id": price.ProductID, "unit_amount": price.UnitAmount, "currency": price.Currency, "recurring_interval": recurringInterval.String, }) _, err = h.Database.ExecContext(r.Context(), `INSERT INTO integration.outbox (provider, action_type, payload) VALUES ($1, $2, $3)`, "stripe", "create_stripe_price", payload, ) if err != nil { h.Logger.Error("failed to enqueue stripe price outbox entry", slog.Any("error", err), slog.String("price_id", price.PriceID)) // Price was created successfully — log the outbox failure but show success } h.renderProductPricesPage(w, r, productID, "Price created successfully.", "") } func (h *OperatorPartialsHandler) renderProductPricesPage(w http.ResponseWriter, r *http.Request, productID string, success string, errMsg string) { data := ProductPricesData{ ProductID: productID, StripeDashboardURL: h.StripeDashboardURL, Success: success, Error: errMsg, } if errMsg != "" { // On error, still try to load the product name for the header if product, err := h.BillingQ.GetProductByID(r.Context(), productID); err == nil { data.ProductName = product.Name } h.Templates.Render(w, "operator_product_prices.html", data) return } product, err := h.BillingQ.GetProductByID(r.Context(), productID) if err != nil { h.Logger.Error("failed to get product", "error", err, "product_id", productID) h.renderError(w, "operator_product_prices.html", "Product not found") return } data.ProductName = product.Name prices, err := h.BillingQ.ListPricesByProduct(r.Context(), productID) if err != nil { h.Logger.Error("failed to list prices", "error", err, "product_id", productID) h.renderError(w, "operator_product_prices.html", "Failed to load prices") return } vms := make([]PriceViewModel, len(prices)) for i, price := range prices { stripePriceID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetPriceMappingByPriceID(r.Context(), price.PriceID); err == nil { if mapping.StripePriceID.Valid { stripePriceID = mapping.StripePriceID.String } syncStatus = mapping.SyncStatus } isRecurring := price.RecurringInterval.Valid interval := "" if isRecurring { interval = price.RecurringInterval.String } vms[i] = PriceViewModel{ PriceID: price.PriceID, ProductID: price.ProductID, UnitAmount: formatCurrency(price.UnitAmount, price.Currency), Currency: price.Currency, RecurringInterval: interval, IsRecurring: isRecurring, TrialPeriodDays: price.TrialPeriodDays.Int32, IsActive: price.IsActive, StripePriceID: stripePriceID, StripeSyncStatus: syncStatus, CreatedAt: price.CreatedAt.Format("Jan 2, 2006"), } } data.Prices = vms h.Templates.Render(w, "operator_product_prices.html", data) } // getStatusBadgeClass returns the Bootstrap badge class for a subscription status func getStatusBadgeClass(status string) string { switch status { case "active", "trialing": return "success" case "past_due", "unpaid", "incomplete": return "danger" case "canceled", "ended", "incomplete_expired", "paused": return "secondary" default: return "secondary" } } // getInvoiceStatusBadgeClass returns the Bootstrap badge class for an invoice status func getInvoiceStatusBadgeClass(status string, amountDue, amountPaid int32) string { switch status { case "paid": if amountDue == amountPaid { return "success" } return "warning" case "open", "draft": return "warning" case "void", "uncollectible": return "secondary" default: return "secondary" } } // getPaymentStatusBadgeClass returns the Bootstrap badge class for a payment status func getPaymentStatusBadgeClass(status string) string { switch status { case "succeeded": return "success" case "pending": return "warning" case "failed": return "danger" case "canceled": return "secondary" default: return "secondary" } } // getSyncStatusBadgeClass returns the Bootstrap badge class for a sync status func getSyncStatusBadgeClass(status string) string { switch status { case "synced": return "success" case "pending": return "warning" case "deleted": return "secondary" default: return "secondary" } } // GetStripeEntityURL returns the Stripe dashboard URL for an entity func (h *OperatorPartialsHandler) GetStripeEntityURL(entityType, stripeID string) string { if h.StripeDashboardURL == "" || stripeID == "" { return "" } return fmt.Sprintf("%s/%s/%s", h.StripeDashboardURL, entityType, stripeID) } // Helper to get current time for templates func now() time.Time { return time.Now() }