Files
billit/internal/handler/billing.go
2025-12-06 15:31:18 +05:30

437 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Invoice ")
fmt.Fprintf(w, "%s</title>", invoiceID[:8])
fmt.Fprint(w, "<link href='/assets/css/output.css' rel='stylesheet'>")
fmt.Fprint(w, `<style type="text/css">
@media print {
.no-print { display: none !important; }
.page-break { page-break-before: always; }
@page { margin: 1cm; size: A4; }
body { background: white !important; }
.invoice-table th { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.invoice-totals .row-total { background: #e0e0e0 !important; color: black !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
.invoice-details-block { white-space: pre-wrap; font-size: 0.875rem; line-height: 1.4; }
.invoice-footer-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; }
</style>`)
fmt.Fprint(w, "</head><body>")
fmt.Fprintf(w, `<div class="container page">
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;padding-bottom:1rem;border-bottom:1px solid #e0e0e0;">
<a href="/invoice" class="text-accent">&larr; Back</a>
<button onclick="window.print()" class="btn btn-primary">Print Invoice</button>
</div>
<div class="invoice">
<div class="invoice-header">
<div>
<h1 class="invoice-title">Tax Invoice</h1>
</div>
<div class="invoice-meta">
<p>Invoice ID: %s</p>
</div>
<div class="invoice-meta">
<p>Date: %s</p>
</div>
</div>`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10])
// Display company details above the invoice table
if invoice.CompanyDetails != "" {
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, 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, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;gap:2rem;">`)
// Left side: Billed To (50%)
fmt.Fprint(w, `<div style="flex:1;"><strong>Billed To:</strong><br>`)
if invoice.BuyerName != "" {
fmt.Fprintf(w, `<span class="invoice-details-block">%s</span>`, invoice.BuyerName)
}
if invoice.BuyerDetails != "" {
fmt.Fprintf(w, `<br><span class="invoice-details-block">%s</span>`, invoice.BuyerDetails)
}
fmt.Fprint(w, `</div>`)
// Right side: Total Amount (50%)
fmt.Fprintf(w, `<div style="flex:1;">
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
} else {
fmt.Fprint(w, `<div class="invoice-footer-section">`)
// Total Amount takes 100%
fmt.Fprintf(w, `<div>
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
}
fmt.Fprint(w, `</div>`)
// Bank details (left) and QR code (right) in the same section
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;align-items:flex-start;">`)
if invoice.BankDetails != "" {
fmt.Fprintf(w, `<div style="flex:1;"><strong>Bank Details:</strong><br><span class="invoice-details-block">%s</span></div>`, invoice.BankDetails)
} else {
fmt.Fprint(w, `<div style="flex:1;"></div>`)
}
fmt.Fprintf(w, `<div style="margin-left:1rem;"><img src="data:image/png;base64,%s" alt="QR Code" style="width:80px;height:80px;"></div>`, qrBase64)
fmt.Fprint(w, `</div>`)
fmt.Fprint(w, "</div>")
fmt.Fprint(w, "</div></body></html>")
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 := `<option value="">-- Select Product --</option>`
for _, p := range products {
productOptions += fmt.Sprintf(`<option value="%s">%s (₹%.2f)</option>`, p.SKU, p.Name, p.BasePrice)
}
rowHTML := fmt.Sprintf(`
<div class="product-row">
<div class="product-row-grid">
<div class="form-group" style="margin:0;">
<label class="form-label">Product</label>
<select name="product_sku_%d" class="form-select">%s</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">Qty</label>
<input type="number" name="qty_%d" value="1" min="0" class="form-input">
</div>
<div style="padding-top:20px;">
<button type="button" onclick="this.closest('.product-row').remove()" class="btn btn-danger btn-sm">×</button>
</div>
</div>
</div>`, index, productOptions, index)
return c.HTML(http.StatusOK, rowHTML)
}