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

108
internal/handler/account.go Normal file
View File

@@ -0,0 +1,108 @@
package handler
import (
"billit/internal/database"
"billit/internal/logic"
"billit/internal/view"
"net/http"
"github.com/labstack/echo/v4"
)
// AccountHandlers holds references for account operations
type AccountHandlers struct {
db database.Service
auth *logic.AuthService
}
// NewAccountHandlers creates handlers with db and auth access
func NewAccountHandlers(db database.Service, authService *logic.AuthService) *AccountHandlers {
return &AccountHandlers{db: db, auth: authService}
}
// AccountPageHandler renders the /account page
func (h *AccountHandlers) AccountPageHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
return view.RenderServerError(c, "Failed to load account details.")
}
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", ""))
}
// UpdateDetailsHandler handles POST /account/details
func (h *AccountHandlers) UpdateDetailsHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
return view.RenderServerError(c, "Failed to load account details.")
}
companyDetails := c.FormValue("company_details")
bankDetails := c.FormValue("bank_details")
invoicePrefix := c.FormValue("invoice_prefix")
if invoicePrefix == "" {
invoicePrefix = "INV" // Default prefix
}
err = h.db.UpdateUserDetails(userID, companyDetails, bankDetails, invoicePrefix)
if err != nil {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details"))
}
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, companyDetails, bankDetails, invoicePrefix, "Details updated successfully", ""))
}
// ChangePasswordHandler handles POST /account/password
func (h *AccountHandlers) ChangePasswordHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
return view.RenderServerError(c, "Failed to load account details.")
}
currentPassword := c.FormValue("current_password")
newPassword := c.FormValue("new_password")
confirmPassword := c.FormValue("confirm_password")
// Validate current password
if !logic.CheckPassword(currentPassword, user.Password) {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect"))
}
// Validate new password
if len(newPassword) < 8 {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters"))
}
if newPassword != confirmPassword {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match"))
}
// Hash new password
hash, err := logic.HashPassword(newPassword)
if err != nil {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
}
// Update password in database
err = h.db.UpdateUserPassword(userID, hash)
if err != nil {
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password"))
}
return view.Render(c, view.AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", ""))
}

176
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,176 @@
package handler
import (
"billit/internal/logic"
"billit/internal/view"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/labstack/echo/v4"
)
// AuthHandlers holds auth service reference
type AuthHandlers struct {
auth *logic.AuthService
}
// NewAuthHandlers creates handlers with auth service
func NewAuthHandlers(authService *logic.AuthService) *AuthHandlers {
return &AuthHandlers{auth: authService}
}
// createAuthCookie creates an HTTP-only secure cookie for the token
func createAuthCookie(token string) *http.Cookie {
domain := os.Getenv("COOKIE_DOMAIN")
secure := os.Getenv("COOKIE_SECURE") == "true"
return &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
Domain: domain,
MaxAge: int(24 * time.Hour.Seconds()), // Match token duration
HttpOnly: true, // Prevents JavaScript access
Secure: secure, // Only send over HTTPS in production
SameSite: http.SameSiteStrictMode,
}
}
// clearAuthCookie returns a cookie that clears the auth token
func clearAuthCookie() *http.Cookie {
domain := os.Getenv("COOKIE_DOMAIN")
secure := os.Getenv("COOKIE_SECURE") == "true"
return &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
}
}
// LoginPageHandler renders the login page (home page)
func (h *AuthHandlers) LoginPageHandler(c echo.Context) error {
// Check if already logged in
cookie, err := c.Cookie("auth_token")
if err == nil && cookie.Value != "" {
_, err := h.auth.ValidateToken(cookie.Value)
if err == nil {
// Already logged in, redirect to home
return c.Redirect(http.StatusFound, "/home")
}
}
// Capture redirect URL from query param
redirectURL := c.QueryParam("redirect")
return view.Render(c, view.LoginPage("", "", redirectURL))
}
// LoginHandler handles login form submission
func (h *AuthHandlers) LoginHandler(c echo.Context) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
redirectURL := c.FormValue("redirect")
if email == "" || password == "" {
return view.Render(c, view.LoginPage("Email and password are required", email, redirectURL))
}
token, err := h.auth.Login(email, password)
if err != nil {
return view.Render(c, view.LoginPage("Invalid email or password", email, redirectURL))
}
// Set HTTP-only cookie
cookie := createAuthCookie(token)
c.SetCookie(cookie)
// Redirect to original URL or home page
if redirectURL != "" && strings.HasPrefix(redirectURL, "/") {
return c.Redirect(http.StatusFound, redirectURL)
}
return c.Redirect(http.StatusFound, "/home")
}
// RegisterPageHandler renders the registration page
func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error {
return view.Render(c, view.RegisterPage("", ""))
}
// RegisterHandler handles registration form submission
func (h *AuthHandlers) RegisterHandler(c echo.Context) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
confirmPassword := c.FormValue("confirm_password")
if email == "" || password == "" {
return view.Render(c, view.RegisterPage("Email and password are required", email))
}
if password != confirmPassword {
return view.Render(c, view.RegisterPage("Passwords do not match", email))
}
if len(password) < 8 {
return view.Render(c, view.RegisterPage("Password must be at least 8 characters", email))
}
_, err := h.auth.Register(email, password)
if err != nil {
if err == logic.ErrUserExists {
return view.Render(c, view.RegisterPage("An account with this email already exists", email))
}
return view.Render(c, view.RegisterPage(err.Error(), email))
}
// Auto-login after registration
token, err := h.auth.Login(email, password)
if err != nil {
return c.Redirect(http.StatusFound, "/")
}
cookie := createAuthCookie(token)
c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/home")
}
// LogoutHandler clears the auth cookie and redirects to login
func (h *AuthHandlers) LogoutHandler(c echo.Context) error {
cookie := clearAuthCookie()
c.SetCookie(cookie)
return c.Redirect(http.StatusFound, "/")
}
// AuthMiddleware protects routes that require authentication
func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cookie, err := c.Cookie("auth_token")
if err != nil || cookie.Value == "" {
// No cookie - redirect to login with original URL for post-login redirect
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
return c.Redirect(http.StatusFound, "/?redirect="+redirectPath)
}
claims, err := h.auth.ValidateToken(cookie.Value)
if err != nil {
// Invalid/expired token - show session expired dialog
c.SetCookie(clearAuthCookie())
redirectPath := url.QueryEscape(c.Request().URL.RequestURI())
return view.Render(c, view.SessionExpiredPage(redirectPath))
}
// Store user info in context
c.Set("user_id", claims.UserID)
c.Set("user_email", claims.Email)
return next(c)
}
}

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)
}

107
internal/handler/buyer.go Normal file
View File

@@ -0,0 +1,107 @@
package handler
import "billit/internal/view"
import (
"billit/internal/database"
"net/http"
"github.com/labstack/echo/v4"
)
// BuyerHandlers holds db reference for buyer operations
type BuyerHandlers struct {
db database.Service
}
// NewBuyerHandlers creates handlers with db access
func NewBuyerHandlers(db database.Service) *BuyerHandlers {
return &BuyerHandlers{db: db}
}
// BuyerListHandler renders the /buyer page with all buyers
func (h *BuyerHandlers) BuyerListHandler(c echo.Context) error {
userID := getUserID(c)
buyers, err := h.db.GetAllBuyerDetails(userID)
if err != nil {
return view.RenderServerError(c, "Failed to load buyers. Please try again.")
}
return view.Render(c, view.BuyerListPage(buyers))
}
// BuyerCreatePageHandler renders the /buyer/create form page
func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error {
return view.Render(c, view.BuyerCreatePage())
}
// BuyerEditPageHandler renders the /buyer/edit/:id form page
func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error {
id := c.Param("id")
userID := getUserID(c)
buyer, err := h.db.GetBuyerDetails(id, userID)
if err != nil || buyer == nil {
return view.RenderNotFound(c, "Buyer not found or you don't have access to it.")
}
return view.Render(c, view.BuyerEditPage(*buyer))
}
// BuyerCreateHandler handles POST /buyer/create
func (h *BuyerHandlers) BuyerCreateHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
name := c.FormValue("name")
if name == "" {
return c.String(http.StatusBadRequest, "Name is required")
}
details := c.FormValue("details")
_, err := h.db.CreateBuyerDetails(userID, name, details)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to create buyer")
}
return c.Redirect(http.StatusFound, "/buyer")
}
// BuyerUpdateHandler handles POST /buyer/edit/:id
func (h *BuyerHandlers) BuyerUpdateHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
id := c.Param("id")
name := c.FormValue("name")
if name == "" {
return c.String(http.StatusBadRequest, "Name is required")
}
details := c.FormValue("details")
err := h.db.UpdateBuyerDetails(id, userID, name, details)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to update buyer")
}
return c.Redirect(http.StatusFound, "/buyer")
}
// BuyerDeleteHandler handles DELETE /buyer/:id
func (h *BuyerHandlers) BuyerDeleteHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
id := c.Param("id")
err := h.db.DeleteBuyerDetails(id, userID)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to delete buyer")
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,23 @@
package handler
import (
"billit/internal/database"
"net/http"
"github.com/labstack/echo/v4"
)
// HealthHandlers holds dependencies for health checks
type HealthHandlers struct {
db database.Service
}
// NewHealthHandlers creates health handlers with db access
func NewHealthHandlers(db database.Service) *HealthHandlers {
return &HealthHandlers{db: db}
}
// HealthHandler returns the health status
func (h *HealthHandlers) HealthHandler(c echo.Context) error {
return c.JSON(http.StatusOK, h.db.Health())
}

39
internal/handler/home.go Normal file
View File

@@ -0,0 +1,39 @@
package handler
import "billit/internal/models"
import "billit/internal/view"
import (
"billit/internal/database"
"github.com/labstack/echo/v4"
)
// HomeHandlers holds db reference for home page operations
type HomeHandlers struct {
db database.Service
}
// NewHomeHandlers creates handlers with db access
func NewHomeHandlers(db database.Service) *HomeHandlers {
return &HomeHandlers{db: db}
}
// HomePageHandler renders the home page with recent data
func (h *HomeHandlers) HomePageHandler(c echo.Context) error {
userID := getUserID(c)
userEmail, _ := c.Get("user_email").(string)
// Get recent products (last 5)
recentProducts, err := h.db.GetRecentProducts(userID, 5)
if err != nil {
recentProducts = []models.Product{}
}
// Get recent invoices (last 5)
recentInvoices, err := h.db.GetRecentInvoices(userID, 5)
if err != nil {
recentInvoices = []models.Invoice{}
}
return view.Render(c, view.HomePage(userEmail, recentProducts, recentInvoices))
}

View File

@@ -0,0 +1,33 @@
package handler
import "billit/internal/view"
import (
"billit/internal/database"
"net/http"
"github.com/labstack/echo/v4"
)
// InvoicesHandlers holds db reference for invoice operations
type InvoicesHandlers struct {
db database.Service
}
// NewInvoicesHandlers creates handlers with db access
func NewInvoicesHandlers(db database.Service) *InvoicesHandlers {
return &InvoicesHandlers{db: db}
}
// InvoicesListHandler renders the /invoice page with all invoices
func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
invoices, err := h.db.GetAllInvoices(userID)
if err != nil {
return view.RenderServerError(c, "Failed to load invoices. Please try again.")
}
return view.Render(c, view.InvoicesPage(invoices))
}

53
internal/handler/modal.go Normal file
View File

@@ -0,0 +1,53 @@
package handler
import (
"billit/internal/view"
"strings"
"github.com/labstack/echo/v4"
)
// ModalHandlers holds dependencies
type ModalHandlers struct{}
// NewModalHandlers creates new handlers
func NewModalHandlers() *ModalHandlers {
return &ModalHandlers{}
}
// ConfirmHandler renders the confirmation modal
func (h *ModalHandlers) ConfirmHandler(c echo.Context) error {
title := c.QueryParam("title")
if title == "" {
title = "Confirm Action"
}
message := c.QueryParam("message")
if message == "" {
message = "Are you sure you want to proceed?"
}
confirmText := c.QueryParam("confirm_text")
if confirmText == "" {
confirmText = "Confirm"
}
url := c.QueryParam("url")
method := c.QueryParam("method")
if method == "" {
method = "delete" // Default to delete
}
target := c.QueryParam("target")
props := view.ModalProps{
Title: title,
Message: message,
ConfirmText: confirmText,
ConfirmURL: url,
Method: strings.ToLower(method),
Target: target,
}
return view.Render(c, view.Modal(props))
}

233
internal/handler/product.go Normal file
View File

@@ -0,0 +1,233 @@
package handler
import "billit/internal/models"
import "billit/internal/view"
import (
"billit/internal/database"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
// ProductHandlers holds db reference for product operations
type ProductHandlers struct {
db database.Service
}
// NewProductHandlers creates handlers with db access
func NewProductHandlers(db database.Service) *ProductHandlers {
return &ProductHandlers{db: db}
}
// getUserID extracts user ID from context (set by auth middleware)
func getUserID(c echo.Context) string {
if uid, ok := c.Get("user_id").(string); ok {
return uid
}
return ""
}
// ProductListHandler renders the /product page with all products
func (h *ProductHandlers) ProductListHandler(c echo.Context) error {
userID := getUserID(c)
products, err := h.db.GetAllProducts(userID)
if err != nil {
return view.RenderServerError(c, "Failed to load products. Please try again.")
}
return view.Render(c, view.ProductListPage(products))
}
// ProductCreatePageHandler renders the /product/create form page
func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error {
return view.Render(c, view.ProductCreatePage())
}
// ProductEditPageHandler renders the /product/edit/:sku form page
func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error {
sku := c.Param("sku")
userID := getUserID(c)
product, err := h.db.GetProductBySKU(sku, userID)
if err != nil || product == nil {
return view.RenderNotFound(c, "Product not found or you don't have access to it.")
}
return view.Render(c, view.ProductEditPage(*product))
}
// ProductCreateHandler handles POST /product/create
func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
sku := c.FormValue("sku")
if sku == "" {
return c.String(http.StatusBadRequest, "SKU is required")
}
name := c.FormValue("name")
if name == "" {
return c.String(http.StatusBadRequest, "Name is required")
}
// Check if SKU already exists for this user
existing, _ := h.db.GetProductBySKU(sku, userID)
if existing != nil {
return c.String(http.StatusBadRequest, "A product with this SKU already exists")
}
hsn := c.FormValue("hsn")
baseStr := c.FormValue("base_price")
wholesaleStr := c.FormValue("wholesale_price")
gstStr := c.FormValue("gst_rate")
smallQtyStr := c.FormValue("small_order_qty")
base, _ := strconv.ParseFloat(baseStr, 64)
wholesale, _ := strconv.ParseFloat(wholesaleStr, 64)
if wholesale == 0 {
wholesale = base // default wholesale to base price
}
gstRate := 0.18 // default 18%
switch gstStr {
case "0":
gstRate = 0.0
case "5":
gstRate = 0.05
case "12":
gstRate = 0.12
case "18":
gstRate = 0.18
case "28":
gstRate = 0.28
}
smallQty := 1
if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 {
smallQty = v
}
smallFeeStr := c.FormValue("small_order_fee")
smallFee, _ := strconv.ParseFloat(smallFeeStr, 64)
unit := c.FormValue("unit")
if unit == "" {
unit = "pcs"
}
product := models.Product{
SKU: sku,
Name: name,
HSNCode: hsn,
BasePrice: base,
WholesalePrice: wholesale,
GSTRate: gstRate,
SmallOrderQty: smallQty,
SmallOrderFee: smallFee,
Unit: unit,
}
if err := h.db.CreateProduct(product, userID); err != nil {
return c.String(http.StatusInternalServerError, "failed to create product")
}
// Redirect to product list
return c.Redirect(http.StatusSeeOther, "/product")
}
// ProductUpdateHandler handles POST /product/edit/:sku
func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error {
userID := getUserID(c)
if userID == "" {
return c.Redirect(http.StatusFound, "/")
}
sku := c.Param("sku")
// Verify product belongs to user
existing, _ := h.db.GetProductBySKU(sku, userID)
if existing == nil {
return c.String(http.StatusNotFound, "Product not found")
}
name := c.FormValue("name")
if name == "" {
return c.String(http.StatusBadRequest, "Name is required")
}
hsn := c.FormValue("hsn")
baseStr := c.FormValue("base_price")
wholesaleStr := c.FormValue("wholesale_price")
gstStr := c.FormValue("gst_rate")
smallQtyStr := c.FormValue("small_order_qty")
base, _ := strconv.ParseFloat(baseStr, 64)
wholesale, _ := strconv.ParseFloat(wholesaleStr, 64)
if wholesale == 0 {
wholesale = base
}
gstRate := 0.18
switch gstStr {
case "0":
gstRate = 0.0
case "5":
gstRate = 0.05
case "12":
gstRate = 0.12
case "18":
gstRate = 0.18
case "28":
gstRate = 0.28
}
smallQty := 1
if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 {
smallQty = v
}
smallFeeStr := c.FormValue("small_order_fee")
smallFee, _ := strconv.ParseFloat(smallFeeStr, 64)
unit := c.FormValue("unit")
if unit == "" {
unit = "pcs"
}
product := models.Product{
SKU: sku,
Name: name,
HSNCode: hsn,
BasePrice: base,
WholesalePrice: wholesale,
GSTRate: gstRate,
SmallOrderQty: smallQty,
SmallOrderFee: smallFee,
Unit: unit,
}
if err := h.db.UpdateProduct(product, userID); err != nil {
return c.String(http.StatusInternalServerError, "failed to update product")
}
return c.Redirect(http.StatusSeeOther, "/product")
}
// ProductDeleteHandler handles DELETE /product/:sku
func (h *ProductHandlers) ProductDeleteHandler(c echo.Context) error {
userID := getUserID(c)
sku := c.Param("sku")
if err := h.db.DeleteProduct(sku, userID); err != nil {
return c.String(http.StatusInternalServerError, "failed to delete product")
}
// For HTMX, return empty to remove the row
if c.Request().Header.Get("HX-Request") == "true" {
return c.NoContent(http.StatusOK)
}
return c.Redirect(http.StatusSeeOther, "/product")
}