BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
+16
-13
@@ -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"]
|
||||
|
||||
@@ -7,3 +7,4 @@ RprzZGLlOpwCslfvNFrz6vB9HnUxYHIPexB54YwTtUZjpoz+Um/A5y6nAn94P/E5
|
||||
RqTqVp80vHPpTXL/KSOwU6E8NQYHWPhp1eziiq0hfTOZeDzZIeDKn+tHNwBiU71q
|
||||
KQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
|
||||
+71
-2
@@ -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)
|
||||
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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"]
|
||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
+11
-4
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+159
-33
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user