Files
member-console/internal/server/operator_billing.go

559 lines
17 KiB
Go

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()
}