feat(boc): v1.0 - Complete Business Operations Center
- Go backend API with full CRUD for all modules - Rust analytics service with parallel processing - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables - Redis cache, Kafka event streaming - WebSocket hub, automation engine - PDF generation, Resend email integration - JWT auth, multi-tenant - Docker Compose deployment - Nginx reverse proxy Refs: BOC-001
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"boc/email"
|
||||
"boc/pdf"
|
||||
)
|
||||
|
||||
type FinanceHandler struct {
|
||||
DB *sql.DB
|
||||
EmailClient *email.Client
|
||||
}
|
||||
|
||||
func NewFinanceHandler(db *sql.DB) *FinanceHandler {
|
||||
return &FinanceHandler{DB: db}
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) SetEmailClient(client *email.Client) {
|
||||
h.EmailClient = client
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
DueDate sql.NullString `json:"due_date"`
|
||||
PaidAt sql.NullString `json:"paid_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Expense struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Vendor string `json:"vendor"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) GetCashFlow(w http.ResponseWriter, r *http.Request) {
|
||||
// Get paid invoices this month
|
||||
var income float64
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_invoices
|
||||
WHERE status = 'paid'
|
||||
AND paid_at >= NOW() - INTERVAL '1 month'
|
||||
`).Scan(&income)
|
||||
if err != nil {
|
||||
income = 0
|
||||
}
|
||||
|
||||
// Get outstanding invoices
|
||||
var outstanding float64
|
||||
err = h.DB.QueryRow(`
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_invoices
|
||||
WHERE status = 'sent'
|
||||
`).Scan(&outstanding)
|
||||
if err != nil {
|
||||
outstanding = 0
|
||||
}
|
||||
|
||||
// Get expenses this month
|
||||
var expenses float64
|
||||
err = h.DB.QueryRow(`
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_expenses
|
||||
WHERE status = 'approved'
|
||||
AND created_at >= NOW() - INTERVAL '1 month'
|
||||
`).Scan(&expenses)
|
||||
if err != nil {
|
||||
expenses = 0
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"income_this_month": income,
|
||||
"outstanding": outstanding,
|
||||
"expenses": expenses,
|
||||
"net_cashflow": income - expenses,
|
||||
"currency": "USD",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) GetBudget(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT name, fiscal_year, category, amount, spent, currency
|
||||
FROM boc_budgets
|
||||
WHERE status = 'active'
|
||||
ORDER BY fiscal_year DESC, category
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
budgets := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var name, category, currency string
|
||||
var fiscalYear int
|
||||
var amount, spent float64
|
||||
if err := rows.Scan(&name, &fiscalYear, &category, &amount, &spent, ¤cy); err != nil {
|
||||
continue
|
||||
}
|
||||
budgets = append(budgets, map[string]interface{}{
|
||||
"name": name,
|
||||
"fiscal_year": fiscalYear,
|
||||
"category": category,
|
||||
"amount": amount,
|
||||
"spent": spent,
|
||||
"remaining": amount - spent,
|
||||
"currency": currency,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"budgets": budgets,
|
||||
"total": len(budgets),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) ListInvoices(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if status == "all" {
|
||||
query = `
|
||||
SELECT id, customer_id, amount, currency, status, due_date, paid_at, created_at
|
||||
FROM boc_invoices
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, customer_id, amount, currency, status, due_date, paid_at, created_at
|
||||
FROM boc_invoices
|
||||
WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
invoices := []Invoice{}
|
||||
for rows.Next() {
|
||||
var i Invoice
|
||||
if err := rows.Scan(&i.ID, &i.CustomerID, &i.Amount, &i.Currency, &i.Status, &i.DueDate, &i.PaidAt, &i.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
invoices = append(invoices, i)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"invoices": invoices,
|
||||
"total": len(invoices),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||
var req Expense
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
err := h.DB.QueryRow(`
|
||||
INSERT INTO boc_expenses (category, description, amount, currency, vendor, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||
RETURNING id
|
||||
`, req.Category, req.Description, req.Amount, req.Currency, req.Vendor).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create expense")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": id,
|
||||
"message": "Expense created",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandler) ListExpenses(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if status == "all" {
|
||||
query = `
|
||||
SELECT id, category, description, amount, currency, vendor, status, created_at
|
||||
FROM boc_expenses
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, category, description, amount, currency, vendor, status, created_at
|
||||
FROM boc_expenses
|
||||
WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
expenses := []Expense{}
|
||||
for rows.Next() {
|
||||
var e Expense
|
||||
if err := rows.Scan(&e.ID, &e.Category, &e.Description, &e.Amount, &e.Currency,
|
||||
&e.Vendor, &e.Status, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
expenses = append(expenses, e)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"expenses": expenses,
|
||||
"total": len(expenses),
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateInvoicePDF generates a PDF for an invoice
|
||||
func (h *FinanceHandler) GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
||||
invoiceID := r.URL.Query().Get("id")
|
||||
if invoiceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invoice id required")
|
||||
return
|
||||
}
|
||||
|
||||
var customerID, currency, status string
|
||||
var amount float64
|
||||
var dueDate sql.NullString
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT customer_id, amount, currency, status, due_date
|
||||
FROM boc_invoices WHERE id = $1
|
||||
`, invoiceID).Scan(&customerID, &amount, ¤cy, &status, &dueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "invoice not found")
|
||||
return
|
||||
}
|
||||
|
||||
var customerName, customerAddress, customerOrgNr string
|
||||
h.DB.QueryRow(`
|
||||
SELECT name, address, org_nr FROM boc_customers WHERE id = $1
|
||||
`, customerID).Scan(&customerName, &customerAddress, &customerOrgNr)
|
||||
|
||||
data := pdf.InvoiceData{
|
||||
InvoiceNumber: invoiceID[:8],
|
||||
InvoiceDate: time.Now(),
|
||||
DueDate: time.Now().AddDate(0, 0, 30),
|
||||
CustomerName: customerName,
|
||||
CustomerAddress: customerAddress,
|
||||
CustomerOrgNr: customerOrgNr,
|
||||
Items: []pdf.InvoiceItem{
|
||||
{
|
||||
Description: "Tjänst",
|
||||
Quantity: 1,
|
||||
Unit: "st",
|
||||
UnitPrice: amount,
|
||||
Total: amount,
|
||||
},
|
||||
},
|
||||
Subtotal: amount,
|
||||
VATRate: 0.25,
|
||||
VATAmount: amount * 0.25,
|
||||
Total: amount * 1.25,
|
||||
Currency: currency,
|
||||
CompanyName: "Landvex Inc",
|
||||
CompanyAddress: "Houston, TX",
|
||||
CompanyOrgNr: "559141-7042",
|
||||
CompanyBankgiro: "1234-5678",
|
||||
Notes: fmt.Sprintf("Status: %s | Betalningsvillkor: 30 dagar", status),
|
||||
}
|
||||
|
||||
pdfBytes, err := pdf.GenerateInvoice(data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"faktura-%s.pdf\"", invoiceID[:8]))
|
||||
w.Write(pdfBytes)
|
||||
}
|
||||
|
||||
// SendInvoiceEmail sends an invoice via email with PDF attachment
|
||||
func (h *FinanceHandler) SendInvoiceEmail(w http.ResponseWriter, r *http.Request) {
|
||||
if h.EmailClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "email not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
InvoiceID string `json:"invoice_id"`
|
||||
To []string `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PDF first
|
||||
var customerID, currency, status string
|
||||
var amount float64
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT customer_id, amount, currency, status, due_date
|
||||
FROM boc_invoices WHERE id = $1
|
||||
`, req.InvoiceID).Scan(&customerID, &amount, ¤cy, &status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "invoice not found")
|
||||
return
|
||||
}
|
||||
|
||||
var customerName, customerAddress, customerOrgNr string
|
||||
h.DB.QueryRow(`
|
||||
SELECT name, address, org_nr FROM boc_customers WHERE id = $1
|
||||
`, customerID).Scan(&customerName, &customerAddress, &customerOrgNr)
|
||||
|
||||
data := pdf.InvoiceData{
|
||||
InvoiceNumber: req.InvoiceID[:8],
|
||||
InvoiceDate: time.Now(),
|
||||
DueDate: time.Now().AddDate(0, 0, 30),
|
||||
CustomerName: customerName,
|
||||
CustomerAddress: customerAddress,
|
||||
CustomerOrgNr: customerOrgNr,
|
||||
Items: []pdf.InvoiceItem{
|
||||
{
|
||||
Description: "Tjänst",
|
||||
Quantity: 1,
|
||||
Unit: "st",
|
||||
UnitPrice: amount,
|
||||
Total: amount,
|
||||
},
|
||||
},
|
||||
Subtotal: amount,
|
||||
VATRate: 0.25,
|
||||
VATAmount: amount * 0.25,
|
||||
Total: amount * 1.25,
|
||||
Currency: currency,
|
||||
CompanyName: "Landvex Inc",
|
||||
CompanyAddress: "Houston, TX",
|
||||
CompanyOrgNr: "559141-7042",
|
||||
CompanyBankgiro: "1234-5678",
|
||||
Notes: fmt.Sprintf("Status: %s", status),
|
||||
}
|
||||
|
||||
pdfBytes, err := pdf.GenerateInvoice(data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.EmailClient.SendInvoice(req.To, req.InvoiceID[:8], pdfBytes, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("email failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Invoice sent",
|
||||
"to": req.To,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user