437 lines
14 KiB
Go
437 lines
14 KiB
Go
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">← 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)
|
||
}
|