package handler import "billit/internal/models" import "billit/internal/view" import ( "billit/internal/database" "billit/internal/logic" "encoding/base64" "encoding/json" "fmt" "math" "net/http" "strconv" "strings" "github.com/google/uuid" "github.com/labstack/echo/v4" qrcode "github.com/skip2/go-qrcode" ) // numberToWords converts a number to Indian English words (supports up to crores) func numberToWords(n float64) string { if n == 0 { return "Zero" } ones := []string{"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"} tens := []string{"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"} convertLessThanHundred := func(n int) string { if n < 20 { return ones[n] } if n%10 == 0 { return tens[n/10] } return tens[n/10] + " " + ones[n%10] } convertLessThanThousand := func(n int) string { if n < 100 { return convertLessThanHundred(n) } if n%100 == 0 { return ones[n/100] + " Hundred" } return ones[n/100] + " Hundred " + convertLessThanHundred(n%100) } // Split into rupees and paise rupees := int(math.Floor(n)) paise := int(math.Round((n - float64(rupees)) * 100)) var result string if rupees >= 10000000 { // Crores crores := rupees / 10000000 rupees = rupees % 10000000 result += convertLessThanThousand(crores) + " Crore " } if rupees >= 100000 { // Lakhs lakhs := rupees / 100000 rupees = rupees % 100000 result += convertLessThanHundred(lakhs) + " Lakh " } if rupees >= 1000 { // Thousands thousands := rupees / 1000 rupees = rupees % 1000 result += convertLessThanHundred(thousands) + " Thousand " } if rupees > 0 { result += convertLessThanThousand(rupees) } result = strings.TrimSpace(result) if result == "" { result = "Zero" } result += " Rupees" if paise > 0 { result += " and " + convertLessThanHundred(paise) + " Paise" } return result + " Only" } // BillingHandlers holds db reference for billing operations type BillingHandlers struct { db database.Service } // NewBillingHandlers creates handlers with db access func NewBillingHandlers(db database.Service) *BillingHandlers { return &BillingHandlers{db: db} } // BillingPageHandler renders the /billing page for creating bills func (h *BillingHandlers) BillingPageHandler(c echo.Context) error { userID := getUserID(c) products, err := h.db.GetAllProducts(userID) if err != nil { products = []models.Product{} } buyers, err := h.db.GetAllBuyerDetails(userID) if err != nil { buyers = []models.BuyerDetails{} } return view.Render(c, view.BillingPage(products, buyers)) } // CalculateBillHandler calculates the bill (HTMX endpoint) func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error { userID := getUserID(c) customerType := c.FormValue("customer_type") regionType := c.FormValue("region_type") includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" cType := logic.CustomerRetail if customerType == "wholesale" { cType = logic.CustomerWholesale } isInterState := regionType == "inter" calculator := logic.NewCalculator() var items []logic.LineItem var totalFee float64 // Support up to 50 product slots for dynamic adding for i := 0; i < 50; i++ { sku := c.FormValue("product_sku_" + strconv.Itoa(i)) qtyStr := c.FormValue("qty_" + strconv.Itoa(i)) if sku == "" { continue } qty, err := strconv.Atoi(qtyStr) if err != nil || qty <= 0 { continue } // Get product from DB (user-scoped) dbProduct, err := h.db.GetProductBySKU(sku, userID) if err != nil || dbProduct == nil { continue } // Convert to logic.Product product := logic.Product{ SKU: dbProduct.SKU, Name: dbProduct.Name, HSNCode: dbProduct.HSNCode, BasePrice: dbProduct.BasePrice, WholesalePrice: dbProduct.WholesalePrice, GSTRate: logic.Rate(dbProduct.GSTRate), SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderFee: dbProduct.SmallOrderFee, Unit: dbProduct.Unit, } lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState) items = append(items, lineItem) // Apply per-product convenience fee if quantity is below threshold and checkbox is checked if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 { totalFee += product.SmallOrderFee } } invoice := calculator.CalculateInvoice(items, totalFee, isInterState) invoice.CustomerType = cType return view.Render(c, view.InvoiceSummary(invoice)) } // GenerateBillHandler generates final invoice with UUID and persists to DB func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error { userID := getUserID(c) if userID == "" { return c.Redirect(http.StatusFound, "/") } customerType := c.FormValue("customer_type") regionType := c.FormValue("region_type") buyerID := c.FormValue("buyer_id") includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" cType := logic.CustomerRetail if customerType == "wholesale" { cType = logic.CustomerWholesale } isInterState := regionType == "inter" calculator := logic.NewCalculator() var items []logic.LineItem var totalFee float64 for i := 0; i < 50; i++ { sku := c.FormValue("product_sku_" + strconv.Itoa(i)) qtyStr := c.FormValue("qty_" + strconv.Itoa(i)) if sku == "" { continue } qty, err := strconv.Atoi(qtyStr) if err != nil || qty <= 0 { continue } dbProduct, err := h.db.GetProductBySKU(sku, userID) if err != nil || dbProduct == nil { continue } product := logic.Product{ SKU: dbProduct.SKU, Name: dbProduct.Name, HSNCode: dbProduct.HSNCode, BasePrice: dbProduct.BasePrice, WholesalePrice: dbProduct.WholesalePrice, GSTRate: logic.Rate(dbProduct.GSTRate), SmallOrderQty: dbProduct.SmallOrderQty, SmallOrderFee: dbProduct.SmallOrderFee, Unit: dbProduct.Unit, } lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState) items = append(items, lineItem) // Apply per-product convenience fee if checkbox is checked if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 { totalFee += product.SmallOrderFee } } if len(items) == 0 { return c.String(http.StatusBadRequest, "No products selected") } invoice := calculator.CalculateInvoice(items, totalFee, isInterState) invoice.CustomerType = cType // Get user's company and bank details user, err := h.db.GetUserByID(userID) if err == nil && user != nil { invoice.CompanyDetails = user.CompanyDetails invoice.BankDetails = user.BankDetails } // Get buyer details if selected if buyerID != "" { buyer, err := h.db.GetBuyerDetails(buyerID, userID) if err == nil && buyer != nil { invoice.BuyerName = buyer.Name invoice.BuyerDetails = buyer.Details } } // Generate UUID for invoice invoiceID := uuid.New().String() // Generate human-readable invoice ID humanReadableID, err := h.db.GetNextInvoiceNumber(userID) if err != nil { return c.String(http.StatusInternalServerError, "failed to generate invoice number") } // Persist to DB (user-scoped) if err := h.db.CreateInvoice(invoiceID, humanReadableID, invoice, userID); err != nil { return c.String(http.StatusInternalServerError, "failed to save invoice") } // Redirect to invoice view c.Response().Header().Set("HX-Redirect", fmt.Sprintf("/invoice/%s", invoiceID)) return c.NoContent(http.StatusOK) } // ShowInvoiceHandler displays the invoice by UUID (requires auth) func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error { userID := getUserID(c) if userID == "" { return c.Redirect(http.StatusFound, "/") } invoiceID := c.Param("id") inv, err := h.db.GetInvoice(invoiceID, userID) if err != nil || inv == nil { return view.RenderNotFound(c, "Invoice not found or you don't have access to it.") } // Parse the JSON data back into Invoice struct var invoice logic.Invoice if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil { return c.String(http.StatusInternalServerError, "failed to parse invoice data") } // Generate QR code for invoice URL invoiceURL := fmt.Sprintf("%s://%s/invoice/%s", c.Scheme(), c.Request().Host, invoiceID) qrPNG, err := qrcode.Encode(invoiceURL, qrcode.Medium, 100) if err != nil { return c.String(http.StatusInternalServerError, "failed to generate QR code") } qrBase64 := base64.StdEncoding.EncodeToString(qrPNG) // Render printable invoice page with multi-page support c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) w := c.Response().Writer fmt.Fprint(w, "Invoice ") fmt.Fprintf(w, "%s", invoiceID[:8]) fmt.Fprint(w, "") fmt.Fprint(w, ``) fmt.Fprint(w, "") fmt.Fprintf(w, `
← Back

Tax Invoice

Invoice ID: %s

Date: %s

`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10]) // Display company details above the invoice table if invoice.CompanyDetails != "" { fmt.Fprintf(w, `
From:
%s
`, invoice.CompanyDetails) } if err := view.PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil { return err } // Display buyer details and total amount in same section (50-50 split or 100% if no buyer) totalGST := invoice.TotalCGST + invoice.TotalSGST + invoice.TotalIGST hasBuyerInfo := invoice.BuyerName != "" || invoice.BuyerDetails != "" if hasBuyerInfo { fmt.Fprint(w, `") fmt.Fprint(w, "
") return nil } // AddProductRowHandler returns HTML for a new product row (HTMX endpoint) func (h *BillingHandlers) AddProductRowHandler(c echo.Context) error { userID := getUserID(c) indexStr := c.QueryParam("index") index, err := strconv.Atoi(indexStr) if err != nil { index = 0 } products, _ := h.db.GetAllProducts(userID) // Build product options HTML productOptions := `` for _, p := range products { productOptions += fmt.Sprintf(``, p.SKU, p.Name, p.BasePrice) } rowHTML := fmt.Sprintf(`
`, index, productOptions, index) return c.HTML(http.StatusOK, rowHTML) }