BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup

This commit is contained in:
Bernt
2026-07-28 23:08:32 +00:00
parent 0b4f160af1
commit af874040ca
11541 changed files with 1654104 additions and 1103 deletions
+16 -13
View File
@@ -1,5 +1,7 @@
# Build stage
FROM golang:1.25-alpine AS builder
# BOC Backend Dockerfile
# Multi-stage build for minimal image
FROM golang:1.22-alpine AS builder
WORKDIR /app
@@ -10,31 +12,32 @@ RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
# Copy source
COPY . .
# Build the binary
# Build
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o boc .
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates wget
RUN apk --no-cache add ca-certificates
WORKDIR /root/
WORKDIR /app
# Copy binary from builder
# Copy binary
COPY --from=builder /app/boc .
# Copy migrations
COPY --from=builder /app/db/migrations ./db/migrations
# Expose port
# Create non-root user
RUN addgroup -g 1000 -S boc && \
adduser -u 1000 -S boc -G boc
USER boc
EXPOSE 9092
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:9092/health || exit 1
CMD wget -qO- http://localhost:9092/health || exit 1
# Run the binary
CMD ["./boc"]
+1
View File
@@ -7,3 +7,4 @@ RprzZGLlOpwCslfvNFrz6vB9HnUxYHIPexB54YwTtUZjpoz+Um/A5y6nAn94P/E5
RqTqVp80vHPpTXL/KSOwU6E8NQYHWPhp1eziiq0hfTOZeDzZIeDKn+tHNwBiU71q
KQIDAQAB
-----END PUBLIC KEY-----
+71 -2
View File
@@ -3,13 +3,18 @@ package auth
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog/log"
)
// RS256Service validates RS256 JWT tokens using a public key
@@ -20,6 +25,66 @@ type RS256Service struct {
audience string
}
// JWKS represents a JSON Web Key Set
type JWKS struct {
Keys []JWK `json:"keys"`
}
// JWK represents a JSON Web Key
type JWK struct {
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
}
// NewRS256ServiceFromURL fetches JWKS from URL and creates RS256Service
func NewRS256ServiceFromURL(jwksURL string) (*RS256Service, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("JWKS endpoint returned %d", resp.StatusCode)
}
var jwks JWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %w", err)
}
if len(jwks.Keys) == 0 {
return nil, fmt.Errorf("no keys in JWKS")
}
// Use first signing key
key := jwks.Keys[0]
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, fmt.Errorf("failed to decode N: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, fmt.Errorf("failed to decode E: %w", err)
}
pub := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: int(new(big.Int).SetBytes(eBytes).Int64()),
}
return &RS256Service{
publicKey: pub,
issuer: "prexo-identity",
audience: "prexo",
}, nil
}
// NewRS256Service loads the public key from a PEM file
func NewRS256Service(publicKeyPath string) (*RS256Service, error) {
pemData, err := os.ReadFile(publicKeyPath)
@@ -71,6 +136,7 @@ func (s *RS256Service) Middleware() func(http.Handler) http.Handler {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := s.ValidateToken(tokenString)
if err != nil {
log.Warn().Err(err).Msg("token validation failed")
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
@@ -89,8 +155,11 @@ func (s *RS256Service) ValidateToken(tokenString string) (*Claims, error) {
}
return s.publicKey, nil
})
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid token: %w", err)
if err != nil {
return nil, fmt.Errorf("token parse error: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("token invalid")
}
mapClaims, ok := token.Claims.(jwt.MapClaims)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY event-stream /usr/local/bin/
EXPOSE 9097
CMD ["event-stream"]
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
module event-stream
go 1.25.10
require (
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/segmentio/kafka-go v0.4.51 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/segmentio/kafka-go"
)
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
CompanyID string `json:"company_id,omitempty"`
JobID string `json:"job_id,omitempty"`
Amount float64 `json:"amount,omitempty"`
Currency string `json:"currency,omitempty"`
Status string `json:"status"`
Country string `json:"country,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
RiskScore float64 `json:"risk_score,omitempty"`
AnomalyDetected bool `json:"anomaly_detected,omitempty"`
}
type EventStore struct {
kafkaWriter *kafka.Writer
}
func NewEventStore(brokers []string) *EventStore {
// Use explicit dialer to avoid DNS issues
dialer := &kafka.Dialer{
Timeout: 10 * time.Second,
DualStack: true,
}
return &EventStore{
kafkaWriter: &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: "quixzoom.events",
Balancer: &kafka.LeastBytes{},
Dialer: dialer,
},
}
}
func (s *EventStore) PublishEvent(ctx context.Context, event Event) error {
event.Timestamp = time.Now().UTC()
if event.EventID == "" {
event.EventID = fmt.Sprintf("evt_%d", time.Now().UnixNano())
}
data, err := json.Marshal(event)
if err != nil {
return err
}
return s.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(event.EventType),
Value: data,
})
}
func (s *EventStore) Close() error {
return s.kafkaWriter.Close()
}
func main() {
brokers := os.Getenv("KAFKA_BROKERS")
if brokers == "" {
brokers = "172.24.0.5:29092"
}
store := NewEventStore([]string{brokers})
defer store.Close()
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Ingest event
r.Post("/api/v1/events", func(w http.ResponseWriter, r *http.Request) {
var event Event
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
if event.EventType == "" {
http.Error(w, `{"error":"event_type required"}`, http.StatusBadRequest)
return
}
// Anomaly detection (simple rule-based)
if event.Amount > 100000 {
event.RiskScore = 0.8
event.AnomalyDetected = true
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := store.PublishEvent(ctx, event); err != nil {
log.Printf("Error publishing event: %v", err)
http.Error(w, `{"error":"failed to publish"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"status": "published",
"event_id": event.EventID,
})
})
// Search events (placeholder - would query Elasticsearch)
r.Get("/api/v1/events/search", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
eventType := r.URL.Query().Get("type")
userID := r.URL.Query().Get("user_id")
// TODO: Query Elasticsearch
json.NewEncoder(w).Encode(map[string]interface{}{
"query": query,
"type": eventType,
"user_id": userID,
"results": []Event{},
"total": 0,
"note": "Elasticsearch integration pending",
})
})
port := os.Getenv("PORT")
if port == "" {
port = "9097"
}
log.Printf("Event streaming server starting on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}
+8
View File
@@ -0,0 +1,8 @@
module sie4-import
go 1.25.10
require (
github.com/google/uuid v1.6.0 // indirect
github.com/lib/pq v1.12.3 // indirect
)
+4
View File
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"bufio"
"database/sql"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
type SIE4Parser struct {
accounts map[string]string // account_number -> name
ib map[string]float64 // account_number -> opening balance
ub map[string]float64 // account_number -> closing balance
vouchers []Voucher
companyID uuid.UUID
tenantID uuid.UUID
db *sql.DB
}
type Voucher struct {
Series string
Number int
Date time.Time
Description string
Transactions []Transaction
}
type Transaction struct {
Account string
Amount float64
}
func main() {
if len(os.Args) < 2 {
log.Fatal("Usage: sie4-import <sie-file>")
}
db, err := sql.Open("postgres", os.Getenv("DB_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
parser := &SIE4Parser{
accounts: make(map[string]string),
ib: make(map[string]float64),
ub: make(map[string]float64),
db: db,
}
// Get LandveX AB company ID
var companyIDStr string
err = db.QueryRow("SELECT id FROM boc_companies WHERE org_number = $1", "559141-7042").Scan(&companyIDStr)
if err != nil {
log.Fatal("LandveX AB not found:", err)
}
parser.companyID = uuid.MustParse(companyIDStr)
var tenantIDStr string
err = db.QueryRow("SELECT tenant_id FROM boc_companies WHERE id = $1", companyIDStr).Scan(&tenantIDStr)
if err != nil {
log.Fatal("Tenant not found:", err)
}
parser.tenantID = uuid.MustParse(tenantIDStr)
// Parse SIE4 file
file, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentVoucher *Voucher
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#KONTO") {
parts := strings.Fields(line)
if len(parts) >= 3 {
accNum := parts[1]
name := strings.Trim(strings.Join(parts[2:], " "), "\"")
parser.accounts[accNum] = name
}
} else if strings.HasPrefix(line, "#IB") {
parts := strings.Fields(line)
if len(parts) >= 4 {
accNum := parts[2]
amount, _ := strconv.ParseFloat(parts[3], 64)
parser.ib[accNum] = amount
}
} else if strings.HasPrefix(line, "#UB") {
parts := strings.Fields(line)
if len(parts) >= 4 {
accNum := parts[2]
amount, _ := strconv.ParseFloat(parts[3], 64)
parser.ub[accNum] = amount
}
} else if strings.HasPrefix(line, "#VER") {
if currentVoucher != nil && len(currentVoucher.Transactions) > 0 {
parser.vouchers = append(parser.vouchers, *currentVoucher)
}
parts := strings.Fields(line)
if len(parts) >= 5 {
series := parts[1]
number, _ := strconv.Atoi(parts[2])
dateStr := parts[3]
date, _ := time.Parse("20060102", dateStr)
desc := strings.Trim(strings.Join(parts[4:], " "), "\"")
currentVoucher = &Voucher{
Series: series,
Number: number,
Date: date,
Description: desc,
}
}
} else if strings.HasPrefix(line, "#TRANS") {
if currentVoucher != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
accNum := parts[1]
amount, _ := strconv.ParseFloat(parts[3], 64)
currentVoucher.Transactions = append(currentVoucher.Transactions, Transaction{
Account: accNum,
Amount: amount,
})
}
}
}
}
if currentVoucher != nil && len(currentVoucher.Transactions) > 0 {
parser.vouchers = append(parser.vouchers, *currentVoucher)
}
fmt.Printf("Parsed %d accounts, %d vouchers\n", len(parser.accounts), len(parser.vouchers))
// Import to database
parser.importAccounts()
parser.importVouchers()
parser.importBalances()
fmt.Println("Import complete")
}
func (p *SIE4Parser) importAccounts() {
for accNum, name := range p.accounts {
_, err := p.db.Exec(`
INSERT INTO boc_chart_of_accounts (company_id, account_code, name, account_type, is_active)
VALUES ($1, $2, $3, 'asset', true)
ON CONFLICT (company_id, account_code) DO UPDATE SET name = $3
`, p.companyID, accNum, name)
if err != nil {
log.Printf("Error importing account %s: %v", accNum, err)
}
}
fmt.Printf("Imported %d accounts\n", len(p.accounts))
}
func (p *SIE4Parser) importVouchers() {
for _, v := range p.vouchers {
var entryID uuid.UUID
err := p.db.QueryRow(`
INSERT INTO boc_journal_entries (company_id, entry_number, entry_date, description, source, status, posted_at)
VALUES ($1, $2, $3, $4, 'import', 'posted', NOW())
RETURNING id
`, p.companyID, fmt.Sprintf("%s%d", v.Series, v.Number), v.Date, v.Description).Scan(&entryID)
if err != nil {
log.Printf("Error importing voucher %s%d: %v", v.Series, v.Number, err)
continue
}
for _, t := range v.Transactions {
// Get account ID
var accountID uuid.UUID
err := p.db.QueryRow(`
SELECT id FROM boc_chart_of_accounts
WHERE company_id = $1 AND account_code = $2
`, p.companyID, t.Account).Scan(&accountID)
if err != nil {
log.Printf("Account not found: %s", t.Account)
continue
}
var debit, credit float64
if t.Amount > 0 {
debit = t.Amount
} else {
credit = -t.Amount
}
_, err = p.db.Exec(`
INSERT INTO boc_journal_lines (company_id, entry_id, account_id, debit, credit, description)
VALUES ($1, $2, $3, $4, $5, $6)
`, p.companyID, entryID, accountID, debit, credit, v.Description)
if err != nil {
log.Printf("Error importing line: %v", err)
}
}
}
fmt.Printf("Imported %d vouchers\n", len(p.vouchers))
}
func (p *SIE4Parser) importBalances() {
fiscalYear := 2026
for accNum, amount := range p.ub {
var accountID uuid.UUID
err := p.db.QueryRow(`
SELECT id FROM boc_chart_of_accounts
WHERE company_id = $1 AND account_code = $2
`, p.companyID, accNum).Scan(&accountID)
if err != nil {
continue
}
_, err = p.db.Exec(`
INSERT INTO boc_period_balances (company_id, account_id, fiscal_year, period, closing_balance)
VALUES ($1, $2, $3, 0, $4)
ON CONFLICT (company_id, account_id, fiscal_year, period)
DO UPDATE SET closing_balance = $4
`, p.companyID, accountID, fiscalYear, amount)
if err != nil {
log.Printf("Error importing balance for %s: %v", accNum, err)
}
}
fmt.Println("Imported balances")
}
Binary file not shown.
+11 -4
View File
@@ -12,21 +12,28 @@ require (
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/zerolog v1.35.1
github.com/segmentio/kafka-go v0.4.47
github.com/stretchr/testify v1.8.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.51.0
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/rs/xid v1.6.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+23
View File
@@ -1,5 +1,7 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -7,6 +9,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,12 +31,16 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
@@ -40,6 +48,14 @@ github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
@@ -55,6 +71,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@@ -78,6 +95,7 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -92,6 +110,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -106,11 +126,14 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+122 -99
View File
@@ -4,10 +4,9 @@ import (
"encoding/json"
"net/http"
"os"
"time"
)
var ledgerBaseURL = getEnv("LEDGER_URL", "http://localhost:3250")
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -15,6 +14,8 @@ func getEnv(key, fallback string) string {
return fallback
}
var ledgerBaseURL = getEnv("LEDGER_URL", "http://172.17.0.1:3250")
// LedgerClient handles communication with aamos-ledger
type LedgerClient struct {
BaseURL string
@@ -37,19 +38,39 @@ func NewLedgerFinanceHandler() *LedgerFinanceHandler {
return &LedgerFinanceHandler{Client: NewLedgerClient()}
}
// GetBalanceSheet returns trial balance from ledger
func (h *LedgerFinanceHandler) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/reports/balance")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
period := r.URL.Query().Get("period")
if period == "" {
period = time.Now().Format("2006-01")
}
resp, err := h.Client.Get("/api/ledger/trial-balance?period=" + period)
if err != nil || resp.StatusCode != 200 {
// Fallback to mock data
w.Header().Set("Content-Type", "application/json")
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": period,
})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
@@ -60,91 +81,60 @@ func (h *LedgerFinanceHandler) GetBalanceSheet(w http.ResponseWriter, r *http.Re
json.NewEncoder(w).Encode(result)
}
// GetIncomeStatement returns income statement (not yet implemented in ledger)
func (h *LedgerFinanceHandler) GetIncomeStatement(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/reports/income")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
return
}
// Return mock data until ledger implements this
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
json.NewEncoder(w).Encode(map[string]interface{}{
"revenue": 245000,
"expenses": 180000,
"net_income": 65000,
"period": time.Now().Format("2006-01"),
})
}
// GetMomsReport returns VAT report (not yet implemented in ledger)
func (h *LedgerFinanceHandler) GetMomsReport(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/tax/moms")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
json.NewEncoder(w).Encode(map[string]interface{}{
"moms_in": 61250,
"moms_ut": 35000,
"moms_att_betala": 26250,
"period": time.Now().Format("2006-01"),
})
}
// GetAccounts returns chart of accounts from ledger
func (h *LedgerFinanceHandler) GetAccounts(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/accounts")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
resp, err := h.Client.Get("/api/v1/accounts")
if err != nil || resp.StatusCode != 200 {
// Fallback to mock data
w.Header().Set("Content-Type", "application/json")
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},
},
})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func (h *LedgerFinanceHandler) GetCustomers(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/customers")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
var accounts []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"accounts": accounts})
return
}
@@ -152,25 +142,58 @@ func (h *LedgerFinanceHandler) GetCustomers(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(result)
}
// GetInvoices returns invoices (mock until implemented)
func (h *LedgerFinanceHandler) GetInvoices(w http.ResponseWriter, r *http.Request) {
resp, err := h.Client.Get("/api/ledger/invoices")
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
writeError(w, resp.StatusCode, "ledger error")
return
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
writeError(w, http.StatusInternalServerError, "decode error")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
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"},
},
"total": 2,
})
}
// GetCashflow returns cashflow (mock until implemented)
func (h *LedgerFinanceHandler) GetCashflow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"inflow": 320000,
"outflow": 180000,
"net": 140000,
"period": time.Now().Format("2006-01"),
})
}
// GetBudget returns budget (mock until implemented)
func (h *LedgerFinanceHandler) GetBudget(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"budget": 500000,
"actual": 245000,
"remaining": 255000,
"period": time.Now().Format("2006-01"),
})
}
// CreateExpense creates an expense (mock until implemented)
func (h *LedgerFinanceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "EXP-001",
"status": "created",
})
}
// ListExpenses lists expenses (mock until implemented)
func (h *LedgerFinanceHandler) ListExpenses(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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"},
},
"total": 2,
})
}
+134
View File
@@ -0,0 +1,134 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
)
// MockLedgerHandler provides mock data when real ledger is unavailable
type MockLedgerHandler struct{}
func NewMockLedgerHandler() *MockLedgerHandler {
return &MockLedgerHandler{}
}
func (h *MockLedgerHandler) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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"),
})
}
func (h *MockLedgerHandler) GetIncomeStatement(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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"),
})
}
func (h *MockLedgerHandler) GetMomsReport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"moms_in": 143750,
"moms_ut": 71250,
"moms_att_betala": 72500,
"period": time.Now().Format("2006-01"),
})
}
func (h *MockLedgerHandler) GetAccounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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},
},
})
}
func (h *MockLedgerHandler) GetInvoices(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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,
})
}
func (h *MockLedgerHandler) GetCashflow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"inflow": 320000,
"outflow": 180000,
"net": 140000,
"period": time.Now().Format("2006-01"),
})
}
func (h *MockLedgerHandler) GetBudget(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"budget": 500000,
"actual": 245000,
"remaining": 255000,
"period": time.Now().Format("2006-01"),
})
}
func (h *MockLedgerHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "EXP-001",
"status": "created",
})
}
func (h *MockLedgerHandler) ListExpenses(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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,
})
}
+1 -1
View File
@@ -28,7 +28,7 @@ type Contract struct {
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
RenewalDate *time.Time `json:"renewal_date"`
DocumentURL string `json:"document_url"`
DocumentURL *string `json:"document_url"`
CreatedAt time.Time `json:"created_at"`
}
+1 -1
View File
@@ -51,7 +51,7 @@ type Product struct {
func (h *SalesHandler) ListDeals(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
if status == "" {
status = "open"
status = "active"
}
rows, err := h.DB.Query(`
+180
View File
@@ -0,0 +1,180 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"sync"
)
// SettingsHandler manages system configuration
type SettingsHandler struct {
mu sync.RWMutex
settings map[string]interface{}
path string
}
func NewSettingsHandler() *SettingsHandler {
h := &SettingsHandler{
settings: make(map[string]interface{}),
path: "/app/config/system.json",
}
h.load()
return h
}
func (h *SettingsHandler) load() {
data, err := os.ReadFile(h.path)
if err != nil {
// Use defaults
h.settings = h.defaultSettings()
return
}
json.Unmarshal(data, &h.settings)
}
func (h *SettingsHandler) save() error {
data, err := json.MarshalIndent(h.settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(h.path, data, 0644)
}
func (h *SettingsHandler) defaultSettings() map[string]interface{} {
return map[string]interface{}{
"appearance": map[string]interface{}{
"theme": "light",
"density": "comfortable",
"sidebar_width": 240,
"animations": true,
},
"dashboard": map[string]interface{}{
"greeting_enabled": true,
"kpi_refresh": 300,
"activity_max": 10,
},
"notifications": map[string]interface{}{
"in_app": true,
"email": false,
"slack": false,
},
"advanced": map[string]interface{}{
"api_rate_limit": 1000,
"cache_ttl": 300,
"log_level": "info",
"export_max_rows": 10000,
},
}
}
// GetSettings returns all settings
func (h *SettingsHandler) GetSettings(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.settings)
}
// UpdateSettings updates settings
func (h *SettingsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
h.mu.Lock()
defer h.mu.Unlock()
// Merge updates
for key, value := range updates {
h.settings[key] = value
}
if err := h.save(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save settings")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.settings)
}
// GetModuleConfig returns module configuration
func (h *SettingsHandler) GetModuleConfig(w http.ResponseWriter, r *http.Request) {
module := r.URL.Query().Get("module")
if module == "" {
writeError(w, http.StatusBadRequest, "module required")
return
}
h.mu.RLock()
defer h.mu.RUnlock()
modules, ok := h.settings["modules"].(map[string]interface{})
if !ok {
writeError(w, http.StatusNotFound, "modules not configured")
return
}
config, ok := modules[module]
if !ok {
writeError(w, http.StatusNotFound, "module not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
// ToggleModule enables/disables a module
func (h *SettingsHandler) ToggleModule(w http.ResponseWriter, r *http.Request) {
module := r.URL.Query().Get("module")
if module == "" {
writeError(w, http.StatusBadRequest, "module required")
return
}
h.mu.Lock()
defer h.mu.Unlock()
modules, ok := h.settings["modules"].(map[string]interface{})
if !ok {
modules = make(map[string]interface{})
h.settings["modules"] = modules
}
config, ok := modules[module].(map[string]interface{})
if !ok {
config = map[string]interface{}{"enabled": false}
}
// Toggle enabled state
if enabled, ok := config["enabled"].(bool); ok {
config["enabled"] = !enabled
} else {
config["enabled"] = true
}
modules[module] = config
if err := h.save(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.GetSettings(w, r)
case http.MethodPut:
h.UpdateSettings(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ type TicketComment struct {
func (h *SupportHandler) ListTickets(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
if status == "" {
status = "open"
status = "active"
}
rows, err := h.DB.Query(`
+147 -8
View File
@@ -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")
}
+142
View File
@@ -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
}
+159 -33
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
@@ -10,10 +11,13 @@ import (
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"boc/auth"
"boc/automation"
"boc/config"
"boc/db"
"boc/handlers"
@@ -22,6 +26,17 @@ import (
"boc/store"
)
// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func main() {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
cfg := config.Load()
@@ -38,19 +53,53 @@ func main() {
_ = store.New(database)
// RS256 auth service (AAMOS standard)
var authService *auth.RS256Service
if _, err := os.Stat("auth/jwt-public.pem"); err == nil {
authService, err = auth.NewRS256Service("auth/jwt-public.pem")
if err != nil {
logger.Warn().Err(err).Msg("RS256 init failed, falling back to HS256")
}
// Initialize handlers
crmH := handlers.NewCRMHandler(database)
salesH := handlers.NewSalesHandler(database)
hrH := handlers.NewHRHandler(database)
legalH := handlers.NewLegalHandler(database)
marketingH := handlers.NewMarketingHandler(database)
supportH := handlers.NewSupportHandler(database)
analyticsH := handlers.NewAnalyticsHandler(database)
ledgerH := ledger.NewHandler()
// Automation engine
autoEngine := automation.NewEngine(database, logger)
autoH := handlers.NewAutomationHandler(database, autoEngine)
// Auth: Try RS256 (Ouroboros) first, fall back to HS256
var authMiddleware func(http.Handler) http.Handler
// Try RS256 from Ouroboros JWKS
rs256Service, err := auth.NewRS256ServiceFromURL("http://localhost:3208/.well-known/jwks.json")
if err != nil {
logger.Warn().Err(err).Msg("RS256 init failed, using HS256 fallback")
// Fallback to HS256
hs256Service := auth.NewService(database, cfg.JWTSecret)
authMiddleware = hs256Service.Middleware()
} else {
logger.Info().Msg("RS256 auth service initialized from Ouroboros")
authMiddleware = rs256Service.Middleware()
}
// HS256 fallback for local dev
_ = auth.NewService(database, cfg.JWTSecret)
ledgerH := ledger.NewHandler()
// Prometheus metrics
requestDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "boc_request_duration_seconds",
Help: "Request duration in seconds",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
}, []string{"method", "path", "status"})
requestCount := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "boc_request_total",
Help: "Total requests",
}, []string{"method", "path", "status"})
activeUsers := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "boc_active_users",
Help: "Currently active users",
})
prometheus.MustRegister(requestDuration, requestCount, activeUsers)
r := chi.NewRouter()
r.Use(middleware.CORS)
@@ -58,30 +107,32 @@ func main() {
r.Use(hlog.RequestIDHandler("req_id", "X-Request-ID"))
r.Use(middleware.Logger(logger))
r.Use(chimw.Recoverer)
r.Get("/health", handlers.NewHealthHandler())
// Auth endpoints
r.Post("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
// Try RS256 first, fall back to HS256
if authService != nil {
// Forward to ouroboros-identity for RS256 tokens
http.Redirect(w, r, "http://localhost:3208/api/auth/token", http.StatusTemporaryRedirect)
return
}
// Local HS256 fallback
hs256AuthHandler := &handlers.AuthHandler{DB: database, JWTSecret: []byte(cfg.JWTSecret)}
hs256AuthHandler.Login(w, r)
// Metrics middleware
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, req)
duration := time.Since(start).Seconds()
requestDuration.WithLabelValues(req.Method, req.URL.Path, fmt.Sprintf("%d", rw.statusCode)).Observe(duration)
requestCount.WithLabelValues(req.Method, req.URL.Path, fmt.Sprintf("%d", rw.statusCode)).Inc()
})
})
r.Group(func(r chi.Router) {
// Use RS256 if available, otherwise HS256
if authService != nil {
r.Use(authService.Middleware())
} else {
r.Use(middleware.Auth(cfg))
}
r.Get("/health", handlers.NewHealthHandler())
r.Get("/metrics", promhttp.Handler().ServeHTTP)
// Auth endpoints (no auth required)
r.Post("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
// Forward to Ouroboros for RS256 tokens
http.Redirect(w, r, "http://localhost:3208/api/auth/token", http.StatusTemporaryRedirect)
})
// Protected routes
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
// Auth me
r.Get("/api/v1/auth/me", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.FromContext(r.Context())
if !ok {
@@ -92,12 +143,87 @@ func main() {
w.Write([]byte(`{"user":{"sub":"` + claims.Sub + `","email":"` + claims.Email + `","roles":[]}}`))
})
// Ledger (proxy to aamos-ledger)
// CRM
r.Get("/api/v1/crm/customers", crmH.ListCustomers)
r.Post("/api/v1/crm/customers", crmH.CreateCustomer)
r.Get("/api/v1/crm/customers/{id}", crmH.GetCustomer)
r.Put("/api/v1/crm/customers/{id}", crmH.UpdateCustomer)
r.Delete("/api/v1/crm/customers/{id}", crmH.DeleteCustomer)
r.Get("/api/v1/crm/leads", crmH.ListLeads)
r.Get("/api/v1/crm/pipeline", crmH.GetPipeline)
r.Post("/api/v1/crm/interactions", crmH.CreateInteraction)
r.Get("/api/v1/crm/customers/{id}/interactions", crmH.GetCustomerInteractions)
// Sales
r.Get("/api/v1/sales/deals", salesH.ListDeals)
r.Post("/api/v1/sales/deals", salesH.CreateDeal)
r.Get("/api/v1/sales/deals/{id}", salesH.GetDeal)
r.Put("/api/v1/sales/deals/{id}", salesH.UpdateDeal)
r.Get("/api/v1/sales/mrr", salesH.GetMRR)
r.Get("/api/v1/sales/arr", salesH.GetARR)
r.Get("/api/v1/sales/products", salesH.ListProducts)
r.Post("/api/v1/sales/products", salesH.CreateProduct)
// Finance (Ledger integration)
r.Get("/api/v1/finance/balance", ledgerH.GetBalanceSheet)
r.Get("/api/v1/finance/income", ledgerH.GetIncomeStatement)
r.Get("/api/v1/finance/moms", ledgerH.GetMomsReport)
r.Get("/api/v1/finance/accounts", ledgerH.GetAccounts)
r.Get("/api/v1/finance/invoices", ledgerH.GetInvoices)
r.Get("/api/v1/finance/cashflow", ledgerH.GetCashflow)
r.Get("/api/v1/finance/budget", ledgerH.GetBudget)
r.Post("/api/v1/finance/expenses", ledgerH.CreateExpense)
r.Get("/api/v1/finance/expenses", ledgerH.ListExpenses)
// HR
r.Get("/api/v1/hr/employees", hrH.ListEmployees)
r.Post("/api/v1/hr/employees", hrH.CreateEmployee)
r.Get("/api/v1/hr/employees/{id}", hrH.GetEmployee)
r.Put("/api/v1/hr/employees/{id}", hrH.UpdateEmployee)
r.Get("/api/v1/hr/leaves", hrH.ListLeaves)
r.Post("/api/v1/hr/leaves", hrH.CreateLeave)
r.Get("/api/v1/hr/timesheets", hrH.ListTimesheets)
r.Post("/api/v1/hr/timesheets", hrH.CreateTimesheet)
// Legal
r.Get("/api/v1/legal/contracts", legalH.ListContracts)
r.Post("/api/v1/legal/contracts", legalH.CreateContract)
r.Get("/api/v1/legal/contracts/{id}", legalH.GetContract)
r.Put("/api/v1/legal/contracts/{id}", legalH.UpdateContract)
r.Get("/api/v1/legal/reminders", legalH.ListReminders)
// Marketing
r.Get("/api/v1/marketing/campaigns", marketingH.ListCampaigns)
r.Post("/api/v1/marketing/campaigns", marketingH.CreateCampaign)
r.Get("/api/v1/marketing/content", marketingH.ListContent)
r.Post("/api/v1/marketing/content", marketingH.CreateContent)
// Support
r.Get("/api/v1/support/tickets", supportH.ListTickets)
r.Post("/api/v1/support/tickets", supportH.CreateTicket)
r.Get("/api/v1/support/tickets/{id}", supportH.GetTicket)
r.Put("/api/v1/support/tickets/{id}", supportH.UpdateTicket)
r.Post("/api/v1/support/tickets/{id}/comments", supportH.AddComment)
r.Get("/api/v1/support/csat", supportH.GetCSAT)
// Analytics
r.Get("/api/v1/analytics/users", analyticsH.GetActiveUsers)
r.Get("/api/v1/analytics/revenue", analyticsH.GetRevenue)
r.Get("/api/v1/analytics/retention", analyticsH.GetRetention)
r.Get("/api/v1/analytics/dashboard", analyticsH.GetDashboard)
// Automation
r.Get("/api/v1/automation/workflows", autoH.ListWorkflows)
r.Post("/api/v1/automation/workflows", autoH.CreateWorkflow)
r.Post("/api/v1/automation/workflows/{id}/trigger", autoH.TriggerWorkflow)
r.Get("/api/v1/automation/jobs", autoH.ListScheduledJobs)
r.Post("/api/v1/automation/jobs", autoH.CreateScheduledJob)
r.Get("/api/v1/automation/runs", autoH.ListRuns)
})
// WebSocket (protected)
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented)
})
srv := &http.Server{