1530 lines
48 KiB
Go
1530 lines
48 KiB
Go
// Clavitor Corporate Admin - Clean Q&D implementation
|
|
// 95% Paddle format, 5% our extensions
|
|
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
var db *sql.DB
|
|
|
|
func main() {
|
|
var err error
|
|
db, err = sql.Open("sqlite3", "corporate.db")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Initialize schema
|
|
initDB()
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Routes
|
|
r.Get("/", handleHome)
|
|
r.Get("/customers", handleCustomersList)
|
|
r.Get("/customers/{id}", handleCustomerView)
|
|
r.Get("/customers/new", handleCustomerForm)
|
|
r.Post("/customers", handleCustomerCreate)
|
|
|
|
r.Get("/subscriptions", handleSubscriptionsList)
|
|
r.Get("/subscriptions/{id}", handleSubscriptionView)
|
|
|
|
r.Get("/transactions", handleTransactionsList)
|
|
r.Get("/transactions/{id}", handleTransactionView)
|
|
|
|
r.Get("/vaults", handleVaultsList)
|
|
r.Get("/vaults/{l0}", handleVaultView)
|
|
|
|
r.Get("/discounts", handleDiscountsList)
|
|
r.Get("/discounts/new", handleDiscountForm)
|
|
r.Post("/discounts", handleDiscountCreate)
|
|
|
|
r.Get("/events", handleEventsList)
|
|
|
|
// P0/PRF Credential Registration API
|
|
// TODO: Add SUPER DUPER SECURE POP AUTH middleware
|
|
// These handlers are defined in credentials_api.go
|
|
r.Post("/api/v1/credentials/register", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Credential API - implement in credentials_api.go", 501)
|
|
})
|
|
r.Get("/api/v1/credentials", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Credential API - implement in credentials_api.go", 501)
|
|
})
|
|
r.Get("/api/v1/credentials/wl3", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Credential API - implement in credentials_api.go", 501)
|
|
})
|
|
r.Get("/api/v1/credentials/sync", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Credential API - implement in credentials_api.go", 501)
|
|
})
|
|
|
|
// Paddle Checkout API
|
|
r.Post("/api/checkout", HandleCheckout)
|
|
r.Get("/checkout/success", HandleCheckoutSuccess)
|
|
r.Post("/webhooks/paddle", HandlePaddleWebhook)
|
|
|
|
fmt.Println("Admin server starting...")
|
|
fmt.Println("Local: http://localhost:1985")
|
|
fmt.Println("Production: https://admin.dev.clavitor.ai (via Caddy reverse proxy)")
|
|
fmt.Println("Paddle Sandbox: https://sandbox-vendors.paddle.com")
|
|
log.Fatal(http.ListenAndServe(":1985", r))
|
|
}
|
|
|
|
func initDB() {
|
|
// Read and execute schema
|
|
// For now just ensure tables exist with basic structure
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL,
|
|
name TEXT,
|
|
locale TEXT DEFAULT 'en',
|
|
status TEXT DEFAULT 'active',
|
|
marketing_consent INTEGER DEFAULT 0,
|
|
billing_email TEXT,
|
|
parent_id TEXT,
|
|
level INTEGER DEFAULT 1,
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS addresses (
|
|
id TEXT PRIMARY KEY,
|
|
customer_id TEXT NOT NULL,
|
|
country_code TEXT NOT NULL,
|
|
city TEXT,
|
|
created_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
id TEXT PRIMARY KEY,
|
|
paddle_id TEXT,
|
|
customer_id TEXT NOT NULL,
|
|
address_id TEXT,
|
|
business_id TEXT,
|
|
vault_l0 TEXT,
|
|
status TEXT,
|
|
plan TEXT,
|
|
currency_code TEXT,
|
|
collection_mode TEXT DEFAULT 'automatic',
|
|
billing_cycle TEXT,
|
|
current_billing_period TEXT,
|
|
discount TEXT,
|
|
items TEXT,
|
|
created_at INTEGER,
|
|
started_at INTEGER,
|
|
first_billed_at INTEGER,
|
|
next_billed_at INTEGER,
|
|
paused_at INTEGER,
|
|
canceled_at INTEGER,
|
|
updated_at INTEGER,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
sync_error TEXT,
|
|
last_paddle_sync_at INTEGER,
|
|
pending_since INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS subscription_items (
|
|
id TEXT PRIMARY KEY,
|
|
subscription_id TEXT NOT NULL,
|
|
price_id TEXT,
|
|
quantity INTEGER DEFAULT 1,
|
|
created_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
id TEXT PRIMARY KEY,
|
|
paddle_id TEXT,
|
|
subscription_id TEXT,
|
|
customer_id TEXT NOT NULL,
|
|
address_id TEXT,
|
|
business_id TEXT,
|
|
status TEXT,
|
|
origin TEXT,
|
|
currency_code TEXT,
|
|
amount_cents INTEGER,
|
|
details TEXT,
|
|
payments TEXT,
|
|
adjusted_totals TEXT,
|
|
payout_totals TEXT,
|
|
adjusted_payout_totals TEXT,
|
|
line_items TEXT,
|
|
invoice_number TEXT,
|
|
invoice_url TEXT,
|
|
billing_details TEXT,
|
|
additional_information TEXT,
|
|
purchase_order_number TEXT,
|
|
checkout_url TEXT,
|
|
billed_at INTEGER,
|
|
completed_at INTEGER,
|
|
revised_at INTEGER,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
processed_at INTEGER,
|
|
processing_error TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS vaults (
|
|
l0 TEXT PRIMARY KEY,
|
|
customer_id TEXT,
|
|
subscription_id TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at INTEGER,
|
|
last_seen_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS credentials (
|
|
id TEXT PRIMARY KEY,
|
|
l0 TEXT NOT NULL,
|
|
p0 TEXT NOT NULL,
|
|
wl3_path TEXT,
|
|
created_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS discounts (
|
|
id TEXT PRIMARY KEY,
|
|
paddle_id TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
description TEXT,
|
|
enabled_for_checkout INTEGER DEFAULT 0,
|
|
code TEXT,
|
|
type TEXT,
|
|
mode TEXT,
|
|
amount TEXT,
|
|
currency_code TEXT,
|
|
recur INTEGER DEFAULT 0,
|
|
maximum_recurring_intervals INTEGER,
|
|
usage_limit INTEGER,
|
|
restrict_to TEXT,
|
|
expires_at INTEGER,
|
|
times_used INTEGER DEFAULT 0,
|
|
discount_group_id TEXT,
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
sync_error TEXT,
|
|
last_paddle_sync_at INTEGER,
|
|
pending_since INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
occurred_at INTEGER,
|
|
event_type TEXT,
|
|
entity_type TEXT,
|
|
entity_id TEXT,
|
|
customer_id TEXT,
|
|
human_id INTEGER,
|
|
vault_l0 TEXT,
|
|
amount_cents INTEGER,
|
|
currency_code TEXT,
|
|
details TEXT,
|
|
source TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
paddle_event_id TEXT
|
|
);
|
|
`
|
|
_, err := db.Exec(schema)
|
|
if err != nil {
|
|
log.Printf("Schema init: %v", err)
|
|
}
|
|
|
|
// Migrate existing tables - add missing columns
|
|
migrations := []string{
|
|
// Customers table
|
|
`ALTER TABLE customers ADD COLUMN marketing_consent INTEGER DEFAULT 0`,
|
|
`ALTER TABLE customers ADD COLUMN billing_email TEXT`,
|
|
`ALTER TABLE customers ADD COLUMN paddle_id TEXT`,
|
|
`ALTER TABLE customers ADD COLUMN sync_status TEXT DEFAULT 'pending'`,
|
|
`ALTER TABLE customers ADD COLUMN sync_error TEXT`,
|
|
`ALTER TABLE customers ADD COLUMN last_paddle_sync_at INTEGER`,
|
|
`ALTER TABLE customers ADD COLUMN pending_since INTEGER`,
|
|
// Subscriptions table
|
|
`ALTER TABLE subscriptions ADD COLUMN paddle_id TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN business_id TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN billing_cycle TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN current_billing_period TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN discount TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN started_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN first_billed_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN paused_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN canceled_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN updated_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN sync_status TEXT DEFAULT 'pending'`,
|
|
`ALTER TABLE subscriptions ADD COLUMN sync_error TEXT`,
|
|
`ALTER TABLE subscriptions ADD COLUMN last_paddle_sync_at INTEGER`,
|
|
`ALTER TABLE subscriptions ADD COLUMN pending_since INTEGER`,
|
|
// Transactions table
|
|
`ALTER TABLE transactions ADD COLUMN paddle_id TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN address_id TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN business_id TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN origin TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN details TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN payments TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN adjusted_totals TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN payout_totals TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN adjusted_payout_totals TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN line_items TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN invoice_url TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN billing_details TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN additional_information TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN purchase_order_number TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN checkout_url TEXT`,
|
|
`ALTER TABLE transactions ADD COLUMN billed_at INTEGER`,
|
|
`ALTER TABLE transactions ADD COLUMN completed_at INTEGER`,
|
|
`ALTER TABLE transactions ADD COLUMN revised_at INTEGER`,
|
|
`ALTER TABLE transactions ADD COLUMN updated_at INTEGER`,
|
|
`ALTER TABLE transactions ADD COLUMN processed_at INTEGER`,
|
|
`ALTER TABLE transactions ADD COLUMN processing_error TEXT`,
|
|
// Vaults table
|
|
`ALTER TABLE vaults ADD COLUMN subscription_id TEXT`,
|
|
`ALTER TABLE vaults ADD COLUMN last_seen_at INTEGER`,
|
|
// Discounts table
|
|
`ALTER TABLE discounts ADD COLUMN paddle_id TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN enabled_for_checkout INTEGER DEFAULT 0`,
|
|
`ALTER TABLE discounts ADD COLUMN code TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN mode TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN currency_code TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN recur INTEGER DEFAULT 0`,
|
|
`ALTER TABLE discounts ADD COLUMN maximum_recurring_intervals INTEGER`,
|
|
`ALTER TABLE discounts ADD COLUMN usage_limit INTEGER`,
|
|
`ALTER TABLE discounts ADD COLUMN restrict_to TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN expires_at INTEGER`,
|
|
`ALTER TABLE discounts ADD COLUMN times_used INTEGER DEFAULT 0`,
|
|
`ALTER TABLE discounts ADD COLUMN discount_group_id TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN updated_at INTEGER`,
|
|
`ALTER TABLE discounts ADD COLUMN sync_status TEXT DEFAULT 'pending'`,
|
|
`ALTER TABLE discounts ADD COLUMN sync_error TEXT`,
|
|
`ALTER TABLE discounts ADD COLUMN last_paddle_sync_at INTEGER`,
|
|
`ALTER TABLE discounts ADD COLUMN pending_since INTEGER`,
|
|
// Events table
|
|
`ALTER TABLE events ADD COLUMN entity_type TEXT`,
|
|
`ALTER TABLE events ADD COLUMN entity_id TEXT`,
|
|
`ALTER TABLE events ADD COLUMN human_id INTEGER`,
|
|
`ALTER TABLE events ADD COLUMN vault_l0 TEXT`,
|
|
`ALTER TABLE events ADD COLUMN amount_cents INTEGER`,
|
|
`ALTER TABLE events ADD COLUMN currency_code TEXT`,
|
|
`ALTER TABLE events ADD COLUMN source TEXT`,
|
|
`ALTER TABLE events ADD COLUMN ip_address TEXT`,
|
|
`ALTER TABLE events ADD COLUMN user_agent TEXT`,
|
|
`ALTER TABLE events ADD COLUMN paddle_event_id TEXT`,
|
|
}
|
|
|
|
for _, migration := range migrations {
|
|
db.Exec(migration) // Ignore errors - columns may already exist
|
|
}
|
|
}
|
|
|
|
// ========== HOME / DASHBOARD ==========
|
|
|
|
func handleHome(w http.ResponseWriter, r *http.Request) {
|
|
stats := map[string]interface{}{
|
|
"customers": 0,
|
|
"subscriptions": 0,
|
|
"transactions": 0,
|
|
"vaults": 0,
|
|
}
|
|
|
|
var count int
|
|
db.QueryRow("SELECT COUNT(*) FROM customers").Scan(&count)
|
|
stats["customers"] = count
|
|
db.QueryRow("SELECT COUNT(*) FROM subscriptions").Scan(&count)
|
|
stats["subscriptions"] = count
|
|
db.QueryRow("SELECT COUNT(*) FROM transactions").Scan(&count)
|
|
stats["transactions"] = count
|
|
db.QueryRow("SELECT COUNT(*) FROM vaults").Scan(&count)
|
|
stats["vaults"] = count
|
|
|
|
render(w, "home", stats)
|
|
}
|
|
|
|
// ========== CUSTOMERS ==========
|
|
|
|
func handleCustomersList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT c.id, c.email, c.name, c.locale, c.status, c.marketing_consent,
|
|
c.parent_id, c.level, c.billing_email, c.custom_data,
|
|
c.created_at, c.updated_at,
|
|
COALESCE(p.email, '') as parent_email,
|
|
(SELECT COUNT(*) FROM subscriptions WHERE customer_id = c.id) as subs,
|
|
(SELECT COUNT(*) FROM addresses WHERE customer_id = c.id) as addrs,
|
|
(SELECT COUNT(*) FROM vaults WHERE customer_id = c.id) as vaults
|
|
FROM customers c
|
|
LEFT JOIN customers p ON c.parent_id = p.id
|
|
ORDER BY c.created_at DESC
|
|
LIMIT 100
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var customers []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, email, name, locale, status, billingEmail, customData, parentEmail string
|
|
var parentID string
|
|
var level, subs, addrs, vaultCount int
|
|
var marketingConsent int
|
|
var createdAt, updatedAt int64
|
|
rows.Scan(&id, &email, &name, &locale, &status, &marketingConsent,
|
|
&parentID, &level, &billingEmail, &customData, &createdAt, &updatedAt,
|
|
&parentEmail, &subs, &addrs, &vaultCount)
|
|
customers = append(customers, map[string]interface{}{
|
|
"id": id, "email": email, "name": name, "locale": locale, "status": status,
|
|
"marketing_consent": marketingConsent == 1, "level": level,
|
|
"parent_id": parentID, "parent_email": parentEmail,
|
|
"billing_email": billingEmail, "custom_data": customData,
|
|
"subs": subs, "addresses": addrs, "vaults": vaultCount,
|
|
"created": time.Unix(createdAt, 0).Format("2006-01-02"),
|
|
"updated": time.Unix(updatedAt, 0).Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
render(w, "customers", customers)
|
|
}
|
|
|
|
func handleCustomerView(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var c struct {
|
|
ID, Email, Name, Status string
|
|
Level int
|
|
ParentID string
|
|
CreatedAt int64
|
|
}
|
|
db.QueryRow("SELECT id, email, name, status, level, parent_id, created_at FROM customers WHERE id = ?", id).Scan(
|
|
&c.ID, &c.Email, &c.Name, &c.Status, &c.Level, &c.ParentID, &c.CreatedAt)
|
|
|
|
// Get subscriptions
|
|
rows, _ := db.Query("SELECT id, status, plan, currency_code, next_billed_at FROM subscriptions WHERE customer_id = ?", id)
|
|
defer rows.Close()
|
|
var subs []map[string]interface{}
|
|
for rows.Next() {
|
|
var sid, status, plan, currency string
|
|
var nextBilled int
|
|
rows.Scan(&sid, &status, &plan, ¤cy, &nextBilled)
|
|
subs = append(subs, map[string]interface{}{
|
|
"id": sid, "status": status, "plan": plan, "currency": currency,
|
|
"next_billed": time.Unix(int64(nextBilled), 0).Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
// Get addresses
|
|
addRows, _ := db.Query("SELECT id, country_code, city FROM addresses WHERE customer_id = ?", id)
|
|
defer addRows.Close()
|
|
var addresses []map[string]interface{}
|
|
for addRows.Next() {
|
|
var aid, country, city string
|
|
addRows.Scan(&aid, &country, &city)
|
|
addresses = append(addresses, map[string]interface{}{"id": aid, "country": country, "city": city})
|
|
}
|
|
|
|
render(w, "customer_view", map[string]interface{}{
|
|
"customer": c,
|
|
"subscriptions": subs,
|
|
"addresses": addresses,
|
|
})
|
|
}
|
|
|
|
func handleCustomerForm(w http.ResponseWriter, r *http.Request) {
|
|
// Get potential parent customers (MSPs)
|
|
rows, _ := db.Query("SELECT id, email, name, level FROM customers WHERE level = 1 ORDER BY name")
|
|
defer rows.Close()
|
|
var parents []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, email, name string
|
|
var level int
|
|
rows.Scan(&id, &email, &name, &level)
|
|
parents = append(parents, map[string]interface{}{"id": id, "email": email, "name": name})
|
|
}
|
|
|
|
render(w, "customer_form", parents)
|
|
}
|
|
|
|
func handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
|
|
email := r.FormValue("email")
|
|
name := r.FormValue("name")
|
|
parentID := r.FormValue("parent_id")
|
|
|
|
level := 1
|
|
if parentID != "" {
|
|
var parentLevel int
|
|
db.QueryRow("SELECT level FROM customers WHERE id = ?", parentID).Scan(&parentLevel)
|
|
level = parentLevel + 1
|
|
}
|
|
|
|
// Generate ID (ctm_ prefix for Paddle compatibility)
|
|
id := fmt.Sprintf("ctm_%d", time.Now().UnixNano())
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO customers (id, email, name, status, parent_id, level, created_at)
|
|
VALUES (?, ?, ?, 'active', ?, ?, ?)
|
|
`, id, email, name, parentID, level, time.Now().Unix())
|
|
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/customers", 302)
|
|
}
|
|
|
|
// ========== SUBSCRIPTIONS ==========
|
|
|
|
func handleSubscriptionsList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT s.id, s.paddle_id, s.customer_id, s.address_id, s.business_id,
|
|
s.currency_code, s.status, s.collection_mode, s.plan, s.vault_l0,
|
|
s.billing_cycle, s.current_billing_period, s.discount,
|
|
s.created_at, s.started_at, s.first_billed_at, s.next_billed_at,
|
|
s.paused_at, s.canceled_at, s.updated_at,
|
|
s.sync_status, s.sync_error, s.last_paddle_sync_at,
|
|
c.email as customer_email,
|
|
(SELECT COUNT(*) FROM subscription_items WHERE subscription_id = s.id) as items
|
|
FROM subscriptions s
|
|
JOIN customers c ON s.customer_id = c.id
|
|
ORDER BY s.created_at DESC
|
|
LIMIT 100
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var subs []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, paddleID, customerID, addressID, businessID, currency, status, mode, plan, vaultL0 string
|
|
var billingCycle, billingPeriod, discount, syncStatus, syncError string
|
|
var items int
|
|
var created, started, firstBilled, nextBilled, paused, canceled, updated, lastSync int64
|
|
var customerEmail string
|
|
|
|
rows.Scan(&id, &paddleID, &customerID, &addressID, &businessID,
|
|
¤cy, &status, &mode, &plan, &vaultL0,
|
|
&billingCycle, &billingPeriod, &discount,
|
|
&created, &started, &firstBilled, &nextBilled, &paused, &canceled, &updated,
|
|
&syncStatus, &syncError, &lastSync,
|
|
&customerEmail, &items)
|
|
|
|
subs = append(subs, map[string]interface{}{
|
|
"id": id, "paddle_id": paddleID, "customer_id": customerID,
|
|
"address_id": addressID, "business_id": businessID,
|
|
"currency": currency, "status": status, "mode": mode,
|
|
"plan": plan, "vault": vaultL0,
|
|
"items_count": items,
|
|
"sync_status": syncStatus,
|
|
"customer": customerEmail,
|
|
"created": formatTime(created),
|
|
"next_bill": formatTime(nextBilled),
|
|
})
|
|
}
|
|
|
|
render(w, "subscriptions", subs)
|
|
}
|
|
|
|
func handleSubscriptionView(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var s struct {
|
|
ID, CustomerID, AddressID, Status, Plan, Currency, Mode, VaultL0, Items string
|
|
Created, NextBilled int
|
|
}
|
|
db.QueryRow(`
|
|
SELECT id, customer_id, address_id, status, plan, currency_code,
|
|
collection_mode, vault_l0, items, created_at, next_billed_at
|
|
FROM subscriptions WHERE id = ?
|
|
`, id).Scan(&s.ID, &s.CustomerID, &s.AddressID, &s.Status, &s.Plan, &s.Currency, &s.Mode, &s.VaultL0, &s.Items, &s.Created, &s.NextBilled)
|
|
|
|
render(w, "subscription_view", s)
|
|
}
|
|
|
|
// ========== TRANSACTIONS ==========
|
|
|
|
func handleTransactionsList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT t.id, t.paddle_id, t.subscription_id, t.customer_id,
|
|
t.address_id, t.business_id, t.status, t.origin,
|
|
t.currency_code, t.amount_cents, t.details, t.payments,
|
|
t.adjusted_totals, t.payout_totals, t.adjusted_payout_totals,
|
|
t.line_items, t.invoice_number, t.invoice_url,
|
|
t.billing_details, t.additional_information, t.purchase_order_number,
|
|
t.checkout_url, t.billed_at, t.completed_at, t.revised_at,
|
|
t.created_at, t.updated_at, t.processed_at, t.processing_error,
|
|
c.email as customer_email
|
|
FROM transactions t
|
|
JOIN customers c ON t.customer_id = c.id
|
|
ORDER BY t.created_at DESC
|
|
LIMIT 100
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var txs []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, paddleID, subID, customerID, addressID, businessID, status, origin, currency string
|
|
var amount int
|
|
var invoiceNum, invoiceURL, checkoutURL, customerEmail string
|
|
var billed, completed, created int64
|
|
|
|
rows.Scan(&id, &paddleID, &subID, &customerID, &addressID, &businessID,
|
|
&status, &origin, ¤cy, &amount,
|
|
new(string), new(string), new(string), new(string), // Skip large JSON fields for list
|
|
&invoiceNum, &invoiceURL, new(string), new(string), new(string),
|
|
&checkoutURL, &billed, &completed,
|
|
&created, new(int64), new(int64), new(string),
|
|
&customerEmail)
|
|
|
|
txs = append(txs, map[string]interface{}{
|
|
"id": id, "paddle_id": paddleID, "subscription_id": subID,
|
|
"status": status, "origin": origin,
|
|
"currency": currency, "amount": float64(amount) / 100,
|
|
"invoice": invoiceNum, "checkout": checkoutURL != "",
|
|
"customer": customerEmail,
|
|
"created": formatTime(created),
|
|
"billed": formatTime(billed),
|
|
})
|
|
}
|
|
|
|
render(w, "transactions", txs)
|
|
}
|
|
|
|
func handleTransactionView(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
var t struct {
|
|
ID, Status, Currency, Invoice string
|
|
AmountCents int
|
|
Created int
|
|
}
|
|
db.QueryRow("SELECT id, status, amount_cents, currency_code, invoice_number, created_at FROM transactions WHERE id = ?", id).Scan(
|
|
&t.ID, &t.Status, &t.AmountCents, &t.Currency, &t.Invoice, &t.Created)
|
|
|
|
render(w, "transaction_view", t)
|
|
}
|
|
|
|
// ========== VAULTS ==========
|
|
|
|
func handleVaultsList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT v.l0, v.customer_id, v.subscription_id, v.status,
|
|
v.created_at, v.last_seen_at,
|
|
c.email as customer_email,
|
|
(SELECT COUNT(*) FROM credentials WHERE l0 = v.l0) as devices
|
|
FROM vaults v
|
|
LEFT JOIN customers c ON v.customer_id = c.id
|
|
ORDER BY v.created_at DESC
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var vaults []map[string]interface{}
|
|
for rows.Next() {
|
|
var l0, customerID, subID, status, customerEmail string
|
|
var created, lastSeen int64
|
|
var devices int
|
|
rows.Scan(&l0, &customerID, &subID, &status, &created, &lastSeen, &customerEmail, &devices)
|
|
vaults = append(vaults, map[string]interface{}{
|
|
"l0": l0, "customer_id": customerID, "subscription_id": subID,
|
|
"status": status, "customer": customerEmail,
|
|
"devices": devices,
|
|
"created": formatTime(created), "last_seen": formatTime(lastSeen),
|
|
})
|
|
}
|
|
|
|
render(w, "vaults", vaults)
|
|
}
|
|
|
|
func handleVaultView(w http.ResponseWriter, r *http.Request) {
|
|
l0 := chi.URLParam(r, "l0")
|
|
|
|
var v struct {
|
|
L0, CustomerID, Status string
|
|
Created int
|
|
}
|
|
db.QueryRow("SELECT l0, customer_id, status, created_at FROM vaults WHERE l0 = ?", l0).Scan(
|
|
&v.L0, &v.CustomerID, &v.Status, &v.Created)
|
|
|
|
// Get credentials
|
|
rows, _ := db.Query("SELECT id, p0, wl3_path, created_at FROM credentials WHERE l0 = ?", l0)
|
|
defer rows.Close()
|
|
var creds []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, p0, path string
|
|
var created int
|
|
rows.Scan(&id, &p0, &path, &created)
|
|
creds = append(creds, map[string]interface{}{
|
|
"id": id, "p0": p0, "path": path,
|
|
"created": time.Unix(int64(created), 0).Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
render(w, "vault_view", map[string]interface{}{"vault": v, "credentials": creds})
|
|
}
|
|
|
|
// ========== DISCOUNTS (MSP Margins) ==========
|
|
|
|
func handleDiscountsList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT id, paddle_id, status, description, enabled_for_checkout,
|
|
code, type, mode, amount, currency_code, recur,
|
|
maximum_recurring_intervals, usage_limit, restrict_to,
|
|
expires_at, times_used, discount_group_id,
|
|
custom_data, created_at, updated_at,
|
|
sync_status, sync_error, last_paddle_sync_at
|
|
FROM discounts
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var discounts []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, paddleID, status, desc, dtype, mode, amount, currency string
|
|
var code, restrictTo, discountGroupID, customData, syncStatus string
|
|
var enabledForCheckout, recur int
|
|
var maxRecurring, usageLimit, timesUsed int
|
|
var created int64
|
|
|
|
rows.Scan(&id, &paddleID, &status, &desc, &enabledForCheckout,
|
|
&code, &dtype, &mode, &amount, ¤cy, &recur,
|
|
&maxRecurring, &usageLimit, &restrictTo,
|
|
new(int64), ×Used, &discountGroupID,
|
|
&customData, &created, new(int64),
|
|
&syncStatus, new(string), new(int64))
|
|
|
|
discounts = append(discounts, map[string]interface{}{
|
|
"id": id, "paddle_id": paddleID, "status": status,
|
|
"description": desc, "type": dtype, "amount": amount,
|
|
"currency": currency, "recur": recur == 1,
|
|
"usage_limit": usageLimit, "times_used": timesUsed,
|
|
"code": code, "sync_status": syncStatus,
|
|
"created": formatTime(created),
|
|
})
|
|
}
|
|
|
|
render(w, "discounts", discounts)
|
|
}
|
|
|
|
func handleDiscountForm(w http.ResponseWriter, r *http.Request) {
|
|
render(w, "discount_form", nil)
|
|
}
|
|
|
|
func handleDiscountCreate(w http.ResponseWriter, r *http.Request) {
|
|
desc := r.FormValue("description")
|
|
dtype := r.FormValue("type") // percentage or flat
|
|
amount := r.FormValue("amount")
|
|
mspID := r.FormValue("msp_id") // Optional: which MSP this is for
|
|
|
|
id := fmt.Sprintf("dsc_%d", time.Now().UnixNano())
|
|
customData := ""
|
|
if mspID != "" {
|
|
customData = fmt.Sprintf(`{"msp_id": "%s", "tier": "standard"}`, mspID)
|
|
}
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO discounts (id, description, type, amount, status, custom_data, created_at)
|
|
VALUES (?, ?, ?, ?, 'active', ?, ?)
|
|
`, id, desc, dtype, amount, customData, time.Now().Unix())
|
|
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/discounts", 302)
|
|
}
|
|
|
|
// ========== EVENTS / AUDIT LOG ==========
|
|
|
|
func handleEventsList(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.Query(`
|
|
SELECT e.id, e.occurred_at, e.event_type, e.entity_type, e.entity_id,
|
|
e.customer_id, e.human_id, e.vault_l0, e.amount_cents, e.currency_code,
|
|
e.details, e.source, e.ip_address, e.user_agent,
|
|
c.email as customer_email
|
|
FROM events e
|
|
LEFT JOIN customers c ON e.customer_id = c.id
|
|
ORDER BY e.occurred_at DESC
|
|
LIMIT 200
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, eventType, entityType, entityID, customerID, vaultL0 string
|
|
var customerEmail string
|
|
var source, ipAddress, userAgent string
|
|
var occurred int64
|
|
var amountCents int
|
|
|
|
rows.Scan(&id, &occurred, &eventType, &entityType, &entityID,
|
|
&customerID, new(int64), &vaultL0, &amountCents, new(string),
|
|
new(string), &source, &ipAddress, &userAgent,
|
|
&customerEmail)
|
|
|
|
events = append(events, map[string]interface{}{
|
|
"id": id, "occurred": time.Unix(occurred, 0).Format("2006-01-02 15:04:05"),
|
|
"event": eventType, "entity": entityType, "entity_id": entityID,
|
|
"customer": customerEmail, "customer_id": customerID,
|
|
"vault_l0": vaultL0, "amount_cents": amountCents,
|
|
"source": source, "ip": ipAddress,
|
|
})
|
|
}
|
|
|
|
render(w, "events", events)
|
|
}
|
|
|
|
// ========== TEMPLATES ==========
|
|
|
|
func render(w http.ResponseWriter, name string, data interface{}) {
|
|
tmpl := template.Must(template.New(name).Parse(templates[name]))
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
var templates = map[string]string{
|
|
"home": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Clavitor Admin</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; font-weight: 500; }
|
|
h1 { margin-bottom: 10px; }
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 30px 0; }
|
|
.stat { background: #f5f5f5; padding: 25px; border-radius: 8px; text-align: center; }
|
|
.stat h2 { margin: 0; font-size: 42px; color: #0066cc; }
|
|
.stat p { margin: 10px 0 0 0; color: #666; font-size: 16px; }
|
|
.section { margin: 40px 0; }
|
|
.section h3 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Clavitor Corporate Admin</h1>
|
|
<p>95% Paddle format + 5% our extensions</p>
|
|
|
|
<div class="stats">
|
|
<div class="stat"><h2>{{.customers}}</h2><p>Customers</p></div>
|
|
<div class="stat"><h2>{{.subscriptions}}</h2><p>Subscriptions</p></div>
|
|
<div class="stat"><h2>{{.transactions}}</h2><p>Transactions</p></div>
|
|
<div class="stat"><h2>{{.vaults}}</h2><p>Vaults</p></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Quick Actions</h3>
|
|
<p><a href="/customers/new">+ New Customer</a> | <a href="/discounts/new">+ New Discount (MSP)</a></p>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
|
|
"customers": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Customers</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1400px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 13px; }
|
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.btn { display: inline-block; padding: 4px 8px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 12px; }
|
|
.level { display: inline-block; padding: 2px 6px; background: #eee; border-radius: 4px; font-size: 11px; }
|
|
.status { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.status.active { background: #d4edda; }
|
|
.status.archived { background: #f8d7da; }
|
|
.id { font-family: monospace; font-size: 11px; color: #666; }
|
|
.email { font-size: 12px; }
|
|
.count { display: inline-block; padding: 2px 6px; background: #e9ecef; border-radius: 10px; font-size: 11px; min-width: 20px; text-align: center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Customers <a href="/customers/new" class="btn">+ New</a></h1>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Email</th>
|
|
<th>Name</th>
|
|
<th>Locale</th>
|
|
<th>Status</th>
|
|
<th>Marketing</th>
|
|
<th>Level</th>
|
|
<th>Parent</th>
|
|
<th>Billing Email</th>
|
|
<th>Subs</th>
|
|
<th>Addrs</th>
|
|
<th>Vaults</th>
|
|
<th>Created</th>
|
|
<th>Updated</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="id">{{.id}}</td>
|
|
<td class="email">{{.email}}</td>
|
|
<td>{{.name}}</td>
|
|
<td>{{.locale}}</td>
|
|
<td><span class="status {{.status}}">{{.status}}</span></td>
|
|
<td>{{if .marketing_consent}}Yes{{else}}No{{end}}</td>
|
|
<td><span class="level">L{{.level}}</span></td>
|
|
<td>{{if .parent_email}}{{.parent_email}}{{else}}-{{end}}</td>
|
|
<td>{{if .billing_email}}{{.billing_email}}{{else}}-{{end}}</td>
|
|
<td><span class="count">{{.subs}}</span></td>
|
|
<td><span class="count">{{.addresses}}</span></td>
|
|
<td><span class="count">{{.vaults}}</span></td>
|
|
<td>{{.created}}</td>
|
|
<td>{{.updated}}</td>
|
|
<td><a href="/customers/{{.id}}" class="btn">View</a></td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"customer_view": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Customer</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
.section { background: #f5f5f5; padding: 20px; margin: 20px 0; border-radius: 8px; }
|
|
h2 { margin-top: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>Customer: {{.customer.Email}}</h1>
|
|
|
|
<div class="section">
|
|
<p><strong>ID:</strong> {{.customer.ID}}</p>
|
|
<p><strong>Name:</strong> {{.customer.Name}}</p>
|
|
<p><strong>Status:</strong> {{.customer.Status}}</p>
|
|
<p><strong>Level:</strong> {{.customer.Level}}</p>
|
|
<p><strong>Created:</strong> {{.customer.CreatedAt}}</p>
|
|
</div>
|
|
|
|
<h2>Subscriptions</h2>
|
|
<table>
|
|
<tr><th>ID</th><th>Plan</th><th>Status</th><th>Currency</th><th>Next Bill</th></tr>
|
|
{{range .subscriptions}}
|
|
<tr>
|
|
<td>{{.id}}</td>
|
|
<td>{{.plan}}</td>
|
|
<td>{{.status}}</td>
|
|
<td>{{.currency}}</td>
|
|
<td>{{.next_billed}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
|
|
<h2>Addresses</h2>
|
|
<table>
|
|
<tr><th>ID</th><th>Country</th><th>City</th></tr>
|
|
{{range .addresses}}
|
|
<tr><td>{{.id}}</td><td>{{.country}}</td><td>{{.city}}</td></tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"customer_form": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>New Customer</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
label { display: block; margin: 20px 0 5px 0; font-weight: 500; }
|
|
input, select { width: 100%; padding: 10px; font-size: 16px; }
|
|
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>New Customer</h1>
|
|
<form method="post" action="/customers">
|
|
<label>Email:</label>
|
|
<input type="email" name="email" required>
|
|
|
|
<label>Name:</label>
|
|
<input type="text" name="name">
|
|
|
|
<label>Parent (MSP):</label>
|
|
<select name="parent_id">
|
|
<option value="">None (Root)</option>
|
|
{{range .}}
|
|
<option value="{{.id}}">{{.email}} ({{.name}})</option>
|
|
{{end}}
|
|
</select>
|
|
|
|
<button type="submit">Create Customer</button>
|
|
</form>
|
|
</body>
|
|
</html>`,
|
|
|
|
"subscriptions": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Subscriptions</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.status { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.status.active { background: #d4edda; }
|
|
.status.past_due { background: #f8d7da; }
|
|
.status.paused { background: #fff3cd; }
|
|
.status.canceled { background: #f5f5f5; }
|
|
.id { font-family: monospace; font-size: 10px; color: #666; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
|
.mode { font-size: 11px; }
|
|
.mode.automatic { color: #28a745; }
|
|
.mode.manual { color: #fd7e14; }
|
|
.sync { font-size: 11px; }
|
|
.sync.synced { color: #28a745; }
|
|
.sync.pending { color: #fd7e14; }
|
|
.sync.error { color: #dc3545; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Subscriptions</h1>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Paddle ID</th>
|
|
<th>Customer</th>
|
|
<th>Plan</th>
|
|
<th>Currency</th>
|
|
<th>Status</th>
|
|
<th>Mode</th>
|
|
<th>Vault</th>
|
|
<th>Items</th>
|
|
<th>Sync</th>
|
|
<th>Created</th>
|
|
<th>Next Bill</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="id">{{.id}}</td>
|
|
<td class="id">{{.paddle_id}}</td>
|
|
<td>{{.customer}}</td>
|
|
<td>{{.plan}}</td>
|
|
<td>{{.currency}}</td>
|
|
<td><span class="status {{.status}}">{{.status}}</span></td>
|
|
<td><span class="mode {{.mode}}">{{.mode}}</span></td>
|
|
<td class="id">{{.vault}}</td>
|
|
<td>{{.items_count}}</td>
|
|
<td><span class="sync {{.sync_status}}">{{.sync_status}}</span></td>
|
|
<td>{{.created}}</td>
|
|
<td>{{.next_bill}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"subscription_view": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Subscription</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
.section { background: #f5f5f5; padding: 20px; margin: 20px 0; border-radius: 8px; }
|
|
pre { background: white; padding: 15px; overflow-x: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>Subscription: {{.ID}}</h1>
|
|
|
|
<div class="section">
|
|
<p><strong>Customer ID:</strong> {{.CustomerID}}</p>
|
|
<p><strong>Address ID:</strong> {{.AddressID}}</p>
|
|
<p><strong>Status:</strong> {{.Status}}</p>
|
|
<p><strong>Plan:</strong> {{.Plan}}</p>
|
|
<p><strong>Currency:</strong> {{.Currency}}</p>
|
|
<p><strong>Collection Mode:</strong> {{.Mode}}</p>
|
|
<p><strong>Vault L0:</strong> {{.VaultL0}}</p>
|
|
<p><strong>Created:</strong> {{.Created}}</p>
|
|
<p><strong>Next Bill:</strong> {{.NextBilled}}</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Items (JSON)</h3>
|
|
<pre>{{.Items}}</pre>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
|
|
"transactions": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Transactions</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.amount { font-weight: 500; text-align: right; }
|
|
.status { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.status.ready { background: #fff3cd; }
|
|
.status.billed { background: #cce5ff; }
|
|
.status.completed { background: #d4edda; }
|
|
.status.past_due { background: #f8d7da; }
|
|
.status.canceled { background: #f5f5f5; }
|
|
.id { font-family: monospace; font-size: 10px; color: #666; max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
|
|
.origin { font-size: 11px; color: #666; }
|
|
.bool { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.bool.yes { background: #d4edda; }
|
|
.bool.no { background: #f5f5f5; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Transactions</h1>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Paddle ID</th>
|
|
<th>Customer</th>
|
|
<th>Amount</th>
|
|
<th>Status</th>
|
|
<th>Origin</th>
|
|
<th>Invoice</th>
|
|
<th>Checkout</th>
|
|
<th>Created</th>
|
|
<th>Billed</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="id">{{.id}}</td>
|
|
<td class="id">{{.paddle_id}}</td>
|
|
<td>{{.customer}}</td>
|
|
<td class="amount">{{.currency}} {{printf "%.2f" .amount}}</td>
|
|
<td><span class="status {{.status}}">{{.status}}</span></td>
|
|
<td><span class="origin">{{.origin}}</span></td>
|
|
<td>{{if .invoice}}{{.invoice}}{{else}}-{{end}}</td>
|
|
<td><span class="bool {{if .checkout}}yes{{else}}no{{end}}">{{if .checkout}}Yes{{else}}No{{end}}</span></td>
|
|
<td>{{.created}}</td>
|
|
<td>{{.billed}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"transaction_view": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Transaction</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
.section { background: #f5f5f5; padding: 20px; margin: 20px 0; border-radius: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>Transaction: {{.ID}}</h1>
|
|
|
|
<div class="section">
|
|
<p><strong>Status:</strong> {{.Status}}</p>
|
|
<p><strong>Amount:</strong> {{.Currency}} {{printf "%.2f" .AmountCents}}</p>
|
|
<p><strong>Invoice:</strong> {{.Invoice}}</p>
|
|
<p><strong>Created:</strong> {{.Created}}</p>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
|
|
"vaults": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Vaults</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1400px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
|
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.l0 { font-family: monospace; font-size: 12px; }
|
|
.id { font-family: monospace; font-size: 10px; color: #666; max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
|
|
.status { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.status.active { background: #d4edda; }
|
|
.status.suspended { background: #fff3cd; }
|
|
.status.archived { background: #f8d7da; }
|
|
.devices { display: inline-block; padding: 2px 8px; background: #e9ecef; border-radius: 10px; font-size: 11px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Vaults</h1>
|
|
<table>
|
|
<tr>
|
|
<th>L0</th>
|
|
<th>Customer</th>
|
|
<th>Customer ID</th>
|
|
<th>Subscription ID</th>
|
|
<th>Devices</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Last Seen</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="l0"><a href="/vaults/{{.l0}}">{{.l0}}</a></td>
|
|
<td>{{.customer}}</td>
|
|
<td class="id">{{.customer_id}}</td>
|
|
<td class="id">{{if .subscription_id}}{{.subscription_id}}{{else}}-{{end}}</td>
|
|
<td><span class="devices">{{.devices}}</span></td>
|
|
<td><span class="status {{.status}}">{{.status}}</span></td>
|
|
<td>{{.created}}</td>
|
|
<td>{{.last_seen}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"vault_view": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Vault</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
.section { background: #f5f5f5; padding: 20px; margin: 20px 0; border-radius: 8px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }
|
|
.p0 { font-family: monospace; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>Vault: {{.vault.L0}}</h1>
|
|
|
|
<div class="section">
|
|
<p><strong>Customer ID:</strong> {{.vault.CustomerID}}</p>
|
|
<p><strong>Status:</strong> {{.vault.Status}}</p>
|
|
<p><strong>Created:</strong> {{.vault.Created}}</p>
|
|
</div>
|
|
|
|
<h2>Credentials (WL3s)</h2>
|
|
<table>
|
|
<tr><th>ID</th><th>P0</th><th>Path</th><th>Created</th></tr>
|
|
{{range .credentials}}
|
|
<tr>
|
|
<td>{{.id}}</td>
|
|
<td class="p0">{{.p0}}</td>
|
|
<td>{{.path}}</td>
|
|
<td>{{.created}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"discounts": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Discounts</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 11px; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.btn { display: inline-block; padding: 6px 12px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 12px; }
|
|
.id { font-family: monospace; font-size: 10px; color: #666; max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
|
|
.status { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.status.active { background: #d4edda; }
|
|
.status.archived { background: #f8d7da; }
|
|
.type { display: inline-block; padding: 2px 6px; background: #e9ecef; border-radius: 4px; font-size: 11px; }
|
|
.recur { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.recur.yes { background: #d4edda; }
|
|
.recur.no { background: #f5f5f5; }
|
|
.sync { font-size: 11px; }
|
|
.sync.synced { color: #28a745; }
|
|
.sync.pending { color: #fd7e14; }
|
|
.usage { font-family: monospace; font-size: 11px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Discounts <a href="/discounts/new" class="btn">+ New MSP Discount</a></h1>
|
|
<p>Use for MSP margins and promotional discounts</p>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Paddle ID</th>
|
|
<th>Description</th>
|
|
<th>Type</th>
|
|
<th>Amount</th>
|
|
<th>Currency</th>
|
|
<th>Recur</th>
|
|
<th>Usage</th>
|
|
<th>Code</th>
|
|
<th>Sync</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="id">{{.id}}</td>
|
|
<td class="id">{{.paddle_id}}</td>
|
|
<td>{{.description}}</td>
|
|
<td><span class="type">{{.type}}</span></td>
|
|
<td>{{.amount}}</td>
|
|
<td>{{.currency}}</td>
|
|
<td><span class="recur {{if .recur}}yes{{else}}no{{end}}">{{if .recur}}Yes{{else}}No{{end}}</span></td>
|
|
<td class="usage">{{.times_used}}/{{if .usage_limit}}{{.usage_limit}}{{else}}∞{{end}}</td>
|
|
<td>{{if .code}}{{.code}}{{else}}-{{end}}</td>
|
|
<td><span class="sync {{.sync_status}}">{{.sync_status}}</span></td>
|
|
<td>{{.created}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
|
|
"discount_form": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>New Discount</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
label { display: block; margin: 20px 0 5px 0; font-weight: 500; }
|
|
input, select, textarea { width: 100%; padding: 10px; font-size: 16px; }
|
|
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
|
|
<h1>New Discount (MSP Margin)</h1>
|
|
<form method="post" action="/discounts">
|
|
<label>Description:</label>
|
|
<input type="text" name="description" placeholder="e.g., Johan MSP - 30% reseller margin" required>
|
|
|
|
<label>Type:</label>
|
|
<select name="type">
|
|
<option value="percentage">Percentage</option>
|
|
<option value="flat">Flat Amount</option>
|
|
</select>
|
|
|
|
<label>Amount:</label>
|
|
<input type="text" name="amount" placeholder="30 for 30% or 1000 for $10.00" required>
|
|
|
|
<label>MSP Customer ID (optional):</label>
|
|
<input type="text" name="msp_id" placeholder="ctm_xxx - links discount to MSP">
|
|
|
|
<button type="submit">Create Discount</button>
|
|
</form>
|
|
</body>
|
|
</html>`,
|
|
|
|
"events": `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Events</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 25px; text-decoration: none; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; }
|
|
.time { font-family: monospace; white-space: nowrap; }
|
|
.id { font-family: monospace; font-size: 10px; color: #666; max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
|
|
.source { display: inline-block; padding: 2px 6px; background: #e9ecef; border-radius: 4px; font-size: 11px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/customers">Customers</a>
|
|
<a href="/subscriptions">Subscriptions</a>
|
|
<a href="/transactions">Transactions</a>
|
|
<a href="/vaults">Vaults</a>
|
|
<a href="/discounts">Discounts</a>
|
|
<a href="/events">Events</a>
|
|
</nav>
|
|
<h1>Events (Audit Log)</h1>
|
|
<table>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Event</th>
|
|
<th>Entity</th>
|
|
<th>Entity ID</th>
|
|
<th>Customer</th>
|
|
<th>Customer ID</th>
|
|
<th>Vault</th>
|
|
<th>Amount</th>
|
|
<th>Source</th>
|
|
<th>IP</th>
|
|
</tr>
|
|
{{range .}}
|
|
<tr>
|
|
<td class="time">{{.occurred}}</td>
|
|
<td>{{.event}}</td>
|
|
<td>{{.entity}}</td>
|
|
<td class="id">{{.entity_id}}</td>
|
|
<td>{{if .customer}}{{.customer}}{{else}}-{{end}}</td>
|
|
<td class="id">{{.customer_id}}</td>
|
|
<td class="id">{{if .vault_l0}}{{.vault_l0}}{{else}}-{{end}}</td>
|
|
<td>{{if .amount_cents}}{{.amount_cents}}{{else}}-{{end}}</td>
|
|
<td><span class="source">{{.source}}</span></td>
|
|
<td class="id">{{if .ip}}{{.ip}}{{else}}-{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`,
|
|
}
|
|
|
|
func formatTime(unix int64) string {
|
|
if unix == 0 {
|
|
return "-"
|
|
}
|
|
return time.Unix(unix, 0).Format("2006-01-02")
|
|
}
|