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 # BOC Backend Dockerfile
# Multi-stage build for minimal image # Multi-stage build for minimal image
FROM golang:1.22-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app 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{ return &Config{
Port: getEnv("PORT", "9092"), Port: getEnv("PORT", "9092"),
DBURL: getEnv("DB_URL", "postgres://boc:boc@localhost:5432/boc?sslmode=disable"), 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="), JWTSecret: getEnv("JWT_SECRET", "w+Qkf/CoDda3Ba7vZLKokrGHiwUV5Ak/3tiBmFAvRC8="),
AMOSBaseURL: getEnv("AMOS_BASE_URL", "http://localhost:9000"), AMOSBaseURL: getEnv("AMOS_BASE_URL", "http://localhost:9000"),
CORSOrigins: splitComma(getEnv("CORS_ORIGINS", "http://localhost:3000")), 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 // GetAccounts returns all BAS accounts with balances
func (c *RobustClient) GetAccounts(ctx context.Context) ([]LedgerAccount, error) { func (c *RobustClient) GetAccounts(ctx context.Context) ([]LedgerAccount, error) {
rows, err := c.db.QueryContext(ctx, ` 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) - 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 SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a FROM boc_chart_of_accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id LEFT JOIN boc_journal_lines jl ON a.id = jl.account_id
GROUP BY a.id, a.code, a.name, a.account_type GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.code ORDER BY a.account_code
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("query accounts: %w", err) 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, ` 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) - 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 SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a FROM boc_chart_of_accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id LEFT JOIN boc_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') 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.code, a.name, a.account_type GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.code ORDER BY a.account_code
`, period+"-01") `, period+"-01")
if err != nil { if err != nil {
return nil, fmt.Errorf("query balance sheet: %w", err) 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 { switch a.AccountType {
case "Asset": case "Asset", "asset":
bs.Assets = append(bs.Assets, a) bs.Assets = append(bs.Assets, a)
bs.TotalAssets += a.Balance bs.TotalAssets += a.Balance
case "Liability": case "Liability", "liability":
bs.Liabilities = append(bs.Liabilities, a) bs.Liabilities = append(bs.Liabilities, a)
bs.TotalLiabilities += a.Balance bs.TotalLiabilities += a.Balance
case "Equity": case "Equity", "equity":
bs.Equity = append(bs.Equity, a) bs.Equity = append(bs.Equity, a)
bs.TotalEquity += a.Balance bs.TotalEquity += a.Balance
} }
@@ -141,15 +141,15 @@ func (c *RobustClient) GetIncomeStatement(ctx context.Context, period string) (*
} }
rows, err := c.db.QueryContext(ctx, ` 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) - 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 SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a FROM boc_chart_of_accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id LEFT JOIN boc_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') 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') WHERE a.account_type IN ('Revenue', 'Expense')
GROUP BY a.id, a.code, a.name, a.account_type GROUP BY a.id, a.account_code, a.name, a.account_type
ORDER BY a.code ORDER BY a.account_code
`, period+"-01") `, period+"-01")
if err != nil { if err != nil {
return nil, fmt.Errorf("query income statement: %w", err) 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 { switch a.AccountType {
case "Revenue": case "Revenue", "revenue":
is.Revenues = append(is.Revenues, a) is.Revenues = append(is.Revenues, a)
is.TotalRevenue += a.Balance is.TotalRevenue += a.Balance
case "Expense": case "Expense", "expense":
is.Expenses = append(is.Expenses, a) is.Expenses = append(is.Expenses, a)
is.TotalExpense += a.Balance 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) // Moms in (utgående moms från försäljning)
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(jl.credit), 0) SELECT COALESCE(SUM(jl.credit), 0)
FROM journal_lines jl FROM boc_journal_lines jl
JOIN journal_entries je ON jl.journal_entry_id = je.id JOIN boc_journal_entries je ON jl.entry_id = je.id
JOIN accounts a ON jl.account_id = a.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') 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) `, period+"-01").Scan(&report.MomsUt)
if err != nil { if err != nil {
return nil, fmt.Errorf("query moms ut: %w", err) 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} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
AMOS_BASE_URL: "http://172.17.0.1:3250" AMOS_BASE_URL: "http://172.17.0.1:3250"
LEDGER_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" MIGRATIONS_DIR: "./db/migrations"
REDIS_URL: "redis://redis:6379" REDIS_URL: "redis://redis:6379"
RESEND_API_KEY: ${RESEND_API_KEY:-} 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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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> <script type="module" crossorigin src="/assets/index-BtifZyOD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0Irvkd7.css"> <link rel="stylesheet" crossorigin href="/assets/index-oHhMI01Z.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+34
View File
@@ -104,6 +104,40 @@ export const financeApi = {
cashflow: () => api.get<{ cashflow: unknown[] }>('/finance/cashflow'), 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 // HR
export const hrApi = { export const hrApi = {
employees: () => api.get<{ employees: unknown[] }>('/hr/employees'), employees: () => api.get<{ employees: unknown[] }>('/hr/employees'),
+61 -2
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/Table' } from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { financeApi, salesApi, crmApi } from '@/lib/api' import { financeApi, salesApi, crmApi, ledgerApi } from '@/lib/api'
import { import {
DollarSign, DollarSign,
Users, Users,
@@ -60,17 +60,25 @@ export function DashboardPage() {
const [customerCount, setCustomerCount] = useState(0) const [customerCount, setCustomerCount] = useState(0)
const [deals, setDeals] = useState<Deal[]>([]) const [deals, setDeals] = useState<Deal[]>([])
const [dealTotal, setDealTotal] = useState(0) 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(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const [balanceRes, mrrRes, customersRes, dealsRes] = await Promise.all([ const [balanceRes, mrrRes, customersRes, dealsRes, ledgerBs, ledgerIs] = await Promise.all([
financeApi.balance(), financeApi.balance(),
salesApi.mrr(), salesApi.mrr(),
crmApi.customers(), crmApi.customers(),
salesApi.deals(), salesApi.deals(),
ledgerApi.balanceSheet('2026-01').catch(() => null),
ledgerApi.incomeStatement('2026-01').catch(() => null),
]) ])
const b = balanceRes as { total_assets?: number } const b = balanceRes as { total_assets?: number }
@@ -84,6 +92,16 @@ export function DashboardPage() {
const d = dealsRes as { deals: Deal[]; total: number } const d = dealsRes as { deals: Deal[]; total: number }
setDeals(d.deals || []) setDeals(d.deals || [])
setDealTotal(d.total || 0) 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard data') setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
} finally { } finally {
@@ -124,6 +142,37 @@ export function DashboardPage() {
icon: <ShoppingCart size={18} />, icon: <ShoppingCart size={18} />,
}, },
] ]
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) { if (loading) {
return ( return (
@@ -158,6 +207,16 @@ export function DashboardPage() {
<KPICard key={kpi.label} {...kpi} index={i} /> <KPICard key={kpi.label} {...kpi} index={i} />
))} ))}
</div> </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 */} {/* Infrastructure Health — Grafana Integration */}
<Card> <Card>