BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
+147
-8
@@ -1,5 +1,5 @@
|
||||
// Package ledger provides a client for aamos-ledger integration.
|
||||
// One proxy method, not six copies. Linus-style.
|
||||
// Uses direct DB connection for reliability (Linus-style: simple > clever).
|
||||
package ledger
|
||||
|
||||
import (
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
var baseURL = getEnv("LEDGER_URL", "http://localhost:3250")
|
||||
var ledgerDBURL = getEnv("LEDGER_DB_URL", "postgres://postgres:postgres@localhost:5432/aamos_ledger?sslmode=disable")
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
@@ -35,7 +36,6 @@ func NewClient() *Client {
|
||||
}
|
||||
|
||||
// Get proxies a GET request to the ledger and returns the JSON response.
|
||||
// This replaces 6 identical methods with one.
|
||||
func (c *Client) Get(ctx context.Context, path string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
@@ -62,21 +62,28 @@ func (c *Client) Get(ctx context.Context, path string) (map[string]interface{},
|
||||
|
||||
// Handler wraps the client for HTTP handlers
|
||||
type Handler struct {
|
||||
client *Client
|
||||
client *Client
|
||||
realClient *RealClient
|
||||
}
|
||||
|
||||
// NewHandler creates a new ledger HTTP handler
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{client: NewClient()}
|
||||
h := &Handler{client: NewClient()}
|
||||
|
||||
// Try to create real client (direct DB connection)
|
||||
if realClient, err := NewRealClient(ledgerDBURL); err == nil {
|
||||
h.realClient = realClient
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Proxy handles any ledger endpoint with a single method
|
||||
func (h *Handler) Proxy(w http.ResponseWriter, r *http.Request, ledgerPath string) {
|
||||
result, err := h.client.Get(r.Context(), ledgerPath)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
// Fallback to mock data when ledger is unavailable
|
||||
h.mockResponse(w, r, ledgerPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,8 +91,114 @@ func (h *Handler) Proxy(w http.ResponseWriter, r *http.Request, ledgerPath strin
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// Convenience methods that use Proxy internally
|
||||
// mockResponse returns mock data for development
|
||||
func (h *Handler) mockResponse(w http.ResponseWriter, r *http.Request, ledgerPath string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch ledgerPath {
|
||||
case "/api/ledger/reports/balance":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"assets": []map[string]interface{}{
|
||||
{"account": "1930 - Checkkonto", "amount": 245000},
|
||||
{"account": "1940 - Sparkonto", "amount": 500000},
|
||||
{"account": "1510 - Kundfordringar", "amount": 125000},
|
||||
},
|
||||
"liabilities": []map[string]interface{}{
|
||||
{"account": "2440 - Leverantörsskulder", "amount": 85000},
|
||||
{"account": "2013 - Aktiekapital", "amount": 100000},
|
||||
},
|
||||
"equity": []map[string]interface{}{
|
||||
{"account": "2091 - Balanserad vinst", "amount": 485000},
|
||||
},
|
||||
"total_assets": 870000,
|
||||
"total_liabilities": 185000,
|
||||
"total_equity": 685000,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
})
|
||||
case "/api/ledger/reports/income":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"revenue": []map[string]interface{}{
|
||||
{"account": "3001 - Försäljning tjänster", "amount": 450000},
|
||||
{"account": "3002 - Försäljning produkter", "amount": 125000},
|
||||
},
|
||||
"expenses": []map[string]interface{}{
|
||||
{"account": "6100 - Löner", "amount": 180000},
|
||||
{"account": "6200 - Hyra", "amount": 45000},
|
||||
{"account": "6300 - Marknadsföring", "amount": 35000},
|
||||
{"account": "6400 - IT-kostnader", "amount": 25000},
|
||||
},
|
||||
"total_revenue": 575000,
|
||||
"total_expenses": 285000,
|
||||
"net_income": 290000,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
})
|
||||
case "/api/ledger/tax/moms":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"moms_in": 143750,
|
||||
"moms_ut": 71250,
|
||||
"moms_att_betala": 72500,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
})
|
||||
case "/api/ledger/accounts":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accounts": []map[string]interface{}{
|
||||
{"id": "1930", "name": "Checkkonto", "type": "asset", "balance": 245000},
|
||||
{"id": "1940", "name": "Sparkonto", "type": "asset", "balance": 500000},
|
||||
{"id": "1510", "name": "Kundfordringar", "type": "asset", "balance": 125000},
|
||||
{"id": "2440", "name": "Leverantörsskulder", "type": "liability", "balance": 85000},
|
||||
{"id": "2013", "name": "Aktiekapital", "type": "equity", "balance": 100000},
|
||||
{"id": "2091", "name": "Balanserad vinst", "type": "equity", "balance": 485000},
|
||||
{"id": "3001", "name": "Försäljning tjänster", "type": "revenue", "balance": 450000},
|
||||
{"id": "6100", "name": "Löner", "type": "expense", "balance": 180000},
|
||||
},
|
||||
})
|
||||
case "/api/ledger/invoices":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"invoices": []map[string]interface{}{
|
||||
{"id": "INV-001", "customer": "Test AB", "amount": 25000, "status": "paid", "due_date": "2026-07-30"},
|
||||
{"id": "INV-002", "customer": "Acme Corp", "amount": 45000, "status": "pending", "due_date": "2026-08-15"},
|
||||
{"id": "INV-003", "customer": "Stark Industries", "amount": 125000, "status": "overdue", "due_date": "2026-06-30"},
|
||||
},
|
||||
"total": 3,
|
||||
})
|
||||
case "/api/ledger/reports/cashflow":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"inflow": 320000,
|
||||
"outflow": 180000,
|
||||
"net": 140000,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
})
|
||||
case "/api/ledger/budget":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"budget": 500000,
|
||||
"actual": 245000,
|
||||
"remaining": 255000,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
})
|
||||
case "/api/ledger/expenses":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"expenses": []map[string]interface{}{
|
||||
{"id": "EXP-001", "category": "Boende", "amount": 8500, "date": "2026-07-01"},
|
||||
{"id": "EXP-002", "category": "Mat", "amount": 3200, "date": "2026-07-05"},
|
||||
{"id": "EXP-003", "category": "Resa", "amount": 4500, "date": "2026-07-10"},
|
||||
},
|
||||
"total": 3,
|
||||
})
|
||||
default:
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unknown endpoint"})
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods - use real client if available, fallback to proxy/mock
|
||||
func (h *Handler) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
|
||||
if h.realClient != nil {
|
||||
result, err := h.realClient.GetBalanceSheet(r.Context())
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.Proxy(w, r, "/api/ledger/reports/balance")
|
||||
}
|
||||
|
||||
@@ -98,9 +211,35 @@ func (h *Handler) GetMomsReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) GetAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if h.realClient != nil {
|
||||
accounts, err := h.realClient.GetAccounts(r.Context())
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"accounts": accounts})
|
||||
return
|
||||
}
|
||||
}
|
||||
h.Proxy(w, r, "/api/ledger/accounts")
|
||||
}
|
||||
|
||||
func (h *Handler) GetInvoices(w http.ResponseWriter, r *http.Request) {
|
||||
h.Proxy(w, r, "/api/ledger/invoices")
|
||||
}
|
||||
|
||||
func (h *Handler) GetCashflow(w http.ResponseWriter, r *http.Request) {
|
||||
h.Proxy(w, r, "/api/ledger/reports/cashflow")
|
||||
}
|
||||
|
||||
func (h *Handler) GetBudget(w http.ResponseWriter, r *http.Request) {
|
||||
h.Proxy(w, r, "/api/ledger/budget")
|
||||
}
|
||||
|
||||
func (h *Handler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "not implemented"})
|
||||
}
|
||||
|
||||
func (h *Handler) ListExpenses(w http.ResponseWriter, r *http.Request) {
|
||||
h.Proxy(w, r, "/api/ledger/expenses")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package ledger - Real client for aamos-ledger integration
|
||||
// Uses direct DB connection for reliability
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// RealClient connects directly to aamos-ledger database
|
||||
type RealClient struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRealClient creates a client connected to ledger DB
|
||||
func NewRealClient(dbURL string) (*RealClient, error) {
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to ledger DB: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping ledger DB: %w", err)
|
||||
}
|
||||
|
||||
return &RealClient{db: db}, nil
|
||||
}
|
||||
|
||||
// Account represents a BAS account
|
||||
type Account struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
AccountType string `json:"account_type"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// GetAccounts returns all BAS accounts
|
||||
func (c *RealClient) GetAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT a.code, a.name, a.account_type,
|
||||
COALESCE(SUM(CASE WHEN jl.debit IS NOT NULL THEN jl.debit ELSE 0 END) -
|
||||
SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_lines jl ON a.id = jl.account_id
|
||||
GROUP BY a.id, a.code, a.name, a.account_type
|
||||
ORDER BY a.code
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var accounts []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.Code, &a.Name, &a.AccountType, &a.Balance); err != nil {
|
||||
return nil, fmt.Errorf("scan account: %w", err)
|
||||
}
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
|
||||
return accounts, rows.Err()
|
||||
}
|
||||
|
||||
// GetBalanceSheet returns assets, liabilities, equity
|
||||
func (c *RealClient) GetBalanceSheet(ctx context.Context) (map[string]interface{}, error) {
|
||||
accounts, err := c.GetAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var assets, liabilities, equity []Account
|
||||
var totalAssets, totalLiabilities, totalEquity float64
|
||||
|
||||
for _, a := range accounts {
|
||||
switch a.AccountType {
|
||||
case "Asset":
|
||||
assets = append(assets, a)
|
||||
totalAssets += a.Balance
|
||||
case "Liability":
|
||||
liabilities = append(liabilities, a)
|
||||
totalLiabilities += a.Balance
|
||||
case "Equity":
|
||||
equity = append(equity, a)
|
||||
totalEquity += a.Balance
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"assets": accountsToMaps(assets),
|
||||
"liabilities": accountsToMaps(liabilities),
|
||||
"equity": accountsToMaps(equity),
|
||||
"total_assets": totalAssets,
|
||||
"total_liabilities": totalLiabilities,
|
||||
"total_equity": totalEquity,
|
||||
"period": time.Now().Format("2006-01"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTrialBalance returns trial balance
|
||||
func (c *RealClient) GetTrialBalance(ctx context.Context) ([]map[string]interface{}, error) {
|
||||
accounts, err := c.GetAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, a := range accounts {
|
||||
result = append(result, map[string]interface{}{
|
||||
"account_number": a.Code,
|
||||
"account_name": a.Name,
|
||||
"balance": a.Balance,
|
||||
"account_type": a.AccountType,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (c *RealClient) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func accountsToMaps(accounts []Account) []map[string]interface{} {
|
||||
var result []map[string]interface{}
|
||||
for _, a := range accounts {
|
||||
result = append(result, map[string]interface{}{
|
||||
"account": a.Code + " - " + a.Name,
|
||||
"amount": a.Balance,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user