package handlers import ( "database/sql" "encoding/json" "fmt" "net/http" "time" "github.com/go-chi/chi/v5" "boc/email" "boc/pdf" ) type QuoteHandler struct { DB *sql.DB EmailClient *email.Client } func NewQuoteHandler(db *sql.DB) *QuoteHandler { return &QuoteHandler{DB: db} } func (h *QuoteHandler) SetEmailClient(client *email.Client) { h.EmailClient = client } type Quote struct { ID string `json:"id"` CustomerID string `json:"customer_id"` QuoteNumber string `json:"quote_number"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` Amount float64 `json:"amount"` TaxAmount float64 `json:"tax_amount"` Currency string `json:"currency"` ValidUntil *time.Time `json:"valid_until"` AcceptedAt *time.Time `json:"accepted_at"` Notes string `json:"notes"` Terms string `json:"terms"` CreatedAt time.Time `json:"created_at"` } type QuoteItem struct { ID string `json:"id"` QuoteID string `json:"quote_id"` ProductID *string `json:"product_id"` Description string `json:"description"` Quantity float64 `json:"quantity"` UnitPrice float64 `json:"unit_price"` TaxRate float64 `json:"tax_rate"` Discount float64 `json:"discount"` Total float64 `json:"total"` } func (h *QuoteHandler) ListQuotes(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, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes ORDER BY created_at DESC LIMIT 100` } else { query = `SELECT id, customer_id, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes 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() quotes := []Quote{} for rows.Next() { var q Quote if err := rows.Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Status, &q.Amount, &q.Currency, &q.ValidUntil, &q.CreatedAt); err != nil { continue } quotes = append(quotes, q) } writeJSON(w, http.StatusOK, map[string]interface{}{ "quotes": quotes, "total": len(quotes), }) } func (h *QuoteHandler) CreateQuote(w http.ResponseWriter, r *http.Request) { var req struct { CustomerID string `json:"customer_id"` Title string `json:"title"` Description string `json:"description"` ValidUntil *time.Time `json:"valid_until"` Notes string `json:"notes"` Terms string `json:"terms"` Items []QuoteItem `json:"items"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return } // Generate quote number quoteNumber := fmt.Sprintf("Q-%d", time.Now().Unix()) // Calculate totals var totalAmount, totalTax float64 for _, item := range req.Items { itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100) itemTax := itemTotal * (item.TaxRate / 100) totalAmount += itemTotal totalTax += itemTax } tx, err := h.DB.Begin() if err != nil { writeError(w, http.StatusInternalServerError, "transaction error") return } defer tx.Rollback() var id string err = tx.QueryRow(` INSERT INTO boc_quotes (customer_id, quote_number, title, description, amount, tax_amount, currency, valid_until, notes, terms) VALUES ($1, $2, $3, $4, $5, $6, 'USD', $7, $8, $9) RETURNING id `, req.CustomerID, quoteNumber, req.Title, req.Description, totalAmount, totalTax, req.ValidUntil, req.Notes, req.Terms).Scan(&id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create quote") return } // Insert items for _, item := range req.Items { itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100) _, err = tx.Exec(` INSERT INTO boc_quote_items (quote_id, product_id, description, quantity, unit_price, tax_rate, discount, total) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, id, item.ProductID, item.Description, item.Quantity, item.UnitPrice, item.TaxRate, item.Discount, itemTotal) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create quote items") return } } if err := tx.Commit(); err != nil { writeError(w, http.StatusInternalServerError, "commit failed") return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": id, "number": quoteNumber, "message": "Quote created", }) } func (h *QuoteHandler) GetQuote(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var q Quote err := h.DB.QueryRow(` SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at FROM boc_quotes WHERE id = $1 `, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "quote not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "database error") return } // Get items rows, err := h.DB.Query(` SELECT id, product_id, description, quantity, unit_price, tax_rate, discount, total FROM boc_quote_items WHERE quote_id = $1 ORDER BY sort_order `, id) if err != nil { writeError(w, http.StatusInternalServerError, "database error") return } defer rows.Close() items := []QuoteItem{} for rows.Next() { var i QuoteItem if err := rows.Scan(&i.ID, &i.ProductID, &i.Description, &i.Quantity, &i.UnitPrice, &i.TaxRate, &i.Discount, &i.Total); err != nil { continue } items = append(items, i) } writeJSON(w, http.StatusOK, map[string]interface{}{ "quote": q, "items": items, }) } func (h *QuoteHandler) AcceptQuote(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") _, err := h.DB.Exec(` UPDATE boc_quotes SET status = 'accepted', accepted_at = NOW() WHERE id = $1 `, id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to accept quote") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Quote accepted", }) } func (h *QuoteHandler) ConvertToOrder(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") tx, err := h.DB.Begin() if err != nil { writeError(w, http.StatusInternalServerError, "transaction error") return } defer tx.Rollback() // Get quote details var customerID string var amount, taxAmount float64 err = tx.QueryRow(`SELECT customer_id, amount, tax_amount FROM boc_quotes WHERE id = $1`, id).Scan(&customerID, &amount, &taxAmount) if err != nil { writeError(w, http.StatusNotFound, "quote not found") return } // Create order orderNumber := fmt.Sprintf("O-%d", time.Now().Unix()) var orderID string err = tx.QueryRow(` INSERT INTO boc_orders (customer_id, quote_id, order_number, title, amount, tax_amount, currency, status) SELECT customer_id, id, $2, title, amount, tax_amount, currency, 'confirmed' FROM boc_quotes WHERE id = $1 RETURNING id `, id, orderNumber).Scan(&orderID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create order") return } // Copy quote items to order items _, err = tx.Exec(` INSERT INTO boc_order_items (order_id, product_id, description, quantity, unit_price, tax_rate, discount, total) SELECT $1, product_id, description, quantity, unit_price, tax_rate, discount, total FROM boc_quote_items WHERE quote_id = $2 `, orderID, id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to copy items") return } // Update quote _, err = tx.Exec(`UPDATE boc_quotes SET status = 'converted', converted_to_order_id = $1 WHERE id = $2`, orderID, id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update quote") return } if err := tx.Commit(); err != nil { writeError(w, http.StatusInternalServerError, "commit failed") return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "order_id": orderID, "number": orderNumber, "message": "Quote converted to order", }) } // GenerateQuotePDF generates a PDF for a quote func (h *QuoteHandler) GenerateQuotePDF(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var q Quote err := h.DB.QueryRow(` SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at FROM boc_quotes WHERE id = $1 `, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "quote not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "database error") return } var customerName, customerAddress string h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress) validUntil := time.Now().AddDate(0, 0, 30) if q.ValidUntil != nil { validUntil = *q.ValidUntil } items := []pdf.QuoteItem{ { Description: q.Title, Quantity: 1, Unit: "st", UnitPrice: q.Amount, Total: q.Amount, }, } data := pdf.QuoteData{ QuoteNumber: q.QuoteNumber, QuoteDate: q.CreatedAt, ValidUntil: validUntil, CustomerName: customerName, CustomerAddress: customerAddress, Items: items, Subtotal: q.Amount, VATRate: 0.25, VATAmount: q.TaxAmount, Total: q.Amount + q.TaxAmount, Currency: q.Currency, CompanyName: "Landvex Inc", CompanyAddress: "Houston, TX", Notes: q.Notes, } pdfBytes, err := pdf.GenerateQuote(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=\"offert-%s.pdf\"", q.QuoteNumber)) w.Write(pdfBytes) } // SendQuoteEmail sends a quote via email with PDF attachment func (h *QuoteHandler) SendQuoteEmail(w http.ResponseWriter, r *http.Request) { if h.EmailClient == nil { writeError(w, http.StatusServiceUnavailable, "email not configured") return } id := chi.URLParam(r, "id") var req struct { To []string `json:"to"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return } var q Quote err := h.DB.QueryRow(` SELECT id, customer_id, quote_number, title, amount, tax_amount, currency, valid_until, created_at FROM boc_quotes WHERE id = $1 `, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.CreatedAt) if err != nil { writeError(w, http.StatusNotFound, "quote not found") return } var customerName, customerAddress string h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress) validUntil := time.Now().AddDate(0, 0, 30) if q.ValidUntil != nil { validUntil = *q.ValidUntil } data := pdf.QuoteData{ QuoteNumber: q.QuoteNumber, QuoteDate: q.CreatedAt, ValidUntil: validUntil, CustomerName: customerName, CustomerAddress: customerAddress, Items: []pdf.QuoteItem{ { Description: q.Title, Quantity: 1, Unit: "st", UnitPrice: q.Amount, Total: q.Amount, }, }, Subtotal: q.Amount, VATRate: 0.25, VATAmount: q.TaxAmount, Total: q.Amount + q.TaxAmount, Currency: q.Currency, CompanyName: "Landvex Inc", CompanyAddress: "Houston, TX", } pdfBytes, err := pdf.GenerateQuote(data) if err != nil { writeError(w, http.StatusInternalServerError, "pdf generation failed") return } err = h.EmailClient.SendQuote(req.To, q.QuoteNumber, pdfBytes, "") if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("email failed: %v", err)) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Quote sent", "to": req.To, }) }