feat: BOC ledger-integration frontend + backend fixes

- Uppdatera robust_client.go med rätt tabellnamn (boc_chart_of_accounts, boc_journal_lines, boc_journal_entries)
- Uppdatera kolumnnamn (entry_id istället för journal_entry_id, account_code istället för code)
- Hantera både stora och små bokstäver för account_type
- Lägg till ledgerApi i frontend med BalanceSheet, IncomeStatement, MomsReport
- Uppdatera DashboardPage med Ledger KPI-kort
- Lägg till LEDGER_DB_URL i docker-compose.yml
- Uppdatera Dockerfile till golang:1.25-alpine
This commit is contained in:
Bernt
2026-07-29 19:53:39 +00:00
parent f367eaadb8
commit bd28b60338
14 changed files with 671 additions and 488 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# BOC Backend Dockerfile
# Multi-stage build for minimal image
FROM golang:1.22-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -26,7 +26,7 @@ func Load() *Config {
return &Config{
Port: getEnv("PORT", "9092"),
DBURL: getEnv("DB_URL", "postgres://boc:boc@localhost:5432/boc?sslmode=disable"),
LedgerDBURL: getEnv("LEDGER_DB_URL", "postgres://postgres:postgres@localhost:5432/aamos_ledger?sslmode=disable"),
LedgerDBURL: getEnv("LEDGER_DB_URL", "postgres://wavult_admin:efG15aKjqgu7uotZoAiLTRBtBDMoXITxIe9Hi6EB@platform-identity-core.cvi0qcksmsfj.eu-north-1.rds.amazonaws.com:5432/amos?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "w+Qkf/CoDda3Ba7vZLKokrGHiwUV5Ak/3tiBmFAvRC8="),
AMOSBaseURL: getEnv("AMOS_BASE_URL", "http://localhost:9000"),
CORSOrigins: splitComma(getEnv("CORS_ORIGINS", "http://localhost:3000")),
+89
View File
@@ -0,0 +1,89 @@
package handlers
import (
"encoding/json"
"net/http"
"boc/ledger"
)
var ledgerDBURL = getEnv("LEDGER_DB_URL", "postgres://wavult_admin:efG15aKjqgu7uotZoAiLTRBtBDMoXITxIe9Hi6EB@platform-identity-core.cvi0qcksmsfj.eu-north-1.rds.amazonaws.com:5432/amos?sslmode=disable")
// LedgerV2Handler uses direct DB connection for reliable data
type LedgerV2Handler struct {
client *ledger.RobustClient
}
// NewLedgerV2Handler creates a handler with DB connection
func NewLedgerV2Handler() (*LedgerV2Handler, error) {
client, err := ledger.NewRobustClient(ledgerDBURL)
if err != nil {
return nil, err
}
return &LedgerV2Handler{client: client}, nil
}
// GetAccounts returns all BAS accounts
func (h *LedgerV2Handler) GetAccounts(w http.ResponseWriter, r *http.Request) {
accounts, err := h.client.GetAccounts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"accounts": accounts,
})
}
// GetBalanceSheet returns balance sheet for a period
func (h *LedgerV2Handler) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
period := r.URL.Query().Get("period")
if period == "" {
period = "2026-01"
}
bs, err := h.client.GetBalanceSheet(r.Context(), period)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bs)
}
// GetIncomeStatement returns P&L for a period
func (h *LedgerV2Handler) GetIncomeStatement(w http.ResponseWriter, r *http.Request) {
period := r.URL.Query().Get("period")
if period == "" {
period = "2026-01"
}
is, err := h.client.GetIncomeStatement(r.Context(), period)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(is)
}
// GetMomsReport returns VAT report for a period
func (h *LedgerV2Handler) GetMomsReport(w http.ResponseWriter, r *http.Request) {
period := r.URL.Query().Get("period")
if period == "" {
period = "2026-01"
}
report, err := h.client.GetMomsReport(r.Context(), period)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(report)
}
+26 -26
View File
@@ -44,13 +44,13 @@ type LedgerAccount struct {
// GetAccounts returns all BAS accounts with balances
func (c *RobustClient) GetAccounts(ctx context.Context) ([]LedgerAccount, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
SELECT a.account_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
FROM boc_chart_of_accounts a
LEFT JOIN boc_journal_lines jl ON a.id = jl.account_id
GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.account_code
`)
if err != nil {
return nil, fmt.Errorf("query accounts: %w", err)
@@ -87,14 +87,14 @@ func (c *RobustClient) GetBalanceSheet(ctx context.Context, period string) (*Bal
}
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
SELECT a.account_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
LEFT JOIN journal_entries je ON jl.journal_entry_id = je.id AND je.entry_date >= $1::date AND je.entry_date < ($1::date + INTERVAL '1 month')
GROUP BY a.id, a.code, a.name, a.account_type
ORDER BY a.code
FROM boc_chart_of_accounts a
LEFT JOIN boc_journal_lines jl ON a.id = jl.account_id
LEFT JOIN boc_journal_entries je ON jl.entry_id = je.id AND je.entry_date >= $1::date AND je.entry_date < ($1::date + INTERVAL '1 month')
GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.account_code
`, period+"-01")
if err != nil {
return nil, fmt.Errorf("query balance sheet: %w", err)
@@ -109,13 +109,13 @@ func (c *RobustClient) GetBalanceSheet(ctx context.Context, period string) (*Bal
}
switch a.AccountType {
case "Asset":
case "Asset", "asset":
bs.Assets = append(bs.Assets, a)
bs.TotalAssets += a.Balance
case "Liability":
case "Liability", "liability":
bs.Liabilities = append(bs.Liabilities, a)
bs.TotalLiabilities += a.Balance
case "Equity":
case "Equity", "equity":
bs.Equity = append(bs.Equity, a)
bs.TotalEquity += a.Balance
}
@@ -141,15 +141,15 @@ func (c *RobustClient) GetIncomeStatement(ctx context.Context, period string) (*
}
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
SELECT a.account_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
LEFT JOIN journal_entries je ON jl.journal_entry_id = je.id AND je.entry_date >= $1::date AND je.entry_date < ($1::date + INTERVAL '1 month')
FROM boc_chart_of_accounts a
LEFT JOIN boc_journal_lines jl ON a.id = jl.account_id
LEFT JOIN boc_journal_entries je ON jl.entry_id = je.id AND je.entry_date >= $1::date AND je.entry_date < ($1::date + INTERVAL '1 month')
WHERE a.account_type IN ('Revenue', 'Expense')
GROUP BY a.id, a.code, a.name, a.account_type
ORDER BY a.code
GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.account_code
`, period+"-01")
if err != nil {
return nil, fmt.Errorf("query income statement: %w", err)
@@ -164,10 +164,10 @@ func (c *RobustClient) GetIncomeStatement(ctx context.Context, period string) (*
}
switch a.AccountType {
case "Revenue":
case "Revenue", "revenue":
is.Revenues = append(is.Revenues, a)
is.TotalRevenue += a.Balance
case "Expense":
case "Expense", "expense":
is.Expenses = append(is.Expenses, a)
is.TotalExpense += a.Balance
}
@@ -197,11 +197,11 @@ func (c *RobustClient) GetMomsReport(ctx context.Context, period string) (*MomsR
// Moms in (utgående moms från försäljning)
err := c.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(jl.credit), 0)
FROM journal_lines jl
JOIN journal_entries je ON jl.journal_entry_id = je.id
JOIN accounts a ON jl.account_id = a.id
FROM boc_journal_lines jl
JOIN boc_journal_entries je ON jl.entry_id = je.id
JOIN boc_chart_of_accounts a ON jl.account_id = a.id
WHERE je.entry_date >= $1::date AND je.entry_date < ($1::date + INTERVAL '1 month')
AND a.code LIKE '26%'
AND a.account_code LIKE '26%'
`, period+"-01").Scan(&report.MomsUt)
if err != nil {
return nil, fmt.Errorf("query moms ut: %w", err)
+1
View File
@@ -48,6 +48,7 @@ services:
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
AMOS_BASE_URL: "http://172.17.0.1:3250"
LEDGER_URL: "http://172.17.0.1:3250"
LEDGER_DB_URL: "postgres://boc:boc_secret_2026@postgres:5432/boc?sslmode=disable"
MIGRATIONS_DIR: "./db/migrations"
REDIS_URL: "redis://redis:6379"
RESEND_API_KEY: ${RESEND_API_KEY:-}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-CbMx-xOV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0Irvkd7.css">
<script type="module" crossorigin src="/assets/index-BtifZyOD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oHhMI01Z.css">
</head>
<body>
<div id="root"></div>
+34
View File
@@ -104,6 +104,40 @@ export const financeApi = {
cashflow: () => api.get<{ cashflow: unknown[] }>('/finance/cashflow'),
}
// Ledger (aamos-ledger integration)
export interface LedgerAccount {
code: string
name: string
account_type: string
balance: number
}
export interface BalanceSheet {
assets: LedgerAccount[]
liabilities: LedgerAccount[]
equity: LedgerAccount[]
total_assets: number
total_liabilities: number
total_equity: number
period: string
}
export interface IncomeStatement {
revenues: LedgerAccount[]
expenses: LedgerAccount[]
total_revenue: number
total_expense: number
net_income: number
period: string
}
export const ledgerApi = {
accounts: () => api.get<{ accounts: LedgerAccount[] }>('/ledger/accounts'),
balanceSheet: (period?: string) => api.get<BalanceSheet>(`/ledger/balance-sheet?period=${period || ''}`),
incomeStatement: (period?: string) => api.get<IncomeStatement>(`/ledger/income-statement?period=${period || ''}`),
momsReport: (period?: string) => api.get<unknown>(`/ledger/moms?period=${period || ''}`),
}
// HR
export const hrApi = {
employees: () => api.get<{ employees: unknown[] }>('/hr/employees'),
+61 -2
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { formatCurrency, formatDate } from '@/lib/utils'
import { financeApi, salesApi, crmApi } from '@/lib/api'
import { financeApi, salesApi, crmApi, ledgerApi } from '@/lib/api'
import {
DollarSign,
Users,
@@ -61,16 +61,24 @@ export function DashboardPage() {
const [deals, setDeals] = useState<Deal[]>([])
const [dealTotal, setDealTotal] = useState(0)
// Ledger state
const [ledgerAssets, setLedgerAssets] = useState(0)
const [ledgerLiabilities, setLedgerLiabilities] = useState(0)
const [ledgerEquity, setLedgerEquity] = useState(0)
const [netIncome, setNetIncome] = useState(0)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [balanceRes, mrrRes, customersRes, dealsRes] = await Promise.all([
const [balanceRes, mrrRes, customersRes, dealsRes, ledgerBs, ledgerIs] = await Promise.all([
financeApi.balance(),
salesApi.mrr(),
crmApi.customers(),
salesApi.deals(),
ledgerApi.balanceSheet('2026-01').catch(() => null),
ledgerApi.incomeStatement('2026-01').catch(() => null),
])
const b = balanceRes as { total_assets?: number }
@@ -84,6 +92,16 @@ export function DashboardPage() {
const d = dealsRes as { deals: Deal[]; total: number }
setDeals(d.deals || [])
setDealTotal(d.total || 0)
// Ledger data
if (ledgerBs) {
setLedgerAssets(ledgerBs.total_assets || 0)
setLedgerLiabilities(ledgerBs.total_liabilities || 0)
setLedgerEquity(ledgerBs.total_equity || 0)
}
if (ledgerIs) {
setNetIncome(ledgerIs.net_income || 0)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
} finally {
@@ -125,6 +143,37 @@ export function DashboardPage() {
},
]
const ledgerKpis = [
{
label: 'Ledger Assets',
value: formatCurrency(ledgerAssets),
change: 0,
changeLabel: 'per balansräkning',
icon: <DollarSign size={18} />,
},
{
label: 'Ledger Liabilities',
value: formatCurrency(ledgerLiabilities),
change: 0,
changeLabel: 'per balansräkning',
icon: <TrendingUp size={18} />,
},
{
label: 'Net Income',
value: formatCurrency(netIncome),
change: 0,
changeLabel: 'per resultaträkning',
icon: <TrendingUp size={18} />,
},
{
label: 'Equity',
value: formatCurrency(ledgerEquity),
change: 0,
changeLabel: 'per balansräkning',
icon: <DollarSign size={18} />,
},
]
if (loading) {
return (
<div className="space-y-10">
@@ -159,6 +208,16 @@ export function DashboardPage() {
))}
</div>
{/* Ledger KPI Cards */}
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Ledger (Bokföring)</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
{ledgerKpis.map((kpi, i) => (
<KPICard key={kpi.label} {...kpi} index={i} />
))}
</div>
</div>
{/* Infrastructure Health — Grafana Integration */}
<Card>
<CardHeader