refactor: restructure in entirety

This commit is contained in:
Arkaprabha Chakraborty
2025-12-06 15:31:18 +05:30
parent 28733e22d3
commit 17a2bce744
43 changed files with 854 additions and 1342 deletions

436
internal/handler/billing.go Normal file
View File

@@ -0,0 +1,436 @@
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)
}