559 lines
17 KiB
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()
|
|
}
|