67a69ab073
- 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
396 lines
10 KiB
Go
396 lines
10 KiB
Go
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,
|
|
})
|
|
}
|